Compare commits

..
Author SHA1 Message Date
Codeman maintainer 1e24817b51 chore: version packages 2026-09-02 10:49:36 +02:00
Ark0N f7cf15485e feat(models): offer Fable 5.1 in the model picker and task routing (#372)
Adds `claude-fable-5-1` to the App Settings model picker and the five task-routing selects, mirroring how Fable 5 is already offered: a base option with data-ctx="1" plus its [1m] companion row. No settings-ui.js logic change, since the cards and the 1M switch are built from those options.
2026-09-02 10:48:51 +02:00
Codeman maintainer 71ffbf18e4 chore: version packages 2026-09-01 21:55:00 +02:00
Codeman maintainer 826ddaa9aa build(docker): ship the Docker CLI in the Compose image, not the whole engine
docker/server.Dockerfile installed Debian's `docker.io` to get a client for the
socket mounted by Compose. That package is the full ENGINE: even with
--no-install-recommends it pulls 15 packages including containerd, runc, dmsetup
and iptables, none of which a container that only talks to a mounted socket can
use, and it ships Docker 20.10.24 (2023).

Copy the CLI and the buildx plugin from the official docker:29-cli image
instead. Measured on the same node:22-bookworm-slim base: 266 MB -> 108 MB, so
158 MB smaller with a current CLI (29.7.2) in place of a two-year-old one.

Three things verified rather than assumed, by building the real image and
running it:

- docker:cli is an ALPINE image, so copying a binary into this Debian one is
  only safe because the binaries are static Go builds (ldd: "Not a valid dynamic
  program"). In the built image, `docker --version`, `docker ps` and
  `docker build` all work against a mounted host socket as the unprivileged
  runtime user.
- buildx is copied on purpose. scripts/build-agent-image.mjs shells out to
  `docker build` and Codeman auto-builds the agent image on the first Docker
  case. Without the plugin that still works today — CLI 29 falls back to the
  classic builder, tested — but that builder is deprecated and will be dropped,
  so the plugin keeps the path supported.
- docker-compose is NOT copied: Codeman never shells out to it.

Pinned to the 29 major, matching how the base images here are pinned.
2026-09-01 21:54:56 +02:00
Codeman maintainer e2b72aafd7 chore: version packages 2026-09-01 11:32:50 +02:00
Codeman maintainer b15cc0eb1a fix(ui): keep the plan-usage chip's 5h slot when no session window is open
The header chip silently shrank from "5h 4% · 7d 52%" to a lone "7d 52%", which
reads as half the feature breaking rather than as an idle window.

Nothing was broken. Claude Code documents `rate_limits.five_hour` as "present
only while the API reports it and its resets_at has not passed", so between
5-hour session windows the key simply leaves the statusline payload. Codeman's
snapshot replaces the Claude half wholesale on every sample, so the segment
disappeared until usage opened a new window. Confirmed against a live 2.1.252
session by capturing real statusline payloads on an isolated tmux socket: the
boot render carries no `rate_limits` at all, and the first post-response render
carries both windows.

The slot now stays, with a dimmed em dash. Claude only: a missing CODEX bucket
means that plan has no such limit rather than an idle window, so those stay
omitted (pinned by the existing test). The placeholder can never stand alone
either — hasWindows() still gates the row, so a provider reporting nothing
renders nothing rather than a row of dashes. The tooltip says "5-hour limit: no
active session window" instead of dropping the line.

Verified in a real browser against a dev instance: the idle chip renders
"5h — · 7d 52%" with the dash at opacity 0.55 in --text-dim while the live
value keeps its green, and the chip holds its shape (100px idle vs 107px with
both windows).
2026-09-01 11:32:18 +02:00
Codeman maintainer 2a32b5064a Merge pull request #349 from opticon454/feature/docker-compose
Docker Compose deployment: Codeman runs in a container and spawns Docker cases
as SIBLING containers through the mounted host socket (Docker-outside-of-Docker).

Resolved the README conflict (master had grown to eight CLIs since the branch
was cut) and moved the Compose blurb out of the feature bullets into Quick
Start, next to the other ways of starting Codeman.

Three review findings from the PR discussion are fixed here rather than left
for a follow-up, because two of them are shipped-image problems:

- `.dockerignore` excluded `.env` only at the ROOT. A pattern is matched against
  the whole context-relative path, so `docker/.env` — which the deployment's own
  README tells the user to fill with CODEMAN_PASSWORD and provider API keys —
  was picked up by `COPY . .` and baked into the image at
  /opt/codeman/docker/.env. Verified in both directions against a real build
  context: with a canary secret in docker/.env, the unfixed ignore file lets
  /ctx/docker/.env through, and `**/.env` (plus `**/.env.*` and a negation for
  the checked-in .env.example) leaves only the example behind.
- `CODEMAN_CASES_PATH` moved the server's CASES_DIR but not the CLI's, which
  still hardcoded ~/codeman-cases, so `codeman skill install --case <name>`
  reported "Case not found" on exactly the deployment the override exists for.
  Both now resolve through config/cases-dir.ts. state-store.ts keeps its own
  literal on purpose: that one migrates the historical ~/claudeman-cases
  directory by name and is about the old default, not the active location.
- CLAUDE.md gained the Compose paragraph (the sibling-container inversion, the
  three env vars, the .dockerignore and root-owned-bind traps) and .dockerignore
  joins the documented list of files that genuinely belong in the repo root.

The PR's `mode === 'claude'` guard on dockerResumeId is an unrelated master bug
fix riding along: appendResumeFlag() maps a resume id onto codex/gemini/pi/grok/
deepseek/omp/antigravity and RESUME_ID_SAFE accepts a UUID, so a Docker case's
lastClaudeSessionId was handed to every non-claude CLI.

Full gate green in a merge worktree: 6360 tests, lint, format, frontend syntax,
public assets, lockfile.
2026-09-01 11:32:03 +02:00
Codeman maintainer 0da0c8219d chore: version packages
1.24.2. Also corrects two numbers in the CLAUDE.md trust-dialog paragraph that
was written while the fix was still uncommitted: the keystroke cap is 6, not 3,
and the scan now schedules its own follow-up read rather than waiting on PTY
output that a static dialog never produces.
2026-09-01 02:31:31 +02:00
Codeman maintainer aaa93d4252 fix(session): answer Claude Code 2.1.252's reversed folder-trust dialog
Every claude session in a directory claude had not seen before died about six
seconds after it started (`Pane is dead (status 1)`), before the agent drew a
composer. Reproduced on a fresh case and measured.

Claude Code 2.1.252 rewrote the dialog. It used to be

  ❯ 1. Yes, I trust this folder
    2. No, exit

and is now unnumbered, reversed, and highlights the option that quits:

  ❯ No, exit
    Yes, I trust this folder

Detection still worked (the confirm affordance carries the match once the
numbered option text is gone), so the failure was entirely in the answer: the
auto-accept pressed Enter on the highlighted default, which is now exit.

trustDialogNextKey() reads the ❯ marker off the rendered pane and returns ONE
keystroke at a time: an arrow while the cursor is on the wrong option, Enter
only once the screen shows it on the trust option, and null for a frame that
does not say. Both layouts are handled, and which way the trust option lies is
read from the frame rather than assumed, so a further reordering costs a
repaint instead of a session. The last marked option wins, because the
direct-PTY fallback reads an append-only buffer where an older frame must not
out-vote the freshest one.

Two things only a live pane showed:

- The scan ran solely from the PTY onData handler. The arrow that moves the
  cursor is the last output the pane produces, so the first fix parked every
  session with the cursor sitting on the right option and no Enter ever sent.
  It now schedules its own follow-up read (_trustDialogTimer, cleared in
  _clearAllTimers()), offset past the scan throttle so the chain cannot break
  on a boundary.
- The keystroke cap goes 3 -> 6, since answering is no longer one press.

The bundled codeman skill had the same blind \r as its bounded fallback, so
preamble 1.21.0 replaces it with _trust_key/_accept_trust: read
terminal?full=1, steer onto the trust option, re-read, then confirm. Those
keystrokes go out under their own clientId, because input sequence numbers are
monotonic per client and spending prompt numbers on dialog keys would make the
next send-and-wait look like a stale duplicate and vanish while reporting
success. The readiness recipes in docs/extending-codeman.md,
docs/api-reference.md and the skill's own reference carry the corrected answer,
plus a symptom-table entry for a worker whose pane is dead seconds after spawn.

Verified live on an isolated instance (own data dir and tmux socket): fresh
case -> arrow at 5 s -> Enter at 7 s -> composer, with hasTrustDialogAccepted
recorded. With the server-side auto-accept disabled in a throwaway copy, the
skill's fallback cleared a genuinely parked dialog in 1.1 s and spawn_worker
took a brand-new case to a live composer in 7.2 s; spawn_workers + sendwait +
last_text then ran end to end.
2026-09-01 02:31:26 +02:00
Codeman maintainer 3518af3a9f docs: correct CLAUDE.md drift and document four undocumented subsystems
Audit of CLAUDE.md against the tree. Verified still accurate: the 31-module
frontend load order (matches index.html exactly), SSE registry parity at
157 = 157 (confirmed by running the parity test), config/ 21 files, types/ 22
domain files, 136 mobile device profiles, the version line, and every Quick
Reference command.

Drift corrected: 24 route modules to 25, ~220 handlers to ~227, system-routes
51 to 56, app.js ~5K lines to ~6.7K and 30 modules to 31, install.sh 92KB to
104KB. Completed the CLI resolver inventory, which was missing
deepseek-cli-resolver and omp-cli-resolver even though both modes are
documented, and named the shared cli-executable-resolver lookup chain.

Filled the gaps found by sweeping every src module against the file:

- Owner tab layouts (COD-359) had 6 source modules, 7 test files, 2 routes, an
  SSE event and a state.json key, with zero mentions anywhere in CLAUDE.md or
  docs/. The paragraph records the four things a reader would otherwise get
  wrong: it is backend-only as of 1.24.1 with no frontend consumer, the service
  is the sole mutation boundary, it projects onto PUT /api/session-order rather
  than replacing it, and reconciliation is gated on a successful restore.
- codeman doctor and codeman users were undocumented top-level CLI commands.
- Four subsystems whose invariants lived only in their @fileoverview:
  the workspace-trust dialog recognizer, proc-tree's bounded walk (the
  2026-07-30 incident that took a machine down), deepseek-web-server (one
  child process, deliberately not a shell session), and the Files panel
  search matcher (globs are never compiled to a RegExp).

Also fixes a stale "156 event types" comment in constants.js (actual: 157) and
a contradiction in AGENTS.md, which still carried the retired "never run the
full suite inside a managed tmux session" rule against CLAUDE.md's current
"npm test is the gate and is safe to run bare".

Note: the trust-dialog paragraph documents trustDialogNextKey(), which is part
of a sibling session's in-flight fix for the Claude Code 2.1.252 layout change
(unnumbered, reversed options with "No, exit" highlighted, so a blind carriage
return picks exit and kills the pane). That fix was uncommitted in the shared
tree when this landed, so the doc leads the code until it is committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NMN8UuvdBim3iM87reuQ9Z
2026-09-01 02:10:09 +02:00
Codeman maintainer e5c5d890aa chore: version packages 2026-08-31 22:34:57 +02:00
Codeman maintainer d5b5f8f618 fix(docker): make the dsh profile install survive pnpm's build-script gate
Follow-up to #350, which fixed the actual blocker (issue #352): `dsh plugin` is
a thin forwarder that `spawnSync`s a literal `pnpm` with no npm fallback, so an
image without pnpm dies at exit 127 and takes the whole build with it.

That PR also pinned an allowlist of the two packages whose lifecycle scripts
pnpm blocked at the time. Replace it with a policy that cannot go stale: pnpm,
unlike npm, refuses dependency build scripts by default and FAILS the install
over it (`ERR_PNPM_IGNORED_BUILDS`, exit 1, measured on pnpm 11.24), and the
names to allow move between rebuilds because `@deepseek-harness-tui/dsh-tui` is
resolved by dist-tag, not pinned: 0.9.3 pulled `@google/genai` (whose script is
a literal `preinstall: no-op`), 0.10.0-beta.x does not. An allowlist of two
names would have let the next tree break the build the same way. Allowing them
wholesale is also the exposure this image already accepts three layers up,
where `npm install -g` runs the install scripts of every transitive dep of the
five CLIs above with no gate at all.

Also correct a comment in the `/api/deepseek/install-profile` route that
asserted the opposite of what #352 proved ("dsh bundles its own package
manager, so no system pnpm is required"). The route's behavior is already
right: dsh's own "pnpm not found on PATH" stderr reaches the caller as the
OPERATION_FAILED detail, so the UI's "add a terminal profile" button names the
fix. Documented the prerequisite in docs/deepseek-integration.md, and taught
the docker-cases image smoke test about `dsh`/`omp` plus the profile check that
`dsh --version` does NOT cover.
2026-08-31 22:26:09 +02:00
Codeman maintainer 7762809202 Merge pull request #350 from opticon454/bugfix/dsh-pnpm
fix(docker): install pnpm for DeepSeek profile
2026-08-31 22:22:30 +02:00
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
Codeman maintainer 23fae0c5af chore: version packages
Codex plan usage in the header chip (#346), a visible inline rename in
the session sidebar (#345), and the install.sh Tailscale re-run fix plus
the README network-access prompt description.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:25:32 +02:00
Ark0N e4699159e9 Merge pull request #346 from JackStuart/codex/show-codex-usage-limits
feat(web): show Codex plan usage in header
2026-08-28 00:14:54 +02:00
Ark0N da085f5f7f Merge pull request #345 from fibr/fix/sidebar-inline-rename
fix(ui): show inline rename text in session sidebar
2026-08-28 00:14:48 +02:00
Codeman maintainer 23e32b22d5 docs(readme): describe the actual three-way network-access prompt
The installer bullet still described a two-way choice with 0.0.0.0 as "the
default", which predates the Tailscale option. The prompt has offered three
choices for a while (Tailscale / any device on your network / this machine
only), and the default is computed from what is already on the machine rather
than being fixed at 0.0.0.0.

Now states all three options, that the Tailscale one is a loopback bind
fronted by `tailscale serve` with the tailnet as the login, and how the
highlighted default is chosen. Line 220 already documented the Tailscale
option correctly; this was the only stale spot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 00:02:33 +02:00
Devvyn 26b4ffbb0f fix(docker): install pnpm for DeepSeek profile 2026-08-27 20:47:57 +08:00
Devvyn e2179bd530 chore(docker): remove local handover references 2026-08-27 19:42:37 +08:00
Devvyn b85f7659b7 feat(docker): add Compose deployment support 2026-08-27 19:38:38 +08: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
Codeman maintainer 7dfb4acf24 fix(install): offer Tailscale setup on re-run instead of losing it to a failed build
The network-access prompt, where Tailscale serve is configured, runs AFTER
the build step. A build failure therefore exits before the question is ever
asked, and a user who then finishes the build by hand (rather than re-running
install.sh) ends up with a healthy loopback-only Codeman, a connected
Tailscale, and no serve mapping — with nothing anywhere pointing at
`install.sh tailscale`, the command that fixes it. Reported from a fresh
Ubuntu 24 install that died on the node-pty compile.

- maybe_offer_tailscale_repair(): on the update/re-run path, detect exactly
  that state (loopback bind + tailscale Running + no serve mapping fronting
  Codeman) and offer the retrofit. Silent for a deliberate non-loopback bind,
  silent once a mapping exists, silent when tailscale is absent, and prints
  the command instead of prompting when non-interactive. Returns 0 even when
  setup fails so it can never abort an update.
- print_security_notice(): the loopback branch now names
  `install.sh tailscale` when Tailscale is installed on the box, rather than
  the generic "tailscale serve / cloudflared tunnel" advice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:29:15 +02:00
Codeman maintainer d3f851a5e5 chore: version packages
install.sh installs a build toolchain on Linux (node-pty has no Linux
prebuild, so a stock Ubuntu 24 server died inside node-gyp with
"not found: make"), plus review hardening for #339: the write-queue
reset paths now release the one-chunk-in-flight gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:04:44 +02:00
Ark0N 5f8d4de443 Merge pull request #340 from aakhter/pr/cod-341-file-viewer-search
feat(file-viewer): COD-341 search the full workspace
2026-08-26 18:03:21 +02:00
Ark0N 00b32ad2b8 Merge pull request #339 from dignfei/fix/terminal-live-write-backpressure
fix(terminal): bound live xterm backpressure
2026-08-26 18:03:14 +02:00
Jack Stuart b00ab3ceea feat(web): show Codex plan usage in header 2026-08-26 18:43:52 +08:00
Sergei Lupashin 134e200aec fix(ui): show inline rename text in session sidebar 2026-08-25 19:41:48 +02:00
d fei 7c62b16e5f fix(terminal): bound live xterm backpressure 2026-08-24 19:06:50 +08:00
Aamer Akhter d15d979a33 fix: COD-341 correct changeset package name 2026-08-23 22:55:10 -04:00
Aamer Akhter 858b15e3f5 chore: COD-341 add File Viewer search release note 2026-08-23 22:46:58 -04:00
Aamer Akhter c14171b534 fix(file-viewer): COD-341 finalize deferred navigation 2026-08-23 22:42:10 -04:00
Aamer Akhter acd9ffedc8 fix(file-viewer): COD-341 complete search transitions 2026-08-23 22:27:26 -04:00
Aamer Akhter 921933775b test(file-viewer): COD-341 execute session lifecycle path 2026-08-23 22:08:29 -04:00
Aamer Akhter f6a1f06633 fix(file-viewer): COD-341 synchronize session lifecycle 2026-08-23 21:58:45 -04:00
Aamer Akhter dab8e6643c fix(file-viewer): COD-341 deduplicate normal tree loads 2026-08-23 21:40:10 -04:00
Aamer Akhter 3af36f7c34 fix(file-viewer): COD-341 preserve search row layout 2026-08-23 21:23:17 -04:00
Aamer Akhter 49797e37dd fix(file-viewer): COD-341 gate stale search results 2026-08-23 21:09:52 -04:00
Aamer Akhter c614331d60 feat(file-viewer): COD-341 add server-side search 2026-08-23 21:01:39 -04:00
92 changed files with 7355 additions and 408 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.agents
.claude
.codex
# `**/` matters: a .dockerignore pattern is matched against the WHOLE
# context-relative path, so a bare `.env` excludes ONLY the root file and
# `COPY . .` would bake docker/.env -- CODEMAN_PASSWORD and any provider API
# keys -- into the published image at /opt/codeman/docker/.env (verified).
**/.env
**/.env.*
!**/.env.example
node_modules
dist
coverage
out
test-results
tmp
*.log
+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
+2 -1
View File
@@ -2,7 +2,8 @@
Canonical agent/contributor guidance for this repository lives in [CLAUDE.md](CLAUDE.md) —
project structure, build/test/lint commands, code style, testing safety rules
(never run the full suite inside a managed tmux session), security notes, and
(`npm test` is the CI gate and is safe to run bare; the three excluded suites
have their own runners), security notes, and
the deployment workflow are all maintained there. Please read it before making
changes, and keep it the single source of truth rather than duplicating
sections here.
+184
View File
@@ -1,5 +1,189 @@
# aicodeman
## 1.24.5
### Patch Changes
- Fable 5.1 is selectable in App Settings.
`claude-fable-5-1` is in Claude Code's model catalog (display name "Fable 5.1", June 2026 knowledge cutoff), but the model picker only went up to Fable 5, so pinning it meant hand-editing a case's `.claude/settings.local.json`. It now appears as a card under **App Settings -> Models -> New Claude sessions**, and as an option in **Task routing** (Default for tasks, plus the Explore / Implement / Test / Review overrides).
It is offered exactly the way Fable 5 already is: the "1M capable" badge, the 1M context window switch stays live for it, and base + switch compose into `claude-fable-5-1[1m]`. Both strings are accepted by the CLI.
Deliberately not claimed: that a 1M window is what sets Fable 5.1 apart. The CLI's model catalog marks both fable entries as natively 1M with the same window, so an always-on window for 5.1 next to a switchable one for 5 would encode a difference the models do not have.
### Thanks
- @shenlvkang-collab for #370, which surfaced that Fable 5.1 was missing from the picker.
## 1.24.4
### Patch Changes
- The Compose deployment image ships the Docker CLI instead of the whole Docker engine.
`docker/server.Dockerfile` installed Debian's `docker.io` to get a client for the mounted
host socket. That package is the full **engine**: even with `--no-install-recommends` it
pulls 15 packages including containerd, runc, dmsetup and iptables, none of which a
container that only talks to a socket can use. It also ships Docker 20.10.24, from 2023.
The CLI and the buildx plugin are now copied from the official `docker:29-cli` image
instead. Measured on the same `node:22-bookworm-slim` base: **266 MB → 108 MB**, a 158 MB
saving, with the current CLI (29.7.2) in place of a two-year-old one.
Verified by building the real image and running it: the binaries are static Go builds, so
they work on this glibc image even though they come from an Alpine one, and `docker
--version`, `docker ps` and `docker build` all succeed against a mounted host socket as
the unprivileged runtime user. buildx is copied deliberately — `scripts/build-agent-image.mjs`
shells out to `docker build` and Codeman auto-builds the agent image on the first Docker
case, which without the plugin falls back to the classic builder Docker has deprecated.
`docker-compose` is not copied; Codeman never shells out to it.
## 1.24.3
### Patch Changes
- Docker Compose deployment, and the plan-usage chip stops losing its 5-hour window.
**Run Codeman itself in a container** (#349, @opticon454). `docker/` now carries a
local-image Compose deployment: copy `docker/.env.example` to `docker/.env`, set
`CODEMAN_PASSWORD`, run `bash docker/Start-Codeman.sh`. Docker cases then start as
**sibling** containers through the mounted host socket rather than nested ones, which
inverts an assumption the bare-host path takes for granted: the daemon no longer shares
Codeman's filesystem, so a bind source that is valid inside Codeman means nothing to it.
`CODEMAN_DOCKER_HOST_HOME` translates sources under HOME into the daemon's namespace and
`CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount, so a workspace
resolves to the same absolute path on both sides. `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1`
drops `--memory-swap` for hosts without swap accounting (`--memory` still applies) and
filters only that one kernel warning. Guides: `docs/docker-compose.md`, `docker/README.md`.
Three things were fixed while landing it:
- **`docker/.env` was being baked into the image.** A `.dockerignore` pattern matches the
whole context-relative path, so the bare `.env` line excluded only the root file while
`COPY . .` picked up `docker/.env` — the file the deployment's own README tells you to
fill with `CODEMAN_PASSWORD` and provider API keys — and left it at
`/opt/codeman/docker/.env`. Now excluded via `**/.env`, verified in both directions
against a real build context with a canary secret.
- **`codeman skill install --case <name>` could not find a case under Compose.**
`CODEMAN_CASES_PATH` moved the server's cases dir but not the CLI's, which still
hardcoded `~/codeman-cases`. Both now resolve through one place.
- **A Docker case handed its Claude conversation id to every other CLI.** `resumeOnStart`
seeded `dockerResumeId` from `lastClaudeSessionId` regardless of mode, and
`appendResumeFlag()` maps a resume id onto codex/gemini/pi/grok/deepseek/omp/antigravity.
This one is a plain master bug, unrelated to Compose.
**The plan-usage chip keeps its 5-hour slot.** It silently shrank from `5h 4% · 7d 52%`
to a lone `7d 52%`, which reads as half the feature breaking. Nothing was broken: Claude
Code ships `rate_limits.five_hour` "only while the API reports it and its resets_at has
not passed", so between 5-hour session windows the key simply leaves the statusline
payload. The slot now stays with a dimmed em dash and the tooltip says "no active session
window". Claude only — a missing Codex bucket means that plan has no such limit, so those
stay omitted.
### Thanks
- @opticon454 for #349, and for a write-up that made an infrastructure PR quick to review
## 1.24.2
### Patch Changes
- Fix every new claude session dying on Claude Code 2.1.252's rewritten folder-trust dialog.
That dialog used to offer `❯ 1. Yes, I trust this folder` / `2. No, exit`, so Codeman
answered it by pressing Enter on the highlighted default. 2.1.252 dropped the numbers,
reversed the options and highlights `No, exit`, so the same Enter now answers _exit_: a
session in any directory claude had not seen before died (`Pane is dead (status 1)`)
about six seconds after it started, before the agent ever drew a composer.
- `trustDialogNextKey()` (`src/session-trust-dialog.ts`) now reads the `❯` marker off
the rendered pane and returns ONE keystroke at a time: an arrow while the cursor is on
the wrong option, Enter only once the screen shows it on the trust option. A frame it
cannot read presses nothing. Both the 2.1.252 and the older numbered layout are
handled, and the direction is derived from the frame rather than assumed, so a further
reordering costs a repaint instead of a session.
- The scan schedules its own follow-up read. It had only ever run from the PTY data
handler, which was enough while one Enter answered the dialog; the arrow that moves the
cursor is the last output the pane produces, so a two-keystroke answer would otherwise
stall with the cursor sitting on the right option forever. The keystroke cap goes from
3 to 6 for the same reason.
- The bundled `codeman` agent skill gets the same treatment (preamble 1.21.0): its
`_accept_trust` fallback reads `terminal?full=1`, steers onto the trust option and
confirms only after re-reading, instead of posting a blind `\r`. It sends those
keystrokes under its own `clientId`, because input sequence numbers are monotonic per
client and spending prompt numbers on dialog keys would make the next send-and-wait
look like a stale duplicate and vanish silently.
- Readiness recipes in `docs/extending-codeman.md`, `docs/api-reference.md` and the
skill's own reference carry the corrected answer and a new symptom-table entry for a
worker whose pane is dead seconds after the spawn.
Also included: a CLAUDE.md audit against the tree, correcting counted drift (route
modules, handler counts, frontend module count and app.js size, install.sh size) and
documenting several subsystems that had no entry.
## 1.24.1
### Patch Changes
- The Docker agent base image builds again.
**`docker/agent.Dockerfile` could not be built from a fresh checkout** (#352, fix in #350): the DeepSeek Harness step died with `dsh: pnpm not found on PATH` and exit 127, which took the whole image with it and, because Codeman auto-builds this image on the first Docker case, left Docker mode unusable on a clean host. `dsh plugin` does not bundle a package manager; it spawns a literal `pnpm` with no npm fallback, so pnpm is now installed alongside `dsh` and the layer proves it with `pnpm --version`.
The profile install also passes `--config.dangerouslyAllowAllBuilds=true`, because pnpm, unlike npm, refuses dependency lifecycle scripts by default and fails the install over it (`ERR_PNPM_IGNORED_BUILDS`, exit 1). Which packages that hits moves between rebuilds, since the terminal profile is resolved by dist-tag rather than pinned: the tree that broke the build in August pulled `@google/genai`, today's does not. An allowlist of those names would have gone stale rather than prevented the next break, and running those scripts is the same exposure the image already accepts three layers up, where `npm install -g` runs the install scripts of every transitive dependency of the five CLIs above it with no gate at all.
Documentation caught up with two things it had wrong: the image smoke test in `docs/docker-cases.md` now covers `dsh` and `omp`, and checks the dsh **profile** rather than only the binary (`dsh` is a launcher, so `dsh --version` says nothing about whether a session can start), and `docs/deepseek-integration.md` names pnpm as a prerequisite for installing a terminal profile at all, by hand or through the UI button. A comment in the `/api/deepseek/install-profile` route claimed the opposite of what this bug proved, and is corrected; the route's behaviour was already right, surfacing dsh's own "pnpm not found on PATH" line as the install error.
### Thanks
- @opticon454 for #350, with a reproduction that made this a confirmation rather than a hunt
- @timkjr for reporting #352, and for finding it while verifying Docker support for someone else's PR
## 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
- Codex plan usage in the header chip, a visible inline rename in the session sidebar, and an installer that no longer loses Tailscale access on a re-run.
**Codex plan usage in the header chip** (#346): the plan-usage chip used to show Claude's 5-hour and weekly limits without saying they were Claude's, which stops being a detail the moment you run more than one CLI. It now renders one compact row per provider, Claude above Codex, each labelled and colour-coded by how much is used up. Claude's numbers still come from Codeman's marked `statusLine.command` exporter; Codex's come from the signed-in host CLI's read-only `account/rateLimits/read` app-server request at startup and every five minutes, so credentials stay inside the CLI and no auth material reaches the browser. Only the main `codex` bucket is read (model-specific buckets such as Spark are separate limits and are deliberately excluded), and the Codex row is omitted entirely when no 5-hour or weekly window is available, rather than inventing one.
**Inline rename is visible in the session sidebar** (#345): starting a rename on a sidebar row opened a focused input you could not see. The row's ellipsis clamp was still painting over the live editor, so text and caret went in blind. The sidebar now gets the same unclamped editor layout the vertical tab rail already had. Covered by a Chromium regression test that asserts the painted `overflow` and the input's measured width, not just the class name.
**install.sh keeps Tailscale access on a re-run**: a re-run whose build failed could drop a working Tailscale binding instead of preserving it. The installer now offers Tailscale setup again on re-run rather than losing it, and the README describes the three-way network-access prompt (Tailscale / LAN / local-only) as it actually behaves.
### Thanks
- @JackStuart for #346
- @fibr for #345
- @tailong-wu for #342, whose analysis of the terminal refresh replay loop matched a fix that had landed on master a few hours earlier
## 1.23.1
### Patch Changes
- Fix a fresh-Linux install failure, and bound the browser terminal's live write queue.
**install.sh now installs a build toolchain.** Reported against a stock Ubuntu 24 server: node-pty publishes prebuilt binaries for darwin and win32 only, so on Linux it is always compiled from source during `npm install`. The installer set up Node, tmux and git but never a compiler, so a machine without `build-essential` died deep inside node-gyp with `not found: make` — which reads like an npm bug rather than a missing system package. `make`, a C++ compiler and `python3` are now checked up front exactly like git and tmux, installed per distro (apt / dnf / pacman / apk / zypper) behind the same consent prompt, and re-verified afterwards rather than assumed. If `npm install` fails anyway — including on `install.sh update` — it now names the missing tools and the command that installs them instead of leaving a node-gyp stack trace as the last word.
**Bounded live xterm backpressure** (#339): live output is now one chunk in flight at a time, released by xterm's own parse callback, so xterm's private WriteBuffer can no longer hide an unbounded backlog behind the browser's 128 KiB render cap; queued, loading and incoming bytes all count against that cap. Automatic drop recovery for a shell stays on the bounded 1 MiB tail — a 100k-line shell capture is tens of MiB, and parsing it on the main thread is the freeze the cap exists to prevent — while TUI modes still recover full history behind the existing downgrade guard. Duplicate SSE terminal events are dropped before JSON parsing while WebSocket owns terminal I/O, and recovery is single-flight per active session. Follow-up hardening: the three write-queue reset paths now also release the in-flight gate, so a parse callback that never lands cannot leave live output permanently stalled.
**File Viewer searches the workspace** (#340): the File Viewer search box now queries the server-side file search endpoint with a 250 ms debounce and strict response validation, instead of filtering only the part of the tree already loaded. Tree and search state are scoped to the active session, the hidden-file preference and independent request epochs, so a stale response cannot repaint the panel; cached-tree restoration, directory results and reset behaviour survive session switches and both panel-hide paths.
### Thanks
- @dignfei for #339
- @aakhter for #340
- 858b15e: Search the full session workspace from File Viewer while keeping results scoped to the active session and hidden-file preference.
## 1.23.0
### Minor Changes
+32 -19
View File
File diff suppressed because one or more lines are too long
+14 -12
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
@@ -61,14 +61,14 @@ The installer asks before every system change, and re-running the same line upda
curl -fsSL https://getcodeman.com/install | bash
```
This installs Node.js and tmux if missing, clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
This installs Node.js, tmux and a build toolchain if missing (node-pty ships no Linux prebuilds, so it compiles from source), clones Codeman to `~/.codeman/app`, and builds it. A few things worth knowing:
- **It asks first.** Every system change (package installs, AI CLI download) is prompted, and a menu at the end lets you choose: run Codeman in this terminal, install it as a background service (systemd/launchd, auto-start on boot), or don't start yet. Nothing runs in the background unless you pick it.
- **Network or local-only, your choice.** The installer asks whether the dashboard should be reachable from other devices on your network (`0.0.0.0`, the default, with a strongly recommended password prompt) or from this machine only (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. A bare `codeman web` started by hand still defaults to loopback.
- **How it's reachable, your choice.** The installer offers three ways to reach the dashboard: **Tailscale** (loopback bind fronted by `tailscale serve`, so you get `https://<machine>.<tailnet>.ts.net` with a real certificate and your tailnet as the login, no password needed), **any device on your network** (`0.0.0.0`, with a strongly recommended password prompt), or **this machine only** (`127.0.0.1`, safest). Skipping the password on a network bind requires an explicit confirmation and ends with a loud warning. The highlighted default reflects what is already on the machine (Tailscale when it is already in use, your existing binding on a re-run), and a bare Enter never pulls in new software. A bare `codeman web` started by hand still defaults to loopback.
- **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
@@ -82,6 +82,8 @@ codeman users add alice --admin # create the first admin account
codeman web --multiuser # named logins + per-user case spaces
```
**Prefer Docker Compose?** A local-image Compose deployment ships in `docker/`: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage and networking options.
Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
<details>
@@ -171,7 +173,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 +255,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 +439,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 +462,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 +797,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 +1013,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>
+69
View File
@@ -0,0 +1,69 @@
# =============================================================================
# Codeman Docker Compose environment template
# Copy this file to .env and set the values for the Docker host.
# =============================================================================
TZ=Australia/Perth
# Optional overrides for direct `docker compose` use. The Bash start script
# detects these values from CODEMAN_APPDATA_PATH automatically. Compose uses
# 1000:1000 when the variables are omitted.
# PUID=1000
# PGID=1000
# Name of the account that runs Codeman and all local CLI sessions. Changing
# this value rebuilds the image with a matching account.
CODEMAN_RUNTIME_USER=opencode
# Required. Persistent Codeman application data, CLI credentials, and session
# state are stored here on the host and mounted at the runtime account's home
# directory in the container.
CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman
# Required for Docker cases. This must be an absolute path on the Docker host.
# Codeman and each isolated case use this same path, so it cannot be a
# container-only path such as /home/opencode/codeman-cases.
CODEMAN_CASES_PATH=/mnt/user/appdata/Coding/codeman/codeman-cases
# Required. Network bind address, host port, and local image tag.
CODEMAN_HOST=0.0.0.0
CODEMAN_PORT=3000
CODEMAN_IMAGE=codeman:local
# Required for any network-accessible Codeman instance. Use a unique, strong
# password. This file is safe to commit; copy it to .env and set the value.
CODEMAN_PASSWORD=changeme
# Required. Username for Codeman HTTP Basic authentication.
CODEMAN_USERNAME=admin
# Optional: authenticate Gemini CLI without an interactive login.
GEMINI_API_KEY=
# Linux default. On Docker Desktop, use the socket path supported by your
# Docker installation when it differs from /var/run/docker.sock.
DOCKER_SOCKET=/var/run/docker.sock
# Optional override for direct `docker compose` use. The Bash start script
# detects this from DOCKER_SOCKET automatically. The direct Compose default is
# 999, but the correct value depends on the Docker host.
# DOCKER_SOCKET_GID=999
# Set to 1 only when Docker-case hook callbacks are required.
CODEMAN_DOCKER_BRIDGE_HOOKS=0
# Set to 1 when `docker info` reports `SwapLimit=false`. The case memory limit
# remains active; Codeman omits --memory-swap and filters the daemon's exact
# unsupported-swap warning while preserving all other Docker create errors.
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=0
# Required only when applying the macvlan example in README.md.
CODEMAN_MACVLAN_NETWORK=br0.11
CODEMAN_IPV4_ADDRESS=10.10.11.236
CODEMAN_MAC_ADDRESS=02:10:11:00:00:EC
# Required only when creating a new managed macvlan network, rather than using
# the external-network macvlan example.
CODEMAN_MACVLAN_PARENT=br0.11
CODEMAN_MACVLAN_SUBNET=10.10.11.0/24
CODEMAN_MACVLAN_GATEWAY=10.10.11.1
+96
View File
@@ -0,0 +1,96 @@
# Codeman Docker deployment
This folder contains the Compose configuration, server image Dockerfile, and environment template for a locally built Codeman server.
## Start
From the repository root, create the runtime environment file and set the required values, especially `CODEMAN_PASSWORD`.
```sh
cp docker/.env.example docker/.env
bash docker/Start-Codeman.sh
```
On PowerShell, use the following command instead.
```powershell
Copy-Item docker/.env.example docker/.env
docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d
```
Every required value is defined and explained in `.env.example`. `GEMINI_API_KEY` is intentionally optional and may remain blank.
On Linux, `Start-Codeman.sh` stops with an error when required paths are missing. It creates the application-data directory when safe, detects its numeric owner as `PUID:PGID`, and detects `DOCKER_SOCKET_GID` from the configured Docker socket. It rejects a root-owned application-data directory because Codeman and its local CLI sessions must remain unprivileged.
Codeman, Claude, OpenCode, and other local sessions run as the unprivileged account named by `CODEMAN_RUNTIME_USER`, which defaults to `opencode`. When Compose is run directly, `PUID` and `PGID` default to `1000:1000`; set them in `.env` when the application-data directory has a different owner. The Bash start script determines them automatically instead.
To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically.
## Application data storage
The default configuration uses a host-folder bind mount:
```yaml
volumes:
- type: bind
source: ${CODEMAN_APPDATA_PATH}
target: /home/${CODEMAN_RUNTIME_USER}
```
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/Coding/codeman`.
`CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere.
Compose also exposes `CODEMAN_APPDATA_PATH` to Codeman as `CODEMAN_DOCKER_HOST_HOME`. This lets Docker case seed files, CLI credentials and the hook secret be mounted using paths that exist in the host daemon's filesystem. Direct host installations do not set this variable and retain their existing behaviour.
Set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` when `docker info` reports `SwapLimit=false`. Codeman continues to apply the configured case memory limit, omits Docker's unsupported `--memory-swap` option, and filters only the daemon's exact swap-capability warning. Every other Docker create error and its exit status remain visible.
For an existing installation created by a root-running image, change ownership of the application-data directory before upgrading so the configured `PUID` and `PGID` can read the saved credentials and state:
```sh
chown -R 99:100 /mnt/user/appdata/Coding/codeman
```
Replace `99:100` and the path with the values from your `.env` file.
Do not replace this bind mount with a Docker-managed named volume when Docker cases are enabled. Codeman passes seed, credential, transcript and hook-secret bind sources to the host Docker daemon, so their source files must have stable paths in the daemon's filesystem. A named volume does not provide the required host path mapping.
## Static macvlan networking
The default configuration publishes a host port. It does not use `network_mode: host`. To attach Codeman directly to an existing external macvlan network with a static IP address and MAC address, remove the `ports:` section and add the following to the `codeman` service:
```yaml
mac_address: ${CODEMAN_MAC_ADDRESS}
networks:
codeman_lan:
ipv4_address: ${CODEMAN_IPV4_ADDRESS}
```
Then add this top-level network declaration:
```yaml
networks:
codeman_lan:
external: true
name: ${CODEMAN_MACVLAN_NETWORK}
```
Set `CODEMAN_MACVLAN_NETWORK`, `CODEMAN_IPV4_ADDRESS`, and `CODEMAN_MAC_ADDRESS` in `.env`. The values in `.env.example` match the supplied Unraid example network and should be changed for other hosts.
### Create a managed macvlan network
If an external macvlan network does not already exist, use this top-level declaration instead. Do not use it together with the external-network declaration.
```yaml
networks:
codeman_lan:
driver: macvlan
driver_opts:
parent: ${CODEMAN_MACVLAN_PARENT}
ipam:
config:
- subnet: ${CODEMAN_MACVLAN_SUBNET}
gateway: ${CODEMAN_MACVLAN_GATEWAY}
```
Macvlan containers are ordinarily not reachable from their Docker host without additional host-network routing. Confirm the selected address, MAC address, parent interface, and subnet are reserved and valid for the target network before starting the stack.
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
env_file="$script_dir/.env"
compose_file="$script_dir/docker-compose.yaml"
if [[ ! -f "$env_file" ]]; then
printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2
printf 'Create it from %s/.env.example before starting Codeman.\n' "$script_dir" >&2
exit 1
fi
compose_command=(docker compose --env-file "$env_file" -f "$compose_file")
appdata_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
docker_socket=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }'
)
if [[ -z "$appdata_path" ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2
exit 1
fi
if [[ ! -d "$appdata_path" ]]; then
if [[ "$EUID" == '0' ]]; then
printf 'Error: Refusing to create CODEMAN_APPDATA_PATH as root: %s\n' "$appdata_path" >&2
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
exit 1
fi
mkdir -p -- "$appdata_path"
fi
if owner_ids=$(stat -c '%u:%g' -- "$appdata_path" 2>/dev/null); then
:
elif owner_ids=$(stat -f '%u:%g' "$appdata_path" 2>/dev/null); then
:
else
printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
exit 1
fi
export PUID=${owner_ids%%:*}
export PGID=${owner_ids##*:}
if [[ "$PUID" == '0' ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is owned by root: %s\n' "$appdata_path" >&2
printf 'Change the directory ownership to the unprivileged account that should run Codeman.\n' >&2
exit 1
fi
if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then
printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-<unset>}" >&2
exit 1
fi
if socket_ids=$(stat -c '%u:%g' -- "$docker_socket" 2>/dev/null); then
:
elif socket_ids=$(stat -f '%u:%g' "$docker_socket" 2>/dev/null); then
:
else
printf 'Error: Cannot determine the owner of DOCKER_SOCKET: %s\n' "$docker_socket" >&2
exit 1
fi
export DOCKER_SOCKET_GID=${socket_ids##*:}
exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d
+42 -4
View File
@@ -76,9 +76,32 @@ RUN curl -fsSL https://x.ai/cli/install.sh | bash \
# Codeman deliberately does NOT seed `profiles/` from the host: it is a
# per-profile node_modules tree, host-arch-specific and far too large to copy on
# every container start.
RUN npm install -g @deepseek-ai/dsh \
# ⚠️ `pnpm` is a HARD dependency of `dsh plugin`, not optional tooling: the
# subcommand is a thin forwarder that `spawnSync`s a literal `pnpm` with no
# fallback to npm, so on an image without it the profile install below dies
# with `dsh: pnpm not found on PATH` / exit 127 and takes the whole build with
# it (issue #352). It stays on PATH at runtime too, so a container user can run
# `dsh plugin add` themselves.
RUN npm install -g @deepseek-ai/dsh pnpm \
&& npm cache clean --force \
&& dsh --version
&& dsh --version \
&& pnpm --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
@@ -106,12 +129,27 @@ 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.
# ⚠️ `dangerouslyAllowAllBuilds` is what keeps that profile install from becoming
# the next #352. pnpm (unlike npm) blocks dependency lifecycle scripts by default
# and FAILS the install over it — `ERR_PNPM_IGNORED_BUILDS`, exit 1, measured on
# pnpm 11.24 — so any package in the tui's tree that ships one stops the build
# dead. An allowlist of the offenders rots: `@deepseek-harness-tui/dsh-tui` is
# resolved by dist-tag, not pinned, and 0.9.3 pulled `@google/genai` (a
# `preinstall: no-op`) where 0.10.0-beta.x does not, so the names to allow move
# under us between rebuilds. Allowing them wholesale is also the SAME exposure
# this image already accepts three layers up: `npm install -g` runs the install
# scripts of every transitive dep of the five CLIs above it, with no gate at all.
# `.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 \
dsh plugin --profile dsh-tui add --config.dangerouslyAllowAllBuilds=true \
@deepseek-harness-tui/dsh-tui \
&& test -f /home/agent/.dsh/profiles/dsh-tui/package.json \
&& chgrp -R 0 /home/agent \
&& chmod -R g=u /home/agent
+65
View File
@@ -0,0 +1,65 @@
name: codeman
services:
codeman:
build:
context: ..
dockerfile: docker/server.Dockerfile
args:
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
PGID: ${PGID:-1000}
PUID: ${PUID:-1000}
image: ${CODEMAN_IMAGE}
init: true
restart: unless-stopped
ports:
- "${CODEMAN_PORT}:${CODEMAN_PORT}"
environment:
CODEMAN_DOCKER_BRIDGE_HOOKS: ${CODEMAN_DOCKER_BRIDGE_HOOKS}
# Host-side equivalent of the runtime user's HOME. Docker case seed,
# credential and hook mounts are translated into the daemon namespace.
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
CODEMAN_HOST: ${CODEMAN_HOST}
CODEMAN_PASSWORD: ${CODEMAN_PASSWORD}
CODEMAN_PORT: ${CODEMAN_PORT}
CODEMAN_USERNAME: ${CODEMAN_USERNAME}
GEMINI_API_KEY: ${GEMINI_API_KEY}
PGID: ${PGID:-1000}
PUID: ${PUID:-1000}
TZ: ${TZ}
group_add:
# Retain access to the host Docker socket without running as root.
- ${DOCKER_SOCKET_GID:-999}
volumes:
# Application data and CLI credentials persist on the configured host
# path, rather than in a Docker-managed volume.
- type: bind
source: ${CODEMAN_APPDATA_PATH}
target: /home/${CODEMAN_RUNTIME_USER}
# Docker cases are sibling containers on the host daemon. Their workspace
# must be visible to Codeman at the same absolute path used by that daemon.
- type: bind
source: ${CODEMAN_CASES_PATH}
target: ${CODEMAN_CASES_PATH}
# Codeman uses the host daemon to create isolated Docker cases. This is
# Docker-outside-of-Docker, not Docker-in-Docker.
- type: bind
source: ${DOCKER_SOCKET}
target: /var/run/docker.sock
extra_hosts:
- "host.docker.internal:host-gateway"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD-SHELL
- >-
node -e "fetch('http://127.0.0.1:${CODEMAN_PORT}/api/status').then((response) => process.exit(response.status < 500 ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
+114
View File
@@ -0,0 +1,114 @@
# syntax=docker/dockerfile:1
# Build the application from the checkout supplied as the Docker build context.
# No published Codeman application image is required.
FROM node:22-bookworm-slim AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/codeman
COPY . .
RUN npm ci \
&& npm run build \
&& npm prune --omit=dev --ignore-scripts \
&& npm cache clean --force
# The Docker CLI talks to the host daemon through the socket mounted by
# docker/docker-compose.yaml. It does not run a Docker daemon in this container.
FROM node:22-bookworm-slim
ARG CODEMAN_RUNTIME_USER=opencode
ARG PUID=1000
ARG PGID=1000
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
openssh-client \
procps \
ripgrep \
tmux \
&& rm -rf /var/lib/apt/lists/*
# The Docker CLI, taken from the official image rather than Debian's `docker.io`.
# That package is the full ENGINE: with --no-install-recommends it still pulls 15
# packages including containerd, runc, dmsetup and iptables, none of which a
# client that only talks to a mounted socket can use. Measured on top of this
# base image: `docker.io` costs 266 MB and ships Docker 20.10.24 (2023), while
# these two files cost 108 MB and ship the current CLI (493 MB vs 335 MB total).
#
# The binaries are STATIC Go builds, so they run on this glibc image even though
# the image they come from is Alpine (verified: `docker --version`, `docker ps`
# and `docker build` all work here against a mounted host socket).
#
# buildx is copied on purpose. `scripts/build-agent-image.mjs` shells out to
# `docker build` — Codeman auto-builds the agent image on the first Docker case —
# and without the plugin that silently falls back to the CLASSIC builder, which
# Docker has deprecated and will eventually drop. `docker-compose` is NOT copied:
# Codeman never shells out to it.
COPY --from=docker:29-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=docker:29-cli \
/usr/local/libexec/docker/cli-plugins/docker-buildx \
/usr/local/libexec/docker/cli-plugins/docker-buildx
# Keep credentials out of the image. Users authenticate these CLIs at runtime
# through Codeman sessions, and the configured host bind mount retains state.
RUN npm install --global \
@anthropic-ai/claude-code \
@google/gemini-cli \
@openai/codex \
opencode-ai \
&& npm cache clean --force
# Keep the web server and every local Codeman session unprivileged. PUID and
# PGID match the host-owned application-data directory mounted by Compose. The
# requested GID may not exist in the base image, and a host UID such as 1000 may
# already belong to the baked `node` account, so handle both cases explicitly.
RUN set -eux; \
case "${PUID}" in ''|*[!0-9]*) echo "PUID must be numeric" >&2; exit 1;; esac; \
case "${PGID}" in ''|*[!0-9]*) echo "PGID must be numeric" >&2; exit 1;; esac; \
if [ "${PUID}" -eq 0 ]; then \
echo "PUID must identify an unprivileged account, not root" >&2; \
exit 1; \
fi; \
if ! getent group "${PGID}" >/dev/null; then \
groupadd --gid "${PGID}" codeman-runtime; \
fi; \
existing_user="$(getent passwd "${PUID}" | cut -d: -f1 || true)"; \
if [ -n "${existing_user}" ]; then \
usermod \
--login "${CODEMAN_RUNTIME_USER}" \
--gid "${PGID}" \
--home "/home/${CODEMAN_RUNTIME_USER}" \
--move-home \
--shell /bin/bash \
"${existing_user}"; \
else \
useradd \
--uid "${PUID}" \
--gid "${PGID}" \
--create-home \
--home-dir "/home/${CODEMAN_RUNTIME_USER}" \
--shell /bin/bash \
"${CODEMAN_RUNTIME_USER}"; \
fi
WORKDIR /opt/codeman
COPY --from=build /opt/codeman /opt/codeman
ENV CODEMAN_PORT=3000 \
HOME=/home/${CODEMAN_RUNTIME_USER} \
NODE_ENV=production
EXPOSE 3000
USER ${CODEMAN_RUNTIME_USER}
CMD ["node", "dist/index.js", "web"]
+10 -5
View File
@@ -204,11 +204,16 @@ turn.
The reliable sequence is: poll `GET /api/v1/sessions/:id` until `.data.pid` is
non-null, then `wait-output` for the composer's own marker (`bypass`, the status
bar of a CLI spawned in bypass mode) with a short timeout, handling the trust
dialog only as the bounded fallback (`trust` matched → send `\r` → wait for
`bypass` again). Do not probe `trust` first and Enter blindly: the dialog text
stays in the terminal buffer for the life of the session, so a `trust` probe with
`from=buffer` keeps matching on every later run and the Enter lands in a ready
composer. A worked version is in
dialog only as the bounded fallback.
⚠️ **The fallback is not a bare `\r`.** Claude Code 2.1.252 unnumbered the dialog's
options, reversed them and highlights `No, exit`, so an Enter sent blind quits the
CLI and the pane dies seconds after the spawn. Read the `❯` marker off the current
frame (`GET /api/v1/sessions/:id/terminal?full=1`), send `ESC [ B` while it is on
`No, exit`, re-read, and confirm only once it is on `Yes, I trust this folder`.
Reading the current frame is also what keeps this correct on later runs: the dialog
text stays in the terminal buffer for the life of the session, so a `trust` probe
with `from=buffer` keeps matching long after the dialog is gone. A worked version is in
[`extending-codeman.md`](extending-codeman.md#seam-3-http-api-and-cli).
### `GET /api/v1/sessions/:id/wait`
File diff suppressed because one or more lines are too long
+8
View File
@@ -47,6 +47,14 @@ By hand, or to pick a different front door:
dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui
```
⚠️ **`pnpm` has to be on PATH for either route.** `dsh plugin` is a thin forwarder
that spawns a literal `pnpm` with no npm fallback, so without one it exits 127 with
`dsh: pnpm not found on PATH` — both by hand and behind the UI button, which
surfaces that same line as the install error. `npm install -g pnpm` (or
`corepack enable pnpm`) is the fix. This is what broke the Docker agent image in
[#352](https://github.com/Ark0N/Codeman/issues/352); the image now installs pnpm
alongside `dsh`.
Codeman's default is `@deepseek-harness-tui/dsh-tui` because it is by a wide
margin the most used community TUI, it is MIT, and it implements the status
contract described in §3. It is a **default, not a requirement**: any profile
+15 -3
View File
@@ -2,7 +2,7 @@
Run a case inside an **isolated Docker container** instead of directly on the host. Any number of Codeman sessions can share one container (it is scoped to the case, not the session), so a whole project lives in a sandbox with its own network, resource caps, and filesystem, and you can **export the container to move it to another machine**.
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` / `grok` all work inside the container.
Docker mode is a **location overlay on cases**, the direct analog of [remote SSH cases](./remote-hosts.md): where a remote case runs a local tmux pane doing `ssh host` into a durable remote tmux server, a docker case runs a local tmux pane doing `docker exec -it` into a durable **in-container** tmux server. It is not a separate `SessionMode`, so `claude` / `shell` / `opencode` / `codex` / `gemini` / `antigravity` / `pi` / `grok` / `deepseek` / `omp` all work inside the container.
## One-time setup: build the base image
@@ -25,12 +25,24 @@ A zero exit code only proves the layers ran, not that the toolchain works. Verif
```bash
docker run --rm codeman/agent:base bash -lc \
'for c in claude codex gemini opencode agy pi grok; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
```
⚠️ `dsh --version` is the one line above that answers a different question than the
others: `dsh` is a profile launcher, so a working binary says nothing about whether
the image can actually run a DeepSeek session. Check the profile the Dockerfile
installs into the agent's HOME as well, or a `mode: 'deepseek'` case starts a pane
that dies on arrival:
```bash
docker run --rm codeman/agent:base ls ~/.dsh/profiles/dsh-tui/package.json
```
Building that profile is also why `pnpm` is in the image: `dsh plugin` forwards straight to a literal `pnpm` and exits 127 without it (issue #352), and pnpm — unlike npm — blocks dependency lifecycle scripts by default and fails the install over it, so the profile step passes `--config.dangerouslyAllowAllBuilds=true`.
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"
+68
View File
@@ -0,0 +1,68 @@
# Docker Compose deployment
This configuration builds the Codeman application image locally from this checkout. It does not download or depend on a pre-built Codeman image.
For the Compose configuration, environment settings, storage migration, and macvlan networking examples, see the [Docker deployment guide](../docker/README.md).
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
## Prerequisites
- Docker Engine or Docker Desktop with Docker Compose v2
- A reachable Docker daemon
The application container mounts the Docker daemon socket so Codeman can create and manage its isolated Docker cases. Treat anyone who can administer this Compose project as having Docker-host-equivalent access.
## Start
Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/Coding/codeman` on the host to `/home/${CODEMAN_RUNTIME_USER}` in the container, preserving Codeman state and CLI credentials outside Docker-managed volumes.
```sh
cp docker/.env.example docker/.env
```
On PowerShell, use the following command instead.
```powershell
Copy-Item docker/.env.example docker/.env
```
On Linux, run the stack with the start script. It determines `PUID` and `PGID` from the owner of `CODEMAN_APPDATA_PATH`, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned application-data directory is rejected so the runtime account cannot become UID 0.
```sh
bash docker/Start-Codeman.sh
```
On other platforms, run Compose directly. `PUID` and `PGID` default to `1000:1000`; set them in `docker/.env` when the application-data directory has a different owner.
```sh
docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d
```
Open `http://localhost:3000` and sign in with the username and password from `docker/.env`.
## Operations
The local image is tagged `codeman:local` by default. Change `CODEMAN_IMAGE` in `docker/.env` if a different local tag suits your environment.
```sh
docker compose --env-file docker/.env -f docker/docker-compose.yaml logs -f codeman
bash docker/Start-Codeman.sh
docker compose --env-file docker/.env -f docker/docker-compose.yaml down
```
`CODEMAN_APPDATA_PATH` holds Codeman state and survives container recreation. Remove that host directory only when deliberately resetting the installation.
`CODEMAN_CASES_PATH` must be an absolute path on the Docker host. Compose mounts it at the same path inside Codeman, so the host daemon can bind the managed workspace into isolated Docker cases. Do not set it to `/home/${CODEMAN_RUNTIME_USER}/codeman-cases`.
Compose passes `CODEMAN_APPDATA_PATH` into Codeman as `CODEMAN_DOCKER_HOST_HOME`. Codeman uses that value to translate generated Docker seed, credential and hook-secret bind sources from the container's home path into paths visible to the host Docker daemon.
If `docker info` reports `SwapLimit=false`, set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1`. Isolated cases retain their configured memory limit. Codeman omits the unsupported swap-limit option and filters only the daemon's exact swap-capability warning while retaining every other Docker create error.
If that directory was created by an earlier root-running image, change its ownership to the configured `PUID:PGID` before starting this version. This preserves existing CLI credentials and session state while allowing the unprivileged runtime account to use them.
## Docker cases
The default socket path is `/var/run/docker.sock`, which works with a standard Linux Docker Engine. The Bash start script detects its numeric group ID. When running Compose directly, set `DOCKER_SOCKET_GID`, for example using `stat -c '%g' /var/run/docker.sock`, so the unprivileged `CODEMAN_RUNTIME_USER` account can create Docker cases. Docker Desktop users should set `DOCKER_SOCKET` in `docker/.env` only when their Docker installation exposes a different compatible socket path.
Codeman Docker cases are sibling containers on the host daemon, not children of the application container. The Compose configuration handles their workspace bind mount through `CODEMAN_CASES_PATH`; the `/home/${CODEMAN_RUNTIME_USER}` application-data mapping is for Codeman state and ordinary in-container sessions, not sibling-case workspaces.
+31 -11
View File
@@ -228,6 +228,14 @@ window: the wait resolves on `idle` in a couple of seconds with `timedOut: false
indistinguishable from a finished turn. Wait for the pid, then wait for the
composer, answering the dialog only as the bounded fallback.
⚠️ **Answering it is not "press Enter".** Claude Code 2.1.252 dropped the options'
numbers, reversed them, and highlights `No, exit` by default, so a blind `\r` quits
the CLI and the pane is dead seconds after the spawn. Read the `❯` marker off the
rendered pane (`GET .../terminal?full=1`), send `ESC [ B` while it sits on `No, exit`,
re-read, and confirm only once the marker is on `Yes, I trust this folder`. Codeman's
own auto-accept (`trustDialogNextKey()` in `src/session-trust-dialog.ts`) does exactly
this, inside a 90 s startup window and a 6-keystroke cap.
A worked orchestration: start a worker, get it ready, prompt it, wait, clean up.
```bash
@@ -244,24 +252,36 @@ SID=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" \
[ -n "$SID" ] && [ "$SID" != null ] || { echo "quick-start failed"; exit 1; }
# 2. READINESS: composer marker first, trust dialog only as the bounded fallback.
# Skip this and step 3 reports a turn that never ran. Do NOT probe trust first
# and Enter blindly: the dialog text stays in the buffer for the life of the
# session, so on every later run that probe matches stale text and the Enter
# lands in a ready composer. Match single tokens only: TUI text can arrive
# without its spaces. Stage 1 is short on purpose (an already-trusted case
# matches in <1 s; a first-run case can never pass it and pays it in full).
# Skip this and step 3 reports a turn that never ran. Match single tokens only:
# TUI text can arrive without its spaces. Stage 1 is short on purpose (an
# already-trusted case matches in <1 s; a first-run case can never pass it and
# pays it in full).
# ⚠️ NEVER answer the dialog with a bare \r. Its highlighted option is `No, exit`
# (claude-cli 2.1.252), so a blind Enter quits the CLI; and the dialog text stays
# in the buffer for the life of the session, so a `from=buffer` probe for `trust`
# keeps matching long after it is gone. Read the CURRENT pane instead and steer.
until [ "$("${CURL[@]}" "$API/api/v1/sessions/$SID" | jq '.data.pid')" != null ]
do sleep 1; done
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=5000') # composer's status bar = ready
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=2000')
jq -e '.data.wait.matched' <<<"$T" >/dev/null && \
ESC=$(printf '\033') # \x1b is GNU-sed only; this form also works on macOS
for _ in 1 2 3 4 5 6; do
# Which option the ❯ marker sits on, read off the CURRENT frame.
K=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$ESC\[[0-9;?]*[a-zA-Z]//g" -e "s/$ESC[()][AB0]//g" | tr -d ' \t' \
| grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/')
[ -n "$K" ] || break # no dialog on screen: nothing to answer
[ "$K" = confirm ] && IN="\r" || IN="$ESC[B"
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \
-H 'Content-Type: application/json' -d '{"input":"\r","useMux":true}' >/dev/null
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg i "$IN" '{input:$i,useMux:true}')" >/dev/null
[ "$K" = confirm ] && break
sleep 1 # re-read: confirm the arrow landed
done
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=bypass' --data-urlencode 'from=buffer' \
--data-urlencode 'timeout=45000' >/dev/null
+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.
+1 -1
View File
@@ -136,7 +136,7 @@ Worth knowing:
- **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open.
Shell sessions open from a bounded recent tail so a large transcript cannot stall tab
switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling
stays within the bounded browser buffer so dragging upward remains responsive.
and automatic output recovery stay within the bounded browser buffer.
- **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude
versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is
always local scrollback. Other CLIs scroll locally.
+214 -7
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
# ============================================================================
@@ -245,7 +256,11 @@ print_security_notice() {
echo -e " ${YELLOW}${BOLD}Security:${NC}"
echo -e " Codeman binds ${BOLD}127.0.0.1${NC} (this machine only) — no password needed by default."
echo -e " To reach it from another device, do ONE of:"
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
if check_tailscale; then
echo -e " ${CYAN}•${NC} ${CYAN}bash $INSTALL_DIR/install.sh tailscale${NC} ${DIM}(Tailscale is installed here; HTTPS, recommended)${NC}, or"
else
echo -e " ${CYAN}•${NC} tailscale serve / cloudflared tunnel ${DIM}(recommended)${NC}, or"
fi
echo -e " ${CYAN}•${NC} ${CYAN}codeman web --host 0.0.0.0${NC} AND set ${CYAN}CODEMAN_PASSWORD${NC}"
echo -e " A non-loopback bind without a password still starts, but warns loudly."
echo -e " ${DIM}Details: docs/security-architecture.md${NC}"
@@ -412,6 +427,27 @@ check_tmux() {
command -v tmux &>/dev/null
}
# node-pty ships prebuilt binaries for darwin and win32 ONLY, so on Linux it is
# always compiled from source during `npm install`. Without a toolchain that
# fails deep inside node-gyp with `not found: make`, which reads like an npm bug
# rather than a missing system package (issue: fresh Ubuntu 24 server install).
# So the toolchain is checked up front, exactly like git and tmux.
#
# Returns a human-readable list of what is missing, empty when all present.
missing_build_tools() {
local missing=""
command -v make &>/dev/null || missing="make"
if ! command -v c++ &>/dev/null && ! command -v g++ &>/dev/null && ! command -v clang++ &>/dev/null; then
missing="${missing:+$missing, }a C++ compiler (g++)"
fi
command -v python3 &>/dev/null || missing="${missing:+$missing, }python3"
printf '%s' "$missing"
}
check_build_tools() {
[[ -z "$(missing_build_tools)" ]]
}
check_claude() {
# Check PATH first
if command -v claude &>/dev/null; then
@@ -667,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
@@ -934,6 +1001,50 @@ install_git_suse() {
run_as_root zypper install -y git
}
# Build toolchain for node-pty's source compile (see missing_build_tools).
install_buildtools_debian() {
info "Installing build tools via apt (build-essential, python3)..."
ensure_sudo
run_as_root apt-get update -qq
run_as_root apt-get install -y -qq build-essential python3
}
install_buildtools_fedora() {
info "Installing build tools (gcc, gcc-c++, make, python3)..."
ensure_sudo
if command -v dnf &>/dev/null; then
run_as_root dnf install -y gcc gcc-c++ make python3
else
run_as_root yum install -y gcc gcc-c++ make python3
fi
}
install_buildtools_arch() {
info "Installing build tools via pacman (base-devel, python)..."
ensure_sudo
run_as_root pacman -Sy --noconfirm base-devel python
}
install_buildtools_alpine() {
info "Installing build tools via apk (build-base, python3)..."
ensure_sudo
run_as_root apk add --no-cache build-base python3
}
install_buildtools_suse() {
info "Installing build tools via zypper..."
ensure_sudo
run_as_root zypper install -y gcc gcc-c++ make python3
}
install_buildtools_macos() {
# macOS normally never gets here: node-pty ships darwin prebuilds. Only a
# forced source build needs a compiler, and Xcode CLT is its only supplier.
info "Requesting Xcode Command Line Tools..."
xcode-select --install 2>/dev/null || true
die "Finish the Xcode Command Line Tools install in the dialog, then re-run this installer."
}
install_cloudflared_macos() {
info "Installing cloudflared via Homebrew..."
ensure_homebrew
@@ -1809,6 +1920,41 @@ setup_tailscale_access() {
return 0
}
# A loopback install with Tailscale already connected but nothing fronting
# Codeman is one command away from working remote access — and that is exactly
# where a user lands when the first install died BEFORE the network-access
# prompt (it runs after the build, so any build failure costs the network step
# too) or when they finished a broken build by hand instead of re-running the
# installer. Detect that state on re-run and offer the retrofit, rather than
# leaving them to discover `install.sh tailscale` on their own. Never nags a
# deliberate network bind, and never nags once a serve mapping already exists.
maybe_offer_tailscale_repair() {
# A non-loopback bind already has network access; leave that choice alone.
if [[ "$EXISTING_FOUND" == "1" && -n "$EXISTING_HOST" && "$EXISTING_HOST" != "127.0.0.1" ]]; then
return 0
fi
check_tailscale || return 0
command -v node &>/dev/null || return 0
[[ "$(ts_status_field 's.BackendState')" == "Running" ]] || return 0
# Already fronting Codeman: nothing to repair.
[[ -z "$(detect_tailscale_serve_url)" ]] || return 0
echo ""
info "Tailscale is connected here, but no serve mapping fronts Codeman yet."
if [[ "$NONINTERACTIVE" == "1" ]] || ! has_tty; then
echo -e " ${DIM}Enable HTTPS access from your tailnet with:${NC} ${CYAN}bash $INSTALL_DIR/install.sh tailscale${NC}"
return 0
fi
if ! prompt_yes_no "Set up Tailscale HTTPS access now? (your tailnet is the login; no password needed)" "y"; then
echo -e " ${DIM}Any time later:${NC} ${CYAN}bash $INSTALL_DIR/install.sh tailscale${NC}"
return 0
fi
if setup_tailscale_access; then
verify_tailscale_access || true
fi
return 0
}
# `install.sh tailscale`: retrofit Tailscale access onto an existing install
# (also the target of every "set it up later" hint above).
setup_tailscale_subcommand() {
@@ -2061,6 +2207,29 @@ setup_tunnel_service() {
# Installation Helpers
# ============================================================================
# npm install with an actionable message for the failure that actually happens
# on a fresh Linux box: no toolchain, so node-pty cannot compile.
npm_install_deps() {
if npm install --quiet --no-fund --no-audit 2>/dev/null; then
return 0
fi
if npm install --no-fund --no-audit; then
return 0
fi
error "npm install failed."
if [[ "$(detect_os)" == "linux" ]] && ! check_build_tools; then
error "Missing native build tools: $(missing_build_tools)"
error "node-pty has no Linux prebuilds, so it must compile from source."
error "Install them and re-run this installer:"
error " Debian/Ubuntu: sudo apt-get install -y build-essential python3"
error " Fedora/RHEL: sudo dnf install -y gcc gcc-c++ make python3"
error " Arch: sudo pacman -S --noconfirm base-devel python"
error " Alpine: sudo apk add build-base python3"
fi
exit 1
}
install_dependency() {
local dep_name="$1"
local os="$2"
@@ -2174,6 +2343,31 @@ main() {
fi
fi
# Native build toolchain. node-pty compiles from source on Linux, so this is
# a hard requirement there, not a nicety.
if [[ "$os" == "linux" ]]; then
info "Checking build tools (node-pty compiles from source on Linux)..."
local missing_tools
missing_tools="$(missing_build_tools)"
if [[ -z "$missing_tools" ]]; then
success "Build tools are installed"
else
warn "Missing build tools: $missing_tools"
headless_guard "install build tools (system package via sudo)"
if prompt_yes_no "Install the build tools now?"; then
install_dependency "buildtools" "$os" "$distro"
hash -r 2>/dev/null || true
missing_tools="$(missing_build_tools)"
if [[ -n "$missing_tools" ]]; then
die "Build tools still missing after install: $missing_tools. Install them manually and re-run."
fi
success "Build tools installed"
else
die "A build toolchain (make, g++, python3) is required: node-pty has no Linux prebuilds and compiles from source."
fi
fi
fi
# AI CLI (Codeman drives one of: Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi)
local has_claude=false
local has_opencode=false
@@ -2183,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
@@ -2217,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=""
@@ -2341,7 +2540,7 @@ main() {
# ========================================================================
info "Installing dependencies..."
npm install --quiet --no-fund --no-audit 2>/dev/null || npm install --no-fund --no-audit
npm_install_deps
info "Building..."
npm run build --quiet 2>/dev/null || npm run build
@@ -2576,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"
@@ -2584,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
@@ -2637,7 +2840,7 @@ update() {
git fetch --quiet origin
git reset --hard "origin/$BRANCH" --quiet
npm install --quiet --no-fund --no-audit 2>/dev/null || npm install --no-fund --no-audit
npm_install_deps
npm run build --quiet 2>/dev/null || npm run build
date -u +%Y-%m-%dT%H:%M:%SZ > "$INSTALL_DIR/.install-complete"
success "Updated to $(node -e "console.log(require('./package.json').version)")"
@@ -2674,6 +2877,10 @@ update() {
BIND_ACK="$EXISTING_ACK"
fi
# An update is the only place a half-configured install gets a second
# chance at remote access; the fresh-install path asks outright.
maybe_offer_tailscale_repair
print_security_notice
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.23.0",
"version": "1.24.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.23.0",
"version": "1.24.5",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.23.0",
"version": "1.24.5",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
+51 -17
View File
@@ -47,7 +47,7 @@ later call opens with, and your first REAL call performs them anyway:
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
```
⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same
@@ -75,8 +75,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh"
mkdir -p "$(dirname "$PRE")"
# Rewrite unless the file already ends with THIS version's stamp, so a stale or a
# half-written file self-heals here instead of costing you a round trip to rm it.
grep -qs '^CODEMAN_PREAMBLE=1.20.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.20.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.21.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -127,6 +127,43 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
# "No, exit" by default:
# Security guide
# ❯ No, exit
# Yes, I trust this folder
# Enter to confirm . Esc to cancel
# so the bare \r that answered the old layout now answers *exit* and the pane is
# dead (`status 1`) seconds after the spawn -- measured on a live 2.1.252 case.
# These two read the rendered pane and steer onto the trust option instead.
_trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
}
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
local sid="$1" k i=1
while [ "$i" -le 6 ]; do
k=$(_trust_key "$sid")
[ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read
# A SEPARATE clientId for these keys. seq is monotonic per clientId, so
# spending prompt numbers here would make the next sendwait -- whose default
# seq is the epoch second -- look like a stale duplicate and vanish silently.
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg k "$([ "$k" = confirm ] && printf '\r' || printf '\033[B')" \
--arg c "$CID-trust-$sid" --argjson s "$i" \
'{input:$k,useMux:true,clientId:$c,seq:$s}')" >/dev/null
[ "$k" = confirm ] && return 0
sleep 1; i=$((i+1)) # re-read: the arrow is CONFIRMED before Enter goes out
done
return 1
}
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
@@ -177,19 +214,16 @@ spawn_worker() {
grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || {
echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2
delete_session "$sid" >/dev/null; return 1; }
# Short composer wait FIRST, then the trust-dialog probe: a case still showing the
# dialog can never pass the composer wait, so probing early keeps a cold case from
# Short composer wait FIRST, then the trust dialog: a case still showing the
# dialog can never pass the composer wait, so acting early keeps a cold case from
# paying the whole long wait before the fallback even runs (§5.2). A warm case
# matches in under a second and never reaches the probe.
# matches in under a second and never reaches it, and _accept_trust returns in a
# blink when there is no dialog, so this costs nothing in the ordinary slow case.
r=$(_composer_up "$sid" 5000)
if [ "$r" != true ]; then
if "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000' \
| jq -e '.data.wait.matched' >/dev/null; then
# Codeman's own auto-accept gives up after 90 s / 3 tries; this is that bounded fallback.
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg c "$CID-$sid" '{input:"\r",useMux:true,clientId:$c,seq:1}')" >/dev/null
fi
# Codeman answers this dialog itself and normally wins the race; this is the
# bounded fallback for when its 90 s window / 6-keystroke cap has run out.
_accept_trust "$sid"
r=$(_composer_up "$sid" 45000)
fi
[ "$r" = true ] || { echo "worker $sid never drew a composer; deleted it. Retry by hand via the §5.2 ladder (its billed stage-4 probe included)" >&2
@@ -288,10 +322,10 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.20.0
CODEMAN_PREAMBLE=1.21.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
```
Every later Bash call that touches the API starts with the same two loader lines from
@@ -342,7 +376,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.20.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
N=(alpha beta) # INVENT one fresh case name per worker; never list cases first
# (a name may carry a mode: `beta:deepseek`, see below)
T=('reply with one line: the absolute path of your working directory'
@@ -422,7 +456,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:
+46 -12
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.20.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
API="${CODEMAN_API_URL:?CODEMAN_API_URL not set; refusing to guess}"
SELF="${CODEMAN_SESSION_ID:?CODEMAN_SESSION_ID not set}"
# Credentials, cheapest first. Your session has usually INHERITED the server's
@@ -49,6 +49,43 @@ _dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness T
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
}
# ---- the workspace-trust dialog: READ the screen, never press Enter blind ----
# Claude Code 2.1.252 dropped the option numbers, REVERSED them, and highlights
# "No, exit" by default:
# Security guide
# ❯ No, exit
# Yes, I trust this folder
# Enter to confirm . Esc to cancel
# so the bare \r that answered the old layout now answers *exit* and the pane is
# dead (`status 1`) seconds after the spawn -- measured on a live 2.1.252 case.
# These two read the rendered pane and steer onto the trust option instead.
_trust_key() { # <sid> -> "confirm" | "move" | "" (nothing safe to press)
# full=1 returns the RENDERED pane; a claude pane keeps no tmux history, so that
# is the current frame rather than every repaint since launch. tail -1 anyway,
# because the freshest marked row is the only one still true.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/terminal" --data-urlencode 'full=1' \
| jq -r '.data.terminalBuffer // empty' \
| sed -e "s/$(printf '\033')\[[0-9;?]*[a-zA-Z]//g" -e "s/$(printf '\033')[()][AB0]//g" \
| tr -d ' \t' | grep -i '❯[0-9.]*\(yes,itrustthisfolder\|no,exit\)' | tail -1 \
| sed -e 's/.*[Yy]es,.*/confirm/' -e 's/.*[Nn]o,.*/move/'
}
_accept_trust() { # <sid> -> 0 once it has answered the dialog, 1 if it could not
local sid="$1" k i=1
while [ "$i" -le 6 ]; do
k=$(_trust_key "$sid")
[ -n "$k" ] || return 1 # no dialog on screen, or a layout this cannot read
# A SEPARATE clientId for these keys. seq is monotonic per clientId, so
# spending prompt numbers here would make the next sendwait -- whose default
# seq is the epoch second -- look like a stale duplicate and vanish silently.
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg k "$([ "$k" = confirm ] && printf '\r' || printf '\033[B')" \
--arg c "$CID-trust-$sid" --argjson s "$i" \
'{input:$k,useMux:true,clientId:$c,seq:$s}')" >/dev/null
[ "$k" = confirm ] && return 0
sleep 1; i=$((i+1)) # re-read: the arrow is CONFIRMED before Enter goes out
done
return 1
}
# spawn_worker <caseName> [mode] -> session id on stdout, diagnostics on stderr.
# quick-start AND readiness in one call, with a strict contract: NON-EMPTY stdout means
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
@@ -99,19 +136,16 @@ spawn_worker() {
grep -qs '/api/hook-event' "$cp/.claude/settings.local.json" || {
echo "case '$name' resolved to '$cp', which has no Codeman hooks (workspaceHooksEnabled off, remote, or an older server?): turn the setting on, or work §5.1+§5.5 by hand with markers" >&2
delete_session "$sid" >/dev/null; return 1; }
# Short composer wait FIRST, then the trust-dialog probe: a case still showing the
# dialog can never pass the composer wait, so probing early keeps a cold case from
# Short composer wait FIRST, then the trust dialog: a case still showing the
# dialog can never pass the composer wait, so acting early keeps a cold case from
# paying the whole long wait before the fallback even runs (§5.2). A warm case
# matches in under a second and never reaches the probe.
# matches in under a second and never reaches it, and _accept_trust returns in a
# blink when there is no dialog, so this costs nothing in the ordinary slow case.
r=$(_composer_up "$sid" 5000)
if [ "$r" != true ]; then
if "${CURL[@]}" -G "$API/api/v1/sessions/$sid/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000' \
| jq -e '.data.wait.matched' >/dev/null; then
# Codeman's own auto-accept gives up after 90 s / 3 tries; this is that bounded fallback.
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg c "$CID-$sid" '{input:"\r",useMux:true,clientId:$c,seq:1}')" >/dev/null
fi
# Codeman answers this dialog itself and normally wins the race; this is the
# bounded fallback for when its 90 s window / 6-keystroke cap has run out.
_accept_trust "$sid"
r=$(_composer_up "$sid" 45000)
fi
[ "$r" = true ] || { echo "worker $sid never drew a composer; deleted it. Retry by hand via the §5.2 ladder (its billed stage-4 probe included)" >&2
@@ -210,4 +244,4 @@ last_text() {
# The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept
# bare on purpose: the write condition above anchors on it with $, so an inline comment
# here would fail that match and rewrite this file on every single bootstrap.
CODEMAN_PREAMBLE=1.20.0
CODEMAN_PREAMBLE=1.21.0
+14 -13
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
@@ -800,7 +800,8 @@ for environment and setup problems.
| wait routes 404 on a valid session id | read the `.error` text: a `Route ...` prefix means the server predates the wait endpoints (< 1.13.0; a dev build can serve them while reporting an older version, so probe, never version-compare), poll `terminal?tail=` and say so. `Session ... not found` means your id is wrong, not the server |
| wait on `stop` never resolves | a mode with no hook signals, or hooks not reaching the server (Docker/remote), or a case created by Codeman < 1.13.0 against an `--https` install (its hook curls lacked `-k` and TLS-failed silently; a 1.13.0+ server rewrites them the next time a session starts in that case). Use markers or `idle,exit` |
| wait on `stop` never resolves, on a **dsh** worker whose pane clearly finished | that profile does not implement the harness's supervisor contract, which Codeman cannot detect at request time (an unrecognized profile is treated as launchable on purpose). The wait is accepted and then times out. Drive that worker with markers, or switch to a profile that reports — `@deepseek-harness-tui/dsh-tui` does |
| new claude worker ignores its first prompt | it was showing the first-run trust dialog and Codeman's auto-accept did not fire (it is bounded by a 90 s window and an attempt cap); use the readiness recipe in SKILL.md, wait for `shift+tab` first, accept the dialog only as the bounded fallback |
| new claude worker ignores its first prompt | it was showing the first-run trust dialog and Codeman's auto-accept did not fire (it is bounded by a 90 s window and a keystroke cap); use the readiness recipe in SKILL.md, wait for `shift+tab` first, answer the dialog only as the bounded fallback |
| a brand-new claude worker's pane is DEAD (`status 1`) seconds after the spawn | something pressed Enter at the first-run trust dialog. Since claude-cli 2.1.252 its options are unnumbered, reversed, and the highlighted default is `No, exit`, so a blind `\r` — an up-front Enter, or a task prompt typed into the dialog — quits the CLI. Answer it by reading the `❯` marker off `terminal?full=1` and arrowing onto `Yes, I trust this folder` first: `_accept_trust` in the §0 preamble |
| readiness burns its whole budget, then the worker answers fine anyway | you matched `bypass`, which is the statusline of ONE permission mode. Codeman spawns `--dangerously-skip-permissions` by default, but the server's `claudeMode` setting also has `auto` (`auto mode on`), `allowedTools` and `normal` (both `don't ask on`), and the effective per-session value is not exposed on `GET /api/v1/sessions/:id`. Match **`shift+tab`** instead: every mode's status bar ends `(shift+tab to cycle)` (measured per mode against claude-cli 2.1.226). Expect `blocked` signals mid-turn on the non-default modes |
| ANSI escapes survive the strip pipeline | `sed -e 's/\x1b…'` on macOS: `\x1b` is GNU-only, BSD sed matches nothing and strips nothing. Use the `ESC=$(printf '\033')` form above |
| `wait-output` times out although the pane shows the text | multi-word match against a TUI screen; the stream has no spaces there, match one token |
+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
+17 -16
View File
@@ -69,9 +69,14 @@ SEQ=1 # $CID is the fixed literal from the preamble; never rebuild
# plus a two-marker screen match in session-trust-dialog.ts), not the output stream.
# It still misses two ways, and both leave the dialog up until someone answers it:
# it only scans in the first 90 s after the pane started (TRUST_DIALOG_WINDOW_MS),
# and it gives up after 3 Enter presses (TRUST_DIALOG_MAX_ATTEMPTS). So: composer
# marker first, dialog only as the bounded fallback (a blind Enter up front would
# land in an already-ready composer).
# and it gives up after 6 keystrokes (TRUST_DIALOG_MAX_ATTEMPTS). So: composer
# marker first, dialog only as the bounded fallback.
# ⚠️ The dialog is NOT answered with Enter. Since claude-cli 2.1.252 the options
# lost their numbers, swapped places, and the highlighted one is `No, exit`, so a
# blind \r quits the CLI and the pane is dead seconds after the spawn (measured).
# _accept_trust (§0 preamble) reads the ❯ marker off the rendered pane, arrows onto
# `Yes, I trust this folder`, re-reads to confirm the move landed, and only then
# presses Enter.
# Stage 1 is SHORT on purpose: an already-trusted case matches in <1 s, while a
# virgin case can never pass it (the dialog is up) and always pays it in full,
# the long budget belongs to stage 3, after the dialog is answered.
@@ -92,13 +97,8 @@ done
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
if jq -e '.data.wait.matched' <<<"$T" >/dev/null; then
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
SEQ=$((SEQ+1))
fi
_accept_trust "$SID" # reads the marker and steers; never a blind \r. Own clientId,
# so it spends none of $SEQ's numbers.
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000')
fi
@@ -106,8 +106,8 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
# stage 4, mode-agnostic and bounded: answering a trivial prompt IS readiness.
# COSTS THE WORKER ONE BILLED TURN, so it only runs when the fast marker missed.
# Split token (the typed line echoes into the stream) and unique per call. Must stay
# AFTER the dialog fallback: free text plus \r into a trust dialog still up answers
# it blind, the same footgun as an up-front Enter.
# AFTER the dialog fallback: the select widget swallows the text and the \r answers
# whatever is highlighted, which on a live dialog is `No, exit`.
TOK="${RANDOM}_$$"
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"reply with the word READY immediately followed by _'"$TOK"' and nothing else\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
@@ -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
@@ -527,9 +527,10 @@ done
circuit breaker, which exists to stop a worker that crashes on every start from being
restarted in a loop; clearing it unasked re-arms that loop.
- Then run **Flow 1's readiness stages 1-3** on each SID. A path claude has never been
run in shows the trust dialog, and typing your task into a dialog answers it blind and
loses the task. Stages 1-3 cost no turn; stage 4, if it fires, costs that worker one
billed turn.
run in shows the trust dialog, and typing your task into it does not just lose the
task: the select widget swallows the text and the trailing `\r` answers the
highlighted option, which since claude-cli 2.1.252 is `No, exit`. Stages 1-3 cost no
turn; stage 4, if it fires, costs that worker one billed turn.
### 4. Hand out the tasks: markers, not send-and-wait
+33 -17
View File
@@ -171,10 +171,27 @@ itself, reliably enough that stage 1 usually just works: `_maybeAcceptTrustDialo
reads the **rendered pane** via `capturePaneText()` rather than the arriving chunk
(the per-chunk `includes()` version could never match, because tmux repaints the row
with cursor-forward escapes in place of spaces, and it is documented in-source as the
historical bug). The remaining miss modes are structural: the auto-accept only runs
inside a 90 s window after interactive start and gives up after 3 attempts. So keep
the dialog handling as a bounded fallback, and never send a blind Enter up front (if
auto-accept already fired, it lands in the composer).
historical bug).
⚠️ **The answer is no longer "press Enter".** Claude Code 2.1.252 dropped the option
numbers, reversed the two options, and highlights the one that quits:
```
❯ No, exit
Yes, I trust this folder
Enter to confirm · Esc to cancel
```
so a blind `\r` answers *exit*: the pane is dead (`Pane is dead (status 1)`) about six
seconds after the spawn, measured on a fresh case. Read the marker off the rendered
pane (`GET .../terminal?full=1`), send `ESC [ B` while it sits on `No, exit`, re-read,
and press Enter only once the marker is on the trust option. `_accept_trust` in the
§0 preamble is exactly that, and `trustDialogNextKey()` is the server-side twin.
The remaining miss modes are structural: the auto-accept only runs inside a 90 s window
after interactive start and gives up after 6 keystrokes. So keep the dialog handling as
a bounded fallback, and never send a blind Enter up front — landing in an already-ready
composer only wastes a turn, landing in this dialog ends the worker.
Stage 1 is short on purpose: an already-trusted case matches `shift+tab` in under a
second, while a case still showing the dialog cannot pass stage 1 at all and always
@@ -232,14 +249,11 @@ SEQ=1 # $CID came from the §0 preamble; do NOT rebuild it from $$
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=5000')
if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
# composer never appeared, so the trust dialog is probably still up; accept it once
T=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=trust' --data-urlencode 'from=buffer' --data-urlencode 'timeout=2000')
if jq -e '.data.wait.matched' <<<"$T" >/dev/null; then
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
SEQ=$((SEQ+1))
fi
# Composer never appeared, so the trust dialog is probably still up. NEVER a blind
# Enter here: the highlighted option is "No, exit". _accept_trust (§0 preamble) reads
# the marker off the pane, arrows onto the trust option, re-reads, then confirms. It
# carries its OWN clientId, so it spends none of $SEQ's numbers.
_accept_trust "$SID"
R=$("${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000')
fi
@@ -248,8 +262,10 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then
# of a broken worker, and answering is proof that it works. Split the token (your
# keystrokes echo into the stream) and keep it unique per call. This costs the worker
# one billed turn, so it runs only after the fast path missed. It must stay AFTER
# stage 2, which is the only thing that clears the trust dialog: free text plus \r
# into a dialog still up answers it blind, the same footgun as the up-front Enter.
# stage 2, which is the only thing that clears the trust dialog: the typed text is
# swallowed by the select widget and the \r then answers whatever is highlighted,
# which since 2.1.252 is "No, exit" -- the same footgun as the up-front Enter, except
# that it kills the worker rather than wasting a turn.
TOK="${RANDOM}_$$"
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"reply with the word READY immediately followed by _'"$TOK"' and nothing else\r","useMux":true,"clientId":"'"$CID"'","seq":'$SEQ'}' >/dev/null
@@ -357,7 +373,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 +415,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 +501,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
+4 -1
View File
@@ -15,6 +15,7 @@ import { existsSync, readFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { dataPath } from './config/instance.js';
import { casePath } from './config/cases-dir.js';
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
@@ -146,7 +147,9 @@ export function resolveCliCasePath(name: string): string {
} catch {
// no registry yet, or unreadable/invalid JSON: fall through to the cases dir
}
return join(homedir(), 'codeman-cases', name);
// Same resolver the server uses, so CODEMAN_CASES_PATH (Docker Compose) moves
// the CLI's idea of a case with it instead of leaving it on the home default.
return casePath(name);
}
/**
+37
View File
@@ -0,0 +1,37 @@
/**
* @fileoverview Where case (project) folders live.
*
* Deliberately NOT instance-scoped, unlike `dataPath()`: `~/codeman-cases` is
* shared by every Codeman on the machine, the same way `~/codeman-users/<u>`
* user spaces are, so a beta instance sees the same projects as prod.
*
* `CODEMAN_CASES_PATH` overrides the location. Docker Compose deployments set
* it to a host-absolute bind mount so a Docker case's workspace resolves to the
* SAME absolute path inside Codeman and on the host daemon that mounts it.
*
* ⚠️ **One resolver, every caller.** This started life as three hardcoded
* `join(homedir(), 'codeman-cases')` copies. When only the web server's copy
* learned the override, `codeman skill install --case <name>` still looked in
* the home default and reported "Case not found" on exactly the deployment the
* override exists for. A new cases-dir consumer imports this; it does not
* rebuild the path.
*
* (`state-store.ts` keeps its own literal on purpose: that one migrates the
* historical `~/claudeman-cases` directory to `~/codeman-cases` by name, and is
* about the old default location rather than the active one.)
*
* @module config/cases-dir
*/
import { homedir } from 'node:os';
import { join } from 'node:path';
/** Absolute path to the shared cases directory. */
export function getCasesDir(): string {
return process.env.CODEMAN_CASES_PATH || join(homedir(), 'codeman-cases');
}
/** Absolute path to one case folder inside it. */
export function casePath(name: string): string {
return join(getCasesDir(), name);
}
+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',
+45 -5
View File
@@ -23,7 +23,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { createHash } from 'node:crypto';
@@ -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;
}
@@ -276,6 +277,24 @@ export interface DockerMount {
readonly?: boolean;
}
/**
* Resolve a bind source into the Docker daemon's filesystem namespace.
*
* A bare-host Codeman process and its Docker daemon see the same HOME, so the
* source is returned unchanged. In Docker-outside-of-Docker deployments,
* `runtimeHome` is the path inside Codeman while `daemonHome` is the host path
* bind-mounted there. Sources beneath HOME must therefore be translated before
* they are sent through the Docker socket.
*/
export function resolveDockerDaemonMountSource(source: string, runtimeHome: string, daemonHome?: string): string {
const configuredDaemonHome = daemonHome?.trim();
if (!configuredDaemonHome) return source;
const relativeSource = relative(resolve(runtimeHome), resolve(source));
if (relativeSource.startsWith('..') || isAbsolute(relativeSource)) return source;
return resolve(configuredDaemonHome, relativeSource);
}
/**
* Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager)
* resolves the environment-dependent bits (host uid, existing cred mounts, the
@@ -299,6 +318,8 @@ export interface DockerCreateContext {
addHostGateway: boolean;
/** Engine host-gateway alias (host.docker.internal / host.containers.internal). */
gatewayAlias: string;
/** Omit --memory-swap when the host kernel cannot enforce swap limits. */
disableSwapLimit?: boolean;
}
/**
@@ -316,12 +337,15 @@ function mountSpec(m: DockerMount): string {
return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`;
}
function resourceFlags(resources?: DockerResourceLimits): string[] {
function resourceFlags(resources?: DockerResourceLimits, disableSwapLimit = false): string[] {
if (!resources) return [];
const flags: string[] = [];
if (resources.memory) {
// memory-swap == memory disables swap, making --memory a REAL OOM cap.
flags.push('--memory', resources.memory, '--memory-swap', resources.memory);
flags.push('--memory', resources.memory);
// memory-swap == memory disables swap where the daemon supports swap
// accounting. Some kernels, including the deployed Unraid host, do not;
// requesting it there emits a warning and Docker ignores the value.
if (!disableSwapLimit) flags.push('--memory-swap', resources.memory);
}
if (resources.cpus) flags.push('--cpus', resources.cpus);
if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit));
@@ -386,7 +410,7 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] {
if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`);
args.push(
...resourceFlags(docker.resources),
...resourceFlags(docker.resources, ctx.disableSwapLimit),
// GPU passthrough (needs the NVIDIA container toolkit on the host). No storage
// cap is set, so the container's writable layer + volumes grow elastically as
// data flows in (bounded only by host disk).
@@ -639,6 +663,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;
}
+93 -9
View File
@@ -1,16 +1,32 @@
/**
* @fileoverview Recognizing Claude Code's workspace-trust dialog on screen.
* @fileoverview Recognizing Claude Code's workspace-trust dialog on screen, and
* working out which keystroke answers it.
*
* Claude asks once per directory before it will read or edit anything:
* Claude asks once per directory before it will read or edit anything. The
* layout has changed under us at least twice; both of these are live shapes:
*
* Quick safety check: Is this a project you created or one you trust? ...
* Quick safety check: Is this a project you created or one you trust? ... (<= 2.1.220)
* ❯ 1. Yes, I trust this folder
* 2. No, exit
* Enter to confirm · Esc to cancel
*
* Quick safety check: Is this a project you created or one you trust? ... (2.1.252)
* Security guide
* ❯ No, exit
* Yes, I trust this folder
* Enter to confirm · Esc to cancel
*
* Codeman sessions run permission-skipping or classifier-guarded modes, so the
* answer is always yes, and a session parked on this dialog is simply stuck.
*
* ⚠️ **Never press Enter without reading the selection.** The options are now
* unnumbered, REVERSED, and the highlighted default is "No, exit" — so the blind
* `\r` that answered the old layout picks *exit* on the new one and the pane
* dies (`Pane is dead (status 1)`) seconds after the session starts, which is
* exactly what a fresh case did on Claude Code 2.1.252. `trustDialogNextKey()`
* reads the `❯` marker instead and moves the cursor onto the trust option before
* it confirms anything.
*
* **Why the text has to be compacted.** tmux repaints a row by writing each word
* and then a cursor-forward (`\x1b[C`) instead of a space, and Ink colours each
* word separately, so the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`.
@@ -39,6 +55,25 @@ const TRUST_PHRASES = [
/** The dialog's own affordances. Prose that quotes the question will not have these. */
const CONFIRM_PHRASES = ['entertoconfirm', 'esctocancel', '2.no,exit'];
/** The option that answers yes, compacted. Identical text in both layouts. */
const YES_OPTION = 'yes,itrustthisfolder';
/** The option that quits Claude. It is the highlighted DEFAULT since 2.1.252. */
const NO_OPTION = 'no,exit';
/** Ink's selection marker. The only marked row while the dialog is up. */
const SELECTION_MARK = '❯';
/** A numbered option's `1.` / `2.` prefix, which the 2.1.220 layout put after the marker. */
const OPTION_NUMBER_PREFIX = /^\d+\./;
/** Move the selection one row down / up. Literal, so `send-keys -l` carries them. */
export const TRUST_KEY_DOWN = '\x1b[B';
export const TRUST_KEY_UP = '\x1b[A';
/** Confirm the highlighted option. */
export const TRUST_KEY_CONFIRM = '\r';
/**
* Charset-select sequences (`ESC ( B`), which tmux emits around styled runs and
* `stripAnsi` does not cover. Left in, they would land inside a phrase as a
@@ -65,6 +100,50 @@ export function isTrustDialogScreen(text: string): boolean {
return TRUST_PHRASES.some((p) => compact.includes(p)) && CONFIRM_PHRASES.some((p) => compact.includes(p));
}
/**
* Which option the `❯` marker sits on, or null when this text does not say.
*
* The LAST marked option wins. A pane capture holds exactly one frame and so
* exactly one marker, but the direct-PTY fallback reads an append-only buffer
* where every repaint since launch is still present — there the freshest frame
* is the one at the end, and an older one must not out-vote it.
*/
function selectedTrustOption(compact: string): { at: number; option: 'yes' | 'no' } | null {
let selected: { at: number; option: 'yes' | 'no' } | null = null;
for (let at = compact.indexOf(SELECTION_MARK); at >= 0; at = compact.indexOf(SELECTION_MARK, at + 1)) {
const after = compact.slice(at + SELECTION_MARK.length).replace(OPTION_NUMBER_PREFIX, '');
if (after.startsWith(YES_OPTION)) selected = { at, option: 'yes' };
else if (after.startsWith(NO_OPTION)) selected = { at, option: 'no' };
}
return selected;
}
/**
* The single keystroke that moves this dialog one step closer to "yes", or null
* when the screen does not show clearly enough to touch.
*
* One step per call on purpose: the caller re-reads the screen between
* keystrokes, so a moved cursor is CONFIRMED before Enter is pressed rather than
* assumed. Firing arrow+Enter together would re-create the failure this exists
* to prevent whenever the arrow is dropped (Ink drops keystrokes while it is
* still mounting a widget) — the Enter would then land on "No, exit".
*
* Returning null is the safe answer, not a failure: an unreadable frame means
* wait for the next repaint, and a layout whose options this cannot name means
* leave the dialog to the human. The caller's startup window bounds the waiting.
*/
export function trustDialogNextKey(text: string): string | null {
const compact = compactScreenText(text);
if (!compact.includes(YES_OPTION)) return null; // no trust option to steer onto
const selected = selectedTrustOption(compact);
if (!selected) return null; // marker missing, or not on an option we recognize
if (selected.option === 'yes') return TRUST_KEY_CONFIRM;
// On "No, exit". Which way the trust option lies is read from THIS frame — it
// sits below in 2.1.252 and above in the numbered layout before it — so the
// order flipping again costs a repaint, not a killed session.
return compact.includes(YES_OPTION, selected.at) ? TRUST_KEY_DOWN : TRUST_KEY_UP;
}
/**
* How long after the pane starts the dialog is still plausible. It renders
* before the main UI, so this only has to cover a slow first launch; leaving it
@@ -72,16 +151,21 @@ export function isTrustDialogScreen(text: string): boolean {
*/
export const TRUST_DIALOG_WINDOW_MS = 90_000;
/** Minimum gap between two Enter presses, and between two screen reads. */
/** Minimum gap between two keystrokes, and between two screen reads. */
export const TRUST_DIALOG_RETRY_MS = 1500;
/**
* Attempts before giving up and leaving the dialog to the user. A keystroke can
* land while Ink is still mounting the widget and be dropped, which is the other
* half of why sessions got stuck here; retrying costs nothing, but retrying
* forever would hammer Enter into whatever came next.
* Keystrokes before giving up and leaving the dialog to the user. A keystroke
* can land while Ink is still mounting the widget and be dropped, which is the
* other half of why sessions got stuck here; retrying costs nothing, but
* retrying forever would hammer Enter into whatever came next.
*
* Six rather than three because answering is no longer one press: the 2.1.252
* layout needs an arrow onto the trust option and then Enter, each confirmed
* against a re-read of the screen, so a cap of three left only one dropped
* keystroke of slack.
*/
export const TRUST_DIALOG_MAX_ATTEMPTS = 3;
export const TRUST_DIALOG_MAX_ATTEMPTS = 6;
/**
* How much of the append-only terminal buffer to read on a direct-PTY session,
+168 -10
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';
@@ -64,6 +66,8 @@ import { RalphTracker } from './ralph-tracker.js';
import { BashToolParser } from './bash-tool-parser.js';
import {
isTrustDialogScreen,
trustDialogNextKey,
TRUST_KEY_CONFIRM,
TRUST_DIALOG_WINDOW_MS,
TRUST_DIALOG_RETRY_MS,
TRUST_DIALOG_MAX_ATTEMPTS,
@@ -180,7 +184,8 @@ export function isExternalCliMode(mode: SessionMode): boolean {
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek'
mode === 'deepseek' ||
mode === 'omp'
);
}
@@ -200,6 +205,8 @@ function getModeLabel(mode: SessionMode): string {
return 'Grok';
case 'deepseek':
return 'DeepSeek';
case 'omp':
return 'OMP';
case 'shell':
return 'Shell';
case 'claude':
@@ -450,8 +457,9 @@ export class Session extends EventEmitter {
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
private _trustDialogAttempts = 0; // Enter presses sent at the trust dialog
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
private _trustDialogTimer: NodeJS.Timeout | null = null; // Re-read after a keystroke (see below)
private _interactiveStartedAt = 0; // When the interactive pane launched (bounds that scan)
private _taskTracker: TaskTracker;
@@ -528,6 +536,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 +637,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 +694,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 +753,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 +1390,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 +1517,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 +1612,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 +1647,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 +1667,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.
*
@@ -1765,6 +1844,10 @@ export class Session extends EventEmitter {
this._interactiveStartedAt = Date.now();
this._trustDialogAttempts = 0;
this._lastTrustDialogScanAt = 0;
if (this._trustDialogTimer) {
clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = null;
}
// COD-118: if the PTY exit breaker has tripped (repeated non-zero exits in a
// short window), refuse to respawn. This is the uniform choke point that stops
@@ -1877,6 +1960,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 +1972,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 +2097,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);
@@ -2141,6 +2236,15 @@ export class Session extends EventEmitter {
* makes a retry safe, since the terminal buffer is append-only and keeps the
* dialog in its tail long after it has been answered.
*
* ⚠️ **The keystroke is read off the screen, never assumed.** Claude Code
* 2.1.252 dropped the option numbers, put "No, exit" first, and highlights IT
* by default, so the bare `\r` this used to send now answers *exit*: a fresh
* case died (`Pane is dead (status 1)`) about six seconds after spawning.
* `trustDialogNextKey()` returns one step at a time — an arrow while the
* cursor is on the wrong option, Enter only once the screen shows it on the
* trust option — and this method re-reads the pane between the two, so a
* dropped arrow costs a repaint instead of the session.
*
* Three guards keep an Enter press off a live session: a startup-only window,
* a two-marker match (isTrustDialogScreen), and an attempt cap.
*/
@@ -2161,17 +2265,39 @@ export class Session extends EventEmitter {
this._terminalBuffer.value.slice(-TRUST_DIALOG_SCAN_BYTES);
if (!isTrustDialogScreen(screen)) return;
// Null means the frame does not say which option is highlighted. Waiting for
// the next repaint is the safe move; pressing Enter blind is the bug.
const key = trustDialogNextKey(screen);
if (key === null) return;
this._trustDialogAttempts++;
if (this._trustDialogAttempts > TRUST_DIALOG_MAX_ATTEMPTS) {
this._trustDialogAccepted = true; // leave it to the user rather than keep typing
console.warn(`[Session] Workspace trust dialog did not clear after retries: ${this.id}`);
return;
}
const step = key === TRUST_KEY_CONFIRM ? 'confirming' : 'moving to the trust option';
console.log(
`[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts})`
`[Session] Auto-accepting workspace trust dialog for: ${this.id} (attempt ${this._trustDialogAttempts}, ${step})`
);
// Enter confirms the highlighted default, "1. Yes, I trust this folder".
this.writeViaMux('\r');
this.writeViaMux(key);
// ⚠️ Schedule the next read; do NOT wait for more PTY output. This scan only
// ever ran from `onData`, which was enough while one Enter answered the
// dialog. It is not enough now: the arrow that moves the cursor is the LAST
// output the pane produces, so a dialog left sitting on the trust option
// never gets its Enter and the worker stays parked on it forever (measured
// on a live 2.1.252 spawn: cursor moved at 6 s, then nothing). The timer is
// one-shot and self-rearming through this same path, and every exit route
// goes through _clearAllTimers().
// The +100ms puts the re-entry OUTSIDE the scan throttle above; firing at
// exactly the throttle boundary would let the scan return early and break
// the chain with the dialog still on screen.
if (this._trustDialogTimer) clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = setTimeout(() => {
this._trustDialogTimer = null;
this._maybeAcceptTrustDialog();
}, TRUST_DIALOG_RETRY_MS + 100);
}
/**
@@ -2298,10 +2424,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
@@ -2670,6 +2822,12 @@ export class Session extends EventEmitter {
}
private _clearAllTimers(): void {
// Clear the workspace-trust follow-up read
if (this._trustDialogTimer) {
clearTimeout(this._trustDialogTimer);
this._trustDialogTimer = null;
}
// Clear activity timeout to prevent memory leak
if (this.activityTimeout) {
clearTimeout(this.activityTimeout);
+78 -7
View File
@@ -54,6 +54,7 @@ import {
type PiConfig,
type GrokConfig,
type DeepSeekConfig,
type OmpConfig,
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
@@ -74,6 +75,7 @@ import {
hostGatewayAlias,
resolveDockerClaudeArtifacts,
resolveDockerCredentialArtifacts,
resolveDockerDaemonMountSource,
type DockerCreateContext,
type DockerMount,
type DockerSeedCopy,
@@ -99,6 +101,8 @@ import {
resolveDeepSeekDir,
getDeepSeekNotFoundMessage,
resolveDefaultDeepSeekProfile,
getOmpNotFoundMessage,
resolveOmpDir,
resolveLocalShell,
loginShellArgs,
} from './utils/index.js';
@@ -896,6 +900,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 +973,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 +1029,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 +1215,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 +1249,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
@@ -1319,8 +1355,23 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
const startFailMsg = shellescape(`Codeman: container ${docker.containerName} failed to start (docker daemon down?)`);
const imageCheck = `${base} image inspect ${image} >/dev/null 2>&1 || { echo ${imageMissingMsg}; exit 1; }`;
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact chain.
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${base} ${createArgs}`;
// create-if-missing (idempotent): reconnect / boot recovery re-runs this exact
// chain. A daemon without swap accounting warns whenever --memory is present,
// even when --memory-swap is omitted. In compatibility mode, retain the memory
// cap and filter ONLY that exact warning; all other stdout/stderr and the real
// create exit status are preserved so mount/config failures remain visible.
// A session-unique file avoids shell variables and command substitution, both
// of which would be expanded too early by the nested bash/tmux launch layers.
const createOutputPath = shellescape(`/tmp/codeman-create-${sessionId}.log`);
const filteredCreateOutput = `sed '/^WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted\\. Memory limited without swap\\.$/d' ${createOutputPath}`;
const removeCreateOutput = `rm -f ${createOutputPath}`;
const createCommand = createContext.disableSwapLimit
? `{ if ${base} ${createArgs} >${createOutputPath} 2>&1; ` +
`then ${filteredCreateOutput}; ${removeCreateOutput}; ` +
`elif ${base} inspect ${name} >/dev/null 2>&1; then ${removeCreateOutput}; ` +
`else ${filteredCreateOutput} >&2; ${removeCreateOutput}; false; fi; }`
: `${base} ${createArgs}`;
const ensure = `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
const start = `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
// Seed writable credential config from read-only host mounts ONCE per container
// (guarded by [ -e ] so reconnects never clobber in-container config; `cp -a` for
@@ -1431,11 +1482,18 @@ export function resolveDockerLaunchOptions(
sessionId,
instance: CODEMAN_INSTANCE,
userArgs,
credentialMounts,
extraMounts,
credentialMounts: credentialMounts.map((mount) => ({
...mount,
src: resolveDockerDaemonMountSource(mount.src, home, process.env.CODEMAN_DOCKER_HOST_HOME),
})),
extraMounts: extraMounts.map((mount) => ({
...mount,
src: resolveDockerDaemonMountSource(mount.src, home, process.env.CODEMAN_DOCKER_HOST_HOME),
})),
envCreate,
addHostGateway: !isDesktop,
gatewayAlias,
disableSwapLimit: process.env.CODEMAN_DOCKER_DISABLE_SWAP_LIMIT === '1',
};
const execEnv: Record<string, string> = {
@@ -1810,7 +1868,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 +1877,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 +1983,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 +2097,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
envOverrides,
effort,
@@ -2099,6 +2164,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 +2183,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
effort,
sessionName: name,
@@ -2345,6 +2414,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
envOverrides,
effort,
@@ -2376,6 +2446,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) */
+44 -2
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Pure parsing + formatting of Claude Code statusline telemetry.
* @fileoverview Pure parsing + formatting of Claude and Codex plan telemetry.
*
* Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command`
* on each render. On Pro/Max subscriptions that blob carries a `rate_limits`
@@ -15,7 +15,10 @@
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
* before the first API response and for non-subscriber auth — both yield null.
*
* All functions are pure for testability. See `test/usage-telemetry.test.ts`.
* The Codex parser consumes the read-only `account/rateLimits/read` app-server
* response and selects only the main `codex` bucket, excluding model-specific
* buckets. All functions are pure for testability. See
* `test/usage-telemetry.test.ts` and `test/codex-plan-usage.test.ts`.
*
* @module usage-telemetry
*/
@@ -51,6 +54,22 @@ export interface RawStatuslinePayload {
model?: { display_name?: string };
}
interface RawCodexRateLimitWindow {
usedPercent?: unknown;
windowDurationMins?: unknown;
resetsAt?: unknown;
}
interface RawCodexRateLimitSnapshot {
primary?: RawCodexRateLimitWindow | null;
secondary?: RawCodexRateLimitWindow | null;
}
interface RawCodexRateLimitsResponse {
rateLimits?: RawCodexRateLimitSnapshot | null;
rateLimitsByLimitId?: Record<string, RawCodexRateLimitSnapshot | null> | null;
}
function clampPct(n: number): number {
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
@@ -88,6 +107,29 @@ export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): St
return t;
}
/** Normalize the main Codex app-server bucket into the chip's two known windows. */
export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | null {
if (!value || typeof value !== 'object') return null;
const response = value as RawCodexRateLimitsResponse;
const snapshot = response.rateLimitsByLimitId?.codex ?? response.rateLimits;
if (!snapshot || typeof snapshot !== 'object') return null;
const telemetry: StatusTelemetry = {};
for (const window of [snapshot.primary, snapshot.secondary]) {
if (!window || typeof window.usedPercent !== 'number' || !Number.isFinite(window.usedPercent)) continue;
if (window.windowDurationMins !== 300 && window.windowDurationMins !== 10_080) continue;
const resetsAt =
typeof window.resetsAt === 'number' && Number.isFinite(window.resetsAt) && window.resetsAt > 0
? Math.round(window.resetsAt * 1000)
: 0;
const normalized = { usedPercentage: clampPct(window.usedPercent), resetAt: resetsAt };
if (window.windowDurationMins === 300) telemetry.fiveHour = normalized;
if (window.windowDurationMins === 10_080) telemetry.sevenDay = normalized;
}
return telemetry.fiveHour || telemetry.sevenDay ? telemetry : null;
}
/**
* Current-session status for the in-terminal statusline footer. This is the
* "status of the current session" the user sees in Claude's footer — distinct
+86 -1
View File
@@ -9,7 +9,9 @@
import { join } from 'node:path';
import { homedir } from 'node:os';
import { spawn } from 'node:child_process';
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
import { parseCodexRateLimitsResponse, type StatusTelemetry } from '../usage-telemetry.js';
/** Common directories where the Codex CLI binary may be installed */
const CODEX_SEARCH_DIRS = [
@@ -21,7 +23,8 @@ const CODEX_SEARCH_DIRS = [
join(homedir(), 'bin'), // User bin
];
const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS });
const CODEX_BINARY = process.platform === 'win32' ? 'codex.exe' : 'codex';
const codexResolver = createCliExecutableResolver({ binary: CODEX_BINARY, searchDirs: CODEX_SEARCH_DIRS });
const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @openai/codex';
/**
@@ -35,6 +38,11 @@ export function resolveCodexDir(): string | null {
return codexResolver.resolve()?.directory ?? null;
}
/** Absolute Codex executable path, for direct app-server requests. */
export function resolveCodexBinaryPath(): string | null {
return codexResolver.resolve()?.binaryPath ?? null;
}
/**
* Check if Codex CLI is available on the system.
*/
@@ -45,3 +53,80 @@ export function isCodexAvailable(): boolean {
export function getCodexNotFoundMessage(): string {
return formatCliNotFoundMessage(CODEX_NOT_FOUND, codexResolver.diagnostics());
}
type CodexRateLimitsRequest = (binaryPath: string, clientVersion: string) => Promise<unknown>;
const APP_SERVER_TIMEOUT_MS = 10_000;
const APP_SERVER_MAX_OUTPUT_BYTES = 256 * 1024;
function requestCodexRateLimits(binaryPath: string, clientVersion: string): Promise<unknown> {
return new Promise((resolve) => {
let settled = false;
let initialized = false;
let buffer = '';
const child = spawn(binaryPath, ['app-server', '--stdio'], {
stdio: ['pipe', 'pipe', 'ignore'],
windowsHide: true,
});
const timeout = setTimeout(() => finish(null), APP_SERVER_TIMEOUT_MS);
const finish = (value: unknown): void => {
if (settled) return;
settled = true;
clearTimeout(timeout);
child.stdin.end();
child.kill();
resolve(value);
};
const send = (message: unknown): void => {
if (!settled && child.stdin.writable) child.stdin.write(`${JSON.stringify(message)}\n`);
};
const handleLine = (line: string): void => {
if (!line.trim()) return;
let message: { id?: number; result?: unknown; error?: unknown };
try {
message = JSON.parse(line) as { id?: number; result?: unknown; error?: unknown };
} catch {
return;
}
if (message.id === 1) {
if (message.error) return finish(null);
if (!initialized) {
initialized = true;
send({ method: 'account/rateLimits/read', id: 2 });
}
} else if (message.id === 2) {
finish(message.error ? null : message.result);
}
};
child.on('error', () => finish(null));
child.on('close', () => finish(null));
child.stdin.on('error', () => finish(null));
child.stdout.on('data', (chunk: Buffer) => {
buffer += chunk.toString('utf8');
if (Buffer.byteLength(buffer) > APP_SERVER_MAX_OUTPUT_BYTES) return finish(null);
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? '';
for (const line of lines) handleLine(line);
});
send({
method: 'initialize',
id: 1,
params: {
clientInfo: { name: 'codeman', title: 'Codeman', version: clientVersion },
capabilities: null,
},
});
});
}
/** Read the signed-in host account's main Codex limits without exposing credentials. */
export async function readCodexPlanUsage(
binaryPath: string,
clientVersion: string,
request: CodexRateLimitsRequest = requestCodexRateLimits
): Promise<StatusTelemetry | null> {
return parseCodexRateLimitsResponse(await request(binaryPath, clientVersion));
}
+8 -1
View File
@@ -37,7 +37,13 @@ export {
} from './claude-cli-resolver.js';
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
export { resolveOpenCodeDir, getOpenCodeNotFoundMessage } from './opencode-cli-resolver.js';
export { resolveCodexDir, isCodexAvailable, getCodexNotFoundMessage } from './codex-cli-resolver.js';
export {
resolveCodexDir,
resolveCodexBinaryPath,
isCodexAvailable,
getCodexNotFoundMessage,
readCodexPlanUsage,
} from './codex-cli-resolver.js';
export { resolveGeminiDir, isGeminiAvailable, getGeminiNotFoundMessage } from './gemini-cli-resolver.js';
export {
resolveAntigravityDir,
@@ -61,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;
}
+14 -6
View File
@@ -1,10 +1,11 @@
/**
* @fileoverview Process-wide last-known plan-usage telemetry (account-global).
*
* The status-telemetry route writes the latest broadcast value here; the SSE
* init snapshot (`getLightState`) replays it so the header "Plan Usage Limits"
* chip shows immediately on a fresh page load / SSE reconnect — before any new
* statusline render arrives, and without relying on per-browser localStorage.
* The Claude status-telemetry route and host Codex poll merge their latest
* values here. The SSE init snapshot (`getLightState`) replays the combined
* value so the header "Plan Usage Limits" chip shows immediately on a fresh
* page load / SSE reconnect — before either source emits another sample, and
* without relying on per-browser localStorage.
*
* Null until the first telemetry of the process; cleared naturally on restart.
*
@@ -13,8 +14,15 @@
let latest: Record<string, unknown> | null = null;
export function setLatestPlanUsage(value: Record<string, unknown>): void {
latest = value;
export function setLatestPlanUsage(value: Record<string, unknown>): Record<string, unknown> {
const codex = latest?.codex;
latest = { ...value, ...(codex !== undefined ? { codex } : {}) };
return latest;
}
export function setLatestCodexPlanUsage(value: object | null): Record<string, unknown> {
latest = { ...(latest ?? {}), codex: value };
return latest;
}
export function getLatestPlanUsage(): Record<string, unknown> | null {
+123 -43
View File
@@ -679,12 +679,19 @@ class CodemanApp {
// Terminal write batching with DEC 2026 sync support
this.pendingWrites = [];
this.writeFrameScheduled = false;
// xterm.write() parses asynchronously. Keep at most one live-output chunk
// inside xterm so its private WriteBuffer cannot bypass our 128KB cap.
this._terminalWriteInFlight = false;
this._terminalWriteInFlightBytes = 0;
this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll
this.syncWaitTimeout = null; // Timeout for incomplete sync blocks
this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes
this._loadBufferQueue = null; // queued SSE events during buffer load
this._bufferLoadSeq = 0;
this._bufferLoadOwner = null;
// Single-flight token for terminal buffer recovery. The identity check also
// lets a session switch invalidate an older fetch without blocking the new tab.
this._terminalRefreshOwner = null;
// Flicker filter state (buffers output after screen clears)
this.flickerFilterBuffer = '';
@@ -1606,7 +1613,15 @@ class CodemanApp {
this._sseHandlerWrappers = new Map();
for (const [event, method] of _SSE_HANDLER_MAP) {
const fn = this[method];
const wsOwnsTerminal =
method === '_onSSETerminal' ||
method === '_onSSENeedsRefresh' ||
method === '_onSSEClearTerminal';
this._sseHandlerWrappers.set(event, (e) => {
// While WS owns terminal I/O, the parallel SSE stream is redundant.
// Drop it before JSON.parse so a busy terminal cannot turn duplicate
// SSE traffic/backpressure into another expensive buffer replay.
if (wsOwnsTerminal && this._wsReady) return;
try {
fn.call(this, e.data ? JSON.parse(e.data) : {});
} catch (err) {
@@ -1853,18 +1868,18 @@ class CodemanApp {
if (this.sessions.size === 0) this.stopSystemStatsPolling();
}
// SSE wrappers — skip terminal events when WebSocket is delivering for this session.
// SSE wrappers — skip terminal events while WebSocket owns active terminal I/O.
// WS handler calls the underlying _onSession* methods directly.
_onSSETerminal(data) {
if (this._wsReady && this._wsSessionId === data.id) return;
if (this._wsReady) return;
this._onSessionTerminal(data);
}
_onSSENeedsRefresh(data) {
if (this._wsReady && this._wsSessionId === data?.id) return;
if (this._wsReady) return;
this._onSessionNeedsRefresh(data);
}
_onSSEClearTerminal(data) {
if (this._wsReady && this._wsSessionId === data?.id) return;
if (this._wsReady) return;
this._onSessionClearTerminal(data);
}
@@ -1872,15 +1887,15 @@ class CodemanApp {
if (data.id === this.activeSessionId) {
if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`);
// Hard cap: track total bytes queued in render buffers (pendingWrites +
// flickerFilterBuffer). When rAF is throttled (tab
// backgrounded, GPU busy), data accumulates with no flush, reaching
// 889KB+ and freezing Chrome for minutes. Drop data beyond 128KB and
// schedule a buffer reload to recover the display once the burst subsides.
// Hard cap all app-owned render queues plus the one xterm chunk currently
// parsing. Check the incoming frame too; otherwise a single large frame can
// jump over the cap. Dropped data is recovered from the canonical buffer.
const queued = (this.pendingWrites?.reduce((s, w) => s + w.length, 0) || 0)
+ (this.flickerFilterBuffer?.length || 0);
if (queued > 131072) { // 128KB — drop to prevent accumulation
// Schedule a self-recovery: reload the full terminal buffer once the
+ (this.flickerFilterBuffer?.length || 0)
+ (this._loadBufferQueue?.reduce((s, w) => s + w.length, 0) || 0)
+ (this._terminalWriteInFlightBytes || 0);
if (queued + data.data.length > 131072) { // 128KB — drop to prevent accumulation
// Schedule a self-recovery once the
// queue drains (debounced to avoid hammering the API during sustained bursts).
if (!this._clientDropRecoveryTimer) {
this._clientDropRecoveryTimer = setTimeout(() => {
@@ -2255,9 +2270,11 @@ class CodemanApp {
? 'Grok'
: mode === 'deepseek'
? 'DeepSeek'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
: mode === 'omp'
? 'OMP'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
}
async toggleResponseViewer() {
@@ -2357,33 +2374,38 @@ class CodemanApp {
}
}
async _onSessionNeedsRefresh() {
async _onSessionNeedsRefresh(event = {}) {
// Server sends this after SSE backpressure clears — terminal data was dropped,
// so reload the buffer to recover from any display corruption.
if (!this.activeSessionId || !this.terminal) return;
const sessionId = this.activeSessionId;
if (event?.id && event.id !== sessionId) return;
if (!sessionId || !this.terminal) return;
// Skip if buffer load already in progress — avoids competing clear+rewrite cycles
if (this._isLoadingBuffer) return;
const sessionId = this.activeSessionId;
if (this._terminalRefreshOwner?.sessionId === sessionId) return;
const refreshOwner = { sessionId };
this._terminalRefreshOwner = refreshOwner;
try {
// Recovery should restore the WHOLE picture, so ask for full history
// rather than a tail. Measured on a 900-line shell pane: the tail rewrite
// replaced an 869-row buffer with 158 rows, so every backpressure refresh
// silently destroyed most of the scrollback it was meant to repair.
//
// A repaint-mode pane is the opposite case (tmux keeps ~one frame for it),
// so the full capture can be SMALLER than what xterm already holds. Reuse
// the same downgrade guard as the scroll-to-top re-pull and fall back to
// the historical tail there, leaving that case exactly as it was.
let res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`);
// A shell can retain a multi-megabyte/100k-line tmux history. Automatic
// recovery stays bounded just like normal shell selection; only the
// explicit "Load full history" action is allowed to pay for a full replay.
// TUI modes still recover the whole picture, with the downgrade guard for
// repaint-mode panes whose tmux capture can be smaller than xterm's buffer.
const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell';
let res = await fetch(
useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
);
let data = (await res.json())?.data ?? {};
if (data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
data = (await res.json())?.data ?? {};
}
// Bail on a tab switch mid-fetch: writing here would paint this session's
// history into the terminal the user is now looking at. The window is two
// fetches wide in the fallback case, so this guard is not optional.
if (this.activeSessionId !== sessionId) return;
if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return;
if (data.terminalBuffer) {
// This refresh is SERVER-triggered, so a user quietly reading scrollback
// did not ask for it and must not be dragged to the bottom by it (#259).
@@ -2413,6 +2435,8 @@ class CodemanApp {
}
} catch (err) {
console.error('needsRefresh reload failed:', err);
} finally {
if (this._terminalRefreshOwner === refreshOwner) this._terminalRefreshOwner = null;
}
}
@@ -2575,8 +2599,8 @@ class CodemanApp {
}
}
// Claude plan usage limits (5-hour + weekly) — account-global, so the latest
// sample from any session drives the shared header chip.
// Claude + Codex plan usage limits — account-global, so the latest sample
// drives the shared header chip.
_onSessionStatusTelemetry(data) {
this.updatePlanUsageChip(data);
// Persist last-known so the chip shows immediately on the next page load /
@@ -2603,9 +2627,6 @@ class CodemanApp {
const chip = document.getElementById('planUsageChip');
if (!chip || !data) return;
const pct = (w) => (w && typeof w.usedPercentage === 'number' ? Math.round(w.usedPercentage) : null);
const five = pct(data.fiveHour);
const seven = pct(data.sevenDay);
if (five === null && seven === null) return;
// Per-window color by how much is used up: green < 60%, yellow 60–84%, red ≥ 85%.
const colorClass = (p) => (p >= 85 ? 'pu-red' : p >= 60 ? 'pu-yellow' : 'pu-green');
// innerHTML here is XSS-safe ONLY because every interpolated value is a
@@ -2613,18 +2634,52 @@ class CodemanApp {
// string field (e.g. modelDisplayName, which the route also broadcasts) is
// ever shown in this chip, render it via textContent — never interpolate an
// untrusted string into this template.
const seg = (label, p) => {
if (p === null) return '';
// `idle: true` keeps a missing window's SLOT with a dimmed em dash instead of
// dropping it. Claude only: Claude Code documents `five_hour` as "present
// only while the API reports it and its resets_at has not passed", so that
// key leaves the statusline payload whenever no 5-hour session window is
// open, and a chip that silently shrank from two windows to one read as a
// broken feature rather than as an idle window (reported 2026-09-01). A
// missing CODEX bucket means the opposite — that plan has no such limit —
// so those stay omitted rather than showing a dash forever.
const seg = (label, p, idle) => {
if (p === null) {
if (!idle) return '';
return `<span class="pu-win pu-win-idle"><span class="pu-label">${label}</span><span class="pu-val">—</span></span>`;
}
const n = Math.round(Number(p));
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>`;
};
chip.innerHTML = [seg('5h', five), seg('7d', seven)].filter(Boolean).join('<span class="pu-sep">·</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, idle) => {
// hasWindows() gates the row, so a placeholder can only ever appear
// ALONGSIDE a real reading — a provider reporting nothing still renders
// nothing, never a row of em dashes.
if (!hasWindows(usage)) return '';
const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean);
if (!windows.length) return '';
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, true), row('Codex', data.codex, false)].filter(Boolean);
chip.innerHTML = rows.length ? rows.join('') : '—';
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
const details = (provider, usage, idle) => {
const lines = [];
const five = pct(usage?.fiveHour);
const seven = pct(usage?.sevenDay);
if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`);
else if (idle && seven !== null) lines.push('5-hour limit: no active session window');
if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`);
return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : '';
};
chip.title =
`Claude plan usage\n` +
`5-hour limit: ${five ?? '—'}% used (resets ${resetStr(data.fiveHour)})\n` +
`Weekly limit: ${seven ?? '—'}% used (resets ${resetStr(data.sevenDay)})`;
[details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') ||
'Plan usage limits';
}
// Scheduled runs
@@ -3466,11 +3521,21 @@ class CodemanApp {
this.flickerFilterActive = false;
// Clear pending terminal writes
this._clearTimer('syncWaitTimeout');
this._clearTimer('_clientDropRecoveryTimer');
this.pendingWrites = [];
this.writeFrameScheduled = false;
// Release the one-chunk-in-flight gate with the rest of the write queue.
// flushPendingWrites() early-returns while this is set, so a reset that
// cleared everything EXCEPT this flag would leave live output permanently
// stalled if xterm's parse callback never lands (disposed terminal, or a
// throw inside the async parse). A late callback is harmless: it clears an
// already-clear flag and schedules a flush.
this._terminalWriteInFlight = false;
this._terminalWriteInFlightBytes = 0;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
this._terminalRefreshOwner = null;
// Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
// Preserve local echo overlay text across SSE reconnect — just hide until
@@ -4856,7 +4921,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>
@@ -5334,11 +5399,20 @@ class CodemanApp {
this._tabCompletionBaseText = null;
this._clearTimer('_tabCompletionFallback');
this._clearTimer('_clientDropRecoveryTimer');
this._terminalRefreshOwner = null;
// Clean up pending terminal writes to prevent old session data from appearing in new session
this._clearTimer('syncWaitTimeout');
this.pendingWrites = [];
this.writeFrameScheduled = false;
// Release the one-chunk-in-flight gate with the rest of the write queue.
// flushPendingWrites() early-returns while this is set, so a reset that
// cleared everything EXCEPT this flag would leave live output permanently
// stalled if xterm's parse callback never lands (disposed terminal, or a
// throw inside the async parse). A late callback is harmless: it clears an
// already-clear flag and schedules a flush.
this._terminalWriteInFlight = false;
this._terminalWriteInFlightBytes = 0;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
@@ -5652,8 +5726,11 @@ class CodemanApp {
this._clearTimer('syncWaitTimeout');
this.pendingWrites = [];
this.writeFrameScheduled = false;
this._terminalWriteInFlight = false;
this._terminalWriteInFlightBytes = 0;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._terminalRefreshOwner = null;
this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
this.activeSessionId = null;
}
@@ -5684,6 +5761,7 @@ class CodemanApp {
this._cleanupPreviousSession(sessionId);
this.activeSessionId = sessionId;
this._activateFileBrowserSession?.(sessionId);
// Repaint the partial-history banner for the tab being switched TO. The
// replay paths refresh it when their fetch lands; without this the previous
// session's notice stays on screen until then (#258).
@@ -6291,7 +6369,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 -1
View File
@@ -10,7 +10,7 @@
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
* @globals {function} escapeHtml - XSS-safe HTML escaping
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (156 event types; must match backend src/web/sse-events.ts)
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (157 event types; must match backend src/web/sse-events.ts)
* @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
*
* @dependency None (first in load order)
+1
View File
@@ -80,6 +80,7 @@ const HOME_SESSIONS_MODE_BADGE = {
pi: 'pi',
grok: 'gk',
deepseek: 'ds',
omp: 'om',
};
Object.assign(CodemanApp.prototype, {
+18 -2
View File
@@ -192,7 +192,7 @@
<button class="btn-icon-header btn-file-viewer" onclick="app.toggleFileBrowserButton()" title="File Viewer" aria-label="Open file viewer" aria-expanded="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
<button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div>
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
@@ -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>
@@ -2020,6 +2028,8 @@
<div class="set-modelgrid" id="appSettingsModelCards" role="radiogroup" aria-label="Model for new Claude sessions" data-search="model opus sonnet haiku fable claude"></div>
<select id="appSettingsClaudeModel" class="set-select set-field-hidden" aria-hidden="true" tabindex="-1">
<option value="" data-meta="Whatever the CLI picks">Default (CLI setting)</option>
<option value="claude-fable-5-1" data-meta="Latest" data-base="claude-fable-5-1" data-ctx="1">Fable 5.1</option>
<option value="claude-fable-5-1[1m]" data-variant="1m" data-base="claude-fable-5-1">Fable 5.1 (1M context)</option>
<option value="claude-fable-5" data-meta="Most powerful" data-base="claude-fable-5" data-ctx="1">Fable 5</option>
<option value="claude-fable-5[1m]" data-variant="1m" data-base="claude-fable-5">Fable 5 (1M context)</option>
<option value="opus" data-meta="Most capable" data-base="opus" data-ctx="1">Opus</option>
@@ -2032,7 +2042,7 @@
<div class="set-row" id="appSettingsContextRow" data-search="1m context window opus long">
<div class="set-row-text">
<span class="set-row-label">1M context window</span>
<span class="set-row-desc" id="appSettingsContextDesc">Available for Fable 5, Opus and Opus 4.6.</span>
<span class="set-row-desc" id="appSettingsContextDesc">Available for Fable 5.1, Fable 5, Opus and Opus 4.6.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsOpusContext1m"><span class="slider"></span></label>
</div>
@@ -2072,6 +2082,7 @@
</div>
<select id="appSettingsDefaultModel" class="set-select">
<option value="">Default (CLI default)</option>
<option value="claude-fable-5-1">Fable 5.1 (Latest)</option>
<option value="claude-fable-5">Fable 5 (Most powerful)</option>
<option value="opus">Opus (Most capable)</option>
<option value="sonnet">Sonnet (Balanced)</option>
@@ -2086,6 +2097,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5-1">Fable 5.1</option>
<option value="claude-fable-5">Fable 5</option>
</select>
</div>
@@ -2096,6 +2108,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5-1">Fable 5.1</option>
<option value="claude-fable-5">Fable 5</option>
</select>
</div>
@@ -2106,6 +2119,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5-1">Fable 5.1</option>
<option value="claude-fable-5">Fable 5</option>
</select>
</div>
@@ -2116,6 +2130,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5-1">Fable 5.1</option>
<option value="claude-fable-5">Fable 5</option>
</select>
</div>
@@ -2709,6 +2724,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;
+716 -69
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);
}
},
});
@@ -2980,54 +2980,423 @@ Object.assign(CodemanApp.prototype, {
btn.setAttribute('aria-label', label);
},
_ensureFileBrowserState() {
if (!this._fileBrowserState) {
const ownerSessionId = this.activeSessionId || null;
const showHidden = this.fileBrowserShowHidden === true;
this._fileBrowserState = {
treeEpoch: 0,
searchEpoch: 0,
ownerSessionId,
view: 'normal',
normalState: this.fileBrowserData
? { sessionId: ownerSessionId, showHidden, treeEpoch: 0, phase: 'ready', data: this.fileBrowserData }
: null,
treeInFlight: null,
inFlight: null,
matches: [],
deferredDirectoryTarget: null,
filter: typeof this.fileBrowserFilter === 'string' ? this.fileBrowserFilter : '',
};
}
return this._fileBrowserState;
},
_activateFileBrowserSession(sessionId) {
if (!sessionId) return;
const state = this._ensureFileBrowserState();
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
clearTimeout(state.inFlight.timer);
}
state.searchEpoch++;
state.treeEpoch++;
state.ownerSessionId = sessionId;
state.treeInFlight = null;
state.inFlight = null;
state.normalState = null;
state.matches = [];
state.deferredDirectoryTarget = null;
state.filter = '';
state.view = 'normal';
this.fileBrowserData = null;
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs?.clear?.();
this.fileBrowserAllExpanded = false;
const searchInput = this.$?.('fileBrowserSearch');
if (searchInput) searchInput.value = '';
this._syncFileBrowserExpandBtn();
const expandBtn = this.$?.('fileBrowserExpandBtn');
if (expandBtn) expandBtn.innerHTML = '\u229E';
const panel = this.$?.('fileBrowserPanel');
const treeEl = this.$?.('fileBrowserTree');
const statusEl = this.$?.('fileBrowserStatus');
const visible = panel?.classList.contains('visible') === true;
if (treeEl) {
treeEl.innerHTML = visible
? `<div class="file-browser-loading">${escapeHtml('Loading files...')}</div>`
: '';
}
if (statusEl) statusEl.textContent = visible ? 'Loading files...' : '';
if (visible) {
const load = this.loadFileBrowser?.(sessionId);
load?.catch?.(() => {});
}
},
_resetFileBrowserForHide() {
const state = this._ensureFileBrowserState();
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
clearTimeout(state.inFlight.timer);
}
state.searchEpoch++;
state.treeEpoch++;
state.treeInFlight = null;
state.inFlight = null;
state.normalState = null;
state.matches = [];
state.deferredDirectoryTarget = null;
state.filter = '';
state.view = 'normal';
this.fileBrowserData = null;
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs?.clear?.();
this.fileBrowserAllExpanded = false;
const searchInput = this.$?.('fileBrowserSearch');
if (searchInput) searchInput.value = '';
this._syncFileBrowserExpandBtn();
const expandBtn = this.$?.('fileBrowserExpandBtn');
if (expandBtn) expandBtn.innerHTML = '\u229E';
const treeEl = this.$?.('fileBrowserTree');
if (treeEl) treeEl.innerHTML = '';
const statusEl = this.$?.('fileBrowserStatus');
if (statusEl) statusEl.textContent = '';
},
_setFileBrowserExpandDisabled(disabled) {
const btn = this.$('fileBrowserExpandBtn');
if (btn) btn.disabled = disabled;
},
_hasFileBrowserQuery() {
const state = this._ensureFileBrowserState();
const input = this.$?.('fileBrowserSearch');
const inputValue = typeof input?.value === 'string' ? input.value : '';
const filterValue = typeof state.filter === 'string' ? state.filter : '';
return inputValue.trim() !== '' || filterValue.trim() !== '';
},
_syncFileBrowserExpandBtn() {
this._setFileBrowserExpandDisabled(this._hasFileBrowserQuery());
},
_renderFileBrowserNormalStatus(data, showHidden) {
const statusEl = this.$('fileBrowserStatus');
if (!statusEl || !data) return;
const { totalFiles, totalDirectories, truncated } = data;
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`;
},
_isFileBrowserNormalCompatible(candidate, sessionId, showHidden, treeEpoch) {
return (
candidate?.sessionId === sessionId &&
candidate.showHidden === showHidden &&
candidate.treeEpoch === treeEpoch
);
},
_isFileBrowserTreeContextCurrent(request, requireCurrentRecord = false) {
const state = this._ensureFileBrowserState();
return (
(!requireCurrentRecord || state.treeInFlight === request) &&
state.ownerSessionId === request.sessionId &&
state.treeEpoch === request.treeEpoch &&
(this.fileBrowserShowHidden === true) === request.showHidden
);
},
_canRenderFileBrowserNormal(normalState) {
const state = this._ensureFileBrowserState();
return (
state.view === 'normal' &&
this.activeSessionId === normalState?.sessionId &&
this._isFileBrowserNormalCompatible(
normalState,
state.ownerSessionId,
this.fileBrowserShowHidden === true,
state.treeEpoch,
) &&
this.$('fileBrowserPanel')?.classList.contains('visible') === true
);
},
_renderFileBrowserNormalState(normalState) {
if (!normalState || !this._canRenderFileBrowserNormal(normalState)) return;
const treeEl = this.$('fileBrowserTree');
const statusEl = this.$('fileBrowserStatus');
if (!treeEl) return;
if (normalState.phase === 'loading') {
this.fileBrowserData = null;
treeEl.innerHTML = `<div class="file-browser-loading">${escapeHtml('Loading files...')}</div>`;
if (statusEl) statusEl.textContent = 'Loading files...';
return;
}
if (normalState.phase === 'error') {
this.fileBrowserData = null;
const detail = normalState.error && normalState.error !== 'Failed to load files'
? `: ${normalState.error}`
: '';
const message = `Failed to load files${detail}`;
treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml(message)}</div>`;
if (statusEl) statusEl.textContent = message;
return;
}
if (normalState.phase !== 'ready') return;
this.fileBrowserData = normalState.data;
this._syncFileBrowserExpandBtn();
this.renderFileBrowserTree(normalState.sessionId);
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
},
_validateFileBrowserTreeEnvelope(result) {
if (!result || typeof result !== 'object' || result.success !== true) return null;
const data = result.data;
if (!data || typeof data !== 'object' || !Array.isArray(data.tree)) return null;
if (data.mode === 'search') return null;
if (
typeof data.totalFiles !== 'number' ||
!Number.isFinite(data.totalFiles) ||
data.totalFiles < 0 ||
typeof data.totalDirectories !== 'number' ||
!Number.isFinite(data.totalDirectories) ||
data.totalDirectories < 0 ||
typeof data.truncated !== 'boolean'
) {
return null;
}
const validNodes = nodes => nodes.every(node => {
if (!node || typeof node !== 'object') return false;
if (typeof node.name !== 'string' || typeof node.path !== 'string') return false;
if (node.type !== 'file' && node.type !== 'directory') return false;
if (node.size !== undefined && (typeof node.size !== 'number' || !Number.isFinite(node.size))) return false;
if (node.extension !== undefined && typeof node.extension !== 'string') return false;
if (node.children !== undefined && (!Array.isArray(node.children) || !validNodes(node.children))) return false;
return true;
});
return validNodes(data.tree) ? data : null;
},
_normalizeFileBrowserTreeError(error) {
return typeof error?.message === 'string' && error.message ? error.message : 'Failed to load files';
},
_validateFileBrowserSearchEnvelope(result) {
if (!result || typeof result !== 'object' || result.success !== true) return null;
const data = result.data;
if (!data || typeof data !== 'object' || data.mode !== 'search' || !Array.isArray(data.matches)) return null;
if (typeof data.truncated !== 'boolean') return null;
if (
data.matchCount !== undefined &&
(typeof data.matchCount !== 'number' || !Number.isFinite(data.matchCount) || data.matchCount < 0)
) {
return null;
}
for (const match of data.matches) {
if (!match || typeof match !== 'object') return null;
if (typeof match.name !== 'string' || typeof match.path !== 'string') return null;
if (match.type !== 'file' && match.type !== 'directory') return null;
if (match.size !== undefined && (typeof match.size !== 'number' || !Number.isFinite(match.size))) return null;
if (match.extension !== undefined && typeof match.extension !== 'string') return null;
}
return data;
},
_canRenderFileBrowserSearch(request) {
const state = this._ensureFileBrowserState();
const panel = this.$('fileBrowserPanel');
return (
state.searchEpoch === request.epoch &&
state.ownerSessionId === request.ownerSessionId &&
this.activeSessionId === request.ownerSessionId &&
(this.fileBrowserShowHidden === true) === request.showHidden &&
state.filter === request.rawInput &&
panel?.classList.contains('visible') === true
);
},
_renderFileBrowserSearchError() {
const treeEl = this.$('fileBrowserTree');
const statusEl = this.$('fileBrowserStatus');
const message = 'Search failed';
if (treeEl) treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml(message)}</div>`;
if (statusEl) statusEl.textContent = message;
},
_canContinueFileBrowserHiddenReload(continuation, normalState) {
const state = this._ensureFileBrowserState();
const input = this.$?.('fileBrowserSearch');
const currentInput = typeof input?.value === 'string' ? input.value : state.filter;
return (
state.view === 'normal' &&
state.searchEpoch === continuation.searchEpoch &&
state.treeEpoch === continuation.treeEpoch &&
state.ownerSessionId === continuation.ownerSessionId &&
this.activeSessionId === continuation.ownerSessionId &&
(this.fileBrowserShowHidden === true) === continuation.showHidden &&
state.filter === continuation.rawInput &&
currentInput === continuation.rawInput &&
currentInput.trim() === continuation.query &&
this.$?.('fileBrowserPanel')?.classList.contains('visible') === true &&
normalState?.phase === 'ready' &&
this._isFileBrowserNormalCompatible(
normalState,
continuation.ownerSessionId,
continuation.showHidden,
continuation.treeEpoch,
)
);
},
async toggleFileBrowserHidden() {
const state = this._ensureFileBrowserState();
const rawInput = typeof state.filter === 'string' ? state.filter : '';
const query = rawInput.trim();
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
try {
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
} catch {}
this._syncFileBrowserHiddenBtn();
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
clearTimeout(state.inFlight.timer);
}
state.searchEpoch++;
state.inFlight = null;
state.matches = [];
state.deferredDirectoryTarget = null;
state.normalState = null;
this.fileBrowserData = null;
if (query.length <= 256) state.view = 'normal';
this._syncFileBrowserExpandBtn();
// Expanded-directory state is deliberately preserved so toggling does not
// collapse the tree the user just navigated.
if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId);
},
const ownerSessionId = state.ownerSessionId || this.activeSessionId;
if (!ownerSessionId || this.activeSessionId !== ownerSessionId) return;
async loadFileBrowser(sessionId) {
if (!sessionId) return;
const searchEpoch = state.searchEpoch;
const showHidden = this.fileBrowserShowHidden === true;
const load = this.loadFileBrowser(ownerSessionId, { force: true });
const treeEpoch = state.treeEpoch;
if (!load?.then) return;
await load;
const treeEl = this.$('fileBrowserTree');
const statusEl = this.$('fileBrowserStatus');
this._syncFileBrowserHiddenBtn();
if (!treeEl) return;
// Show loading state
treeEl.innerHTML = '<div class="file-browser-loading">Loading files...</div>';
try {
const showHidden = this.fileBrowserShowHidden === true;
const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=${showHidden}`);
if (!res.ok) throw new Error('Failed to load files');
const result = await res.json();
if (!result.success) throw new Error(result.error || 'Failed to load files');
this.fileBrowserData = result.data;
this.renderFileBrowserTree();
// Update status
if (statusEl) {
const { totalFiles, totalDirectories, truncated } = result.data;
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`;
}
} catch (err) {
console.error('Failed to load file browser:', err);
treeEl.innerHTML = `<div class="file-browser-empty">Failed to load files: ${escapeHtml(err.message)}</div>`;
if (!query || query.length > 256) return;
const continuation = { ownerSessionId, showHidden, treeEpoch, searchEpoch, rawInput, query };
if (this._canContinueFileBrowserHiddenReload(continuation, state.normalState)) {
this.filterFileBrowser(rawInput);
}
},
renderFileBrowserTree() {
loadFileBrowser(sessionId, { force = false } = {}) {
if (!sessionId) return undefined;
const state = this._ensureFileBrowserState();
const treeEl = this.$('fileBrowserTree');
this._syncFileBrowserHiddenBtn();
if (!treeEl) return undefined;
if (!state.ownerSessionId) state.ownerSessionId = sessionId;
if (state.ownerSessionId !== sessionId) return undefined;
if (force) state.treeEpoch++;
const showHidden = this.fileBrowserShowHidden === true;
const treeEpoch = state.treeEpoch;
const inFlight = state.treeInFlight;
if (
!force &&
this._isFileBrowserNormalCompatible(inFlight, sessionId, showHidden, treeEpoch)
) {
return inFlight.promise;
}
const settled = state.normalState;
if (
!force &&
this._isFileBrowserNormalCompatible(settled, sessionId, showHidden, treeEpoch) &&
(settled.phase === 'ready' || settled.phase === 'error')
) {
if (settled.phase === 'ready') this.fileBrowserData = settled.data;
this._renderFileBrowserNormalState(settled);
return Promise.resolve(settled);
}
const loadingState = { sessionId, showHidden, treeEpoch, phase: 'loading' };
state.normalState = loadingState;
this.fileBrowserData = null;
this._renderFileBrowserNormalState(loadingState);
const record = { sessionId, showHidden, treeEpoch, promise: null };
const request = (async () => {
try {
const res = await fetch(
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`,
);
if (!res.ok) throw new Error('Failed to load files');
const result = await res.json();
const data = this._validateFileBrowserTreeEnvelope(result);
if (!data) {
const detail = result && typeof result === 'object' && typeof result.error === 'string'
? result.error
: 'Failed to load files';
throw new Error(detail);
}
if (!this._isFileBrowserTreeContextCurrent(record, true)) return;
const nextNormalState = { sessionId, showHidden, treeEpoch, phase: 'ready', data };
state.normalState = nextNormalState;
this.fileBrowserData = data;
const deferredRendered = this._completeDeferredFileBrowserDirectory?.(nextNormalState) === true;
if (!deferredRendered) this._renderFileBrowserNormalState(nextNormalState);
} catch (error) {
if (!this._isFileBrowserTreeContextCurrent(record, true)) return;
const nextNormalState = {
sessionId,
showHidden,
treeEpoch,
phase: 'error',
error: this._normalizeFileBrowserTreeError(error),
};
state.normalState = nextNormalState;
this.fileBrowserData = null;
this._completeDeferredFileBrowserDirectory?.(nextNormalState);
console.error('Failed to load file browser:', error);
this._renderFileBrowserNormalState(nextNormalState);
}
})();
record.promise = request.finally(() => {
if (state.treeInFlight === record) state.treeInFlight = null;
});
state.treeInFlight = record;
return record.promise;
},
renderFileBrowserTree(ownerSessionId) {
const treeEl = this.$('fileBrowserTree');
if (!treeEl || !this.fileBrowserData) return;
const state = this._ensureFileBrowserState();
const owner = ownerSessionId || state.normalState?.sessionId || state.ownerSessionId || this.activeSessionId;
if (!owner) return;
const { tree } = this.fileBrowserData;
if (!tree || tree.length === 0) {
treeEl.innerHTML = '<div class="file-browser-empty">No files found</div>';
@@ -3035,21 +3404,10 @@ Object.assign(CodemanApp.prototype, {
}
const html = [];
const filter = this.fileBrowserFilter.toLowerCase();
const renderNode = (node, depth) => {
const isDir = node.type === 'directory';
const isExpanded = this.fileBrowserExpandedDirs.has(node.path);
const matchesFilter = !filter || node.name.toLowerCase().includes(filter);
// For directories, check if any children match
let hasMatchingChildren = false;
if (isDir && filter && node.children) {
hasMatchingChildren = this.hasMatchingChild(node, filter);
}
const shouldShow = matchesFilter || hasMatchingChildren;
const hiddenClass = !shouldShow && filter ? ' hidden-by-filter' : '';
const icon = isDir
? (isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1')
@@ -3066,11 +3424,11 @@ Object.assign(CodemanApp.prototype, {
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
const downloadBtn = !isDir
? `<a class="file-tree-download" href="/api/sessions/${this.activeSessionId}/file-raw?path=${encodeURIComponent(node.path)}&download=true" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: '';
html.push(`
<div class="file-tree-item${hiddenClass}" data-path="${escapeHtml(node.path)}" data-type="${node.type}" data-depth="${depth}">
<div class="file-tree-item" data-path="${escapeHtml(node.path)}" data-type="${escapeHtml(node.type)}" data-depth="${depth}">
${expandIcon}
<span class="file-tree-icon">${icon}</span>
<span class="${nameClass}">${escapeHtml(node.name)}</span>
@@ -3102,21 +3460,12 @@ Object.assign(CodemanApp.prototype, {
if (type === 'directory') {
this.toggleFileBrowserFolder(path);
} else {
this.openFilePreview(path);
this.openFilePreview(path, owner);
}
});
});
},
hasMatchingChild(node, filter) {
if (!node.children) return false;
for (const child of node.children) {
if (child.name.toLowerCase().includes(filter)) return true;
if (child.type === 'directory' && this.hasMatchingChild(child, filter)) return true;
}
return false;
},
toggleFileBrowserFolder(path) {
if (this.fileBrowserExpandedDirs.has(path)) {
this.fileBrowserExpandedDirs.delete(path);
@@ -3127,12 +3476,291 @@ Object.assign(CodemanApp.prototype, {
},
filterFileBrowser(value) {
this.fileBrowserFilter = value;
// Auto-expand all if filtering
if (value) {
this.expandAllDirectories(this.fileBrowserData?.tree || []);
const state = this._ensureFileBrowserState();
const rawInput = String(value ?? '');
const query = rawInput.trim();
state.searchEpoch++;
state.filter = rawInput;
state.deferredDirectoryTarget = null;
this.fileBrowserFilter = rawInput;
this._syncFileBrowserExpandBtn();
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
clearTimeout(state.inFlight.timer);
}
this.renderFileBrowserTree();
state.inFlight = null;
if (!state.ownerSessionId && this.activeSessionId) state.ownerSessionId = this.activeSessionId;
const ownerSessionId = state.ownerSessionId || null;
if (!this.activeSessionId || !ownerSessionId || this.activeSessionId !== ownerSessionId) return;
if (!query) {
state.view = 'normal';
state.matches = [];
this._syncFileBrowserExpandBtn();
const normal = state.normalState;
if (
ownerSessionId &&
this._isFileBrowserNormalCompatible(
normal,
ownerSessionId,
this.fileBrowserShowHidden === true,
state.treeEpoch,
)
) {
this._renderFileBrowserNormalState(normal);
}
return;
}
if (query.length > 256) {
const message = 'Search queries are limited to 256 characters';
state.view = 'query-error';
state.matches = [];
this._syncFileBrowserExpandBtn();
const treeEl = this.$('fileBrowserTree');
const statusEl = this.$('fileBrowserStatus');
if (treeEl) treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml(message)}</div>`;
if (statusEl) statusEl.textContent = message;
return;
}
const panel = this.$('fileBrowserPanel');
const treeEl = this.$('fileBrowserTree');
if (!ownerSessionId || !panel || !treeEl) return;
const request = {
epoch: state.searchEpoch,
treeEpoch: state.treeEpoch,
ownerSessionId,
showHidden: this.fileBrowserShowHidden === true,
rawInput,
query,
timer: null,
};
state.view = 'search-pending';
state.matches = [];
state.inFlight = request;
this._syncFileBrowserExpandBtn();
treeEl.innerHTML = `<div class="file-browser-loading">${escapeHtml('Searching...')}</div>`;
const statusEl = this.$('fileBrowserStatus');
if (statusEl) statusEl.textContent = 'Searching...';
request.timer = setTimeout(async () => {
request.timer = null;
try {
const res = await fetch(
`/api/sessions/${encodeURIComponent(ownerSessionId)}/files?depth=5&showHidden=${request.showHidden}&q=${encodeURIComponent(query)}`,
);
if (!res.ok) throw new Error('Search failed');
const result = await res.json();
const data = this._validateFileBrowserSearchEnvelope(result);
if (!data) throw new Error('Search failed');
const canRender = this._canRenderFileBrowserSearch(request);
if (state.inFlight === request) state.inFlight = null;
if (!canRender) return;
state.view = 'search-results';
state.matches = data.matches;
this._renderFileBrowserSearchResults(data.matches, ownerSessionId, data);
} catch (err) {
const canRender = this._canRenderFileBrowserSearch(request);
if (state.inFlight === request) state.inFlight = null;
if (!canRender) return;
console.error('Failed to search file browser:', err);
state.view = 'search-error';
state.matches = [];
this._renderFileBrowserSearchError();
}
}, 250);
},
_renderFileBrowserSearchResults(matches, ownerSessionId, data) {
const treeEl = this.$('fileBrowserTree');
if (!treeEl || !ownerSessionId) return;
const state = this._ensureFileBrowserState();
const searchContext = {
ownerSessionId,
showHidden: this.fileBrowserShowHidden === true,
treeEpoch: state.treeEpoch,
searchEpoch: state.searchEpoch,
rawInput: state.filter,
query: state.filter.trim(),
view: state.view,
};
if (matches.length === 0) {
treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml('No matches')}</div>`;
} else {
const ownerPath = encodeURIComponent(ownerSessionId);
treeEl.innerHTML = matches
.map(match => {
const isDir = match.type === 'directory';
const icon = isDir ? '📁' : this.getFileIcon(match.extension || '');
const sizeStr = !isDir && match.size !== undefined
? `<span class="file-tree-size">${this.formatFileSize(match.size)}</span>`
: '';
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
const downloadBtn = !isDir
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: '';
return `
<div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}">
<span class="file-tree-expand"></span>
<span class="file-tree-icon">${icon}</span>
<span class="${nameClass}">${escapeHtml(match.name)}</span>
${sizeStr}
${downloadBtn}
</div>
`;
})
.join('');
}
treeEl.querySelectorAll('.file-tree-item').forEach(item => {
item.addEventListener('click', () => {
const path = item.dataset.path;
if (item.dataset.type === 'directory') {
this._openFileBrowserSearchDirectory({ ...searchContext, path });
} else {
this.openFilePreview(path, ownerSessionId);
}
});
});
const statusEl = this.$('fileBrowserStatus');
if (statusEl) {
const count = data.matchCount === undefined ? matches.length : data.matchCount;
statusEl.textContent = `${count} ${count === 1 ? 'match' : 'matches'}${data.truncated ? ' (truncated)' : ''}`;
}
},
_findFileBrowserDirectory(nodes, targetPath, ancestors = []) {
if (!Array.isArray(nodes)) return null;
for (const node of nodes) {
if (!node || typeof node !== 'object') continue;
if (node.type === 'directory' && node.path === targetPath) {
return { target: node, ancestors: [...ancestors] };
}
if (node.type !== 'directory' || !Array.isArray(node.children)) continue;
const found = this._findFileBrowserDirectory(node.children, targetPath, [...ancestors, node.path]);
if (found) return found;
}
return null;
},
_isFileBrowserDirectoryContextCurrent(target) {
const state = this._ensureFileBrowserState();
const input = this.$?.('fileBrowserSearch');
const currentInput = typeof input?.value === 'string' ? input.value : state.filter;
return (
target &&
state.ownerSessionId === target.ownerSessionId &&
this.activeSessionId === target.ownerSessionId &&
state.treeEpoch === target.treeEpoch &&
state.searchEpoch === target.searchEpoch &&
(this.fileBrowserShowHidden === true) === target.showHidden &&
state.filter === target.rawInput &&
currentInput === target.rawInput &&
currentInput.trim() === target.query &&
state.view === target.view &&
this.$?.('fileBrowserPanel')?.classList.contains('visible') === true
);
},
_promptFileBrowserDirectoryReload() {
this.showToast?.('Reload files before opening this folder', 'info');
},
_openFileBrowserSearchDirectory(target) {
if (!this._isFileBrowserDirectoryContextCurrent(target)) return;
const state = this._ensureFileBrowserState();
const normalState = state.normalState;
if (
!this._isFileBrowserNormalCompatible(
normalState,
target.ownerSessionId,
target.showHidden,
target.treeEpoch,
)
) {
state.deferredDirectoryTarget = null;
this._promptFileBrowserDirectoryReload();
return;
}
if (normalState.phase === 'loading') {
state.deferredDirectoryTarget = { ...target };
return;
}
state.deferredDirectoryTarget = null;
if (normalState.phase !== 'ready') {
this._promptFileBrowserDirectoryReload();
return;
}
const found = this._findFileBrowserDirectory(normalState.data?.tree, target.path);
if (!found) {
this._promptFileBrowserDirectoryReload();
return;
}
this._leaveFileBrowserSearchForDirectory([...found.ancestors, found.target.path], normalState);
},
_completeDeferredFileBrowserDirectory(normalState) {
const state = this._ensureFileBrowserState();
const target = state.deferredDirectoryTarget;
if (!target) return false;
if (!this._isFileBrowserDirectoryContextCurrent(target)) {
if (state.deferredDirectoryTarget === target) state.deferredDirectoryTarget = null;
return false;
}
if (
!this._isFileBrowserNormalCompatible(
normalState,
target.ownerSessionId,
target.showHidden,
target.treeEpoch,
) ||
(normalState.phase !== 'ready' && normalState.phase !== 'error')
) {
return false;
}
state.deferredDirectoryTarget = null;
if (normalState.phase === 'error') {
this._promptFileBrowserDirectoryReload();
return false;
}
const found = this._findFileBrowserDirectory(normalState.data?.tree, target.path);
if (!found) {
this._promptFileBrowserDirectoryReload();
return false;
}
this._leaveFileBrowserSearchForDirectory([...found.ancestors, found.target.path], normalState);
return true;
},
_leaveFileBrowserSearchForDirectory(paths, normalState) {
const state = this._ensureFileBrowserState();
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
clearTimeout(state.inFlight.timer);
}
state.searchEpoch++;
state.inFlight = null;
state.filter = '';
state.matches = [];
state.deferredDirectoryTarget = null;
state.view = 'normal';
this.fileBrowserFilter = '';
const input = this.$?.('fileBrowserSearch');
if (input) input.value = '';
this._syncFileBrowserExpandBtn();
for (const path of paths) {
if (typeof path === 'string') this.fileBrowserExpandedDirs?.add?.(path);
}
this.fileBrowserData = normalState.data;
this._renderFileBrowserNormalState(normalState);
},
expandAllDirectories(nodes) {
@@ -3151,6 +3779,10 @@ Object.assign(CodemanApp.prototype, {
},
toggleFileBrowserExpand() {
if (this._hasFileBrowserQuery()) {
this._syncFileBrowserExpandBtn();
return;
}
this.fileBrowserAllExpanded = !this.fileBrowserAllExpanded;
const btn = this.$('fileBrowserExpandBtn');
@@ -3165,14 +3797,28 @@ Object.assign(CodemanApp.prototype, {
},
refreshFileBrowser() {
if (this.activeSessionId) {
this.fileBrowserExpandedDirs.clear();
this.fileBrowserFilter = '';
this.fileBrowserAllExpanded = false;
const searchInput = this.$('fileBrowserSearch');
if (searchInput) searchInput.value = '';
this.loadFileBrowser(this.activeSessionId);
const state = this._ensureFileBrowserState();
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
clearTimeout(state.inFlight.timer);
}
state.inFlight = null;
state.searchEpoch++;
state.filter = '';
state.matches = [];
state.deferredDirectoryTarget = null;
state.view = 'normal';
this.fileBrowserFilter = '';
this.fileBrowserExpandedDirs.clear();
this.fileBrowserAllExpanded = false;
const expandBtn = this.$('fileBrowserExpandBtn');
if (expandBtn) expandBtn.innerHTML = '\u229E';
const searchInput = this.$('fileBrowserSearch');
if (searchInput) searchInput.value = '';
this._syncFileBrowserExpandBtn();
const ownerSessionId = state.ownerSessionId || this.activeSessionId;
if (!ownerSessionId || this.activeSessionId !== ownerSessionId) return undefined;
return this.loadFileBrowser(ownerSessionId, { force: true });
},
// Header "File Viewer" button (opt-in via App Settings → Header Displays →
@@ -3203,6 +3849,7 @@ Object.assign(CodemanApp.prototype, {
closeFileBrowserPanel() {
const panel = this.$('fileBrowserPanel');
this._resetFileBrowserForHide();
if (panel) {
panel.classList.remove('visible');
// Reset position so it reopens at default location
+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';
},
+4 -2
View File
@@ -922,7 +922,7 @@ Object.assign(CodemanApp.prototype, {
desc.textContent = inert
? 'The selected model has no 1M variant.'
: base
? 'Available for Fable 5, Opus and Opus 4.6.'
? 'Available for Fable 5.1, Fable 5, Opus and Opus 4.6.'
: 'With no model pinned, this starts new sessions on Opus with a 1M window.';
}
},
@@ -1230,6 +1230,7 @@ Object.assign(CodemanApp.prototype, {
['welcomeClaudeBtn', 'claude'],
['welcomeOpencodeBtn', 'opencode'],
['welcomeAntigravityBtn', 'antigravity'],
['welcomeOmpBtn', 'omp'],
['welcomeGeminiBtn', 'gemini'],
['welcomePiBtn', 'pi'],
['welcomeGrokBtn', 'grok'],
@@ -2901,7 +2902,8 @@ Object.assign(CodemanApp.prototype, {
this.fileBrowserDragListeners._onFirstDrag = onFirstDrag;
}
}
} else {
} else if (fileBrowserPanel.classList.contains('visible')) {
this._resetFileBrowserForHide?.();
fileBrowserPanel.classList.remove('visible');
}
}
+82 -9
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);
}
@@ -1535,7 +1536,10 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name-prefix {
display: inline;
}
html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name.tab-name-renaming {
:is(
html[data-tab-orientation='vertical'] .tab-rail,
html[data-session-list='sidebar'] .session-sidebar
) .session-tab .tab-name.tab-name-renaming {
display: flex;
align-items: center;
-webkit-box-orient: initial;
@@ -1544,11 +1548,17 @@ html[data-tab-orientation='vertical'] .tab-rail .session-tab .tab-name.tab-name-
overflow: visible;
}
html[data-tab-orientation='vertical'] .tab-name-renaming .tab-rename-prefix {
:is(
html[data-tab-orientation='vertical'] .tab-rail,
html[data-session-list='sidebar'] .session-sidebar
) .tab-name-renaming .tab-rename-prefix {
flex: 0 0 auto;
}
html[data-tab-orientation='vertical'] .tab-name-renaming .tab-rename-input {
:is(
html[data-tab-orientation='vertical'] .tab-rail,
html[data-session-list='sidebar'] .session-sidebar
) .tab-name-renaming .tab-rename-input {
flex: 1 1 0;
width: auto;
min-width: 0;
@@ -2490,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);
@@ -3874,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. */
@@ -4983,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
@@ -5107,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;
@@ -12363,17 +12409,20 @@ kbd {
}
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
live 5-hour + weekly plan limits parsed from the Claude statusline. Ships
live Claude and Codex plan limits in provider rows. Ships
hidden via the marker class below because display is PER-DEVICE and the
server cannot read localStorage; settings-ui.js reveals it on load (desktop
default ON, handhelds OFF) and on a live toggle. */
.header-plan-usage {
display: inline-flex !important;
align-items: center;
height: 22px;
padding: 0 0.5rem;
border-radius: 11px;
flex-direction: column;
align-items: stretch;
justify-content: center;
min-height: 22px;
padding: 3px 0.5rem;
border-radius: 8px;
font-size: 0.7rem;
line-height: 1.1;
font-weight: 500;
font-family: 'SF Mono', Monaco, monospace;
color: var(--text-dim);
@@ -12382,6 +12431,23 @@ kbd {
white-space: nowrap;
cursor: default;
}
.header-plan-usage .pu-row {
display: flex;
align-items: baseline;
}
.header-plan-usage .pu-provider {
width: 46px;
flex: 0 0 46px;
font-size: 0.58rem;
font-weight: 700;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.header-plan-usage .pu-windows {
display: inline-flex;
align-items: baseline;
}
/* Readable two-window layout: dim uppercase label + bold, color-coded value. */
.header-plan-usage .pu-win {
display: inline-flex;
@@ -12404,6 +12470,13 @@ kbd {
color: var(--text-dim);
opacity: 0.45;
}
/* A window Claude is not currently reporting: the slot stays, dimmed, so the
chip keeps its shape instead of looking like half of it broke. */
.header-plan-usage .pu-win-idle .pu-label,
.header-plan-usage .pu-win-idle .pu-val {
color: var(--text-dim);
opacity: 0.55;
}
/* Green/yellow/red by how much of the window is used up. */
.header-plan-usage .pu-green {
color: #3fb950;
+70 -12
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();
@@ -3167,7 +3214,7 @@ Object.assign(CodemanApp.prototype, {
* arrived, which looked like truncated responses and idle shell commands.
*/
_scheduleTerminalWriteFlush() {
if (this.writeFrameScheduled || this.pendingWrites.length === 0) return;
if (this._terminalWriteInFlight || this.writeFrameScheduled || this.pendingWrites.length === 0) return;
this.writeFrameScheduled = true;
this._safeYield(() => {
this.writeFrameScheduled = false;
@@ -3358,7 +3405,7 @@ Object.assign(CodemanApp.prototype, {
* Strips markers and writes content atomically within a single frame.
*/
flushPendingWrites() {
if (this.pendingWrites.length === 0 || !this.terminal) return;
if (this._terminalWriteInFlight || this.pendingWrites.length === 0 || !this.terminal) return;
const _t0 = performance.now();
// xterm.js 6.0+ natively handles DEC 2026 synchronized output markers.
@@ -3389,14 +3436,25 @@ Object.assign(CodemanApp.prototype, {
const preserveViewportY =
this.terminal.buffer?.active && !this.isTerminalAtBottom() ? this.terminal.buffer.active.viewportY : null;
if (_joinedLen <= MAX_FRAME_BYTES) {
this.terminal.write(joined);
} else {
// Write first chunk now, defer rest to next frame
this.terminal.write(joined.slice(0, MAX_FRAME_BYTES));
const writeChunk = joined.slice(0, MAX_FRAME_BYTES);
if (_joinedLen > MAX_FRAME_BYTES) {
// Keep the remainder app-side where the 128KB cap can see it. The next
// chunk is scheduled only after xterm confirms this one was parsed.
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
deferred = true;
this._scheduleTerminalWriteFlush();
}
this._terminalWriteInFlight = true;
this._terminalWriteInFlightBytes = writeChunk.length;
try {
this.terminal.write(writeChunk, () => {
this._terminalWriteInFlight = false;
this._terminalWriteInFlightBytes = 0;
this._scheduleTerminalWriteFlush();
});
} catch (err) {
this._terminalWriteInFlight = false;
this._terminalWriteInFlightBytes = 0;
throw err;
}
if (
preserveViewportY !== null &&
+39 -9
View File
@@ -8,11 +8,10 @@
import { join, resolve, relative, isAbsolute } from 'node:path';
import { realpathSync, existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
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';
@@ -21,12 +20,15 @@ import type { EventPort } from './ports/event-port.js';
import type { AuthSessionRecord } from './ports/auth-port.js';
import type { StaleExpirationMap } from '../utils/index.js';
import { dataPath } from '../config/instance.js';
import { getCasesDir } from '../config/cases-dir.js';
import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js';
import { SYNTHETIC_ADMIN, findUser } from '../user-store.js';
// Shared path constants used across route modules. CASES_DIR (project folders)
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
export const CASES_DIR = join(homedir(), 'codeman-cases');
// The cases dir is resolved in ONE place (config/cases-dir.ts) because the CLI
// resolves it too, and CODEMAN_CASES_PATH must move both or neither.
export const CASES_DIR = getCasesDir();
export const SETTINGS_PATH = dataPath('settings.json');
/**
@@ -264,6 +266,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 +288,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;
+136 -21
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(
@@ -2994,9 +3076,9 @@ export function registerSessionRoutes(
casePath = dockerCase.hostWorkspacePath; // a REAL host dir (bind-mounted into the container)
docker = sessionDocker;
// Seed resume so a relaunch resumes the case's last conversation from the
// bind-mounted transcript (decision: resume-on-start default ON).
if (sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
// Seed only Claude's resume id. Codex, Gemini, and the other CLIs have
// separate conversation stores and must never receive a Claude UUID.
if (mode === 'claude' && sessionDocker.resumeOnStart && dockerCase.lastClaudeSessionId) {
dockerResumeId = dockerCase.lastClaudeSessionId;
}
} else {
@@ -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 {
+3 -3
View File
@@ -57,9 +57,9 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
if (!ctx.sessions.has(id)) lastSig.delete(id);
}
}
const payload = { sessionId, ...telemetry };
setLatestPlanUsage(payload); // replayed in the SSE init snapshot for fresh loads
ctx.broadcast(SessionStatusTelemetry, payload);
const update = { sessionId, ...telemetry };
const snapshot = setLatestPlanUsage(update); // replayed in the SSE init snapshot for fresh loads
ctx.broadcast(SessionStatusTelemetry, snapshot);
}
}
+18 -2
View File
@@ -614,8 +614,13 @@ export function registerSystemRoutes(
// same negative-pid signal as runGit() in git-clone.ts, which is the
// synchronous-spawn precedent this endpoint is modelled on.
detached: true,
// dsh bundles its own package manager, so no system pnpm is required —
// but it still needs a HOME to resolve $DSH_HOME against.
// Inherit the environment: this needs a HOME to resolve $DSH_HOME
// against, and a PATH carrying `pnpm`. ⚠️ `dsh plugin` does NOT bundle a
// package manager — it `spawnSync`s a literal `pnpm` with no npm
// fallback, so on a host without one this exits 127 and dsh's own
// stderr ("pnpm not found on PATH") is what reaches the caller through
// the OPERATION_FAILED detail below. That is the same missing
// dependency that broke the docker agent image in issue #352.
env: process.env,
});
} catch (err) {
@@ -694,6 +699,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)
// ═══════════════════════════════════════════════════════════════
+31 -5
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,
@@ -449,7 +471,7 @@ export const CreateSessionSchema = z.object({
effort: effortLevelSchema,
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
modelOverride: z.string().max(50).optional(),
/** Inject the plan-usage statusLine exporter into the case (App Settings → Display → "Plan Usage Limits"). Claude-only. */
/** Inject the Claude statusLine source for the shared plan-usage chip. Claude sessions only; Codex is host-polled. */
statusLineTelemetry: z.boolean().optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
@@ -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']),
+36 -1
View File
@@ -143,7 +143,9 @@ import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.j
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
import { SseEvent } from './sse-events.js';
import { getLatestPlanUsage } from './plan-usage-latest.js';
import { getLatestPlanUsage, setLatestCodexPlanUsage } from './plan-usage-latest.js';
import { telemetrySignature } from '../usage-telemetry.js';
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
import type { ScheduledRun } from './ports/index.js';
import { registerAuthMiddleware, registerSecurityHeaders, registerHostGuard } from './middleware/auth.js';
import { isMultiUserMode } from '../config/multiuser.js';
@@ -186,6 +188,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
// Length range covers crypto.randomUUID() (36 chars) plus any short stable IDs,
// while capping growth of `sseClientsById` and blocking pathological inputs.
const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/;
const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000;
function escapeHtmlText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
@@ -268,6 +271,8 @@ export class WebServer extends EventEmitter {
private cachedSessionsList: { data: unknown[]; timestamp: number } | null = null;
// Token recording for daily stats (track what's been recorded to avoid double-counting)
private lastRecordedTokens: Map<string, { input: number; output: number }> = new Map();
private codexUsageRefreshInFlight = false;
private lastCodexUsageSignature: string | null = null;
// Server startup time for respawn grace period calculation
private readonly serverStartTime: number = Date.now();
// Pending respawn start timers (for cleanup on shutdown)
@@ -1441,6 +1446,7 @@ export class WebServer extends EventEmitter {
{ isPiAvailable },
{ isGrokAvailable },
{ isDeepSeekRunnable, isDeepSeekAvailable },
{ isOmpAvailable },
{ isCloudflaredAvailable },
{ isGitAvailable },
] = await Promise.all([
@@ -1452,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'),
]);
@@ -1470,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.
@@ -2394,6 +2402,23 @@ export class WebServer extends EventEmitter {
}
}
private async refreshCodexPlanUsage(): Promise<void> {
if (this.codexUsageRefreshInFlight) return;
this.codexUsageRefreshInFlight = true;
try {
const binaryPath = resolveCodexBinaryPath();
const usage = binaryPath ? await readCodexPlanUsage(binaryPath, APP_VERSION) : null;
const signature = usage ? telemetrySignature(usage) : '';
if (signature === this.lastCodexUsageSignature) return;
this.lastCodexUsageSignature = signature;
const snapshot = setLatestCodexPlanUsage(usage);
this.cachedLightState = null;
this.broadcast(SseEvent.SessionStatusTelemetry, snapshot);
} finally {
this.codexUsageRefreshInFlight = false;
}
}
async start(): Promise<void> {
// Multi-user first boot: create the initial admin from CODEMAN_USERNAME/PASSWORD
// if there are no users yet, else refuse to start (there would be no way in).
@@ -2531,6 +2556,15 @@ export class WebServer extends EventEmitter {
// $CODEMAN_HOOK_SECRET_FILE — hook curls cat that path at execution time.
getHookSecret();
// Main Codex subscription limits come from the signed-in local CLI. Keep
// this read-only and host-scoped; multi-user SSE routing makes it admin-only.
if (!this.testMode) {
void this.refreshCodexPlanUsage();
this.cleanup.setInterval(() => void this.refreshCodexPlanUsage(), CODEX_USAGE_POLL_INTERVAL_MS, {
description: 'Codex plan-usage refresh',
});
}
// Start scheduled runs cleanup timer
this.cleanup.setInterval(
() => {
@@ -2752,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
@@ -116,7 +116,7 @@ export const SessionMessage = 'session:message' as const;
export const SessionInteractive = 'session:interactive' as const;
/** Prompt sent to session for execution. */
export const SessionRunning = 'session:running' as const;
/** Claude plan-usage telemetry (5-hour + weekly limits) parsed from the statusline. */
/** Combined Claude and main Codex plan-usage telemetry for the shared header chip. */
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
// ─── Session: Ralph ──────────────────────────────────────────────────────────
+19 -1
View File
@@ -120,13 +120,31 @@ describe('App Settings modal structure', () => {
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
// The cards render the base models; the [1m] rows exist so that base + the
// context switch can compose back into a real claudeModel value.
for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-opus-4-6[1m]']) {
for (const value of ['opus[1m]', 'claude-fable-5[1m]', 'claude-fable-5-1[1m]', 'claude-opus-4-6[1m]']) {
expect(select).toContain(`value="${value}"`);
}
expect(select).toContain('data-ctx="1"');
expect(modal).toContain('id="appSettingsOpusContext1m"');
});
it('models: offers Fable 5.1 as a card and to task routing', () => {
const modal = settingsModal();
const select = modal.match(/id="appSettingsClaudeModel"([\s\S]*?)<\/select>/)?.[1] ?? '';
// The cards are built from these options, so data-ctx is what keeps the 1M
// switch live for the model rather than greying the row out.
expect(select).toMatch(/value="claude-fable-5-1"[^>]*data-ctx="1"/);
for (const id of [
'appSettingsDefaultModel',
'appSettingsModelExplore',
'appSettingsModelImplement',
'appSettingsModelTest',
'appSettingsModelReview',
]) {
const routing = modal.match(new RegExp(`id="${id}"([\\s\\S]*?)</select>`))?.[1] ?? '';
expect(routing, `${id} does not offer Fable 5.1`).toContain('value="claude-fable-5-1"');
}
});
it('has retired the modal-tab chrome everywhere, not just here', () => {
// Session Options and Add Case moved onto this same `set-*` surface, so the
// old tab classes have no users left. A reappearance means a modal drifted
+34
View File
@@ -19,6 +19,7 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
import { dataPath } from '../src/config/instance.js';
import { program, resolveCliCasePath, resolveSkillTargetPath } from '../src/cli.js';
import { getCasesDir } from '../src/config/cases-dir.js';
const LINKED_CASES_FILE = dataPath('linked-cases.json');
const CASES_DIR = join(homedir(), 'codeman-cases');
@@ -142,3 +143,36 @@ describe('skill command wiring', () => {
}
});
});
describe('cases dir override (CODEMAN_CASES_PATH)', () => {
// The Docker Compose deployment points Codeman at a host-absolute bind mount
// so a Docker case resolves to the same path inside the container and on the
// host daemon. The override shipped on the server's CASES_DIR only, which left
// the CLI looking in the home default: `codeman skill install --case <name>`
// then reported "Case not found" on exactly the deployment it exists for.
const saved = process.env.CODEMAN_CASES_PATH;
afterEach(() => {
if (saved === undefined) delete process.env.CODEMAN_CASES_PATH;
else process.env.CODEMAN_CASES_PATH = saved;
});
it('moves the CLI and the server together', () => {
process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases';
expect(getCasesDir()).toBe('/srv/codeman-cases');
expect(resolveCliCasePath('demo')).toBe(join('/srv/codeman-cases', 'demo'));
});
it('falls back to the home default when unset', () => {
delete process.env.CODEMAN_CASES_PATH;
expect(getCasesDir()).toBe(CASES_DIR);
expect(resolveCliCasePath('demo')).toBe(join(CASES_DIR, 'demo'));
});
it('still lets a linked case win over the override', () => {
// The registry lookup runs first, so a case linked in from outside the cases
// dir keeps resolving to its real location under Compose too.
process.env.CODEMAN_CASES_PATH = '/srv/codeman-cases';
writeLinkedCases(JSON.stringify({ linked: join(LINKED_ROOT, 'linked') }));
expect(resolveCliCasePath('linked')).toBe(join(LINKED_ROOT, 'linked'));
});
});
+110
View File
@@ -0,0 +1,110 @@
/**
* @fileoverview Main Codex subscription usage for the shared header chip.
*
* Codex can return multiple model buckets. The header deliberately follows the
* backward-compatible `codex` bucket only; model-specific buckets such as Spark
* are separate limits and are not part of the requested row.
*/
import { describe, expect, it, vi } from 'vitest';
import * as telemetryModule from '../src/usage-telemetry.js';
import * as codexResolverModule from '../src/utils/codex-cli-resolver.js';
const REAL_RESPONSE = {
rateLimits: {
limitId: 'codex',
primary: { usedPercent: 40, windowDurationMins: 10080, resetsAt: 1788306836 },
secondary: null,
},
rateLimitsByLimitId: {
codex_bengalfox: {
limitId: 'codex_bengalfox',
limitName: 'GPT-5.3-Codex-Spark',
primary: { usedPercent: 12, windowDurationMins: 300, resetsAt: 1787750984 },
secondary: { usedPercent: 23, windowDurationMins: 10080, resetsAt: 1788337784 },
},
codex: {
limitId: 'codex',
primary: { usedPercent: 40, windowDurationMins: 10080, resetsAt: 1788306836 },
secondary: null,
},
},
};
type ParseCodexRateLimits = (value: unknown) => {
fiveHour?: { usedPercentage: number; resetAt: number };
sevenDay?: { usedPercentage: number; resetAt: number };
} | null;
function parser(): ParseCodexRateLimits {
const candidate = (telemetryModule as Record<string, unknown>).parseCodexRateLimitsResponse;
expect(candidate, 'usage telemetry must expose the Codex rate-limit parser').toBeTypeOf('function');
return candidate as ParseCodexRateLimits;
}
describe('parseCodexRateLimitsResponse', () => {
it('uses only the main codex bucket and maps its duration-tagged weekly window', () => {
expect(parser()(REAL_RESPONSE)).toEqual({
sevenDay: { usedPercentage: 40, resetAt: 1788306836 * 1000 },
});
});
it('maps 5-hour and 7-day windows by duration even when their positions are reversed', () => {
const result = parser()({
rateLimitsByLimitId: {
codex: {
primary: { usedPercent: 44, windowDurationMins: 10080, resetsAt: 200 },
secondary: { usedPercent: 17, windowDurationMins: 300, resetsAt: 100 },
},
},
});
expect(result).toEqual({
fiveHour: { usedPercentage: 17, resetAt: 100_000 },
sevenDay: { usedPercentage: 44, resetAt: 200_000 },
});
});
it('falls back to the backward-compatible rateLimits snapshot', () => {
expect(
parser()({
rateLimits: {
limitId: 'codex',
primary: { usedPercent: 8, windowDurationMins: 300, resetsAt: 300 },
secondary: null,
},
})
).toEqual({ fiveHour: { usedPercentage: 8, resetAt: 300_000 } });
});
it('ignores unrelated duration buckets and malformed percentages', () => {
expect(
parser()({
rateLimitsByLimitId: {
codex: {
primary: { usedPercent: '40', windowDurationMins: 10080, resetsAt: 200 },
secondary: { usedPercent: 20, windowDurationMins: 60, resetsAt: 100 },
},
},
})
).toBeNull();
});
});
type CodexRequest = (
binaryPath: string,
clientVersion: string,
request?: (binaryPath: string, clientVersion: string) => Promise<unknown>
) => Promise<ReturnType<ParseCodexRateLimits>>;
describe('readCodexPlanUsage', () => {
it('queries through the supplied app-server boundary and normalizes the result', async () => {
const candidate = (codexResolverModule as Record<string, unknown>).readCodexPlanUsage;
expect(candidate, 'the Codex resolver must expose a read-only usage query').toBeTypeOf('function');
const request = vi.fn(async () => REAL_RESPONSE);
await expect((candidate as CodexRequest)('/opt/codex', '1.23.0', request)).resolves.toEqual({
sevenDay: { usedPercentage: 40, resetAt: 1788306836 * 1000 },
});
expect(request).toHaveBeenCalledWith('/opt/codex', '1.23.0');
});
});
+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 () => {
+20
View File
@@ -80,6 +80,26 @@ describe('buildDockerLaunchCommand', () => {
expect(cmd).toContain("docker start 'codeman-case-myproj'");
});
it('avoids eager create expansion, tolerates a concurrent creator, and preserves real failures in compatibility mode', () => {
const opts = launchOpts();
opts.createContext.disableSwapLimit = true;
const cmd = buildDockerLaunchCommand(opts);
// No command substitution or shell variables: either could expand eagerly
// before the inspect side of || short-circuits in a nested launch shell.
expect(cmd).not.toContain('$(');
expect(cmd).not.toContain('codeman_create_output');
expect(cmd).toContain('if docker create');
expect(cmd).toContain("'/tmp/codeman-create-1a2b3c4d5e6f.log'");
// If another session created the case between inspect and create, re-inspect
// succeeds and the losing creator continues without printing the conflict.
expect(cmd).toContain("elif docker inspect 'codeman-case-myproj' >/dev/null 2>&1; then rm -f");
expect(cmd).toContain('Your kernel does not support swap limit capabilities');
expect(cmd).toContain('else sed');
expect(cmd).toContain('>&2; rm -f');
expect(cmd).toContain('; false; fi;');
expect(cmd).not.toContain('--memory-swap');
});
it('execs a TTY into the durable in-container tmux', () => {
const cmd = buildDockerLaunchCommand(launchOpts());
expect(cmd).toContain("exec docker exec -it --workdir '/home/arkon/cases/myproj'");
+53
View File
@@ -32,6 +32,7 @@ import {
resolveClaudeJsonSeedMount,
resolveDockerClaudeArtifacts,
resolveDockerCredentialArtifacts,
resolveDockerDaemonMountSource,
toSessionDocker,
writeDockerCases,
writeDockerHosts,
@@ -267,6 +268,32 @@ describe('buildDockerCreateArgs', () => {
expect(s).not.toContain('--storage-opt');
expect(buildDockerCreateArgs(ctx()).join(' ')).not.toContain('--gpus');
});
it('omits the unsupported swap limit while retaining the memory limit when disabled', () => {
const s = buildDockerCreateArgs(ctx({ disableSwapLimit: true })).join(' ');
expect(s).toContain('--memory 4g');
expect(s).not.toContain('--memory-swap');
});
});
describe('resolveDockerDaemonMountSource', () => {
const runtimeHome = join(tmpdir(), 'codeman-runtime-home');
const daemonHome = join(tmpdir(), 'codeman-daemon-home');
it('maps paths beneath the runtime HOME into the daemon-visible HOME', () => {
const source = join(runtimeHome, '.codeman', 'docker-seeds', 'codeman-case-test1.json');
expect(resolveDockerDaemonMountSource(source, runtimeHome, daemonHome)).toBe(
join(daemonHome, '.codeman', 'docker-seeds', 'codeman-case-test1.json')
);
});
it('preserves direct-host and non-HOME sources', () => {
const source = join(runtimeHome, '.claude', 'settings.json');
expect(resolveDockerDaemonMountSource(source, runtimeHome)).toBe(source);
const outsideHome = join(tmpdir(), 'codeman-cases', 'test1');
expect(resolveDockerDaemonMountSource(outsideHome, runtimeHome, daemonHome)).toBe(outsideHome);
});
});
describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencode)', () => {
@@ -329,6 +356,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)', () => {
+8 -1
View File
@@ -38,7 +38,7 @@ interface FakeElement {
textContent: string;
classes: Set<string>;
attrs: Record<string, string>;
classList: { toggle: (name: string, on: boolean) => void };
classList: { toggle: (name: string, on: boolean) => void; contains: (name: string) => boolean };
setAttribute: (name: string, value: string) => void;
}
@@ -55,6 +55,9 @@ function fakeElement(): FakeElement {
if (on) classes.add(name);
else classes.delete(name);
},
contains(name: string) {
return classes.has(name);
},
},
setAttribute(name: string, value: string) {
attrs[name] = value;
@@ -92,10 +95,12 @@ function loadPanel(store: Map<string, string> | null) {
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
const elements: Record<string, FakeElement> = {
fileBrowserPanel: fakeElement(),
fileBrowserTree: fakeElement(),
fileBrowserStatus: fakeElement(),
fileBrowserHiddenBtn: fakeElement(),
};
elements.fileBrowserPanel.classList.toggle('visible', true);
const requests: string[] = [];
const app = new CodemanApp() as Record<string, any>;
app.$ = (id: string) => elements[id] ?? null;
@@ -149,10 +154,12 @@ describe('File Viewer show-hidden toggle', () => {
const { app, requests } = loadPanel(store);
await app.loadFileBrowser('sess-1');
expect(requests[0]).toContain('showHidden=false');
const previousTreeEpoch = app._fileBrowserState.treeEpoch;
await app.toggleFileBrowserHidden();
expect(app.fileBrowserShowHidden).toBe(true);
expect(app._fileBrowserState.treeEpoch).toBe(previousTreeEpoch + 1);
expect(requests).toHaveLength(2);
expect(requests[1]).toContain('showHidden=true');
expect(store.get(STORAGE_KEY)).toBe('1');
File diff suppressed because it is too large Load Diff
+40
View File
@@ -541,6 +541,46 @@ describe('Inline rename input', () => {
expect(result.firstRenameClassActive).toBe(false);
});
it('Session sidebar paints typing without ellipsizing the live editor', async () => {
await resetState();
const id = 'sidebar-live-input';
await page.evaluate((sessionId) => {
const app = (
window as unknown as {
app: {
sessions: Map<string, { id: string; name: string }>;
startInlineRename: (id: string) => void;
};
}
).app;
document.documentElement.dataset.sessionList = 'sidebar';
document.documentElement.dataset.sidebar = 'expanded';
const list = document.getElementById('sessionSidebarList') as HTMLElement;
const tab = document.createElement('div');
tab.setAttribute('data-test-tab', '1');
tab.className = 'session-tab';
tab.innerHTML =
'<span class="tab-info"><span class="tab-name-row">' +
`<span class="tab-name" data-session-id="${sessionId}">old title</span>` +
'</span></span>';
list.appendChild(tab);
app.sessions.set(sessionId, { id: sessionId, name: 'old title' });
app.startInlineRename(sessionId);
}, id);
const label = page.locator(`.tab-name[data-session-id="${id}"]`);
const input = label.locator('input.tab-rename-input');
await input.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
await page.keyboard.type('edited title');
expect(await input.inputValue()).toBe('edited title');
expect(await input.evaluate((node) => document.activeElement === node)).toBe(true);
expect(await label.evaluate((node) => node.classList.contains('tab-name-renaming'))).toBe(true);
expect(await label.evaluate((node) => getComputedStyle(node).overflow)).toBe('visible');
expect(await input.evaluate((node) => node.getBoundingClientRect().width)).toBeGreaterThan(0);
});
it('Vertical rail paints typing in an unclamped editor and restores the clamp on cancel', async () => {
await resetState();
const id = 'vertical-live-input';
+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();
});
});
+4 -3
View File
@@ -225,10 +225,11 @@ describe('OpenCode session initial resize', () => {
await route.continue();
});
// Dispatch the needsRefresh event directly on the EventSource
// (this is how the server sends SSE events — as named events)
// Exercise the SSE fallback path. While WebSocket owns terminal I/O these
// duplicate SSE terminal events are intentionally ignored.
await page.evaluate((sid: string) => {
const app = (window as unknown as { app: { eventSource: EventSource } }).app;
const app = (window as unknown as { app: { eventSource: EventSource; _disconnectWs: () => void } }).app;
app._disconnectWs();
if (app.eventSource) {
const event = new MessageEvent('session:needsRefresh', {
data: JSON.stringify({ id: sid }),
+142
View File
@@ -0,0 +1,142 @@
/** @fileoverview Header plan-usage chip provider rows (Claude above Codex). */
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadCodemanAppClass() {
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const chip = { innerHTML: '', title: '' };
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
document: {
addEventListener: vi.fn(),
getElementById: (id: string) => (id === 'planUsageChip' ? chip : null),
},
localStorage: {
length: 0,
key: vi.fn(),
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
},
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: {},
});
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
return {
CodemanApp: (context as { __CodemanApp: new () => unknown }).__CodemanApp,
chip,
};
}
type UsageApp = { updatePlanUsageChip: (data: unknown) => void };
describe('header plan usage chip', () => {
it('renders Claude first and the main Codex limits underneath', () => {
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({
fiveHour: { usedPercentage: 97, resetAt: 1000 },
sevenDay: { usedPercentage: 44, resetAt: 2000 },
codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } },
});
expect(chip.innerHTML).toContain('class="pu-row"');
expect(chip.innerHTML).toContain('class="pu-provider">Claude</span>');
expect(chip.innerHTML).toContain('class="pu-provider">Codex</span>');
expect(chip.innerHTML.indexOf('Claude')).toBeLessThan(chip.innerHTML.indexOf('Codex'));
expect(chip.innerHTML).toContain('97%');
expect(chip.innerHTML).toContain('44%');
expect(chip.innerHTML).toContain('40%');
expect(chip.title).toContain('Claude plan usage');
expect(chip.title).toContain('Codex plan usage');
});
it('omits unavailable Codex windows instead of inventing zero usage', () => {
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({
fiveHour: { usedPercentage: 10, resetAt: 1000 },
sevenDay: { usedPercentage: 20, resetAt: 2000 },
codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } },
});
const codexRow = chip.innerHTML.slice(chip.innerHTML.indexOf('Codex'));
expect(codexRow).not.toContain('5h');
expect(codexRow).toContain('7d');
});
it("keeps Claude's 5h slot as a dash when no session window is open", () => {
// Claude Code ships `five_hour` "only while the API reports it and its
// resets_at has not passed", so between session windows the key is simply
// absent. The chip used to shrink to a lone 7d segment, which reads as a
// broken feature rather than an idle window (reported 2026-09-01).
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({ sevenDay: { usedPercentage: 52, resetAt: 2000 } });
expect(chip.innerHTML).toContain('pu-win-idle');
expect(chip.innerHTML).toContain('5h');
expect(chip.innerHTML).toContain('52%');
expect(chip.title).toContain('no active session window');
});
it('renders no row at all for a provider reporting nothing', () => {
// The placeholder must never stand alone: a row of em dashes would claim a
// provider is idle when it is really absent.
const { CodemanApp, chip } = loadCodemanAppClass();
const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp;
app.updatePlanUsageChip({ codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } } });
expect(chip.innerHTML).not.toContain('pu-win-idle');
expect(chip.innerHTML).toContain('40%');
});
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');
});
});
+18
View File
@@ -0,0 +1,18 @@
/** @fileoverview Merging Claude status-line usage with polled Codex usage. */
import { expect, it } from 'vitest';
import * as latestModule from '../src/web/plan-usage-latest.js';
it('preserves the Codex row when a later Claude status-line sample arrives', () => {
const module = latestModule as Record<string, unknown>;
expect(module.setLatestCodexPlanUsage).toBeTypeOf('function');
const setCodex = module.setLatestCodexPlanUsage as (value: unknown) => unknown;
const setClaude = module.setLatestPlanUsage as (value: Record<string, unknown>) => unknown;
setCodex({ sevenDay: { usedPercentage: 40, resetAt: 3000 } });
expect(setClaude({ sessionId: 's1', fiveHour: { usedPercentage: 97, resetAt: 1000 } })).toEqual({
sessionId: 's1',
fiveHour: { usedPercentage: 97, resetAt: 1000 },
codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } },
});
});
+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([]);
});
});
@@ -19,19 +19,20 @@
* including the sweep's deleted-workspace guard.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdtemp, rm, readFile, mkdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createMockRouteContext } from '../mocks/index.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { generateHooksConfig, applyWorkspaceHooks } from '../../src/hooks-config.js';
import { getDataDir } from '../../src/config/instance.js';
import { CASES_DIR } from '../../src/web/route-helpers.js';
import { Session } from '../../src/session.js';
interface HooksFile {
hooks?: Record<string, Array<{ matcher?: string; hooks?: Array<{ command?: string }> }>>;
@@ -223,6 +224,7 @@ describe('POST /api/sessions workspace hooks', () => {
describe('POST /api/quick-start workspace hooks', () => {
let app: FastifyInstance;
let ctx: MockRouteContext;
const quickStart = (payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/quick-start', payload });
@@ -230,15 +232,19 @@ describe('POST /api/quick-start workspace hooks', () => {
const hooksFileIn = (dir: string) => join(dir, '.claude', 'settings.local.json');
beforeEach(async () => {
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
app = Fastify({ logger: false });
await app.register(fastifyCookie);
registerSessionRoutes(app, createMockRouteContext());
ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
});
afterEach(async () => {
await app.close();
vi.restoreAllMocks();
// Docker fixtures + case dirs must not leak into the next test.
await rm(join(getDataDir(), 'docker-hosts.json'), { force: true });
await rm(join(getDataDir(), 'docker-cases.json'), { force: true });
@@ -260,7 +266,7 @@ describe('POST /api/quick-start workspace hooks', () => {
});
/** Minimal docker host + case fixtures (docker IO is no-op'd under vitest). */
const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string) => {
const writeDockerFixtures = async (caseName: string, hostWorkspacePath: string, lastClaudeSessionId?: string) => {
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'docker-hosts.json'),
@@ -268,7 +274,7 @@ describe('POST /api/quick-start workspace hooks', () => {
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath }])
JSON.stringify([{ name: caseName, type: 'docker', hostId: 'd1', hostWorkspacePath, lastClaudeSessionId }])
);
};
@@ -300,6 +306,35 @@ describe('POST /api/quick-start workspace hooks', () => {
await rm(ws, { recursive: true, force: true });
}
});
it.each(['codex', 'gemini'] as const)('does not pass a saved Claude conversation id to Docker %s', async (mode) => {
const ws = await mkdtemp(join(tmpdir(), `codeman-docker-${mode}-`));
try {
await writeDockerFixtures('dockexternal', ws, 'e83a9063-3cb4-44d2-a9a0-df153b81721f');
const res = await quickStart({ caseName: 'dockexternal', mode });
expect(res.statusCode).toBe(200);
const session = ctx.sessions.get(JSON.parse(res.body).sessionId);
expect(session?.toState().resumeSessionId).toBeUndefined();
} finally {
await rm(ws, { recursive: true, force: true });
}
});
it('passes a saved Claude conversation id only to Docker Claude', async () => {
const ws = await mkdtemp(join(tmpdir(), 'codeman-docker-resume-'));
const resumeId = 'e83a9063-3cb4-44d2-a9a0-df153b81721f';
try {
await writeDockerFixtures('dockresume', ws, resumeId);
const res = await quickStart({ caseName: 'dockresume', mode: 'claude' });
expect(res.statusCode).toBe(200);
const session = ctx.sessions.get(JSON.parse(res.body).sessionId);
expect(session?.toState().resumeSessionId).toBe(resumeId);
} finally {
await rm(ws, { recursive: true, force: true });
}
});
});
describe('applyWorkspaceHooks (the shared decision core in hooks-config)', () => {
+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) {'));
+101 -1
View File
@@ -8,10 +8,23 @@
*
* RAW_DIALOG_CHUNK below is a verbatim slice of the PTY stream from a live
* session parked on that dialog (Claude Code 2.1.220).
*
* The second bug this pins: Claude Code 2.1.252 dropped the option numbers, put
* "No, exit" first and highlights IT, so the blind Enter that answered the old
* layout selects *exit* and the pane dies seconds after the session starts.
* RENDERED_DIALOG_2_1_252 is a verbatim `capture-pane -p` of that screen.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import { isTrustDialogScreen, compactScreenText, TRUST_DIALOG_MAX_ATTEMPTS } from '../src/session-trust-dialog.js';
import {
isTrustDialogScreen,
compactScreenText,
trustDialogNextKey,
TRUST_KEY_CONFIRM,
TRUST_KEY_DOWN,
TRUST_KEY_UP,
TRUST_DIALOG_MAX_ATTEMPTS,
} from '../src/session-trust-dialog.js';
/** Verbatim from the wire: note the `\x1b[C` where every space should be. */
const RAW_DIALOG_CHUNK =
@@ -29,6 +42,25 @@ const RENDERED_DIALOG = [
' Enter to confirm · Esc to cancel',
].join('\n');
/**
* Verbatim `capture-pane -p` from Claude Code 2.1.252 on a fresh case: no
* numbers, the options reversed, and the cursor parked on the one that quits.
*/
const RENDERED_DIALOG_2_1_252 = [
' Accessing workspace:',
' /home/arkon/codeman-cases/trustprobe1',
' Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source',
" project, or work from your team). If not, take a moment to review what's in this folder first.",
" Claude Code'll be able to read, edit, and execute files here.",
' Security guide',
' ❯ No, exit',
' Yes, I trust this folder',
' Enter to confirm · Esc to cancel',
].join('\n');
/** The same screen after one arrow press: the cursor has moved onto "yes". */
const RENDERED_DIALOG_2_1_252_ON_YES = RENDERED_DIALOG_2_1_252.replace(' ❯ No, exit\n Yes,', ' No, exit\n ❯ Yes,');
/** An ordinary working session: no dialog anywhere. */
const RENDERED_MAIN_UI = [
'✻ Actualizing… (13m 23s · ↓ 47.5k tokens)',
@@ -61,12 +93,54 @@ describe('isTrustDialogScreen', () => {
expect(isTrustDialogScreen('press Enter to confirm the release')).toBe(false);
});
it('sees the 2.1.252 dialog, whose options lost their numbers', () => {
// '2.no,exit' is gone from this layout, so the confirm affordance is now the
// only thing carrying the match.
expect(isTrustDialogScreen(RENDERED_DIALOG_2_1_252)).toBe(true);
});
it('compacts away both real spaces and the escapes tmux sends instead', () => {
expect(compactScreenText('I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder')).toBe('itrustthisfolder');
expect(compactScreenText('I trust this folder')).toBe('itrustthisfolder');
});
});
describe('trustDialogNextKey', () => {
it('confirms straight away when the trust option is already highlighted', () => {
expect(trustDialogNextKey(RENDERED_DIALOG)).toBe(TRUST_KEY_CONFIRM);
expect(trustDialogNextKey(RENDERED_DIALOG_2_1_252_ON_YES)).toBe(TRUST_KEY_CONFIRM);
});
it('moves DOWN instead of confirming when 2.1.252 parks the cursor on "No, exit"', () => {
// The regression in one line: Enter here answers *exit* and kills the pane.
expect(trustDialogNextKey(RENDERED_DIALOG_2_1_252)).toBe(TRUST_KEY_DOWN);
});
it('moves UP when the trust option is the one above, as in the numbered layout', () => {
const numberedOnNo = RENDERED_DIALOG.replace(' ❯ 1. Yes,', ' 1. Yes,').replace(
' 2. No, exit',
' ❯ 2. No, exit'
);
expect(trustDialogNextKey(numberedOnNo)).toBe(TRUST_KEY_UP);
});
it('reads the LAST frame in an append-only buffer, not the first', () => {
// The direct-PTY fallback has no pane to capture, so it reads a buffer that
// still holds every repaint since launch. The freshest frame is the truth.
const buffer = `${RENDERED_DIALOG_2_1_252}\n${RENDERED_DIALOG_2_1_252_ON_YES}`;
expect(trustDialogNextKey(buffer)).toBe(TRUST_KEY_CONFIRM);
});
it('presses nothing when the screen does not say which option is selected', () => {
// A layout this cannot read is a dialog for the human, not a coin flip: the
// wrong guess exits Claude.
const noMarker = RENDERED_DIALOG_2_1_252.replace(' ❯ No, exit', ' No, exit');
expect(trustDialogNextKey(noMarker)).toBe(null);
expect(trustDialogNextKey(RENDERED_MAIN_UI)).toBe(null);
expect(trustDialogNextKey('')).toBe(null);
});
});
describe('Session trust-dialog auto-accept', () => {
afterEach(() => vi.useRealTimers());
@@ -102,6 +176,32 @@ describe('Session trust-dialog auto-accept', () => {
expect(writes).toEqual(['\r']);
});
it('walks the 2.1.252 dialog onto the trust option before it confirms', () => {
vi.useFakeTimers();
// The whole point: no Enter goes out while "No, exit" is highlighted.
let screen = RENDERED_DIALOG_2_1_252;
const { writes, tick } = sessionShowing(() => screen);
tick();
expect(writes).toEqual([TRUST_KEY_DOWN]);
screen = RENDERED_DIALOG_2_1_252_ON_YES;
vi.advanceTimersByTime(2000);
tick();
expect(writes).toEqual([TRUST_KEY_DOWN, TRUST_KEY_CONFIRM]);
});
it('never presses Enter while the cursor sits on "No, exit"', () => {
vi.useFakeTimers();
// A dialog that never moves (a dropped arrow, a wedged pane) must run out of
// attempts pressing arrows, not answer *exit* on the way.
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG_2_1_252);
for (let i = 0; i < 20; i++) {
tick();
vi.advanceTimersByTime(2000);
}
expect(writes).toEqual(Array(TRUST_DIALOG_MAX_ATTEMPTS).fill(TRUST_KEY_DOWN));
});
it('retries a dropped keystroke, then gives up rather than typing forever', () => {
vi.useFakeTimers();
// Ink can drop a keystroke while it is still mounting the widget, so one
+113 -1
View File
@@ -38,7 +38,10 @@ function loadTerminalUiHarness(mode: string) {
app._workerYield = () => {};
app._chunkedWriteGen = 0;
app.terminal = {
write: (data: string) => writes.push(data),
write: (data: string, callback?: () => void) => {
writes.push(data);
callback?.();
},
scrollToBottom: () => {},
scrollToLine: () => {},
};
@@ -46,7 +49,90 @@ function loadTerminalUiHarness(mode: string) {
return { app, writes };
}
function loadAppHarness() {
const dir = resolve(import.meta.dirname, '../src/web/public');
const fetchMock = vi.fn();
const context = vm.createContext({
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
performance: { now: () => 0 },
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: fetchMock,
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: { isTouchDevice: () => false },
});
const constants = readFileSync(resolve(dir, 'constants.js'), 'utf8');
const appSource = readFileSync(resolve(dir, 'app.js'), 'utf8');
vm.runInContext(`${constants}\n${appSource}\nglobalThis.__CodemanApp = CodemanApp;`, context);
const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp;
return { CodemanApp, fetchMock };
}
describe('terminal flush budget', () => {
it('counts incoming, loading, and xterm in-flight bytes before accepting live output', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const start = source.indexOf('_onSessionTerminal(data)');
const body = source.slice(start, source.indexOf('\n // ═', start));
expect(body).toContain('this._loadBufferQueue?.reduce');
expect(body).toContain('this._terminalWriteInFlightBytes || 0');
expect(body).toContain('queued + data.data.length > 131072');
});
it('drops redundant SSE terminal events whenever WebSocket owns terminal I/O', () => {
const { CodemanApp } = loadAppHarness();
const app = Object.create(CodemanApp.prototype) as any;
app._wsReady = true;
app._onSessionTerminal = vi.fn();
app._onSessionNeedsRefresh = vi.fn();
app._onSessionClearTerminal = vi.fn();
app._onSSETerminal({ id: 'session-1', data: 'duplicate' });
app._onSSENeedsRefresh({});
app._onSSEClearTerminal({ id: 'session-1' });
expect(app._onSessionTerminal).not.toHaveBeenCalled();
expect(app._onSessionNeedsRefresh).not.toHaveBeenCalled();
expect(app._onSessionClearTerminal).not.toHaveBeenCalled();
});
it('runs at most one buffer recovery per session and ignores stale-session events', async () => {
const { CodemanApp, fetchMock } = loadAppHarness();
const app = Object.create(CodemanApp.prototype) as any;
app.activeSessionId = 'session-1';
app.sessions = new Map([['session-1', { mode: 'shell' }]]);
app.terminal = {};
app._isLoadingBuffer = false;
app._terminalRefreshOwner = null;
let releaseFetch!: () => void;
fetchMock.mockImplementation(
() =>
new Promise((resolveFetch) => {
releaseFetch = () => resolveFetch({ json: async () => ({ data: { terminalBuffer: '' } }) });
})
);
await app._onSessionNeedsRefresh({ id: 'stale-session' });
expect(fetchMock).not.toHaveBeenCalled();
const first = app._onSessionNeedsRefresh({ id: 'session-1' });
const duplicate = app._onSessionNeedsRefresh({ id: 'session-1' });
expect(fetchMock).toHaveBeenCalledOnce();
expect(fetchMock).toHaveBeenCalledWith('/api/sessions/session-1/terminal?tail=1048576');
releaseFetch();
await Promise.all([first, duplicate]);
expect(app._terminalRefreshOwner).toBe(null);
});
it('drains a large final batch without waiting for unrelated terminal output', () => {
const { app, writes } = loadTerminalUiHarness('codex');
const scheduled: Array<() => void> = [];
@@ -89,6 +175,32 @@ describe('terminal flush budget', () => {
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
});
it('waits for xterm to parse a live chunk before submitting the next one', () => {
const { app, writes } = loadTerminalUiHarness('shell');
const scheduled: Array<() => void> = [];
let parsed: (() => void) | undefined;
app._safeYield = (callback: () => void) => scheduled.push(callback);
app.isTerminalAtBottom = () => true;
app.terminal.write = (data: string, callback?: () => void) => {
writes.push(data);
parsed = callback;
};
app.batchTerminalWrite('x'.repeat(96 * 1024));
scheduled.shift()?.();
expect(writes.map((write) => write.length)).toEqual([64 * 1024]);
expect(app.pendingWrites.join('')).toHaveLength(32 * 1024);
expect(scheduled).toHaveLength(0);
expect(app._terminalWriteInFlightBytes).toBe(64 * 1024);
parsed?.();
expect(scheduled).toHaveLength(1);
scheduled.shift()?.();
expect(writes.map((write) => write.length)).toEqual([64 * 1024, 32 * 1024]);
});
it('releases the live-output gate but waits for xterm to parse a small replay', async () => {
const { app, writes } = loadTerminalUiHarness('codex');
let writeDone: (() => void) | undefined;
+8 -10
View File
@@ -191,7 +191,7 @@ describe('backpressure refresh keeps a reader in place (issue #259)', () => {
it('is wired into the refresh path instead of an unconditional scrollToBottom', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const start = app.indexOf('async _onSessionNeedsRefresh()');
const start = app.indexOf('async _onSessionNeedsRefresh(');
expect(start).toBeGreaterThan(-1);
const body = app.slice(start, app.indexOf('\n async _onSessionClearTerminal', start));
expect(body).toContain('computeRewriteScrollLine');
@@ -199,18 +199,16 @@ describe('backpressure refresh keeps a reader in place (issue #259)', () => {
expect(body).toContain('this.terminal.scrollToLine(target)');
});
it('recovers FULL history, guarded against a repaint-pane downgrade', () => {
// Measured before the fix: this path rewrote an 869-row buffer from a 1MB
// tail and left 158 rows, so the refresh meant to REPAIR the terminal was
// destroying most of its scrollback. It asks for full history now, and
// falls back to the tail only when the full capture would shrink the buffer
// (a repaint-mode pane keeps roughly one frame in tmux).
it('keeps shell recovery bounded and full TUI recovery downgrade-safe', () => {
// A shell's automatic recovery must not reset+replay a multi-megabyte tmux
// history on xterm's main thread. TUI modes still recover full history and
// fall back when a repaint-mode pane would shrink the browser buffer.
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const start = app.indexOf('async _onSessionNeedsRefresh()');
const start = app.indexOf('async _onSessionNeedsRefresh(');
const body = app.slice(start, app.indexOf('\n async _onSessionClearTerminal', start));
expect(body).toContain("const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell'");
expect(body).toContain('terminal?full=1');
expect(body).toContain('this._replayWouldShrinkBuffer(data.terminalBuffer)');
// The tail must survive as the fallback, not vanish.
expect(body).toContain('tail=${TERMINAL_TAIL_SIZE}');
expect(body).toContain('useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer');
});
});