Compare commits

...
Author SHA1 Message Date
Codeman maintainer 02bbf13b3c chore: version packages 2026-08-30 16:29:14 +02:00
Codeman maintainer da91b4353b Merge pull request #353 from timkjr/omp-mode
feat: add OMP (Oh My Pi) as a new session backend
2026-08-30 16:16:26 +02:00
timkjr b6d0f1fa32 fix(omp): wire OMP into install.sh's CLI detection (it had none)
Every other CLI (claude/opencode/codex/gemini/antigravity/pi/grok/dsh) has
a check_*/get_*_path pair wired into install.sh's detection loop and the
"no AI CLI found" aggregate checks. OMP had neither -- a user with only
omp installed would be told no CLI was found and offered to install
Claude Code or OpenCode.

Added OMP_SEARCH_PATHS (mirrors src/utils/omp-cli-resolver.ts's
OMP_SEARCH_DIRS) and check_omp()/get_omp_path(), wired into both
aggregate conditions (the interactive install-menu trigger and the
end-of-run reminder) and added omp's real vendor curl one-liner to the
reminder block. The DeepSeek Harness line was never in that reminder to
begin with -- confirmed it has no vendor one-liner (dsh installs via
Codeman's own API after the server is already up), so it stays out, with
an explanatory line instead.

Also fixed the "Skip" menu text, which was missing Gemini and DeepSeek
Harness from its example list independent of the omp gap, and the same
stale sibling-CLI-list bug (missing DeepSeek Harness and OMP, "the
eight"/"这七个") in README.md and the repo's existing README.zh-CN.md.
2026-08-28 15:19:19 -05:00
timkjr 65e994d29a fix(omp): correct docs/counts/URLs, resolver install-path order, stray comment + CSS
Small cleanup items from upstream review (Ark0N/Codeman#353):

- OMP_SEARCH_DIRS now leads with ~/.local/bin, matching omp.sh's real
  installer target (~/.omp/bin was an earlier unverified guess, confirmed
  wrong against a real --no-cache Docker build).
- docs/omp-integration.md: fixed the dead GitHub URL (can1357/omp ->
  can1357/oh-my-pi), corrected the CLI count (ninth backend, tenth
  SessionMode incl. shell -- not eighth), matched the install-path guidance
  to the resolver fix, updated the version example to the actually-tested
  18.0.8, and added a Docker-section caveat: --resume pinning does not
  currently reach an in-container omp process, since Docker panes never see
  ompConfig.
- docs/architecture-invariants.md: fixed a heading missing ", OMP" (CLAUDE.md
  already linked to the -omp anchor, so the link was dead) and added an OMP
  specifics paragraph -- the one external CLI missing an entry in this doc.
- .changeset/omp-backend.md: corrected the sibling-CLI list (was missing Pi,
  Grok, and DeepSeek Harness) and the backend count.
- Removed a stray orphaned comment fragment in the quick-start docker branch
  and split two CSS lines that had two declarations jammed onto one line.
2026-08-28 14:37:28 -05:00
timkjr f18dccace1 fix: don't discard codex/gemini/antigravity conversations on Resume; fix DELETE ownership dup + missing broadcast
resumeHistorySession() creates the resumed row in its own mode via a
modeConfigKey map (opencode/pi/grok/omp -> continueSession, deepseek ->
resumeSession) and retires the old row afterward. codex, gemini and
antigravity were missing from that map, so resuming one of their rows
started a brand-new session with NO continuation while still deleting
the row it came from -- silent data loss dressed as the duplicate-row
fix. Gate row retirement on continuesSomething (true only for modes that
actually got a continuation config) instead of wiring an unverified
sessionId->native-conversation-id assumption for the three affected CLIs.

DELETE /api/sessions/:id reimplemented the ownership 404 check inline in
two places instead of going through findSessionOrFail, and its
persisted-only-session branch never broadcast session:deleted, so other
open tabs kept the retired row until their next unrelated fetch. Extract
the shared 404 into sessionNotFoundError(), add findPersistedSessionOrFail()
alongside findSessionOrFail() in route-helpers.ts (same ownership
contract, returns a SessionState instead of a live Session), and use both
from the route instead of inline checks. Add the missing broadcast.
2026-08-28 14:03:07 -05:00
timkjr 2ee2eacb4b fix(omp): clamp OMP_AUTH_BROKER_URL/TOKEN, correct the env-allowlist docs
The docs claimed omp "has no documented vendor-key namespace of its own"
and "the multi-user clamp has nothing to gate" for omp — both false. Per
omp's own docs/environment-variables.md, it reads ~40 provider keys from
env (pi's known 34-key problem in the same shape), and its own knobs are
mostly PI_* (already globally allowlisted): PI_CONFIG_DIR,
PI_CODING_AGENT_DIR, PI_CODING_AGENT_SESSION_DIR, PI_SUBPROCESS_CMD,
PI_SHELL_PREFIX. The first three also move the ~/.omp tree
omp-session-resolver.ts/omp-transcript.ts hardcode, silently degrading
pinning/history — a known gap shared with pi, documented but not fixed
here.

The OMP_* prefix this PR adds brings in OMP_AUTH_BROKER_URL/
OMP_AUTH_BROKER_TOKEN, where omp resolves credentials from — the same
shape DEEPSEEK_BASE_URL is already dropped for in
clampEnvOverridesForOwner(). Add both to OWNER_CLAMPED_ENV_KEYS so a
non-granted owner in multi-user mode can't redirect them, and correct the
false claims in CLAUDE.md, docs/omp-integration.md, and the stale
resolveOmpHome() comment. Also documents omp's default
tools.approvalMode: yolo, which was previously unstated.
2026-08-28 13:45:12 -05:00
timkjr c4f6eb1e5e fix(omp): resolve and pin the respawn session id only at actual respawn time
findLatestOmpSessionId()'s newest-mtime pin ran eagerly inside
_buildRespawnPaneOptions(), which startInteractive() calls unconditionally
on every boot-recovery reattach — before anything checks whether the pane
is actually dead. With two omp tabs in the same case dir, this could pin
an ALIVE pane's session onto whichever sibling's file happened to be
newest on disk, purely as a side effect of building options that might
never lead to a respawn (reported in Ark0N/Codeman#353 review).

Move resolution out of the eager builder into _pinOmpRespawnId(), called
explicitly only where a respawn is actually confirmed: the dead-pane
branch in _setupOrAttachMuxSession() and reattachRemote(). Add
resolveAndClaimOmpSessionId(), which verifies each candidate's own file
header (cwd) rather than trusting the mangled-directory match alone, and
tracks claimed ids in a process-wide registry so two ambiguous resolutions
can't both pick the same sibling's conversation.
2026-08-28 13:18:25 -05:00
timkjrandClaude Sonnet 5 ab83d8ffec fix(omp): a fresh "Run OMP" click no longer silently resumes an old conversation
Found live 2026-08-27 by Tim: clicking Run OMP to start a brand-new session
in a case directory with prior omp history launched --resume <old-id>
instead of a clean `omp` invocation.

Root cause: Session._resolvedOmpRespawnConfig() resolves-and-pins the
newest on-disk omp conversation as a side effect on this._ompConfig. That
is correct when reattaching to an ALREADY-TRACKED mux session (a dead-pane
respawn, or a boot-recovery reattach - the constructor sets _muxSession
from persisted state before startInteractive() ever runs there), but it
ran unconditionally. startInteractive() computes
`respawnPaneOptions: this._buildRespawnPaneOptions()` eagerly in the same
object literal that builds `createSessionOptions.ompConfig: this._ompConfig`,
so for a genuinely brand-new session (no muxSession in its create config,
_muxSession still null) the resolve-and-pin side effect ran and poisoned
this._ompConfig before that field was even read.

Fix: gate the resolve-and-pin logic on `this._muxSession` already being
set. A fresh session has no muxSession yet and now passes through
untouched; a real reattach (muxSession present since construction) keeps
resolving and pinning exactly as before.

Verified live in production against the exact reported scenario (a fresh
omp session in a case dir with 8+ hours of prior omp history) - confirmed
both via the API (ompConfig stays empty, claudeSessionId equals the
session's own id) and visually in the GUI. Regression test constructs a
real Session + TmuxManager to exercise the actual private-method
interaction directly, since no existing test called startInteractive() at
all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:32:30 -05:00
timkjrandClaude Sonnet 5 1829fe91af docs(omp): add docs/omp-integration.md, matching sibling CLI docs
OMP was the one external CLI mode with no dedicated user-guide doc, unlike
opencode/pi/grok/deepseek which each have one. Covers install, auth (omp
owns its own entirely - no Codeman-side login flow or bypass switch),
what Codeman wires up (OmpConfig), the exact-id pinning mechanism and the
directory-mangling bug behind it, kill-survival via transcript scanning,
terminal behavior, Docker/remote-SSH cases, and known gaps (no idle hook,
mid-turn kill data loss, unverified symlinked-$HOME behavior).

Cross-referenced from README.md's Multi-CLI doc list and docs/docker-cases.md's
credential-seeding summary (which now also documents OMP's sessions/-is-shared
exception to the seed-everything pattern the other CLIs use).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:32:30 -05:00
timkjrandClaude Sonnet 5 d74cde759b feat(omp): install omp in the docker agent image, isolate its credentials
OMP had full routing at the Docker layer (default pane command, schema) but
was never actually installed in docker/agent.Dockerfile, and had no
credential-isolation entry in docker-hosts.ts's CRED_STORES - a Docker-mode
OMP session would have failed with "omp: command not found", and even with
the binary present would have had no config/auth seeded, despite the README
already claiming OMP has "seamless auth, isolated credentials" in Docker.

- docker/agent.Dockerfile: install omp via its own installer (standalone
  binary, same shape as grok/antigravity - not on npm). Verified against a
  real --no-cache build: the installer actually targets ~/.local/bin, not
  ~/.omp/bin as the resolver's OMP_SEARCH_DIRS ordering would suggest -
  confirmed omp/18.0.8 installs and runs correctly inside the image.
- src/docker-hosts.ts: add a .omp/agent CRED_STORES entry. Unlike every
  sibling CLI in this family, sessions/ is SHARED (RW), not seeded: Codeman
  reads ~/.omp/agent/sessions/**/*.jsonl host-side for history recovery and
  --resume pinning (omp-transcript.ts, omp-session-resolver.ts), the same
  reason codex's sessions/ is shared rather than seeded. Seeding it instead
  would silently break the kill-survival feature for Docker cases. Only the
  small config files (config.yml/mcp.json/models.yml/settings.yml) are
  seeded; the SQLite caches and terminal-sessions/ stay container-local.
- test/docker-hosts.test.ts: pin the new CRED_STORES entry's behavior.

Found in passing (NOT fixed here, unrelated and pre-existing on master): the
agent image's DeepSeek (dsh) plugin-install step currently fails on a fresh
build ("pnpm not found on PATH"), confirmed via git diff against
origin/master that this line is untouched by this branch. Worth a separate
issue/PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:32:30 -05:00
timkjrandClaude Sonnet 5 853681f970 harden(omp): resume-path test coverage, silent-fallback logging, cwd validation
Follow-up from a full-branch review pass (Opus) of the omp-mode integration:

- Add pinning tests for resolveOmpConfigForCreate() (session-routes.ts),
  exported to make it testable: the exact "resume this OMP row from
  history" pipeline that mangleOmpWorkingDir's earlier bug lived in had
  zero coverage despite being the resolver module's whole reason to exist.
- Log a warning when findLatestOmpSessionId() finds nothing on disk and
  continuation silently degrades to omp's own ambiguous --continue,
  in both call sites (session create and respawn pinning) - previously
  silent, making the degradation invisible to anyone debugging it.
- Require an absolute cwd before trusting a session file's working
  directory in omp-transcript.ts's parser, so a corrupted/malformed
  session file can't point a downstream resume at a relative or empty
  path.
- Document (don't speculatively fix) an unverified symlinked-$HOME edge
  case in mangleOmpWorkingDir(): the review's suggested realpath() fix
  assumes omp itself resolves symlinks before mangling, which is
  unconfirmed - guessing wrong there would trade one silent mismatch
  for a different one.
- Incidental: fixed unrelated pre-existing prettier drift in
  session-routes.ts (antigravity/opencode dynamic import line-wrapping)
  that was blocking the pre-commit formatting gate on this file.

Confirmed as a non-issue: the model-name regex allowing "/" is
intentional (provider/model ids like "crof/glm-5.2" were used
successfully in live testing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:32:30 -05:00
timkjrandClaude Sonnet 5 ed983f898b fix(omp): resolve claudeSessionId alias on boot-recovery reattach
Two bugs compounded to break continuation pinning on every real OMP
case (only /tmp-based manual testing happened to work by coincidence):

1. startInteractive() had a second, unconditional claudeSessionId
   assignment after the mux branch that clobbered its correctly
   resolved value back to the session's own id on every mux path.

2. mangleOmpWorkingDir() assumed omp mirrors Claude Code's directory
   naming (home prefix kept), but omp actually strips $HOME first.
   findLatestOmpSessionId() was silently returning null for every
   case under ~/codeman-cases/, so resumeSessionId never resolved for
   any real case dir - only /tmp paths (outside $HOME) worked, which
   is every dir this feature was previously tested against.

Verified live: killed and relaunched the omp-verify server process
mid-session (plain reattach, pane stayed alive) and confirmed
claudeSessionId now resolves to the real omp transcript uuid instead
of the Codeman session's own id.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 11:32:30 -05:00
timkjr 54a930c80e feat(omp): survive a full session kill by reading omp's own transcripts
Claude conversations survive "Kill Tmux & Claude" because Codeman reads
them back independently from ~/.claude/projects, not from its own
session bookkeeping. omp conversations had no equivalent: kill the
Codeman session and the conversation vanished from Past Sessions
entirely, even though omp itself never forgot it on disk.

Adds omp-transcript.ts, a scanner over omp's own
~/.omp/agent/sessions/<mangled-cwd>/<uuid>.jsonl files (the same shape
as Claude Code's own transcript scanner, but simpler -- these files are
small enough to read whole instead of doing head/tail windows). Each
file's own "session" header line carries the real cwd and session id
directly, so unlike Claude's mangled-directory-name decoding this
never has to guess. Wired into gatherUnifiedInputs() as a second
history source alongside the Claude scan, and HistoryInput/
mergeUnifiedSessions() now carry an optional `mode` so a non-claude
history-only row still gets a real mode badge.

Also fixes the ambiguity behind the "continue picks the wrong
conversation" report from this session's testing: omp mints its OWN
session uuid, unrelated to Codeman's, so a live/persisted row and its
own history-scan row would otherwise show up as two separate entries
for the same conversation the moment the id gets resolved. Reuses the
existing claudeSessionId alias field (mergeUnifiedSessions' fold-into-
owner mechanism) to point at the resolved omp id, threading it through
every place `_claudeSessionId` gets (re)computed -- the constructor,
_resolvedOmpRespawnConfig, and a new _maybeCaptureOmpSessionId() that
opportunistically resolves it the first time a brand-new omp session
(one that has never gone through a respawn) goes idle.

Also closes a THIRD instance of the "ompConfig never got wired in
here" gap this session kept finding: restoreMuxSessions() in server.ts
restores every sibling CLI's config from persisted state on boot except
omp's, so a boot-recovered omp session always lost its resolved resume
id and fell back to guessing again.

Verified live end-to-end: told a session a secret, killed it fully
(Kill Tmux equivalent, killMux=true -- the Codeman session AND its tmux
pane both gone), and the conversation still showed up in the unified
list as a history-sourced row with the real first prompt as its title
and an omp mode badge, keyed by omp's own session id.

Known remaining gap, not fixed here: the claudeSessionId alias doesn't
yet resolve reliably on every boot-recovery path for a session that
was never respawned while alive (e.g. a plain re-attach to a pane that
was never dead) -- worth a follow-up, but doesn't affect the two things
that matter most: the conversation surviving a kill, and continuation
correctness once an id has been resolved (which happens on the very
next respawn either way).
2026-08-28 11:32:30 -05:00
timkjr 4c332c6141 fix(omp): retire the old row on resume, and let DELETE remove persisted-only sessions
Every non-claude "Resume" click creates a brand-new Codeman session
(there is no id to reattach to), but the old row was never cleaned up
-- click resume on the same conversation a few times and the session
list fills up with duplicate rows sharing one name. resumeHistorySession
now retires the row it resumed from after the new one starts.

That retirement needs DELETE to actually work on a row that was never
live in the first place (the normal case for anything showing up in
"Resume Conversation"): findSessionOrFail only checks the in-memory
live-session map, so DELETE 404s on a persisted-only entry today. Give
the route a fallback: when the id isn't live, look it up in persisted
state instead and demote/remove it there (respecting the existing
pinned-session protection). Verified live against a real persisted-only
row via the API, and added route-test coverage for both the success
and still-truly-unknown-id cases (which needed a demoteOrRemoveSession
mock the route harness didn't have).

Also includes an unrelated pre-existing prettier drift fix picked up
by npm run format (omp-cli-resolver.ts, antigravity/opencode import
wrapping in session-routes.ts).
2026-08-28 11:32:30 -05:00
timkjr 253599ce9c fix(omp): wire ompConfig into respawnPane and default to --continue there
respawnPane() -- the path used when a session's pane died (crash, idle
respawn, or the user's own /exit) but the Codeman session object is
still tracked -- never had ompConfig wired through at all, in either
its options destructure or its inner buildSpawnCommand() call. This is
a gap in the original OMP patch, distinct from the resumeHistorySession
fix (which only covers a session that has been fully closed and shows
up as a history row): reselecting a tab whose CLI process just exited
goes through this path instead, and always launched a bare, contextless
`omp` no matter what.

Beyond the wiring, respawning a dead pane is semantically different
from creating a brand-new session: the conversation is still "this
session" to the user, so _buildRespawnPaneOptions() now defaults
ompConfig to continueSession:true unless the session already carries
an explicit resumeSessionId (which still wins in buildOmpCommand).

Verified live: told a session a secret, exited OMP so the pane died
(session and tmux both left alone), forced the exact dead-pane-respawn
path, and the new process replied with the secret -- confirming
`omp --continue` fired instead of a blank omp.
2026-08-28 11:32:30 -05:00
timkjr 3e1a0e679f fix(omp): resume by mode, not silently as claude, and support --continue
resumeHistorySession() never sent mode when recreating a session from a
history/session-manager row, so the server default silently opened a
plain Claude session for every non-claude row -- reproduced live: OMP
rows spawned Claude sessions on click. Thread the row's mode through
every call site (welcome list, session manager, mobile overview) and
only send the Claude-specific resumeSessionId for claude rows.

Codeman has no live PTY-reattach outside server boot, and it's moot for
OMP anyway (exiting it kills the pane's only process), so route the
non-claude relaunch through each CLI's own continue-most-recent flag
instead of a context-free fresh start. OMP never got one: buildOmpCommand
only implemented --model/--resume despite omp --help documenting
-c/--continue. Added continueSession to OmpConfig end-to-end (type,
schema, builder) mirroring the existing opencode/pi/grok/deepseek
fields, and wired resumeHistorySession to use it.

Verified live: told a real omp session a secret, exited it, closed the
tab without killing tmux, relaunched with --continue in the same
directory, and had it recall the secret.
2026-08-28 11:32:30 -05:00
timkjr 7ec48adcc8 fix(omp): keep external-CLI mode enumerations complete in skill docs
Two prose lists in skills/codeman/ named some but not all external CLI
modes after the omp-mode rebase, which is exactly the drift
test/agent-skill-mode-lists.test.ts exists to catch: SKILL.md's
no-hook-signals list was missing omp, and endpoints.md's version-probe
sentence named pi/grok/omp as a bare 3-mode run with no matching class.
2026-08-28 11:32:30 -05:00
timkjr 9841f4ffb9 refactor(omp): align omp resolver + doctor with upstream shared CLI resolver
- omp-cli-resolver.ts already uses createCliExecutableResolver; add dedicated
  test/omp-cli-resolver.test.ts mirroring pi's (version-probe accept/reject,
  negative-cache backoff, VITEST hermeticity gate)
- dependency-registry omp entry now requires OMP_VERSION_REGEX match like pi,
  so codeman doctor and the run-mode resolver agree on what counts as installed
- system-routes /api/omp/status surfaces version
2026-08-28 11:32:30 -05:00
Codeman maintainer d8688dc143 fix(web): drop the provider label from the plan-usage chip when there is only one
The chip prefixes every row with the provider name, so a machine that only
has Claude limits renders "CLAUDE 5H 60% 7D 23%" — a 46px label naming the
only thing it could possibly be. The name exists to tell two rows apart, so
it should only appear when there are two.

updatePlanUsageChip() now checks whether both Claude and Codex actually have
windows before building the rows, and emits the .pu-provider span only in
that case. The tooltip keeps naming the provider in both cases: it has the
room, and the chip no longer does.

Verified in a browser on an isolated beta instance: Claude-only renders bare
windows with no .pu-provider in the DOM, Codex-only the same, and the
two-provider chip is byte-identical to before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 13:59:40 +02:00
timkjr e82380e14a fix(ui): close unclosed CSS blocks that killed the stylesheet tail
The rebase hand-repair dropped the closing brace of .welcome-btn-pi:hover
and .btn-toolbar.btn-run.mode-pi:hover before the inserted OMP rules.
The browser CSS parser drops every rule after an unclosed block, so the
deployed UI rendered as unstyled text bars (only ~456 of ~2583 rules
applied). Verified clean via esbuild --minify (no css-syntax-error) and
rebuilt dist.
2026-08-26 20:10:34 -05:00
timkjr c0423bf560 fix(omp): complete omp wiring in UI files, skill docs, and tests after rebase 2026-08-26 20:10:34 -05:00
timkjr 4f5678fac4 feat(omp): rebase OMP backend onto master (merge Pi + OMP modes) 2026-08-26 20:05:48 -05:00
57 changed files with 2298 additions and 113 deletions
+3
View File
@@ -2,6 +2,9 @@
.agents/
skills-lock.json
# In-session decision scratchpad (context-survival mechanism, not a deliverable)
DECISIONS.md
# Written by install.sh into end-user clones when setup finishes
.install-complete
+15
View File
@@ -1,5 +1,20 @@
# aicodeman
## 1.24.0
### Minor Changes
- OMP (Oh My Pi) as a tenth run mode, mode-faithful Resume for external CLIs, and a cleaner plan-usage chip.
**OMP (`omp`) run mode** (#353): Oh My Pi joins Claude Code, shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build and DeepSeek Harness as a run mode, in local, Docker and remote-SSH sessions: toolbar dropdown, welcome button, phone overview, command palette, clone-repo brain picker, cron agent types, tab badges and per-mode colours, plus `GET /api/omp/status`, a `codeman doctor` entry, install.sh detection and the docker agent image. The resolver leads with `~/.local/bin` (the upstream installer's real target) and demands `omp/<semver>` from `--version`, so an unrelated binary with the same three-letter name is never spawned. Past omp conversations appear in Past Sessions, read from omp's own session files (the header line carries the real working directory, so nothing has to reverse-engineer omp's directory mangling), and a respawned or resumed omp session is pinned to an exact conversation with `--resume <id>` instead of omp's newest-file `--continue`. Review hardening before merge: the pin is resolved only at the moment a respawn is actually confirmed (an eager resolve on boot recovery used to alias two omp tabs in one case directory onto one conversation), candidates are verified against their own header `cwd` and claimed process-wide so siblings cannot double-pin; `OMP_*` joins the env-override allowlist and `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are clamped for non-granted owners in multi-user mode, the same shape as `DEEPSEEK_BASE_URL`. Known and documented: omp's own knobs are mostly `PI_*` (it is a pi fork), its default `tools.approvalMode` is `yolo`, and in-container `--resume` pinning does not reach a Docker omp pane.
**Resume keeps the row's own CLI** (#353): clicking Resume on an OpenCode, Pi, Grok, DeepSeek or OMP row used to create a plain Claude session, since the create request never carried the row's mode. Resume now relaunches in the row's own mode with that CLI's continue flag, and retires the stale row it came from so three clicks no longer leave three copies of the same name. Codex, Gemini and Antigravity rows have no continuation wired yet, so their rows are deliberately left in place. `DELETE /api/sessions/:id` accepts a persisted-only session (ownership enforced through the same helper as live lookups, 404 rather than 403 so nothing leaks) and broadcasts `session_deleted` so other tabs drop the row too.
**Plan-usage chip drops the provider label when there is only one**: a machine with only Claude limits rendered `CLAUDE 5H 60% 7D 23%`, a 46px label naming the only thing it could be. The name exists to tell two rows apart, so it now appears only when both Claude and Codex have windows; the tooltip still names the provider either way.
### Thanks
- @timkjr for #353, and for turning every review finding around within a day
## 1.23.2
### Patch Changes
+4 -5
View File
File diff suppressed because one or more lines are too long
+10 -10
View File
@@ -5,7 +5,7 @@
<h2 align="center">Mission control for AI coding agents</h2>
<p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; Terminal - One Dashboard &bull; Any Device</em>
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; OMP &bull; Terminal - One Dashboard &bull; Any Device</em>
</p>
<p align="center">
@@ -27,7 +27,7 @@
<img src="docs/images/subagent-demo-20260724.gif" alt="Codeman — parallel subagent visualization" width="900">
</p>
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
Get started in one line (macOS & Linux, Windows via WSL):
@@ -42,7 +42,7 @@ codeman web
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
- **One dashboard, seven CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, or Grok](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions)
- **One dashboard, eight CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or OMP](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions)
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
@@ -68,7 +68,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh update` and `install.sh uninstall` also exist.
- **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently. Set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), or [Grok Build](https://github.com/xai-org/grok-build) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the seven is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install:
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install Claude Code or OpenCode, or you can skip and install one yourself later. After install:
```bash
codeman web
@@ -171,7 +171,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
wsl bash -c "curl -fsSL https://getcodeman.com/install | bash"
```
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), or [Grok Build](https://github.com/xai-org/grok-build)). After installing, `http://localhost:3000` is accessible from your Windows browser.
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi)). After installing, `http://localhost:3000` is accessible from your Windows browser.
</details>
@@ -253,7 +253,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
| Field | What it does |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, or `Terminal` (plain shell). |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `OMP`, or `Terminal` (plain shell). |
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
@@ -437,7 +437,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, or **Grok** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md) and [`docs/grok-integration.md`](docs/grok-integration.md)
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
@@ -460,7 +460,7 @@ Run a case inside its own hardened Docker container instead of directly on your
- **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others.
- **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket; a **sealed** profile (no host credentials, network off) is one toggle away.
- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / Pi logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.
- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / OMP logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
- **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript.
Prerequisite: just Docker (or Podman). The agent base image builds itself automatically on first use, with progress streamed to the UI (or pre-build it with `node scripts/build-agent-image.mjs`). Full guide: [`docs/docker-cases.md`](docs/docker-cases.md).
@@ -795,7 +795,7 @@ When a CLI runs in a Codeman-managed session, these environment variables are se
5. **`/api/v1/*`** is a stable alias of `/api/*`.
6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling).
7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/pi) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/omp) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
### Recipes
@@ -1011,7 +1011,7 @@ flowchart TB
subgraph External["External"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi</small>"]
BG["Background Agents<br/><small>(Task tool)</small>"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / OMP</small>"] BG["Background Agents<br/><small>(Task tool)</small>"]
end
end
+2 -2
View File
@@ -58,7 +58,7 @@ curl -fsSL https://getcodeman.com/install | bash
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev) 或 [Grok Build](https://github.com/xai-org/grok-build)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这七个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后:
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后:
```bash
codeman web
@@ -141,7 +141,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
wsl bash -c "curl -fsSL https://getcodeman.com/install | bash"
```
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev) 或 [Grok Build](https://github.com/xai-org/grok-build))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
</details>
+21 -1
View File
@@ -80,6 +80,22 @@ RUN npm install -g @deepseek-ai/dsh \
&& npm cache clean --force \
&& dsh --version
# OMP (Oh My Pi) is NOT on npm: a standalone binary via omp.sh's installer, which
# targets $HOME/.local/bin with no --dir override (verified 2026-08-27 — the
# resolver's OMP_SEARCH_DIRS lists ~/.omp/bin first, which turned out to be the
# WRONG guess for the installer's actual target; build this step for real
# rather than trust that ordering). At build time $HOME is root's home and
# unreachable by the `agent` user, so copy the binary into /usr/local/bin and
# drop root's ~/.local/bin/omp in the same layer so the image does not carry
# the download twice.
RUN curl -fsSL https://omp.sh/install | sh \
&& cp -L /root/.local/bin/omp /usr/local/bin/omp.real \
&& rm -f /usr/local/bin/omp \
&& mv /usr/local/bin/omp.real /usr/local/bin/omp \
&& chmod 755 /usr/local/bin/omp \
&& rm -f /root/.local/bin/omp \
&& omp --version
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
@@ -106,10 +122,14 @@ ENV HOME=/home/agent
# writable by the arbitrary uid the container actually runs as, and a profile
# installed after it would miss that fixup. DSH_HOME points the launcher at the
# agent's dir while this still runs as root.
# `.omp/agent` is pre-created for the same reason `.codex` is: it is a MIXED
# store (per-file config seeds PLUS a shared `sessions/` RW bind mount for
# Codeman's own host-side history/resume reads), and neither kind of artifact
# creates its own parent directory.
RUN useradd -g 0 -m -d /home/agent -s /bin/bash agent \
&& mkdir -p /home/agent/.npm /home/agent/.cache /home/agent/.config /home/agent/.codeman \
/home/agent/.claude/projects /home/agent/.codex/sessions /home/agent/.pi/agent /home/agent/.grok \
/home/agent/.dsh \
/home/agent/.dsh /home/agent/.omp/agent \
&& DSH_HOME=/home/agent/.dsh HOME=/home/agent \
dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui \
&& test -f /home/agent/.dsh/profiles/dsh-tui/package.json \
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -30,7 +30,7 @@ docker run --rm codeman/agent:base bash -lc \
Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (Google and xAI ship standalone binaries), so each has its own Dockerfile step, adding roughly 190MB and 160MB respectively. Pi also gets its own step, because upstream documents installing it with `--ignore-scripts` and that flag must not silently change how the other npm CLIs install.
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md).
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md).
## Quickest path: one-click "Run in Docker"
+171
View File
@@ -0,0 +1,171 @@
# OMP (Oh My Pi) sessions
Codeman can drive [OMP](https://github.com/can1357/oh-my-pi) (`omp`, Oh My Pi) as a session
backend, alongside Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok and
DeepSeek Harness. `omp` is the ninth CLI backend (tenth `SessionMode`, counting
`shell`): its own PTY, its own tmux session, its own tab identity. It is not a
location overlay like Docker or remote-SSH cases, and it is not a web tab.
## Install
```bash
curl -fsSL https://omp.sh/install | sh
```
The installer places the binary in `~/.local/bin` (verified against a real
`--no-cache` Docker build — see `docker/agent.Dockerfile`; an earlier guess of
`~/.omp/bin` was wrong). Codeman resolves the binary via the server PATH and then
the usual install locations (`~/.local/bin` first, then `~/.omp/bin`,
`/usr/local/bin`, `~/.bun/bin`, `~/.npm-global/bin`, `~/bin`).
**`omp` is a short name**, so like `pi` and `grok` the resolver does not trust a PATH
hit on its own: it runs `omp --version` and requires `omp/<semver>`-shaped output
(e.g. `omp/18.0.8`) before accepting a candidate. Check what it resolved:
```bash
curl -s localhost:3000/api/omp/status | jq
# { "available": true, "path": "/home/you/.local/bin", "version": "18.0.8" }
```
## Authenticate
OMP owns its own auth and provider configuration entirely in `~/.omp` — there is
no Codeman-side login flow, API key field, or bypass switch to configure. Run `omp`
directly once outside Codeman to complete whatever onboarding the CLI itself asks
for; every session started through Codeman afterward inherits that config.
## What Codeman wires up
`OmpConfig` (per session, persisted in `state.json`, round-trips through respawn):
| Field | Flag | Notes |
| ------------------ | --------------- | ---------------------------------------------------------- |
| `model` | `--model <v>` | Regex-validated (`[a-zA-Z0-9._-/]+`); `provider/model` forms like `crof/glm-5.2` pass |
| `continueSession` | `--continue` | omp's own "most recent conversation in this directory" heuristic |
| `resumeSessionId` | `--resume <id>` | Ids only, id-regexed; wins over `--continue` when both are present |
Every value is regex-validated and **dropped** (not escaped) if it fails, because the
result is interpolated into the pane's spawn command.
**omp reads its own model routing and hooks from `~/.omp`, so no trust or
permission flags are needed** — unlike every sibling CLI in this family, there is no
bypass-permissions equivalent to wire up, so `buildOmpCommand()` only ever passes
`--model`/`--resume`/`--continue`. ⚠️ That does NOT mean omp is unrestricted: its
documented default `tools.approvalMode` is `yolo`, so an omp pane auto-approves exec
with no flag from Codeman — the CLI's own config, not Codeman, is what would need to
change that.
Env overrides: the `OMP_*` prefix is allowlisted, and per omp's own
`docs/environment-variables.md` it is not the narrow surface it looks like. omp reads
roughly 40 provider keys from the environment (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
`XAI_API_KEY`, `HF_TOKEN`, ...) — pi's 34-key problem in the same shape — which is why
none of those get a dedicated allowlist entry; a session authenticates from `~/.omp`
config or the server process's own env instead, like pi. omp's own documented knobs
are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`,
`PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`,
`OMP_PROFILE`/`PI_PROFILE`), and `PI_*` is already allowlisted globally because pi
mode needs it — so an omp session today already accepts all of those. The first three
also move the tree `omp-session-resolver.ts` and `omp-transcript.ts` hardcode
(`resolveOmpHome()` assumes `~/.omp` unconditionally), so pinning and history quietly
stop working under a redirected config root; this is a known gap, not fixed here.
The `OMP_` prefix itself brings in `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`,
where omp resolves credentials from — the same shape `DEEPSEEK_BASE_URL` is dropped
for in `clampEnvOverridesForOwner()` (session-routes.ts), so both are clamped there
for a non-granted owner in multi-user mode. None of this matters in single-user mode.
## Exact-id pinning: why `--resume`, not just `--continue`
`--continue` alone is ambiguous the moment **any** other omp conversation has
touched the same working directory more recently — it just picks the newest session
file on disk, silently. That happens routinely: a closed-then-resumed Codeman row
plus a still-running duplicate, two Codeman sessions pointed at the same case, or a
plain reattach after a server restart.
`src/utils/omp-session-resolver.ts` resolves and **pins** the exact conversation id
once (`findLatestOmpSessionId()` reads `~/.omp/agent/sessions/<mangled-workingDir>/`,
the newest `.jsonl` file's embedded uuid), then every later respawn reuses that
pinned id via `--resume` instead of re-guessing with `--continue`.
⚠️ **The directory mangling is NOT a straight `/` → `-` replace.** Unlike Claude
Code's `~/.claude/projects/*` convention (which keeps the full path, e.g.
`-home-user-codeman-cases-foo`), omp strips the `$HOME` prefix FIRST and only then
dash-replaces (`/home/user/codeman-cases/foo` → `-codeman-cases-foo`; a path outside
`$HOME`, like `/tmp/...`, is dash-replaced as-is with no stripping). Getting this
wrong doesn't error — `findLatestOmpSessionId()` just silently returns null for
every case under `$HOME` (virtually all real Codeman cases), so pinning quietly
degrades to omp's own ambiguous `--continue`. This was found and fixed 2026-08-27
after months of testing had only ever exercised `/tmp`-based working directories,
where the bug's wrong output happened to coincidentally match the right one.
## Surviving a full session kill
`src/omp-transcript.ts` scans `~/.omp/agent/sessions/**/*.jsonl` directly — a second,
independent history source alongside Codeman's own state. This means an OMP
conversation's history (working directory, first/last prompt, size) is recoverable
in the Past Sessions list even when **both** the Codeman session record and the
underlying tmux pane are gone — verified live against a full OS reboot, not just a
"Kill Tmux" button click.
## Terminal behavior
OMP renders inside tmux like every external CLI (narrow scrollback strip — alt-screen
toggles only, not the full Claude/Codex/Gemini strip). It stays out of the
alt-screen-strip list and lands on the `'buffer'` local-echo policy via the
`_updateLocalEchoState` fallthrough, same as grok and pi.
## Docker cases
The agent image installs omp in its own Dockerfile step (not npm; omp's installer
targets `$HOME/.local/bin` with no `--dir` override, the same shape as grok's
installer). Rebuild with the mandatory `--no-cache`:
```bash
node scripts/build-agent-image.mjs --no-cache
```
⚠️ **`--resume` pinning does not currently reach an in-container omp process.**
Docker panes are built from `defaultDockerCommandForMode`, which never sees
`ompConfig` — `appendResumeFlag()`'s `case 'omp'` keys off the top-level
`resumeSessionId` field, which nothing populates for omp today. Host-side history
recovery still works (the shared `sessions/` mount below), but a respawned
in-container omp pane falls back to its own ambiguous `--continue`, not a pinned
id. Flagged in upstream review, not yet fixed.
Credentials are **mostly seeded**, but `sessions/` is the one exception in this CLI
family: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded
(read-only mount, copied into the container's own `~/.omp/agent` once), so an
in-container omp never writes refreshed config back to the host and `docker commit`
exports stay secret-free. But `~/.omp/agent/sessions/` is **shared (RW)**, not
seeded — the same treatment as codex's `sessions/`, and for the identical reason:
Codeman reads it host-side (`omp-transcript.ts`, `omp-session-resolver.ts`) for
history recovery and `--resume` pinning. Seeding it instead of sharing it would make
an in-container OMP conversation invisible to Codeman's own history/resume logic,
silently breaking Docker support for the kill-survival feature above. The rest of
`~/.omp/agent` (`agent.db`/`history.db`/`models.db` SQLite caches,
`terminal-sessions/`, `blobs/`, `cache/`) stays container-local and is neither
shared nor seeded.
## Remote SSH cases
`omp` mode is routed through an interactive login shell
(`exec "$SHELL" -i -l -c 'omp'`), because sshd's remote-command PATH does not
include `~/.local/bin`. Per-session config and `envOverrides` do not cross ssh and are
rejected rather than silently ignored; use the per-host command override instead.
## Known gaps
- **No idle/completion hook.** Idle detection falls back to output-stabilization
like every other external CLI. If omp ever ships a hooks system, a Codeman hook
POSTing to `/api/hook-event` would be the highest-value follow-up.
- **Killing a pane mid-turn loses the conversation for real.** `tmux kill-session`
before an in-TUI `/exit` beats omp's own session-file flush — confirmed by direct
testing (kill after a clean `/exit` resumes correctly; kill without `/exit` first
does not). This is not something Codeman can compensate for from outside the
process; it would need an upstream omp fix (e.g. flush-on-SIGTERM).
- **Unverified: `$HOME` as a symlink.** The directory-mangling fix above compares
against the literal `homedir()` string, not a `realpath()`-resolved one. Whether
omp itself canonicalizes symlinks before mangling is unconfirmed — this has not
been tested against a symlinked-home setup.
- Ralph, respawn heuristics, token/CLI-info parsing and the `❯` readiness probe are
off for omp, as for every external CLI.
+55 -4
View File
@@ -149,6 +149,17 @@ ANTIGRAVITY_SEARCH_PATHS=(
"$HOME/bin/agy"
)
# OMP CLI search paths (from src/utils/omp-cli-resolver.ts's OMP_SEARCH_DIRS —
# ~/.local/bin leads, omp.sh's installer target; ~/.omp/bin is a fallback only)
OMP_SEARCH_PATHS=(
"$HOME/.local/bin/omp"
"$HOME/.omp/bin/omp"
"/usr/local/bin/omp"
"$HOME/.bun/bin/omp"
"$HOME/.npm-global/bin/omp"
"$HOME/bin/omp"
)
# ============================================================================
# Color Output
# ============================================================================
@@ -692,6 +703,37 @@ get_grok_path() {
done
}
# `omp` is a short name too, so like grok/pi the server-side resolver
# additionally probes `omp --version`. Detection here only feeds the
# "you have no AI CLI" hint, so a plain executable test is enough.
check_omp() {
if command -v omp &>/dev/null; then
return 0
fi
for path in "${OMP_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
return 0
fi
done
return 1
}
get_omp_path() {
if command -v omp &>/dev/null; then
command -v omp
return
fi
for path in "${OMP_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]]; then
echo "$path"
return
fi
done
}
check_cloudflared() {
# Check ~/.local/bin first (matches tunnel-manager.ts resolution order)
if [[ -x "$HOME/.local/bin/cloudflared" ]]; then
@@ -2335,6 +2377,7 @@ main() {
local has_pi=false
local has_grok=false
local has_dsh=false
local has_omp=false
info "Checking AI CLI tools..."
if check_claude; then
@@ -2369,17 +2412,21 @@ main() {
has_dsh=true
success "DeepSeek Harness found at $(get_dsh_path)"
fi
if check_omp; then
has_omp=true
success "OMP CLI found at $(get_omp_path)"
fi
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" && "$has_dsh" == "false" ]]; then
if [[ "$has_claude" == "false" && "$has_opencode" == "false" && "$has_codex" == "false" && "$has_gemini" == "false" && "$has_antigravity" == "false" && "$has_pi" == "false" && "$has_grok" == "false" && "$has_dsh" == "false" && "$has_omp" == "false" ]]; then
echo ""
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, or DeepSeek Harness."
warn "No AI CLI found. Codeman needs at least one: Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP."
headless_guard "install an AI CLI (curl | bash from its vendor)"
echo ""
echo -e " ${BOLD}Which AI CLI would you like to install?${NC}"
echo -e " ${CYAN}1)${NC} Claude Code (Anthropic)"
echo -e " ${CYAN}2)${NC} OpenCode (open-source)"
echo -e " ${CYAN}3)${NC} Both"
echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity, Pi or Grok)"
echo -e " ${CYAN}4)${NC} Skip (I'll install one myself, e.g. Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness or OMP)"
echo ""
local cli_choice=""
@@ -2728,7 +2775,7 @@ main() {
echo -e " https://github.com/Ark0N/Codeman"
echo ""
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok && ! check_dsh; then
if ! check_claude && ! check_opencode && ! check_codex && ! check_gemini && ! check_antigravity && ! check_pi && ! check_grok && ! check_dsh && ! check_omp; then
echo -e " ${YELLOW}${BOLD}Reminder:${NC} Install at least one AI CLI to start using Codeman:"
echo -e " ${CYAN}curl -fsSL https://claude.ai/install.sh | bash${NC} # Claude Code"
echo -e " ${CYAN}curl -fsSL https://opencode.ai/install | bash${NC} # OpenCode"
@@ -2736,7 +2783,11 @@ main() {
echo -e " ${CYAN}curl -fsSL https://antigravity.google/cli/install.sh | bash${NC} # Antigravity"
echo -e " ${CYAN}npm install -g --ignore-scripts @earendil-works/pi-coding-agent${NC} # Pi"
echo -e " ${CYAN}curl -fsSL https://x.ai/cli/install.sh | bash${NC} # Grok"
echo -e " ${CYAN}curl -fsSL https://omp.sh/install | sh${NC} # OMP"
echo ""
echo -e " DeepSeek Harness has no vendor one-liner — install it from within Codeman"
echo -e " once the server is up (Run dropdown → Install DeepSeek Profile, or see"
echo -e " docs/deepseek-integration.md)."
fi
# Security notice — last informational block so it stays visible (when not
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.23.2",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.23.2",
"version": "1.24.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.23.2",
"version": "1.24.0",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+1 -1
View File
@@ -422,7 +422,7 @@ Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor co
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
instead of guessed-from-silence ones — and it writes a structured transcript, which is
what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`,
`pi` and `grok` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
`pi`, `grok` and `omp` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
Three things to know before you spawn one:
+12 -12
View File
@@ -237,7 +237,7 @@ minutes, never retry the credential.
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
returns is too early (verified live: empty on the first call, full prose seconds later).
It is also `""` before the worker's first completed turn, and permanently `""` for
`shell`, `opencode`, `gemini`, `antigravity`, `pi` and `grok`, which write no transcript at
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `omp`, which write no transcript at
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
for the same reason claude does (the harness finalizes the assistant message just after
it reports `idle`), so poll it the same way.
@@ -339,20 +339,20 @@ ESC=$(printf '\033')
`POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"w9-worker","effort":"high"}`
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek`; response is
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
on the user's disk) if missing, do not retry it in a loop, and remember the name.
⚠️ A `mode` whose CLI is **not installed on the server** fails the spawn with
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`
and `GET /api/v1/pi/status` each return `.data.{available, path}` (no session needed).
Pi's and grok's also carry `.data.version`, because `pi` is a short generic name and
`grok` is a name with npm squatters, so an unrelated binary on `$PATH` can shadow either:
the resolver rejects one whose `--version` is not version-shaped, so `available:false`
there can mean "a different `pi`/`grok` is in front" rather than "nothing is installed".
`shell` has no CLI to probe.
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
`GET /api/v1/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed).
Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name,
`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
not version-shaped, so `available:false` there can mean "a different program of the same
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
⚠️ **Branch on `.success` before reading `.data.sessionId`.** On any failure the field
is absent, `jq -r` prints the literal string `null`, and every later call then targets
@@ -466,9 +466,9 @@ Quirks that will bite you:
session answers with an empty timeline rather than a 404.
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
returns early for every external CLI mode (`session.ts:2261`), so it is permanently
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:174-183`, lists only those six), so the parser does
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
a shell worker running `cat build.log` really does populate this. In practice it stays
+2 -2
View File
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
| liveness / death check | HTTP `wait?until=exit` |
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`) | HTTP only (no other CLI has messaging) |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`) | HTTP only (no other CLI has messaging) |
| delete | HTTP, via SKILL.md's `delete_session` guard |
## Availability: probe, never assume
@@ -347,7 +347,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
### Mixed fleets: the pairing matrix
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`) cannot be peers
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`) cannot be peers
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
messaging in their briefs. The claude half of the fleet can use messaging among itself,
subject to the namespace rule: **messaging works between two sessions that share one
+1 -1
View File
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# (.data is {text,timestamp}; text is also "" before the first completed turn and
# always "" for shell/opencode/gemini/antigravity/pi/grok, which have no transcript, use
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
+3 -3
View File
@@ -357,7 +357,7 @@ recovered by submitting it with `{"input":"\r"}`.
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
real end-of-turn signal rather than a guess. On
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`, requesting them explicitly is a
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a
400, and lifecycle transitions there are coarse (a short shell command may emit **no**
`idle` transition at all, verified live), so synchronize those with markers.
@@ -399,7 +399,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
single read taken the instant send-and-wait returns comes back `""` even though the
turn finished (verified live: empty on the first call, full text seconds later). `text`
is also `""` before the worker's first completed turn, and always `""` for modes with
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`; the first four
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`; the first four
verified live, pi from the same source path), which is
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
@@ -485,7 +485,7 @@ turn), and both better than diffing terminal samples:
```
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`** (those parsers are skipped wholesale) and
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`** (those parsers are skipped wholesale) and
in practice empty for `shell`. Source-verified, not measured live.
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
+25
View File
@@ -10,6 +10,7 @@
import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js';
import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js';
import { DEEPSEEK_VERSION_REGEX } from '../utils/deepseek-cli-resolver.js';
import { OMP_VERSION_REGEX } from '../utils/omp-cli-resolver.js';
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
@@ -190,6 +191,30 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
},
],
},
{
id: 'omp',
label: 'OMP CLI',
category: 'core',
required: false,
usedBy: ['OMP sessions'],
// Same version-match discipline as pi: `omp` is a short generic name, so a
// `which omp` hit alone is not the coding agent. Both sides share
// OMP_VERSION_REGEX, so the doctor and the run mode cannot drift into telling
// the user opposite things about the same binary.
resolvers: [
{
match: ALL,
resolver: {
kind: 'path',
bins: ['omp'],
versionArg: '--version',
versionRegex: OMP_VERSION_REGEX,
requireVersionMatch: true,
},
},
],
installHint: { linux: 'curl -fsSL https://omp.sh/install | sh', darwin: 'brew install can1357/tap/omp' },
},
{
id: 'libreoffice',
label: 'LibreOffice',
+17
View File
@@ -147,6 +147,7 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
pi: 'exec pi',
grok: 'exec grok',
deepseek: 'exec dsh',
omp: 'exec omp',
};
return commands[mode as DockerCommandMode] || commands.shell;
}
@@ -639,6 +640,22 @@ const CRED_STORES: CredStorePolicy[] = [
},
{ rel: '.config/gcloud', seedWhole: true },
{ rel: '.config/opencode', seedWhole: true },
// OMP keeps its config in `~/.omp/agent` (config.yml/mcp.json/models.yml/
// settings.yml — small, no bigger than grok's config.toml/pager.toml), but
// that dir ALSO holds agent.db/history.db/models.db (SQLite caches) and
// terminal-sessions/blobs/cache (large, regenerable), so seed only the
// config files. UNLIKE pi/grok, `sessions/` is SHARED (RW), not
// host-invisible: Codeman reads `~/.omp/agent/sessions/**/*.jsonl`
// HOST-SIDE for history recovery and --resume pinning
// (omp-transcript.ts, omp-session-resolver.ts) — the same reason codex's
// `sessions/` is shared rather than seeded. Without this, an in-container
// OMP conversation would be invisible to Codeman's own history-scan/resume
// logic, silently breaking the kill-survival feature for Docker cases.
{
rel: '.omp/agent',
shareDirs: ['sessions'],
seedFiles: ['config.yml', 'mcp.json', 'models.yml', 'settings.yml'],
},
];
/**
+3
View File
@@ -21,6 +21,7 @@ import type {
PiConfig,
GrokConfig,
DeepSeekConfig,
OmpConfig,
SessionRemote,
SessionDocker,
} from './types.js';
@@ -82,6 +83,7 @@ export interface CreateSessionOptions {
piConfig?: PiConfig;
grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Ephemeral — not written to disk. */
@@ -116,6 +118,7 @@ export interface RespawnPaneOptions {
piConfig?: PiConfig;
grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
/** Resume a previous Claude conversation when respawning */
resumeSessionId?: string;
/** Extra env vars exported before launching the CLI (preserved across respawns). */
+175
View File
@@ -0,0 +1,175 @@
/**
* @fileoverview Scan `~/.omp/agent/sessions/*&#47;*.jsonl` for Past Sessions rows,
* the omp analog of what `scanProjectDir()` (session-routes.ts) does for
* Claude's own `~/.claude/projects` transcripts.
*
* Without this, an omp conversation exists ONLY as a Codeman-level live/
* persisted session record — delete that (a "Kill Tmux" close, or any other
* cleanup) and the conversation vanishes from Past Sessions entirely, even
* though `omp` itself never forgot it. Claude conversations don't have that
* problem because Codeman already reads them back from Claude's own
* transcript files independent of its own session bookkeeping; this gives
* omp conversations the same treatment.
*
* Each omp session file's SECOND line is a `{"type":"session","id":...,
* "cwd":...}` header carrying the real (unmangled) working directory and the
* session's own id directly — no need to reverse-engineer the mangled
* directory name the way Claude Code's own scanner has to (see
* `decodeProjectKey()` in session-routes.ts and its "lossy" caveat). Prompt
* text comes from each `{"type":"message","message":{"role":"user",...}}`
* entry, giving a real first-message title instead of a bare case name.
*
* Unlike Claude's transcripts (which can run to tens of MB of tool-call
* output), an omp session file is the conversation only, so this reads each
* file whole rather than doing head/tail windows — bounded by a size cap so
* one unexpectedly huge file can't blow up memory.
*
* @module omp-transcript
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
function ompSessionsRoot(): string {
return join(homedir(), '.omp', 'agent', 'sessions');
}
/** Skip anything absurdly large rather than parsing it whole into memory. */
const MAX_OMP_SESSION_FILE_BYTES = 2 * 1024 * 1024;
/** Defensive cap on total files scanned across every directory, mirroring
* the Claude scanner's own instinct not to let one pathological tree stall
* a request — a real omp install has, at most, a few hundred of these. */
const MAX_OMP_SESSION_FILES = 2000;
export interface OmpHistorySession {
sessionId: string;
workingDir: string;
sizeBytes: number;
/** ISO timestamp, from the file's own mtime. */
lastModified: string;
firstPrompt?: string;
lastPrompt?: string;
}
function extractUserPromptText(message: unknown): string | undefined {
if (!message || typeof message !== 'object') return undefined;
const m = message as { role?: unknown; content?: unknown };
if (m.role !== 'user' || !Array.isArray(m.content)) return undefined;
const parts: string[] = [];
for (const block of m.content) {
if (block && typeof block === 'object' && (block as { type?: unknown }).type === 'text') {
const text = (block as { text?: unknown }).text;
if (typeof text === 'string') parts.push(text);
}
}
const joined = parts.join(' ').trim();
return joined || undefined;
}
/** Parse one omp session `.jsonl` file, or null when it's unreadable, empty, or has no session header. */
function parseOmpSessionFile(filePath: string): OmpHistorySession | null {
let stat: ReturnType<typeof statSync>;
try {
stat = statSync(filePath);
} catch {
return null;
}
if (stat.size === 0 || stat.size > MAX_OMP_SESSION_FILE_BYTES) return null;
let raw: string;
try {
raw = readFileSync(filePath, 'utf-8');
} catch {
return null;
}
let sessionId: string | undefined;
let workingDir: string | undefined;
let firstPrompt: string | undefined;
let lastPrompt: string | undefined;
for (const line of raw.split('\n')) {
if (!line) continue;
let entry: unknown;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (!entry || typeof entry !== 'object') continue;
const e = entry as Record<string, unknown>;
if (e.type === 'session' && typeof e.id === 'string' && typeof e.cwd === 'string' && e.cwd.startsWith('/')) {
// A corrupted or malformed session file could carry a relative or empty
// cwd; requiring an absolute path keeps a downstream resume attempt
// from being pointed at a nonsense working directory.
sessionId = e.id;
workingDir = e.cwd;
} else if (e.type === 'message') {
const prompt = extractUserPromptText(e.message);
if (prompt) {
if (!firstPrompt) firstPrompt = prompt;
lastPrompt = prompt;
}
}
}
if (!sessionId || !workingDir) return null;
return {
sessionId,
workingDir,
sizeBytes: stat.size,
lastModified: stat.mtime.toISOString(),
firstPrompt,
lastPrompt,
};
}
/**
* Scan every omp conversation on disk into Past-Sessions rows. Best-effort
* throughout: a missing `~/.omp` (never installed/used), an unreadable
* directory, or one corrupt file yields fewer rows rather than throwing —
* this feeds the same unified merge the Claude transcript scanner does, and
* one broken source must never blank the whole Past Sessions list.
*/
export function scanOmpSessionsHistory(): OmpHistorySession[] {
const root = ompSessionsRoot();
let dirEntries: string[];
try {
dirEntries = readdirSync(root);
} catch {
return [];
}
const out: OmpHistorySession[] = [];
for (const dirName of dirEntries) {
if (out.length >= MAX_OMP_SESSION_FILES) break;
const dirPath = join(root, dirName);
let dirStat: ReturnType<typeof statSync>;
try {
dirStat = statSync(dirPath);
} catch {
continue;
}
if (!dirStat.isDirectory()) continue;
let files: string[];
try {
files = readdirSync(dirPath);
} catch {
continue;
}
for (const file of files) {
if (out.length >= MAX_OMP_SESSION_FILES) break;
if (!file.endsWith('.jsonl')) continue;
try {
const parsed = parseOmpSessionFile(join(dirPath, file));
if (parsed) out.push(parsed);
} catch {
// One bad file must not sink the whole scan.
}
}
}
return out;
}
+2
View File
@@ -119,6 +119,7 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
// profile inventory is unknown here. The per-host `commands.deepseek` override
// is the escape hatch for naming one.
deepseek: remoteLoginShellCommand('dsh'),
omp: remoteLoginShellCommand('omp'),
};
return commands[mode as RemoteCommandMode] || commands.shell;
}
@@ -274,6 +275,7 @@ const REMOTE_CLI_BIN: Partial<Record<SessionMode, string>> = {
gemini: 'gemini',
antigravity: 'agy',
pi: 'pi',
omp: 'omp',
};
/**
+11
View File
@@ -99,6 +99,13 @@ export type HistoryInput = {
gitBranch?: string;
worktreeName?: string;
worktreeRepo?: string;
/**
* Set only by a non-claude transcript source (currently omp); the Claude
* scanner never stamps this; the meaningfulness floor below still counts a
* row with a `mode` as real, since that also signals "not claude" — see
* where it's read below for the isReal check this touches.
*/
mode?: string;
};
/** Mux process-stat view. */
@@ -175,6 +182,10 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
overwrite(item, 'gitBranch', h.gitBranch);
overwrite(item, 'worktreeName', h.worktreeName);
overwrite(item, 'worktreeRepo', h.worktreeRepo);
// Claude rows never set this (they're implicitly claude); a non-claude
// transcript source (currently only omp) does, so a history-only row
// still gets a mode badge instead of reading as claude by default.
overwrite(item, 'mode', h.mode);
const ms = Date.parse(h.lastModified);
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
}
+120 -6
View File
@@ -53,9 +53,11 @@ import {
type PiConfig,
type GrokConfig,
type DeepSeekConfig,
type OmpConfig,
type SessionRemote,
type SessionDocker,
} from './types.js';
import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js';
import { probeDockerCliVersion } from './docker-hosts.js';
import { probeRemoteCliVersion } from './remote-hosts.js';
import type { TerminalMultiplexer, MuxSession } from './mux-interface.js';
@@ -180,7 +182,8 @@ export function isExternalCliMode(mode: SessionMode): boolean {
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek'
mode === 'deepseek' ||
mode === 'omp'
);
}
@@ -200,6 +203,8 @@ function getModeLabel(mode: SessionMode): string {
return 'Grok';
case 'deepseek':
return 'DeepSeek';
case 'omp':
return 'OMP';
case 'shell':
return 'Shell';
case 'claude':
@@ -528,6 +533,8 @@ export class Session extends EventEmitter {
// DeepSeek Harness configuration (only for mode === 'deepseek')
private _deepSeekConfig: DeepSeekConfig | undefined;
// OMP configuration (only for mode === 'omp')
private _ompConfig: OmpConfig | undefined;
private _resumeSessionId: string | undefined;
// Ephemeral env overrides (e.g., CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS). Exported by tmux
@@ -627,6 +634,8 @@ export class Session extends EventEmitter {
grokConfig?: GrokConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig;
/** OMP configuration (only for mode === 'omp') */
ompConfig?: OmpConfig;
/** Resume a previous Claude conversation (used after server reboot) */
resumeSessionId?: string;
/** Extra env vars exported to the CLI at spawn time (no disk persistence) */
@@ -682,7 +691,13 @@ export class Session extends EventEmitter {
this._wireActivityAt = config.lastActivityAt || Date.now();
this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0;
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = config.resumeSessionId || this.id;
// For omp, `claudeSessionId` doubles as the generic "external transcript id"
// alias key mergeUnifiedSessions() folds a history row into its owning
// session by: omp mints its OWN uuid, unrelated to this Codeman id, so
// without this an omp conversation's Past-Sessions row (keyed by omp's
// id) would never merge with its own live/persisted row (keyed by this
// id) — it would just show up a second time.
this._claudeSessionId = config.resumeSessionId || config.ompConfig?.resumeSessionId || this.id;
// Restored from state.json on boot recovery. start() resets _claudeSessionId
// to the launch id even when re-attaching to a mux session whose CLI has
// moved on (a `/clear` before the restart), so this anchor is what lets the
@@ -735,6 +750,10 @@ export class Session extends EventEmitter {
if (config.piConfig) {
this._piConfig = config.piConfig;
}
// Apply OMP configuration
if (config.ompConfig) {
this._ompConfig = config.ompConfig;
}
// Apply DeepSeek Harness configuration
if (config.deepSeekConfig) {
@@ -1368,6 +1387,7 @@ export class Session extends EventEmitter {
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
effort: this._effort,
// COD-118: runtime-only — surfaced so the frontend can require explicit user
@@ -1494,7 +1514,11 @@ export class Session extends EventEmitter {
let needsNewSession = false;
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await mux.respawnPane(options.respawnPaneOptions);
// Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`).
// `options.respawnPaneOptions` was built eagerly before this dead-pane
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
this._pinOmpRespawnId();
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
@@ -1585,6 +1609,9 @@ export class Session extends EventEmitter {
return false;
}
// Confirmed the mux session (and thus the pane) exists but this reattach
// is about to respawn it — safe to resolve/pin now.
this._pinOmpRespawnId();
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
if (!newPid) {
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
@@ -1617,6 +1644,16 @@ export class Session extends EventEmitter {
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
// OMP resolution/pinning does NOT happen here. This object is built
// EAGERLY — including on every boot-recovery reattach, before anyone
// knows whether the pane is actually dead — so resolving here mutated
// `_ompConfig`/`_claudeSessionId` even for a pane that was simply being
// reattached to, not respawned; with two omp tabs in the same case dir
// that mis-pinned the ALIVE session onto whichever file happened to be
// newest on disk (reported live in the Ark0N/Codeman#353 review). The
// real pin now happens in `_pinOmpRespawnId()`, called by callers ONLY
// once they've confirmed an actual respawn is about to happen.
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1627,6 +1664,45 @@ export class Session extends EventEmitter {
};
}
/**
* OMP-only: resolve and PIN the exact conversation to continue when
* respawning a dead pane, so every later respawn reuses the same id
* instead of re-resolving (and re-risking picking up a DIFFERENT
* conversation that happened to touch this directory more recently). See
* the comment at the call site in {@link _buildRespawnPaneOptions} for why
* "newest file on disk" is safe here specifically. Non-omp modes and a
* session that already carries an explicit id pass through untouched.
*/
private _pinOmpRespawnId(): void {
if (this.mode !== 'omp') return;
if (this._ompConfig?.resumeSessionId) return;
// Callers MUST call this only immediately before an ACTUAL respawn (a
// confirmed-dead pane, or a genuine remote reattach) — never while merely
// building options that might not lead to a respawn. A fresh "Run OMP"
// click has no _muxSession yet and must never inherit whatever omp
// conversation happens to be newest on disk for this working directory
// (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that
// fix intact now that resolution has moved out of the eager options build.
if (!this._muxSession) return;
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
if (resolvedId) {
this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId };
// Alias omp's own session uuid to this Codeman id — see the
// constructor's claudeSessionId comment for why this field is the
// (generically-named) mechanism that folds a Past-Sessions row back
// into its live/persisted session instead of duplicating it.
this._claudeSessionId = resolvedId;
return;
}
// Nothing unclaimed on disk (the dying process never got far enough to
// write a session file, or a sibling already claimed the only candidate)
// — fall back to the CLI's own "most recent" heuristic.
console.warn(
`[Session] OMP: no session file found under ${this.workingDir} to pin --resume on respawn; falling back to ambiguous --continue`
);
this._ompConfig = { ...this._ompConfig, continueSession: true };
}
/**
* Remember whether the CLI currently wants to be told about mouse clicks.
*
@@ -1877,6 +1953,7 @@ export class Session extends EventEmitter {
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1888,8 +1965,14 @@ export class Session extends EventEmitter {
spawnErrLabel: 'mux attachment',
});
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = this._resumeSessionId || this.id;
// Set claudeSessionId — when resuming, the Claude conversation ID is the
// resumed one. `_pinOmpRespawnId()` (called just above, inside
// `_setupOrAttachMuxSession()`'s dead-pane branch) may have JUST aliased
// this to omp's own session uuid — that already-resolved id must win
// over the generic `this.id` fallback, or this line clobbers it back
// to the Codeman id
// on every single respawn.
this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
// For NEW mux sessions: wait for readiness then clean buffer
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
@@ -2007,7 +2090,12 @@ export class Session extends EventEmitter {
}
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = this._resumeSessionId || this.id;
// Mirrors the mux branch above and must not clobber it: this line runs
// unconditionally after both the mux and direct-PTY paths, so it also needs
// the ompConfig fallback or it stomps the mux branch's correctly-resolved
// OMP alias back to this.id on every mux/plain-reattach boot recovery
// (the "third reset point" — see DECISIONS.md).
this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
this._pid = this.ptyProcess.pid;
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
@@ -2298,10 +2386,36 @@ export class Session extends EventEmitter {
this._isWorking = false;
this._status = 'idle';
this._lastPromptTime = Date.now();
if (wasWorking) this._maybeCaptureOmpSessionId();
this.emit('idle');
}
}
/**
* A brand-new omp session (never yet respawned, so
* {@link _pinOmpRespawnId} has never run) has no captured
* omp-native session id: `_claudeSessionId` still defaults to this
* session's OWN Codeman id from the constructor. Until something aliases
* it, the omp history scan's row for this exact conversation (keyed by
* omp's own uuid) merges with nothing and shows up a second time. The
* first turn going idle is the first moment omp has definitely written
* its session file, so resolve and alias it here — best-effort, and only
* once (skips once `_claudeSessionId` differs from `this.id`, whether from
* this capture or a resume/respawn that already resolved one).
*/
private _maybeCaptureOmpSessionId(): void {
if (this.mode !== 'omp' || this._claudeSessionId !== this.id) return;
try {
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
if (resolvedId) {
this._claudeSessionId = resolvedId;
this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId };
}
} catch {
// Best-effort: a failed capture just means the next respawn tries again.
}
}
/**
* Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions).
* Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every
+51 -3
View File
@@ -54,6 +54,7 @@ import {
type PiConfig,
type GrokConfig,
type DeepSeekConfig,
type OmpConfig,
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
@@ -99,6 +100,8 @@ import {
resolveDeepSeekDir,
getDeepSeekNotFoundMessage,
resolveDefaultDeepSeekProfile,
getOmpNotFoundMessage,
resolveOmpDir,
resolveLocalShell,
loginShellArgs,
} from './utils/index.js';
@@ -896,6 +899,34 @@ function buildDeepSeekCommand(config?: DeepSeekConfig): string {
return parts.join(' ');
}
/**
* Build the OMP CLI command with appropriate flags.
*
* omp reads its model routing and hooks from ~/.omp (agent dir), so no
* trust/permission flags are needed: the CLI's own config governs. The only
* CLI flags passed are the per-session overrides Codeman knows about.
*/
function buildOmpCommand(config?: OmpConfig): string {
const parts = ['omp'];
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
// --resume and --continue conflict; a valid explicit session id wins,
// mirroring the sibling builders (grok/pi/opencode).
const safeId =
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeId) {
parts.push('--resume', safeId);
} else if (config?.continueSession) {
parts.push('--continue');
}
return parts.join(' ');
}
/**
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
@@ -941,6 +972,7 @@ export function buildSpawnCommand(options: {
piConfig?: PiConfig;
grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
resumeSessionId?: string;
effort?: EffortLevel;
/** Codeman session name, passed to claude as `--name` (version-gated, sanitized; local spawns only). */
@@ -996,6 +1028,9 @@ export function buildSpawnCommand(options: {
if (options.mode === 'deepseek') {
return buildDeepSeekCommand(options.deepSeekConfig);
}
if (options.mode === 'omp') {
return buildOmpCommand(options.ompConfig);
}
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
// so a `$SHELL` here is expanded by the SERVER process's shell against the
@@ -1179,7 +1214,6 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session
* adopts/resizes/respawns our session (same defence as the remote socket).
*/
const DOCKER_TMUX_SOCKET = 'codeman-docker';
/**
* Deterministic, reattach-stable in-container tmux session name. Derived from the
* same stable field the local muxName uses (first 8 chars of the sessionId), so a
@@ -1214,6 +1248,7 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
case 'grok':
return `${modeCommand} --resume ${resumeId}`;
case 'deepseek':
case 'omp':
return `${modeCommand} --resume ${resumeId}`;
default:
return modeCommand; // shell / opencode: no resume
@@ -1810,7 +1845,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek'
mode === 'deepseek' ||
mode === 'omp'
? 'export COLORTERM=truecolor'
: 'unset COLORTERM',
...(mode === 'codex' ||
@@ -1818,7 +1854,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek'
mode === 'deepseek' ||
mode === 'omp'
? ['unset NO_COLOR']
: []),
// Stamp each Codex pane with a unique originator so the response-viewer
@@ -1923,6 +1960,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dir = resolveDeepSeekDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'omp') {
const dir = resolveOmpDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
}
@@ -2033,6 +2074,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
envOverrides,
effort,
@@ -2099,6 +2141,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'grok' && !cliDir) {
throw new Error(getGrokNotFoundMessage());
}
if (mode === 'omp' && !cliDir) {
throw new Error(getOmpNotFoundMessage());
}
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
@@ -2115,6 +2160,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
effort,
sessionName: name,
@@ -2345,6 +2391,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
envOverrides,
effort,
@@ -2376,6 +2423,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
effort,
sessionName: name,
+17 -4
View File
@@ -8,7 +8,7 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' (which CLI backend)
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' (which CLI backend)
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
@@ -55,11 +55,12 @@ export type SessionMode =
| 'antigravity'
| 'pi'
| 'grok'
| 'deepseek';
| 'deepseek'
| 'omp';
export type RemoteCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
>;
/**
@@ -168,7 +169,7 @@ export interface RemoteSessionInfo {
/** Which CLI backends a Docker case can run (same set as remote). */
export type DockerCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
>;
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
@@ -343,6 +344,16 @@ export interface AntigravityConfig {
resumeConversationId?: string;
}
/** OMP CLI session configuration */
export interface OmpConfig {
/** Model identifier (e.g., "crof/glm-5.2"). Passed via --model. */
model?: string;
/** Resume a previous conversation (passed via --resume). */
resumeSessionId?: string;
/** Continue the most recent session in this directory (passed via --continue). */
continueSession?: boolean;
}
/**
* Pi CLI (pi.dev) session configuration.
*
@@ -620,6 +631,8 @@ export interface SessionState {
grokConfig?: GrokConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig;
/** OMP-specific configuration (only for mode === 'omp') */
ompConfig?: OmpConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
+1
View File
@@ -67,3 +67,4 @@ export {
export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolver.js';
export { compileFileQuery, matchFileQuery } from './file-query.js';
export type { FileQueryMatcher } from './file-query.js';
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
+144
View File
@@ -0,0 +1,144 @@
/**
* @fileoverview Resolve the OMP CLI binary across common install paths.
*
* Uses the shared `createCliExecutableResolver` (cli-executable-resolver.ts),
* same as the sibling claude/opencode/codex/gemini/antigravity/pi resolvers:
* server process PATH first, then common install directories, then — last,
* because it is the only step that spawns anything — an interactive login
* shell, which is what finds nvm/Homebrew/user-npm installs when Codeman runs
* as a systemd/launchd service with a minimal PATH.
*
* Provides an augmented PATH directory for tmux sessions.
*
* @module utils/omp-cli-resolver
*/
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
type CliResolverHost,
} from './cli-executable-resolver.js';
/**
* Common directories where the OMP CLI binary may be installed. `~/.local/bin`
* leads: omp.sh's installer targets `$HOME/.local/bin` with no `--dir`
* override (verified against a real `--no-cache` Docker build — see
* docker/agent.Dockerfile); `~/.omp/bin` was an unverified guess that turned
* out wrong, kept after `~/.local/bin` only as a defensive fallback.
*/
const OMP_SEARCH_DIRS = [
join(homedir(), '.local', 'bin'),
join(homedir(), '.omp', 'bin'),
'/usr/local/bin',
join(homedir(), '.bun', 'bin'),
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
/**
* A real `omp --version` prints `omp/<semver>` (e.g. `omp/17.4.0`).
*
* Shape mirrors PI_VERSION_REGEX: a capturing group and a leading boundary so
* `omp/17.4.0` matches while an unrelated `omp` (some other program) does not.
*/
export const OMP_VERSION_REGEX = /(?:^|\s)omp\/(\d+\.\d+\.\d+)/;
const OMP_NOT_FOUND = 'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh';
/**
* Run `omp --version` on a candidate path and return the trimmed version when
* it looks like the coding agent. Returns null for anything else — a missing
* binary, a non-zero exit, a hang (timeout), or output that is not
* `omp/<semver>`-shaped (which is how an unrelated `omp` on PATH gets rejected).
*
* Never runs under vitest: the suites must stay hermetic and must not depend on
* whether the dev box happens to have omp installed. The shared resolver host
* is already inert under vitest, so this gate is defense in depth for any
* opted-in host that still carries the default probe.
*/
function probeOmpVersion(binPath: string): string | null {
if (process.env.VITEST) return null;
try {
const out = execFileSync(binPath, ['--version'], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
// A stuck or hostile `omp` that ignores SIGTERM would survive the timeout
// and block the server (execFileSync keeps waiting after the signal).
killSignal: 'SIGKILL',
}).trim();
const candidate = OMP_VERSION_REGEX.exec(out)?.[1];
if (candidate) return candidate;
console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" printed ${JSON.stringify(out.slice(0, 80))}`);
} catch (err) {
console.warn(`[OmpResolver] Ignoring ${binPath}: "omp --version" failed (${(err as Error).message})`);
}
return null;
}
type OmpVersionProbe = (binPath: string) => string | null;
function createOmpResolver(
host?: CliResolverHost,
versionProbe: OmpVersionProbe = probeOmpVersion,
now?: () => number
) {
return createCliExecutableResolver<string>(
{
binary: 'omp',
searchDirs: OMP_SEARCH_DIRS,
validateCandidate: (binPath) => {
const version = versionProbe(binPath);
return version ? { accepted: true, metadata: version } : { accepted: false };
},
now,
},
host
);
}
/**
* Creates an isolated OMP wrapper around an injected host, version probe and
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
* is exactly what the hermeticity test exercises.
*/
export function createOmpResolverForTest(host: CliResolverHost, versionProbe?: OmpVersionProbe, now?: () => number) {
return createOmpResolver(host, versionProbe ?? probeOmpVersion, now);
}
const ompResolver = createOmpResolver();
/**
* Finds the directory containing a verified `omp` binary.
* Checks `which omp` first, then falls back to common install locations. Every
* candidate must pass the `omp --version` sanity probe before it is accepted.
*
* @returns Directory path, or null if not found
*/
export function resolveOmpDir(): string | null {
return ompResolver.resolve()?.directory ?? null;
}
/**
* Check if the OMP CLI is available on the system.
*/
export function isOmpAvailable(): boolean {
return resolveOmpDir() !== null;
}
export function getOmpNotFoundMessage(): string {
return formatCliNotFoundMessage(OMP_NOT_FOUND, ompResolver.diagnostics());
}
/**
* Version reported by the resolved `omp` binary, or null when omp is
* unavailable. Surfaced through `GET /api/omp/status` so a misresolution is
* diagnosable from the UI.
*/
export function getOmpCliVersion(): string | null {
return ompResolver.resolve()?.metadata ?? null;
}
+192
View File
@@ -0,0 +1,192 @@
/**
* @fileoverview Resolve the real OMP session id for a working directory, so a
* relaunch can pass `--resume <id>` instead of the ambiguous `--continue`.
*
* `omp` persists each conversation as its own file under
* `~/.omp/agent/sessions/<mangled-workingDir>/<ISO-timestamp>_<session-uuid>.jsonl`
* (workingDir mangled the same way Claude Code mangles `~/.claude/projects/*`:
* every `/` replaced with `-`). `--continue` picks whichever file in that
* directory is newest, which silently drifts to the WRONG conversation the
* moment two Codeman sessions ever touch the same directory — exactly what a
* closed-then-resumed row plus a still-running duplicate produces. Resolving
* the id once and pinning it with `--resume` removes that ambiguity for every
* later relaunch of the same Codeman session.
*
* @module utils/omp-session-resolver
*/
import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, sep } from 'node:path';
/** A real OMP session file is `<ISO-ish-timestamp>_<uuid>.jsonl`; only the uuid matters here. */
const OMP_SESSION_FILE_PATTERN = /^.+_([a-zA-Z0-9-]+)\.jsonl$/;
/**
* Mirrors `omp`'s own directory mangling. Confirmed empirically against real
* `~/.omp/agent/sessions/` directory names (2026-08-27): unlike Claude Code's
* `~/.claude/projects/*`, which keeps the home prefix (`-home-user-dev-foo`),
* omp collapses a home-relative workingDir to its home-relative remainder
* FIRST (`/home/user/dev/foo` -> `/dev/foo`) and only then dash-replaces
* (`-dev-foo`) — a path outside $HOME (e.g. `/tmp/...`) is dash-replaced as-is.
* Getting this wrong doesn't error, it just silently returns an empty
* directory listing: findLatestOmpSessionId() below then always falls through
* to null, so continuation pinning quietly degrades to omp's own ambiguous
* `--continue` for every case under $HOME (i.e. virtually all real Codeman
* cases) while appearing to work in `/tmp`-based manual testing.
* Pure so it's unit-testable without touching the filesystem.
*/
export function mangleOmpWorkingDir(workingDir: string): string {
// UNVERIFIED EDGE CASE: if $HOME is itself a symlink, this compares against
// the literal homedir() string, not a realpath()-resolved one. Whether that
// matches omp's own behavior is unconfirmed — we only empirically verified
// omp strips a literal $HOME prefix (2026-08-27), not that it canonicalizes
// symlinks first. Do not "fix" this with realpathSync() without confirming
// omp's actual behavior on a symlinked-home setup; guessing wrong here would
// trade one silent mismatch for a different one.
const home = homedir();
const relative =
workingDir === home || workingDir.startsWith(home + sep) ? workingDir.slice(home.length) : workingDir;
return relative.replace(/\//g, '-');
}
/**
* `~/.omp` — omp's own env overrides are mostly `PI_*` (shared with pi mode, already
* allowlisted in schemas.ts), and `PI_CONFIG_DIR` in particular can move this root.
* That is not honored here: a session with a redirected `PI_CONFIG_DIR` silently
* degrades pinning/history to omp's own ambiguous `--continue` instead of erroring,
* a known gap (found in Ark0N/Codeman#353 review) shared with pi and not fixed here.
*/
function resolveOmpHome(): string {
return join(homedir(), '.omp');
}
/**
* Newest OMP session id for this working directory, or null when the
* directory doesn't exist yet (never launched) or holds no session files.
*
* Deliberately "newest file, full stop" rather than a time-windowed match:
* callers only invoke this at a moment where that's unambiguous by
* construction — right after the file that answers it was the only thing
* that could have just been written (a dead pane's process already exited,
* or a session being resumed has no live sibling in the same directory yet).
*/
export function findLatestOmpSessionId(workingDir: string): string | null {
const dir = join(resolveOmpHome(), 'agent', 'sessions', mangleOmpWorkingDir(workingDir));
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return null;
}
let newestMtime = -Infinity;
let newestId: string | null = null;
for (const entry of entries) {
const match = OMP_SESSION_FILE_PATTERN.exec(entry);
if (!match) continue;
let mtimeMs: number;
try {
mtimeMs = statSync(join(dir, entry)).mtimeMs;
} catch {
continue;
}
if (mtimeMs > newestMtime) {
newestMtime = mtimeMs;
newestId = match[1];
}
}
return newestId;
}
/**
* The session header line is always near the top of the file (the
* transcript's own "second line" — see omp-transcript.ts), so identifying a
* file never needs reading the whole thing (up to multi-MB, per that same
* module's size cap). Bounded read only.
*/
const HEADER_READ_BYTES = 8 * 1024;
function readOmpSessionHeader(filePath: string): { id: string; cwd: string } | null {
let raw: string;
try {
const fd = openSync(filePath, 'r');
try {
const buf = Buffer.alloc(HEADER_READ_BYTES);
const bytesRead = readSync(fd, buf, 0, HEADER_READ_BYTES, 0);
raw = buf.toString('utf-8', 0, bytesRead);
} finally {
closeSync(fd);
}
} catch {
return null;
}
for (const line of raw.split('\n')) {
if (!line) continue;
let entry: unknown;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (!entry || typeof entry !== 'object') continue;
const e = entry as Record<string, unknown>;
if (e.type === 'session' && typeof e.id === 'string' && typeof e.cwd === 'string') {
return { id: e.id, cwd: e.cwd };
}
}
return null;
}
/**
* Process-wide registry of OMP session ids already pinned to a live Codeman
* session. Two omp tabs in the same case dir (`w1-foo`, `w2-foo`) resolve
* against the SAME directory on disk — without this, both could pick the
* newest file and alias onto each other's conversation (found in upstream PR
* review, Ark0N/Codeman#353). Never released: this holds at most a handful of
* short ids per real omp conversation ever pinned in this process's lifetime,
* immaterial memory even after weeks of uptime — correctness here matters
* more than reclaiming it.
*/
const claimedOmpSessionIds = new Set<string>();
/**
* Safe variant of {@link findLatestOmpSessionId} for callers where two omp
* sessions CAN share the same case directory — a dead-pane respawn, a
* boot-recovery reattach, or a first-idle capture — instead of the narrower
* cases where "newest file" is unambiguous by construction. Verifies each
* candidate's own header `cwd` against `workingDir` (mangling is a lossy
* one-way transform — see {@link mangleOmpWorkingDir} — so trusting the
* filename-derived id alone isn't enough) and skips any id a sibling session
* has already claimed. Claims the id it returns so a concurrent caller
* resolving the same directory in the same tick can't double-claim it.
*/
export function resolveAndClaimOmpSessionId(workingDir: string): string | null {
const dir = join(resolveOmpHome(), 'agent', 'sessions', mangleOmpWorkingDir(workingDir));
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return null;
}
let newestMtime = -Infinity;
let newestId: string | null = null;
for (const entry of entries) {
if (!OMP_SESSION_FILE_PATTERN.test(entry)) continue;
const filePath = join(dir, entry);
let mtimeMs: number;
try {
mtimeMs = statSync(filePath).mtimeMs;
} catch {
continue;
}
if (mtimeMs <= newestMtime) continue;
const header = readOmpSessionHeader(filePath);
if (!header || header.cwd !== workingDir || claimedOmpSessionIds.has(header.id)) continue;
newestMtime = mtimeMs;
newestId = header.id;
}
if (newestId) claimedOmpSessionIds.add(newestId);
return newestId;
}
+15 -6
View File
@@ -2270,9 +2270,11 @@ class CodemanApp {
? 'Grok'
: mode === 'deepseek'
? 'DeepSeek'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
: mode === 'omp'
? 'OMP'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
}
async toggleResponseViewer() {
@@ -2638,10 +2640,15 @@ class CodemanApp {
if (!Number.isFinite(n)) return '';
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
};
// The provider label only earns its space when there is more than one
// provider to tell apart: a machine with Claude alone shows bare windows.
const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null;
const labelled = hasWindows(data) && hasWindows(data.codex);
const row = (provider, usage) => {
const windows = [seg('5h', pct(usage?.fiveHour)), seg('7d', pct(usage?.sevenDay))].filter(Boolean);
if (!windows.length) return '';
return `<span class="pu-row"><span class="pu-provider">${provider}</span><span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
};
const rows = [row('Claude', data), row('Codex', data.codex)].filter(Boolean);
chip.innerHTML = rows.length ? rows.join('') : '—';
@@ -4896,7 +4903,7 @@ class CodemanApp {
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : ''}
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : mode === 'deepseek' ? '<span class="tab-mode deepseek" aria-hidden="true">ds</span>' : mode === 'omp' ? '<span class="tab-mode omp" aria-hidden="true">om</span>' : ''}
<span class="tab-name" data-session-id="${id}" data-full-name="${escapeHtml(name)}">${tabLabel}</span>
${inlineSessionActions ? tabActionsHtml : ''}
<span class="tab-detached-badge" aria-hidden="true">detached</span>
@@ -6344,7 +6351,9 @@ class CodemanApp {
? 'Kill Tmux & Grok'
: session.mode === 'deepseek'
? 'Kill Tmux & DeepSeek'
: 'Kill Tmux & Claude Code';
: session.mode === 'omp'
? 'Kill Tmux & OMP'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
+1
View File
@@ -80,6 +80,7 @@ const HOME_SESSIONS_MODE_BADGE = {
pi: 'pi',
grok: 'gk',
deepseek: 'ds',
omp: 'om',
};
Object.assign(CodemanApp.prototype, {
+9
View File
@@ -452,6 +452,10 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run DeepSeek
</button>
<button class="welcome-btn welcome-btn-omp" id="welcomeOmpBtn" style="display: none;" onclick="app.setRunMode('omp'); app.runOmp()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run OMP
</button>
</div>
<div class="welcome-qr" id="welcomeQr" onclick="app.toggleWelcomeQrSize()">
<div class="welcome-qr-inner" id="welcomeQrInner"></div>
@@ -643,6 +647,9 @@
<button class="run-mode-option run-mode-option-install" data-action="deepseek-install" id="runModeDeepSeekInstall" style="display: none;" onclick="app.installDeepSeekProfile()">
<span class="run-mode-dot deepseek"></span>DeepSeek — add a terminal profile…
</button>
<button class="run-mode-option" data-mode="omp" onclick="app.setRunMode('omp')">
<span class="run-mode-dot omp"></span>OMP
</button>
<div class="run-mode-sep"></div>
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
<span class="run-mode-dot shell"></span>Terminal / Shell
@@ -930,6 +937,7 @@
<option value="pi">Pi</option>
<option value="grok">Grok</option>
<option value="deepseek">DeepSeek</option>
<option value="omp">OMP</option>
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -2709,6 +2717,7 @@
<option value="pi" data-cli="pi">Pi</option>
<option value="grok" data-cli="grok">Grok</option>
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
<option value="omp" data-cli="omp">OMP</option>
<option value="shell">Shell (no agent)</option>
</select>
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
+2 -1
View File
@@ -56,6 +56,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'pi', label: 'Pi', short: 'Pi' },
{ mode: 'grok', label: 'Grok', short: 'Grok' },
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
{ mode: 'omp', label: 'OMP', short: 'OMP' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
];
@@ -388,7 +389,7 @@ Object.assign(CodemanApp.prototype, {
async resumeMobileOverviewSession(sessionId) {
const row = (this._mobileOverviewPastRows || []).find((r) => r.id === sessionId);
if (!row || !row.workingDir) return;
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined);
await this.resumeHistorySession(row.claudeSessionId || row.id, row.workingDir, row.name || undefined, row.mode);
},
// ═══════════════════════════════════════════════════════════════
+20
View File
@@ -1003,6 +1003,20 @@ html.mobile-init .file-browser-panel {
border-color: rgba(150, 170, 255, 0.55) !important;
}
/* OMP mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek blocks above. */
.btn-toolbar.btn-run.mode-omp,
.btn-toolbar.btn-run-gear.mode-omp {
background: #312e81 !important;
border-color: rgba(129, 140, 248, 0.3) !important;
color: #e0e7ff !important;
}
.btn-toolbar.btn-run.mode-omp:active,
.btn-toolbar.btn-run-gear.mode-omp:active {
background: #4f46e5 !important;
border-color: rgba(129, 140, 248, 0.5) !important;
}
/* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu {
bottom: 100%;
@@ -3086,6 +3100,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-omp, .btn-toolbar.btn-run-gear.mode-omp) {
background: linear-gradient(135deg, #4f46e5, #6366f1);
border-color: #4338ca;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
background: linear-gradient(135deg, #27272a, #52525b);
border-color: #18181b;
+2 -2
View File
@@ -432,7 +432,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek' };
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
@@ -670,7 +670,7 @@ Object.assign(CodemanApp.prototype, {
} else if (record.workingDir) {
// History rows are keyed by the Claude conversation UUID; resumed
// sessions carry theirs separately as claudeSessionId.
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir);
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir, undefined, s.mode);
}
},
});
+59 -6
View File
@@ -400,6 +400,9 @@ Object.assign(CodemanApp.prototype, {
if (mode === 'antigravity') {
return await this.runAntigravity();
}
if (mode === 'omp') {
return await this.runOmp();
}
if (mode === 'pi') {
return await this.runPi();
}
@@ -474,7 +477,7 @@ Object.assign(CodemanApp.prototype, {
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
*/
_refreshRunModeAvailability(menu) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']) {
const btn = menu.querySelector(`.run-mode-option[data-mode="${mode}"]`);
if (btn) btn.style.display = this.isCliAvailable(mode) ? 'flex' : 'none';
}
@@ -689,7 +692,7 @@ Object.assign(CodemanApp.prototype, {
btn.append(...parts);
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.resumeHistorySession(s.sessionId, s.workingDir, s.name);
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode);
});
container.appendChild(btn);
}
@@ -710,7 +713,7 @@ Object.assign(CodemanApp.prototype, {
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
}
if (label) {
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'shell' ? 'Run SH' : 'Run';
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'antigravity' ? 'Run AG' : mode === 'pi' ? 'Run PI' : mode === 'grok' ? 'Run GK' : mode === 'deepseek' ? 'Run DS' : mode === 'omp' ? 'Run OMP' : mode === 'shell' ? 'Run SH' : 'Run';
}
},
@@ -1423,6 +1426,56 @@ Object.assign(CodemanApp.prototype, {
}
},
async runOmp() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run omp on the OTHER side — skip the local status probe
// and the local-only config below (quick-start rejects them for remote cases).
const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location;
const isRemote = _runLoc === 'remote' || _runLoc === 'docker';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OMP session in ${caseName}...`);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/omp/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
);
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
caseName,
mode: 'omp',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start OMP');
await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session);
if (data.data.sessionId) {
await this.selectSession(data.data.sessionId);
}
this.terminal.focus();
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
/**
* Launch a Grok Build (xAI `grok`) session.
*
@@ -1626,7 +1679,7 @@ Object.assign(CodemanApp.prototype, {
if (detachToggle) detachToggle.checked = this.hasTabDetachOverride(sessionId);
// Reset to an appropriate tab — Summary for external CLIs (Respawn/Ralph are Claude-only)
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek';
const isAltMode = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
this.switchOptionsTab(isAltMode ? 'summary' : 'respawn');
// Update respawn status display and buttons
@@ -1656,7 +1709,7 @@ Object.assign(CodemanApp.prototype, {
}
// Hide Claude-specific options for external CLI sessions
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek';
const isExternalCli = session.mode === 'opencode' || session.mode === 'codex' || session.mode === 'gemini' || session.mode === 'antigravity' || session.mode === 'pi' || session.mode === 'grok' || session.mode === 'deepseek' || session.mode === 'omp';
const claudeOnlyEls = document.querySelectorAll('[data-claude-only]');
claudeOnlyEls.forEach(el => { el.style.display = isExternalCli ? 'none' : ''; });
@@ -3442,7 +3495,7 @@ Object.defineProperty(CodemanApp.prototype, 'runMode', {
},
set(mode) {
this._runMode =
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'claude'
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'omp' || mode === 'claude'
? mode
: 'claude';
},
+1
View File
@@ -1230,6 +1230,7 @@ Object.assign(CodemanApp.prototype, {
['welcomeClaudeBtn', 'claude'],
['welcomeOpencodeBtn', 'opencode'],
['welcomeAntigravityBtn', 'antigravity'],
['welcomeOmpBtn', 'omp'],
['welcomeGeminiBtn', 'gemini'],
['welcomePiBtn', 'pi'],
['welcomeGrokBtn', 'grok'],
+38 -1
View File
@@ -349,7 +349,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
.session-tab .tab-mode.gemini,
.session-tab .tab-mode.antigravity,
.session-tab .tab-mode.pi,
.session-tab .tab-mode.grok
.session-tab .tab-mode.grok,
.session-tab .tab-mode.omp
) {
color: var(--accent-d);
}
@@ -2499,6 +2500,10 @@ body.solo-mode .btn-lifecycle-log {
background: rgba(34, 211, 238, 0.2);
color: #22d3ee;
}
.session-tab .tab-mode.omp {
background: rgba(129, 140, 248, 0.2);
color: #818cf8;
}
.session-tab .tab-mode.pi {
background: rgba(244, 114, 182, 0.2);
@@ -3883,6 +3888,22 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #fff1f7;
transform: translateY(-1px);
}
/* OMP: indigo identity, matching .btn-toolbar.btn-run.mode-omp and
.run-mode-dot.omp so the welcome action reads as the same backend. */
.welcome-btn-omp {
background: linear-gradient(135deg, #1e1b4b 0%, #4f46e5 55%, #6366f1 100%);
border-color: rgba(129, 140, 248, 0.4);
color: #e0e7ff;
box-shadow: 0 2px 8px rgba(129, 140, 248, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.welcome-btn-omp:hover {
background: linear-gradient(135deg, #312e81 0%, #6366f1 55%, #818cf8 100%);
box-shadow: 0 4px 20px rgba(129, 140, 248, 0.3), 0 0 40px rgba(79, 70, 229, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(165, 180, 252, 0.5);
color: #eef2ff;
transform: translateY(-1px);
}
/* Grok (xAI): monochrome charcoal identity, matching .btn-toolbar.btn-run.mode-grok
and .run-mode-dot.grok so the welcome action reads as the same backend. */
@@ -4992,6 +5013,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
border-color: rgba(249, 168, 212, 0.6);
color: #fff1f7;
}
/* OMP mode colors */
.btn-toolbar.btn-run.mode-omp,
.btn-toolbar.btn-run-gear.mode-omp {
background: linear-gradient(135deg, #312e81 0%, #4f46e5 55%, #6366f1 100%);
border-color: rgba(129, 140, 248, 0.5);
color: #e0e7ff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.btn-toolbar.btn-run.mode-omp:hover,
.btn-toolbar.btn-run-gear.mode-omp:hover {
background: linear-gradient(135deg, #3730a3 0%, #6366f1 55%, #818cf8 100%);
box-shadow: 0 0 12px rgba(129, 140, 248, 0.35), 0 2px 8px rgba(79, 70, 229, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(165, 180, 252, 0.6);
color: #eef2ff;
}
/* Grok mode colors. Same cascade note as pi above: this base-sheet pair only
renders on the `og` skin — the nested `html:not([data-skin="og"])` block
@@ -5116,6 +5152,7 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.run-mode-dot.pi { background: #f472b6; }
.run-mode-dot.grok { background: #a1a1aa; }
.run-mode-dot.deepseek { background: #4d6bfe; }
.run-mode-dot.omp { background: #818cf8; }
.run-mode-dot.shell { background: #94a3b8; }
/* Phone-only Enter button (see index.html). Hidden by default at every width;
+51 -4
View File
@@ -2193,7 +2193,7 @@ Object.assign(CodemanApp.prototype, {
if (isLive && this.sessions.has(s.sessionId)) {
this.selectSession(s.sessionId);
} else {
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode);
}
})
);
@@ -2436,7 +2436,7 @@ Object.assign(CodemanApp.prototype, {
} else {
// Resume by the Claude conversation UUID when present (resumed sessions
// carry theirs separately from their Codeman id).
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name);
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode);
}
this.closeSessionManager?.();
closeMenu();
@@ -2904,7 +2904,7 @@ Object.assign(CodemanApp.prototype, {
return `w${startNumber}-${dirName}`;
},
async resumeHistorySession(sessionId, workingDir, existingName) {
async resumeHistorySession(sessionId, workingDir, existingName, mode) {
// Close the run mode menu if open
document.getElementById('runModeMenu')?.classList.remove('active');
// Close folder history modal if open
@@ -2925,13 +2925,45 @@ Object.assign(CodemanApp.prototype, {
const globalSettings = this.loadAppSettingsFromStorage();
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), globalSettings);
const effort = this.getEffortSetting(globalSettings);
// `resumeSessionId` is a Claude conversation UUID (server reads it from
// ~/.claude/projects); an external-CLI row has no such thing, so sending
// it there gets silently ignored while the OMITTED `mode` field defaults
// the create to plain claude — reproducing whatever conversation THAT
// uuid happens to collide with instead of the row's own backend. Row mode
// wins here. Codeman has no cross-restart PTY-reattach outside server
// boot, so "resume" for a non-claude row means relaunching the CLI's own
// continue-most-recent flag (opencode/pi/grok/omp --continue, deepseek
// resumeSession) in the same directory — real conversation continuity,
// just not the literal old process.
const effectiveMode = mode || 'claude';
const modeConfigKey = {
opencode: 'openCodeConfig',
pi: 'piConfig',
grok: 'grokConfig',
omp: 'ompConfig',
}[effectiveMode];
// codex/gemini/antigravity have no wired continuation here yet (their
// configs use an exact conversation id, not a "continue most recent"
// flag, and the row's own `sessionId` is not verified to carry that
// id for these three modes) — `continuesSomething` below is what keeps
// their row from being retired for a resume that didn't actually
// continue anything.
const modeConfig =
modeConfigKey
? { [modeConfigKey]: { continueSession: true } }
: effectiveMode === 'deepseek'
? { deepSeekConfig: { resumeSession: true } }
: {};
const continuesSomething = Boolean(modeConfigKey) || effectiveMode === 'deepseek';
const createRes = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workingDir,
name,
resumeSessionId: sessionId,
mode: effectiveMode,
...(effectiveMode === 'claude' ? { resumeSessionId: sessionId } : {}),
...modeConfig,
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(effort ? { effort } : {}),
}),
@@ -2944,6 +2976,21 @@ Object.assign(CodemanApp.prototype, {
// Start interactive
await fetch(`/api/sessions/${newSessionId}/interactive`, { method: 'POST' });
// Retire the row being resumed: a non-claude "resume" is really a brand
// new Codeman session pointed at the same directory (there is no id to
// reattach to), so without this every resume leaves the old row behind
// as a duplicate — click it 3 times, see the same name 3 times. Claude
// rows are left alone: `sessionId` there is a claudeSessionId, which
// usually has no live/persisted Codeman session of its own to delete.
// Gated on `continuesSomething`: for codex/gemini/antigravity (no
// continuation wired above), this is really a FRESH session with no
// relation to the old row's conversation, so retiring it would discard
// the old conversation with no recovery — worse than the duplicate row
// this guard exists to prevent for the modes that DO continue.
if (effectiveMode !== 'claude' && continuesSomething && sessionId !== newSessionId) {
fetch(`/api/sessions/${sessionId}?killMux=true`, { method: 'DELETE' }).catch(() => {});
}
this.terminal.writeln(`\x1b[90m Session ${name} ready\x1b[0m`);
await this.selectSession(newSessionId);
this.terminal.focus();
+35 -7
View File
@@ -12,7 +12,7 @@ import { homedir } from 'node:os';
import type { z } from 'zod';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Session } from '../session.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js';
import { ApiErrorCode, createErrorResponse, type AuthUser, type SessionState } from '../types.js';
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
import { SseEvent } from './sse-events.js';
@@ -264,6 +264,18 @@ export function revokeUserSessions(
return removed;
}
/**
* The 404 both session-lookup helpers below throw. A missing session and one
* the caller isn't allowed to see get the IDENTICAL error (never 403), so
* existence of another user's session is never leaked.
*/
function sessionNotFoundError(sessionId: string): Error & { statusCode: number; body: unknown } {
return Object.assign(new Error(`Session ${sessionId} not found`), {
statusCode: 404,
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
});
}
/**
* Look up a session by ID or throw a structured error.
* Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`.
@@ -274,15 +286,31 @@ export function revokeUserSessions(
*/
export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session {
const session = ctx.sessions.get(sessionId);
if (!session || (req && !canAccessOwned(getAuthUser(req), session.owner))) {
throw Object.assign(new Error(`Session ${sessionId} not found`), {
statusCode: 404,
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
});
}
if (!session) throw sessionNotFoundError(sessionId);
if (req && !canAccessOwned(getAuthUser(req), session.owner)) throw sessionNotFoundError(sessionId);
return session;
}
/**
* Like {@link findSessionOrFail}, for a session that exists ONLY in persisted
* state — a resumed-but-never-reattached row (e.g. a non-claude "Resume" that
* relaunched into a new session and wants to retire the row it can no longer
* reattach to) has no live `Session` instance for `findSessionOrFail` to
* return, so this returns the persisted record instead. Same ownership
* enforcement, same 404-not-403 leak protection — this is that function's
* missing other half, not a separate check reimplemented inline.
*/
export function findPersistedSessionOrFail(
store: { getSession(id: string): SessionState | null },
sessionId: string,
req?: FastifyRequest
): SessionState {
const persisted = store.getSession(sessionId);
if (!persisted) throw sessionNotFoundError(sessionId);
if (req && !canAccessOwned(getAuthUser(req), persisted.owner)) throw sessionNotFoundError(sessionId);
return persisted;
}
/** Shortest prefix accepted for a parent session id (see resolveParentSessionId). */
const PARENT_SESSION_ID_MIN_PREFIX = 8;
+133 -18
View File
@@ -20,12 +20,14 @@ import {
type ApiResponse,
type SessionColor,
type SessionStatus,
type SessionMode,
type CodexConfig,
type GeminiConfig,
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type DeepSeekConfig,
type OmpConfig,
} from '../../types.js';
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
@@ -65,6 +67,7 @@ import {
autoConfigureRalph,
canAccessOwned,
CASES_DIR,
findPersistedSessionOrFail,
findSessionOrFail,
getAuthUser,
isAdmin,
@@ -136,6 +139,8 @@ import {
toSessionDocker,
} from '../../docker-hosts.js';
import { LRUMap } from '../../utils/lru-map.js';
import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
import {
getLastTranscriptResponse,
isExternalCliTranscriptMode,
@@ -395,10 +400,10 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
/**
* Env-var keys a non-granted owner must not be able to set, because each one
* hands back privilege the config clamp above just removed — or, for the last,
* redirects a credential the server injects.
* hands back privilege the config clamp above just removed, or redirects a
* credential-resolution endpoint.
*
* All are DeepSeek's, and all are reachable because `DSH_*` and `DEEPSEEK_*` are
* The DeepSeek three are reachable because `DSH_*` and `DEEPSEEK_*` are
* allowlisted `envOverrides` prefixes (schemas.ts) — which they have to be, since
* that is also how a user configures the harness's non-privileged knobs.
*
@@ -415,8 +420,24 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
* URL would have the operator's API key sent as a bearer credential to a host
* of their choosing. (`DEEPSEEK_API_KEY` itself stays overridable: supplying
* your OWN key removes privilege rather than granting it.)
* - `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` are where omp resolves
* credentials from — the same shape as `DEEPSEEK_BASE_URL` above, reachable
* because `OMP_*` is an allowlisted prefix. Unlike DeepSeek, Codeman does not
* forward any operator-held key into an omp pane today (omp's provider
* credentials live in `~/.omp` config files, not env vars), so there is no
* known concrete exfiltration path yet — clamped defensively anyway, since a
* non-granted owner redirecting where a shared multi-tenant deployment
* resolves auth from is not something to allow silently (found in
* Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`,
* already allowlisted for pi and not addressed here — see resolveOmpHome()).
*/
const OWNER_CLAMPED_ENV_KEYS = ['DSH_PERMISSION_MODE', 'DSH_HOME', 'DEEPSEEK_BASE_URL'] as const;
const OWNER_CLAMPED_ENV_KEYS = [
'DSH_PERMISSION_MODE',
'DSH_HOME',
'DEEPSEEK_BASE_URL',
'OMP_AUTH_BROKER_URL',
'OMP_AUTH_BROKER_TOKEN',
] as const;
/**
* Env-var half of the multi-user bypass clamp.
@@ -744,6 +765,36 @@ async function injectAgentSkill(casePath: string): Promise<void> {
// bypassing the `workspaceHooksEnabled` setting. Route handlers here resolve the
// setting through the ConfigPort (tests stub it) and pass it as the second arg.
/**
* A "Resume"/"continue" request for a NEW omp-mode session (the frontend's
* resumeHistorySession(), or anyone hitting the API directly) carries
* `continueSession: true` but no id — omp has none to give it, since Codeman
* has never tracked its own conversation UUID. Left as `--continue`, that
* picks whichever session file in the directory is newest, which silently
* drifts to the WRONG conversation the moment a second omp session (this
* one, a sibling worker, a stray manual run) has touched the same directory
* more recently. Resolve the real id up front instead, same as the
* dead-pane-respawn path in session.ts does, so even the FIRST relaunch of a
* resumed conversation is pinned rather than guessed.
*/
export function resolveOmpConfigForCreate(
mode: SessionMode,
workingDir: string,
ompConfig: OmpConfig | undefined
): OmpConfig | undefined {
if (mode !== 'omp') return undefined;
if (!ompConfig || ompConfig.resumeSessionId || !ompConfig.continueSession) {
return ompConfig;
}
const resolvedId = findLatestOmpSessionId(workingDir);
if (!resolvedId) {
console.warn(
`[Session] OMP: no session file found under ${workingDir} to pin --resume; falling back to ambiguous --continue`
);
}
return resolvedId ? { ...ompConfig, resumeSessionId: resolvedId } : ompConfig;
}
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
@@ -866,6 +917,7 @@ export function registerSessionRoutes(
body.mode !== 'pi' &&
body.mode !== 'grok' &&
body.mode !== 'deepseek' &&
body.mode !== 'omp' &&
body.envOverrides &&
Object.keys(body.envOverrides).length > 0 &&
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
@@ -965,6 +1017,12 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
}
}
if (body.mode === 'omp') {
const { isOmpAvailable, getOmpNotFoundMessage } = await import('../../utils/omp-cli-resolver.js');
if (!isOmpAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOmpNotFoundMessage());
}
}
// Pre-validate resumeSessionId: check that the conversation file actually exists
// in Claude's projects directory. If not, skip resume to avoid confusing
@@ -1012,12 +1070,14 @@ export function registerSessionRoutes(
? body.piConfig?.model
: mode === 'grok'
? body.grokConfig?.model
: // DeepSeek's model is a composition entry in the profile's config
// tree, not a session flag, so there is deliberately nothing to
// read here (see docs/deepseek-integration.md).
mode !== 'shell' && mode !== 'deepseek'
? modelConfig?.defaultModel || undefined
: undefined;
: mode === 'omp'
? body.ompConfig?.model
: // DeepSeek's model is a composition entry in the profile's config
// tree, not a session flag, so there is deliberately nothing to
// read here (see docs/deepseek-integration.md).
mode !== 'shell' && mode !== 'deepseek'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
@@ -1056,6 +1116,7 @@ export function registerSessionRoutes(
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig),
resumeSessionId: validatedResumeId,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort,
@@ -1124,9 +1185,26 @@ export function registerSessionRoutes(
const query = req.query as { killMux?: string };
const killMux = query.killMux !== 'false'; // Default to true
// Security: owner-scoped lookup 404s foreign/missing sessions uniformly (no existence leak, no cross-user kill).
const session = findSessionOrFail(ctx, id, req);
// A resumed/detached-but-never-live row (e.g. a non-claude "Resume" that
// relaunched into a NEW session and wants to retire the old one it can no
// longer reattach to) has no entry in ctx.sessions at all — only in
// persisted state. Fall back to removing that persisted record directly
// rather than 404ing: the caller means "make this row go away", and a
// stale duplicate row is exactly what's left behind otherwise. Pinned
// sessions keep their existing demote-not-delete protection.
if (!ctx.sessions.has(id)) {
// Called for its existence/ownership 404 side effect only — demoteOrRemoveSession
// below re-looks-up the record by id, so the returned SessionState is unused here.
findPersistedSessionOrFail(ctx.store, id, req);
ctx.store.demoteOrRemoveSession(id);
// Mirrors the broadcast at the tail of the live-session cleanup path
// (_doCleanupSession in server.ts) — without it, other open tabs keep
// showing the retired row until their next unrelated fetch.
ctx.broadcast(SseEvent.SessionDeleted, { id });
return {};
}
const session = findSessionOrFail(ctx, id, req);
await ctx.cleanupSession(session.id, killMux, 'user_delete');
return {};
});
@@ -1289,6 +1367,7 @@ export function registerSessionRoutes(
session.mode !== 'pi' &&
session.mode !== 'grok' &&
session.mode !== 'deepseek' &&
session.mode !== 'omp' &&
ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -2857,6 +2936,7 @@ export function registerSessionRoutes(
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
envOverrides,
effort,
parentSessionId,
@@ -2907,6 +2987,7 @@ export function registerSessionRoutes(
piConfig ||
grokConfig ||
deepSeekConfig ||
ompConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2941,6 +3022,7 @@ export function registerSessionRoutes(
piConfig ||
grokConfig ||
deepSeekConfig ||
ompConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -3042,6 +3124,16 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
}
}
// Check OMP availability if requested
if (mode === 'omp') {
const { isOmpAvailable } = await import('../../utils/omp-cli-resolver.js');
if (!isOmpAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
);
}
}
// Check Grok availability if requested
if (mode === 'grok') {
@@ -3103,14 +3195,15 @@ export function registerSessionRoutes(
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi and Grok use their own systems)
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP use their own systems)
if (
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity' &&
mode !== 'pi' &&
mode !== 'grok'
mode !== 'grok' &&
mode !== 'omp'
) {
await writeHooksConfig(resolvedCasePath);
}
@@ -3185,6 +3278,7 @@ export function registerSessionRoutes(
mode !== 'pi' &&
mode !== 'grok' &&
mode !== 'deepseek' &&
mode !== 'omp' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
@@ -3209,10 +3303,12 @@ export function registerSessionRoutes(
? piConfig?.model
: mode === 'grok'
? grokConfig?.model
: // DeepSeek's model lives in the profile's config tree, not here.
mode !== 'shell' && mode !== 'deepseek'
? qsModelConfig?.defaultModel || undefined
: undefined;
: mode === 'omp'
? ompConfig?.model
: // DeepSeek's model lives in the profile's config tree, not here.
mode !== 'shell' && mode !== 'deepseek'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
@@ -3252,6 +3348,7 @@ export function registerSessionRoutes(
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
ompConfig: resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig),
envOverrides: qsGatedEnvOverrides,
effort,
remote,
@@ -4100,6 +4197,24 @@ export function registerSessionRoutes(
// Projects dir may not exist.
}
// OMP's own session files (~/.omp/agent/sessions) — the non-claude twin
// of the scan above; see omp-transcript.ts for why this exists at all.
try {
for (const h of scanOmpSessionsHistory()) {
history.push({
sessionId: h.sessionId,
workingDir: h.workingDir,
sizeBytes: h.sizeBytes,
lastModified: h.lastModified,
firstPrompt: h.firstPrompt,
lastPrompt: h.lastPrompt,
mode: 'omp',
});
}
} catch {
// Best-effort, same as the claude scan above.
}
// Mux process stats (best-effort; guard against mocks lacking the method).
let mux: MuxStatInput[] = [];
try {
+11
View File
@@ -694,6 +694,17 @@ export function registerSystemRoutes(
};
});
// ========== OMP ==========
app.get('/api/omp/status', async () => {
const { isOmpAvailable, resolveOmpDir, getOmpCliVersion } = await import('../../utils/omp-cli-resolver.js');
return {
available: isOmpAvailable(),
path: resolveOmpDir(),
version: getOmpCliVersion(),
};
});
// ═══════════════════════════════════════════════════════════════
// State & Lifecycle (cleanup, lifecycle log, stats)
// ═══════════════════════════════════════════════════════════════
+30 -4
View File
@@ -141,6 +141,7 @@ const ALLOWED_ENV_PREFIXES = [
// 34-provider-key problem in a new shape, and the answer is the same one.
'DSH_',
'DEEPSEEK_',
'OMP_',
];
/**
@@ -180,7 +181,7 @@ const safeEnvOverridesSchema = z
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_* keys and CLAUDE_CONFIG_DIR are allowed.',
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_* keys and CLAUDE_CONFIG_DIR are allowed.',
}
);
@@ -345,6 +346,25 @@ const GrokConfigSchema = z
})
.optional();
/**
* Schema for OMP CLI-specific configuration.
*/
const OmpConfigSchema = z
.object({
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._-]+$/)
.optional(),
continueSession: z.boolean().optional(),
})
.optional();
/**
* Schema for DeepSeek Harness (`dsh`)-specific configuration.
*
@@ -440,7 +460,9 @@ const parentSessionIdSchema = z.string().max(100).optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(),
mode: z
.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp'])
.optional(),
name: z.string().max(100).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema,
@@ -458,6 +480,7 @@ export const CreateSessionSchema = z.object({
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
deepSeekConfig: DeepSeekConfigSchema,
ompConfig: OmpConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
@@ -869,7 +892,9 @@ export const QuickStartSchema = z.object({
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']).optional(),
mode: z
.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp'])
.optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
@@ -877,6 +902,7 @@ export const QuickStartSchema = z.object({
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
deepSeekConfig: DeepSeekConfigSchema,
ompConfig: OmpConfigSchema,
envOverrides: safeEnvOverridesSchema,
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
effort: effortLevelSchema,
@@ -1410,7 +1436,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek']),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
+4
View File
@@ -1446,6 +1446,7 @@ export class WebServer extends EventEmitter {
{ isPiAvailable },
{ isGrokAvailable },
{ isDeepSeekRunnable, isDeepSeekAvailable },
{ isOmpAvailable },
{ isCloudflaredAvailable },
{ isGitAvailable },
] = await Promise.all([
@@ -1457,6 +1458,7 @@ export class WebServer extends EventEmitter {
import('../utils/pi-cli-resolver.js'),
import('../utils/grok-cli-resolver.js'),
import('../utils/deepseek-cli-resolver.js'),
import('../utils/omp-cli-resolver.js'),
import('../utils/cloudflared-resolver.js'),
import('../git-clone.js'),
]);
@@ -1475,6 +1477,7 @@ export class WebServer extends EventEmitter {
// profile is offered the fix rather than a greyed-out entry.
deepseek: isDeepSeekRunnable(),
deepseekBinary: isDeepSeekAvailable(),
omp: isOmpAvailable(),
cloudflared: isCloudflaredAvailable(),
// Not a run mode: the Add Case → Clone tab is an offer this box cannot
// keep without git (issue #236), same reasoning as cloudflared above.
@@ -2783,6 +2786,7 @@ export class WebServer extends EventEmitter {
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined,
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
+1 -1
View File
@@ -401,7 +401,7 @@ describe('Session Manager unified list', () => {
const [historyRecord, , historyOptions] = app._buildHistoryItem.mock.calls[1];
expect(historyRecord).toMatchObject({ sessionId: 'conv-uuid-1', sizeBytes: 2048, firstPrompt: 'old prompt' });
historyOptions.onActivate();
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old');
expect(app.resumeHistorySession).toHaveBeenCalledWith('conv-uuid-1', '/repo/old', undefined, undefined);
});
it('surfaces an error message instead of an empty list when the endpoint fails', async () => {
+26
View File
@@ -329,6 +329,32 @@ describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencod
expect(mounts).toEqual([]);
expect(seedCopies).toEqual([]);
});
it('omp: shares sessions/ RW (host-side history/resume reads), seeds config files only', () => {
mkdirSync(join(home, '.omp', 'agent', 'sessions'), { recursive: true });
writeFileSync(join(home, '.omp', 'agent', 'config.yml'), '');
writeFileSync(join(home, '.omp', 'agent', 'mcp.json'), '{}');
writeFileSync(join(home, '.omp', 'agent', 'models.yml'), '');
writeFileSync(join(home, '.omp', 'agent', 'settings.yml'), '');
// Regenerable local state that must NOT be seeded (mirrors the pi/grok exclusions).
writeFileSync(join(home, '.omp', 'agent', 'agent.db'), '');
mkdirSync(join(home, '.omp', 'agent', 'terminal-sessions'), { recursive: true });
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
expect(mounts).toContainEqual({
src: join(home, '.omp', 'agent', 'sessions'),
dst: '/home/agent/.omp/agent/sessions',
});
const dests = seedCopies.map((s) => s.to);
expect(dests).toContain('/home/agent/.omp/agent/config.yml');
expect(dests).toContain('/home/agent/.omp/agent/mcp.json');
expect(dests).toContain('/home/agent/.omp/agent/models.yml');
expect(dests).toContain('/home/agent/.omp/agent/settings.yml');
expect(dests).not.toContain('/home/agent/.omp/agent/agent.db');
expect(mounts.some((m) => m.dst === '/home/agent/.omp/agent/terminal-sessions')).toBe(false);
// seed copies of individual files are NOT recursive
expect(seedCopies.filter((s) => s.to.startsWith('/home/agent/.omp')).every((s) => !s.recursive)).toBe(true);
});
});
describe('resolveDockerClaudeArtifacts (isolated claude state)', () => {
+2 -1
View File
@@ -433,6 +433,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
'pi',
'grok',
'deepseek',
'omp',
'shell',
]);
});
@@ -447,7 +448,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
src.indexOf('];', src.indexOf('const MOBILE_OVERVIEW_RUN_MODES')) + 2
);
const offered = [...modesBlock.matchAll(/mode: '([^']+)'/g)].map((m) => m[1]);
expect(offered).toContain('antigravity');
expect(offered).toContain('omp');
const fn = src.slice(src.indexOf('_buildMobileOverviewRunMenu() {'));
const gate = fn.slice(0, fn.indexOf('const header'));
expect(gate).toContain('isCliAvailable');
+1
View File
@@ -86,6 +86,7 @@ export function createMockRouteContext(options?: {
getSession: vi.fn(),
setSession: vi.fn(),
removeSession: vi.fn(),
demoteOrRemoveSession: vi.fn(() => 'removed' as const),
getSettings: vi.fn(() => ({})),
setSettings: vi.fn(),
getRalphLoopState: vi.fn(() => ({})),
+132
View File
@@ -0,0 +1,132 @@
/**
* @fileoverview Tests for the OMP CLI resolver wrapper.
*
* OMP is a resolver with a version probe: `omp` is a short binary name, so a
* resolved path is only accepted once `omp --version` prints an `omp/<semver>`
* string (e.g. `omp/17.4.0`). The probe EXECUTES the candidate, which is
* exactly why it must never run under vitest — the hermeticity test below pins
* that gate with a real executable fixture that would make the test fail
* loudly if the gate were deleted again.
*/
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createOmpResolverForTest } from '../src/utils/omp-cli-resolver.js';
import {
cliResolveRetryDelayMs,
createProductionCliResolverHost,
type CliResolverHost,
} from '../src/utils/cli-executable-resolver.js';
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function createHost(
options: {
processPathResult?: string | null;
loginShellResults?: Array<string | null>;
existingPaths?: string[];
} = {}
): CliResolverHost {
const loginShellResults = [...(options.loginShellResults ?? [])];
const existingPaths = new Set(options.existingPaths ?? []);
return {
processPath: '/service/bin',
shellPath: '/bin/zsh',
shellArgs: ['-l'],
findOnProcessPath: () => options.processPathResult ?? null,
findInLoginShell: () => loginShellResults.shift() ?? null,
exists: (path) => existingPaths.has(path),
};
}
describe('OMP CLI resolver', () => {
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
const binaryPath = '/service/bin/omp';
const probe = vi.fn(() => '17.4.0');
const resolver = createOmpResolverForTest(
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
probe
);
expect(resolver.resolve()).toMatchObject({
binaryPath,
directory: '/service/bin',
source: 'process-path',
metadata: '17.4.0',
});
expect(probe).toHaveBeenCalledWith(binaryPath);
});
it('rejects a candidate the probe refuses and falls through to a later one', () => {
// An unrelated `omp` on the service PATH (probe returns null) must not mask
// the real coding agent found by the login shell.
const impostor = '/service/bin/omp';
const genuine = '/login-shell/bin/omp';
const probe = vi.fn((binPath: string) => (binPath === genuine ? '17.4.0' : null));
const resolver = createOmpResolverForTest(
createHost({
processPathResult: impostor,
loginShellResults: [genuine],
existingPaths: [impostor, genuine],
}),
probe
);
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '17.4.0' });
});
it('negative-caches a miss and retries only after the backoff elapses', () => {
const binaryPath = '/late/bin/omp';
let now = 0;
const probe = vi.fn(() => '17.4.0');
const resolver = createOmpResolverForTest(
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
probe,
() => now
);
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
expect(probe).not.toHaveBeenCalled();
now = cliResolveRetryDelayMs(1);
expect(resolver.resolve()?.metadata).toBe('17.4.0');
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
});
it('never executes an omp candidate under vitest (the ambient probe is VITEST-gated)', () => {
// A REAL executable fixture that prints a valid version. If the guard in
// probeOmpVersion is ever removed again, the probe runs this script, the
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
// behavior rather than by source text. (The suites must never execute
// whatever `omp` binary the machine running them happens to carry.)
const root = mkdtempSync(join(tmpdir(), 'codeman-omp-vitest-gate-'));
temporaryDirectories.push(root);
const binaryPath = join(root, 'omp');
writeFileSync(binaryPath, '#!/bin/sh\necho omp/0.99.0\n');
chmodSync(binaryPath, 0o755);
const hostOptions = {
processPath: root,
shellPath: '/bin/bash',
shellArgs: ['-i', '-l'] as string[],
runCommand: () => '',
isExecutableFile: (path: string) => path === binaryPath,
};
// Default (ambient) probe: the candidate is found but never executed, so
// the VITEST gate reports it unusable and resolution misses.
const gated = createOmpResolverForTest(createProductionCliResolverHost(hostOptions));
expect(gated.resolve()).toBeNull();
// Control: identical setup with an injected probe resolves, proving the
// null above comes from the gate, not from the fixture or the host.
const control = createOmpResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* @fileoverview Pins the "Run OMP always resumes" bug found live 2026-08-27,
* and its follow-on fix for the sibling-aliasing bug found in upstream PR
* review (Ark0N/Codeman#353).
*
* Session._pinOmpRespawnId() resolves-and-pins the newest on-disk omp
* conversation as a side effect on `this._ompConfig`. That is correct ONLY
* immediately before an ACTUAL respawn (a confirmed-dead pane, or a genuine
* remote reattach) — never while merely building options that might not
* lead to one. It used to run eagerly inside `_buildRespawnPaneOptions()`,
* which startInteractive() calls unconditionally (including for a genuinely
* brand-new session, and for a boot-recovery reattach to a pane that turns
* out to still be alive): a fresh "Run OMP" click in a working directory
* with any prior omp history silently launched `--resume <old-id>` instead
* of a clean `omp` invocation, and — with two omp tabs in the same case dir
* — a live pane's `_ompConfig`/`claudeSessionId` could get mis-pinned to
* whichever sibling's file happened to be newest on disk, even though
* nothing was actually being respawned. Resolution now happens only inside
* `_pinOmpRespawnId()`, called by a caller that has already confirmed a
* real respawn is happening.
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
import type { MuxSession } from '../src/types.js';
describe('OMP: fresh session vs. reattach must not share resumeSessionId resolution', () => {
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-resume-test');
const sessions: Session[] = [];
afterEach(() => {
for (const s of sessions.splice(0)) s.stop();
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
});
function seedOmpSessionFile(id: string) {
mkdirSync(workingDir, { recursive: true });
mkdirSync(sessionDir, { recursive: true });
// resolveAndClaimOmpSessionId() verifies the file's own header (not just
// the filename), mirroring the real `omp` session-file shape — the
// header's `cwd` must match `workingDir` for the candidate to count.
const header = `${JSON.stringify({ type: 'session', id, cwd: workingDir })}\n`;
writeFileSync(join(sessionDir, `2026-08-27T17-31-08-001Z_${id}.jsonl`), header);
}
it('a brand-new session (no prior mux session) never inherits an on-disk conversation', async () => {
seedOmpSessionFile('old-conversation-id');
const session = new Session({
workingDir,
mode: 'omp',
mux: new TmuxManager(),
useMux: true,
});
sessions.push(session);
await session.startInteractive();
const state = session.toState();
expect(state.ompConfig).toBeUndefined();
expect(session.claudeSessionId).toBe(session.id);
});
it('a plain reattach to an existing mux session (pane still alive) does NOT pin', async () => {
// Regression for the sibling-aliasing bug: pinning must never be a side
// effect of merely building respawn options for a pane that might still
// be alive (isPaneDead is unconditionally false under IS_TEST_MODE,
// which is what a real "just reattaching, nothing died" boot recovery
// looks like from Session's perspective).
seedOmpSessionFile('sibling-conversation-id');
const muxSession: MuxSession = {
sessionId: 'placeholder',
muxName: 'codeman-deadbeef',
pid: 1,
createdAt: Date.now(),
workingDir,
mode: 'omp',
attached: false,
};
const session = new Session({
workingDir,
mode: 'omp',
mux: new TmuxManager(),
useMux: true,
muxSession,
});
sessions.push(session);
await session.startInteractive();
const state = session.toState();
expect(state.ompConfig?.resumeSessionId).toBeUndefined();
expect(session.claudeSessionId).toBe(session.id);
});
it('_pinOmpRespawnId() resolves and pins the real id once a respawn is confirmed', () => {
seedOmpSessionFile('real-omp-uuid');
const muxSession: MuxSession = {
sessionId: 'placeholder',
muxName: 'codeman-deadbeef',
pid: 1,
createdAt: Date.now(),
workingDir,
mode: 'omp',
attached: false,
};
const session = new Session({
workingDir,
mode: 'omp',
mux: new TmuxManager(),
useMux: true,
muxSession,
});
sessions.push(session);
(session as unknown as { _pinOmpRespawnId(): void })._pinOmpRespawnId();
expect(session.toState().ompConfig?.resumeSessionId).toBe('real-omp-uuid');
expect(session.claudeSessionId).toBe('real-omp-uuid');
});
});
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
import { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
describe('OMP mode schemas', () => {
it('accepts OMP session creation config', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'omp',
ompConfig: {
model: 'crof/glm-5.2',
},
});
expect(parsed.mode).toBe('omp');
expect(parsed.ompConfig).toEqual({
model: 'crof/glm-5.2',
});
});
it('accepts OMP quick-start config', () => {
const parsed = QuickStartSchema.parse({
caseName: 'omp-case',
mode: 'omp',
ompConfig: {
resumeSessionId: 'session-1234abcd',
},
});
expect(parsed.mode).toBe('omp');
expect(parsed.ompConfig?.resumeSessionId).toBe('session-1234abcd');
});
it('rejects unsafe OMP model strings', () => {
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'omp',
ompConfig: { model: 'omp; rm -rf /' },
})
).toThrow();
});
it('allows OMP_* env overrides and still rejects unknown prefixes', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'omp',
envOverrides: { OMP_PROFILE: 'work' },
});
expect(parsed.envOverrides).toEqual({ OMP_PROFILE: 'work' });
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
envOverrides: { RANDOM_PREFIX_KEY: 'x' },
})
).toThrow();
});
});
describe('OMP spawn command', () => {
it('builds a bare omp command when no config is sent', () => {
const cmd = buildSpawnCommand({ mode: 'omp', sessionId: 'abc12345' });
expect(cmd).toBe('omp');
});
it('passes --model and --resume, and drops unsafe ids', () => {
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { model: 'crof/glm-5.2', resumeSessionId: 'session-99' },
})
).toBe('omp --model crof/glm-5.2 --resume session-99');
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { resumeSessionId: 'x; rm -rf /' },
})
).toBe('omp');
});
it('continues the most recent session when no explicit resume id is given', () => {
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { continueSession: true },
})
).toBe('omp --continue');
});
it('prefers an explicit --resume id over --continue', () => {
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { resumeSessionId: 'session-99', continueSession: true },
})
).toBe('omp --resume session-99');
});
it('drops unsafe model strings from the spawn command', () => {
expect(
buildSpawnCommand({
mode: 'omp',
sessionId: 'abc12345',
ompConfig: { model: 'a`b' },
})
).toBe('omp');
});
});
describe('OMP mode gates', () => {
it('is an external CLI mode (readiness/ralph/respawn gating)', () => {
expect(isExternalCliMode('omp')).toBe(true);
});
it('is NOT an alt-screen strip mode (unverified TUI, like opencode/antigravity)', () => {
expect(isAltScreenStripMode('omp')).toBe(false);
});
it('has docker/remote default commands', () => {
expect(defaultDockerCommandForMode('omp')).toBe('exec omp');
// Routed through an interactive login shell so per-user PATH entries resolve —
// same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
expect(defaultRemoteCommandForMode('omp')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'omp\'');
});
});
describe('OMP multi-user clamp: the env-var half', () => {
// Unlike DeepSeek, omp has no permission FLAG or CONFIG for the clamp to
// gate (buildOmpCommand() only ever emits --model/--resume/--continue), so
// the only privilege surface is the two credential-resolution env vars the
// OMP_* prefix admits.
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
});
it('strips OMP_AUTH_BROKER_URL and OMP_AUTH_BROKER_TOKEN, leaving unrelated overrides alone', async () => {
const out = await _clampEnvOverridesForOwner('nobody', {
OMP_AUTH_BROKER_URL: 'https://attacker.example/broker',
OMP_AUTH_BROKER_TOKEN: 'stolen-token',
OMP_PROFILE: 'default',
});
expect(out).toEqual({ OMP_PROFILE: 'default' });
});
it('is a no-op in single-user mode', async () => {
delete process.env.CODEMAN_MULTIUSER;
const input = { OMP_AUTH_BROKER_URL: 'https://attacker.example/broker' };
expect(await _clampEnvOverridesForOwner(undefined, input)).toBe(input);
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* @fileoverview Tests for OMP session-id resolution from disk.
*
* Pins the home-relative directory mangling bug found 2026-08-27: omp
* collapses a home-relative workingDir to its home-relative remainder BEFORE
* dash-replacing (`/home/user/dev/foo` -> `-dev-foo`), unlike Claude Code's
* `~/.claude/projects/*` convention (`-home-user-dev-foo`) this module was
* originally written to mirror. Getting this wrong doesn't throw — it just
* makes findLatestOmpSessionId() silently return null for every case under
* $HOME (virtually all real Codeman cases), so continuation pinning quietly
* degraded to omp's own ambiguous `--continue` while appearing to work in
* manual testing done entirely under /tmp (which sits outside $HOME and was
* mangled correctly by coincidence).
*
* test/setup.ts gives this file its own temp $HOME, so homedir() below is
* already sandboxed — writing real files under it is safe and exercises the
* exact home-relative path the bug hid behind.
*/
import { mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { findLatestOmpSessionId, mangleOmpWorkingDir } from '../src/utils/omp-session-resolver.js';
import { resolveOmpConfigForCreate } from '../src/web/routes/session-routes.js';
describe('mangleOmpWorkingDir', () => {
it('strips the home prefix before dash-replacing a home-relative path', () => {
const home = homedir();
expect(mangleOmpWorkingDir(join(home, 'codeman-cases', 'testcase'))).toBe('-codeman-cases-testcase');
});
it('dash-replaces a path outside $HOME as-is', () => {
expect(mangleOmpWorkingDir('/tmp/omp-verify-case')).toBe('-tmp-omp-verify-case');
});
it('treats workingDir === home as the empty remainder', () => {
expect(mangleOmpWorkingDir(homedir())).toBe('');
});
it('does not false-positive on a sibling directory sharing a prefix with $HOME', () => {
const sibling = `${homedir()}-other/dev/foo`;
expect(mangleOmpWorkingDir(sibling)).toBe(sibling.replace(/\//g, '-'));
});
});
describe('findLatestOmpSessionId', () => {
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-testcase');
afterEach(() => {
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
});
it('finds the newest session file under a home-relative workingDir', () => {
const workingDir = join(homedir(), 'codeman-cases', 'testcase');
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, '2026-08-27T17-15-57-989Z_older-id.jsonl'), '{}');
const newer = join(sessionDir, '2026-08-27T17-31-08-001Z_newer-id.jsonl');
writeFileSync(newer, '{}');
// Force a deterministic mtime order regardless of filesystem timestamp resolution.
const now = Date.now() / 1000;
utimesSync(join(sessionDir, '2026-08-27T17-15-57-989Z_older-id.jsonl'), now, now);
utimesSync(newer, now + 1, now + 1);
expect(findLatestOmpSessionId(workingDir)).toBe('newer-id');
});
it('returns null when the mangled directory does not exist', () => {
expect(findLatestOmpSessionId(join(homedir(), 'never-launched'))).toBeNull();
});
});
describe('resolveOmpConfigForCreate', () => {
// The exact pipeline "resume this OMP row from the history list" drives:
// POST /api/sessions with mode:'omp' + ompConfig:{continueSession:true}
// must come back with resumeSessionId PINNED to the real omp transcript
// uuid, not left as the ambiguous continueSession flag alone. This was the
// one path flagged by review as having zero coverage despite being the
// exact mechanism the whole resolver module exists to serve.
const workingDir = join(homedir(), 'codeman-cases', 'resume-test');
const sessionDir = join(homedir(), '.omp', 'agent', 'sessions', '-codeman-cases-resume-test');
afterEach(() => {
rmSync(join(homedir(), '.omp'), { recursive: true, force: true });
});
it('pins resumeSessionId from disk when resuming with only continueSession set', () => {
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, '2026-08-27T17-31-08-001Z_real-omp-uuid.jsonl'), '{}');
const resolved = resolveOmpConfigForCreate('omp', workingDir, { continueSession: true });
expect(resolved).toEqual({ continueSession: true, resumeSessionId: 'real-omp-uuid' });
});
it('does not attempt resolution when resumeSessionId is already explicit', () => {
mkdirSync(sessionDir, { recursive: true });
writeFileSync(join(sessionDir, '2026-08-27T17-31-08-001Z_disk-uuid.jsonl'), '{}');
const resolved = resolveOmpConfigForCreate('omp', workingDir, {
continueSession: true,
resumeSessionId: 'already-pinned',
});
// Must return the caller's id unchanged, never overwrite it with whatever
// happens to be newest on disk.
expect(resolved).toEqual({ continueSession: true, resumeSessionId: 'already-pinned' });
});
it('leaves ompConfig unchanged when continueSession is not set', () => {
const resolved = resolveOmpConfigForCreate('omp', workingDir, {});
expect(resolved).toEqual({});
});
it('leaves ompConfig unchanged when nothing is on disk to resolve', () => {
const resolved = resolveOmpConfigForCreate('omp', join(homedir(), 'never-launched'), {
continueSession: true,
});
expect(resolved).toEqual({ continueSession: true });
});
it('returns undefined for a non-omp mode regardless of ompConfig', () => {
expect(resolveOmpConfigForCreate('claude', workingDir, { continueSession: true })).toBeUndefined();
});
it('returns undefined when ompConfig is undefined', () => {
expect(resolveOmpConfigForCreate('omp', workingDir, undefined)).toBeUndefined();
});
});
+32
View File
@@ -79,4 +79,36 @@ describe('header plan usage chip', () => {
expect(codexRow).not.toContain('5h');
expect(codexRow).toContain('7d');
});
it('drops the provider label when Claude is the only provider with limits', () => {
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({
fiveHour: { usedPercentage: 60, resetAt: 1000 },
sevenDay: { usedPercentage: 23, resetAt: 2000 },
});
expect(chip.innerHTML).toContain('class="pu-row"');
expect(chip.innerHTML).not.toContain('pu-provider');
expect(chip.innerHTML).not.toContain('Claude');
expect(chip.innerHTML).toContain('60%');
expect(chip.innerHTML).toContain('23%');
// The tooltip still names the provider — it has room, and the chip no longer does.
expect(chip.title).toContain('Claude plan usage');
});
it('drops the provider label when Codex is the only provider with limits', () => {
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({
codex: { fiveHour: { usedPercentage: 12, resetAt: 3000 } },
});
expect(chip.innerHTML).toContain('class="pu-row"');
expect(chip.innerHTML).not.toContain('pu-provider');
expect(chip.innerHTML).toContain('12%');
expect(chip.title).toContain('Codex plan usage');
});
});
+10
View File
@@ -20,6 +20,7 @@ import { isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js
import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js';
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.js';
@@ -66,6 +67,10 @@ vi.mock('../src/utils/deepseek-cli-resolver.js', () => ({
listDeepSeekProfiles: vi.fn(() => []),
resolveDefaultDeepSeekProfile: vi.fn(() => null),
}));
vi.mock('../src/utils/omp-cli-resolver.js', () => ({
isOmpAvailable: vi.fn(() => false),
resolveOmpDir: vi.fn(() => null),
}));
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
isCloudflaredAvailable: vi.fn(() => false),
resolveCloudflaredPath: vi.fn(() => null),
@@ -156,6 +161,9 @@ describe('WebServer.renderIndexHtml', () => {
vi.mocked(isAntigravityAvailable).mockReturnValue(false);
vi.mocked(isPiAvailable).mockReturnValue(true);
vi.mocked(isGrokAvailable).mockReturnValue(false);
vi.mocked(isDeepSeekAvailable).mockReturnValue(false);
vi.mocked(isDeepSeekRunnable).mockReturnValue(false);
vi.mocked(isOmpAvailable).mockReturnValue(true);
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
vi.mocked(isGitAvailable).mockReturnValue(true);
const { server } = makeServer({});
@@ -173,6 +181,7 @@ describe('WebServer.renderIndexHtml', () => {
grok: false,
deepseek: false,
deepseekBinary: false,
omp: true,
cloudflared: true,
git: true,
});
@@ -191,6 +200,7 @@ describe('WebServer.renderIndexHtml', () => {
isGrokAvailable,
isDeepSeekAvailable,
isDeepSeekRunnable,
isOmpAvailable,
isCloudflaredAvailable,
isGitAvailable,
]) {
+143
View File
@@ -0,0 +1,143 @@
/**
* @fileoverview Upstream review fix (Ark0N/Codeman#353, PR #3): resumeHistorySession()
* threads the row's own mode through session creation via a `modeConfigKey` map
* (opencode/pi/grok/omp → `continueSession: true`), then retires the old row via
* DELETE. codex/gemini/antigravity were missing from that map, so resuming one of
* their rows created a session with NO continuation while still deleting the row
* it came from — data loss dressed as a fix. The correction: only retire the row
* when the new session actually continues something.
*
* Loaded via `vm` against a stub CodemanApp, same harness as resume-name.test.ts.
* `fetch` is a shared mutable stub so each test can inspect exactly which requests
* fired without a real network/server.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi, beforeEach } from 'vitest';
/* eslint-disable @typescript-eslint/no-explicit-any */
/** The fetch the vm's shipping code calls; swapped per test (see beforeEach). */
let currentFetch: (...args: unknown[]) => unknown = () => {
throw new Error('fetch not stubbed for this test');
};
function loadTerminalUiPrototype(): Record<string, (...args: unknown[]) => unknown> {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: vi.fn(() => null) },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
fetch: (...args: unknown[]) => currentFetch(...args),
});
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
return (context as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
}
const proto = loadTerminalUiPrototype();
function makeApp() {
return {
terminal: { clear: vi.fn(), writeln: vi.fn(), focus: vi.fn() },
cases: [],
resumeHistorySession: proto.resumeHistorySession as (...args: unknown[]) => Promise<void>,
_closeFolderHistoryModal: vi.fn(),
_resolveResumeName: () => 'w1-case',
loadAppSettingsFromStorage: () => ({}),
getCaseSettings: () => ({}),
buildEnvOverrides: () => ({}),
getEffortSetting: () => undefined,
selectSession: vi.fn(async () => {}),
};
}
/** DELETE calls the fetch mock recorded. */
function deleteCalls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls
.filter(([, opts]: [string, { method?: string }]) => opts?.method === 'DELETE')
.map(([url]: [string]) => url);
}
/** POST /api/sessions body the fetch mock recorded. */
function createBody(fetchMock: ReturnType<typeof vi.fn>): any {
const call = fetchMock.mock.calls.find(([url]: [string]) => url === '/api/sessions');
return call ? JSON.parse((call[1] as { body: string }).body) : undefined;
}
function stubFetch(newSessionId: string): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn(async (url: string) => {
if (url === '/api/sessions') {
return { json: async () => ({ success: true, data: { session: { id: newSessionId } } }) };
}
return { json: async () => ({ success: true }) };
});
currentFetch = fetchMock;
return fetchMock;
}
describe('resumeHistorySession: row retirement is gated on actual continuation', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = stubFetch('new-session-id');
});
it.each(['codex', 'gemini', 'antigravity'])(
'does NOT retire the old row for %s (no continuation is wired for it)',
async (mode) => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode);
expect(createBody(fetchMock)).toMatchObject({ mode });
expect(createBody(fetchMock).codexConfig).toBeUndefined();
expect(createBody(fetchMock).geminiConfig).toBeUndefined();
expect(createBody(fetchMock).antigravityConfig).toBeUndefined();
expect(deleteCalls(fetchMock)).toEqual([]);
}
);
it.each([
['opencode', 'openCodeConfig'],
['pi', 'piConfig'],
['grok', 'grokConfig'],
['omp', 'ompConfig'],
])('retires the old row for %s (continueSession is wired via %s)', async (mode, configKey) => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode);
expect(createBody(fetchMock)[configKey]).toEqual({ continueSession: true });
expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']);
});
it('retires the old row for deepseek (resumeSession is wired)', async () => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', 'deepseek');
expect(createBody(fetchMock).deepSeekConfig).toEqual({ resumeSession: true });
expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']);
});
it('never retires a claude row (resumeSessionId is a claudeSessionId, not a Codeman row id)', async () => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'claude-uuid', '/repo', 'w1-repo', 'claude');
expect(createBody(fetchMock)).toMatchObject({ mode: 'claude', resumeSessionId: 'claude-uuid' });
expect(deleteCalls(fetchMock)).toEqual([]);
});
it('never retires when the new session id equals the old one (no-op resume)', async () => {
fetchMock = stubFetch('same-id');
const app = makeApp();
await app.resumeHistorySession.call(app, 'same-id', '/repo', 'w1-repo', 'omp');
expect(deleteCalls(fetchMock)).toEqual([]);
});
});
+30
View File
@@ -341,6 +341,36 @@ describe('session-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('removes a persisted-only session (not live) via the state store, without touching cleanupSession', async () => {
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({
id: 'ghost-session',
owner: undefined,
} as never);
const res = await harness.app.inject({
method: 'DELETE',
url: '/api/sessions/ghost-session',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx.store.demoteOrRemoveSession).toHaveBeenCalledWith('ghost-session');
expect(harness.ctx.cleanupSession).not.toHaveBeenCalled();
// Ark0N/Codeman#353 review: the persisted-only branch used to demote/remove
// with no broadcast, so other open tabs kept showing the retired row until
// their next unrelated fetch.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' });
});
it('404s a persisted-only session id the state store does not recognize either', async () => {
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce(null);
const res = await harness.app.inject({
method: 'DELETE',
url: '/api/sessions/truly-nonexistent',
});
expect(res.statusCode).toBe(404);
expect(harness.ctx.store.demoteOrRemoveSession).not.toHaveBeenCalled();
});
});
// ========== DELETE /api/sessions (delete all) ==========
+22 -2
View File
@@ -81,6 +81,16 @@ describe('run mode UI', () => {
expect(app.runMode).toBe('antigravity');
expect(runBtnLabel.textContent).toBe('Run AG');
});
it('accepts OMP mode from server sync and updates the run button label', async () => {
const { app, storage, runBtnLabel } = loadRunModeHarness();
storage.set('codeman_runMode', 'claude');
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'omp' }));
expect(app.runMode).toBe('omp');
expect(runBtnLabel.textContent).toBe('Run OMP');
});
});
describe('Run launch synchronization', () => {
@@ -367,12 +377,13 @@ describe('Codex quick start settings', () => {
'welcomeGeminiBtn',
'welcomePiBtn',
'welcomeGrokBtn',
'welcomeOmpBtn',
'welcomeTunnelBtn',
]) {
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
}
const modeBtns: Record<string, { style: { display: string } }> = {};
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'omp', 'shell']) {
modeBtns[mode] = { style: { display: 'PRISTINE' } };
}
const menu = {
@@ -405,6 +416,7 @@ describe('Codex quick start settings', () => {
antigravity: false,
pi: false,
grok: false,
omp: false,
cloudflared: false,
};
@@ -442,16 +454,23 @@ describe('Codex quick start settings', () => {
withAgy.app.applyWelcomeCliVisibility();
expect(withAgy.welcomeBtns.welcomeAntigravityBtn.style.display).toBe('flex');
expect(withAgy.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
// OMP is a first-class welcome action, gated on `omp` like the rest.
const withOmp = loadUi({ ...ALL_OFF, omp: true });
withOmp.app.applyWelcomeCliVisibility();
expect(withOmp.welcomeBtns.welcomeOmpBtn.style.display).toBe('flex');
expect(withOmp.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
});
it('gates every run mode in the dropdown, antigravity included, and never shell', () => {
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true });
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true, omp: true });
app._refreshRunModeAvailability(menu);
expect(modeBtns.claude.style.display).toBe('flex');
expect(modeBtns.antigravity.style.display).toBe('flex');
expect(modeBtns.opencode.style.display).toBe('none');
expect(modeBtns.codex.style.display).toBe('none');
expect(modeBtns.gemini.style.display).toBe('none');
expect(modeBtns.omp.style.display).toBe('flex');
// Shell needs no external CLI, and leaving it alone is what guarantees the
// menu is never empty on a box with nothing installed.
expect(modeBtns.shell.style.display).toBe('PRISTINE');
@@ -468,6 +487,7 @@ describe('Codex quick start settings', () => {
expect(offered).toContain('antigravity');
expect(offered).toContain('pi');
expect(offered).toContain('grok');
expect(offered).toContain('omp');
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));