Compare commits

..
Author SHA1 Message Date
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
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
Codeman maintainer a51563ce1f chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:14:41 +02:00
Ark0N ca5fe1ab3e Merge pull request #338 from Ark0N/feat/vertical-rail-detailed-rows
Vertical tab rail: detailed rows (created / working / status), plus a rename-cancel fix
2026-08-25 19:12:57 +02:00
Ark0N 9cd10afdc9 Merge pull request #341 from Ark0N/feat/deepseek-agent-workers
Spawn and drive DeepSeek Harness workers from the codeman agent skill
2026-08-25 19:12:45 +02:00
Ark0N 975705ad87 Merge pull request #337 from Ark0N/feat/deepseek-harness
feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode
2026-08-25 19:12:01 +02:00
Codeman maintainer 93a1042bb3 Merge remote-tracking branch 'origin/feat/deepseek-harness' into feat/deepseek-agent-workers
# Conflicts:
#	CLAUDE.md
2026-08-25 19:02:34 +02:00
Codeman maintainer a628737d1f fix(deepseek): review-driven hardening across the harness integration
Fifteen review findings on the dsh mode, the serious ones first:

- Multi-user: DEEPSEEK_BASE_URL joins the owner-clamped env keys.
  _configureDeepSeek() forwards the SERVER's own DEEPSEEK_API_KEY into
  every dsh pane and applyEnvOverrides() lands after it, so a non-granted
  owner who could redirect the base URL would have the operator's key sent
  as a bearer credential to a host of their choosing.
- Wait registry: until=stop/blocked is refused on docker and remote-SSH
  dsh sessions (new deepSeekBridgeUnreachable fact in sessionHookOptions).
  The HERDR triple is set via LOCAL tmux setenv, which crosses neither
  docker exec nor ssh, so such a session can never post a hook event and
  the wait burned its whole timeout on every turn.
- Approvals: a dsh item is an ALERT, not an answerable card. The answer
  route refuses (the '1'/Esc keystrokes are Claude-dialog-shaped and the
  option parser cannot read a third-party TUI's frames, so an answer was a
  blind keystroke into a foreign composer), and the push notification
  carries no Approve/Deny actions for dsh sessions.
- Status shim (v3): --seq is forwarded and the server drops stale retried
  reports inside a 60s window (the TUI retries with backoff, so a retried
  'working' could land after 'blocked' and resolve an approval whose
  dialog was still on screen); 4xx responses exit 0 instead of retrying,
  so one misconfigured session cannot feed the auth rate-limit bucket
  until the hook endpoint 429s for the whole instance.
- Web-UI server: concurrent starts are serialized through a lock (two
  racing POSTs used to pick the same port and orphan the winner), and the
  readiness poll / timeout paths only clear or stop the singleton while it
  is still theirs. First click actually opens the tab now
  (refreshWebviews, not the nonexistent loadWebviews). DELETE
  /api/deepseek/web requires the privileged grant in multi-user mode.
- Cron: deepseek jobs run the same two-part launch gate as the HTTP
  create paths (impl moved into the resolver so all three share it) and no
  longer stamp a Claude default model on the session.
- Parity sweeps: quick-start's docker branch rejects deepSeekConfig like
  the remote branch; the Ralph auto-enable list gained deepseek;
  HookEventType gained agent_working; the phone overview run menu filters
  managed webview records like the desktop menu.
- install.sh: the dsh identity probe closes stdin (under curl|bash a
  child that reads stdin eats the rest of the script), bounds the exec
  with timeout where available, and is memoized to one scan per install.
- Welcome screen: .welcome-btn-deepseek styled in the #4d6bfe brand
  identity (it rendered as an unstyled UA-grey button); stale markup
  comment about the web shortcut rewritten; clamp docs updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:01:29 +02:00
Codeman maintainer 015b865f56 fix(deepseek): review fixes for the transcript reader — docker/remote gate, poll memo, honest pairing docs
Four review findings on the worker-transcript feature:

- Docker and remote-SSH dsh sessions now keep the pane segmenter: their
  transcripts live in the container's / remote host's own ~/.dsh, which
  the local reader can never see, so the transcript path returned
  'nothing said yet' forever and an agent polling such a worker starved
  on an answer that existed. Gated on !session.docker && !session.remote
  (statically pinned) and documented in the integration guide.

- last-response reads are memoized on (path, mtime, size, blocks): the
  skill's last_text polls once per second, and each poll decompressed and
  reparsed the whole file on the event loop even when nothing had been
  appended. An unchanged poll now costs one stat.

- The pairing ladder's comment claimed /new is served by step 2; in truth
  the boot-window transcript wins for as long as it exists (deliberately:
  preferring newest-eligible would hand a worker its busier sibling's
  reply). The comment now states the real tradeoff instead of the
  aspirational one. Same for decodeZstdFrames' 'skipped' wording — a
  corrupt frame truncates the decode there, which is the safe behavior.

- stripReasoningPrefix no longer runs on user prompt text, so a prompt
  containing a literal </think> renders whole in blocks view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 18:30:41 +02:00
Codeman maintainer 33f77c4680 fix(tabs): review fixes for the detailed rail — width-dialog default, compact wrap pass, rich-aware resets
Three review findings on the detailed-rows feature, all in its edge cases:

- The App Settings width select consulted the handheld defaults blob
  (tabRailWidth: 256) BEFORE the rich-aware default, which the renderer
  never reads — so a tablet's unsized rich rail rendered 320 while the
  dialog said 256, and a routine Save persisted the 256 (below the 288px
  tight threshold, permanently). The chain now mirrors
  applyTabRailWidth()'s actual resolution.

- _setTabRailWidth() re-rendered on a compact flip but never re-ran
  applyTabWrapSettings(), the one owner of the folder line, whose railRich
  input reads the compact class this function just toggled. A rich rail
  dragged below 240px kept emitting folder rows — persistently, for a
  stored width < 240, since the boot wrap pass runs before the class is
  first applied. The wrap pass now re-runs on the flip, with exactly one
  render either way.

- Both reset affordances (handle dblclick, Enter on the handle) reset to
  the hardcoded 256 even on a rich rail, landing it below the tight
  threshold; both now resolve the rich-aware default (320), via a new
  optional defaultWidth input on resolveTabRailKeyboardWidth().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 18:26:07 +02:00
Codeman maintainer 6261b6f655 feat(skill): spawn and drive DeepSeek Harness workers
The agent skill could spawn a worker in any mode, but it could only
DRIVE a claude one: every other CLI has neither a real end-of-turn
signal nor an answer to read, so the recipes route them through output
markers.

dsh has both halves now -- its harness reports idle/working/blocked to
Codeman, and the previous commit reads its transcript -- so it joins
claude as a mode the four verbs work on unchanged. `spawn_workers alpha
beta:deepseek` is a mixed fleet in one call, and `sendwait` / `last_text`
/ `delete_session` need no per-mode variant.

Preamble 1.20.0 (SKILL.md's §0 heredoc regenerated from it):

- `spawn_worker` grows a deepseek branch that gates on the harness
  composer. ⚠️ Readiness there is NOT the stop signal: the harness
  reports idle at BOOT ~300 ms before its composer paints (measured
  2.26 s vs 2.56 s after spawn), so a send-and-wait fired straight after
  quick-start resolves on the boot edge, reports a turn that never ran,
  and strands the prompt in a pane not yet taking input. Waiting for the
  composer also spends that edge, since signals are edge-triggered.
- `spawn_workers` takes `name[:mode]`, so a mixed fleet stays one
  concurrent call. Case names still have to be unique -- the mode never
  disambiguates two workers that would share a directory.
- `sendwait` asks for `wait:"stop,exit"` instead of the `wait:true`
  default set. That set also carries `idle`, which for an external CLI is
  inferred from output stabilization: on a dsh worker whose TUI repaints
  rarely, the re-wait resolved in 0 ms with `signal:"idle"` on a turn
  with three minutes left to run. It also makes a wrong mode loud -- the
  modes that cannot deliver `stop` answer 400 before writing anything,
  instead of resolving on a flap.
- The self-heal resend carries `delivered:true` forward. The resend is a
  tagged duplicate, so the server truthfully reports `delivered:false`
  about a write it skipped, and §1's cleanup then read a completed turn
  as an undelivered one and kept a finished worker forever.
- dsh workers spawn with the permission posture the Run button sends,
  because the harness default still asks and a worker parked on an
  approval row cannot finish a fan-out. The multi-user clamp still
  applies.

Docs: a worked dsh flow in recipes.md, readiness and the signal rules in
verbs.md, and the corrections this makes necessary -- `stop`/`blocked`
are no longer claude-only, and `last-response` is no longer permanently
empty for deepseek. The integration guide gains a section on reading a
session back and driving one as a worker; its web-UI section was also
stale (that server moved out of a shell session).

The static guard that keeps those lists from naming some external CLIs but
not others is extended rather than exempted: it now knows the three real
classes inside that family (no transcript, no hook signals, and the
positive twin -- the modes whose answers can be read), with the hook class
derived from `hooksAvailableForMode()` so the predicate and the prose
cannot drift apart. Any other partial list still fails, and a new backend
belongs to none of the classes until someone says so.
2026-08-25 04:17:48 +02:00
Codeman maintainer d1bc0c517d feat(deepseek): read dsh session transcripts for last-response
`GET /api/sessions/:id/last-response` is how an agent (and the Response
Viewer) reads what a worker said. DeepSeek was falling through to the
pane segmenter with the other external CLIs, which for this mode is not
merely coarse but wrong: dsh-TUI paints a full-screen splash, so a
`last-response` call on a fresh dsh session answered with its ASCII-art
logo -- and anything polling for a worker's first reply reads that as a
reply.

dsh does not belong in that group. It writes a structured JSONL
transcript per session, so read it. Four things in that file shaped the
reader, all measured against real transcripts on disk:

1. dsh appends ONE ZSTD FRAME PER WRITE, and Node's zlib zstd decoder
   (one-shot and streaming alike) stops at the first frame end: a real
   56-line transcript decoded as 1 line / 158 bytes -- the session header
   alone, i.e. a silent truncation that reads as "nothing said yet"
   forever. `zstdFrameRanges()` walks frame and block headers to find
   exact boundaries; splitting on the 4-byte magic would corrupt
   everything after a magic sequence occurring inside compressed data.
   zstd is resolved at RUNTIME because it landed in Node 22.15 while the
   project floor is 22.0, so an older Node keeps the pane behaviour.
2. Every turn also records a plugin-sourced `user/message` (the runtime
   context snapshot), which must not render as the user's own words.
3. A turn that ends in an error carries the provider's message; it is
   surfaced as `Turn error: …` (and a non-error early stop as
   `Turn ended: …`) rather than as an empty string, which an agent reads
   as "still thinking" through fifteen polls.
4. Reply text is assembled per (turn, step): a finalized message wins and
   the streamed deltas fill in only for a step that never finalized, so a
   partial answer is readable mid-turn and never doubled. "Finalized" is
   tracked as a set of steps rather than as non-empty text, because a
   step whose whole reply was reasoning strips to '' at the `</think>`
   boundary and would otherwise resurrect the raw deltas in its place.

Session-to-transcript pairing is by the transcript's own header `cwd`
plus a boot window against the session's createdAt, never by
reproducing dsh's directory mangling (already two forms on disk) and
never by newest-mtime alone -- mtime alone handed a freshly spawned
worker its predecessor's answer in the same case directory.

An empty result still wins over the pane; only a Node that cannot decode
zstd falls back to it.
2026-08-25 04:14:26 +02:00
Codeman maintainer c30dfaf0e7 fix(deepseek): run the web UI server in the background, not in a shell tab
Clicking "DeepSeek web UI..." opened two tabs: the web tab asked for, and a
shell tab running the server next to it. The shell was deliberate - the server
lived in an ordinary session so it was visible, scrollable, killable and died
with its tab, and nothing new had to supervise a long-lived HTTP server. That
reasoning was sound and the result was still wrong in use: opening a dashboard
should open one tab, and after the first launch the terminal is pure noise.

The server moves to a background child process owned by a new
`src/deepseek-web-server.ts`, behind `POST /api/deepseek/web`. What the session
gave away for free is now explicit, which is most of the module:

- Exactly one server. A second click reuses the running one instead of racing
  it for a port; the session flow could not do this at all, because two clicks
  were simply two sessions.
- Restarted when the requested authority changes. `--trusted-host` fences dsh's
  own /api against the browser authority, and a Codeman reachable at both
  loopback and a tailnet name has two. Reusing a server fenced for the other
  origin renders a page whose every call 403s, which reads as a broken
  dashboard rather than a misconfigured one, so a mismatch restarts instead.
- Killed on shutdown. The child is detached so its whole plugin tree can be
  signalled at once, which also means it would outlive Codeman and hold its
  port against the next start - the exact EADDRINUSE this feature already got
  wrong once.
- Boot output captured and returned. With no shell tab there is nowhere else
  for a stack trace to land, so a failed spawn reports its own tail.

The endpoint is fenced at the same bar as the profile installer and for the
same reason: booting a dsh profile executes the plugin code in it, so this is a
privileged action even though it reads as "open a page". `authority` comes from
the client (`location.host`) because only the browser knows which origin is in
play, and it is regex-confined at the schema boundary - defence in depth behind
the argv-array spawn, admitting host:port in the shapes a browser authority can
take and nothing readable as a second argument.

`GET /api/deepseek/web-port` is gone; port selection moved into the supervisor,
which is the thing that knows whether a server is already running. The two
client-side probe helpers went with it, since the server now owns the wait.

Verified over the tailnet authority end to end: no session is created (session
count unchanged, one tab), the server runs on 3081 beside the user's own dsh
web on 3080, status reports the tailnet authority, and the proxied dashboard
renders with zero 4xx. Full gate green (6148 passed, +6).
2026-08-25 03:08:15 +02:00
Codeman maintainer 15ae5f5d81 fix(deepseek): make the web-UI shortcut pick a free port, verify it, and trust its frame
The `Run > DeepSeek web UI...` shortcut failed three ways at once against a real
install, and the three are independent.

1. It hardcoded `--port 3080`. That is dsh web's OWN default, which makes it
   precisely the port a DeepSeek user is most likely to be serving on already,
   so the launch died with EADDRINUSE against the user's own server. The port
   now comes from `GET /api/deepseek/web-port`, which walks 3080..3119 for a
   free loopback port by BINDING it (a connect probe cannot tell "free" from
   "listening but not answering yet").

2. It opened the tab unconditionally. The crashed server left a saved dashboard
   pointing at nothing, with the failure only visible in a shell tab nobody had
   a reason to look at. The launch now polls the existing webview probe until
   the URL answers, and on timeout reports the error naming the shell tab
   instead of persisting a dead dashboard.

3. The saved tab was untrusted, so the frame was sandboxed without
   `allow-same-origin` and the dashboard was broken twice over: the dsh
   client-runtime reads `localStorage` while loading its plugins and died there
   ("the document is sandboxed and lacks the 'allow-same-origin' flag"), and an
   opaque-origin frame sends `Origin: null`, so dsh's own trust fence 403'd
   every `/api` call no matter which authority `--trusted-host` named. Passing
   `location.host` only means anything once the frame actually carries that
   origin, so `--trusted-host` had never once done its job. The managed tab is
   now created `trusted: true`.

   That trade is real and deliberate: a trusted proxied frame is same-origin
   with Codeman and can reach Codeman's API. It is defensible only because this
   dashboard is an agent harness Codeman just started itself, on loopback, which
   can already run code as the user. It is not a precedent for trusting
   third-party dashboards, which is why it is set at this one call site rather
   than defaulted.

Separately, the shortcut listed its own dashboard twice: once as the menu entry
that starts it and once as the row that entry had written on the previous click.
Webviews now carry an optional `managed` marker, managed rows are filtered out
of the saved-dashboard list, and a relaunch repoints the existing row rather
than stacking one dead dashboard per restart (which the per-launch port would
otherwise guarantee). `managed` is declared in the schema because a plain
`z.object` strips undeclared keys, so an undeclared marker would never survive
the round trip.

`DEEPSEEK_WEB_PORT` is gone from constants.js; its doc comment asserted that a
hand-started `dsh web` and the shortcut "land on the same place and share one
saved tab", which is the bug stated as a feature.

Verified on a real install with the user's own `dsh web` holding 3080: the
shortcut takes 3081, the server answers, exactly one DeepSeek entry shows in the
run menu, and the proxied dashboard renders its workspaces and completes its own
API calls (the previously-403'd `api/settings.describe` now succeeds). Full gate
green (6142 passed), typecheck/lint/format/public-assets clean.
2026-08-25 02:39:57 +02:00
Codeman maintainer 14de2b7012 fix(tabs): keep the created stamp reachable on a tight rail, and do not skip the first render
Two review nits on the vertical rail's detailed rows.

1. The tab-rail-tight rule (below 288px) hides `.tab-meta-created`, and its
   comment claimed the value "survives in the row's title attribute either way".
   It did not: the only title carrying it lived ON that element, and a
   `display: none` element has no hover target, so the created stamp was not
   shrunk but gone with no way to ask for it. Rather than just correcting the
   comment, `_sidebarRichMetaHTML()` now puts BOTH absolute stamps on the
   `.tab-meta` line itself, so the pill and the gaps around the stamps remain as
   hover targets. An item's own title still wins where the item is visible.

2. applyTabOrientation() decided whether applyTabWrapSettings() had already
   re-rendered by comparing `_tallTabsEnabled` before and after. That reads an
   UNDEFINED previous value as "it rendered", but applyTabWrapSettings()
   deliberately renders nothing on its first call ever (it only establishes the
   baseline: `prevTallTabs !== undefined && prevTallTabs !== showFolder`). So on
   a first call that also flips the folder row, neither function rendered and the
   rows stayed stale. Reachable when the pre-paint script throws and leaves the
   layout attributes on their catch-branch fallbacks for applyTabOrientation() to
   correct. The guard now mirrors applyTabWrapSettings()'s own condition.

Both new tests were run against the unfixed code first and fail there, which is
the only thing that makes them regression tests. (The third, "does not render
twice", passes either way by design: it pins that fix 2 did not introduce a
double rebuild.)

Verified in a real browser against a live server with two sessions, driving the
narrowing through _setTabRailWidth() the way the resize drag does: at the 320
default the row reads "CREATED 2m ago · IDLE <1m" with the created element
displayed; at 256 the tight class is on, the created element computes to
display:none, the visible text drops to "IDLE <1m", and the meta line's title
still reads "First created: ...". At 220 the compact threshold drops rich rows
entirely. Screenshots confirm no truncation artifacts in either state.

Full gate green (6104 passed), typecheck, lint, format, frontend-syntax and
public-assets all clean.
2026-08-24 22:58:28 +02:00
Codeman maintainer cdceede33d fix(deepseek): atomic shim write, honest attribution comment, name-fallback profile classifier
The three smaller review nits, plus the first real test coverage for the status
shim (it had none: it is emitted as a STRING, so tsc never sees it).

1. The shim was written with a plain writeFileSync. The TUI can be exec'ing that
   exact path while an upgraded Codeman refreshes it, and a reader catching a
   half-written file gets a syntax error, exits non-zero, and is retried four
   times per state change for a file that will never parse. Now temp + rename
   (atomic within the directory), with the temp chmod'ed before the rename since
   writeFileSync's mode only applies on create, and removed if the write throws.
   SHIM_VERSION bumped to 2, because SHIM_SOURCE changed and an existing v1 shim
   would otherwise keep matching the embedded marker and never be refreshed.

2. The pane-id comment claimed the ambient env "cannot be spoofed by an argument
   the agent itself could influence". The agent runs IN that pane and can invoke
   the shim with CODEMAN_SESSION_ID unset and any argv it likes. It buys nothing
   it did not already have (the hook-secret file is readable from the same pane,
   so it can POST /api/hook-event directly), but the comment read like a security
   boundary. Rewritten to say what the preference actually buys: correct
   attribution when a TUI mangles or re-uses the pane argument. Accidents, not
   adversaries.

3. classifyProfile() folded the directory name into the same haystack as the
   bundles, but only the TUI arm could match a bare name, so a stock profile
   whose package.json has no dsh.profile.bundles (hand-edited, older layout,
   mid-install) classified as `unknown` -> launchable -> eligible as the DEFAULT
   pick, which is exactly the pane-dies-on-arrival failure the two-part
   availability gate exists to prevent. The stock names are now a LAST-resort
   fallback consulted after the bundle patterns, so real bundle evidence still
   wins over a name the user chose. The loose `tui` arm gained word boundaries:
   it decides which profile boots by default, and matching the middle of
   `intuition` is not a rule anyone could predict.

New test/deepseek-status-shim.test.ts runs the generated script the way the
harness does -- real node process, real argv, real env, real listener -- and
covers the exit-code contract that makes the retry behaviour safe: mapped states
post and exit 0, an unknown verb or unmapped state exits 0 WITHOUT posting (a
non-zero there would be four HTTP requests per state change forever), a rejecting
server or an unreachable one exits non-zero so the caller retries, the hook secret
is read at execution time, and `node --check` parses the file (a template-literal
typo in SHIM_SOURCE is invisible to tsc).

Trap worth recording, hit while writing it: the tests must spawn the shim
ASYNCHRONOUSLY. The listener lives in the test process, so spawnSync blocks the
event loop that has to accept the connection, the shim waits out its own 1500ms
socket timeout and exits 1, and it reads exactly like a broken shim (measured:
Socket._onTimeout in its --trace-exit output, server logging nothing).

Verified: full gate green (6142 passed, +10), typecheck/lint/format clean.
2026-08-24 18:00:52 +02:00
Codeman maintainer 2034719d61 fix(deepseek): close the env-var clamp hole, bound the profile install, make the hook gate per-session
Three review findings on the DeepSeek Harness mode, plus one the third exposed.

1. The multi-user clamp was bypassable by a sibling field on the same request.
   clampExternalCliBypassForOwner() clamps deepSeekConfig.permissionMode, but
   DSH_* is an allowlisted envOverrides prefix and applyEnvOverrides() runs AFTER
   _configureDeepSeek(), so a non-granted owner sending
   envOverrides.DSH_PERMISSION_MODE landed last and won. Measured on an isolated
   instance: a session created with permissionMode "read-only" and that override
   ran with DSH_PERMISSION_MODE=danger-full-access in its pane.

   Every other CLI's bypass is a command-line flag reachable only through the
   per-CLI config, which is why the config clamp alone is the whole gate for
   them. clampEnvOverridesForOwner() adds the env-var half: for a non-granted
   owner it DROPS DSH_PERMISSION_MODE and DSH_HOME (dropping falls through to
   what _configureDeepSeek() exports, i.e. the clamped value). DSH_HOME is on
   that list because it aims the launcher at a profile tree whose plugin code
   runs at boot, before any approval row can apply. Verified end to end in real
   multi-user mode: a non-granted user sending both now gets workspace-write and
   no DSH_HOME, while an unrelated DSH_TELEMETRY_MODE passes through untouched.

2. POST /api/deepseek/install-profile could hang forever. spawn's own `timeout`
   signals only the direct child, and a plugin install fans out into
   package-manager children that keep the inherited stdio pipes open, so `close`
   never fires and the held-open request leaks with no route-level deadline.
   Reproduced: with a 1.5s built-in timeout the promise was still unsettled after
   6s and both fan-out children were alive. Now detached: true plus negative-pid
   SIGTERM/SIGKILL, the same escalation runGit() uses for the same reason, with a
   last-resort reap for a grandchild that escaped the group. Same probe after the
   change: close fires, direct child and both grandchildren dead.

3. hooksAvailableForMode() promised more than a dsh session can deliver.
   deepSeekConfig.statusReporting: false disarms the HERDR_* export, and that
   triple is the only reason a dsh session posts hook events, so `until=stop` was
   accepted and then blocked for the caller's whole timeout: the exact
   infinite-wait-dressed-as-a-timeout the predicate exists to prevent. It now
   takes HookCapabilityOptions and every call site passes sessionHookOptions(),
   with the deepseek arm reading `!== false` so a forgotten one degrades to the
   old behaviour. The refusal names the setting rather than saying "no Claude
   Code hooks", which would send the caller hunting a bug that is really a
   setting they chose. Profile conformance stays unknowable at request time and
   is documented as such. The stale "True for `claude` and nothing else" docblock
   is corrected.

4. Exposed by (3): hooksAvailableForMode() was doing double duty as "is this a
   claude session". Read My Mind (POST /api/sessions/:id/readmymind) and intent
   capture read Claude's own transcript, and adding deepseek silently widened
   both to a mode that has none. They compare mode === 'claude' directly now, and
   a static check pins them there.

Verified: full CI gate green (6132 passed), typecheck/lint/format clean, and the
wait-signal gating exercised against a live server with a real dsh 0.1.1-rc.2 --
bridge off plus explicit until=stop is a 400 naming the setting, bridge off with
no `until` still 200s on idle/exit, bridge on accepts stop.
2026-08-24 16:01:02 +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
Codeman maintainer b330f1d9e8 feat(tabs): give the vertical rail the home screen's per-session detail
The vertical tab rail (tabOrientation 'vertical') listed names and nothing
else, while the rich sidebar and both home screens already answered the
question a docked column exists to answer: which of these sessions wants me
next, and how long has it been like that. The rail is a docked column too, so
it now draws the same row.

- New per-device setting tabRailDetail ('rich' | 'simple', default rich),
  App Settings -> Appearance -> Tabs, in SettingsUpdateSchema + displayKeys and
  stamped as data-tab-rail-detail by the pre-paint script, so a detailed rail
  does not flash through simple rows on every load.
- ONE gate for both vertical surfaces: isRichTabRows() =
  isSessionSidebarRich() || isTabRailRich(). The row model, the markup and the
  20s in-place clock are the existing rich-sidebar ones, classified by
  _mobileOverviewState/_mobileOverviewSince, so the rail, the sidebar, the
  desktop home rail and the phone overview cannot disagree about what
  "working" means or which stamp measures it.
- Detail rides on its OWN attribute, exactly as the sidebar's does, so every
  existing [data-tab-orientation='vertical'] rule keeps matching both variants
  untouched. A flip of detail ALONE still forces a full render (the stamps line
  is emitted by the row template, not toggled by CSS) and re-runs
  applyTabWrapSettings(), which owns the folder line and is now rail-aware.
- CSS: every rich paint rule gains a rail twin as a COMMA-GROUPED selector,
  never :is() - an :is() list takes its most specific argument, which would
  lift the sidebar arm from (0,3,1) to the rail's (0,5,1) and let these rules
  outrank things they never used to.
- Width is why there are thresholds. At 256px the stamps line ellipsizes
  mid-word, the same reason the rich sidebar is 300px, so a rail that has never
  been sized defaults to 320 (RICH_DEFAULT_WIDTH, the existing Wide preset,
  which also keeps the settings select on a named choice). A width the user has
  chosen is never overridden: below 288px the created stamp is dropped rather
  than truncated (tab-rail-tight, CSS only) and below 240px the rows go back to
  simple (tab-rail-compact, which re-renders).
- The rich clock is armed and disarmed by applyTabOrientation() as well as
  applySessionListLayout(); a leaked interval would rewrite stamps in a list
  that no longer has any.

Also fixes a data-loss bug in the inline tab rename that predates the rail and
reproduces in every layout, header strip included: Escape set the input to ''
and blurred it, and the blur handler commits - so cancelling a rename PUT an
empty name, and the tab fell back to its folder label (measured against a live
server: ["rail-alpha","","rail-gamma"]). Escape now calls cancelRename(), which
invalidates the edit so the blur that follows the input's removal is a no-op.

Tests: rail-detail gate, the three ways it turns back off (simple, compact,
horizontal), sidebar-wins, render-on-detail-flip and the plumbing/CSS guards in
test/session-list-layout.test.ts; the rename cancel in test/inline-rename.test.ts
(browser suite), pinned by running it against the old code first. Verified live
against a real server on an isolated instance: detailed/simple/compact/header/
sidebar variants, click-select, the ... menu, inline rename, Alt+N, the in-place
stamp tick and a full settings-picker round-trip including reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 04:45:02 +02: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
Codeman maintainer 4cda150493 feat(deepseek): add DeepSeek Harness (dsh) as a ninth CLI run mode
Adds `mode: 'deepseek'` alongside claude/shell/opencode/codex/gemini/
antigravity/pi/grok, plus a shortcut that opens the harness's own browser UI
as a Codeman web tab.

DeepSeek is wired unlike its siblings in three ways, each of which is the
reason for a design decision rather than an accident:

1. The agent is a PROFILE, not the binary. `dsh` is a launcher over
   $DSH_HOME/profiles/<name>, and DeepSeek ships only `web`, `headless` and
   `base` -- the interactive terminal front door is always a third-party
   plugin. So availability is two questions: `isDeepSeekAvailable()` (binary)
   and `isDeepSeekRunnable()` (binary AND a pane-capable profile). The Run
   button gates on the latter, because reporting only the binary would spawn a
   pane that dies on arrival. When the binary is present but no profile is,
   the run menu offers to install one (POST /api/deepseek/install-profile).

2. The permission switch is an env var, not a flag. The harness has no
   command-line permission option; its sandbox/approval rows read
   DSH_PERMISSION_MODE (read-only / workspace-write / danger-full-access).
   Exported via `tmux setenv`, never on the spawn line. Absent = the harness's
   own workspace-write, which still asks, so the multi-user clamp is the
   only-if-sent branch and clamps to workspace-write, never read-only.

3. It is the only non-claude mode that passes hooksAvailableForMode(), and it
   earned that. The terminal front door reports idle/working/blocked to a
   supervising process over a generic env-gated contract; a generated shim
   (deepseek-status-shim.ts) makes Codeman that supervisor and forwards each
   report to /api/hook-event as stop / agent_working / permission_prompt. So a
   dsh session gets definitive respawn triggers, real wait-endpoint signals and
   real Approvals Inbox items instead of output-stabilization guesswork.
   `agent_working` is new (157th SSE constant) and joins
   APPROVAL_RESOLVING_EVENTS so a dialog answered in the terminal clears its
   alert at once.

The resolver needs the strictest identity probe of the family: `dsh` is not
merely a squattable npm name, Debian ships an unrelated `dsh` (dancer's shell),
so `dsh --help` must print the harness's own banner before a candidate is
handed a spawn line.

Model is deliberately not a session field -- it is a composition entry in the
profile's config tree. Env allowlist gains DSH_* and DEEPSEEK_* only; provider
keys named by a settings-file `apiKeyEnv` stay out, which is pi's
34-provider-key problem in a new shape.

Verified live against dsh 0.1.1-rc.2 and @deepseek-harness-tui/dsh-tui: the
status endpoint's two-part answer, the no-profile refusal, the profile
bootstrap, a real session whose pane runs `dsh --profile dsh-tui` with the
permission mode injected via setenv, and the full status bridge -- a
send-and-wait returned signal "stop" from a real turn, and blocked/working
created and cleared an Approvals Inbox item.

Docs: docs/deepseek-integration.md (guide), docs/deepseek-integration-plan.md
(decisions + honest gaps). Tests: test/deepseek-mode.test.ts,
test/deepseek-cli-resolver.test.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 03:37:56 +02: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
101 changed files with 11896 additions and 446 deletions
+3
View File
@@ -2,6 +2,9 @@
.agents/
skills-lock.json
# In-session decision scratchpad (context-survival mechanism, not a deliverable)
DECISIONS.md
# Written by install.sh into end-user clones when setup finishes
.install-complete
+99
View File
@@ -1,5 +1,104 @@
# aicodeman
## 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
- DeepSeek Harness as a ninth run mode, DeepSeek agent workers, and detailed rows for the vertical tab rail.
**DeepSeek Harness (`dsh`) run mode** (#337): DeepSeek's plugin-native agent framework joins Claude Code, shell, OpenCode, Codex, Gemini, Antigravity, Pi and Grok as a run mode. The harness is a profile launcher rather than an agent, so availability is two questions (binary AND a pane-capable profile): the Run button gates on both, a missing terminal profile is offered as a one-click install (`POST /api/deepseek/install-profile`, the only endpoint in Codeman that installs third-party code, fenced accordingly), and the resolver demands the harness's own help banner so Debian's unrelated `dsh` (dancer's shell) can never be spawned. Its permission switch is the `DSH_PERMISSION_MODE` env export (the harness has no bypass flag), injected via tmux setenv and clamped for non-granted owners in multi-user mode, including the env-override path. The community TUI's supervisor-reporting contract makes deepseek the first non-Claude mode with REAL lifecycle signals: a generated status shim turns its idle/working/blocked reports into definitive `stop`/`permission_prompt`/`agent_working` hook events, so dsh sessions get real respawn triggers, real wait signals and red "needs you" alerts instead of output-stabilization guesswork. The vendor's browser UI opens as a managed web tab through a background `dsh web` fenced to Codeman's origin. Docker image support included.
**DeepSeek agent workers** (#341): the codeman agent skill can spawn and drive dsh workers like claude ones — tasked, waited on and read with the same calls. `GET /api/sessions/:id/last-response` reads the harness's real zstd transcript (one frame per append; the reader walks frame boundaries itself, since a naive decode silently truncates to the first frame), distinguishes real prompts from plugin-injected context, and reports a failed turn's provider error instead of an empty answer.
**Vertical tab rail: detailed rows** (#338): the vertical rail can now show the home screen's per-session line (created stamp, state duration, status pill) via the new per-device `tabRailDetail` setting (default detailed; `simple` restores the 1.22.0 rows). One shared row model and one gate (`isRichTabRows()`) keep the rail, the rich sidebar and both home screens in agreement about what "working" means. A never-sized rail opens at the 320px Wide preset; below 288px the created stamp is dropped, below 240px rows fall back to simple. Also fixes Escape during an inline tab rename committing an empty name (the session then displayed its folder name).
**Review hardening across all three** (post-review commits on each PR): multi-user owners without the bypass grant can no longer redirect the server's forwarded `DEEPSEEK_API_KEY` via a `DEEPSEEK_BASE_URL` override; waits on `stop`/`blocked` are refused for docker/remote dsh sessions (their status bridge cannot reach the harness) and docker/remote dsh sessions keep the pane reader (their transcripts are not local); dsh approvals are alerts answered in the terminal, never blind keystrokes into a third-party TUI; the status shim forwards the contract's `--seq` token (stale retried reports are dropped server-side) and treats 4xx as permanent so a misconfigured session cannot rate-limit the hook endpoint for the whole instance; concurrent DeepSeek web-UI starts are serialized; cron deepseek jobs run the same launch gate as the HTTP paths; the installer's dsh identity probe is stdin-closed, bounded and memoized; transcript reads are memoized per (path, mtime, size) so 1s polling stops decoding unchanged files; the rail's width dialog, compact-threshold folder rows and reset affordances are rich-aware.
### Patch Changes
- b330f1d: Vertical tab rail: detailed rows, and a rename cancel that no longer wipes the name.
The vertical rail (Tab Orientation → Vertical) now draws the same per-session
line the home screen and the rich sidebar draw — when the session was created,
how long it has been in the state it is in, the folder it runs in, and a status
pill — instead of just the name. New per-device setting **Vertical Rail Rows**
(`tabRailDetail`, App Settings → Appearance → Tabs) with `Detailed` as the
default and `Simple (name only)` as the opt-out. A rail that has never been
sized now opens at 320px (the existing Wide preset) so the line fits; a narrower
rail sheds the created stamp below 288px and falls back to simple rows below
240px.
Also fixes a data-loss bug in the inline tab rename that predates the rail:
pressing Escape cleared the input and blurred it, and the blur handler commits —
so cancelling a rename stored an EMPTY session name and the tab fell back to its
folder label. Escape now cancels without a request, in every layout.
## 1.22.0
### Minor Changes
+15 -15
View File
File diff suppressed because one or more lines are too long
+12 -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
@@ -171,7 +171,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
wsl bash -c "curl -fsSL https://getcodeman.com/install | bash"
```
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), or [Grok Build](https://github.com/xai-org/grok-build)). After installing, `http://localhost:3000` is accessible from your Windows browser.
Codeman requires tmux, so Windows users need [WSL](https://learn.microsoft.com/en-us/windows/wsl/install). If you don't have WSL yet: run `wsl --install` in an admin PowerShell, reboot, open Ubuntu, then install your preferred AI coding CLI inside WSL ([Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi)). After installing, `http://localhost:3000` is accessible from your Windows browser.
</details>
@@ -253,7 +253,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
| Field | What it does |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, or `Terminal` (plain shell). |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `OMP`, or `Terminal` (plain shell). |
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
@@ -437,7 +437,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Background daemon & service install** — `codeman web -d` runs the server detached with a pidfile, `~/.codeman/web.log`, and verified startup (it polls the server until it answers, so a port clash never reads as success); `codeman service install` writes a systemd user unit (Linux) or LaunchAgent (macOS) with your shell's PATH baked in, so an nvm or Homebrew `node`, `tmux` and `claude` are actually found. Secrets are never written into unit files
- **Self-update** — git-clone installs under systemd/launchd update in place from **App Settings → System → Updates**: it detects the latest release, auto-stashes a dirty tree, and streams build progress across the service restart (npm installs report as non-updatable)
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, or **Grok** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md) and [`docs/grok-integration.md`](docs/grok-integration.md)
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
- **Remote SSH sessions** — point a case at another machine and run the agent there inside a durable remote tmux: survives SSH drops, auto-reconnects, and can discover + attach sessions already running on the host. See [`docs/remote-sessions.md`](docs/remote-sessions.md)
- **Effort & Ultracode** — set a per-session default effort (`low`–`max`) or enable **ultracode** (dynamic multi-agent workflows). Soft defaults only — switchable anytime with `/effort` in-session. Extended-thinking budget is configurable too
@@ -460,7 +460,7 @@ Run a case inside its own hardened Docker container instead of directly on your
- **Shared per-case container** — many sessions can `docker exec` into the same container; killing one session never tears the container out from under the others.
- **Hardened by default** — non-root, `--cap-drop ALL`, `no-new-privileges`, PID/memory caps, never `--privileged` or the docker socket; a **sealed** profile (no host credentials, network off) is one toggle away.
- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / Pi logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.
- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
- **Seamless auth, isolated credentials** — your host Claude / Codex / Antigravity / Gemini / OpenCode / OMP logins work inside the container out of the box: credentials are seeded (copied) in at launch and onboarding/trust prompts are pre-answered, so no login wizard appears. The container keeps its own copies and never writes back to your host credential stores; only conversation transcripts are shared, and exports never capture secrets.- **Move it to another machine** — export a container's whole environment (toolchain + workspace) to a portable `.tar.gz`, `docker load` it on the other side, and import it into a fresh case.
- **Durable** — reconnect after a restart lands back in the same live agent; a container stop/reboot resumes the conversation from the bind-mounted transcript.
Prerequisite: just Docker (or Podman). The agent base image builds itself automatically on first use, with progress streamed to the UI (or pre-build it with `node scripts/build-agent-image.mjs`). Full guide: [`docs/docker-cases.md`](docs/docker-cases.md).
@@ -795,7 +795,7 @@ When a CLI runs in a Codeman-managed session, these environment variables are se
5. **`/api/v1/*`** is a stable alias of `/api/*`.
6. **Wait instead of polling, and don't treat a timeout as an error.** The wait endpoints answer with HTTP `200` and `wait.timedOut: true` when nothing happened in time, so loop over short waits (60s is the default) rather than issuing one long call, because tunnels cut idle connections. `wait.timeoutMs` tells you the timeout the server actually applied after clamping (600s ceiling).
7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/pi) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.
8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
7. **Only `claude` sessions emit `stop` and `blocked`.** Those two come from Claude Code hooks; `shell` and the external CLIs (opencode/codex/gemini/antigravity/omp) accept only `idle`, `working` and `exit`. Asking for `stop` explicitly on those is a `400`; omitting `until` is always safe. ⚠️ On a `shell` session `idle` fires **once**, at startup, and never again, so send-and-wait there can only time out; synchronize hook-less sessions with a `wait-output` marker.8. **Nothing reports "ready", so wait for it explicitly.** A new session answers `{"signal":"exit","immediate":true}` (that means *not started*, not *crashed*) until its PID exists, and a `claude` worker in a fresh case then sits on the CLI's trust dialog. Prompt it there and the wait resolves on `idle` in ~2s looking exactly like a finished turn, while the text sits stuck in the dialog. Recipe 2b below is the sequence that avoids it.
### Recipes
@@ -1011,7 +1011,7 @@ flowchart TB
subgraph External["External"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi</small>"]
BG["Background Agents<br/><small>(Task tool)</small>"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / OMP</small>"] BG["Background Agents<br/><small>(Task tool)</small>"]
end
end
+2 -2
View File
@@ -58,7 +58,7 @@ curl -fsSL https://getcodeman.com/install | bash
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。也可以使用 `install.sh update` 与 `install.sh uninstall`。
- **CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev) 或 [Grok Build](https://github.com/xai-org/grok-build)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这七个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后:
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会提供安装 Claude Code 或 OpenCode 的选项,也可以选择跳过、稍后自行安装。安装完成后:
```bash
codeman web
@@ -141,7 +141,7 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.codeman.web.plist
wsl bash -c "curl -fsSL https://getcodeman.com/install | bash"
```
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev) 或 [Grok Build](https://github.com/xai-org/grok-build))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
Codeman 依赖 tmux,因此 Windows 用户需要 [WSL](https://learn.microsoft.com/en-us/windows/wsl/install)。如果还没装 WSL:在管理员 PowerShell 中运行 `wsl --install`,重启,打开 Ubuntu,然后在 WSL 内安装你偏好的 AI 编程 CLI([Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi))。安装完成后,即可从 Windows 浏览器访问 `http://localhost:3000`。
</details>
+60
View File
@@ -68,6 +68,41 @@ RUN curl -fsSL https://x.ai/cli/install.sh | bash \
&& rm -rf /root/.grok /root/.local/bin/grok /root/.local/bin/agent \
&& grok --version
# DeepSeek Harness (`dsh`). A normal npm package, but the ONLY entry here whose
# binary runs nothing on its own: `dsh` is a profile launcher, and DeepSeek ships
# only `web` and `headless`, so without an interactive profile a
# `mode: 'deepseek'` container would start a pane that dies on arrival. The
# profile itself is installed further down, into the `agent` HOME, because
# 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.
# ⚠️ `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 \
&& 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
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
@@ -88,9 +123,34 @@ ENV HOME=/home/agent
# `.pi/agent` and `.grok` ARE pre-created: both are seeded per-FILE (pi:
# auth/settings/trust/models; grok: auth.json/config.toml/pager.toml), and a
# per-file seed copy, unlike a whole-dir one, does not create its parent directory.
# `.dsh` is pre-created for the same per-file reason (.env/settings.yaml/
# cordis.patch.yml), and the interactive profile is built into it HERE rather than
# after `USER agent`: this layer's closing chgrp/chmod is what makes the whole tree
# 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/.omp/agent \
&& DSH_HOME=/home/agent/.dsh HOME=/home/agent \
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
File diff suppressed because one or more lines are too long
+178
View File
@@ -0,0 +1,178 @@
# DeepSeek Harness (`dsh`) integration plan
> **Status**: Executed. This document records the plan, the decision behind each
> wiring point, and what was and was not verified. The user-facing guide is
> [`deepseek-integration.md`](./deepseek-integration.md); the per-decision
> invariants live in
> [`architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek`](./architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek).
> Template: the grok integration ([`grok-integration-plan.md`](./grok-integration-plan.md)),
> itself calibrated against pi. Every fact below was measured against a live
> **dsh 0.1.1-rc.2** install and **@deepseek-harness-tui/dsh-tui 0.9.0**, not read
> from documentation.
## 1. What the DeepSeek Harness is
[deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
(open-sourced 2026-08-13, MIT) is a plugin-native agent framework: tools, skills,
sessions, sandboxes and whole APPS are Cordis plugins composed into *profiles*.
`dsh` is the launcher — `dsh --profile <name>` boots
`$DSH_HOME/profiles/<name>`, an ordered stack of plugin-bundle patch layers under
the user's own overrides. State lives in `~/.dsh` (`.env` 0600, `settings.yaml`,
`cordis.patch.yml`, `profiles/`, `sessions/`, `storages/`).
## 2. Shape decisions (why DeepSeek is wired the way it is)
DeepSeek is a ninth run mode. Never a location overlay, never a web tab (the
browser UI is handled separately, §3). Three of its decisions have no precedent
in the six external CLIs before it.
| Question | Decision | Why |
| --- | --- | --- |
| What does a pane run? | `dsh --profile <name>`, profile discovered | **The decision that shapes everything else.** DeepSeek ships `web`, `headless` and `base` — no terminal agent. The interactive front door is always a third-party plugin, so Codeman resolves a binary AND a profile inventory, and "available" means both. `resolveDefaultDeepSeekProfile()` prefers a recognized TUI, then an UNRECOGNIZED profile (anyone can publish an app bundle; a classifier that has not heard of one must not hide it), and refuses `web`/`headless`, which cannot occupy a pane. |
| Which TUI? | none blessed; default for BOOTSTRAP only | `POST /api/deepseek/install-profile` defaults to `@deepseek-harness-tui/dsh-tui` (~27.5k weekly downloads, ~4x the next, MIT, and it speaks the status contract in §2.3), but accepts any npm name and the resolver never assumes that profile exists. Codeman offers a default; it does not pick a winner. |
| Permission bypass | `DSH_PERMISSION_MODE` env export, no flag | The harness has NO command-line permission option; its sandbox/approval rows read one env var with three presets (`read-only` / `workspace-write` / `danger-full-access`, read off `dsh --dump-default-config`). This is the one legitimate exception to the `CLAUDE_CODE_EFFORT_LEVEL` ban: that var hard-locks in-session switching, whereas the harness reads this with `??` as a boot-time DEFAULT, so it stays soft. Exported via `tmux setenv`, never on the command line. The Run button sends `danger-full-access`, matching every sibling Run button. |
| Multi-user clamp branch | only-if-sent, clamped to `workspace-write`, **plus an env-var half** | Omitting the export leaves the harness on `workspace-write`, which still ASKS, so an absent config is already safe (the codex/antigravity/grok shape, not pi's materialize). Clamping to `workspace-write` rather than `read-only` is deliberate: the clamp removes privilege, it must not break a session's ability to edit its own workspace. ⚠️ Unlike every sibling, clamping the CONFIG is only half the gate: the switch is an env var, `DSH_*` is an allowlisted `envOverrides` prefix, and `applyEnvOverrides()` runs AFTER `_configureDeepSeek()`, so `envOverrides: {DSH_PERMISSION_MODE: 'danger-full-access'}` on the same request would land last and win. `clampEnvOverridesForOwner()` drops `DSH_PERMISSION_MODE` and `DSH_HOME` for a non-granted owner (dropping falls through to the clamped export). `DSH_HOME` because it aims the launcher at a profile tree whose plugin code runs at BOOT, before any approval row. |
| `hooksAvailableForMode()` granularity | per SESSION for deepseek, per mode for everything else | `deepSeekConfig.statusReporting: false` disarms the `HERDR_*` export, and the triple is the only reason a dsh session posts anything, so a mode-only answer would accept `until=stop` where nothing can send one — the infinite-wait the predicate exists to prevent. Call sites pass `sessionHookOptions(session)`; the default stays permissive so a forgotten one degrades to the old behaviour. ⚠️ Profile conformance stays unknowable at request time (an unrecognized profile is deliberately launchable), so a non-conforming TUI still times out on an explicit `stop`; the default set keeps `idle`/`exit` for that. ⚠️ The predicate is NOT "is this claude": Read My Mind and intent capture read Claude's transcript and were silently widened by this change, so they compare `mode === 'claude'` directly now. |
| Profile install spawn | own process group, hand-rolled timeout | `dsh plugin add` fans out into package-manager children, and spawn's built-in `timeout` signals only the direct child: survivors keep the inherited stdio pipes open, `close` never fires, and the held-open request leaks with no route-level deadline. `detached: true` + negative-pid SIGTERM→SIGKILL, the same escalation `runGit()` uses for the same reason, plus a last-resort reap for a grandchild that escaped the group. |
| Idle detection | **real hook events via a status shim** | The standout decision. The TUI already reports its lifecycle to a supervising process through a generic env-gated contract inherited from Herdr: `HERDR_ENV=1` + `HERDR_BIN_PATH` + `HERDR_PANE_ID` make it run `<bin> pane report-agent <id> --state idle\|working\|blocked …` on every state change, exit 0 = delivered. `deepseek-status-shim.ts` generates a script into the data dir and points `HERDR_BIN_PATH` at it. So deepseek is the only non-claude mode that passes `hooksAvailableForMode()` — earned by emitting definitive signals, not granted. An interface implementation, not an impersonation: no real `herdr` binary is ever executed, and a TUI that ignores the contract simply falls back to output stabilization. |
| `agent_working` event | new, 157th SSE constant | The one hook event with no Claude Code hook behind it. A harness turn cannot run while its own modal approval is on screen, so "started working" proves a dialog was answered in the terminal. Without it a dsh red alert would survive until the next `stop` — the exact stuck-alert bug the claude path already fixed once, and its pane-capture staleness sweep is Claude-dialog-shaped and cannot help here. |
| Resolver | identity probe THEN version probe | Strictest of the family, and not by preference. `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) which would answer a version probe convincingly and then be handed a spawn line. `dsh --help` must match `DeepSeek Harness` first. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release. |
| Env allowlist | `DSH_*` + `DEEPSEEK_*` | `DSH_*` covers the launcher's documented inputs (`DSH_HOME`, `DSH_PERMISSION_MODE`, `DSH_TELEMETRY_MODE`, the `DSH_TUI_*` knobs); `DEEPSEEK_*` is the vendor namespace holding `DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`, same reasoning that admitted `XAI_*` for grok. ⚠️ Pi's lesson repeats exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), and the allowlist is one GLOBAL list, so admitting those would widen every mode at once. They stay out. |
| Model | NOT a session field | The model is a composition entry (`agent-default-model`) in the profile's config tree, set in `~/.dsh/settings.yaml` + `cordis.patch.yml`. Both create paths deliberately resolve no model for this mode rather than inventing a flag. |
| Alt-screen strip | OUT of `isAltScreenStripMode()` | Third-party fullscreen TUIs with their own scrollback and mouse handling — the opencode case, not the Ink case. |
| Local echo | `'buffer'` via the `_updateLocalEchoState` fallthrough | UNMEASURED against a live authenticated session (see §5), same honest gap grok shipped with. The leading TUI's composer supports `@` completion and history search, which *may* make it per-keystroke reactive like codex; if so the fallback is the `'off'` branch. |
| Docker | image installs dsh AND a profile | Profiles are deliberately NOT seeded from the host: each is a per-profile `node_modules` tree, host-arch-specific and far too large to copy per container start. Only `~/.dsh/.env`, `settings.yaml`, `cordis.patch.yml` are seeded (auth + model composition). The profile install rides the `useradd` layer so the closing `chgrp`/`chmod g=u` covers it, which is what keeps it usable under the arbitrary uid the container runs as. |
| Remote SSH | `exec "$SHELL" -i -l -c 'dsh'` | Boots the remote box's default profile; a remote with several needs the per-host `commands.deepseek` override, since `deepSeekConfig` does not cross ssh. |
## 3. The web profile
The browser UI is the only interactive surface DeepSeek ships itself, so it gets
a **shortcut, not a run mode**: `Run ▸ DeepSeek web UI…` starts
`dsh web --no-open --host 127.0.0.1 --port <free> --trusted-host <codeman-authority>`
as a background process and opens the URL as an ordinary web tab.
The server was a **shell session** first, on the reasoning that Codeman already
supervises those (visible, scrollable, killable, dies with its tab) so nothing
new had to own a long-lived HTTP server. That version worked and was still
wrong in use: clicking "open the DeepSeek web UI" put a terminal tab on screen
next to the web tab actually asked for, every single time, and after the first
launch the terminal was pure noise. Opening a dashboard should open one tab.
So `POST /api/deepseek/web` owns it instead (`src/deepseek-web-server.ts`), and
what the session gave away for free is now explicit: exactly one server, reused
rather than raced on a second click; restarted when the requested authority
changes; killed on Codeman shutdown (a detached child would otherwise hold its
port against the next start — the very EADDRINUSE this feature already got
wrong once); and boot output captured, since with no shell tab there is nowhere
else for a stack trace to land. It is fenced at the same bar as the profile
installer: booting a dsh profile executes the plugin code in it, so it requires
the privileged grant in multi-user mode.
`--trusted-host` is load-bearing — dsh fences its `/api` behind a browser-trust
check on the request authority, and a Codeman web tab reaches it through
Codeman's own origin via the webview proxy, not directly. The authority comes
from the CLIENT (`location.host`) because only the browser knows which of a
multi-homed Codeman's origins is actually in play.
Three things about this shortcut are load-bearing and each came from it failing
in exactly that way against a real install:
- **The port is chosen, never hardcoded.** `GET /api/deepseek/web-port` walks
3080..3119 for a free loopback port. 3080 is dsh's own default, which makes it
precisely the port a DeepSeek user is most likely to already be serving on:
binding it unconditionally killed the launch with `EADDRINUSE` against the
user's own `dsh web`.
- **The tab is opened only after the server answers.** The launch polls
`POST /api/webviews/probe` until the URL responds, so a server that dies on
startup reports the failure and points at its shell tab, instead of silently
persisting a dashboard aimed at nothing.
- **The saved tab is `trusted: true`, and must be.** An untrusted webview is
sandboxed without `allow-same-origin`, which breaks this dashboard twice: the
dsh client-runtime reads `localStorage` while loading plugins and dies there,
and an opaque-origin frame sends `Origin: null`, so dsh's trust check 403s
every `/api` call regardless of what `--trusted-host` names. Passing
`location.host` only means anything once the frame actually carries that
origin. The trade is real — a trusted proxied frame is same-origin with
Codeman and can reach Codeman's API — and is defensible only because this
particular dashboard is an agent harness Codeman just started itself on
loopback, which can already run code as the user. It is not a precedent for
trusting third-party dashboards generally.
The record is marked `managed: 'deepseek-web'`, which keeps it out of the
saved-dashboard list: the shortcut that maintains it is already a menu entry, so
listing both showed the same dashboard twice. Being managed is also what lets a
relaunch repoint the existing row instead of stacking one dead dashboard per
restart, since the port is now chosen per launch.
The authority baked into `--trusted-host` is the one the launch was clicked
from, and reuse is conditional on it: a running server fenced for a *different*
origin is stopped and restarted rather than reused, because reusing it renders a
page whose every API call 403s — which reads as a broken dashboard rather than a
misconfigured one.
## 4. Touch points (the checklist)
Backend: `types/session.ts` (SessionMode + `DeepSeekConfig` + SessionState),
`utils/deepseek-cli-resolver.ts` (new) + barrel, `deepseek-status-shim.ts` (new),
`tmux-manager.ts` (`buildDeepSeekCommand`, dispatch, resume flag, PATH export,
truecolor, `_configureDeepSeek`, availability error, plumbing), `session.ts`
(external-mode gate, label, config plumbing, tmux-required error, attach env),
`mux-interface.ts`, `schemas.ts` (prefixes, `DeepSeekConfigSchema`,
`DeepSeekInstallProfileSchema`, both mode enums, remote overrides, cron agentType,
`agent_working`), `session-wait-registry.ts` (`hooksAvailableForMode`),
`hook-event-routes.ts` (`APPROVAL_RESOLVING_EVENTS`), `session-routes.ts` (clamp +
both create paths + `resolveDeepSeekLaunchError`), `system-routes.ts`
(`GET /api/deepseek/status`, `POST /api/deepseek/install-profile`), `server.ts`
(availability inject + mux restore), `sse-events.ts`, `docker-hosts.ts`,
`remote-hosts.ts`, `config/dependency-registry.ts`,
`response-viewer-transcript.ts`, `cron/cron-service.ts` (comment),
`tui/tui-client.ts` + `tui-app.ts`.
Frontend: `index.html` (welcome button, run-mode entry, install affordance, web-UI
shortcut, cron option, clone Brain option), `session-ui.js` (`runDeepSeek()`,
`runDeepSeekWeb()`, `installDeepSeekProfile()`, dispatch, availability, "Run DS"
label, external-CLI gates), `app.js` (label, `ds` tab badge, kill-menu, SSE map),
`settings-ui.js` (welcome gate + `_onHookAgentWorking`), `constants.js`,
`mobile-overview.js`, `home-sessions.js`, `panels-ui.js`, `i18n.js`,
`terminal-ui.js`, `styles.css` + `mobile.css` (brand-indigo identity; the non-og
skin block and the mobile `!important` pair are both load-bearing).
Meta: `docker/agent.Dockerfile`, `install.sh`, `package.json` keyword,
`skills/codeman/reference/*`, CLAUDE.md, `architecture-invariants.md`.
Tests: `test/deepseek-mode.test.ts` + `test/deepseek-cli-resolver.test.ts` (new);
`run-mode-ui`, `render-index-html`, `mobile-overview`, `agent-skill-mode-lists`
(extended).
## 5. Verification performed
See the summary at the end of the implementing session for the live run. In
short: the CI gate green; the resolver, profile inventory, spawn-line and clamp
behaviour covered by 31 new unit tests; and an isolated instance used to exercise
`GET /api/deepseek/status` and a real session against the live dsh install.
**Not verified (honest gaps):**
- The local-echo `'buffer'` policy against the TUI's real composer (§2). If it
turns out per-keystroke reactive like codex's, flip it to the `'off'` branch;
teaching `PredictiveEchoAddon` its composer row is the larger follow-up.
- Scrollback/repaint behaviour of a third-party fullscreen TUI under the narrow
strip during a long session.
- A Docker case with `mode: 'deepseek'` (needs a `--no-cache` agent-image
rebuild — see the `--no-cache` rule in CLAUDE.md).
- A remote-SSH deepseek case.
- The web-UI shortcut against a tunnel authority. Loopback and a tailnet name are
both verified end to end through the webview proxy (dashboard renders, its
`/api` calls succeed, no shell session created).
## 6. Follow-ups
- **Response viewer**: read `~/.dsh/sessions/**` (JSONL) the way codex rollouts
are read back. Highest-value follow-up, and very achievable.
- **`headless` as an execution backend** for Codeman's own AI checks
(`ai-idle-checker`, `ai-plan-checker`), today Claude-only.
- **Profile/model picker in Session Options**, reading `GET /api/deepseek/status`
`.profiles`.
- **`--patch` overlays per session**, which is the harness-native way to change
agent composition without touching the user's profile.
- Measure the local-echo policy and pin the result the way pi did.
+305
View File
@@ -0,0 +1,305 @@
# DeepSeek Harness (`dsh`) in Codeman
Codeman can run [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)
as a session backend, alongside Claude Code, OpenCode, Codex, Gemini,
Antigravity, Pi and Grok. It is the ninth run mode, and the one that is wired
least like the others, for two reasons worth understanding before you use it.
## 1. The agent is a profile, not the binary
`dsh` is a **launcher**, not an agent. It boots a *profile*: an ordered stack of
plugin-bundle patch layers under `$DSH_HOME/profiles/<name>` (`$DSH_HOME`
defaults to `~/.dsh`). DeepSeek ships three bundles and none of them is a
terminal agent:
| Profile | What it is | Can Codeman run it in a tab? |
| ------------ | --------------------------------- | ---------------------------- |
| `web` | the browser UI, served on :3080 | no — but see §6 |
| `headless` | answers one task and exits | no |
| (`base`) | the shared core, no app at all | no |
The interactive terminal front door is **always a third-party plugin**. So
"DeepSeek is installed" and "Codeman can start a DeepSeek session" are different
questions, and Codeman answers both separately:
```bash
curl -s localhost:3000/api/deepseek/status | jq
{
"available": true, # the `dsh` binary resolved and proved its identity
"runnable": false, # ...but nothing installed can drive a pane
"path": "/home/you/.local/bin",
"version": "0.1.1-rc.2",
"dshHome": "/home/you/.dsh",
"defaultProfile": null,
"profiles": [ { "name": "web", "kind": "web", "bundles": [...] } ]
}
```
### Installing a terminal profile
From the UI: open the **Run** dropdown. When `dsh` is installed but no
pane-capable profile is, the menu shows **DeepSeek — add a terminal profile…**.
One click installs one and the normal DeepSeek entry appears.
By hand, or to pick a different front door:
```bash
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
under `$DSH_HOME/profiles` that is not `web` or `headless` shows up in the
inventory and can be launched, including one you compose yourself. The endpoint
accepts any npm package name:
```bash
curl -sX POST localhost:3000/api/deepseek/install-profile \
-H 'Content-Type: application/json' \
-d '{"profile":"my-tui","package":"@someone/dsh-tui"}'
```
Installing a plugin is arbitrary code execution on the host, so in multi-user
mode this endpoint requires the can-bypass-permissions grant (the same bar as a
`shell` session). The request is held open while the package manager runs and is
bounded at five minutes; the install runs in its own process group, so hitting
that bound kills the whole tree rather than just the launcher.
> **`dsh` is also a Debian program.** `apt install dsh` gives you "dancer's
> shell", a distributed shell, which would answer `--version` convincingly.
> Codeman's resolver therefore demands the harness's own help banner before it
> will point a spawn line at a candidate, and `GET /api/deepseek/status` reports
> `path` and `version` so a misresolution is diagnosable rather than presenting
> as "the mode just doesn't work".
## 2. Permissions are an env var, not a flag
The harness has **no `--dangerously-skip-permissions` equivalent**. Its sandbox
and approval rows are configuration, driven by one documented input,
`DSH_PERMISSION_MODE`, with three presets (read off `dsh --dump-default-config`):
| `DSH_PERMISSION_MODE` | sandbox | approvals | notes |
| --------------------- | -------------------- | --------- | ------------------------- |
| `read-only` | `read-only` | ask | |
| `workspace-write` | `workspace-write` | ask | the harness's own default |
| `danger-full-access` | `danger-full-access` | **never** | what the Run button sends |
Codeman exports it via `tmux setenv`, never on the command line. Because the
harness reads it with `??`, it is a **soft default**: it sets the boot-time
preset and you can still change permission mode inside the session.
Omitting it entirely leaves the harness on `workspace-write`, which still asks —
which is why the multi-user clamp only needs to force a *sent* value down. A
non-granted owner's `danger-full-access` becomes `workspace-write`, not
`read-only`: the clamp removes privilege without breaking the session's ability
to edit its own workspace.
Because the switch is an env var rather than a flag, that clamp has a second half
no other CLI needs. `DSH_*` is an allowlisted `envOverrides` prefix (it has to be:
that is also how you set the harness's ordinary knobs), and env overrides are
applied *after* the permission export, so in multi-user mode a non-granted owner
sending
```json
{ "mode": "deepseek", "envOverrides": { "DSH_PERMISSION_MODE": "danger-full-access" } }
```
would otherwise hand back the privilege the config clamp just removed. For a
non-granted owner Codeman therefore **drops `DSH_PERMISSION_MODE` and `DSH_HOME`
from `envOverrides`**; dropping them falls through to the clamped config and the
server's own `DSH_HOME`. `DSH_HOME` is in that list because it points the
launcher at a profile tree, and a profile's plugin code runs at boot, before any
approval row can apply. Single-user installs and granted owners are unaffected.
## 3. Real idle detection (the interesting part)
Every other external CLI mode in Codeman is **readiness-guessed**: Codeman
watches the PTY go quiet and infers that a turn ended. Claude is the exception,
because Claude Code fires hooks.
DeepSeek is the second exception. The community terminal front door already
reports its own lifecycle to a supervising process through a generic,
env-var-gated contract (inherited from [Herdr](https://herdr.dev)): when
`HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set, it shells out on
every state change with
```
"$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
--source custom:dsh-tui --agent dsh-tui \
--state idle|working|blocked [--message ...] --seq N
```
Codeman points `HERDR_BIN_PATH` at a small generated shim
(`~/.codeman/dsh-status-shim.mjs`, written at session create) which forwards each
report to `POST /api/hook-event`. The mapping:
| Harness state | Codeman hook event | What you get |
| ------------- | ------------------ | -------------------------------------------------------- |
| `blocked` | `permission_prompt`| red "needs you" tab alert + an Approvals Inbox item |
| `idle` | `stop` | definitive end-of-turn: respawn triggers, `wait` returns |
| `working` | `agent_working` | clears an alert answered in the terminal, at once |
So a DeepSeek session gets Claude-grade signals: `GET /api/sessions/:id/wait`
really can block on `stop` and `blocked` for it, and it is the only non-Claude
mode for which that is true (`hooksAvailableForMode`).
That is a per-*session* answer, not a per-mode one. Turning the bridge off with
`deepSeekConfig.statusReporting: false` means nothing will ever post a hook event
for that session, so an explicit `until=stop` is refused up front (with a message
naming the setting) rather than blocking for your whole timeout. Omitting `until`
never fails: the hook-only signals are dropped from the default set and you still
get `idle` and `exit`.
One limit worth knowing: whether the *profile* implements the contract cannot be
known at request time (Codeman deliberately treats an unrecognized profile as
launchable). A dsh session running a non-conforming TUI therefore still accepts
`until=stop` and will time out on it. `idle`/`exit` are the reliable pair there.
This is an interface implementation, not an impersonation — nothing on your
machine executes a real `herdr` binary. If you use a terminal profile that does
*not* implement the contract, the shim is simply never called and the mode falls
back to output-stabilization readiness like its siblings. Turn it off per session
with `deepSeekConfig.statusReporting: false`.
## 4. Starting a session
From the UI, pick **DeepSeek** in the Run dropdown (or the **Run DeepSeek**
welcome button) and press Run. Over the API:
```bash
curl -sX POST localhost:3000/api/quick-start \
-H 'Content-Type: application/json' \
-d '{
"caseName": "myproject",
"mode": "deepseek",
"deepSeekConfig": {
"profile": "dsh-tui",
"permissionMode": "danger-full-access"
}
}'
```
`deepSeekConfig` fields: `profile`, `permissionMode`, `resumeSession`,
`resumeSessionId`, `statusReporting`. Resume prefers an explicit id over the
most-recent form, and both are passed through to the profile's app, which is
where `--resume` is understood.
**Models are not a session field.** The model is a composition entry in the
profile's config tree (`agent-default-model`), not a CLI flag, so Codeman does
not try to set one. Configure it where the harness does: `~/.dsh/settings.yaml`
plus a home-level `~/.dsh/cordis.patch.yml`, or a `--patch` overlay on the
profile. That is also how you point dsh at a local or third-party provider.
**Environment.** `DSH_*` and `DEEPSEEK_*` are allowlisted for `envOverrides`
(so `DSH_HOME`, `DSH_PERMISSION_MODE`, `DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`
all flow through). Provider keys with *other* names are deliberately not: a dsh
`settings.yaml` can nominate any env var as a credential via `apiKeyEnv`, and
Codeman's allowlist is global, so admitting them would widen it for every mode at
once. Authenticate those the way dsh does, from the file or the server's own
environment.
## 5. Reading a session back, and driving one as a worker
dsh writes a real transcript — `$DSH_HOME/sessions/<mangled-cwd>/<id>/session.jsonl.zstd`
— so `GET /api/sessions/:id/last-response` reads that rather than segmenting the
pane, and the Response Viewer shows a dsh conversation the way it shows a claude
or codex one (`?context=full` returns prompt / response / tool blocks).
Reading the pane instead is not merely coarse for this mode, it is wrong: dsh-TUI
paints a full-screen splash, so the segmenter answered a `last-response` call for
a fresh dsh session with its ASCII-art logo — which anything polling for a
worker's first answer reads as an answer. Three things about the file shaped the
reader (`src/deepseek-transcript.ts`):
- **It is one zstd FRAME per append, not one zstd stream.** `zstd -dc` decodes all
of them, Node's `zlib` zstd decoder stops at the first: a real 56-line
transcript came back as 1 line. The reader walks frame headers itself. On a Node
older than 22.15 (no zstd at all) the mode falls back to the pane, as before.
- **Not every `user/message` is the user.** Each turn also records a
plugin-sourced runtime-context snapshot; only `source.kind === 'user'` is a
prompt.
- **A failed turn is not an empty one.** `turn/end` carries the provider's error,
which is returned as `Turn error: …` (and an early stop such as `max-tokens` as
`Turn ended: …`) instead of an empty string that reads as "still thinking".
The transcript reader applies to **local** dsh sessions only. A Docker case's
harness writes its transcript inside the container's own `~/.dsh` (the workspace
bind mount does not cover it), and a remote-SSH case's lives on the remote host,
so the local reader could never find those files — such sessions keep the pane
segmenter, coarse but real. The splash caveat above applies to them accordingly.
### As an agent worker
Because dsh has both halves — a real end-of-turn signal and a real transcript — an
agent can drive a dsh session the same way it drives a claude one, and the bundled
`codeman` agent skill does. Spawning `beta:deepseek` in its worker list gives a
worker that is tasked, waited on and read with the same calls as its claude
siblings; no other external CLI mode qualifies. Two edges are worth repeating here:
- **Readiness is not the stop signal.** The harness reports `idle` at boot roughly
300 ms *before* the composer paints (measured 2.26 s vs 2.56 s after spawn), so a
send-and-wait fired immediately after create resolves on that boot report,
reports a turn that never ran, and leaves the prompt in a pane that was not yet
accepting input. Wait for the composer (`❯`) instead.
- **Wait on `stop`, not on the default signal set.** That set also carries `idle`,
which for every external CLI is inferred from output stabilization; a dsh TUI
that repaints rarely reads as idle mid-turn.
## 6. The web UI as a tab
The browser UI is the one interactive surface DeepSeek ships itself, so it gets a
shortcut rather than a run mode: **Run ▸ DeepSeek web UI…** starts
`dsh web --no-open --host 127.0.0.1 --port <free> --trusted-host <codeman-host>`
as a background child process (`src/deepseek-web-server.ts`, behind
`POST/GET/DELETE /api/deepseek/web`) and opens it as a Codeman web tab once the
server actually answers.
It is a child process rather than a shell session because the session version
opened a terminal tab nobody asked for on every click. What the session gave for
free is therefore explicit here: one instance with reuse, a restart when the
requested `--trusted-host` authority differs from the running one, a kill on
server stop, and captured boot output. The `--trusted-host` flag is load-bearing —
dsh fences its `/api` behind a browser-trust check on the request authority, and a
Codeman web tab reaches it through Codeman's own origin via the webview proxy, not
directly. Without it the page renders and every API call fails.
## 7. Docker and remote cases
Docker cases work: the agent image installs `dsh` and bootstraps a `dsh-tui`
profile into the container. Profiles are deliberately **not** seeded from the
host (each is a per-profile `node_modules` tree, host-arch-specific and far too
large to copy on every container start); only `~/.dsh/.env`, `settings.yaml` and
`cordis.patch.yml` are seeded, which is what carries auth and model composition
in. As with pi and grok, in-container sessions are invisible host-side:
`~/.dsh/sessions` inside a container is that container's own.
Remote SSH cases default to `dsh` through a login shell, which boots the remote
box's default profile. If the remote has several, name one with the per-host
`commands.deepseek` override — the local `deepSeekConfig` does not cross ssh.
## 8. What is not wired
Deliberately minimal, on the same reasoning as the grok integration: the harness
is a fast-moving developer preview and every flag added is a flag validated
forever.
- `--patch` overlays per session (the profile's own layers apply as normal).
- `dsh plugin` management beyond first-time profile install.
- The `headless` profile as a one-shot execution backend for Codeman's own
internal AI checks (today those are Claude-only).
- Model/provider selection from Session Options.
## Verified against
`dsh 0.1.1-rc.2` and `@deepseek-harness-tui/dsh-tui 0.9.0`. The permission
presets, the profile layout, and the supervisor contract above were all read off
the live install rather than from documentation.
+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"
+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.
+278 -7
View File
@@ -125,6 +125,14 @@ PI_SEARCH_PATHS=(
"$HOME/bin/pi"
)
# DeepSeek Harness search paths (from src/utils/deepseek-cli-resolver.ts)
DSH_SEARCH_PATHS=(
"$HOME/.local/bin/dsh"
"/usr/local/bin/dsh"
"$HOME/.npm-global/bin/dsh"
"$HOME/bin/dsh"
)
# Grok CLI search paths (from src/utils/grok-cli-resolver.ts)
GROK_SEARCH_PATHS=(
"$HOME/.grok/bin/grok"
@@ -141,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
# ============================================================================
@@ -237,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}"
@@ -404,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
@@ -594,6 +638,57 @@ check_grok() {
return 1
}
# `dsh` is the hardest name of the lot: Debian ships an unrelated `dsh`
# (dancer's shell). The server-side resolver settles it by demanding the
# harness's own help banner; detection here only feeds the "you have no AI CLI"
# hint, so the same banner grep is enough — but unlike every sibling probe it
# EXECUTES the candidate, so it must be bounded. </dev/null is load-bearing
# twice over: a foreign binary that blocks on stdin would hang the install, and
# under `curl | bash` a child that reads stdin EATS THE REST OF THIS SCRIPT.
# The timeout (where coreutils ships one; stock macOS has none) bounds a binary
# that ignores EOF, mirroring the server resolver's own EXEC_TIMEOUT_MS.
dsh_banner_probe() {
local runner=()
if command -v timeout &>/dev/null; then runner=(timeout 5); fi
"${runner[@]}" "$1" --help </dev/null 2>/dev/null | grep -qi "DeepSeek Harness"
}
# Resolved ONCE and memoized: the probe executes a possibly-foreign binary, and
# the check/get/reminder call sites together used to re-run the whole scan many
# times per install.
DSH_RESOLVE_DONE=""
DSH_RESOLVED_PATH=""
resolve_dsh() {
[[ -n "$DSH_RESOLVE_DONE" ]] && return 0
DSH_RESOLVE_DONE=1
local candidate path
if command -v dsh &>/dev/null; then
candidate="$(command -v dsh)"
if dsh_banner_probe "$candidate"; then
DSH_RESOLVED_PATH="$candidate"
return 0
fi
fi
for path in "${DSH_SEARCH_PATHS[@]}"; do
if [[ -x "$path" ]] && dsh_banner_probe "$path"; then
DSH_RESOLVED_PATH="$path"
return 0
fi
done
return 0
}
check_dsh() {
resolve_dsh
[[ -n "$DSH_RESOLVED_PATH" ]]
}
get_dsh_path() {
resolve_dsh
echo "$DSH_RESOLVED_PATH"
}
get_grok_path() {
if command -v grok &>/dev/null; then
command -v grok
@@ -608,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
@@ -875,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
@@ -1750,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() {
@@ -2002,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"
@@ -2115,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
@@ -2123,6 +2376,8 @@ main() {
local has_antigravity=false
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
@@ -2153,17 +2408,25 @@ main() {
has_grok=true
success "Grok CLI found at $(get_grok_path)"
fi
if check_dsh; then
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" ]]; 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, or Grok."
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=""
@@ -2277,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
@@ -2512,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; 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"
@@ -2520,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
@@ -2573,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)")"
@@ -2610,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.22.0",
"version": "1.24.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.22.0",
"version": "1.24.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.22.0",
"version": "1.24.1",
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -63,6 +63,7 @@
"antigravity",
"pi",
"grok",
"deepseek",
"gemini-cli",
"ai-agents",
"agent",
+121 -34
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.19.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.20.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.19.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
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) ----
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
@@ -121,23 +121,53 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
}
# 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 claude worker in a hook-carrying case. Anything less is rc 1 with EMPTY
# stdout, and the half-spawned session is deleted here rather than handed back, because
# a worker that never drew its composer would eat the task prompt with its trust
# dialog. There is deliberately no pid poll: wait-output already blocks until the
# composer draws, and pid!=null proved startup, never readiness.
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
# deepseek: ask for the same permission posture the Run button sends, because the
# harness's own default (`workspace-write`) still ASKS, and a worker that stops on
# an approval row is a worker no fan-out can finish. It is not an escalation --
# claude workers already spawn with permissions skipped, and in multi-user mode the
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" '{caseName:$n,mode:$m,parentSessionId:$p}')")
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
# Inbox all work here exactly as they do for claude. No hook file to vet
# (the bridge is env-injected, not a workspace file) and no trust dialog.
# ⚠️ Readiness is still not optional, and NOT interchangeable with the stop
# signal: the harness's boot report lands ~300ms BEFORE the composer paints
# (measured 2.26s vs 2.56s after spawn), so a sendwait fired straight after
# quick-start returns on that BOOT signal, reports a turn that never ran, and
# strands the prompt in a pane that was not yet taking input.
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
# The server installs hooks into every claude workspace now, so this grep normally
# passes; it stays because the install is gated on a setting the operator can turn
# off, remote sessions never get hooks, and a session created by an older server
@@ -166,19 +196,25 @@ spawn_worker() {
delete_session "$sid" >/dev/null; return 1; }
printf '%s\n' "$sid"
}
# spawn_workers <caseName>... -> one "<caseName> <sessionId>" line per worker, in order;
# the sessionId column is EMPTY for a spawn that failed (stderr has why). CONCURRENT:
# N workers cost about what one costs. Spawning them one Bash call at a time is the
# single biggest avoidable delay in this skill. Names must be UNIQUE: two workers in
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race.
# spawn_workers <caseName[:mode]>... -> one "<caseName> <sessionId>" line per worker, in
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
# workers, since they would still share the directory).
spawn_workers() {
local d n i=0
local d spec n m i=0
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
[ -z "$(printf '%s\n' "$@" | sort | uniq -d)" ] || { echo "spawn_workers: duplicate case names" >&2; return 1; }
[ -z "$(printf '%s\n' "$@" | sed 's/:.*//' | sort | uniq -d)" ] || { echo "spawn_workers: duplicate case names" >&2; return 1; }
d=$(mktemp -d "${TMPDIR:-/tmp}/codeman-spawn.XXXXXX") || return 1
for n in "$@"; do ( spawn_worker "$n" > "$d/$i" ) & i=$((i+1)); done
for spec in "$@"; do
n=${spec%%:*}; m=${spec#*:}; [ "$m" = "$spec" ] && m=claude
( spawn_worker "$n" "$m" > "$d/$i" ) & i=$((i+1))
done
wait
i=0; for n in "$@"; do printf '%s %s\n' "$n" "$(cat "$d/$i" 2>/dev/null)"; i=$((i+1)); done
i=0; for spec in "$@"; do printf '%s %s\n' "${spec%%:*}" "$(cat "$d/$i" 2>/dev/null)"; i=$((i+1)); done
rm -rf "$d"
}
# sendwait <sid> <prompt> [seq] -> blocks until that worker's turn ENDS (~10 min ceiling
@@ -194,27 +230,46 @@ spawn_workers() {
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy only for a claude
# worker spawn_worker handed back (hooks vetted); hook-less workspaces and other modes
# resolve on flapping idle: markers instead (§5.5).
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
# and for those only. Hook-less workspaces and the other modes resolve on flapping
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
# implement the status contract is the one case that LOOKS like claude but is not:
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
# pane clearly finished means that profile, so switch that worker to markers.
sendwait() {
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
# is still answering, and the re-wait below then resolved in 0 ms with
# `signal:"idle"` on a turn that had another three minutes to run (measured).
# A wait named after the end of a turn should only end with the turn, or with
# the worker. ⚠️ This is also what makes a wrong mode LOUD: the modes that
# cannot deliver `stop` answer 400 (before writing anything) instead of
# resolving on a flap, which is the answer that sends you to markers (§5.5).
body=$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$seq" \
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:true,waitTimeout:20000}')
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"stop,exit",waitTimeout:20000}')
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
-H 'Content-Type: application/json' --data-binary "$body")
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
# The resend is a tagged DUPLICATE, so the server skips the write and reports
# `delivered:false` for it -- truthfully, but about the wrong send. The first
# one delivered, so carry that forward, or §1's cleanup reads a completed turn
# as an undelivered one and keeps a finished worker forever.
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")")
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
fi
printf '%s\n' "$r"
}
# last_text <sid> [prev] -> that worker's last assistant message. Polled, because the
# transcript write LAGS the stop signal, and "some text exists" is not "THIS turn's
# text exists": right after a SECOND turn on the same worker the endpoint still serves
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
# deepseek write a real transcript; the other modes have none, so read the terminal
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
# "some text exists" is not "THIS turn's text exists": right after a SECOND turn on the same worker the endpoint still serves
# the previous answer for a beat (observed live). When reading consecutive turns, pass
# the previous answer as [prev]: the poll then holds out for text that differs from it,
# falling back to whatever it last saw if the budget runs dry, so an honestly repeated
@@ -233,10 +288,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.19.0
CODEMAN_PREAMBLE=1.20.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.19.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.20.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
@@ -287,8 +342,9 @@ 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.19.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.20.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'
'reply with one line: your model name') # tasks, same order as N
@@ -353,6 +409,37 @@ Four things this block leans on, each one link away, no detour needed to run it:
- Each `sendwait` costs that worker one billed turn, as does every prompt you send it.
- Deleting the sessions does **not** remove the case directories: §5.14.
### DeepSeek Harness workers
The block above spawns claude workers. Any entry in `N` may instead name a mode
(`beta:deepseek`), and **a `deepseek` worker is driven by the same four verbs, with no
change to the rest of the block**: `spawn_workers` waits for its composer, `sendwait`
blocks on its real end-of-turn signal, `last_text` reads its answer, `delete_session`
removes it.
That is true of no other non-claude mode, and it is worth knowing why: the DeepSeek
Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor contract it
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`, `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:
- **It needs a pane-capable profile.** `dsh` ships only `web`/`headless`, so the terminal
agent is always an installed profile. `GET /api/v1/deepseek/status` answers both
questions separately (`available` = the binary, `runnable` = a profile that can drive a
pane); a spawn without one fails with `OPERATION_FAILED` rather than falling back.
- **Do not task it on the strength of a `stop` alone.** The harness reports `idle` at
boot ~300 ms *before* its composer paints (measured 2.26 s vs 2.56 s), so a `sendwait`
fired straight after `quick-start` resolves on that boot signal, reports a turn that
never ran, and leaves the prompt in a pane that was not yet taking input. Letting
`spawn_worker` gate on readiness is what steps past that edge; it is not optional.
- **A profile that does not implement the contract looks like a hang.** Codeman cannot
know at spawn time whether one does. The tell is a `sendwait` that times out on a
worker whose pane clearly finished: that profile is one of them, so drive it with
markers instead.
## 2. What do you want to do?
One row per job. Acting on this table alone is correct; the §5 links are the detail.
@@ -360,10 +447,10 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| I want to | Call | Detail |
|-----------|------|--------|
| start a worker **where the work is** | `POST /api/v1/quick-start {"caseName":…}`, which **creates** `~/codeman-cases/<name>` unless the name is already a case. Any other path (a git worktree): `POST /api/v1/sessions {"workingDir":…}` then `POST /api/v1/sessions/:id/interactive`. Both install hooks by default, so expect full signals in either, and **verify** rather than assume. N workers means N worktrees | [§5.1](reference/verbs.md#51-where-to-spawn) |
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`) | [§5.2](reference/verbs.md#52-readiness) |
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy only where the workspace **has hooks** (claude mode; installed by default, but the operator can disable it and remote sessions never get them). Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
| know a new worker can accept a prompt | `GET .../wait-output?match=shift+tab&from=buffer` (urlencode the `+`); a `deepseek` worker draws `❯` instead, and its boot `stop` fires ~300 ms BEFORE that, so never read the signal as readiness | [§5.2](reference/verbs.md#52-readiness) |
| deliver a task **and** know when it finished | `POST .../input` with `"input":"…\r"`, `clientId`, `seq`, `"wait":true`. Resolves on `stop`, so it is trustworthy where the signal is real: claude mode with hooks (installed by default, but the operator can disable it and remote sessions never get them) and `deepseek` mode through its status bridge. Costs the worker one billed turn | [§5.3](reference/verbs.md#53-send-a-task-and-wait) |
| know a hook-less worker finished | it has no `stop`, and `wait:true` there resolves on flapping `idle` **without erroring**: make it print a split, unique marker and `wait-output` on that instead | [§5.5](reference/verbs.md#55-markers-for-hook-less-workers) |
| read the answer | `GET .../last-response`, **polled** (claude/codex only; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
| read the answer | `GET .../last-response`, **polled** (claude, codex and deepseek write a transcript; empty for the other modes) | [§5.4](reference/verbs.md#54-read-the-answer) |
| know if it is alive | `GET .../wait?until=exit&timeout=1000`: an immediate `signal:"exit"` means dead. `status` and `pid` both lie | [§5.6](reference/verbs.md#56-alive-and-stuck) |
| know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) |
| make a runaway worker stop | `POST .../input {"input":"\u001b"}` (ESC, **no** `\r`). Deleting the session would destroy the conversation instead | [§5.7](reference/verbs.md#57-interrupt-without-destroying) |
@@ -464,7 +551,7 @@ these**; open the one row you actually hit.
| [5.1 Where to spawn](reference/verbs.md#51-where-to-spawn) | the work is **not** a fresh scratch case: a linked case, a git worktree, any path that already existed. Hooks are absent there, which silently breaks send-and-wait. The costliest mistake in this skill |
| [5.2 Readiness](reference/verbs.md#52-readiness) | a worker never drew its composer, or you need the trust-dialog ladder by hand |
| [5.3 Send a task and wait](reference/verbs.md#53-send-a-task-and-wait) | the `sendwait` body, its signals, and the duplicate-resend loop |
| [5.4 Read the answer](reference/verbs.md#54-read-the-answer) | `last_text` came back empty, or the mode is not claude/codex |
| [5.4 Read the answer](reference/verbs.md#54-read-the-answer) | `last_text` came back empty, or the mode is not claude/codex/deepseek |
| [5.5 Markers for hook-less workers](reference/verbs.md#55-markers-for-hook-less-workers) | the worker has no `stop` hook: synchronize on a split, unique printed marker |
| [5.6 Alive and stuck](reference/verbs.md#56-alive-and-stuck) | is it dead or just slow? `status` and `pid` both lie |
| [5.7 Interrupt without destroying](reference/verbs.md#57-interrupt-without-destroying) | a runaway worker you want to stop but keep |
+81 -26
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.19.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.20.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
@@ -43,23 +43,53 @@ _composer_up() { # <sid> <timeoutMs> -> "true"/"false". `shift+tab` is the one
--data-urlencode 'match=shift+tab' --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
}
_dsh_up() { # <sid> <timeoutMs> -> "true"/"false". The DeepSeek Harness TUI's
# composer glyph. Override with DSH_READY_MARK for a profile that draws another one.
"${CURL[@]}" -G "$API/api/v1/sessions/$1/wait-output" \
--data-urlencode "match=${DSH_READY_MARK:-❯}" --data-urlencode 'from=buffer' \
--data-urlencode "timeout=$2" | jq -r '.data.wait.matched // false'
}
# 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 claude worker in a hook-carrying case. Anything less is rc 1 with EMPTY
# stdout, and the half-spawned session is deleted here rather than handed back, because
# a worker that never drew its composer would eat the task prompt with its trust
# dialog. There is deliberately no pid poll: wait-output already blocks until the
# composer draws, and pid!=null proved startup, never readiness.
# a READY worker whose end-of-turn signal can be trusted -- a claude worker in a
# hook-carrying case, or a `deepseek` worker whose harness TUI drew its composer.
# Anything less is rc 1 with EMPTY stdout, and the half-spawned session is deleted here
# rather than handed back, because a worker that never drew its composer would eat the
# task prompt with its trust dialog. There is deliberately no pid poll: wait-output
# already blocks until the composer draws, and pid!=null proved startup, never readiness.
spawn_worker() {
local name="${1:?spawn_worker needs a case name}" mode="${2:-claude}" q sid cp r
# parentSessionId doubles the CURL header, so a spawn_worker copied off the shared
# curl (or a body someone rebuilt from this recipe) still carries its lineage.
# deepseek: ask for the same permission posture the Run button sends, because the
# harness's own default (`workspace-write`) still ASKS, and a worker that stops on
# an approval row is a worker no fan-out can finish. It is not an escalation --
# claude workers already spawn with permissions skipped, and in multi-user mode the
# server clamps this back to `workspace-write` for an owner without the grant.
# Spawn by hand (§5.1) when you want a worker that asks.
q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" '{caseName:$n,mode:$m,parentSessionId:$p}')")
-d "$(jq -nc --arg n "$name" --arg m "$mode" --arg p "$SELF" \
'{caseName:$n,mode:$m,parentSessionId:$p}
+ (if $m == "deepseek" then {deepSeekConfig:{permissionMode:"danger-full-access"}} else {} end)')")
sid=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$q")
# NOT retryable in a loop: every quick-start failure code is terminal (§5.1).
[ -n "$sid" ] || { jq -c '{error,errorCode}' <<<"$q" >&2; return 1; }
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # only claude draws a composer
if [ "$mode" = deepseek ]; then
# The one non-claude mode with REAL end-of-turn signals: its TUI reports
# idle/working/blocked to Codeman, so sendwait, until=stop and the Approvals
# Inbox all work here exactly as they do for claude. No hook file to vet
# (the bridge is env-injected, not a workspace file) and no trust dialog.
# ⚠️ Readiness is still not optional, and NOT interchangeable with the stop
# signal: the harness's boot report lands ~300ms BEFORE the composer paints
# (measured 2.26s vs 2.56s after spawn), so a sendwait fired straight after
# quick-start returns on that BOOT signal, reports a turn that never ran, and
# strands the prompt in a pane that was not yet taking input.
r=$(_dsh_up "$sid" 45000)
[ "$r" = true ] || { echo "dsh worker $sid never drew a composer: no pane-capable profile, a profile whose composer is not '${DSH_READY_MARK:-❯}' (set DSH_READY_MARK), or a harness that failed to boot -- check GET /api/v1/deepseek/status. Deleted it" >&2
delete_session "$sid" >/dev/null; return 1; }
printf '%s\n' "$sid"; return 0
fi
[ "$mode" = claude ] || { printf '%s\n' "$sid"; return 0; } # no other mode draws a composer to wait on
# The server installs hooks into every claude workspace now, so this grep normally
# passes; it stays because the install is gated on a setting the operator can turn
# off, remote sessions never get hooks, and a session created by an older server
@@ -88,19 +118,25 @@ spawn_worker() {
delete_session "$sid" >/dev/null; return 1; }
printf '%s\n' "$sid"
}
# spawn_workers <caseName>... -> one "<caseName> <sessionId>" line per worker, in order;
# the sessionId column is EMPTY for a spawn that failed (stderr has why). CONCURRENT:
# N workers cost about what one costs. Spawning them one Bash call at a time is the
# single biggest avoidable delay in this skill. Names must be UNIQUE: two workers in
# one case directory co-edit the same tree (§4), so a repeat is an error here, not a race.
# spawn_workers <caseName[:mode]>... -> one "<caseName> <sessionId>" line per worker, in
# order; the sessionId column is EMPTY for a spawn that failed (stderr has why).
# CONCURRENT: N workers cost about what one costs. Spawning them one Bash call at a time
# is the single biggest avoidable delay in this skill. A bare name is a claude worker;
# `beta:deepseek` makes that one a DeepSeek Harness worker, and a mixed fleet is one
# call. Case names must be UNIQUE: two workers in one case directory co-edit the same
# tree (§4), so a repeat is an error here, not a race (the mode never disambiguates two
# workers, since they would still share the directory).
spawn_workers() {
local d n i=0
local d spec n m i=0
[ "$#" -gt 0 ] || { echo "spawn_workers: no case names given" >&2; return 1; }
[ -z "$(printf '%s\n' "$@" | sort | uniq -d)" ] || { echo "spawn_workers: duplicate case names" >&2; return 1; }
[ -z "$(printf '%s\n' "$@" | sed 's/:.*//' | sort | uniq -d)" ] || { echo "spawn_workers: duplicate case names" >&2; return 1; }
d=$(mktemp -d "${TMPDIR:-/tmp}/codeman-spawn.XXXXXX") || return 1
for n in "$@"; do ( spawn_worker "$n" > "$d/$i" ) & i=$((i+1)); done
for spec in "$@"; do
n=${spec%%:*}; m=${spec#*:}; [ "$m" = "$spec" ] && m=claude
( spawn_worker "$n" "$m" > "$d/$i" ) & i=$((i+1))
done
wait
i=0; for n in "$@"; do printf '%s %s\n' "$n" "$(cat "$d/$i" 2>/dev/null)"; i=$((i+1)); done
i=0; for spec in "$@"; do printf '%s %s\n' "${spec%%:*}" "$(cat "$d/$i" 2>/dev/null)"; i=$((i+1)); done
rm -rf "$d"
}
# sendwait <sid> <prompt> [seq] -> blocks until that worker's turn ENDS (~10 min ceiling
@@ -116,27 +152,46 @@ spawn_workers() {
# (observed live). So the first wait is short; on its timeout a bare \r goes out (the
# missing Enter when the prompt is stranded, a no-op when the turn is genuinely
# running), then the ORIGINAL frame is resent unchanged, which the server takes as a
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy only for a claude
# worker spawn_worker handed back (hooks vetted); hook-less workspaces and other modes
# resolve on flapping idle: markers instead (§5.5).
# tagged duplicate: it re-waits without retyping (§5.3). Trustworthy for a worker
# spawn_worker handed back -- claude (hooks vetted) or deepseek (status bridge) --
# and for those only. Hook-less workspaces and the other modes resolve on flapping
# idle: markers instead (§5.5). ⚠️ A dsh worker running a profile that does not
# implement the status contract is the one case that LOOKS like claude but is not:
# it accepts the send and then burns both waits. One timeout on a dsh worker whose
# pane clearly finished means that profile, so switch that worker to markers.
sendwait() {
local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r
# `wait:"stop,exit"`, never the `wait:true` default set: that set also carries
# `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a
# dsh worker whose TUI repaints rarely the session reads `idle` while the model
# is still answering, and the re-wait below then resolved in 0 ms with
# `signal:"idle"` on a turn that had another three minutes to run (measured).
# A wait named after the end of a turn should only end with the turn, or with
# the worker. ⚠️ This is also what makes a wrong mode LOUD: the modes that
# cannot deliver `stop` answer 400 (before writing anything) instead of
# resolving on a flap, which is the answer that sends you to markers (§5.5).
body=$(jq -nc --arg p "$p" --arg c "$CID-$sid" --argjson s "$seq" \
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:true,waitTimeout:20000}')
'{input:($p+"\r"),useMux:true,clientId:$c,seq:$s,wait:"stop,exit",waitTimeout:20000}')
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
-H 'Content-Type: application/json' --data-binary "$body")
if jq -e '.data.delivered and .data.wait.timedOut' <<<"$r" >/dev/null 2>&1; then
"${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg c "$CID-$sid" --argjson s "$(date +%s)" \
'{input:"\r",useMux:true,clientId:$c,seq:$s}')" >/dev/null
# The resend is a tagged DUPLICATE, so the server skips the write and reports
# `delivered:false` for it -- truthfully, but about the wrong send. The first
# one delivered, so carry that forward, or §1's cleanup reads a completed turn
# as an undelivered one and keeps a finished worker forever.
r=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$sid/input" \
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")")
-H 'Content-Type: application/json' --data-binary "$(jq -c '.waitTimeout=580000' <<<"$body")" \
| jq -c 'if .success and (.data.wait.ended | not) then .data.delivered = true else . end')
fi
printf '%s\n' "$r"
}
# last_text <sid> [prev] -> that worker's last assistant message. Polled, because the
# transcript write LAGS the stop signal, and "some text exists" is not "THIS turn's
# text exists": right after a SECOND turn on the same worker the endpoint still serves
# last_text <sid> [prev] -> that worker's last assistant message (claude, codex and
# deepseek write a real transcript; the other modes have none, so read the terminal
# instead -- §5.4). Polled, because the transcript write LAGS the stop signal, and
# "some text exists" is not "THIS turn's text exists": right after a SECOND turn on the same worker the endpoint still serves
# the previous answer for a beat (observed live). When reading consecutive turns, pass
# the previous answer as [prev]: the poll then holds out for text that differs from it,
# falling back to whatever it last saw if the budget runs dry, so an honestly repeated
@@ -155,4 +210,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.19.0
CODEMAN_PREAMBLE=1.20.0
+26 -18
View File
@@ -237,7 +237,10 @@ 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 Claude transcript.
`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.
**Fix** Poll it, bounded (10 tries, 1 s apart). If it is still empty on a hook-less mode,
that is expected, not a failure: read `terminal?tail=` and strip ANSI instead.
@@ -279,7 +282,7 @@ than into an existing checkout.
| start case + session in one call | `POST /api/v1/quick-start` |
| create a session in an arbitrary directory (no case, **no PTY**, id at `.data.session.id`) | `POST /api/v1/sessions`, then `POST /api/v1/sessions/:id/interactive` or `.../shell` to start it, see [Starting a worker](#starting-a-worker) |
| send input | `POST /api/v1/sessions/:id/input` |
| **read a worker's answer** (claude/codex) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}`, clean transcript text, no TUI noise. ⚠️ **Poll it**, see [symptom 7](#7-last-response-returns-an-empty-string-right-after-stop) |
| **read a worker's answer** (claude/codex/deepseek) | `GET /api/v1/sessions/:id/last-response` → `.data.{text,timestamp}`, clean transcript text, no TUI noise. ⚠️ **Poll it**, see [symptom 7](#7-last-response-returns-an-empty-string-right-after-stop) |
| read terminal (tail is in **BYTES**, raw ANSI) | `GET /api/v1/sessions/:id/terminal?tail=3000` → `.data.terminalBuffer`, for *diagnosis* (unsubmitted prompt?), not for reading answers |
| full tmux scrollback (context bomb; post-mortems only) | `GET /api/v1/sessions/:id/terminal?full=1` |
| background agents, one session | `GET /api/v1/sessions/:id/subagents` |
@@ -321,7 +324,7 @@ on signals and markers for exactly this reason.
⚠️ `GET /api/v1/sessions/:id/output` → `.data.textOutput` looks like the obvious read
but stays **empty for interactive tmux-backed sessions** (it is fed only by the legacy
JSON-stream path). Verified empty on live claude and shell sessions. Use
`last-response` for claude/codex answers; only fall back to `terminal?tail=` for
`last-response` for claude/codex/deepseek answers; only fall back to `terminal?tail=` for
hook-less modes, or to diagnose a prompt that was never submitted, and strip ANSI:
```bash
@@ -336,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`; 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`
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
@@ -463,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`. ⚠️ **`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
@@ -640,10 +643,14 @@ block, so a linked case or a raw `workingDir` had no hooks at all. `POST
session-create path installs hooks regardless of how the directory got there. See
[symptom 8](#8-send-and-wait-resolves-instantly-with-signalidle-and-the-answer-is-last-turns).
Default `until` set: `stop,idle,exit`. On non-claude modes the server silently drops
`stop`/`blocked` from the *default* set (echoed back as `wait.until`, e.g.
Default `until` set: `stop,idle,exit`. On modes with no hook signals the server silently
drops `stop`/`blocked` from the *default* set (echoed back as `wait.until`, e.g.
`["idle","exit"]` on shell); requesting them *explicitly* there is a 400 naming the
mode. ⚠️ That 400 is about **mode**, so a hooks-less *claude* session accepts
mode. ⚠️ `deepseek` is not one of those: its harness reports its own lifecycle, so it
keeps the full default set and accepts an explicit `until=stop`. ⚠️ For dsh the answer is
per-SESSION rather than per-mode — a session created with `statusReporting: false` has no
bridge, and an explicit `until=stop` there is a 400 naming that setting. ⚠️ That 400 is
otherwise about **mode**, so a hooks-less *claude* session accepts
`until=stop` happily and then never resolves it. ⚠️ On hook-less modes the lifecycle
signals are also **coarse in practice**: a
short shell command produced **no** `idle` transition within 60 s (verified live), so
@@ -791,7 +798,8 @@ for environment and setup problems.
| `CODEMAN_MUX` unset but you seem to be in a session | remote-SSH case: the env vars are not exported there. Fail closed, refuse to act |
| connection refused from inside a container | a loopback-bound server is unreachable from a container, and `CODEMAN_DOCKER_BRIDGE_HOOKS=1` does **not** fix that: it opens a hooks-only listener, so hook events start flowing but `/api/v1/*` stays refused. Driving the API from inside a Docker case needs a reachable bind (an operator decision); report it, don't retry |
| 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 | non-claude mode, 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 | 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 |
| 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 |
+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`) | 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`) 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
+54 -1
View File
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
done
printf '%s\n' "$TXT"
# (.data is {text,timestamp}; text is also "" before the first completed turn and
# always "" for shell/opencode/gemini/antigravity/pi/grok, which have no transcript, use
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
@@ -198,6 +198,59 @@ delete_session "$SID"
Increment `SEQ` for every *new* input to the same worker. Reuse the same `SEQ` only to
re-ask about the same delivery (the duplicate-wait loop above).
## Flow 1b: DeepSeek Harness worker, end to end
A `deepseek` worker is driven with the same four verbs as a claude one, because the
harness reports its own lifecycle: its `stop` is a real end-of-turn signal, and its
answer comes from a real transcript. The differences are all at the edges.
```bash
# 0. Is there anything to spawn? `available` is the binary, `runnable` is a profile
# that can drive a pane -- dsh ships only web/headless, so the two differ.
"${CURL[@]}" "$API/api/v1/deepseek/status" | jq -c '{available:.data.available,runnable:.data.runnable,profile:.data.defaultProfile}'
# 1. Spawn. `deepSeekConfig` is optional: an absent profile picks the first
# pane-capable one, and an absent permissionMode leaves the harness on its own
# workspace-write default, which still ASKS before it acts.
Q=$("${CURL[@]}" -X POST "$API/api/v1/quick-start" -H 'Content-Type: application/json' \
-d '{"caseName":"dsh-worker","mode":"deepseek","deepSeekConfig":{"permissionMode":"danger-full-access"}}')
SID=$(jq -r 'if .success then .data.sessionId else empty end' <<<"$Q")
[ -n "$SID" ] || { jq -c '{error, errorCode}' <<<"$Q"; exit 1; } # OPERATION_FAILED = no runnable profile
CREATED+=("$SID")
# 2. Readiness, and ONLY readiness. ⚠️ Do not use the stop signal for this: the
# harness reports idle at BOOT, ~300 ms before the composer paints.
"${CURL[@]}" -G "$API/api/v1/sessions/$SID/wait-output" \
--data-urlencode 'match=❯' --data-urlencode 'from=buffer' --data-urlencode 'timeout=45000' \
| jq -e '.data.wait.matched' >/dev/null || { echo "no composer"; delete_session "$SID"; exit 1; }
# 3. Task it. Identical to a claude worker, including the \r and the (clientId, seq).
"${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" -H 'Content-Type: application/json' \
-d '{"input":"Read calc.py and tell me in one sentence whether add() is correct.\r","useMux":true,"clientId":"codeman-dsh-1","seq":1,"wait":"stop,exit","waitTimeout":300000}' \
| jq -c '{delivered:.data.delivered,signal:.data.wait.signal,timedOut:.data.wait.timedOut}'
# 4. Read it. From $DSH_HOME/sessions/**, not the pane -- scraping a dsh pane returns
# its ASCII-art splash. Poll: the harness finalizes the message just after it
# reports idle. Two answers are not the model's words and say so:
# "Turn error: …" (the provider or harness failed) and "Turn ended: …" (early stop).
for _ in $(seq 1 15); do
TXT=$("${CURL[@]}" "$API/api/v1/sessions/$SID/last-response" | jq -r '.data.text')
[ -n "$TXT" ] && break; sleep 1
done
printf '%s\n' "$TXT"
# 5. Full conversation, if you need the tool calls too:
# "${CURL[@]}" "$API/api/v1/sessions/$SID/last-response?context=full" | jq -r '.data.messages[]|"[\(.label)] \(.text)"'
delete_session "$SID"
```
⚠️ **`wait:"stop,exit"`, not `wait:true`.** The default set also carries `idle`, which
for an external CLI is inferred from output stabilization: a dsh TUI that repaints
rarely reads as idle mid-turn, and a wait carrying `idle` then resolves in 0 ms on a
turn with minutes left to run (measured). The same reason the preamble's `sendwait`
asks for `stop,exit` on every mode.
## Flow 2: shell worker, marker-synchronized
`shell` sessions have no hooks (`stop`/`blocked` are a 400 there), and their lifecycle
+41 -10
View File
@@ -152,7 +152,19 @@ It is **decoration, and resolved rather than trusted**, so treat it accordingly:
### 5.2 Readiness
A new session reports `idle` before its CLI has spawned, and a brand-new case shows a
**dsh workers first**, because their trap is the opposite of claude's: they have no
trust dialog and boot straight into a composer (`❯`, matched `from=buffer`), but the
harness reports `idle` — which reaches you as a `stop` signal — about 300 ms BEFORE that
composer paints (measured 2.26 s vs 2.56 s after spawn, twice). So the signal that means
"this worker finished its turn" is also the first thing it emits at boot, and a
send-and-wait fired straight after `quick-start` resolves on it, reports a turn that
never ran, and leaves the prompt in a pane that was not yet taking input. Wait for the
composer, not for the signal; `spawn_worker` does exactly that, and by the time it
returns the boot edge is spent (signals are edge-triggered, so nothing can catch it
later). A profile whose composer is not `❯` needs `DSH_READY_MARK` set to whatever it
does draw.
For claude: a new session reports `idle` before its CLI has spawned, and a brand-new case shows a
**trust dialog** first, so neither "wait for idle" nor "wait for ❯" means ready (the
trust dialog contains `❯` too, observed live). Codeman auto-accepts that dialog
itself, reliably enough that stage 1 usually just works: `_maybeAcceptTrustDialog()`
@@ -341,17 +353,35 @@ If the loop exhausts its cap, do not keep looping: read the terminal, report wha
see, and remember that a still-typed-but-unsubmitted prompt (missing `\r`) can only be
recovered by submitting it with `{"input":"\r"}`.
⚠️ `stop` and `blocked` fire for `claude` sessions only (they are Claude Code hooks,
and only when the workspace actually has them, see [§5.1](#51-where-to-spawn)). On
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`, requesting them explicitly is a
⚠️ `stop` and `blocked` fire for `claude` sessions (they are Claude Code hooks, and
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`/`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.
⚠️ A dsh session can still refuse them for a per-SESSION reason: `statusReporting:
false` at create time disarms the bridge, and an explicit `until=stop` is then a 400
naming that setting. And a `stop` that is *accepted* is not proof it will ever fire —
whether the installed profile implements the supervisor contract cannot be known at
request time, so a non-conforming one accepts the wait and times out on it. One timeout
on a dsh worker whose pane clearly finished identifies that profile; switch it to
markers.
### 5.4 Read the answer
For `claude` and `codex` workers this is the read path: `last-response` returns the
agent's final message as clean text, taken from the transcript rather than the screen,
so it carries none of the TUI's box-drawing or repaint noise.
For `claude`, `codex` and `deepseek` workers this is the read path: `last-response`
returns the agent's final message as clean text, taken from the transcript rather than
the screen, so it carries none of the TUI's box-drawing or repaint noise.
⚠️ For `deepseek` it reads `$DSH_HOME/sessions/**`, and reading it is the ONLY way to
get that answer: dsh-TUI paints a full-screen splash, so scraping its pane returns the
ASCII-art logo (that is what `last-response` itself used to return for dsh). Two dsh
answers are not the model's words and say so: `Turn error: …` (the provider or the
harness failed the turn) and `Turn ended: …` (an early stop such as `max-tokens`). A
turn still streaming reads back as the partial answer so far, so a non-empty read is
not by itself proof the turn ended — that is what the `stop` signal is for.
```bash
for _ in $(seq 1 10); do # the transcript write LAGS the stop signal
@@ -369,9 +399,10 @@ 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. Fall back to the terminal buffer
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
there, tail in **bytes** (`textOutput` in `GET .../output` stays empty for interactive
sessions; don't use it):
@@ -454,7 +485,7 @@ turn), and both better than diffing terminal samples:
```
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`** (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
+52
View File
@@ -9,6 +9,8 @@
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';
@@ -163,6 +165,56 @@ export const DEPENDENCY_REGISTRY: ToolDependency[] = [
},
],
},
{
id: 'dsh',
label: 'DeepSeek Harness CLI',
category: 'core',
required: false,
usedBy: ['DeepSeek sessions'],
// Version match required, and for a sharper reason than pi or grok: `dsh` is
// not merely a squattable npm name, it is an existing Debian program
// (dancer's shell, `apt install dsh`). The run mode's resolver additionally
// demands the harness's own help banner before it will point a spawn line at
// a candidate; the doctor is advisory and settles for the shared
// DEEPSEEK_VERSION_REGEX, so the two cannot disagree about the VERSION even
// though the resolver is the stricter of the pair about IDENTITY.
resolvers: [
{
match: ALL,
resolver: {
kind: 'path',
bins: ['dsh'],
versionArg: '--version',
versionRegex: DEEPSEEK_VERSION_REGEX,
requireVersionMatch: true,
},
},
],
},
{
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',
+16 -3
View File
@@ -48,8 +48,9 @@ const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
* answer "yes" to, which then loads and EXECUTES repo-local `.pi/extensions` TypeScript,
* so `approveProjectTrust: false` (`--no-approve`) is materialized. Omitting `--approve`
* is NOT a clamp.
* Codex, antigravity and grok need nothing here: their absent config already spawns safe
* (grok's bare spawn is its own ask-mode default; --always-approve is only ever sent).
* Codex, antigravity, grok and deepseek need nothing here: their absent config already spawns safe
* (grok's bare spawn is its own ask-mode default and deepseek's omits DSH_PERMISSION_MODE
* entirely, leaving the harness on workspace-write, which asks; both switches are only ever sent).
* Granted/admin/single-user get undefined for both, i.e. upstream defaults untouched.
*/
export function clampCronExternalCliConfigs(
@@ -394,11 +395,23 @@ export class CronService {
let session: Session;
try {
const mode = job.agentType;
// Same two-part availability gate the HTTP create paths run: `dsh` is a
// profile LAUNCHER, so without this a job on a box with only the stock
// web/headless profiles spawns a bare `dsh` that boots a profile unable
// to drive a pane, and the prompt is typed into a logging server or a
// dead pane instead of failing the run with the actionable message.
if (mode === 'deepseek') {
const { resolveDeepSeekLaunchError } = await import('../utils/deepseek-cli-resolver.js');
const launchError = resolveDeepSeekLaunchError();
if (launchError) return this.failRun(job, run, launchError);
}
const globalNice = await this.deps.getGlobalNiceConfig();
const modelConfig = await this.deps.getModelConfig();
const claudeModeConfig = await this.deps.getClaudeModeConfig();
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner);
const model = mode !== 'shell' ? modelConfig?.defaultModel || undefined : undefined;
// DeepSeek's model is a composition entry in the profile's config tree,
// not a session flag — mirror the HTTP routes' exclusion.
const model = mode !== 'shell' && mode !== 'deepseek' ? modelConfig?.defaultModel || undefined : undefined;
// Section 6.3: materialize the safe default for a non-granted owner (see
// clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own
// spawn default is what would otherwise apply).
+271
View File
@@ -0,0 +1,271 @@
/**
* @fileoverview The DeepSeek Harness -> Codeman status bridge.
*
* ## Why this exists
*
* Every external CLI mode before this one (opencode, codex, gemini, antigravity,
* pi, grok) is READINESS-GUESSED: Codeman watches the PTY go quiet and infers a
* turn ended. Claude is the exception, because Claude Code fires real hooks. The
* DeepSeek Harness TUI gives us a third option, and a much better one than
* guessing: the community terminal front door already reports its own lifecycle
* to an owning supervisor, and it does so through a fully GENERIC, env-var-gated
* contract it inherited from Herdr (herdr.dev).
*
* When all three of `HERDR_ENV=1`, `HERDR_BIN_PATH` and `HERDR_PANE_ID` are set,
* the TUI shells out on every state change:
*
* "$HERDR_BIN_PATH" pane report-agent "$HERDR_PANE_ID" \
* --source custom:dsh-tui --agent dsh-tui \
* --state idle|working|blocked [--message <text>] --seq <n>
*
* and treats exit code 0 as "delivered" (retrying with backoff otherwise). So
* Codeman points `HERDR_BIN_PATH` at the script below and gets DEFINITIVE
* idle/working/blocked signals for dsh sessions: real respawn triggers, real
* `wait`/`wait-output` stop+blocked signals, and real Approvals Inbox items,
* on par with Claude's hooks rather than with output stabilization.
*
* This is an interface implementation, not an impersonation: we implement the
* one verb (`pane report-agent`) that the contract defines, and nothing on the
* machine ever executes a real `herdr` binary — `HERDR_BIN_PATH` is our own
* script, in our own data dir. `HERDR_ENV=1` is the flag the TUI checks to know
* a supervisor is present; a supervisor IS present, it is Codeman.
*
* ## Why it is generated rather than committed
*
* The shim must be an executable file at a stable absolute path in every
* install shape: a git clone (where `scripts/` exists), an `npm i -g aicodeman`
* (where `files` ships only `dist` plus two named scripts), and any
* `CODEMAN_INSTANCE`. Writing it into the data dir at session-create time makes
* one code path cover all of them, single-sources the content here in TS, and
* follows the precedent of `self-update-runner.sh`. It is rewritten whenever the
* embedded version marker changes, so an upgraded Codeman refreshes a stale shim
* without the user knowing it exists.
*
* @module deepseek-status-shim
*/
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from './config/instance.js';
/**
* Bumped whenever SHIM_SOURCE changes. The marker is embedded in the generated
* file, so `ensureDeepSeekStatusShim()` can tell a current shim from one written
* by an older Codeman and rewrite only when needed (rather than rewriting on
* every session create, or — worse — leaving a stale one in place forever).
*/
const SHIM_VERSION = 3;
const SHIM_MARKER = `codeman-dsh-status-shim v${SHIM_VERSION}`;
/**
* Mapping from the harness's three lifecycle states to Codeman hook events.
*
* - `blocked` -> `permission_prompt`: the TUI reports blocked when a tool
* approval or an `ask_user_question` questionnaire is on screen, which is
* exactly the red "needs you" alert and an answerable Approvals Inbox item.
* - `idle` -> `stop`: the definitive end-of-turn signal, the one respawn and the
* wait endpoints care about.
* - `working` -> `agent_working`: a turn STARTED. Codeman infers "working" from
* PTY output well enough on its own, but the event is what RESOLVES a pending
* approval when the user answers a dialog in the terminal instead of in the
* inbox. Without it a dsh session's red alert would survive until the next
* `stop`, which is the exact stuck-alert bug the claude path already had to
* fix once (and the pane-capture staleness sweep that fixed it there is
* Claude-dialog-shaped, so it cannot help here).
*/
export const DEEPSEEK_STATE_TO_HOOK_EVENT: Readonly<Record<string, string>> = Object.freeze({
idle: 'stop',
blocked: 'permission_prompt',
working: 'agent_working',
});
/**
* The generated script.
*
* Constraints it must satisfy, each learned from an existing Codeman hook bug:
* - **TLS**: `CODEMAN_API_URL` is loopback HTTPS with a self-signed cert on
* `--https`/tailscale installs, so certificate verification is disabled for
* the request. Without this the whole bridge dies silently, exactly as the
* claude hook curls did before they grew `-k`.
* - **Secret**: the hook-secret file is read AT EXECUTION TIME, never baked in,
* so rotation needs no respawn and the value never lands on a command line.
* - **Exit codes**: 0 means delivered. Anything else makes the TUI retry with
* backoff, so transport failures self-heal, but an unknown verb or an
* unmapped state exits 0 to avoid a pointless retry storm over something that
* will never succeed.
* - **Timeout**: bounded below the caller's own 2s budget, so we lose the race
* deliberately rather than being killed mid-flight.
*/
const SHIM_SOURCE = `#!/usr/bin/env node
// ${SHIM_MARKER}
// GENERATED BY CODEMAN — do not edit. Rewritten from src/deepseek-status-shim.ts
// whenever its version marker changes.
//
// Implements the one verb the DeepSeek Harness TUI's supervisor contract uses:
// pane report-agent <paneId> --state <idle|working|blocked> [--message <t>] ...
// and forwards it to this Codeman instance as a hook event.
import { readFileSync } from 'node:fs'
import http from 'node:http'
import https from 'node:https'
const STATE_TO_EVENT = ${JSON.stringify(DEEPSEEK_STATE_TO_HOOK_EVENT)}
const TIMEOUT_MS = 1500
const argv = process.argv.slice(2)
const flag = (name) => {
const i = argv.indexOf(name)
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined
}
// Unknown verb: succeed silently. Retrying could never make it succeed, and a
// non-zero exit here would make the caller retry four times per state change.
if (argv[0] !== 'pane' || argv[1] !== 'report-agent') process.exit(0)
const event = STATE_TO_EVENT[String(flag('--state') ?? '')]
if (!event) process.exit(0)
// The pane id we hand the TUI IS the Codeman session id, but prefer the ambient
// env: it is set by the same code that set HERDR_PANE_ID, so a TUI that mangles,
// truncates or re-uses the pane argument still reports against the right session.
// NOT a security boundary, and do not read it as one: the agent runs IN this pane
// and can invoke the shim with CODEMAN_SESSION_ID unset and any argv it likes.
// That buys it nothing it did not already have, since the hook-secret file is
// readable from the same pane and any process there can POST /api/hook-event
// directly. Attribution here is about accidents, not adversaries.
const sessionId = process.env.CODEMAN_SESSION_ID || argv[2]
const apiUrl = process.env.CODEMAN_API_URL
if (!sessionId || !apiUrl) process.exit(1)
let secret = ''
try {
secret = readFileSync(process.env.CODEMAN_HOOK_SECRET_FILE || '', 'utf-8').trim()
} catch {
// Missing file: the loopback bypass still applies when no tunnel is running.
}
// The contract's ordering token: the TUI retries failed deliveries with
// backoff, so a stale report can land AFTER a newer one. Forwarded so the
// server can drop out-of-order arrivals instead of, say, resolving an
// approval with a retried 'working' while the harness sits blocked.
const seq = Number(flag('--seq'))
const body = JSON.stringify({
event,
sessionId,
data: {
source: 'dsh-status-shim',
agent: flag('--agent') || 'dsh',
...(Number.isFinite(seq) ? { seq } : {}),
...(flag('--message') ? { message: flag('--message') } : {}),
},
})
let url
try {
url = new URL('/api/hook-event', apiUrl)
} catch {
process.exit(1)
}
const transport = url.protocol === 'https:' ? https : http
const req = transport.request(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
timeout: TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'X-Codeman-Hook-Secret': secret,
},
// Loopback HTTPS with a self-signed cert (--https / tailscale installs).
rejectUnauthorized: false,
},
(res) => {
res.resume()
const status = res.statusCode ?? 0
// 2xx: delivered. 4xx: PERMANENT — a 401 (missing/rotated secret) or 429
// can never be fixed by retrying, and each retry feeds the auth-failure
// rate-limit bucket, so a single misconfigured dsh session could 429 the
// hook endpoint for the whole instance (killing every claude session's
// real hooks). Exit 0 so the TUI does not retry; only transport errors
// and 5xx stay retryable.
process.exit(status >= 200 && status < 500 ? 0 : 1)
}
)
req.on('timeout', () => {
req.destroy()
process.exit(1)
})
req.on('error', () => process.exit(1))
req.end(body)
`;
/** Absolute path of the generated shim for this instance. */
export function deepSeekStatusShimPath(): string {
return dataPath('dsh-status-shim.mjs');
}
let ensuredThisProcess = false;
/**
* Write the shim if it is missing or stale, and return its path.
*
* Idempotent and cheap: after the first call in a process it does nothing, and
* even the first call only rewrites when the on-disk marker differs. Never
* throws — a data dir that cannot be written is a degraded status bridge, not a
* failed session start, so callers fall back to output-stabilization readiness
* by receiving null.
*/
export function ensureDeepSeekStatusShim(): string | null {
const path = deepSeekStatusShimPath();
if (ensuredThisProcess) return path;
try {
let current = '';
try {
current = readFileSync(path, 'utf-8');
} catch {
// Missing — fall through to the write.
}
if (!current.includes(SHIM_MARKER)) {
mkdirSync(dirname(path), { recursive: true });
// Temp + rename, not a plain write: the TUI can be executing this exact
// path at the moment an upgraded Codeman refreshes it (every state change
// runs it, and session create is when the rewrite happens), and a reader
// that catches a half-written file gets a syntax error, exits non-zero,
// and is retried four times per state change for a file that will never
// parse. rename(2) is atomic within the directory, so a concurrent exec
// sees either the old shim or the new one, never a truncated one.
// Same reasoning as the state-store writes; pid-suffixed so two instances
// sharing a data dir cannot collide on the temp name.
const tempPath = `${path}.${process.pid}.tmp`;
try {
writeFileSync(tempPath, SHIM_SOURCE, { mode: 0o700 });
// The mode argument only applies when writeFileSync CREATES the file, so
// a leftover temp from a crashed run would keep its old permissions.
chmodSync(tempPath, 0o700);
renameSync(tempPath, path);
} catch (err) {
rmSync(tempPath, { force: true });
throw err;
}
}
// Re-assert the mode even when the content matched: a shim that lost its
// executable bit (a restored backup, a copied data dir) would make every
// report fail, and the TUI would retry four times per state change forever.
chmodSync(path, 0o700);
ensuredThisProcess = true;
return path;
} catch (err) {
console.warn(`[DeepSeek] Could not install the status shim at ${path}: ${(err as Error).message}`);
return null;
}
}
/** Test seam: forget the per-process memo so a fresh temp HOME is re-provisioned. */
export function resetDeepSeekStatusShimForTest(): void {
ensuredThisProcess = false;
}
+696
View File
@@ -0,0 +1,696 @@
/**
* @fileoverview Reading a DeepSeek Harness (`dsh`) session transcript off disk.
*
* ## Why this exists
*
* `GET /api/sessions/:id/last-response` is how an agent (and the Response
* Viewer) reads what a worker actually said. For Claude it comes from
* `~/.claude/projects/**`, for Codex from `~/.codex/sessions/**`, and for every
* other external CLI it comes from segmenting the terminal buffer, because
* those CLIs write nothing a reader could open.
*
* dsh is not in that last group: it writes a complete, structured JSONL
* transcript per session. Falling back to the pane for it was measurably wrong
* rather than merely coarse — dsh-TUI paints a full-screen splash, so the pane
* segmenter answered a `last-response` call for a fresh dsh session with the
* ASCII-art logo:
*
* {"text":"✦dsh-TUI v0.8.8█▀▀▀▄█▀▀▀▀█▀▀▀▀█▀▀▀▄█▀▀▀▀…","hasContext":true}
*
* which an agent polling for a worker's answer reads as an answer. This module
* is the real source: it locates the session's transcript, decodes it, and
* returns the last turn's text.
*
* ## The three things that make dsh transcripts unlike codex rollouts
*
* **1. One zstd FRAME per append, not one zstd stream.** The file is
* `session.jsonl.zstd`, and dsh appends by compressing each batch of lines into
* its own frame and writing it at the end. `zstd -dc` handles that (frames
* concatenate by definition), but Node's `zlib.zstdDecompress()` and
* `createZstdDecompress()` both stop at the first frame end: measured on a real
* 56-line transcript, Node returned 158 bytes / 1 line where the CLI returned
* 43,747 bytes / 56 lines. That is a silent truncation to the session header —
* every call would have reported "no answer yet" forever. `decodeZstdFrames()`
* below walks the frame headers itself and decompresses each frame, and
* `test/deepseek-transcript.test.ts` pins it against multi-frame fixtures.
*
* **2. The user's prompts are mixed with injected context.** Every turn also
* writes a `user/message` whose source is a plugin (the runtime-context
* snapshot: sandbox policy, approval policy, cwd). Those are `source.kind ===
* 'plugin'`; a real prompt is `source.kind === 'user'`. Rendering the plugin
* ones would show the agent its own boilerplate back as the user's words.
*
* **3. A failed turn is not an empty turn.** `turn/end` carries
* `reason.kind === 'error'` with the provider's message. Returning `""` there
* makes an agent poll `last-response` fifteen times and conclude the worker
* never answered, when the truth ("the provider rejected the request") was on
* disk the whole time. A turn that ends in an error and produced no text
* answers with that error, prefixed so it can never be mistaken for the model's
* own words.
*
* Verified against `dsh 0.1.1-rc.2` + `@deepseek-harness-tui/dsh-tui 0.8.8`.
*/
import { promises as fs } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import * as zlib from 'node:zlib';
/**
* One rendered block, in the shape the Response Viewer already speaks (see
* `web/response-viewer-transcript.ts`). Imported as a type only — this module
* must stay usable from the session layer without dragging web/ into it.
*/
export interface DeepSeekTranscriptBlock {
kind: 'prompt' | 'response' | 'status' | 'tool';
label: 'Prompt' | 'Response' | 'Status' | 'Tool';
role: 'user' | 'assistant';
text: string;
}
export interface DeepSeekTranscriptResult {
/** Last turn's answer (or its error, prefixed). Empty before the first turn. */
text: string;
/** ISO timestamp of the event `text` came from, or '' when unknown. */
timestamp: string;
/** Rendered blocks, oldest first. Only built when the caller asks for them. */
blocks: DeepSeekTranscriptBlock[];
/** dsh's own session id, from the header line. */
sessionId?: string;
/** Workspace the harness recorded for the session. */
cwd?: string;
}
/**
* zstd decompression is a RUNTIME capability here, not an import.
*
* Node grew `zlib` zstd support in 22.15 (and `@types/node` still does not
* declare it), while Codeman's floor is Node 22.0. So it is resolved through a
* narrow cast and checked before use: on an older 22.x a dsh session keeps the
* pane-segmenter behaviour it had before this module existed instead of
* throwing on every `last-response` call.
*/
type ZstdDecompressSync = (buf: Buffer) => Buffer;
const zstdDecompressSync: ZstdDecompressSync | undefined = (
zlib as unknown as { zstdDecompressSync?: ZstdDecompressSync }
).zstdDecompressSync;
/** Whether this Node can decode the compressed transcripts dsh writes. */
export function zstdSupported(): boolean {
return typeof zstdDecompressSync === 'function';
}
/** zstd frame magic (RFC 8878 §3.1.1). */
const ZSTD_MAGIC = 0xfd2fb528;
/** Skippable-frame magic range: 0x184D2A50..0x184D2A5F. */
const ZSTD_SKIPPABLE_LO = 0x184d2a50;
const ZSTD_SKIPPABLE_HI = 0x184d2a5f;
const DID_FIELD_SIZE = [0, 1, 2, 4];
const FCS_FIELD_SIZE = [0, 2, 4, 8];
/**
* Byte ranges of the zstd frames in `buf`, in order.
*
* Walks frame headers and block headers only — no decompression — so the cost
* is proportional to the number of blocks, not to the content. Stops (rather
* than throws) at the first thing it cannot parse, so a transcript still being
* appended to mid-write yields every whole frame before the torn tail instead
* of failing the whole read.
*
* ⚠️ Splitting on the magic bytes instead would be wrong: the 4-byte sequence
* can occur inside compressed data, and a false split corrupts everything after
* it. The block walk is what makes the boundaries exact.
*/
export function zstdFrameRanges(buf: Buffer): Array<[number, number]> {
const ranges: Array<[number, number]> = [];
let offset = 0;
while (offset + 4 <= buf.length) {
const magic = buf.readUInt32LE(offset);
if (magic >= ZSTD_SKIPPABLE_LO && magic <= ZSTD_SKIPPABLE_HI) {
if (offset + 8 > buf.length) break;
const end = offset + 8 + buf.readUInt32LE(offset + 4);
if (end > buf.length || end <= offset) break;
offset = end;
continue;
}
if (magic !== ZSTD_MAGIC) break;
let p = offset + 4;
if (p >= buf.length) break;
const descriptor = buf[p] as number;
p += 1;
const fcsFlag = descriptor >> 6;
const singleSegment = (descriptor >> 5) & 1;
const hasChecksum = (descriptor >> 2) & 1;
const dictIdFlag = descriptor & 3;
if (!singleSegment) p += 1; // window descriptor
p += DID_FIELD_SIZE[dictIdFlag] as number;
// FCS is absent for flag 0 UNLESS Single_Segment is set, where it is 1 byte.
p += fcsFlag === 0 ? (singleSegment ? 1 : 0) : (FCS_FIELD_SIZE[fcsFlag] as number);
if (p > buf.length) break;
let lastBlock = false;
let torn = false;
while (!lastBlock) {
if (p + 3 > buf.length) {
torn = true;
break;
}
const header = (buf[p] as number) | ((buf[p + 1] as number) << 8) | ((buf[p + 2] as number) << 16);
p += 3;
lastBlock = (header & 1) === 1;
const blockType = (header >> 1) & 3;
const blockSize = header >> 3;
if (blockType === 3) {
torn = true; // reserved: refuse rather than guess
break;
}
p += blockType === 1 ? 1 : blockSize; // RLE stores a single byte
if (p > buf.length) {
torn = true;
break;
}
}
if (torn) break;
if (hasChecksum) p += 4;
if (p > buf.length) break;
ranges.push([offset, p]);
offset = p;
}
return ranges;
}
/**
* Decode a possibly multi-frame zstd buffer. A buffer that does not start with
* a zstd magic is passed through unchanged, which is what lets the same reader
* open a plain `session.jsonl` (dsh writes one when compression is off).
*
* A frame that fails to decompress truncates the decode THERE rather than
* failing it: everything decoded before it is kept, so a half-written tail
* frame does not cost the caller the whole conversation. (Not "skipped" — a
* frame after a corrupt one is never reached, which is the safe reading: dsh
* appends, so a bad frame means everything after it is suspect too.)
*/
export function decodeZstdFrames(buf: Buffer): string {
if (buf.length < 4) return buf.toString('utf8');
const magic = buf.readUInt32LE(0);
if (magic !== ZSTD_MAGIC && (magic < ZSTD_SKIPPABLE_LO || magic > ZSTD_SKIPPABLE_HI)) {
return buf.toString('utf8');
}
if (!zstdDecompressSync) return '';
const parts: Buffer[] = [];
for (const [start, end] of zstdFrameRanges(buf)) {
try {
parts.push(zstdDecompressSync(buf.subarray(start, end)));
} catch {
// Torn or corrupt frame: keep what decoded before it.
break;
}
}
return Buffer.concat(parts).toString('utf8');
}
interface DshEvent {
type?: string;
seq?: number | null;
time?: number;
data?: Record<string, unknown>;
}
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
/**
* Strip a leaked reasoning prefix.
*
* Some providers stream reasoning into the same text block and close it with
* `</think>` without ever opening it (measured on a local deepseek-v4-flash
* route: `"I'll read the file first.</think>\n\nThe add function is…"`). The
* closing tag is the only reliable boundary, so everything up to the LAST one
* goes. A block with no tag is returned untouched.
*/
function stripReasoningPrefix(text: string): string {
const close = text.lastIndexOf('</think>');
return close === -1 ? text : text.slice(close + '</think>'.length);
}
/** `stripReasoning` is for ASSISTANT content only: a user prompt containing a
* literal `</think>` (someone pasting a transcript, say) must render whole. */
function textOfContent(content: unknown, stripReasoning = true): string {
const parts: string[] = [];
for (const entry of asArray(content)) {
const block = asRecord(entry);
if (!block) continue;
if (block.type === 'text' && typeof block.text === 'string') {
parts.push(stripReasoning ? stripReasoningPrefix(block.text) : block.text);
}
}
return parts.join('').trim();
}
function toolCallsOfContent(content: unknown): string[] {
const calls: string[] = [];
for (const entry of asArray(content)) {
const block = asRecord(entry);
if (!block || block.type !== 'tool-call') continue;
const name = typeof block.name === 'string' ? block.name : 'tool';
const args = typeof block.arguments === 'string' ? block.arguments : JSON.stringify(block.arguments ?? {});
calls.push(`${name}(${args})`);
}
return calls;
}
/** Flatten a `tool/result` message down to its text payload. */
function textOfToolResult(message: unknown): string {
const parts: string[] = [];
for (const entry of asArray(asRecord(message)?.content)) {
const block = asRecord(entry);
if (!block) continue;
if (block.type === 'text' && typeof block.text === 'string') parts.push(block.text);
if (block.type === 'tool-result') {
for (const inner of asArray(block.content)) {
const innerBlock = asRecord(inner);
if (innerBlock?.type === 'text' && typeof innerBlock.text === 'string') parts.push(innerBlock.text);
}
}
}
return parts.join('\n').trim();
}
function isoTime(time: unknown): string {
return typeof time === 'number' && Number.isFinite(time) ? new Date(time).toISOString() : '';
}
interface TurnAccumulator {
/** Finalized `assistant/message` text, in step order. */
finalized: Map<number, string>;
/** Steps that produced a finalized message AT ALL. ⚠️ Not the same as a
* non-empty entry in `finalized`: a step whose whole reply was reasoning
* strips to `''`, and without this the deltas — which are NOT stripped at
* write time — would be resurrected in its place, putting the model's raw
* `</think>` monologue in front of the caller (measured). */
finalizedSteps: Set<number>;
/** Streamed deltas per step, used only where no finalized message landed. */
streamed: Map<number, string>;
/** Step order as encountered, so a reply reads in the order it was produced. */
steps: number[];
timestamp: string;
/** Pre-rendered "Turn error: …" / "Turn ended: …" line, when the turn did not
* end with `completed`. */
ending?: string;
}
function ensureStep(turn: TurnAccumulator, step: number): void {
if (!turn.steps.includes(step)) turn.steps.push(step);
}
function turnText(turn: TurnAccumulator): string {
const parts: string[] = [];
for (const step of turn.steps) {
// Deltas are only consulted for a step the model never finalized — a step
// that has both would otherwise render its text twice.
const text = turn.finalizedSteps.has(step)
? (turn.finalized.get(step) ?? '')
: stripReasoningPrefix(turn.streamed.get(step) ?? '');
if (text.trim()) parts.push(text.trim());
}
return parts.join('\n\n').trim();
}
/**
* Parse a decoded dsh transcript.
*
* `text` is the LAST TURN's answer, not the last assistant message anywhere in
* the file: a turn that errored after an earlier turn answered must not hand
* back the earlier turn's text as though it were this turn's reply.
*/
export function parseDeepSeekTranscript(raw: string, options: { blocks?: boolean } = {}): DeepSeekTranscriptResult {
const wantBlocks = options.blocks === true;
const blocks: DeepSeekTranscriptBlock[] = [];
const turns = new Map<number, TurnAccumulator>();
const turnOrder: number[] = [];
let sessionId: string | undefined;
let cwd: string | undefined;
const getTurn = (n: number): TurnAccumulator => {
let turn = turns.get(n);
if (!turn) {
turn = { finalized: new Map(), finalizedSteps: new Set(), streamed: new Map(), steps: [], timestamp: '' };
turns.set(n, turn);
turnOrder.push(n);
}
return turn;
};
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
let event: DshEvent;
try {
event = JSON.parse(line) as DshEvent;
} catch {
continue; // a torn tail line, or a frame we could not decode
}
const data = asRecord(event.data) ?? {};
const turnNo = typeof data.turn === 'number' ? data.turn : 0;
const stepNo = typeof data.step === 'number' ? data.step : 0;
switch (event.type) {
case 'session': {
const header = event as unknown as Record<string, unknown>;
if (typeof header.id === 'string') sessionId = header.id;
if (typeof header.cwd === 'string') cwd = header.cwd;
break;
}
case 'user/message': {
// ⚠️ Only a real prompt. The plugin-sourced twin is the runtime-context
// snapshot dsh injects every turn (sandbox policy, approvals, cwd).
if (asRecord(data.source)?.kind !== 'user') break;
if (!wantBlocks) break;
const text = textOfContent(data.content, false);
if (text) blocks.push({ kind: 'prompt', label: 'Prompt', role: 'user', text });
break;
}
case 'assistant/message': {
const message = asRecord(data.message);
const turn = getTurn(turnNo);
ensureStep(turn, stepNo);
const text = textOfContent(message?.content);
if (message) turn.finalizedSteps.add(stepNo);
if (text) {
turn.finalized.set(stepNo, text);
turn.timestamp = isoTime(event.time) || turn.timestamp;
if (wantBlocks) blocks.push({ kind: 'response', label: 'Response', role: 'assistant', text });
}
if (wantBlocks) {
for (const call of toolCallsOfContent(message?.content)) {
blocks.push({ kind: 'tool', label: 'Tool', role: 'assistant', text: call });
}
}
break;
}
case 'assistant/chunk': {
const chunk = asRecord(data.chunk);
if (chunk?.type !== 'text-delta' || typeof chunk.text !== 'string') break;
const turn = getTurn(turnNo);
ensureStep(turn, stepNo);
turn.streamed.set(stepNo, (turn.streamed.get(stepNo) ?? '') + chunk.text);
break;
}
case 'text-chunks': {
// The batched form of the same deltas (dsh coalesces once a stream gets
// going). ⚠️ These carry `seq: null`, so file order is the only order.
const turn = getTurn(turnNo);
ensureStep(turn, stepNo);
const texts = asArray(data.texts)
.filter((t): t is string => typeof t === 'string')
.join('');
if (texts) turn.streamed.set(stepNo, (turn.streamed.get(stepNo) ?? '') + texts);
break;
}
case 'tool/result': {
if (!wantBlocks) break;
const text = textOfToolResult(data.message);
if (text) blocks.push({ kind: 'tool', label: 'Tool', role: 'assistant', text });
break;
}
case 'turn/end': {
const turn = getTurn(turnNo);
const reason = asRecord(data.reason);
if (reason && reason.kind !== 'completed') {
// Two different things wear this field: a provider failure
// (`kind:'error'` with a message) and an ordinary early stop
// (`kind:'max-tokens'`, measured live). Calling the second one an
// error would misreport a truncated but real answer.
const error = asRecord(reason.error);
const message = typeof error?.message === 'string' ? error.message : undefined;
const kind = typeof reason.kind === 'string' ? reason.kind : 'unknown';
turn.ending = message ? `Turn error: ${message}` : `Turn ended: ${kind}`;
if (wantBlocks) {
blocks.push({ kind: 'status', label: 'Status', role: 'assistant', text: turn.ending });
}
}
turn.timestamp = isoTime(event.time) || turn.timestamp;
break;
}
default:
break;
}
}
const lastTurn = turnOrder.length > 0 ? turns.get(turnOrder[turnOrder.length - 1] as number) : undefined;
let text = lastTurn ? turnText(lastTurn) : '';
// A turn that failed and said nothing answers with its failure, labelled so
// it can never read as the model's own words. Without this an agent polls
// `last-response` fifteen times and concludes the worker never answered.
if (!text && lastTurn?.ending) text = lastTurn.ending;
return { text, timestamp: lastTurn?.timestamp ?? '', blocks, sessionId, cwd };
}
/**
* `$DSH_HOME` for one session: a per-session override wins (`DSH_HOME` is an
* allowlisted `envOverrides` prefix, and pointing a worker at its own profile
* tree is a documented thing to do), then the server's own environment, then
* `~/.dsh`. Reading the wrong tree does not fail loudly — it silently finds no
* transcript — so this must resolve exactly the way the spawn did.
*/
/* ⚠️ The override is EPHEMERAL: `envOverrides` is applied at spawn and exported
* through `tmux setenv`, but is deliberately not persisted to state.json (it can
* carry provider keys). A session that overrode `DSH_HOME` and then outlived a
* server restart therefore resolves to the default tree and finds no transcript
* — it reads as "nothing said yet" rather than as another session's answer,
* because every candidate is matched on its recorded `cwd`. */
export function resolveDeepSeekHome(session: { deepSeekHomeOverride?: string }): string {
const override = session.deepSeekHomeOverride;
if (override && override.trim()) return override.trim();
const fromEnv = process.env.DSH_HOME;
if (fromEnv && fromEnv.trim()) return fromEnv.trim();
return join(homedir(), '.dsh');
}
/**
* How far apart a session's start and its transcript's `createdAt` may be and
* still be the same session. dsh writes the header within ~2 s of pane start
* (measured); 60 s absorbs a cold profile boot without ever reaching a sibling
* started minutes later.
*/
const PAIRING_WINDOW_MS = 60_000;
/** Transcript file names dsh has used, newest convention first. */
const TRANSCRIPT_FILES = ['session.jsonl.zstd', 'session.jsonl'];
/**
* Locate the transcript for a session.
*
* dsh buckets sessions by a mangled cwd (`--home-you-code-app--`) and then by
* its own session id, and the id form has changed between versions (`<uuid>`
* and `session-<uuid>` both exist on disk here). ⚠️ So the mangling is NOT
* reproduced: every candidate's own header line carries `cwd`, which is
* authoritative, and matching on it is immune to the next naming change.
*
* Pairing a Codeman session with ITS transcript then has one hard rule and one
* ladder. The rule: a transcript created BEFORE this session started belongs to
* an earlier conversation in the same directory and is never eligible. Measured
* cost of getting that wrong — a freshly spawned worker answered its very first
* `last-response` with the PREVIOUS session's reply, which is worse than saying
* nothing, because an agent cannot tell a stale answer from a fresh one.
*
* The ladder, once the older ones are out:
*
* 1. a transcript whose header `createdAt` sits within `PAIRING_WINDOW_MS` of
* this session's start — that is this pane's own boot, and it stays right
* even when a sibling session is running in the same case directory;
* 2. otherwise the newest transcript created after this session started;
* 3. otherwise nothing.
*
* ⚠️ The boot transcript wins for as long as it exists on disk — deliberately,
* and even over a LATER transcript in the same workspace. Step 2 cannot tell a
* `/new` from a sibling session that started later in the same directory, so
* preferring newest-eligible would hand a worker its busier sibling's reply
* (the exact bug the hard rule above was measured against, one seat over).
* The cost of that choice: after an interactive `/new` in a dsh tab, this
* reader keeps serving the pre-`/new` conversation (the same session's own
* earlier turns — stale, never foreign); step 2 is reached only when no
* boot-window transcript exists. Worker fleets never `/new`, so they only
* ever see step 1.
*/
export async function findDeepSeekTranscript(options: {
dshHome: string;
workingDir: string;
startedAt?: number;
}): Promise<string | null> {
const sessionsDir = join(options.dshHome, 'sessions');
let buckets: string[];
try {
buckets = (await fs.readdir(sessionsDir, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
} catch {
return null;
}
const candidates: Array<{ path: string; mtimeMs: number }> = [];
for (const bucket of buckets) {
const bucketPath = join(sessionsDir, bucket);
let sessions: string[];
try {
sessions = (await fs.readdir(bucketPath, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
} catch {
continue;
}
for (const sessionDir of sessions) {
for (const file of TRANSCRIPT_FILES) {
const path = join(bucketPath, sessionDir, file);
const stat = await fs.stat(path).catch(() => null);
if (!stat || !stat.isFile() || stat.size === 0) continue;
candidates.push({ path, mtimeMs: stat.mtimeMs });
break;
}
}
}
if (candidates.length === 0) return null;
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
const startedAt = options.startedAt ?? 0;
// Slack in both directions: the harness writes its header a beat after the
// pane starts, and mtimes on a shared clock are not worth trusting to the ms.
const floor = startedAt > 0 ? startedAt - PAIRING_WINDOW_MS : 0;
let laterMatch: string | null = null;
for (const candidate of candidates) {
const header = await readTranscriptHeader(candidate.path);
if (!header || header.cwd !== options.workingDir) continue;
// No usable header timestamp: fall back to the file's own mtime, which is
// still enough to keep a pre-session transcript out.
const createdAt = header.createdAt ?? candidate.mtimeMs;
if (createdAt < floor) continue;
if (startedAt > 0 && Math.abs(createdAt - startedAt) <= PAIRING_WINDOW_MS) return candidate.path;
if (!laterMatch) laterMatch = candidate.path;
}
return laterMatch;
}
/**
* Read only the first frame of a transcript, which is where the header line
* lives. Bounded: a candidate scan must never decompress every conversation on
* the box to answer one `last-response` call.
*/
async function readTranscriptHeader(path: string): Promise<{ cwd?: string; id?: string; createdAt?: number } | null> {
let handle;
try {
handle = await fs.open(path, 'r');
} catch {
return null;
}
try {
const head = Buffer.alloc(65536);
const { bytesRead } = await handle.read(head, 0, head.length, 0);
if (bytesRead === 0) return null;
const text = decodeZstdFrames(head.subarray(0, bytesRead));
const firstLine = text.split('\n').find((line) => line.trim());
if (!firstLine) return null;
const parsed = JSON.parse(firstLine) as { type?: string; cwd?: string; id?: string; createdAt?: number };
if (parsed.type !== 'session') return null;
return {
cwd: parsed.cwd,
id: parsed.id,
createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : undefined,
};
} catch {
return null;
} finally {
await handle.close().catch(() => {});
}
}
/** Hard ceiling on a transcript read. A long agent run is a few hundred KB; a
* file past this is pathological and is not worth a synchronous decode. */
const MAX_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
/**
* Memo of the last few decoded transcripts, keyed on (path, mtime, size,
* blocks). The skill's `last_text` polls once per second, and each poll used
* to zstdDecompressSync + reparse the WHOLE file on the event loop even when
* nothing had been appended — a multi-MB transcript made that a repeated
* ~100ms-class stall on the single-threaded server. A poll that finds the
* file unchanged now costs one stat. Insertion-order eviction; tiny, because
* an entry only earns its keep while a session is being actively polled.
*/
const parseMemo = new Map<string, DeepSeekTranscriptResult>();
const PARSE_MEMO_MAX = 16;
/** Test seam: a fixture that rewrites one path in place inside a single mtime
* tick would otherwise read its predecessor back out of the memo. */
export function resetDeepSeekTranscriptMemoForTest(): void {
parseMemo.clear();
}
/**
* Read one dsh session's last answer.
*
* ⚠️ The two empty outcomes are deliberately different, because the caller must
* treat them differently:
*
* - `null` means **this reader cannot run here** (a Node without zstd), and is
* the signal to fall back to the pane segmenter.
* - an empty `text` means **read fine, nothing said yet** — no transcript for
* this workspace, or a turn still in flight.
*
* Collapsing the two would put the ASCII-art splash back in front of an agent
* that is polling for a worker's first answer.
*/
export async function readDeepSeekLastResponse(
session: { workingDir: string; createdAt?: Date | number; deepSeekHomeOverride?: string },
options: { blocks?: boolean } = {}
): Promise<DeepSeekTranscriptResult | null> {
const createdAt = session.createdAt instanceof Date ? session.createdAt.getTime() : session.createdAt;
// dsh compresses by default, so a Node without zstd can read nothing here.
// That is the one case the pane is still the better answer.
if (!zstdSupported()) return null;
const empty: DeepSeekTranscriptResult = { text: '', timestamp: '', blocks: [] };
const path = await findDeepSeekTranscript({
dshHome: resolveDeepSeekHome(session),
workingDir: session.workingDir,
startedAt: typeof createdAt === 'number' ? createdAt : undefined,
});
if (!path) return empty;
const stat = await fs.stat(path).catch(() => null);
if (!stat || stat.size > MAX_TRANSCRIPT_BYTES) return empty;
const memoKey = `${path}|${stat.mtimeMs}|${stat.size}|${options.blocks ? 1 : 0}`;
const memoized = parseMemo.get(memoKey);
if (memoized) return memoized;
let buf: Buffer;
try {
buf = await fs.readFile(path);
} catch {
return empty;
}
const result = parseDeepSeekTranscript(decodeZstdFrames(buf), options);
if (parseMemo.size >= PARSE_MEMO_MAX) {
const oldest = parseMemo.keys().next().value;
if (oldest !== undefined) parseMemo.delete(oldest);
}
parseMemo.set(memoKey, result);
return result;
}
+283
View File
@@ -0,0 +1,283 @@
/**
* @fileoverview Supervises the one background `dsh web` process behind the Run
* menu's "DeepSeek web UI..." entry.
*
* The shortcut originally started the server inside an ordinary SHELL SESSION,
* on the reasoning that Codeman already knows how to supervise those: it was
* visible, scrollable, killable, and died with its tab, and nothing new had to
* own a long-lived HTTP server. That reasoning was sound and the result was
* still wrong in use — clicking "open the DeepSeek web UI" spawned a terminal
* tab the user never asked for, next to the web tab they did, and the terminal
* was noise every time after the first.
*
* So the server moves here instead: one child process, no session, no tab.
* What that buys back has to be paid for explicitly, which is what this module
* is:
*
* - **Exactly one.** A second click reuses the running server rather than
* racing it for a port. The old shell-session flow could not do this at all,
* because two clicks were simply two sessions.
* - **Restarted when the authority changes.** `--trusted-host` fences dsh's
* `/api` against the browser authority, and a Codeman reachable at both
* loopback and a tailnet name has two. Whoever asks last wins, because the
* asker is by definition the origin about to load the page.
* - **Killed on shutdown.** A detached child that outlived Codeman would hold
* its port against the next start, which is exactly the EADDRINUSE this
* feature already got wrong once.
* - **Failures reported, not swallowed.** The shell tab used to be where the
* stack trace landed. With no tab, the spawn's own output is captured and
* handed back to the caller instead.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { createServer } from 'node:net';
import { join } from 'node:path';
import { getErrorMessage } from './types.js';
/**
* Where the port search starts, and how far it walks.
*
* 3080 is `dsh web`'s own default, so it is the friendly first choice — and
* emphatically not a fixed port. DeepSeek's web UI is a thing users run
* themselves, which makes the default precisely the port most likely to be
* taken already; hardcoding it made this feature die with EADDRINUSE against
* the user's own server.
*/
const PORT_BASE = 3080;
const PORT_SPAN = 40;
/** How long a freshly spawned server gets to answer before we call it failed. */
const READY_TIMEOUT_MS = 30_000;
const READY_POLL_MS = 400;
/** Grace between SIGTERM and SIGKILL when stopping the tree. */
const KILL_GRACE_MS = 3_000;
/** Bound on captured child output, so a chatty boot cannot grow without limit. */
const OUTPUT_CAP = 16_384;
export interface DeepSeekWebStatus {
running: boolean;
port: number | null;
url: string | null;
/** Browser authority this server was started to trust (`--trusted-host`). */
authority: string | null;
}
interface RunningServer {
child: ChildProcess;
port: number;
authority: string;
output: () => string;
}
let current: RunningServer | null = null;
/**
* True when nothing holds `port` on loopback.
*
* Binding is the only honest test: a connect probe cannot tell "free" from
* "listening but not answering yet", and this runs moments before `dsh web`
* binds the same port. It is inherently racy, which is why the caller still
* waits for the server to actually answer before reporting success.
*/
async function isLoopbackPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const probe = createServer();
probe.once('error', () => resolve(false));
probe.once('listening', () => probe.close(() => resolve(true)));
probe.listen(port, '127.0.0.1');
});
}
async function findFreePort(): Promise<number | null> {
for (let port = PORT_BASE; port < PORT_BASE + PORT_SPAN; port++) {
if (await isLoopbackPortFree(port)) return port;
}
return null;
}
/** Does the server answer HTTP yet? Any status counts: dsh may 4xx a bare GET. */
async function answersHttp(port: number): Promise<boolean> {
try {
await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2_000) });
return true;
} catch {
return false;
}
}
/**
* Signal the whole process group.
*
* `dsh web` boots a plugin tree and fans out, so signalling only the direct
* child leaves survivors holding the port. Same negative-pid escalation as
* `runGit()` in git-clone.ts and the profile installer.
*/
function killTree(child: ChildProcess, signal: NodeJS.Signals): void {
try {
if (child.pid) process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
/* already gone */
}
}
}
export function getDeepSeekWebStatus(): DeepSeekWebStatus {
if (!current) return { running: false, port: null, url: null, authority: null };
return {
running: true,
port: current.port,
url: `http://127.0.0.1:${current.port}`,
authority: current.authority,
};
}
/** Stop the background server, if one is running. Safe to call when none is. */
export async function stopDeepSeekWeb(): Promise<void> {
const running = current;
current = null;
if (!running) return;
await new Promise<void>((resolve) => {
let done = false;
const finish = () => {
if (done) return;
done = true;
clearTimeout(hard);
resolve();
};
running.child.once('exit', finish);
killTree(running.child, 'SIGTERM');
const hard = setTimeout(() => {
killTree(running.child, 'SIGKILL');
finish();
}, KILL_GRACE_MS);
});
}
type StartResult = { ok: true; port: number; url: string; reused: boolean } | { ok: false; error: string };
/**
* Serializes concurrent starts. Two POSTs racing (two devices, or a double
* click while the first boots) used to both see `current === null`, pick the
* SAME free port, and spawn twice: the loser died on EADDRINUSE while its exit
* handler nulled the singleton out from under the winner, leaving a live
* `dsh web` nothing tracked or killed — the exact orphan this module exists to
* prevent. The second caller now simply waits and reuses the first's server.
*/
let startLock: Promise<unknown> = Promise.resolve();
/**
* Start (or reuse) the background `dsh web` for `authority`.
*
* @param dshDir directory holding the resolved `dsh` binary.
* @param authority browser authority to pass as `--trusted-host`.
*/
export function startDeepSeekWeb(dshDir: string, authority: string): Promise<StartResult> {
const run = startLock.then(
() => startDeepSeekWebLocked(dshDir, authority),
() => startDeepSeekWebLocked(dshDir, authority)
);
startLock = run.then(
() => undefined,
() => undefined
);
return run;
}
async function startDeepSeekWebLocked(dshDir: string, authority: string): Promise<StartResult> {
// Reuse only when the running server is BOTH healthy and fenced for the
// authority now asking. A server trusting the other origin renders a page
// whose every API call 403s, which looks like a broken dashboard rather than
// a misconfigured one.
if (current) {
if (current.authority === authority && (await answersHttp(current.port))) {
return { ok: true, port: current.port, url: `http://127.0.0.1:${current.port}`, reused: true };
}
await stopDeepSeekWeb();
}
const port = await findFreePort();
if (port === null) {
return { ok: false, error: `No free port for the DeepSeek web UI in ${PORT_BASE}-${PORT_BASE + PORT_SPAN - 1}` };
}
let child: ChildProcess;
try {
child = spawn(
join(dshDir, 'dsh'),
['web', '--no-open', '--host', '127.0.0.1', '--port', String(port), '--trusted-host', authority],
{
stdio: ['ignore', 'pipe', 'pipe'],
// Own process group so the whole plugin tree can be signalled at once.
detached: true,
env: process.env,
}
);
} catch (err) {
return { ok: false, error: `Failed to start dsh web: ${getErrorMessage(err)}` };
}
// The pipes must be drained whether or not anyone reads them: a full pipe
// blocks the child. Storage is capped; draining is not.
let output = '';
const capture = (chunk: Buffer) => {
if (output.length < OUTPUT_CAP) output += chunk.toString('utf-8');
};
child.stdout?.on('data', capture);
child.stderr?.on('data', capture);
let exited = false;
child.once('exit', () => {
exited = true;
// Only clear if this is still the current server: a restart may have
// already replaced it, and clearing then would drop the live one.
if (current?.child === child) current = null;
});
child.once('error', () => {
exited = true;
if (current?.child === child) current = null;
});
const running: RunningServer = { child, port, authority, output: () => output };
current = running;
const deadline = Date.now() + READY_TIMEOUT_MS;
while (Date.now() < deadline) {
if (exited) {
// Guarded like the exit/error handlers: a concurrent stop (DELETE route,
// shutdown) may already have cleared or replaced the singleton, and an
// unconditional null here would drop a server this call does not own.
if (current === running) current = null;
const tail = output.trim().slice(-800);
return { ok: false, error: tail ? `dsh web exited during startup: ${tail}` : 'dsh web exited during startup' };
}
if (await answersHttp(port)) {
return { ok: true, port, url: `http://127.0.0.1:${port}`, reused: false };
}
await new Promise((r) => setTimeout(r, READY_POLL_MS));
}
// Timeout: kill OUR child. Only route through stopDeepSeekWeb() while the
// singleton is still ours — signalling `current` unconditionally here could
// SIGTERM a healthy server a concurrent actor now owns.
if (current === running) {
await stopDeepSeekWeb();
} else {
killTree(running.child, 'SIGKILL');
}
const tail = output.trim().slice(-800);
return {
ok: false,
error: tail
? `dsh web did not answer on port ${port} within ${READY_TIMEOUT_MS / 1000}s: ${tail}`
: `dsh web did not answer on port ${port} within ${READY_TIMEOUT_MS / 1000}s`,
};
}
/** Test seam: forget any tracked child without signalling it. */
export function resetDeepSeekWebForTest(): void {
current = null;
}
+29
View File
@@ -146,6 +146,8 @@ export function defaultDockerCommandForMode(mode: SessionMode): string {
antigravity: 'exec agy',
pi: 'exec pi',
grok: 'exec grok',
deepseek: 'exec dsh',
omp: 'exec omp',
};
return commands[mode as DockerCommandMode] || commands.shell;
}
@@ -625,8 +627,35 @@ const CRED_STORES: CredStorePolicy[] = [
rel: '.grok',
seedFiles: ['auth.json', 'config.toml', 'pager.toml'],
},
// DeepSeek Harness keeps credentials in `~/.dsh/.env` (0600) and composition in
// `settings.yaml` / `cordis.patch.yml`. `profiles/` is deliberately NOT seeded:
// it is a pnpm workspace holding a full node_modules tree per profile, which is
// both enormous and host-arch-specific. An in-container dsh therefore needs its
// profile installed IN the image (see docker/agent.Dockerfile), and the seeded
// files only supply auth and model composition. Same host-invisibility trade-off
// as pi and grok: `~/.dsh/sessions` inside a container is that container's own.
{
rel: '.dsh',
seedFiles: ['.env', 'settings.yaml', 'cordis.patch.yml'],
},
{ 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'],
},
];
/**
+6
View File
@@ -20,6 +20,8 @@ import type {
AntigravityConfig,
PiConfig,
GrokConfig,
DeepSeekConfig,
OmpConfig,
SessionRemote,
SessionDocker,
} from './types.js';
@@ -80,6 +82,8 @@ export interface CreateSessionOptions {
antigravityConfig?: AntigravityConfig;
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. */
@@ -113,6 +117,8 @@ export interface RespawnPaneOptions {
antigravityConfig?: AntigravityConfig;
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;
}
+6
View File
@@ -115,6 +115,11 @@ export function defaultRemoteCommandForMode(mode: SessionMode): string {
antigravity: remoteLoginShellCommand('agy'),
pi: remoteLoginShellCommand('pi'),
grok: remoteLoginShellCommand('grok'),
// `dsh` alone boots nothing: the launcher needs a profile, and the remote box's
// 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;
}
@@ -270,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;
}
+173 -7
View File
@@ -52,9 +52,12 @@ import {
type AntigravityConfig,
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';
@@ -178,7 +181,9 @@ export function isExternalCliMode(mode: SessionMode): boolean {
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok'
mode === 'grok' ||
mode === 'deepseek' ||
mode === 'omp'
);
}
@@ -196,6 +201,10 @@ function getModeLabel(mode: SessionMode): string {
return 'Pi';
case 'grok':
return 'Grok';
case 'deepseek':
return 'DeepSeek';
case 'omp':
return 'OMP';
case 'shell':
return 'Shell';
case 'claude':
@@ -521,6 +530,11 @@ export class Session extends EventEmitter {
private _piConfig: PiConfig | undefined;
// Grok configuration (only for mode === 'grok')
private _grokConfig: GrokConfig | undefined;
// 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
@@ -618,6 +632,10 @@ export class Session extends EventEmitter {
piConfig?: PiConfig;
/** Grok configuration (only for mode === 'grok') */
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) */
@@ -673,7 +691,13 @@ export class Session extends EventEmitter {
this._wireActivityAt = config.lastActivityAt || Date.now();
this._wireActivitySettleUntil = config.lastActivityAt ? Date.now() + WIRE_ACTIVITY_SETTLE_MS : 0;
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = config.resumeSessionId || this.id;
// For omp, `claudeSessionId` doubles as the generic "external transcript id"
// alias key mergeUnifiedSessions() folds a history row into its owning
// session by: omp mints its OWN uuid, unrelated to this Codeman id, so
// without this an omp conversation's Past-Sessions row (keyed by omp's
// id) would never merge with its own live/persisted row (keyed by this
// id) — it would just show up a second time.
this._claudeSessionId = config.resumeSessionId || config.ompConfig?.resumeSessionId || this.id;
// Restored from state.json on boot recovery. start() resets _claudeSessionId
// to the launch id even when re-attaching to a mux session whose CLI has
// moved on (a `/clear` before the restart), so this anchor is what lets the
@@ -726,6 +750,15 @@ 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) {
this._deepSeekConfig = config.deepSeekConfig;
}
// Apply Grok configuration
if (config.grokConfig) {
@@ -879,6 +912,34 @@ export class Session extends EventEmitter {
return this._remote;
}
/**
* `deepSeekConfig.statusReporting` verbatim: `undefined` when the caller sent
* none (i.e. ON), `false` when the user disarmed the status bridge for this
* session.
*
* Exposed because whether a dsh session can deliver `stop`/`blocked` is a
* per-SESSION fact, not a per-mode one, and `hooksAvailableForMode()` is pure
* and holds no `Session` reference by design. Undefined for every other mode,
* where the flag is meaningless.
*/
get deepSeekStatusReporting(): boolean | undefined {
return this._deepSeekConfig?.statusReporting;
}
/**
* This session's `DSH_HOME` override, if it set one.
*
* Deliberately ONE key rather than an `envOverrides` getter: the map can hold
* provider credentials (`DEEPSEEK_API_KEY`, `GEMINI_API_KEY`, …) and is
* kept off the public `SessionState` for exactly that reason. The transcript
* reader needs the profile tree's location and nothing else, so that is all
* this exposes.
*/
get deepSeekHomeOverride(): string | undefined {
const value = this._envOverrides?.DSH_HOME;
return value && value.trim() ? value.trim() : undefined;
}
/** Owning username in multi-user mode, else undefined. */
get owner(): string | undefined {
return this._owner;
@@ -1325,6 +1386,8 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
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
@@ -1451,7 +1514,11 @@ export class Session extends EventEmitter {
let needsNewSession = false;
if (this._muxSession && mux.isPaneDead(this._muxSession.muxName)) {
console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName);
const newPid = await mux.respawnPane(options.respawnPaneOptions);
// Confirmed dead — safe to resolve/pin now (see `_pinOmpRespawnId()`).
// `options.respawnPaneOptions` was built eagerly before this dead-pane
// check ran, so it still carries the pre-pin ompConfig; rebuild it.
this._pinOmpRespawnId();
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
if (!newPid) {
console.error('[Session] Failed to respawn pane, will create new session');
needsNewSession = true;
@@ -1501,7 +1568,8 @@ export class Session extends EventEmitter {
this.mode === 'gemini' ||
this.mode === 'antigravity' ||
this.mode === 'pi' ||
this.mode === 'grok'
this.mode === 'grok' ||
this.mode === 'deepseek'
),
})
);
@@ -1541,6 +1609,9 @@ export class Session extends EventEmitter {
return false;
}
// Confirmed the mux session (and thus the pane) exists but this reattach
// is about to respawn it — safe to resolve/pin now.
this._pinOmpRespawnId();
const newPid = await mux.respawnPane(this._buildRespawnPaneOptions());
if (!newPid) {
console.error('[Session] reattachRemote: respawnPane failed for', this._muxSession.muxName);
@@ -1572,6 +1643,17 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
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,
@@ -1582,6 +1664,45 @@ export class Session extends EventEmitter {
};
}
/**
* OMP-only: resolve and PIN the exact conversation to continue when
* respawning a dead pane, so every later respawn reuses the same id
* instead of re-resolving (and re-risking picking up a DIFFERENT
* conversation that happened to touch this directory more recently). See
* the comment at the call site in {@link _buildRespawnPaneOptions} for why
* "newest file on disk" is safe here specifically. Non-omp modes and a
* session that already carries an explicit id pass through untouched.
*/
private _pinOmpRespawnId(): void {
if (this.mode !== 'omp') return;
if (this._ompConfig?.resumeSessionId) return;
// Callers MUST call this only immediately before an ACTUAL respawn (a
// confirmed-dead pane, or a genuine remote reattach) — never while merely
// building options that might not lead to a respawn. A fresh "Run OMP"
// click has no _muxSession yet and must never inherit whatever omp
// conversation happens to be newest on disk for this working directory
// (reported live 2026-08-27, fixed in 13a19f79); this guard keeps that
// fix intact now that resolution has moved out of the eager options build.
if (!this._muxSession) return;
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
if (resolvedId) {
this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId };
// Alias omp's own session uuid to this Codeman id — see the
// constructor's claudeSessionId comment for why this field is the
// (generically-named) mechanism that folds a Past-Sessions row back
// into its live/persisted session instead of duplicating it.
this._claudeSessionId = resolvedId;
return;
}
// Nothing unclaimed on disk (the dying process never got far enough to
// write a session file, or a sibling already claimed the only candidate)
// — fall back to the CLI's own "most recent" heuristic.
console.warn(
`[Session] OMP: no session file found under ${this.workingDir} to pin --resume on respawn; falling back to ambiguous --continue`
);
this._ompConfig = { ...this._ompConfig, continueSession: true };
}
/**
* Remember whether the CLI currently wants to be told about mouse clicks.
*
@@ -1831,6 +1952,8 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId,
envOverrides: this._envOverrides,
effort: this._effort,
@@ -1842,8 +1965,14 @@ export class Session extends EventEmitter {
spawnErrLabel: 'mux attachment',
});
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = this._resumeSessionId || this.id;
// Set claudeSessionId — when resuming, the Claude conversation ID is the
// resumed one. `_pinOmpRespawnId()` (called just above, inside
// `_setupOrAttachMuxSession()`'s dead-pane branch) may have JUST aliased
// this to omp's own session uuid — that already-resolved id must win
// over the generic `this.id` fallback, or this line clobbers it back
// to the Codeman id
// on every single respawn.
this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
// For NEW mux sessions: wait for readiness then clean buffer
// For RESTORED mux sessions: don't do anything - client will fetch buffer on tab switch
@@ -1924,6 +2053,12 @@ export class Session extends EventEmitter {
if (this.mode === 'grok') {
throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.');
}
// DeepSeek sessions require tmux for DEEPSEEK_API_KEY / DSH_PERMISSION_MODE
// injection via setenv — and for the HERDR_* status-bridge triple, without
// which the mode silently loses its definitive idle/blocked signals.
if (this.mode === 'deepseek') {
throw new Error('DeepSeek Harness sessions require tmux. Direct PTY fallback is not supported.');
}
try {
// Pass --session-id to use the SAME ID as the Codeman session
// This ensures subagents can be directly matched to the correct tab
@@ -1955,7 +2090,12 @@ export class Session extends EventEmitter {
}
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
this._claudeSessionId = this._resumeSessionId || this.id;
// Mirrors the mux branch above and must not clobber it: this line runs
// unconditionally after both the mux and direct-PTY paths, so it also needs
// the ompConfig fallback or it stomps the mux branch's correctly-resolved
// OMP alias back to this.id on every mux/plain-reattach boot recovery
// (the "third reset point" — see DECISIONS.md).
this._claudeSessionId = this._resumeSessionId || this._ompConfig?.resumeSessionId || this.id;
this._pid = this.ptyProcess.pid;
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
@@ -2246,10 +2386,36 @@ export class Session extends EventEmitter {
this._isWorking = false;
this._status = 'idle';
this._lastPromptTime = Date.now();
if (wasWorking) this._maybeCaptureOmpSessionId();
this.emit('idle');
}
}
/**
* A brand-new omp session (never yet respawned, so
* {@link _pinOmpRespawnId} has never run) has no captured
* omp-native session id: `_claudeSessionId` still defaults to this
* session's OWN Codeman id from the constructor. Until something aliases
* it, the omp history scan's row for this exact conversation (keyed by
* omp's own uuid) merges with nothing and shows up a second time. The
* first turn going idle is the first moment omp has definitely written
* its session file, so resolve and alias it here — best-effort, and only
* once (skips once `_claudeSessionId` differs from `this.id`, whether from
* this capture or a resume/respawn that already resolved one).
*/
private _maybeCaptureOmpSessionId(): void {
if (this.mode !== 'omp' || this._claudeSessionId !== this.id) return;
try {
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
if (resolvedId) {
this._claudeSessionId = resolvedId;
this._ompConfig = { ...this._ompConfig, resumeSessionId: resolvedId };
}
} catch {
// Best-effort: a failed capture just means the next respawn tries again.
}
}
/**
* Process expensive parsers (ANSI strip, Ralph, bash tool, token, CLI info, task descriptions).
* Called on a throttled schedule (every EXPENSIVE_PROCESS_INTERVAL_MS) instead of on every
+195 -3
View File
@@ -53,6 +53,8 @@ import {
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type DeepSeekConfig,
type OmpConfig,
type SessionRemote,
type SessionDocker,
type DockerCommandMode,
@@ -95,6 +97,11 @@ import {
getPiNotFoundMessage,
resolveGrokDir,
getGrokNotFoundMessage,
resolveDeepSeekDir,
getDeepSeekNotFoundMessage,
resolveDefaultDeepSeekProfile,
getOmpNotFoundMessage,
resolveOmpDir,
resolveLocalShell,
loginShellArgs,
} from './utils/index.js';
@@ -119,6 +126,7 @@ import {
// ============================================================================
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js';
/** How long a cached process snapshot stays usable. */
const PROC_SNAPSHOT_TTL_MS = 2000;
@@ -846,6 +854,79 @@ function buildGrokCommand(config?: GrokConfig): string {
return parts.join(' ');
}
/**
* Build the DeepSeek Harness (`dsh`) command with appropriate flags.
*
* Unlike every sibling builder, the interesting decision here is not a flag but
* WHICH PROFILE to boot: `dsh` is a launcher over `$DSH_HOME/profiles/<name>`,
* and DeepSeek ships no interactive terminal profile of its own, so the agent a
* pane runs is always one the user installed. An absent `profile` resolves to
* the first pane-capable profile on the box; when there is none we still emit a
* bare `dsh --profile <default>` rather than inventing a name, because the
* availability gate in createSession() has already refused the spawn by then and
* this path only runs for a session that passed it.
*
* There is deliberately NO permission flag: the harness has none. The sandbox
* and approval rows read `DSH_PERMISSION_MODE`, exported through `tmux setenv`
* in buildEnvExports() so it never lands on this command line.
*
* Like the sibling builders, every user value is regex-allowlisted and silently
* DROPPED on failure: the result is interpolated into a `bash -c "..."` string.
*/
function buildDeepSeekCommand(config?: DeepSeekConfig): string {
const parts = ['dsh'];
// A profile name is a single path segment: it is both interpolated into the
// shell line and joined into a filesystem path.
const requested = config?.profile;
const safeProfile =
requested && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(requested)
? requested
: (resolveDefaultDeepSeekProfile() ?? undefined);
if (safeProfile) parts.push('--profile', safeProfile);
// The launcher forwards everything after its own flags to the profile's app,
// which is where `--resume` is understood. An explicit id wins over the
// most-recent-session form, mirroring the sibling builders.
const safeSessionId =
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeSessionId) {
parts.push('--resume', safeSessionId);
} else if (config?.resumeSession) {
parts.push('--resume');
}
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.
@@ -890,6 +971,8 @@ export function buildSpawnCommand(options: {
antigravityConfig?: AntigravityConfig;
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). */
@@ -942,6 +1025,12 @@ export function buildSpawnCommand(options: {
if (options.mode === 'grok') {
return buildGrokCommand(options.grokConfig);
}
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
@@ -1125,7 +1214,6 @@ export function buildRemoteKillCommand(options: { remote: SessionRemote; session
* adopts/resizes/respawns our session (same defence as the remote socket).
*/
const DOCKER_TMUX_SOCKET = 'codeman-docker';
/**
* Deterministic, reattach-stable in-container tmux session name. Derived from the
* same stable field the local muxName uses (first 8 chars of the sessionId), so a
@@ -1159,6 +1247,9 @@ function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: stri
return `${modeCommand} --session ${resumeId}`;
case 'grok':
return `${modeCommand} --resume ${resumeId}`;
case 'deepseek':
case 'omp':
return `${modeCommand} --resume ${resumeId}`;
default:
return modeCommand; // shell / opencode: no resume
}
@@ -1749,10 +1840,22 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const exports = [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
mode === 'codex' ||
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek' ||
mode === 'omp'
? 'export COLORTERM=truecolor'
: 'unset COLORTERM',
...(mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok'
...(mode === 'codex' ||
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek' ||
mode === 'omp'
? ['unset NO_COLOR']
: []),
// Stamp each Codex pane with a unique originator so the response-viewer
@@ -1853,6 +1956,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const dir = resolveGrokDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'deepseek') {
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 };
}
@@ -1883,6 +1994,65 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
setGeminiEnvVars(this.tmux(), muxName);
}
/**
* Configure DeepSeek Harness environment on a tmux session.
*
* Two independent things, both via `tmux setenv` so they are inherited by the
* pane without appearing in `ps`:
*
* 1. `DSH_PERMISSION_MODE` — the harness's only permission input. Exported
* ONLY when the caller sent one, so an absent config lands on the harness's
* own `workspace-write` default (which asks) rather than on ours. That
* "only if sent" shape is what the multi-user clamp relies on.
* 2. The `HERDR_*` triple — the supervisor contract the terminal front door
* uses to report idle/working/blocked. Pointing `HERDR_BIN_PATH` at our own
* generated shim is what upgrades this mode from output-stabilization
* guessing to definitive hook events (see deepseek-status-shim.ts). The
* pane id IS the Codeman session id, which is how the shim attributes a
* report without trusting anything the agent could influence.
*
* Also forwards DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from the server env when
* present, matching the codex/gemini precedent for headless auth.
*/
private _configureDeepSeek(muxName: string, sessionId: string, config?: DeepSeekConfig): void {
const tmuxCmd = this.tmux();
const setenv = (key: string, value: string): void => {
const escaped = value.replace(/'/g, "'\\''");
try {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical */
}
};
for (const key of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DSH_HOME']) {
const val = process.env[key];
if (val) setenv(key, val);
}
// Enum-validated at the schema boundary; re-checked here because this value
// reaches a shell line, and a builder must never trust its caller.
if (
config?.permissionMode &&
['read-only', 'workspace-write', 'danger-full-access'].includes(config.permissionMode)
) {
setenv('DSH_PERMISSION_MODE', config.permissionMode);
}
if (config?.statusReporting !== false) {
const shim = ensureDeepSeekStatusShim();
if (shim) {
setenv('HERDR_ENV', '1');
setenv('HERDR_BIN_PATH', shim);
setenv('HERDR_PANE_ID', sessionId);
}
}
}
/**
* Creates a new tmux session wrapping Claude CLI or a shell.
* In test mode: creates an in-memory session only (no real tmux session).
@@ -1903,6 +2073,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
envOverrides,
effort,
@@ -1963,9 +2135,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'pi' && !cliDir) {
throw new Error(getPiNotFoundMessage());
}
if (mode === 'deepseek' && !cliDir) {
throw new Error(getDeepSeekNotFoundMessage());
}
if (mode === 'grok' && !cliDir) {
throw new Error(getGrokNotFoundMessage());
}
if (mode === 'omp' && !cliDir) {
throw new Error(getOmpNotFoundMessage());
}
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
@@ -1981,6 +2159,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
effort,
sessionName: name,
@@ -2049,6 +2229,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'gemini') {
this._configureGemini(muxName);
}
// For DeepSeek: permission mode + the Herdr-compatible status bridge.
if (mode === 'deepseek') {
this._configureDeepSeek(muxName, sessionId, deepSeekConfig);
}
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
// so secret values stay off the bash command line. Must run before respawn-pane.
@@ -2206,6 +2390,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
envOverrides,
effort,
@@ -2236,6 +2422,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
resumeSessionId,
effort,
sessionName: name,
@@ -2260,6 +2448,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
if (mode === 'gemini') {
this._configureGemini(muxName);
}
// For DeepSeek: permission mode + the Herdr-compatible status bridge.
if (mode === 'deepseek') {
this._configureDeepSeek(muxName, sessionId, deepSeekConfig);
}
// Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides);
+1
View File
@@ -1014,6 +1014,7 @@ const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string
{ id: 'antigravity', label: 'antigravity', detail: 'Google Antigravity' },
{ id: 'pi', label: 'pi', detail: 'pi.dev' },
{ id: 'grok', label: 'grok', detail: 'xAI Grok Build' },
{ id: 'deepseek', label: 'deepseek', detail: 'DeepSeek Harness (dsh)' },
];
// ─────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -149,7 +149,7 @@ export type TuiAnswerResult =
export interface TuiQuickStartOptions {
caseName: string;
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok';
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek';
sessionName?: string;
/** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */
parentSessionId?: string;
+5 -1
View File
@@ -109,7 +109,11 @@ export type HookEventType =
| 'elicitation_response'
| 'stop'
| 'teammate_idle'
| 'task_completed';
| 'task_completed'
// No Claude Code hook behind this one: it is the DeepSeek status bridge's
// "a turn STARTED" report (see deepseek-status-shim.ts). Keep in step with
// HookEventSchema in web/schemas.ts.
| 'agent_working';
// ========== API Response Types ==========
+84 -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' (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)
@@ -17,6 +17,7 @@
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
*
* Cross-domain relationships:
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
@@ -45,11 +46,21 @@ export type SessionStatus = 'idle' | 'busy' | 'stopped' | 'error';
export type ClaudeMode = 'dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools';
/** Session mode: which CLI backend a session runs */
export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok';
export type SessionMode =
| 'claude'
| 'shell'
| 'opencode'
| 'codex'
| 'gemini'
| 'antigravity'
| 'pi'
| 'grok'
| 'deepseek'
| 'omp';
export type RemoteCommandMode = Extract<
SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok'
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
>;
/**
@@ -158,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'
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
>;
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
@@ -333,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.
*
@@ -388,6 +409,61 @@ export interface GrokConfig {
resumeSessionId?: string;
}
/**
* DeepSeek Harness (`dsh`) session configuration.
*
* Two things make this config shaped unlike every sibling above it.
*
* **1. The agent is a PROFILE, not the binary.** `dsh` is a launcher: it boots
* `$DSH_HOME/profiles/<name>`, an ordered stack of plugin-bundle patch layers.
* DeepSeek ships only `web`, `headless` and `base`, so the interactive terminal
* agent is always a third-party profile the user installed. `profile` is
* therefore the primary knob, and an absent one resolves to the first
* pane-capable profile found (see resolveDefaultDeepSeekProfile).
*
* **2. Permissions are an ENV VAR, not a flag.** The harness has no
* `--dangerously-skip-permissions` equivalent; its sandbox and approval rows are
* config, driven by one documented input, `DSH_PERMISSION_MODE`, with three
* presets (measured from `dsh --dump-default-config`):
*
* read-only sandbox read-only, approval ask
* workspace-write sandbox workspace-write, approval ask <- default
* danger-full-access sandbox danger-full-access, approval never
*
* This is the one place a Codeman env export is the RIGHT mechanism rather than
* the forbidden one: unlike `CLAUDE_CODE_EFFORT_LEVEL` (which hard-locks
* in-session `/effort`), `DSH_PERMISSION_MODE` is read with `??` as a boot-time
* DEFAULT, so it stays a soft default the user can still change in-session. It
* is exported via `tmux setenv`, never on the spawn command line.
*/
export interface DeepSeekConfig {
/**
* Profile under `$DSH_HOME/profiles` to boot (`dsh --profile <name>`). Absent
* = the first pane-capable profile installed. A `web`/`headless` profile is
* refused at spawn time: neither can drive an interactive pane.
*/
profile?: string;
/**
* Sandbox + approval preset, exported as `DSH_PERMISSION_MODE`. Absent = the
* harness's own `workspace-write` default, which still ASKS — which is why the
* multi-user clamp only needs the only-if-sent branch here, like
* codex/antigravity/grok rather than pi.
*/
permissionMode?: 'read-only' | 'workspace-write' | 'danger-full-access';
/** Resume the most recent session for this workspace (`--resume`). */
resumeSession?: boolean;
/** Resume a specific session by ID (`--resume <id>`). Wins over resumeSession. */
resumeSessionId?: string;
/**
* Report idle/working/blocked back to Codeman through the Herdr-compatible
* status shim (see `deepseek-status-shim.ts`). Default ON: it upgrades this
* mode from output-stabilization guessing to definitive hook events. Only
* TUIs that implement the contract report; for one that does not, this is
* inert rather than harmful.
*/
statusReporting?: boolean;
}
/**
* Configuration for creating a new session
*/
@@ -553,6 +629,10 @@ export interface SessionState {
piConfig?: PiConfig;
/** Grok-specific configuration (only for mode === 'grok') */
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) */
+16
View File
@@ -32,6 +32,16 @@
export type WebviewEmbedMode = 'proxy' | 'direct';
/** A saved dashboard, persisted to `~/.codeman/webviews.json`. */
/**
* Dashboards Codeman creates and maintains on the user's behalf.
*
* A managed record is hidden from the saved-dashboard list, because the shortcut
* that maintains it is already a menu entry of its own: listing both showed the
* same dashboard twice, once as "DeepSeek web UI..." and once as the row it had
* just written.
*/
export type WebviewManagedKind = 'deepseek-web';
export interface Webview {
id: string;
/** Display name shown on the tab. */
@@ -49,6 +59,12 @@ export interface Webview {
* cookies/localStorage, only for dashboards the user fully trusts.
*/
trusted: boolean;
/**
* Set when Codeman owns this record rather than the user (see
* `WebviewManagedKind`). Managed rows are maintained by the shortcut that
* created them, including repointing the URL when the port changes.
*/
managed?: WebviewManagedKind;
/** Multi-user owner (username). Undefined in single-user mode. */
owner?: string;
createdAt: number;
+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));
}
+401
View File
@@ -0,0 +1,401 @@
/**
* @fileoverview Resolve the DeepSeek Harness CLI (`dsh`) binary and its bootable profiles.
*
* Mirrors pi-cli-resolver.ts / grok-cli-resolver.ts, but the identity probe here
* is STRICTER than either, and deliberately so: `dsh` is not merely a short name
* with npm squatters, it is an EXISTING, widely packaged Unix program. Debian and
* Ubuntu ship `dsh` = "dancer's shell" / distributed shell (`apt install dsh`),
* which like nearly every Unix tool prints a version-shaped string of its own.
* A version-token probe alone (which is all pi and grok need) would
* therefore ACCEPT dancer's shell as the DeepSeek Harness and hand it to a spawn
* line, so every candidate must additionally prove its identity by printing the
* harness's own help banner.
*
* Two probes per candidate, both bounded and both cached behind the shared
* resolver's positive/negative caching:
* 1. `dsh --help` must match DEEPSEEK_IDENTITY_REGEX (`DeepSeek Harness`)
* 2. `dsh --version` must yield a version token (real output: `0.1.1-rc.2`)
* Order matters: identity is checked FIRST, so a foreign `dsh` is rejected on the
* cheaper, more discriminating signal and never contributes a version number.
*
* `dsh` is a profile LAUNCHER, not an agent: `dsh --profile <name>` boots an
* ordered stack of plugin-bundle patch layers, and DeepSeek ships only `web`
* (browser UI), `headless` (one-shot) and `base` (no app). The interactive
* terminal agent Codeman actually drives is a THIRD-PARTY profile the user
* installs. That is why this module resolves two independent things — a binary
* AND a profile inventory — and why "available" for the deepseek run mode means
* both (`isDeepSeekRunnable`, and `resolveDeepSeekLaunchError` in session-routes.ts
* for the actionable per-half message).
*
* @module utils/deepseek-cli-resolver
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
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 `dsh` binary may be installed.
*
* `dsh` is an npm package (`@deepseek-ai/dsh`), so unlike grok there is no
* vendor-owned install dir to lead with: the global npm bin is wherever the
* user's prefix points. `~/.local/bin` heads the list because it is the default
* for a prefix-relocated npm (and is where this box's install landed).
*/
const DEEPSEEK_SEARCH_DIRS = [
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
/**
* A real `dsh --version` prints a bare `0.1.1-rc.2` (measured, 0.1.1-rc.2), so
* the prerelease suffix is part of the token — truncating it to `0.1.1` would
* misreport a release-candidate as a release in `codeman doctor`.
*
* Exported and SHARED with the `dsh` entry in `config/dependency-registry.ts`,
* so the doctor and the run mode cannot disagree about what counts as an
* installed dsh (the same single-source rule as PI_VERSION_REGEX /
* GROK_VERSION_REGEX). Shape is dictated by the doctor's `extractVersion()`
* (first capture group, whole-output scan): hence a capturing group and a
* leading boundary instead of `^`. No `g` flag, so there is no shared
* `lastIndex` to reset.
*/
export const DEEPSEEK_VERSION_REGEX = /(?:^|\s)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?)/;
/**
* The identity marker that separates DeepSeek's `dsh` from Debian's dancer's
* shell. The real launcher's `--help` banner reads:
*
* dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle …
*
* Matched case-insensitively against the help output. This is the check that
* makes the resolver safe to point a spawn line at; see the module header.
*/
export const DEEPSEEK_IDENTITY_REGEX = /DeepSeek\s+Harness/i;
const DEEPSEEK_NOT_FOUND = 'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh';
/** Where profiles live: `$DSH_HOME/profiles`, defaulting to `~/.dsh/profiles`. */
export function resolveDshHome(): string {
const fromEnv = process.env.DSH_HOME?.trim();
return fromEnv && fromEnv.length > 0 ? fromEnv : join(homedir(), '.dsh');
}
/**
* What a profile is FOR, inferred from the bundles it composes.
*
* `interactive` is the only kind a tmux pane can drive: `web` serves a browser
* UI and would occupy the pane with a logging server, `headless` answers one
* task and exits (which reads as an instantly-dead pane). `unknown` is treated
* as interactive-capable on purpose — the whole point of the harness is that
* anyone can publish an app bundle, so an unrecognized third-party profile must
* not be hidden from the picker just because this list has not heard of it.
*/
export type DeepSeekProfileKind = 'interactive' | 'web' | 'headless' | 'unknown';
export interface DeepSeekProfile {
/** Directory name under `$DSH_HOME/profiles`, i.e. the `--profile` argument. */
name: string;
/** Bundle package names composed by the profile, in order. */
bundles: string[];
kind: DeepSeekProfileKind;
}
/** Bundles that positively identify a non-interactive profile. */
const WEB_BUNDLE_PATTERN = /dsh-web-app|dsh-web-frontend/i;
const HEADLESS_BUNDLE_PATTERN = /dsh-headless/i;
/**
* Bundles that positively identify a terminal app. Intentionally a loose
* community-wide pattern rather than one blessed package: the terminal front
* door is third-party by construction (DeepSeek ships none), and a dozen
* scoped `dsh-tui` packages from a dozen different authors compete. Anything
* matching is a TUI; anything unmatched is `unknown`, which still counts as
* launchable.
*
* `tui` carries word boundaries so the loose arm stays a TOKEN match: `-` and
* `/` are non-word characters, so `@someone/tui-app` and `dsh-tui` both match
* while `intuition` and `gratuitous` do not. Being wrong here is cheap (an
* unmatched profile is `unknown`, which is launchable too) but it decides which
* profile a session boots by DEFAULT, and "the one whose name happens to contain
* t-u-i" is not a rule anyone could predict.
*/
const TUI_BUNDLE_PATTERN = /dsh-tui|dsh-terminal-app|\btui\b/i;
/**
* The profile names DeepSeek itself ships for its non-interactive surfaces.
*
* Consulted only AFTER the bundle patterns have found nothing, and only against
* the directory name. `readProfile()` yields an empty bundle list for any
* `package.json` without a `dsh.profile.bundles` array — a hand-edited file, an
* older layout, a profile mid-install — and with no bundles to read, the stock
* `web` and `headless` profiles look exactly like an unrecognized third-party
* one and inherit its launchable-by-default treatment. That is the single
* "unknown" that is knowably wrong, and it produces precisely the
* pane-dies-on-arrival failure the two-part availability gate exists to prevent.
*
* Deliberately a fallback rather than a first check: a third-party profile that
* legitimately composes a terminal app is identified by its BUNDLES, and its
* directory name (which the user chose) must never override that evidence.
*/
const STOCK_NON_INTERACTIVE_PROFILES = new Map<string, DeepSeekProfileKind>([
['web', 'web'],
['headless', 'headless'],
]);
/** Profile directory names that are not profiles. */
const NON_PROFILE_DIRS = new Set(['node_modules', '.bin', '.pnpm']);
function classifyProfile(name: string, bundles: string[]): DeepSeekProfileKind {
const haystack = [name, ...bundles].join(' ');
// Order matters: a profile that composes BOTH a web app and a tui bundle is a
// web profile as far as a tmux pane is concerned, because the web app owns the
// process and blocks.
if (WEB_BUNDLE_PATTERN.test(haystack)) return 'web';
if (HEADLESS_BUNDLE_PATTERN.test(haystack)) return 'headless';
if (TUI_BUNDLE_PATTERN.test(haystack)) return 'interactive';
return STOCK_NON_INTERACTIVE_PROFILES.get(name.toLowerCase()) ?? 'unknown';
}
/**
* Read a single profile directory's `package.json` and return its bundle list.
* Returns null for anything that is not a readable dsh profile, so a stray
* directory under `profiles/` cannot break the inventory.
*/
function readProfile(profilesDir: string, name: string): DeepSeekProfile | null {
try {
const raw = readFileSync(join(profilesDir, name, 'package.json'), 'utf-8');
const parsed = JSON.parse(raw) as { dsh?: { profile?: { bundles?: unknown } } };
const rawBundles = parsed?.dsh?.profile?.bundles;
const bundles = Array.isArray(rawBundles) ? rawBundles.filter((b): b is string => typeof b === 'string') : [];
return { name, bundles, kind: classifyProfile(name, bundles) };
} catch {
return null;
}
}
/**
* Inventory the profiles installed under `$DSH_HOME/profiles`.
*
* Never throws: a missing DSH_HOME (dsh installed but never run) is an empty
* list, which the callers render as "no profile yet" rather than an error.
* Deliberately un-cached — a user can create a profile at any moment (including
* through Codeman's own bootstrap), and the directory scan is cheap next to the
* two process spawns the binary probe already costs.
*/
export function listDeepSeekProfiles(): DeepSeekProfile[] {
const profilesDir = join(resolveDshHome(), 'profiles');
let entries: string[];
try {
entries = readdirSync(profilesDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && !NON_PROFILE_DIRS.has(e.name) && !e.name.startsWith('.'))
.map((e) => e.name);
} catch {
return [];
}
return entries
.map((name) => readProfile(profilesDir, name))
.filter((p): p is DeepSeekProfile => p !== null)
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* The profile a session should boot when the user picked none.
*
* Prefers a positively-identified terminal profile, then an unrecognized one
* (third-party by construction — see TUI_BUNDLE_PATTERN), and refuses to fall
* back to `web`/`headless`, which cannot drive a pane. Returns null when nothing
* launchable is installed, which is what makes the mode report unavailable
* instead of spawning a pane that dies on arrival.
*/
export function resolveDefaultDeepSeekProfile(profiles: DeepSeekProfile[] = listDeepSeekProfiles()): string | null {
return (
profiles.find((p) => p.kind === 'interactive')?.name ?? profiles.find((p) => p.kind === 'unknown')?.name ?? null
);
}
/** True when the profile can occupy a tmux pane as an interactive agent. */
export function isLaunchableProfile(profile: DeepSeekProfile): boolean {
return profile.kind === 'interactive' || profile.kind === 'unknown';
}
/**
* Run the two-stage identity+version probe on a candidate path.
*
* Returns the version token only when the binary proves it is the DeepSeek
* Harness launcher. Returns null for anything else: a missing binary, a
* non-zero exit, a hang (timeout), a help banner without the harness marker
* (this is the dancer's-shell rejection), or output with no version-shaped
* token.
*
* Never runs under vitest: the suites must stay hermetic and must not depend on
* whether the dev box happens to have dsh installed — and since `dsh` names a
* real Debian program, this probe would EXECUTE whatever binary of that name the
* machine carries. 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; tests drive resolution via `createDeepSeekResolverForTest`,
* whose injected probe bypasses it. Pinned by test/deepseek-cli-resolver.test.ts.
*/
function probeDeepSeekVersion(binPath: string): string | null {
if (process.env.VITEST) return null;
const run = (args: string[]): string | null => {
try {
return execFileSync(binPath, args, {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
// A stuck or hostile `dsh` that ignores SIGTERM would survive the timeout
// and block the server (execFileSync keeps waiting after the signal).
killSignal: 'SIGKILL',
}).trim();
} catch (err) {
console.warn(
`[DeepSeekResolver] Ignoring ${binPath}: "dsh ${args.join(' ')}" failed (${(err as Error).message})`
);
return null;
}
};
// Identity first — the discriminating signal, and the one that keeps Debian's
// dancer's shell out of a spawn line.
const help = run(['--help']);
if (help === null) return null;
if (!DEEPSEEK_IDENTITY_REGEX.test(help)) {
console.warn(
`[DeepSeekResolver] Ignoring ${binPath}: "dsh --help" is not the DeepSeek Harness launcher ` +
`(printed ${JSON.stringify(help.slice(0, 80))}). A different program named "dsh" (e.g. Debian's ` +
`dancer's shell) is earlier on PATH.`
);
return null;
}
const out = run(['--version']);
if (out === null) return null;
const candidate = DEEPSEEK_VERSION_REGEX.exec(out)?.[1];
if (candidate) return candidate;
console.warn(`[DeepSeekResolver] Ignoring ${binPath}: "dsh --version" printed ${JSON.stringify(out.slice(0, 80))}`);
return null;
}
type DeepSeekVersionProbe = (binPath: string) => string | null;
function createDeepSeekResolver(
host?: CliResolverHost,
versionProbe: DeepSeekVersionProbe = probeDeepSeekVersion,
now?: () => number
) {
return createCliExecutableResolver<string>(
{
binary: 'dsh',
searchDirs: DEEPSEEK_SEARCH_DIRS,
validateCandidate: (binPath) => {
const version = versionProbe(binPath);
return version ? { accepted: true, metadata: version } : { accepted: false };
},
now,
},
host
);
}
/**
* Creates an isolated DeepSeek 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 createDeepSeekResolverForTest(
host: CliResolverHost,
versionProbe?: DeepSeekVersionProbe,
now?: () => number
) {
return createDeepSeekResolver(host, versionProbe ?? probeDeepSeekVersion, now);
}
const deepSeekResolver = createDeepSeekResolver();
/**
* Finds the directory containing a verified `dsh` binary.
* Checks the server PATH first, then the common install locations. Every
* candidate must pass the identity+version probe before it is accepted.
*
* @returns Directory path, or null if not found
*/
export function resolveDeepSeekDir(): string | null {
return deepSeekResolver.resolve()?.directory ?? null;
}
/**
* Whether the `dsh` BINARY is installed. Note this is deliberately weaker than
* what the run mode needs: a dsh with no launchable profile cannot start a
* session. Callers gating the Run button want `isDeepSeekRunnable()`.
*/
export function isDeepSeekAvailable(): boolean {
return resolveDeepSeekDir() !== null;
}
/** Binary present AND at least one profile that can occupy a pane. */
export function isDeepSeekRunnable(): boolean {
return isDeepSeekAvailable() && resolveDefaultDeepSeekProfile() !== null;
}
export function getDeepSeekNotFoundMessage(): string {
return formatCliNotFoundMessage(DEEPSEEK_NOT_FOUND, deepSeekResolver.diagnostics());
}
/**
* Version reported by the resolved `dsh` binary, or null when dsh is
* unavailable. Surfaced through `GET /api/deepseek/status` so a misresolution
* is diagnosable from the UI.
*/
export function getDeepSeekCliVersion(): string | null {
return deepSeekResolver.resolve()?.metadata ?? null;
}
/** Does the named profile exist and can it drive a pane? */
export function profileExists(name: string): boolean {
return existsSync(join(resolveDshHome(), 'profiles', name, 'package.json'));
}
/**
* Why a DeepSeek session cannot start, or null when it can.
*
* Availability for this mode is TWO questions, not one, because `dsh` is a
* profile launcher rather than an agent: the binary must resolve (and prove it
* is the harness and not Debian's dancer's shell), AND a profile that can occupy
* a pane must exist. Every create path — both HTTP routes AND cron fires — must
* ask this before constructing a Session, or the pane boots the box's default
* profile, which may be a logging web server or a one-shot that exits on
* arrival, and the prompt is typed into it.
*/
export function resolveDeepSeekLaunchError(requestedProfile?: string): string | null {
if (!isDeepSeekAvailable()) return getDeepSeekNotFoundMessage();
const profiles = listDeepSeekProfiles();
if (requestedProfile) {
const match = profiles.find((p) => p.name === requestedProfile);
if (!match) {
return `DeepSeek Harness profile "${requestedProfile}" does not exist. Create it with: dsh plugin --profile ${requestedProfile} add <package>`;
}
if (match.kind === 'web' || match.kind === 'headless') {
return `DeepSeek Harness profile "${requestedProfile}" is a ${match.kind} profile and cannot run in a terminal session. Pick an interactive profile, or open the web profile as a Codeman web tab.`;
}
return null;
}
if (!resolveDefaultDeepSeekProfile(profiles)) {
return (
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only the web and headless ' +
'profiles, so the terminal agent comes from a plugin — install one with: ' +
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
);
}
return null;
}
+21 -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,
@@ -46,5 +52,19 @@ export {
} from './antigravity-cli-resolver.js';
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
export { resolveGrokDir, isGrokAvailable, getGrokCliVersion, getGrokNotFoundMessage } from './grok-cli-resolver.js';
export {
resolveDeepSeekDir,
isDeepSeekAvailable,
isDeepSeekRunnable,
getDeepSeekCliVersion,
getDeepSeekNotFoundMessage,
listDeepSeekProfiles,
resolveDefaultDeepSeekProfile,
isLaunchableProfile,
resolveDshHome,
profileExists,
} from './deepseek-cli-resolver.js';
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 {
+170 -55
View File
@@ -240,6 +240,7 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.HOOK_ELICITATION_COMPLETE, '_onHookElicitationComplete'],
[SSE_EVENTS.HOOK_ELICITATION_RESPONSE, '_onHookElicitationResponse'],
[SSE_EVENTS.HOOK_STOP, '_onHookStop'],
[SSE_EVENTS.HOOK_AGENT_WORKING, '_onHookAgentWorking'],
[SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'],
[SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'],
@@ -678,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 = '';
@@ -1605,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) {
@@ -1852,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);
}
@@ -1871,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(() => {
@@ -2252,9 +2268,13 @@ class CodemanApp {
? 'Pi'
: mode === 'grok'
? 'Grok'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
: mode === 'deepseek'
? 'DeepSeek'
: mode === 'omp'
? 'OMP'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
}
async toggleResponseViewer() {
@@ -2354,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).
@@ -2410,6 +2435,8 @@ class CodemanApp {
}
} catch (err) {
console.error('needsRefresh reload failed:', err);
} finally {
if (this._terminalRefreshOwner === refreshOwner) this._terminalRefreshOwner = null;
}
}
@@ -2572,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 /
@@ -2600,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
@@ -2616,12 +2640,28 @@ class CodemanApp {
if (!Number.isFinite(n)) return '';
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
};
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) => {
const windows = [seg('5h', pct(usage?.fiveHour)), seg('7d', pct(usage?.sevenDay))].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), row('Codex', data.codex)].filter(Boolean);
chip.innerHTML = rows.length ? rows.join('') : '—';
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
chip.title =
`Claude plan usage\n` +
`5-hour limit: ${five ?? '—'}% used (resets ${resetStr(data.fiveHour)})\n` +
`Weekly limit: ${seven ?? '—'}% used (resets ${resetStr(data.sevenDay)})`;
const details = (provider, usage) => {
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)})`);
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 = [details('Claude', data), details('Codex', data.codex)].filter(Boolean).join('\n\n') || 'Plan usage limits';
}
// Scheduled runs
@@ -3463,11 +3503,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
@@ -3826,6 +3876,38 @@ class CodemanApp {
return root.dataset.sessionList === 'sidebar' && root.dataset.sidebarDetail === 'rich';
}
/**
* True when the VERTICAL TAB RAIL (tabOrientation 'vertical') is showing the
* detailed rows: the same "created 3d ago · working 12m" line and status pill
* the rich sidebar and both home screens carry.
*
* A docked column is not a tab strip — that was the argument for the rich
* sidebar, and the rail is a docked column too, so it defaults to rich and
* `tabRailDetail: 'simple'` is the opt-out.
*
* The compact carve-out is not cosmetic: below 240px the rail already drops
* the row actions to a hover affordance, and three lines of stamps in a
* ~208px column ellipsize into noise. `_setTabRailWidth()` re-renders the
* tabs whenever that class flips, so this gate is re-read at the right moment.
*/
isTabRailRich() {
const root = document.documentElement;
return (
root.getAttribute('data-tab-orientation') === 'vertical' &&
root.dataset.tabRailDetail === 'rich' &&
!root.classList.contains('tab-rail-compact')
);
}
/**
* The one gate the render paths ask: does THIS list draw detailed rows?
* Either vertical surface can, and neither can be on at once (the sidebar
* owns the tabs whenever it is active, which forces the rail off).
*/
isRichTabRows() {
return this.isSessionSidebarRich() || this.isTabRailRich();
}
/**
* True where the sidebar is a MODAL off-canvas drawer over the terminal
* instead of a docked column.
@@ -3999,7 +4081,7 @@ class CodemanApp {
this.showHomeSessions?.();
}
// Only the rich rows carry stamps that go stale with no event behind them.
if (this.isSessionSidebarRich()) this._startSidebarRichClock();
if (this.isRichTabRows()) this._startSidebarRichClock();
else this._stopSidebarRichClock();
}
@@ -4116,13 +4198,14 @@ class CodemanApp {
}
/**
* The per-row model for a rich sidebar row: which state the session is in,
* when it was first created, and how long it has been in that state.
* The per-row model for a rich row (detailed sidebar or vertical tab rail):
* which state the session is in, when it was first created, and how long it
* has been in that state.
*
* Classification is `_mobileOverviewState()` and the state duration is
* `_mobileOverviewSince()` (both mobile-overview.js), NOT re-derived here —
* the sidebar, the desktop home rail and the phone overview must never
* disagree about what "working" means or about which stamp measures it.
* the sidebar, the rail, the desktop home rail and the phone overview must
* never disagree about what "working" means or about which stamp measures it.
*
* Guarded like every other cross-file consumer in this app: a stale cached
* mobile-overview.js must degrade to a row with no meta line, not throw and
@@ -4169,7 +4252,21 @@ class CodemanApp {
parts.push(stamp(row.since.key, row.since.at, 'for', 'tab-meta-since'));
}
parts.push(`<span class="tab-pill tab-pill--${escapeHtml(row.state)}">${escapeHtml(row.pill)}</span>`);
return `<span class="tab-meta" data-i18n-skip>${parts.join('')}</span>`;
// Both absolute stamps ALSO on the line itself, not only on the two items.
// Below 288px the rail hides `.tab-meta-created` (the `tab-rail-tight`
// rule), and a tooltip on a `display: none` element has no hover target —
// so without this the created stamp is not merely shrunk, it is gone with
// no way to ask for it. The pill and the gaps around the stamps are the
// hover targets that remain; an item's own title still wins over this one
// where the item is visible.
const lineTitle = [
row.createdAt ? `First created: ${new Date(row.createdAt).toLocaleString()}` : '',
row.since && row.since.at ? `${row.since.key}: ${new Date(row.since.at).toLocaleString()}` : '',
]
.filter(Boolean)
.join(' \u00B7 ');
const lineTitleAttr = lineTitle ? ` title="${escapeHtml(lineTitle)}"` : '';
return `<span class="tab-meta"${lineTitleAttr} data-i18n-skip>${parts.join('')}</span>`;
}
/** Same formatter as both home screens, so a duration is written the same way everywhere. */
@@ -4214,7 +4311,7 @@ class CodemanApp {
_startSidebarRichClock() {
if (this._sidebarRichClock) return;
this._sidebarRichClock = setInterval(() => {
if (!this.isSessionSidebarRich()) {
if (!this.isRichTabRows()) {
this._stopSidebarRichClock();
return;
}
@@ -4431,7 +4528,7 @@ class CodemanApp {
if (canIncremental) {
// Read once for the whole pass, like the full-rebuild path: this touches
// the DOM and the loop below runs for every session on every SSE tick.
const richRows = this.isSessionSidebarRich();
const richRows = this.isRichTabRows();
// Incremental update - only modify changed properties
for (const [id, session] of this.sessions) {
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
@@ -4740,9 +4837,9 @@ class CodemanApp {
// into view replaces it.
const parts = [];
const tabOrder = this.sessionOrder;
// Read once, not per session: isSessionSidebarRich() touches the DOM and
// Read once, not per session: isRichTabRows() touches the DOM and
// this loop runs for every tab on every full rebuild.
const richRows = this.isSessionSidebarRich();
const richRows = this.isRichTabRows();
let _tabIdx = 0;
for (const id of tabOrder) {
const session = this.sessions.get(id);
@@ -4786,9 +4883,10 @@ class CodemanApp {
? (session.workingDir ? `${parsedName.prefix} (${session.workingDir})` : parsedName.prefix)
: (session.workingDir || '');
// Rich sidebar rows only: the home screen's created/state stamps and a
// status pill. richRow is null in every other layout, and both helpers
// below collapse to '' — the header strip's markup is unchanged.
// Rich rows only (the detailed sidebar OR the vertical tab rail): the home
// screen's created/state stamps and a status pill. richRow is null in every
// other layout, and both helpers below collapse to '' — the header strip's
// markup is unchanged.
const richRow = richRows ? this._sidebarRichRow(id, session) : null;
const richMeta = this._sidebarRichMetaHTML(richRow);
const richClass = richRow ? ` tab-state-${richRow.state}` : '';
@@ -4805,7 +4903,7 @@ class CodemanApp {
<span class="tab-status ${status}" aria-hidden="true"></span>
<span class="tab-info">
<span class="tab-name-row">
${mode === 'shell' ? '<span class="tab-mode shell" aria-hidden="true">sh</span>' : mode === 'opencode' ? '<span class="tab-mode opencode" aria-hidden="true">oc</span>' : mode === 'codex' ? '<span class="tab-mode codex" aria-hidden="true">cx</span>' : mode === 'gemini' ? '<span class="tab-mode gemini" aria-hidden="true">gm</span>' : mode === 'antigravity' ? '<span class="tab-mode antigravity" aria-hidden="true">ag</span>' : mode === 'pi' ? '<span class="tab-mode pi" aria-hidden="true">pi</span>' : mode === 'grok' ? '<span class="tab-mode grok" aria-hidden="true">gk</span>' : ''}
${mode === '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>
@@ -5283,11 +5381,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;
@@ -5601,8 +5708,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;
}
@@ -5633,6 +5743,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).
@@ -6238,7 +6349,11 @@ class CodemanApp {
? 'Kill Tmux & Pi'
: session.mode === 'grok'
? 'Kill Tmux & Grok'
: 'Kill Tmux & Claude Code';
: session.mode === 'deepseek'
? 'Kill Tmux & DeepSeek'
: session.mode === 'omp'
? 'Kill Tmux & OMP'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
+8 -1
View File
@@ -164,6 +164,9 @@ function resolveTabOrientation(input) {
const TAB_RAIL_MIN_WIDTH = 208;
const TAB_RAIL_DEFAULT_WIDTH = 256;
/** Detailed rows carry a third line, and it ellipsizes at 256px — see the
rich sidebar's own 300px column. 320px is the existing Wide preset. */
const TAB_RAIL_RICH_DEFAULT_WIDTH = 320;
const TAB_RAIL_MAX_WIDTH = 360;
function resolveTabRailWidth(input = {}) {
@@ -185,7 +188,9 @@ function resolveTabRailKeyboardWidth(input = {}) {
let width;
if (input.key === 'Home') width = TAB_RAIL_MIN_WIDTH;
else if (input.key === 'End') width = TAB_RAIL_MAX_WIDTH;
else if (input.key === 'Enter') width = TAB_RAIL_DEFAULT_WIDTH;
// Enter resets to the caller's effective default (the rich rail's is the
// Wide preset, not 256 — see _defaultTabRailWidth); absent, the base default.
else if (input.key === 'Enter') width = Number(input.defaultWidth) || TAB_RAIL_DEFAULT_WIDTH;
else if (input.key === 'ArrowLeft' || input.key === 'ArrowRight') {
const direction = input.key === 'ArrowLeft' ? -1 : 1;
width = (Number(input.currentWidth) || TAB_RAIL_DEFAULT_WIDTH) + direction * (input.shiftKey ? 32 : 8);
@@ -686,6 +691,7 @@ if (typeof window !== 'undefined') {
};
window.CodemanTabRail = {
DEFAULT_WIDTH: TAB_RAIL_DEFAULT_WIDTH,
RICH_DEFAULT_WIDTH: TAB_RAIL_RICH_DEFAULT_WIDTH,
MIN_WIDTH: TAB_RAIL_MIN_WIDTH,
MAX_WIDTH: TAB_RAIL_MAX_WIDTH,
resolveWidth: resolveTabRailWidth,
@@ -949,6 +955,7 @@ const SSE_EVENTS = {
HOOK_ELICITATION_COMPLETE: 'hook:elicitation_complete',
HOOK_ELICITATION_RESPONSE: 'hook:elicitation_response',
HOOK_STOP: 'hook:stop',
HOOK_AGENT_WORKING: 'hook:agent_working',
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
HOOK_TASK_COMPLETED: 'hook:task_completed',
+2
View File
@@ -79,6 +79,8 @@ const HOME_SESSIONS_MODE_BADGE = {
antigravity: 'ag',
pi: 'pi',
grok: 'gk',
deepseek: 'ds',
omp: 'om',
};
Object.assign(CodemanApp.prototype, {
+1
View File
@@ -110,6 +110,7 @@
'Run Antigravity': '运行 Antigravity',
'Run Pi': '运行 Pi',
'Run Grok': '运行 Grok',
'Run DeepSeek': '运行 DeepSeek',
'Run Shell': '运行 Shell',
'Select AI backend': '选择 AI 后端',
'Create New Case': '新建案例',
+45 -3
View File
@@ -65,7 +65,7 @@
app.js, NOT the handheld storage-key test `m`. Use a different predicate
here and boot will contradict this value, animating the drawer open by
itself on every load between 768 and 1023px. -->
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';var W=Number(A.tabRailWidth);if(V&&Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';}</script>
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';}</script>
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
<style>
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
@@ -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>
@@ -448,6 +448,14 @@
<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 Grok
</button>
<button class="welcome-btn welcome-btn-deepseek" id="welcomeDeepSeekBtn" style="display: none;" onclick="app.setRunMode('deepseek'); app.runDeepSeek()">
<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>
@@ -630,6 +638,18 @@
<button class="run-mode-option" data-mode="grok" onclick="app.setRunMode('grok')">
<span class="run-mode-dot grok"></span>Grok
</button>
<button class="run-mode-option" data-mode="deepseek" onclick="app.setRunMode('deepseek')">
<span class="run-mode-dot deepseek"></span>DeepSeek
</button>
<!-- Shown only when `dsh` is installed but no pane-capable profile is:
DeepSeek ships no terminal front door, so the fix is an install,
not a greyed-out entry the user cannot act on. -->
<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
@@ -642,6 +662,14 @@
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
<span class="run-mode-dot web"></span>Add URL&hellip;
</button>
<!-- The DeepSeek Harness browser UI is the vendor's OWN interactive
surface (the terminal one is third-party), so it gets a shortcut:
POST /api/deepseek/web starts a background `dsh web` fenced to
this origin, and the URL opens as a managed web tab.
Shown only when dsh is installed. -->
<button class="run-mode-option run-mode-option--web" id="runModeDeepSeekWeb" style="display: none;" onclick="app.runDeepSeekWeb()">
<span class="run-mode-dot deepseek"></span>DeepSeek web UI&hellip;
</button>
<div class="run-mode-sep"></div>
<div class="run-mode-header">Recent Sessions</div>
<div class="run-mode-history" id="runModeHistory"></div>
@@ -908,6 +936,8 @@
<option value="antigravity">Antigravity</option>
<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>
@@ -1903,6 +1933,16 @@
<option value="vertical">Vertical (side rail)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab rail detail rows created working idle status pill simple">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Rows</span>
<span class="set-row-desc">Detailed rows carry the home screen's per-session line (created, how long it has been working or idle) and a status pill. Needs ~280px of rail; a rail narrower than 240px drops back to simple rows.</span>
</div>
<select id="appSettingsTabRailDetail" class="set-select">
<option value="rich">Detailed</option>
<option value="simple">Simple (name only)</option>
</select>
</div>
<div class="set-row has-field" data-search="tab rail width resize compact wide maximum">
<div class="set-row-text">
<span class="set-row-label">Vertical Rail Width</span>
@@ -2676,6 +2716,8 @@
<option value="antigravity" data-cli="antigravity">Antigravity</option>
<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>
@@ -2816,7 +2858,7 @@
<div class="form-row">
<label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok + tmux.</span>
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/codex/gemini/opencode/agy/pi/grok/dsh + tmux.</span>
</div>
<div class="form-row">
<label>Network</label>
+8 -1
View File
@@ -55,6 +55,8 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'antigravity', label: 'Antigravity', short: 'Antigravity' },
{ 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' },
];
@@ -387,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);
},
// ═══════════════════════════════════════════════════════════════
@@ -580,6 +582,11 @@ Object.assign(CodemanApp.prototype, {
menu.appendChild(header);
for (const webview of this.webviews ? this.webviews.values() : []) {
// Managed records are Codeman-owned shortcut state (the DeepSeek web UI
// writes one), not saved dashboards: same filter as the desktop run menu,
// or the phone picker lists a stale 127.0.0.1:<port> row that dies on the
// next server restart with no affordance here to restart it.
if (webview.managed) continue;
const option = document.createElement('button');
option.type = 'button';
option.className = 'mobile-overview-run-option';
+43
View File
@@ -986,6 +986,37 @@ html.mobile-init .file-browser-panel {
border-color: rgba(212, 212, 216, 0.5) !important;
}
/* DeepSeek mode colors on mobile. Same `!important` rationale as the pi and
grok blocks above: styles.css nests its skin rules inside
`html:not([data-skin="og"])`, so a bare `.btn-toolbar` rule there outranks a
`.btn-toolbar.btn-x` rule here regardless of load order. */
.btn-toolbar.btn-run.mode-deepseek,
.btn-toolbar.btn-run-gear.mode-deepseek {
background: #16225f !important;
border-color: rgba(124, 147, 255, 0.35) !important;
color: #eef2ff !important;
}
.btn-toolbar.btn-run.mode-deepseek:active,
.btn-toolbar.btn-run-gear.mode-deepseek:active {
background: #3350e6 !important;
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%;
@@ -3069,12 +3100,24 @@ 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;
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-deepseek, .btn-toolbar.btn-run-gear.mode-deepseek) {
background: linear-gradient(135deg, #2740c4, #4d6bfe);
border-color: #1b2a8f;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
border-left-color: var(--control-border-hover) !important;
}
+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' };
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
+287 -9
View File
@@ -1,5 +1,5 @@
/**
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok),
* @fileoverview Quick start (case loading, session spawning for Claude/Shell/OpenCode/Codex/Gemini/Antigravity/Pi/Grok/DeepSeek),
* session options modal (per-session settings, color picker, rename),
* session options tabs (Ralph config tab), case settings (CRUD, links),
* create case modal, and mobile case picker.
@@ -400,12 +400,18 @@ 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();
}
if (mode === 'grok') {
return await this.runGrok();
}
if (mode === 'deepseek') {
return await this.runDeepSeek();
}
if (mode === 'shell') {
return await this.runShell();
}
@@ -471,10 +477,149 @@ 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']) {
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';
}
// DeepSeek is the one mode whose availability has two halves: `dsh` can be
// perfectly installed while no pane-capable profile exists, because DeepSeek
// ships no terminal front door. In that state the honest offer is "add one",
// not a hidden entry with no explanation anywhere.
const avail = window.__codemanCliAvailable || {};
const dsInstall = menu.querySelector('#runModeDeepSeekInstall');
if (dsInstall) {
dsInstall.style.display = !avail.deepseek && avail.deepseekBinary ? 'flex' : 'none';
}
// The web UI needs only the BINARY: it is the one interactive surface
// DeepSeek ships itself, so it works on a box with no terminal profile at
// all (and is the honest thing to offer there).
const dsWeb = menu.querySelector('#runModeDeepSeekWeb');
if (dsWeb) dsWeb.style.display = avail.deepseekBinary ? 'flex' : 'none';
},
/**
* Start the DeepSeek Harness browser UI and open it as a Codeman web tab.
*
* The server is a background child process owned by
* `deepseek-web-server.ts`, NOT a shell session. It was a shell session first,
* on the reasoning that Codeman already supervises those, and that version
* worked - it just put a terminal tab on screen beside the web tab the user
* actually asked for, on every click. Opening a dashboard should open one tab.
*
* `--trusted-host` is the load-bearing flag: dsh fences its `/api` behind a
* browser-trust check on the request authority, and a Codeman web tab reaches
* it through Codeman's own origin via the webview proxy, not directly. Without
* passing Codeman's authority the page renders and every API call fails.
*
* The tab is saved `trusted: true`, and that is REQUIRED rather than a
* convenience: an untrusted webview is sandboxed without `allow-same-origin`,
* which breaks this dashboard twice over. The dsh client-runtime reads
* `localStorage` while loading its plugins and dies there ("the document is
* sandboxed and lacks the 'allow-same-origin' flag"), and an opaque-origin
* frame sends `Origin: null`, so dsh's own trust check 403s every `/api` call
* no matter which authority `--trusted-host` names. Passing `location.host`
* only means anything once the frame actually carries that origin.
*
* The trade this makes is real and worth stating: a trusted proxied frame is
* same-origin with Codeman and can therefore reach Codeman's own API. It is
* defensible only because of what this specific dashboard already is - an
* agent harness Codeman just started itself, on loopback, which can run code
* as the user regardless. It is not a precedent for trusting third-party
* dashboards generally, which is why it is set here rather than defaulted.
*/
async runDeepSeekWeb() {
document.getElementById('runModeMenu')?.classList.remove('active');
const ownsLaunchTerminal = this._beginSessionLaunchStatus('Starting the DeepSeek web UI...');
try {
// One request, and the server owns everything behind it: picking a free
// port, spawning, waiting for the port to answer, and reusing an already
// running server instead of racing it. This used to start the server in a
// shell SESSION, which worked but put a terminal tab on screen next to the
// web tab actually asked for, every single time.
//
// `authority` is what dsh fences its own `/api` behind (`--trusted-host`),
// so it must be the origin this page is loaded from rather than anything
// the server could guess: a Codeman reachable at both loopback and a
// tailnet name has two, and only the browser knows which one is in play.
const startRes = await fetch('/api/deepseek/web', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authority: location.host }),
});
const startData = await startRes.json();
if (!startData.success) throw new Error(startData.error || 'Failed to start the DeepSeek web UI');
const url = startData.data.url;
// One managed record, repointed rather than duplicated: the port is chosen
// per launch, so creating a fresh row each time would stack a dashboard
// per restart, each pointing at a port nothing serves any more.
let webview = [...(this.webviews?.values() || [])].find((w) => w.managed === 'deepseek-web');
if (webview) {
const patchRes = await fetch(`/api/webviews/${webview.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, trusted: true }),
});
const patchData = await patchRes.json();
if (!patchData.success) throw new Error(patchData.error || 'Failed to update the web tab');
webview = patchData.data.webview || patchData.data;
} else {
const wvRes = await fetch('/api/webviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'DeepSeek Harness',
url,
icon: '\u{1F433}',
managed: 'deepseek-web',
trusted: true,
}),
});
const wvData = await wvRes.json();
if (!wvData.success) throw new Error(wvData.error || 'Failed to save the web tab');
webview = wvData.data.webview || wvData.data;
}
// refreshWebviews, not a hopeful optional-chain: openWebview() reads
// this.webviews and silently no-ops on an id it has not loaded, so
// skipping the refresh made the FIRST click create the record but open
// nothing (the SSE round-trip had not landed yet).
await this.refreshWebviews?.();
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Serving on ${url} - opening it as a tab.`);
if (webview?.id) await this.openWebview(webview.id);
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
/**
* Install a DeepSeek Harness terminal profile from the run menu.
*
* Held open for as long as the package manager takes (the endpoint bounds it),
* so the button reports progress rather than appearing to do nothing. On
* success the availability map is patched in place, which is what makes the
* real DeepSeek entry appear without a reload.
*/
async installDeepSeekProfile() {
const label = 'Installing a DeepSeek terminal profile (this can take a minute)...';
const ownsLaunchTerminal = this._beginSessionLaunchStatus(label);
try {
const res = await fetch('/api/deepseek/install-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to install the profile');
window.__codemanCliAvailable = { ...(window.__codemanCliAvailable || {}), deepseek: !!data.data.runnable };
this._appendSessionLaunchStatus(ownsLaunchTerminal, `Installed ${data.data.package} into profile "${data.data.profile}".`);
this.showToast?.(`DeepSeek profile "${data.data.profile}" installed`, 'success');
const menu = document.getElementById('runModeMenu');
if (menu) this._refreshRunModeAvailability(menu);
} catch (err) {
this._reportSessionLaunchError(ownsLaunchTerminal, err.message);
}
},
async _loadRunModeHistory() {
@@ -547,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);
}
@@ -568,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 === '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';
}
},
@@ -1281,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.
*
@@ -1341,6 +1536,84 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Launch a DeepSeek Harness (`dsh`) session.
*
* Sends `permissionMode: 'danger-full-access'` for the same reason every
* sibling Run button sends its bypass switch: Codeman sessions exist for
* autonomous work. The harness has no bypass FLAG, so this rides the
* `DSH_PERMISSION_MODE` export instead, and the multi-user clamp forces it
* back down to `workspace-write` for non-granted owners server-side.
*
* `statusReporting` is left unset, i.e. ON: it is what upgrades this mode from
* output-stabilization guessing to definitive idle/blocked hook events.
*
* The two-part availability check is deliberate. `dsh` being installed is not
* enough — DeepSeek ships no terminal front door, so a box can have a perfect
* binary and nothing a pane can run. Reporting that precisely, with the exact
* command that fixes it, is the difference between "the Run button is broken"
* and a 30-second fix.
*/
async runDeepSeek() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote/docker cases run dsh on the OTHER side: skip the local status probe and the
// local-only config/env 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 DeepSeek session in ${caseName}...`);
this.terminal.focus();
try {
if (!isRemote) {
const statusRes = await fetch('/api/deepseek/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'DeepSeek Harness CLI (dsh) not found. Install with: npm install -g @deepseek-ai/dsh'
);
return;
}
if (!status.runnable) {
this._reportSessionLaunchError(
ownsLaunchTerminal,
'No interactive DeepSeek Harness profile is installed. DeepSeek ships only web and headless ' +
'profiles, so the terminal agent comes from a plugin. Install one from the Run menu, or run: ' +
'dsh plugin --profile dsh-tui add @deepseek-harness-tui/dsh-tui'
);
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: 'deepseek',
sessionName: `w${this._nextCaseSessionStartNumber(caseName)}-${caseName}`,
...(isRemote ? {} : {
deepSeekConfig: { permissionMode: 'danger-full-access' },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start DeepSeek');
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);
}
},
// ═══════════════════════════════════════════════════════════════
// Session Options Modal
@@ -1406,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';
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
@@ -1436,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';
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' : ''; });
@@ -1976,8 +2249,13 @@ Object.assign(CodemanApp.prototype, {
e.preventDefault();
input.blur();
} else if (e.key === 'Escape') {
input.value = '';
input.blur();
// Cancel, never commit. This used to clear the field and blur, and the
// blur handler commits — so Escape RENAMED the session to an empty
// string (measured: the tab fell back to its folder name and the server
// stored ""), in every layout. cancelRename() marks the edit
// invalidated, so the blur that follows the input's removal is a no-op.
e.preventDefault();
cancelRename();
}
});
},
@@ -3217,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 === 'claude'
mode === 'opencode' || mode === 'codex' || mode === 'gemini' || mode === 'antigravity' || mode === 'pi' || mode === 'grok' || mode === 'deepseek' || mode === 'omp' || mode === 'claude'
? mode
: 'claude';
},
+77 -6
View File
@@ -67,6 +67,19 @@ Object.assign(CodemanApp.prototype, {
this._notifySession(data.sessionId, 'info', 'hook-stop', 'Response Complete', data.reason || 'Claude has finished responding');
},
_onHookAgentWorking(data) {
// The agent started a turn, so whatever it was blocked on is gone. Reported
// by the DeepSeek status bridge; a harness turn cannot run while one of its
// own modal approvals is on screen, so this means the dialog was answered in
// the terminal. Same clearing as _onHookElicitationComplete, and notably NOT
// a notification: a turn STARTING is not news.
if (data.sessionId) {
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
this.clearPendingHooks(data.sessionId, 'permission_prompt');
this.clearPendingHooks(data.sessionId, 'idle_prompt');
}
},
_onHookTeammateIdle(data) {
const session = this.sessions.get(data.sessionId);
this._notifySession(data.sessionId, 'warning', 'hook-teammate-idle', 'Teammate Idle', `A teammate is idle in ${session?.name || data.sessionId}`);
@@ -403,9 +416,18 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTabOrientation').value =
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
width: settings.tabRailWidth ?? defaults.tabRailWidth ?? 256,
// Same default resolution as applyTabRailWidth(): a rail that has never
// been sized shows the width it is actually rendering at, which for
// detailed rows is the Wide preset rather than 256. The rich-aware
// default must come BEFORE the per-device defaults blob: the handheld
// blob carries tabRailWidth: 256, which applyTabRailWidth() never reads,
// so consulting it first showed a tablet's unsized rich rail as 256 while
// it rendered at 320 — and a routine Save then PERSISTED the 256.
width: settings.tabRailWidth ?? this._defaultTabRailWidth?.() ?? defaults.tabRailWidth ?? 256,
}) ?? 256;
this.syncTabRailWidthSetting?.(tabRailWidth);
document.getElementById('appSettingsTabRailDetail').value =
settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich';
document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false;
document.getElementById('appSettingsSessionListLayout').value =
settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header';
@@ -1208,9 +1230,11 @@ Object.assign(CodemanApp.prototype, {
['welcomeClaudeBtn', 'claude'],
['welcomeOpencodeBtn', 'opencode'],
['welcomeAntigravityBtn', 'antigravity'],
['welcomeOmpBtn', 'omp'],
['welcomeGeminiBtn', 'gemini'],
['welcomePiBtn', 'pi'],
['welcomeGrokBtn', 'grok'],
['welcomeDeepSeekBtn', 'deepseek'],
// Not a run mode, same reasoning: offering a Cloudflare Tunnel on a box
// without cloudflared can only ever produce "cloudflared not found".
['welcomeTunnelBtn', 'cloudflared'],
@@ -2041,6 +2065,7 @@ Object.assign(CodemanApp.prototype, {
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
showTabDetachButton: document.getElementById('appSettingsShowTabDetachButton').checked,
sessionListLayout: document.getElementById('appSettingsSessionListLayout').value,
sessionSidebarFontSize: this.resolveSessionSidebarFontSize(
@@ -2443,6 +2468,7 @@ Object.assign(CodemanApp.prototype, {
tabTwoRows: false,
tabOrientation: 'horizontal',
tabRailWidth: 256,
tabRailDetail: 'rich',
sessionListLayout: 'header',
sessionSidebarFontSize: 12,
cjkInputEnabled: false,
@@ -2701,6 +2727,14 @@ Object.assign(CodemanApp.prototype, {
const previous = root.getAttribute('data-tab-orientation') || 'horizontal';
root.setAttribute('data-tab-orientation', orientation);
// Row detail rides on its OWN attribute, exactly like the sidebar's
// data-sidebar-detail: every html[data-tab-orientation='vertical'] rule in
// styles.css keeps matching both variants untouched, and the gate in app.js
// reads one attribute instead of re-parsing localStorage per tab.
const previousDetail = root.dataset.tabRailDetail || 'rich';
const detail = (settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich') === 'simple' ? 'simple' : 'rich';
root.dataset.tabRailDetail = detail;
const tabsEl = document.getElementById('sessionTabs');
const rail = document.getElementById('tabRail');
const headerHost = document.getElementById('sessionTabsHost');
@@ -2718,13 +2752,43 @@ Object.assign(CodemanApp.prototype, {
const settleRailWidth =
options.settleRailWidth === true && (orientation === 'vertical' || previous !== orientation);
this.applyTabRailWidth?.({ settle: settleRailWidth });
if (previous !== orientation) {
const orientationChanged = previous !== orientation;
// A detail flip counts as a change on its own: simple ⟷ detailed leaves the
// orientation on 'vertical' both times, and the stamps line is emitted by
// the row template, not toggled by CSS — same reasoning as the sidebar's
// detail half in applySessionListLayout(). Taller rows also move every
// connector anchored to a tab rect.
const changed = orientationChanged || previousDetail !== detail;
if (orientationChanged) {
this.updateTabOverflowMode?.();
if (!settleRailWidth) this.fitAddon?.fit();
this._fullRenderSessionTabs?.();
}
// applyTabWrapSettings() is the ONE owner of tabs-show-folder and is
// rail-aware, so it has to run AFTER the two attributes above — the
// applySessionListLayout() call that precedes this one on the settings-save
// path ran while data-tab-rail-detail still held the old value. It
// re-renders by itself when the folder row appears or disappears, which is
// why the render below is skipped in that case rather than doubled.
const prevTall = this._tallTabsEnabled;
if (changed) this.applyTabWrapSettings?.();
if (changed) {
// Mirror of applyTabWrapSettings()'s OWN render condition, which is
// `prevTallTabs !== undefined && prevTallTabs !== showFolder`: its first
// call ever only establishes the baseline and deliberately renders
// nothing. Reading an undefined previous value as "it rendered" skips
// BOTH renders and leaves the rows stale — reachable whenever this is the
// first call, i.e. when the pre-paint script threw and left the
// attributes on their fallbacks for applyTabOrientation() to correct.
const wrapRendered = prevTall !== undefined && prevTall !== this._tallTabsEnabled;
if (!wrapRendered) this._fullRenderSessionTabs?.();
this._updateConnectionLinesImmediate?.();
this._refreshHomeSessionsIfVisible?.();
}
// Only detailed rows carry stamps that go stale with no event behind them.
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
// on paths where nothing re-rendered (boot with the layout already applied).
if (this.isRichTabRows?.()) this._startSidebarRichClock?.();
else this._stopSidebarRichClock?.();
},
applyTabWrapSettings() {
@@ -2746,7 +2810,13 @@ Object.assign(CodemanApp.prototype, {
const twoRows = !sidebar && deviceType === 'desktop'
? (settings.tabTwoRows ?? defaults.tabTwoRows ?? false)
: false;
const showFolder = sidebar || twoRows;
// The DETAILED vertical rail is the third tall-row surface, for the same
// reason as the sidebar: it is a docked column with a row per session, and
// the stamps line below the name says nothing about WHICH project the
// session is in. Read from the applied attribute, which applyTabOrientation()
// has already written (app.js calls it before this).
const railRich = this.isTabRailRich?.() === true;
const showFolder = sidebar || twoRows || railRich;
const prevTallTabs = this._tallTabsEnabled;
this._tallTabsEnabled = showFolder;
const tabsEl = document.getElementById('sessionTabs');
@@ -2832,7 +2902,8 @@ Object.assign(CodemanApp.prototype, {
this.fileBrowserDragListeners._onFirstDrag = onFirstDrag;
}
}
} else {
} else if (fileBrowserPanel.classList.contains('visible')) {
this._resetFileBrowserForHide?.();
fileBrowserPanel.classList.remove('visible');
}
}
@@ -2960,7 +3031,7 @@ Object.assign(CodemanApp.prototype, {
'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost',
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily',
'language',
+194 -26
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);
@@ -2501,6 +2515,16 @@ body.solo-mode .btn-lifecycle-log {
color: #d4d4d8;
}
/* DeepSeek: the vendor's own brand blue. Deliberately NOT added to the
light-skin `--accent-d` override list above (which rescues gemini/antigravity/
pi/grok, whose pastels wash out on paper backgrounds) — this indigo already
carries enough contrast on the light skins, and overriding it would throw away
the one cue that separates a dsh tab from its neighbours. */
.session-tab .tab-mode.deepseek {
background: rgba(77, 107, 254, 0.18);
color: #7c93ff;
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -3864,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. */
@@ -3882,6 +3922,24 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transform: translateY(-1px);
}
/* DeepSeek Harness: the #4d6bfe blue identity, matching
.btn-toolbar.btn-run.mode-deepseek and .run-mode-dot.deepseek so the welcome
action reads as the same backend. */
.welcome-btn-deepseek {
background: linear-gradient(135deg, #101a4d 0%, #2740c4 55%, #4d6bfe 100%);
border-color: rgba(124, 147, 255, 0.4);
color: #eef2ff;
box-shadow: 0 2px 8px rgba(77, 107, 254, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.welcome-btn-deepseek:hover {
background: linear-gradient(135deg, #16225f 0%, #3350e6 55%, #6b83ff 100%);
box-shadow: 0 4px 20px rgba(77, 107, 254, 0.3), 0 0 40px rgba(39, 64, 196, 0.12), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(150, 170, 255, 0.5);
color: #f8faff;
transform: translateY(-1px);
}
.welcome-btn-gemini {
background: linear-gradient(135deg, #10243f 0%, #174ea6 55%, #4f46e5 100%);
border-color: rgba(96, 165, 250, 0.4);
@@ -4955,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
@@ -4975,6 +5048,25 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #fafafa;
}
/* DeepSeek mode colors. Same cascade note as pi/grok above: this base-sheet pair
only renders on the `og` skin — the nested `html:not([data-skin="og"])` block
re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so deepseek also
carries a rule inside that block (search `.btn-toolbar.btn-run.mode-deepseek`). */
.btn-toolbar.btn-run.mode-deepseek,
.btn-toolbar.btn-run-gear.mode-deepseek {
background: linear-gradient(135deg, #101a4d 0%, #2740c4 55%, #4d6bfe 100%);
border-color: rgba(124, 147, 255, 0.55);
color: #eef2ff;
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-deepseek:hover,
.btn-toolbar.btn-run-gear.mode-deepseek:hover {
background: linear-gradient(135deg, #16225f 0%, #3350e6 55%, #6b83ff 100%);
box-shadow: 0 0 12px rgba(77, 107, 254, 0.35), 0 2px 8px rgba(39, 64, 196, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(150, 170, 255, 0.65);
color: #f8faff;
}
/* Dropdown menu */
.run-mode-menu {
display: none;
@@ -5059,6 +5151,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
.run-mode-dot.antigravity { background: #22d3ee; }
.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;
@@ -12315,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);
@@ -12334,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;
@@ -14403,6 +14517,16 @@ html:not([data-skin="og"]) {
color: #fafafa;
}
.btn-toolbar.btn-run.mode-grok:hover { box-shadow: 0 0 14px -2px rgba(161, 161, 170, 0.5); }
/* DeepSeek keeps its indigo on the non-og skins — same specificity trap as pi
and grok above: without this rule the generic `.btn-toolbar.btn-run` in this
nested block wins and deepseek renders as generic claude blue, which is the
one colour it must not be mistaken for. */
.btn-toolbar.btn-run.mode-deepseek {
background: linear-gradient(135deg, #2740c4, #4d6bfe);
border-color: #1b2a8f;
color: #f8faff;
}
.btn-toolbar.btn-run.mode-deepseek:hover { box-shadow: 0 0 14px -2px rgba(77, 107, 254, 0.55); }
.btn-toolbar.btn-run-gear {
background: var(--accent-d);
border-color: var(--accent);
@@ -17217,19 +17341,31 @@ html[data-session-list="sidebar"] .session-tab.tab-filtered-out {
display: none !important;
}
/* --- Rich rows (sessionListLayout 'sidebar-rich') ----------------------- */
/* --- Rich rows (sessionListLayout 'sidebar-rich' + tabRailDetail 'rich') --- */
/* The detailed variant of the SAME sidebar: identical column, identical
re-parented #sessionTabs, identical filter and Alt+B toggle. The only
difference is that each row also carries the line the desktop home rail and
the phone overview carry — when the session was first created, how long it
has been in the state it is in, and a status pill.
Everything here is scoped to html[data-sidebar-detail="rich"], which
The VERTICAL TAB RAIL is the second surface that draws those rows (it is a
docked column too, and #sessionTabs is the same element re-parented into it),
so every rule below carries a rail twin as an extra COMMA-GROUPED selector.
Deliberately not :is(): an :is() list takes its most specific argument's
specificity, which would silently raise the sidebar arm from (0,3,1) to the
rail arm's (0,5,1) and let these paint rules outrank things they never used
to. Grouped selectors each keep their own weight.
Sidebar rules are scoped to html[data-sidebar-detail="rich"], which
applySessionListLayout() only ever sets to 'rich' while data-session-list is
'sidebar'. `.tab-meta` is emitted by the row template exclusively in that
mode, so these rules have nothing to match anywhere else — the display:none
below is the second lock, not the mechanism. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta {
'sidebar'; rail rules to html[data-tab-orientation='vertical']
[data-tab-rail-detail='rich']:not(.tab-rail-compact), so a rail dragged below
240px drops back to simple rows the same way the collapsed sidebar does.
`.tab-meta` is emitted by the row template exclusively in those modes, so
these rules have nothing to match anywhere else — the display:none below is
the second lock, not the mechanism. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta {
display: flex;
align-items: center;
gap: 0.35em;
@@ -17251,33 +17387,56 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-meta {
display: none;
}
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-item {
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-item,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-item {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-key {
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-key,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-key {
margin-right: 0.35em;
opacity: 0.7;
text-transform: uppercase;
letter-spacing: 0.06em;
}
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-sep {
html[data-sidebar-detail="rich"] .session-sidebar .tab-meta-sep,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-sep {
opacity: 0.45;
}
/* Narrower than the detailed default (the .tab-rail-tight class,
_setTabRailWidth): the created stamp is dropped rather than shown as
"CREA…". Its value stays reachable as the tooltip on the meta LINE
(`.tab-meta` carries both absolute stamps, _sidebarRichMetaHTML) — the title
on the hidden `.tab-meta-created` itself goes away with it, since a
`display: none` element has no hover target. */
html.tab-rail-tight[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-created,
html.tab-rail-tight[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-sep {
display: none;
}
/* On a rail narrower than the detailed default, the state duration is the last
thing that should go: it is the number the row is sorted by, and the pill
next to it is only a word. The created stamp ellipsizes instead. */
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-meta-since {
flex-shrink: 0;
}
/* While a session is actually doing something, how long it has been doing it is
what the eye should land on — same emphasis the home rail gives it. */
html[data-sidebar-detail="rich"] .session-sidebar .session-tab.tab-state-working .tab-meta-since {
html[data-sidebar-detail="rich"] .session-sidebar .session-tab.tab-state-working .tab-meta-since,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab.tab-state-working .tab-meta-since {
color: var(--green);
opacity: 0.95;
}
/* Pushed hard right and never shrinking, so the stamps ellipsize before the
status word does. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill {
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill {
flex-shrink: 0;
margin-left: auto;
padding: 0.1em 0.5em;
@@ -17294,19 +17453,23 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-pill {
/* Same three colors as every other session surface: red means a question is
pending, yellow means it wants input, green means work is happening. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--needs,
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--error {
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--error,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--needs,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--error {
background: color-mix(in srgb, var(--red) 18%, transparent);
border-color: color-mix(in srgb, var(--red) 45%, transparent);
color: var(--red);
}
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--waiting {
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--waiting,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--waiting {
background: color-mix(in srgb, var(--yellow) 18%, transparent);
border-color: color-mix(in srgb, var(--yellow) 45%, transparent);
color: var(--yellow);
}
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--working {
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--working,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--working {
background: color-mix(in srgb, var(--green) 15%, transparent);
border-color: color-mix(in srgb, var(--green) 40%, transparent);
color: var(--green);
@@ -17314,7 +17477,8 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--working {
/* Muted one step further than the idle dot: the pill is a block of color, so it
reads louder than a 9px dot at the same mix. */
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--idle {
html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--idle,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .tab-pill--idle {
background: color-mix(in srgb, var(--green) 7%, transparent);
border-color: color-mix(in srgb, var(--green) 18%, var(--border));
color: color-mix(in srgb, var(--green) 45%, var(--text-muted));
@@ -17329,7 +17493,8 @@ html[data-sidebar-detail="rich"] .session-sidebar .tab-pill--idle {
then just a status dot and its badges. Without the guard, `align-items:
flex-start` and a 0.15rem top margin on .tab-status would push that dot off
the centre line of every row in the rail. */
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab {
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab {
align-items: flex-start;
padding: 0.45rem 0.5rem;
}
@@ -17338,7 +17503,10 @@ html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sideba
three-line one it drifts low, so pin it to the name it acts on. */
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-actions,
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-number,
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-status {
html[data-sidebar-detail="rich"]:not([data-sidebar="collapsed"]) .session-sidebar .session-tab .tab-status,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-name-row > .tab-actions,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-number,
html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail .session-tab .tab-status {
margin-top: 0.15rem;
}
+45 -3
View File
@@ -70,7 +70,27 @@ Object.assign(CodemanApp.prototype, {
const wasCompact = root.classList.contains('tab-rail-compact');
const compact = resolved < 240;
root.classList.toggle('tab-rail-compact', compact);
if (wasCompact !== compact) this._fullRenderSessionTabs?.();
// Second, softer threshold, CSS-only: a detailed row carries two stamps and
// below ~288px the created one ellipsizes to "CREA…", which says nothing.
// It is dropped there instead, leaving the state duration (the number the
// list is ordered by) and its pill intact. No re-render — unlike the rows
// themselves, this is a display toggle on markup that is already there.
root.classList.toggle('tab-rail-tight', resolved < 288);
if (wasCompact !== compact) {
// The folder line is owned by applyTabWrapSettings(), whose railRich
// input reads the compact class this function just toggled — without
// re-running it, a rich rail dragged below 240px kept emitting folder
// rows (and, for a stored width < 240, kept them across reloads: the
// boot-time wrap pass runs before this function first applies the
// class). It re-renders only when the folder flag actually flipped, so
// cover the flip-without-folder-change case (a simple-detail rail
// crossing 240px still changes the row-action affordance) without
// rendering twice.
const prevTall = this._tallTabsEnabled;
this.applyTabWrapSettings?.();
const wrapRendered = prevTall !== undefined && this._tallTabsEnabled !== prevTall;
if (!wrapRendered) this._fullRenderSessionTabs?.();
}
const handle = document.getElementById('tabRailResizeHandle');
if (handle) {
handle.setAttribute('aria-valuemax', String(effectiveMax));
@@ -144,9 +164,28 @@ Object.assign(CodemanApp.prototype, {
}, 150);
},
/**
* The width a rail gets when the user has never picked one.
*
* Detailed rows carry a third line ("created 3d ago · working 12m" plus a
* status pill) and at 256px that line ellipsizes before it is finished — the
* same reason the rich SIDEBAR is 300px and the simple one 260px. 320px is
* the existing Wide preset, so a fresh detailed rail lands on a named choice
* rather than reading "Custom" in the settings select.
*
* Only the DEFAULT moves: a width the user has actually chosen (stored) is
* never overridden, and dragging the rail narrower is never fought — below
* 240px the rows drop back to simple ones on their own.
*/
_defaultTabRailWidth() {
const rich = document.documentElement.dataset.tabRailDetail !== 'simple';
if (rich) return window.CodemanTabRail?.RICH_DEFAULT_WIDTH ?? 320;
return window.CodemanTabRail?.DEFAULT_WIDTH ?? 256;
},
applyTabRailWidth(options = {}) {
const settings = this.loadAppSettingsFromStorage();
const requested = settings.tabRailWidth ?? window.CodemanTabRail?.DEFAULT_WIDTH ?? 256;
const requested = settings.tabRailWidth ?? this._defaultTabRailWidth();
const preferred = window.CodemanTabRail?.resolveWidth({ width: requested }) ?? 256;
if (options.settle) this._claimTabRailResize();
const resolved = this._setTabRailWidth(preferred);
@@ -198,6 +237,7 @@ Object.assign(CodemanApp.prototype, {
key: event.key,
shiftKey: event.shiftKey,
currentWidth: this._getCurrentTabRailWidth(),
defaultWidth: this._defaultTabRailWidth?.(),
...this._getTabRailBounds(),
});
if (width === null || width === undefined) return;
@@ -240,7 +280,9 @@ Object.assign(CodemanApp.prototype, {
handle.addEventListener('dblclick', (event) => {
event.preventDefault();
this._claimTabRailResize();
const preferred = window.CodemanTabRail?.DEFAULT_WIDTH || 256;
// Rich-aware: resetting a detailed rail to 256 would land it below the
// 288px tight threshold and silently drop the created stamp.
const preferred = this._defaultTabRailWidth?.() ?? (window.CodemanTabRail?.DEFAULT_WIDTH || 256);
const effective = this._setTabRailWidth(preferred);
this._scheduleTabRailSettle(effective, preferred);
});
+71 -13
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);
}
})
);
@@ -2214,7 +2214,7 @@ Object.assign(CodemanApp.prototype, {
}
titleSpan.appendChild(document.createTextNode(this._historyRowLabel(s, shortDir)));
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/grok/shell) + a LIVE pill.
// Badge row: mode (claude/codex/opencode/gemini/antigravity/pi/grok/deepseek/shell) + a LIVE pill.
const badgeRow = document.createElement('div');
badgeRow.className = 'history-item-badges';
if (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 &&
+4 -1
View File
@@ -302,7 +302,10 @@ Object.assign(CodemanApp.prototype, {
renderWebviewMenuItems() {
const container = document.getElementById('runModeWebviews');
if (!container) return;
const list = [...(this.webviews?.values() || [])];
// Managed records are maintained by their own menu entry (the DeepSeek web
// UI shortcut), so listing them here showed one dashboard twice: the
// shortcut that starts it, and the row it wrote on the previous click.
const list = [...(this.webviews?.values() || [])].filter((w) => !w.managed);
if (list.length === 0) {
container.innerHTML = '<div class="run-mode-empty">No URLs yet</div>';
return;
+1 -1
View File
@@ -11,7 +11,7 @@ export interface ResponseViewerTranscriptBlock {
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
// would drag node-pty and the whole session layer into this pure module, so the
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok']);
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek']);
function isPromptLine(line: string): boolean {
return /^\s*›\s*/.test(line);
+35 -7
View File
@@ -12,7 +12,7 @@ import { homedir } from 'node:os';
import type { z } from 'zod';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Session } from '../session.js';
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../types.js';
import { ApiErrorCode, createErrorResponse, type AuthUser, type SessionState } from '../types.js';
import { MAX_CONCURRENT_SESSIONS } from '../config/map-limits.js';
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
import { SseEvent } from './sse-events.js';
@@ -264,6 +264,18 @@ export function revokeUserSessions(
return removed;
}
/**
* The 404 both session-lookup helpers below throw. A missing session and one
* the caller isn't allowed to see get the IDENTICAL error (never 403), so
* existence of another user's session is never leaked.
*/
function sessionNotFoundError(sessionId: string): Error & { statusCode: number; body: unknown } {
return Object.assign(new Error(`Session ${sessionId} not found`), {
statusCode: 404,
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
});
}
/**
* Look up a session by ID or throw a structured error.
* Replaces the pattern: `const session = sessions.get(id); if (!session) return createErrorResponse(...)`.
@@ -274,15 +286,31 @@ export function revokeUserSessions(
*/
export function findSessionOrFail(ctx: SessionPort, sessionId: string, req?: FastifyRequest): Session {
const session = ctx.sessions.get(sessionId);
if (!session || (req && !canAccessOwned(getAuthUser(req), session.owner))) {
throw Object.assign(new Error(`Session ${sessionId} not found`), {
statusCode: 404,
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
});
}
if (!session) throw sessionNotFoundError(sessionId);
if (req && !canAccessOwned(getAuthUser(req), session.owner)) throw sessionNotFoundError(sessionId);
return session;
}
/**
* Like {@link findSessionOrFail}, for a session that exists ONLY in persisted
* state — a resumed-but-never-reattached row (e.g. a non-claude "Resume" that
* relaunched into a new session and wants to retire the row it can no longer
* reattach to) has no live `Session` instance for `findSessionOrFail` to
* return, so this returns the persisted record instead. Same ownership
* enforcement, same 404-not-403 leak protection — this is that function's
* missing other half, not a separate check reimplemented inline.
*/
export function findPersistedSessionOrFail(
store: { getSession(id: string): SessionState | null },
sessionId: string,
req?: FastifyRequest
): SessionState {
const persisted = store.getSession(sessionId);
if (!persisted) throw sessionNotFoundError(sessionId);
if (req && !canAccessOwned(getAuthUser(req), persisted.owner)) throw sessionNotFoundError(sessionId);
return persisted;
}
/** Shortest prefix accepted for a parent session id (see resolveParentSessionId). */
const PARENT_SESSION_ID_MIN_PREFIX = 8;
+15 -2
View File
@@ -23,7 +23,7 @@ import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { ApprovalAnswerSchema } from '../schemas.js';
import { parseBody, getAuthUser, canAccessOwned, findSessionOrFail } from '../route-helpers.js';
import { approvalInbox, type ApprovalItem } from '../approval-inbox.js';
import { hooksAvailableForMode } from '../session-wait-registry.js';
import { hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
import type { SessionPort } from '../ports/index.js';
/**
@@ -99,9 +99,22 @@ export function registerApprovalRoutes(app: FastifyInstance, ctx: SessionPort):
// Throws 404 (not 403) for sessions the caller does not own, same
// no-existence-leak rule as every other session route.
const session = findSessionOrFail(ctx, item.sessionId, req);
if (!hooksAvailableForMode(session.mode)) {
if (!hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
return createErrorResponse(ApiErrorCode.CONFLICT, 'Session mode cannot have pending approvals');
}
// A dsh approval is an ALERT, not an answerable card: the dialog belongs to
// a third-party TUI whose keystroke contract Codeman has not measured, the
// Claude-shaped option parser never reads options off its frames, and
// verifyStillAnswerable() can therefore never be conclusive for it — so the
// '1'/Esc below would be a blind keystroke into a foreign composer. The item
// still raises the red alert and clears on the harness's own working/stop
// reports; answering happens in the terminal.
if (session.mode === 'deepseek') {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'DeepSeek Harness approvals must be answered in the terminal: the dialog belongs to a third-party TUI whose keystrokes Codeman cannot verify.'
);
}
// Re-capture the pane before aiming keystrokes at it: if the dialog was
// answered in the terminal moments ago, the digit would land in whatever
+52 -7
View File
@@ -13,7 +13,7 @@ import { HookEventSchema, isValidWorkingDir } from '../schemas.js';
import { sanitizeHookData, parseBody } from '../route-helpers.js';
import { persistDockerCaseClaudeSessionId } from '../../docker-hosts.js';
import { getDataDir } from '../../config/instance.js';
import { sessionWaits, hooksAvailableForMode } from '../session-wait-registry.js';
import { sessionWaits, hooksAvailableForMode, sessionHookOptions } from '../session-wait-registry.js';
import { approvalInbox, type ApprovalKind } from '../approval-inbox.js';
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
@@ -24,8 +24,34 @@ const APPROVAL_KIND_BY_EVENT: Record<string, ApprovalKind> = {
idle_prompt: 'idle',
};
/** Hook events that close a session's pending item without an inbox answer. */
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response']);
/**
* Hook events that close a session's pending item without an inbox answer.
*
* `agent_working` is here because it is the DeepSeek status bridge's report that
* a turn STARTED, and a harness turn cannot be running while one of its own
* modal approvals is on screen — so the agent moving means the dialog was
* answered, in the terminal, by the user. That is the same conclusion the claude
* path reaches through pane capture, which cannot help here because its frame
* parser is Claude-dialog-shaped.
*/
const APPROVAL_RESOLVING_EVENTS = new Set(['stop', 'elicitation_complete', 'elicitation_response', 'agent_working']);
/**
* Last DeepSeek status-bridge sequence number seen per session.
*
* The Herdr contract the dsh TUI speaks stamps every report with `--seq <n>`
* and RETRIES failed deliveries with backoff — so a stale report can land
* AFTER a newer one, and applying it in arrival order resolves an approval
* with a retried `working` while the harness sits blocked, or releases a wait
* with a retried `idle` mid-turn. A report whose seq is not newer than the
* last accepted one is dropped, but only inside a short window: the TUI's
* retry backoff is seconds, so a LOWER seq arriving after the window is a
* restarted TUI's fresh numbering (same pane, new generation), not a stale
* retry, and must be accepted. Insertion-order eviction bounds the map.
*/
const dshSeqBySession = new Map<string, { seq: number; at: number }>();
const DSH_SEQ_STALE_WINDOW_MS = 60_000;
const DSH_SEQ_MAX_SESSIONS = 500;
export function registerHookEventRoutes(
app: FastifyInstance,
@@ -37,6 +63,22 @@ export function registerHookEventRoutes(
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
// DeepSeek status-bridge ordering: drop a stale retried report (see
// dshSeqBySession above). Success rather than an error, so the shim exits 0
// and the TUI does not keep retrying a report that will stay stale.
if (data && data.source === 'dsh-status-shim' && typeof data.seq === 'number') {
const last = dshSeqBySession.get(sessionId);
const now = Date.now();
if (last && data.seq <= last.seq && now - last.at < DSH_SEQ_STALE_WINDOW_MS) {
return {};
}
if (!dshSeqBySession.has(sessionId) && dshSeqBySession.size >= DSH_SEQ_MAX_SESSIONS) {
const oldest = dshSeqBySession.keys().next().value;
if (oldest !== undefined) dshSeqBySession.delete(oldest);
}
dshSeqBySession.set(sessionId, { seq: data.seq, at: now });
}
// Wake anything blocked on `GET /api/sessions/:id/wait`. Hooks are the only
// DEFINITIVE signals Codeman gets (`idle` is inferred from output stabilization
// and can flap mid-turn), so these two are what an orchestrating agent should
@@ -50,7 +92,7 @@ export function registerHookEventRoutes(
// could never legitimately emit one is now dropped instead of steering another
// agent's control flow.
const waitSession = ctx.sessions.get(sessionId);
if (waitSession && hooksAvailableForMode(waitSession.mode)) {
if (waitSession && hooksAvailableForMode(waitSession.mode, sessionHookOptions(waitSession))) {
if (event === 'stop') {
sessionWaits.notifySignal(sessionId, 'stop');
} else if (event === 'permission_prompt' || event === 'elicitation_dialog') {
@@ -111,7 +153,7 @@ export function registerHookEventRoutes(
// session that can never show one must not create an answerable item).
let approvalId: string | undefined;
const approvalKind = APPROVAL_KIND_BY_EVENT[event];
if (session && hooksAvailableForMode(session.mode)) {
if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) {
if (approvalKind) {
const toolInput =
safeData.tool_input && typeof safeData.tool_input === 'object'
@@ -154,12 +196,15 @@ export function registerHookEventRoutes(
// the browser loaded with. Debounced, so a hook burst costs one broadcast.
ctx.broadcastSessionStateDebounced(sessionId);
// Send push notifications for hook events
// Send push notifications for hook events. Push Approve/Deny actions ride
// on approvalId, and the answer route refuses keystrokes for dsh dialogs
// (third-party TUI, unmeasured contract) — so a dsh push stays a plain
// notification instead of offering buttons whose answer would be refused.
ctx.sendPushNotifications(`hook:${event}`, {
sessionId,
sessionName,
...safeData,
...(approvalId && { approvalId }),
...(approvalId && session?.mode !== 'deepseek' && { approvalId }),
});
// Track in run summary
+5 -2
View File
@@ -38,7 +38,6 @@ import { IntentGoalsSchema, ReadMyMindPredictSchema } from '../schemas.js';
import { parseBody, findSessionOrFail } from '../route-helpers.js';
import { intentStore } from '../../intent-store.js';
import { approvalInbox } from '../approval-inbox.js';
import { hooksAvailableForMode } from '../session-wait-registry.js';
import { buildPredictionContext, type PredictionContextInputs } from '../../readmymind-context.js';
import { collectWorkspaceSignals, readTranscriptSignals } from '../../readmymind-collectors.js';
import { readMyMindPredictor } from '../../readmymind-predictor.js';
@@ -72,7 +71,11 @@ export function registerReadMyMindRoutes(app: FastifyInstance, ctx: SessionPort
const body = parseBody(ReadMyMindPredictSchema, req.body ?? {});
const session = findSessionOrFail(ctx, id, req);
if (!hooksAvailableForMode(session.mode)) {
// `mode === 'claude'` directly, NOT hooksAvailableForMode(): that predicate
// answers "can this session deliver stop/blocked", and once `deepseek` earned
// a yes it silently widened this gate to a mode whose sessions have no Claude
// transcript for readTranscriptSignals() to read.
if (session.mode !== 'claude') {
reply.code(400);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Read My Mind predicts claude-mode sessions only');
}
+294 -18
View File
@@ -20,11 +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';
@@ -53,6 +56,7 @@ import { TabLayoutValidationError } from '../../tab-layout.js';
import {
sessionWaits,
resolveWaitSignals,
sessionHookOptions,
signalForStatus,
WaitCapacityError,
type WaitSignal,
@@ -63,6 +67,7 @@ import {
autoConfigureRalph,
canAccessOwned,
CASES_DIR,
findPersistedSessionOrFail,
findSessionOrFail,
getAuthUser,
isAdmin,
@@ -134,11 +139,14 @@ 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,
parseExternalCliTranscript,
} from '../response-viewer-transcript.js';
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
const LINKED_CASES_FILE = dataPath('linked-cases.json');
@@ -337,6 +345,14 @@ export function _resetPasteRateBuckets(): void {
* Grok is like Codex/Antigravity: the bypass switch is `alwaysApprove`
* (`--always-approve`), and an ABSENT config already spawns in grok's own
* ask-mode default, so only a sent config needs the flag forced off.
*
* DeepSeek joins the same only-if-sent branch, but its switch is not a flag: the
* harness has no command-line permission option, and its sandbox/approval rows
* read `DSH_PERMISSION_MODE`. Omitting that export leaves the harness on its own
* `workspace-write` preset, which still asks, so an absent config is already
* safe; a sent one is forced down to `workspace-write` rather than to
* `read-only`, because the clamp exists to remove PRIVILEGE, not to break a
* session's ability to edit its own workspace.
*/
async function clampExternalCliBypassForOwner(
owner: string | undefined,
@@ -344,16 +360,18 @@ async function clampExternalCliBypassForOwner(
geminiConfig: GeminiConfig | undefined,
antigravityConfig: AntigravityConfig | undefined,
piConfig: PiConfig | undefined,
grokConfig: GrokConfig | undefined
grokConfig: GrokConfig | undefined,
deepSeekConfig: DeepSeekConfig | undefined
): Promise<{
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
antigravityConfig: AntigravityConfig | undefined;
piConfig: PiConfig | undefined;
grokConfig: GrokConfig | undefined;
deepSeekConfig: DeepSeekConfig | undefined;
}> {
const granted = await canUsernameRunPrivilegedCommands(owner);
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig };
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig };
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default)
// and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
@@ -364,18 +382,116 @@ async function clampExternalCliBypassForOwner(
: antigravityConfig;
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig;
const clampedDeepSeek = deepSeekConfig
? { ...deepSeekConfig, permissionMode: 'workspace-write' as const }
: deepSeekConfig;
return {
codexConfig: clampedCodex,
geminiConfig: clampedGemini,
antigravityConfig: clampedAntigravity,
piConfig: clampedPi,
grokConfig: clampedGrok,
deepSeekConfig: clampedDeepSeek,
};
}
/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */
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 redirects a
* credential-resolution endpoint.
*
* 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.
*
* - `DSH_PERMISSION_MODE` IS the harness's permission switch. Every other CLI's
* bypass is a command-line FLAG, reachable only through the per-CLI config the
* clamp already owns; this one is an env var, so the config clamp alone is
* half a gate.
* - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code
* executes at BOOT, before any approval row can apply. A user who can write a
* workspace can put a profile in it, so this is the wider of the two.
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureDeepSeek()`
* forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before
* `applyEnvOverrides()` runs — so a non-granted owner who could set the base
* 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',
'OMP_AUTH_BROKER_URL',
'OMP_AUTH_BROKER_TOKEN',
] as const;
/**
* Env-var half of the multi-user bypass clamp.
*
* `clampExternalCliBypassForOwner()` clamps the per-CLI CONFIG, and for every CLI
* but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs
* AFTER `_configureDeepSeek()` in tmux-manager, so an override sent on the SAME
* request lands last and wins, and a non-granted owner could restore
* `danger-full-access` on the very request the config clamp downgraded.
*
* Keys are DROPPED rather than rewritten: dropping falls through to what
* `_configureDeepSeek()` exports, which is the clamped config and the server's own
* `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a
* granted owner, like every other clamp here
* (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`),
* and it returns the caller's own object untouched when there is nothing to strip.
*/
async function clampEnvOverridesForOwner(
owner: string | undefined,
envOverrides: Record<string, string> | undefined
): Promise<Record<string, string> | undefined> {
if (!envOverrides) return envOverrides;
if (!OWNER_CLAMPED_ENV_KEYS.some((key) => key in envOverrides)) return envOverrides;
if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides;
const clamped = { ...envOverrides };
for (const key of OWNER_CLAMPED_ENV_KEYS) delete clamped[key];
return clamped;
}
/** Test hook: the env-var half of the same multi-user safety gate. */
export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner;
/**
* Why a DeepSeek session cannot start, or null when it can.
*
* Availability for this mode is TWO questions, not one, because `dsh` is a
* profile launcher rather than an agent: the binary must resolve (and prove it
* is the harness and not Debian's dancer's shell), AND a profile that can occupy
* a pane must exist. Reporting only the first would let the Run button spawn a
* pane that dies instantly, which is the single most confusing failure this mode
* can produce, so each half gets its own actionable message.
*
* A profile named EXPLICITLY is checked on both counts: existence, and whether
* it is pane-capable — `web` serves a browser UI and `headless` answers one task
* and exits, so both would present as "the tab immediately died".
*/
async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise<string | null> {
// Thin async wrapper: the implementation moved into the resolver module so
// CRON fires can ask the same question before constructing a Session; the
// dynamic import keeps this file's startup free of the probe machinery.
const { resolveDeepSeekLaunchError: impl } = await import('../../utils/deepseek-cli-resolver.js');
return impl(requestedProfile);
}
// ═══════════════════════════════════════════════════════════════
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
// ═══════════════════════════════════════════════════════════════
@@ -649,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
@@ -770,6 +916,8 @@ export function registerSessionRoutes(
body.mode !== 'antigravity' &&
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 + '/'));
@@ -859,12 +1007,22 @@ export function registerSessionRoutes(
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
}
}
if (body.mode === 'deepseek') {
const err = await resolveDeepSeekLaunchError(body.deepSeekConfig?.profile);
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
}
if (body.mode === 'grok') {
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
if (!isGrokAvailable()) {
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
@@ -912,9 +1070,14 @@ export function registerSessionRoutes(
? body.piConfig?.model
: mode === 'grok'
? body.grokConfig?.model
: mode !== 'shell'
? 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);
@@ -925,13 +1088,15 @@ export function registerSessionRoutes(
antigravityConfig: gatedAntigravityConfig,
piConfig: gatedPiConfig,
grokConfig: gatedGrokConfig,
deepSeekConfig: gatedDeepSeekConfig,
} = await clampExternalCliBypassForOwner(
owner,
body.codexConfig,
body.geminiConfig,
body.antigravityConfig,
body.piConfig,
body.grokConfig
body.grokConfig,
body.deepSeekConfig
);
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
@@ -950,8 +1115,10 @@ export function registerSessionRoutes(
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig),
resumeSessionId: validatedResumeId,
envOverrides: body.envOverrides,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort,
tmuxHistoryLimit: terminalHistoryConfig.tmuxHistoryLimit,
remote,
@@ -1018,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 {};
});
@@ -1182,6 +1366,8 @@ export function registerSessionRoutes(
session.mode !== 'antigravity' &&
session.mode !== 'pi' &&
session.mode !== 'grok' &&
session.mode !== 'deepseek' &&
session.mode !== 'omp' &&
ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -1277,7 +1463,10 @@ export function registerSessionRoutes(
wait === true || (typeof wait === 'string' && wait.trim().length > 0) || (Array.isArray(wait) && wait.length > 0);
let until: readonly WaitSignal[] = [];
if (wantsWait) {
const resolved = resolveWaitSignals(wait === true ? undefined : wait, { mode: session.mode });
const resolved = resolveWaitSignals(wait === true ? undefined : wait, {
mode: session.mode,
...sessionHookOptions(session),
});
if (resolved.error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, resolved.error);
until = resolved.until;
}
@@ -1454,7 +1643,7 @@ export function registerSessionRoutes(
// Shared with the `wait` field on POST .../input: unknown token is a 400,
// hook-only signals are rejected explicitly but dropped from the default.
const { until, error } = resolveWaitSignals(query.until, { mode: session.mode });
const { until, error } = resolveWaitSignals(query.until, { mode: session.mode, ...sessionHookOptions(session) });
if (error) return createErrorResponse(ApiErrorCode.INVALID_INPUT, error);
// The value actually applied after clamping, echoed below: a caller that asked
@@ -1922,6 +2111,35 @@ export function registerSessionRoutes(
return await readCodexLastResponse(session, codexQuery.context === 'full');
}
// DeepSeek Harness writes a real structured transcript under
// `$DSH_HOME/sessions/**`, so read that rather than segmenting the pane.
// ⚠️ For dsh the pane fallback is not merely coarse, it is WRONG: dsh-TUI
// paints a full-screen splash, and the segmenter served its ASCII-art logo
// back as the worker's answer (measured), which an agent polling for a
// reply reads as a reply. So an EMPTY transcript result still wins over the
// pane — "nothing said yet" is the honest answer. Only `null`, meaning a
// Node too old to decode zstd, falls through to the segmenter below.
// ⚠️ Local sessions only: a docker case's harness writes its transcript
// inside the CONTAINER's ~/.dsh (the workspace bind-mount does not cover
// it) and a remote-SSH case's lives on the remote host, so the local
// reader would scan a $DSH_HOME that can never hold this session's file
// and return "nothing said yet" forever — an agent polling that worker
// would starve on an answer that exists. Those configurations keep the
// pane segmenter below: coarse, but the real conversation.
if (session.mode === 'deepseek' && !session.docker && !session.remote) {
const deepSeekQuery = req.query as { context?: string };
const full = deepSeekQuery.context === 'full';
const transcript = await readDeepSeekLastResponse(session, { blocks: full });
if (transcript) {
return {
text: transcript.text,
timestamp: transcript.timestamp,
hasContext: transcript.text.length > 0 || transcript.blocks.length > 0,
messages: full ? transcript.blocks : undefined,
};
}
}
// OpenCode / Gemini / Antigravity / Pi render their own TUIs and write no
// Claude transcript, so the scan below finds nothing and the response viewer
// renders permanently empty for them. Segment the terminal buffer instead —
@@ -2717,6 +2935,8 @@ export function registerSessionRoutes(
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
ompConfig,
envOverrides,
effort,
parentSessionId,
@@ -2766,6 +2986,8 @@ export function registerSessionRoutes(
antigravityConfig ||
piConfig ||
grokConfig ||
deepSeekConfig ||
ompConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2799,6 +3021,8 @@ export function registerSessionRoutes(
antigravityConfig ||
piConfig ||
grokConfig ||
deepSeekConfig ||
ompConfig ||
openCodeConfig
) {
return createErrorResponse(
@@ -2900,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') {
@@ -2909,6 +3143,12 @@ export function registerSessionRoutes(
}
}
// Check DeepSeek Harness availability if requested (binary AND a pane-capable profile).
if (mode === 'deepseek') {
const err = await resolveDeepSeekLaunchError(deepSeekConfig?.profile);
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
@@ -2955,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);
}
@@ -3036,6 +3277,8 @@ export function registerSessionRoutes(
mode !== 'antigravity' &&
mode !== 'pi' &&
mode !== 'grok' &&
mode !== 'deepseek' &&
mode !== 'omp' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
@@ -3060,9 +3303,12 @@ export function registerSessionRoutes(
? piConfig?.model
: mode === 'grok'
? grokConfig?.model
: mode !== 'shell'
? 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).
@@ -3072,8 +3318,18 @@ export function registerSessionRoutes(
antigravityConfig: qsGatedAntigravityConfig,
piConfig: qsGatedPiConfig,
grokConfig: qsGatedGrokConfig,
} = await clampExternalCliBypassForOwner(owner, codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig);
deepSeekConfig: qsGatedDeepSeekConfig,
} = await clampExternalCliBypassForOwner(
owner,
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig
);
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
const session = new Session({
workingDir: resolvedCasePath,
name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '',
@@ -3091,7 +3347,9 @@ export function registerSessionRoutes(
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
envOverrides,
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
ompConfig: resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig),
envOverrides: qsGatedEnvOverrides,
effort,
remote,
docker,
@@ -3939,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);
}
}
+250 -1
View File
@@ -16,7 +16,7 @@ import { dataPath } from '../../config/instance.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js';
import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { findUser } from '../../user-store.js';
import { findUser, canUsernameRunPrivilegedCommands } from '../../user-store.js';
import { getAuthUser, requireAdmin, canAccessOwned } from '../route-helpers.js';
import {
ConfigUpdateSchema,
@@ -26,6 +26,8 @@ import {
SubagentWindowStatesSchema,
SubagentParentMapSchema,
RevokeSessionSchema,
DeepSeekInstallProfileSchema,
DeepSeekWebStartSchema,
} from '../schemas.js';
import { subagentWatcher } from '../../subagent-watcher.js';
import { imageWatcher } from '../../image-watcher.js';
@@ -48,6 +50,7 @@ import {
} from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
import { getRepositoryStatus } from '../repo-status.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
@@ -55,6 +58,21 @@ import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
import { resolveTerminalHistoryConfig } from '../../config/terminal-history.js';
/**
* Defaults for `POST /api/deepseek/install-profile`.
*
* The package is the community terminal front door with by far the widest use
* (~27.5k weekly downloads at time of writing, roughly 4x the next), MIT, and
* the one whose supervisor-reporting contract Codeman's status bridge speaks.
* It is a DEFAULT, not a hardcoding: the endpoint accepts any npm name, and the
* resolver never assumes this profile exists.
*/
const DEEPSEEK_DEFAULT_TUI_PACKAGE = '@deepseek-harness-tui/dsh-tui';
const DEEPSEEK_DEFAULT_PROFILE = 'dsh-tui';
/** A plugin install compiles and links a dependency tree; npm-scale, not curl-scale. */
const DEEPSEEK_INSTALL_TIMEOUT_MS = 300_000;
// Maximum screenshot upload size (10MB)
const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024;
// Screenshots directory
@@ -461,6 +479,237 @@ export function registerSystemRoutes(
};
});
// ========== DeepSeek Harness ==========
// The widest of the per-CLI status shapes, because this mode has the widest
// failure surface. Three fields beyond the sibling `available`/`path`:
//
// - `version`, like pi/grok, so a misresolution is diagnosable — and here the
// stakes are higher, since `dsh` is also an existing Debian program
// (dancer's shell) rather than merely a squattable npm name.
// - `profiles`, because `dsh` is a LAUNCHER: a perfectly installed binary with
// no pane-capable profile cannot start a session, and the UI has to be able
// to say which of the two halves is missing.
// - `runnable` + `defaultProfile`, the answer the Run button actually needs,
// so no caller has to re-derive it from the parts and get it subtly wrong.
app.get('/api/deepseek/status', async () => {
const {
isDeepSeekAvailable,
isDeepSeekRunnable,
resolveDeepSeekDir,
getDeepSeekCliVersion,
listDeepSeekProfiles,
resolveDefaultDeepSeekProfile,
resolveDshHome,
} = await import('../../utils/deepseek-cli-resolver.js');
return {
available: isDeepSeekAvailable(),
runnable: isDeepSeekRunnable(),
path: resolveDeepSeekDir(),
version: getDeepSeekCliVersion(),
dshHome: resolveDshHome(),
defaultProfile: resolveDefaultDeepSeekProfile(),
profiles: listDeepSeekProfiles(),
};
});
// Start (or reuse) the background `dsh web` behind the Run menu shortcut.
//
// This runs as a plain child process rather than a shell SESSION on purpose.
// The session version worked, but it put a terminal tab on screen next to the
// web tab the user actually asked for, every single time. Nothing about a
// long-lived HTTP server needs to be a tab.
//
// Fenced at the same bar as the profile installer, and for the same reason:
// booting a dsh profile executes the plugin code in it, so this is a
// privileged action even though it reads as "open a page".
app.post('/api/deepseek/web', async (req) => {
const { authority } = parseBody(DeepSeekWebStartSchema, req.body);
if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'Starting the DeepSeek web UI requires the can-bypass-permissions grant'
);
}
const { resolveDeepSeekDir, getDeepSeekNotFoundMessage } = await import('../../utils/deepseek-cli-resolver.js');
const dir = resolveDeepSeekDir();
if (!dir) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getDeepSeekNotFoundMessage());
const { startDeepSeekWeb } = await import('../../deepseek-web-server.js');
const result = await startDeepSeekWeb(dir, authority);
if (!result.ok) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error);
return { success: true, data: { port: result.port, url: result.url, reused: result.reused } };
});
app.get('/api/deepseek/web', async () => {
const { getDeepSeekWebStatus } = await import('../../deepseek-web-server.js');
return { success: true, data: getDeepSeekWebStatus() };
});
app.delete('/api/deepseek/web', async (req) => {
// Same bar as POST: the server is a single shared instance, so in
// multi-user mode stopping it out from under other users' tabs is a
// privileged act (single-user and granted owners are unaffected).
if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'Stopping the DeepSeek web UI requires the can-bypass-permissions grant'
);
}
const { stopDeepSeekWeb } = await import('../../deepseek-web-server.js');
await stopDeepSeekWeb();
return { success: true, data: { stopped: true } };
});
// Bootstrap an interactive profile so the mode becomes usable.
//
// This exists because DeepSeek ships NO terminal front door: `dsh` on its own
// can only serve a browser UI or answer one headless task, and the agent a
// Codeman pane runs is always a plugin the user installed. Without this the
// mode's first-run experience is a dead Run button and a paragraph of shell
// instructions.
//
// It is the only endpoint in Codeman that installs third-party code, so it is
// fenced accordingly:
// - the privileged grant is required in multi-user mode (same bar as a
// `shell` session, which can already do strictly more);
// - the specifier is regex-confined to an npm name at the schema boundary —
// no path, URL, git spec, or leading dash;
// - the spawn is an argv ARRAY through the resolved `dsh`, never a shell
// string, so even a specifier that slipped the regex could not become a
// second command;
// - the request is held open with a bounded timeout, mirroring the
// synchronous-clone precedent in `POST /api/cases/clone` rather than
// introducing a job store for a once-per-install action — and the bound is
// real, because the install runs in its own process GROUP and the timeout
// kills the whole tree (see the spawn below for why the built-in one is
// not enough).
app.post('/api/deepseek/install-profile', async (req) => {
const body = parseBody(DeepSeekInstallProfileSchema, req.body);
if (isMultiUserMode() && !(await canUsernameRunPrivilegedCommands(getAuthUser(req).username))) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
'Installing a DeepSeek Harness profile requires the can-bypass-permissions grant'
);
}
const { resolveDeepSeekDir, getDeepSeekNotFoundMessage } = await import('../../utils/deepseek-cli-resolver.js');
const dir = resolveDeepSeekDir();
if (!dir) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getDeepSeekNotFoundMessage());
const profile = body.profile || DEEPSEEK_DEFAULT_PROFILE;
const pkg = body.package || DEEPSEEK_DEFAULT_TUI_PACKAGE;
const result = await new Promise<{ code: number | null; output: string; timedOut: boolean }>((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(join(dir, 'dsh'), ['plugin', '--profile', profile, 'add', pkg], {
stdio: ['ignore', 'pipe', 'pipe'],
// Own process group, and the timeout enforced by hand rather than by
// spawn's `timeout` option. A plugin install fans out into
// package-manager resolver/build children, and spawn's own timeout
// signals ONLY the direct child: the survivors keep the inherited stdio
// pipes open, `close` never fires, and this request hangs forever with
// no route-level deadline behind it. Same fan-out, same escalation and
// same negative-pid signal as runGit() in git-clone.ts, which is the
// synchronous-spawn precedent this endpoint is modelled on.
detached: true,
// 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) {
resolve({ code: null, output: `spawn failed: ${getErrorMessage(err)}`, timedOut: false });
return;
}
let output = '';
let timedOut = false;
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
let reapTimer: NodeJS.Timeout | undefined;
const capture = (chunk: Buffer) => {
// Bounded: a package manager can emit megabytes of progress.
if (output.length < 16_384) output += chunk.toString('utf-8');
};
child.stdout?.on('data', capture);
child.stderr?.on('data', capture);
const killTree = (signal: NodeJS.Signals) => {
try {
if (child.pid) process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
/* already gone */
}
}
};
const finish = (code: number | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
if (reapTimer) clearTimeout(reapTimer);
resolve({ code, output, timedOut });
};
const timer = setTimeout(() => {
timedOut = true;
killTree('SIGTERM');
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
// Last resort: a grandchild that escaped the group (double-fork/setsid)
// can hold the pipes open past SIGKILL, and `close` would still never
// arrive. Answer the caller anyway rather than leaking the request.
reapTimer = setTimeout(() => finish(null), 8_000);
}, DEEPSEEK_INSTALL_TIMEOUT_MS);
child.on('error', (err) => {
output = `${output}\n${err.message}`;
finish(null);
});
child.on('close', (code) => finish(code));
});
if (result.code !== 0) {
const detail = result.timedOut
? `timed out after ${Math.round(DEEPSEEK_INSTALL_TIMEOUT_MS / 1000)}s`
: result.output.slice(-1000).trim() || 'no output';
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Installing ${pkg} into profile "${profile}" failed: ${detail}`
);
}
const { listDeepSeekProfiles, resolveDefaultDeepSeekProfile, isDeepSeekRunnable } =
await import('../../utils/deepseek-cli-resolver.js');
return {
profile,
package: pkg,
runnable: isDeepSeekRunnable(),
defaultProfile: resolveDefaultDeepSeekProfile(),
profiles: listDeepSeekProfiles(),
};
});
// ========== 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)
// ═══════════════════════════════════════════════════════════════
+1
View File
@@ -132,6 +132,7 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
// dashboard on an HTTPS Codeman, which is the common case.
embedMode: input.embedMode ?? 'proxy',
trusted: input.trusted ?? false,
managed: input.managed,
owner,
createdAt: Date.now(),
};
+137 -5
View File
@@ -132,6 +132,16 @@ const ALLOWED_ENV_PREFIXES = [
'PI_',
'GROK_',
'XAI_',
// DeepSeek Harness: `DSH_*` carries the launcher's own documented inputs
// (DSH_HOME, DSH_PERMISSION_MODE, DSH_TELEMETRY_MODE, and the DSH_TUI_* knobs
// the terminal front door reads); `DEEPSEEK_*` is the vendor namespace holding
// DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL, the same narrow-vendor reasoning that
// admitted XAI_* for grok. Foreign provider keys stay out: a dsh settings.yaml
// can name ANY env var as a provider credential (apiKeyEnv), which is pi's
// 34-provider-key problem in a new shape, and the answer is the same one.
'DSH_',
'DEEPSEEK_',
'OMP_',
];
/**
@@ -171,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_* 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.',
}
);
@@ -336,6 +346,107 @@ 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.
*
* `permissionMode` maps to the `DSH_PERMISSION_MODE` env export, NOT to a flag —
* the harness has no command-line permission switch. An ABSENT config spawns the
* profile under the harness's own `workspace-write` default, which still asks
* for approval, so the multi-user clamp only needs the only-if-sent branch (like
* codex/antigravity/grok).
*
* `profile` is a directory name under `$DSH_HOME/profiles`, so it is constrained
* to a single path SEGMENT: no separators, no dots-only names. It is interpolated
* into the `bash -c "…"` spawn line and joined into a filesystem path, and this
* regex is what keeps both safe.
*/
const DeepSeekConfigSchema = z
.object({
profile: z
.string()
.min(1)
.max(64)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
.optional(),
permissionMode: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(),
resumeSession: z.boolean().optional(),
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._-]+$/)
.optional(),
statusReporting: z.boolean().optional(),
})
.optional();
/**
* Body of POST /api/deepseek/install-profile.
*
* `package` is a package SPECIFIER handed to `dsh plugin … add`, which runs a
* real package-manager install, so it is the security-relevant field. Two things
* contain it: this regex (an npm name, optionally scoped, optionally with an
* `@version` tail, and NOTHING else — no path, no URL, no git spec, no leading
* dash that could be read as a flag), and the route, which spawns an argv ARRAY
* with no shell. The route additionally requires the privileged grant in
* multi-user mode: installing a plugin is arbitrary code execution on the host,
* the same bar as a `shell` session.
*/
export const DeepSeekInstallProfileSchema = z
.object({
profile: z
.string()
.min(1)
.max(64)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
.optional(),
package: z
.string()
.min(1)
.max(214)
.regex(/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*(?:@[a-zA-Z0-9][a-zA-Z0-9._-]*)?$/)
.optional(),
})
.strict();
/**
* POST /api/deepseek/web: start the background `dsh web` for one browser authority.
*
* `authority` becomes `--trusted-host`, which is what dsh fences its own `/api`
* behind, so it must be the origin the browser will actually load the tab from
* (`location.host`). It reaches a spawn as one element of an argv ARRAY, never a
* shell string, so this regex is defence in depth rather than the only guard: it
* admits host:port in the shapes a browser authority can take (dotted names,
* IPv4, bracketed IPv6) and nothing that could be read as a second argument.
*/
export const DeepSeekWebStartSchema = z
.object({
authority: z
.string()
.min(1)
.max(255)
.regex(/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)(?::\d{1,5})?$/),
})
.strict();
/**
* The session that spawned the one being created — pure UI decoration, drawn as a
* lineage line between the two tabs. Accepted here and, equivalently, as the
@@ -349,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']).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,
@@ -358,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,
@@ -366,6 +479,8 @@ export const CreateSessionSchema = z.object({
antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
deepSeekConfig: DeepSeekConfigSchema,
ompConfig: OmpConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
resumeSessionId: z
.string()
@@ -502,6 +617,7 @@ const RemoteCommandOverridesSchema = z
antigravity: z.string().min(1).max(300).optional(),
pi: z.string().min(1).max(300).optional(),
grok: z.string().min(1).max(300).optional(),
deepseek: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
@@ -776,13 +892,17 @@ 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']).optional(),
mode: z
.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp'])
.optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
antigravityConfig: AntigravityConfigSchema,
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,
@@ -804,6 +924,11 @@ export const HookEventSchema = z.object({
'stop',
'teammate_idle',
'task_completed',
// A turn STARTED. Unlike the others this one has no Claude Code hook behind
// it: it is reported by the DeepSeek Harness status shim, and exists so a
// dialog answered in the terminal resolves its Approvals Inbox item at once
// instead of lingering red until the next `stop`.
'agent_working',
]),
sessionId: z.string().min(1),
data: z.record(z.string(), z.unknown()).nullable().optional(),
@@ -997,6 +1122,7 @@ export const SettingsUpdateSchema = z
tabTwoRows: z.boolean().optional(),
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
tabRailWidth: z.number().int().min(208).max(360).optional(),
tabRailDetail: z.enum(['simple', 'rich']).optional(),
/**
* Session list layout. Display key (per-device).
* 'header' = horizontal tab strip
@@ -1310,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']),
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']),
@@ -1600,6 +1726,12 @@ const WebviewBaseSchema = z.object({
* and call the API that spawns agents.
*/
trusted: z.boolean().optional(),
/**
* Marks a record Codeman maintains itself. Declared here because a plain
* `z.object` STRIPS undeclared keys, so an undeclared marker would be dropped
* on the way in and the dedup it drives would never fire.
*/
managed: z.enum(['deepseek-web']).optional(),
});
/** POST /api/webviews */
+58 -3
View File
@@ -91,10 +91,11 @@ import {
attachSessionListeners,
detachSessionListeners,
} from './session-listener-wiring.js';
import { sessionWaits, hooksAvailableForMode } from './session-wait-registry.js';
import { sessionWaits } from './session-wait-registry.js';
import { intentStore } from '../intent-store.js';
import { AI_CHECK_MODEL } from '../config/ai-defaults.js';
import { approvalInbox } from './approval-inbox.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
setupTimedRespawn,
@@ -142,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';
@@ -185,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;');
@@ -267,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)
@@ -1093,7 +1099,10 @@ export class WebServer extends EventEmitter {
*/
private async captureIntentPrompt(sessionId: string, text: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session || !hooksAvailableForMode(session.mode)) return;
// `mode === 'claude'` directly: the intent profile is fed from Claude's own
// transcript, so this is a claude question, not a hooks-available one (which
// `deepseek` now answers yes to).
if (!session || session.mode !== 'claude') return;
try {
const settings = await this.readSettings();
if (settings.readMyMindEnabled !== true) return;
@@ -1436,6 +1445,8 @@ export class WebServer extends EventEmitter {
{ isAntigravityAvailable },
{ isPiAvailable },
{ isGrokAvailable },
{ isDeepSeekRunnable, isDeepSeekAvailable },
{ isOmpAvailable },
{ isCloudflaredAvailable },
{ isGitAvailable },
] = await Promise.all([
@@ -1446,6 +1457,8 @@ export class WebServer extends EventEmitter {
import('../utils/antigravity-cli-resolver.js'),
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'),
]);
@@ -1457,6 +1470,14 @@ export class WebServer extends EventEmitter {
antigravity: isAntigravityAvailable(),
pi: isPiAvailable(),
grok: isGrokAvailable(),
// RUNNABLE, not merely installed: `dsh` is a profile launcher, and a dsh
// with no pane-capable profile would offer a Run button that spawns a
// pane which dies on arrival. The Add-Profile affordance in the run menu
// keys off `deepseekBinary` instead, so a user who has the binary but no
// 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.
@@ -2381,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).
@@ -2518,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(
() => {
@@ -2738,6 +2785,8 @@ export class WebServer extends EventEmitter {
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
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,
@@ -3122,6 +3171,12 @@ export class WebServer extends EventEmitter {
this._dockerBridgeServer = null;
}
// The background `dsh web` is detached so its whole plugin tree can be
// signalled at once, which also means it would OUTLIVE Codeman and hold its
// port against the next start — the exact EADDRINUSE this feature already
// got wrong once.
void stopDeepSeekWeb();
// Dispose all managed timers (intervals + resettable timeouts)
this.cleanup.dispose();
+97 -10
View File
@@ -170,19 +170,91 @@ export function signalForStatus(status: SessionStatus): WaitSignal | null {
/** Signals that arrive only via Claude Code hooks, so only `claude` mode can emit them. */
const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
/** Per-session facts that can turn a mode's hook capability OFF for one session. */
export interface HookCapabilityOptions {
/**
* `deepSeekConfig.statusReporting`, verbatim (so `undefined` means "not sent",
* i.e. ON). `false` is the per-session opt-out that stops `_configureDeepSeek()`
* exporting the `HERDR_*` triple, which is the ONLY thing that makes a dsh
* session emit hook events at all.
*/
deepSeekStatusReporting?: boolean;
/**
* True when the pane's harness runs somewhere the status bridge cannot reach:
* a docker case (`docker exec` does not carry the local tmux env into the
* container, and the loopback-bound API is unreachable from it) or a
* remote-SSH case (the `HERDR_*` triple is set on the LOCAL ssh process, not
* the remote shell). Such a session never posts a hook event however the
* statusReporting flag is set, so `until=stop` on it would burn its whole
* timeout on every turn.
*/
deepSeekBridgeUnreachable?: boolean;
}
/**
* Whether a session in this mode ever POSTs Codeman hook events, and therefore
* whether `stop` / `blocked` can ever fire for it.
* Whether this session ever POSTs Codeman hook events, and therefore whether
* `stop` / `blocked` can ever fire for it.
*
* True for `claude` and nothing else. The tempting predicate is
* `claude` always (Claude Code fires the hooks itself), `deepseek` when its
* status bridge is armed, nothing else. The tempting predicate is
* `!isExternalCliMode(mode)`, and it is WRONG: that helper covers only
* opencode/codex/gemini/antigravity, so `shell` falls through it — and a shell session
* is a plain bash PTY with no Claude Code and no hooks installed. `until=stop` on one
* was accepted and then blocked for the caller's whole timeout, which is precisely the
* infinite-wait-dressed-as-a-timeout this guard exists to prevent.
*
* ⚠️ `deepseek` is a per-SESSION answer, not a per-mode one, which is why the
* options argument exists: `deepSeekConfig.statusReporting: false` disarms the
* bridge for one session, and answering from the mode alone re-creates the exact
* infinite-wait this guard is for. Every call site therefore passes the session's
* own flag; the default stays permissive so a forgotten one degrades to the old
* behavior rather than 400ing a session that works.
*
* ⚠️ It is also the LIMIT of what can be known at request time. Whether the
* installed profile actually implements the supervisor contract is only
* observable once it reports, and `resolveDefaultDeepSeekProfile()` deliberately
* treats an unrecognized profile as launchable, so a dsh session running a
* non-conforming TUI still answers true here and still times out on an explicit
* `until=stop`. The default signal set keeps `idle`/`exit` for exactly that case.
*
* ⚠️ NOT a stand-in for "is this a claude session". It reads like one and it was
* used as one (Read My Mind, intent capture) until `deepseek` joined and silently
* widened both. Those sites compare `mode === 'claude'` directly now; ask this
* function only about hook SIGNALS.
*/
export function hooksAvailableForMode(mode: SessionMode): boolean {
return mode === 'claude';
export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
if (mode === 'claude') return true;
// `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE
// signals rather than having them inferred. The DeepSeek Harness terminal
// front door reports idle/working/blocked to its supervisor, and Codeman is
// that supervisor (see deepseek-status-shim.ts), so a dsh session really can
// deliver `stop` and `blocked` — unless the user turned the bridge off, in
// which case nothing on the box will ever post one. Every other mode is
// output-stabilization guesswork and must keep failing the ask.
if (mode === 'deepseek') {
return options.deepSeekStatusReporting !== false && options.deepSeekBridgeUnreachable !== true;
}
return false;
}
/**
* Lift the per-session hook facts off a live session.
*
* Structurally typed on purpose: this module is pure and deliberately imports no
* `Session` (importing it would drag node-pty and the session layer into every
* consumer). One helper rather than an inline object literal at each of the four
* call sites, so a future per-session fact is added in one place instead of
* being forgotten at three of them.
*/
export function sessionHookOptions(session: {
deepSeekStatusReporting?: boolean;
docker?: unknown;
remote?: unknown;
}): HookCapabilityOptions {
return {
deepSeekStatusReporting: session.deepSeekStatusReporting,
deepSeekBridgeUnreachable: Boolean(session.docker || session.remote),
};
}
/** Outcome of resolving a caller-supplied wait target against a session's mode. */
@@ -206,10 +278,15 @@ export interface ResolvedWaitSignals {
* not drift; the second-guessing that produces is worse than the duplication.
*
* @param raw - the caller's value (comma string, array, `true` for "the default")
* @param options - `mode` decides whether the hook-only signals are available, and
* names the mode in the error message so the caller can see why
* @param options - `mode` plus the per-session facts `hooksAvailableForMode()` needs
* (a dsh session with its status bridge disarmed emits no hooks even
* though the mode can). The mode also names itself in the error
* message so the caller can see why.
*/
export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode }): ResolvedWaitSignals {
export function resolveWaitSignals(
raw: unknown,
options: { mode: SessionMode } & HookCapabilityOptions
): ResolvedWaitSignals {
const parsed = parseWaitSignals(raw);
if (parsed.invalid.length > 0) {
return {
@@ -218,7 +295,7 @@ export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode })
};
}
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode) ? [] : HOOK_ONLY_SIGNALS);
const unsupported = new Set<WaitSignal>(hooksAvailableForMode(options.mode, options) ? [] : HOOK_ONLY_SIGNALS);
if (parsed.signals.length === 0) {
return { until: DEFAULT_WAIT_SIGNALS.filter((signal) => !unsupported.has(signal)), error: null };
@@ -228,7 +305,17 @@ export function resolveWaitSignals(raw: unknown, options: { mode: SessionMode })
if (rejected.length > 0) {
return {
until: [],
error: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
// A dsh session is the one case where the mode is capable and THIS session
// is not, so saying "never fire for deepseek sessions" would send the
// caller looking for a bug that is really a setting they chose.
error:
options.mode === 'deepseek'
? options.deepSeekBridgeUnreachable
? `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: it runs in a container or on ` +
`a remote host, where the local status bridge cannot reach the harness. Use idle or exit.`
: `Signal(s) ${rejected.join(', ')} never fire for this deepseek session: its status bridge is off ` +
`(deepSeekConfig.statusReporting: false), so nothing posts hook events. Use idle or exit.`
: `Signal(s) ${rejected.join(', ')} never fire for ${options.mode} sessions (no Claude Code hooks). Use idle or exit.`,
};
}
return { until: parsed.signals, error: null };
+12 -3
View File
@@ -5,7 +5,7 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 156 event constants organized by category:
* 157 event constants organized by category:
* - **Core** (1): init
* - **Transport** (1): sse:heartbeat
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
@@ -25,7 +25,8 @@
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image / attachments** (2): image:detected, attachment:detected
* - **Hooks** (8): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed
* - **Hooks** (9): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, agent_working, teammate_idle, task_completed
* (agent_working is the odd one out: reported by the DeepSeek Harness status bridge, not by a Claude Code hook)
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
* - **Clipboard** (1): write
@@ -115,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 ──────────────────────────────────────────────────────────
@@ -360,6 +361,13 @@ export const HookElicitationComplete = 'hook:elicitation_complete' as const;
export const HookElicitationResponse = 'hook:elicitation_response' as const;
/** Claude Code hook: response complete. */
export const HookStop = 'hook:stop' as const;
/**
* Agent started a turn. NOT a Claude Code hook: this one is reported by the
* DeepSeek Harness status bridge, which is why the name is agent-generic. It
* exists so a dialog answered in the terminal clears its alert immediately
* instead of waiting for the turn to end.
*/
export const HookAgentWorking = 'hook:agent_working' as const;
/** Claude Code hook: teammate went idle. */
export const HookTeammateIdle = 'hook:teammate_idle' as const;
/** Claude Code hook: teammate task completed. */
@@ -619,6 +627,7 @@ export const SseEvent = {
HookElicitationComplete,
HookElicitationResponse,
HookStop,
HookAgentWorking,
HookTeammateIdle,
HookTaskCompleted,
+31 -9
View File
@@ -26,11 +26,20 @@
* external CLIs: those lists exist to describe what `isExternalCliMode()` gates
* (no Claude transcript, no hooks, no Claude-format parsers), so naming some but
* not all of them is the drift itself. Runs of one or two modes are exempt, since
* a legitimate pair ("claude or shell") is not a class claim. ONE exception is
* allowed and it is a real one: the "writes no transcript" lists drop `codex`,
* which does write a rollout Codeman reads back (the pane carries a unique
* originator precisely so `last-response` can find it), so external-minus-codex
* is a meaningful class rather than an oversight.
* a legitimate pair ("claude or shell") is not a class claim. The exceptions are
* the REAL classes inside the external family, each one a capability some of those
* CLIs have and the rest do not:
*
* - "writes no transcript" drops `codex` (a rollout Codeman reads back) and
* `deepseek` (a JSONL session file Codeman reads back);
* - "delivers no hook signals" drops `deepseek`, whose harness reports its own
* lifecycle -- that one is derived from `hooksAvailableForMode()` rather than
* restated, so the predicate and the prose cannot drift apart;
* - the positive twin of the first: the modes whose answers CAN be read.
*
* Anything else partial is still the drift. A NEW backend belongs to none of these
* classes until someone says so, so every one of them grows by a mode and every
* stale list fails here -- which is the whole point.
*
* Port: N/A (pure static analysis).
*/
@@ -41,6 +50,7 @@ import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
import { isExternalCliMode } from '../src/session.js';
import { hooksAvailableForMode } from '../src/web/session-wait-registry.js';
import type { SessionMode } from '../src/types/session.js';
const HERE = fileURLToPath(new URL('.', import.meta.url));
@@ -63,6 +73,15 @@ function schemaModes(schema: typeof CreateSessionSchema | typeof QuickStartSchem
const MODES = schemaModes(CreateSessionSchema);
const EXTERNAL_MODES = MODES.filter(isExternalCliMode);
/**
* External modes whose ANSWERS Codeman can read: codex from its rollout,
* deepseek from `$DSH_HOME/sessions/**`. Stated here rather than derived because
* `last-response` branches per mode into a per-CLI reader and there is no single
* predicate to import; the runtime facts are `readCodexLastResponse` and
* `readDeepSeekLastResponse` in session-routes.ts.
*/
const TRANSCRIPT_EXTERNAL_MODES = new Set<string>(['codex', 'deepseek']);
/**
* Mode tokens appearing back to back, separated only by list punctuation — `a|b|c`,
* `a`/`b`/`c`, "`a`, `b` and `c`". Newlines collapse to spaces first so a wrapped list
@@ -109,9 +128,12 @@ describe('agent skill run-mode lists', () => {
it('never enumerates a partial set of external CLI modes', () => {
const complete = new Set<string>(EXTERNAL_MODES);
/** The documented exception: codex writes a rollout, so it is absent from the
* "no transcript" lists on purpose. Every OTHER external mode must still be there. */
const withoutCodex = new Set<string>(EXTERNAL_MODES.filter((m) => m !== 'codex'));
// The real classes inside the family (see the fileoverview). Each is derived, so
// an eighth backend joins none of them and every list naming the other seven fails.
const noTranscript = new Set<string>(EXTERNAL_MODES.filter((m) => !TRANSCRIPT_EXTERNAL_MODES.has(m)));
const withTranscript = new Set<string>(EXTERNAL_MODES.filter((m) => TRANSCRIPT_EXTERNAL_MODES.has(m)));
const noHookSignals = new Set<string>(EXTERNAL_MODES.filter((m) => !hooksAvailableForMode(m)));
const allowed = [complete, noTranscript, withTranscript, noHookSignals];
const sameSet = (a: Set<string>, b: Set<string>) => a.size === b.size && [...a].every((v) => b.has(v));
const offenders: string[] = [];
@@ -121,7 +143,7 @@ describe('agent skill run-mode lists', () => {
if (listed.length < 3) continue;
const externals = new Set<string>(listed.filter(isExternalCliMode));
// Empty is fine (a claude/shell-only list); partial is the drift.
if (externals.size === 0 || sameSet(externals, complete) || sameSet(externals, withoutCodex)) continue;
if (externals.size === 0 || allowed.some((set) => sameSet(externals, set))) continue;
const missing = EXTERNAL_MODES.filter((m) => !externals.has(m));
offenders.push(`${file}: "${run.trim()}" is missing ${missing.join(', ')}`);
}
+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 () => {
+277
View File
@@ -0,0 +1,277 @@
/**
* @fileoverview Tests for the DeepSeek Harness (`dsh`) resolver and profile inventory.
*
* `dsh` needs the strictest identity probe of any CLI Codeman resolves. pi and
* grok are short names with npm squatters; `dsh` is worse — it is an EXISTING,
* widely packaged Unix program (Debian's dancer's shell, `apt install dsh`),
* which would sail through a version-token probe and then be handed a spawn
* line. So the resolver demands the harness's own help banner first, and the
* headline test below is the one that pins that rejection.
*
* The second half covers something no sibling resolver has: a profile
* inventory. `dsh` is a launcher, so "is it installed" and "can it run a
* session" are different questions, and the availability gate needs both.
*/
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
createDeepSeekResolverForTest,
DEEPSEEK_VERSION_REGEX,
DEEPSEEK_IDENTITY_REGEX,
listDeepSeekProfiles,
resolveDefaultDeepSeekProfile,
isLaunchableProfile,
resolveDshHome,
} from '../src/utils/deepseek-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('DeepSeek CLI resolver', () => {
it('accepts a candidate the probe verifies and carries the version as metadata', () => {
const binaryPath = '/service/bin/dsh';
const probe = vi.fn(() => '0.1.1-rc.2');
const resolver = createDeepSeekResolverForTest(
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
probe
);
expect(resolver.resolve()).toMatchObject({
binaryPath,
directory: '/service/bin',
source: 'process-path',
metadata: '0.1.1-rc.2',
});
expect(probe).toHaveBeenCalledWith(binaryPath);
});
it('does not let a foreign `dsh` earlier on PATH mask the real one', () => {
// The dancer's-shell case, at resolver level: a `dsh` that is a real program
// and answers --version must still be refused, and must not stop the search.
const impostor = '/usr/bin/dsh';
const genuine = '/login-shell/bin/dsh';
const probe = vi.fn((binPath: string) => (binPath === genuine ? '0.1.1-rc.2' : null));
const resolver = createDeepSeekResolverForTest(
createHost({
processPathResult: impostor,
loginShellResults: [genuine],
existingPaths: [impostor, genuine],
}),
probe
);
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell' });
});
it('negative-caches a miss and retries only after the backoff elapses', () => {
const binaryPath = '/late/bin/dsh';
let now = 0;
const probe = vi.fn(() => '0.1.1-rc.2');
const resolver = createDeepSeekResolverForTest(
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('0.1.1-rc.2');
});
it('extracts the version from the real output shape (a bare `0.1.1-rc.2`)', () => {
// Shared with the dependency registry (doctor), so the accepted shape is
// contract. The prerelease tail is part of the token on purpose: dropping it
// would report a release candidate as a release.
expect(DEEPSEEK_VERSION_REGEX.exec('0.1.1-rc.2')?.[1]).toBe('0.1.1-rc.2');
expect(DEEPSEEK_VERSION_REGEX.exec('dsh 1.2.3')?.[1]).toBe('1.2.3');
expect(DEEPSEEK_VERSION_REGEX.exec('not a version')).toBeNull();
});
it('identifies the harness by its help banner and rejects a foreign dsh', () => {
expect(DEEPSEEK_IDENTITY_REGEX.test('dsh: boot a DeepSeek Harness profile — an ordered stack')).toBe(true);
// Debian's dancer's shell: a real program, a real version, not our agent.
expect(DEEPSEEK_IDENTITY_REGEX.test('Usage: dsh [options] [command] ...\nDistributed shell')).toBe(false);
});
it('never executes a dsh candidate under vitest (the ambient probe is VITEST-gated)', () => {
// A REAL executable fixture that answers BOTH probes convincingly. If the
// guard in probeDeepSeekVersion is ever removed, this script runs, the
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
// behavior rather than by source text. That matters more here than for any
// sibling: `dsh` is a name real machines genuinely carry.
const root = mkdtempSync(join(tmpdir(), 'codeman-dsh-vitest-gate-'));
temporaryDirectories.push(root);
const binaryPath = join(root, 'dsh');
writeFileSync(
binaryPath,
'#!/bin/sh\ncase "$1" in --help) echo "dsh: boot a DeepSeek Harness profile";; *) echo "9.9.9";; esac\n'
);
chmodSync(binaryPath, 0o755);
const hostOptions = {
processPath: root,
shellPath: '/bin/bash',
shellArgs: ['-i', '-l'] as string[],
runCommand: () => '',
isExecutableFile: (path: string) => path === binaryPath,
};
const gated = createDeepSeekResolverForTest(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 = createDeepSeekResolverForTest(createProductionCliResolverHost(hostOptions), () => '9.9.9');
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '9.9.9' });
});
});
describe('DeepSeek profile inventory', () => {
let home: string;
const ORIGINAL_DSH_HOME = process.env.DSH_HOME;
function writeProfile(name: string, bundles: string[]): void {
const dir = join(home, 'profiles', name);
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, 'package.json'),
JSON.stringify({ name: `dsh-profile-${name}`, dsh: { profile: { bundles } } })
);
}
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'codeman-dsh-home-'));
temporaryDirectories.push(home);
process.env.DSH_HOME = home;
});
afterEach(() => {
if (ORIGINAL_DSH_HOME === undefined) delete process.env.DSH_HOME;
else process.env.DSH_HOME = ORIGINAL_DSH_HOME;
});
it('honours DSH_HOME over the default ~/.dsh', () => {
expect(resolveDshHome()).toBe(home);
});
it('is empty (not an error) when dsh has never been run', () => {
rmSync(home, { recursive: true, force: true });
expect(listDeepSeekProfiles()).toEqual([]);
expect(resolveDefaultDeepSeekProfile()).toBeNull();
});
it('classifies the profiles DeepSeek ships as unable to drive a pane', () => {
writeProfile('web', ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']);
writeProfile('headless', ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']);
const profiles = listDeepSeekProfiles();
expect(profiles.map((p) => `${p.name}:${p.kind}`).sort()).toEqual(['headless:headless', 'web:web']);
expect(profiles.every((p) => !isLaunchableProfile(p))).toBe(true);
// The whole point: a perfectly installed dsh with only the shipped profiles
// still cannot start a Codeman session.
expect(resolveDefaultDeepSeekProfile()).toBeNull();
});
it('prefers an interactive profile and ignores node_modules', () => {
writeProfile('web', ['@deepseek-ai/dsh-web-app']);
writeProfile('dsh-tui', ['@deepseek-ai/dsh-base', '@deepseek-harness-tui/dsh-tui']);
mkdirSync(join(home, 'profiles', 'node_modules', 'something'), { recursive: true });
const names = listDeepSeekProfiles().map((p) => p.name);
expect(names).not.toContain('node_modules');
expect(resolveDefaultDeepSeekProfile()).toBe('dsh-tui');
});
it('treats an unrecognized third-party profile as launchable', () => {
// Anyone can publish an app bundle, so an unknown profile must not be hidden
// from the picker just because this classifier has not heard of it.
writeProfile('custom', ['@someone/dsh-my-own-surface']);
const profile = listDeepSeekProfiles().find((p) => p.name === 'custom')!;
expect(profile.kind).toBe('unknown');
expect(isLaunchableProfile(profile)).toBe(true);
expect(resolveDefaultDeepSeekProfile()).toBe('custom');
});
it('does not treat a bundle-less stock profile as launchable', () => {
// readProfile() yields an empty bundle list for any package.json without a
// `dsh.profile.bundles` array (hand-edited, older layout, mid-install), and
// with no bundles to read the shipped web/headless profiles used to look
// exactly like an unrecognized third-party one — inheriting its
// launchable-by-default treatment and producing the pane-dies-on-arrival
// failure the two-part availability gate exists to prevent.
const bare = (name: string) => {
const dir = join(home, 'profiles', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: `dsh-profile-${name}` }));
};
bare('web');
bare('headless');
const profiles = listDeepSeekProfiles();
expect(profiles.map((p) => `${p.name}:${p.kind}`).sort()).toEqual(['headless:headless', 'web:web']);
expect(profiles.every((p) => !isLaunchableProfile(p))).toBe(true);
expect(resolveDefaultDeepSeekProfile()).toBeNull();
});
it('lets bundle evidence beat the name fallback', () => {
// The name check is a LAST resort, so a profile the user happened to call
// `web` that really composes a terminal app is still interactive. Otherwise
// a directory name would override what the profile actually contains.
writeProfile('web', ['@deepseek-ai/dsh-base', '@someone/dsh-tui']);
const profile = listDeepSeekProfiles().find((p) => p.name === 'web')!;
expect(profile.kind).toBe('interactive');
expect(resolveDefaultDeepSeekProfile()).toBe('web');
});
it('does not read `tui` out of the middle of an unrelated word', () => {
// The loose arm is a TOKEN match: `@someone/tui-app` is a TUI, `intuition`
// is a word. Being wrong is cheap (unknown is launchable too) but it decides
// which profile boots by DEFAULT, and "its name contains t-u-i" is not a
// rule anyone could predict.
writeProfile('intuition', ['@someone/gratuitous-surface']);
expect(listDeepSeekProfiles().find((p) => p.name === 'intuition')!.kind).toBe('unknown');
writeProfile('mine', ['@someone/tui-app']);
expect(listDeepSeekProfiles().find((p) => p.name === 'mine')!.kind).toBe('interactive');
// Preferred over the merely-unknown one, which is the whole point of ranking.
expect(resolveDefaultDeepSeekProfile()).toBe('mine');
});
it('survives a stray directory under profiles/', () => {
mkdirSync(join(home, 'profiles', 'not-a-profile'), { recursive: true });
writeProfile('dsh-tui', ['@deepseek-harness-tui/dsh-tui']);
expect(listDeepSeekProfiles().map((p) => p.name)).toEqual(['dsh-tui']);
});
});
+402
View File
@@ -0,0 +1,402 @@
/**
* DeepSeek Harness (`dsh`) run mode.
*
* The interesting assertions here are the ones that differ from every sibling
* CLI, because dsh is shaped differently in two ways:
*
* 1. the agent is a PROFILE, not the binary, so the spawn line carries
* `--profile <name>` and a profile name has to be treated as a path segment;
* 2. the permission switch is an ENV VAR (`DSH_PERMISSION_MODE`), not a flag,
* so the thing to pin is that nothing permission-shaped ever reaches the
* command line.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { CreateSessionSchema, QuickStartSchema, HookEventSchema } 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 { hooksAvailableForMode, resolveWaitSignals, sessionHookOptions } from '../src/web/session-wait-registry.js';
import { _clampExternalCliBypassForOwner, _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
import { DEEPSEEK_STATE_TO_HOOK_EVENT } from '../src/deepseek-status-shim.js';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
vi.mock('../src/utils/deepseek-cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/deepseek-cli-resolver.js')>();
return { ...actual, resolveDefaultDeepSeekProfile: vi.fn(() => 'dsh-tui') };
});
describe('DeepSeek mode schemas', () => {
it('accepts DeepSeek session creation config', () => {
const parsed = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
deepSeekConfig: { profile: 'dsh-tui', permissionMode: 'danger-full-access' },
});
expect(parsed.mode).toBe('deepseek');
expect(parsed.deepSeekConfig).toEqual({ profile: 'dsh-tui', permissionMode: 'danger-full-access' });
});
it('accepts DeepSeek quick-start config', () => {
const parsed = QuickStartSchema.parse({
caseName: 'dsh-case',
mode: 'deepseek',
deepSeekConfig: { resumeSessionId: 'sess_01H9', statusReporting: false },
});
expect(parsed.mode).toBe('deepseek');
expect(parsed.deepSeekConfig?.resumeSessionId).toBe('sess_01H9');
expect(parsed.deepSeekConfig?.statusReporting).toBe(false);
});
it('rejects a profile name that is not a single path segment', () => {
// A profile is BOTH interpolated into a `bash -c "…"` line and joined into a
// filesystem path under $DSH_HOME/profiles, so separators and traversal have
// to die at the schema boundary.
for (const profile of ['../../etc/passwd', 'a/b', './x', '-rf', 'has space', 'semi;colon']) {
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'deepseek', deepSeekConfig: { profile } })
).toThrow();
}
});
it('rejects an unknown permission preset', () => {
// The three presets are the harness's own; anything else would be exported
// verbatim as DSH_PERMISSION_MODE and silently fall back to its default.
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
deepSeekConfig: { permissionMode: 'yolo' },
})
).toThrow();
});
it('rejects unsafe resumeSessionId values', () => {
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
deepSeekConfig: { resumeSessionId: '../../etc/passwd' },
})
).toThrow();
});
it('allows DSH_* and DEEPSEEK_* env overrides but not a foreign provider key', () => {
const ok = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
envOverrides: { DSH_HOME: '/tmp/dsh', DEEPSEEK_API_KEY: 'sk-test' },
});
expect(ok.envOverrides).toEqual({ DSH_HOME: '/tmp/dsh', DEEPSEEK_API_KEY: 'sk-test' });
// A dsh settings.yaml can name ANY env var as a provider credential
// (apiKeyEnv), which is pi's 34-provider-key problem in a new shape. The
// allowlist is global, so admitting them would widen every mode at once.
expect(() =>
CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'deepseek',
envOverrides: { QWEN5090_API_KEY: 'sk-test' },
})
).toThrow();
});
});
describe('DeepSeek spawn command', () => {
it('boots the requested profile', () => {
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'dsh-tui' },
});
expect(cmd).toBe('dsh --profile dsh-tui');
});
it('falls back to the resolved default profile when none was requested', () => {
const cmd = buildSpawnCommand({ mode: 'deepseek', sessionId: 's1' });
expect(cmd).toBe('dsh --profile dsh-tui');
});
it('never puts anything permission-shaped on the command line', () => {
// The harness has NO permission flag: the switch is the DSH_PERMISSION_MODE
// env export, applied via `tmux setenv`. If this ever starts failing, someone
// has invented a flag that does not exist.
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'dsh-tui', permissionMode: 'danger-full-access' },
});
expect(cmd).toBe('dsh --profile dsh-tui');
expect(cmd).not.toMatch(/danger|approve|permission|yolo|dangerously/i);
});
it('prefers an explicit resume id over the most-recent form', () => {
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'p', resumeSession: true, resumeSessionId: 'sess_42' },
});
expect(cmd).toBe('dsh --profile p --resume sess_42');
});
it('resumes the most recent session when only the flag is set', () => {
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'p', resumeSession: true },
});
expect(cmd).toBe('dsh --profile p --resume');
});
it('drops an unsafe profile rather than interpolating it', () => {
// Defense in depth behind the schema: builders must not trust their callers,
// because this string is interpolated into a `bash -c "…"` argument.
const cmd = buildSpawnCommand({
mode: 'deepseek',
sessionId: 's1',
deepSeekConfig: { profile: 'evil; rm -rf /' },
});
expect(cmd).not.toContain('rm -rf');
expect(cmd).toBe('dsh --profile dsh-tui');
});
});
describe('DeepSeek mode wiring', () => {
it('is an external CLI mode', () => {
expect(isExternalCliMode('deepseek')).toBe(true);
});
it('is NOT an alt-screen strip mode', () => {
// The strip is for Ink-style repaint TUIs (claude/codex/gemini). A dsh
// terminal profile is a third-party fullscreen TUI, i.e. the opencode case.
expect(isAltScreenStripMode('deepseek')).toBe(false);
});
it('has default remote and docker commands', () => {
expect(defaultRemoteCommandForMode('deepseek')).toContain('dsh');
expect(defaultDockerCommandForMode('deepseek')).toBe('exec dsh');
});
});
describe('DeepSeek status bridge', () => {
it('is the only non-claude mode allowed to deliver hook signals', () => {
// Earned, not granted: the harness terminal front door REPORTS its state to
// a supervisor, so `stop` and `blocked` for a dsh session are definitive
// rather than inferred. Every other external CLI must keep failing this.
expect(hooksAvailableForMode('deepseek')).toBe(true);
expect(hooksAvailableForMode('claude')).toBe(true);
for (const mode of ['shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok'] as const) {
expect(hooksAvailableForMode(mode)).toBe(false);
}
});
it('is a per-SESSION answer for deepseek: a disarmed status bridge emits nothing', () => {
// `statusReporting: false` is what stops _configureDeepSeek() exporting the
// HERDR_* triple, and the triple is the ONLY reason a dsh session posts hook
// events. Answering from the mode alone would accept `until=stop` on a
// session where nothing can ever send one, which is the exact
// infinite-wait-dressed-as-a-timeout this predicate exists to prevent.
expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: false })).toBe(false);
expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: true })).toBe(true);
// Not sent = ON, so an ordinary session is unaffected.
expect(hooksAvailableForMode('deepseek', {})).toBe(true);
expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: undefined })).toBe(true);
// The flag is meaningless for every other mode and must not move them.
expect(hooksAvailableForMode('claude', { deepSeekStatusReporting: false })).toBe(true);
expect(hooksAvailableForMode('codex', { deepSeekStatusReporting: true })).toBe(false);
});
it('refuses an explicit stop/blocked on a dsh session whose bridge is off, and says why', () => {
const off = { mode: 'deepseek' as const, deepSeekStatusReporting: false };
const on = { mode: 'deepseek' as const };
expect(resolveWaitSignals('stop', on)).toEqual({ until: ['stop'], error: null });
const rejected = resolveWaitSignals('stop', off);
expect(rejected.until).toEqual([]);
// The generic "no Claude Code hooks" wording would send the caller hunting a
// bug that is really a setting they chose, so this arm names the setting.
expect(rejected.error).toContain('statusReporting');
expect(rejected.error).not.toContain('no Claude Code hooks');
// An OMITTED `until` must never 400: the hook-only signals are dropped from
// the default set instead, leaving the two that still work.
expect(resolveWaitSignals(undefined, off)).toEqual({ until: ['idle', 'exit'], error: null });
expect(resolveWaitSignals(undefined, on).until).toContain('stop');
});
it('refuses stop/blocked on a docker or remote dsh session, where the bridge cannot reach the harness', () => {
// `docker exec` does not carry the local tmux env into the container and the
// remote shell never sees the local `HERDR_*` setenv, so such a session can
// never post a hook event however statusReporting is set — accepting
// `until=stop` there burns the caller's whole timeout on every turn.
expect(hooksAvailableForMode('deepseek', { deepSeekBridgeUnreachable: true })).toBe(false);
const unreachable = { mode: 'deepseek' as const, deepSeekBridgeUnreachable: true };
const rejected = resolveWaitSignals('stop', unreachable);
expect(rejected.until).toEqual([]);
expect(rejected.error).toContain('container or on a remote host');
// The default set degrades instead of erroring, exactly like the disarmed case.
expect(resolveWaitSignals(undefined, unreachable)).toEqual({ until: ['idle', 'exit'], error: null });
// sessionHookOptions() is what lifts the fact off a live session.
expect(sessionHookOptions({ docker: { containerName: 'c' } }).deepSeekBridgeUnreachable).toBe(true);
expect(sessionHookOptions({ remote: { hostId: 'h' } }).deepSeekBridgeUnreachable).toBe(true);
expect(sessionHookOptions({}).deepSeekBridgeUnreachable).toBe(false);
});
it('keeps the hook predicate out of the two gates that mean "is this claude"', () => {
// Read My Mind and intent capture read Claude's own transcript, so they mean
// mode === 'claude'. They used to ask hooksAvailableForMode(), which was the
// same question until `deepseek` earned a yes and silently widened both to a
// mode with no transcript to read. Static, because the alternative is
// standing up a predictor and a transcript watcher to observe one `if`.
const rmm = readFileSync(join(process.cwd(), 'src/web/routes/readmymind-routes.ts'), 'utf-8');
expect(rmm).toContain("session.mode !== 'claude'");
// Comment lines dropped first: the comment above that `if` names the
// predicate in order to explain why it is NOT the one being called there.
const uncommented = (src: string) =>
src
.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join('\n');
expect(uncommented(rmm)).not.toMatch(/hooksAvailableForMode\(/);
const server = readFileSync(join(process.cwd(), 'src/web/server.ts'), 'utf-8');
expect(server).toContain("if (!session || session.mode !== 'claude') return;");
});
it('keeps the transcript reader off docker and remote-SSH sessions', () => {
// A docker case's harness writes its transcript inside the CONTAINER's
// ~/.dsh and a remote-SSH case's lives on the remote host, so the local
// reader would scan a $DSH_HOME that can never hold the file and return
// "nothing said yet" forever — starving an agent that polls the worker.
// Those sessions must keep the pane segmenter. Static, because standing up
// a docker/remote session in the unit harness is exactly what the tmux
// test-mode mocks exist to avoid.
const routes = readFileSync(join(process.cwd(), 'src/web/routes/session-routes.ts'), 'utf-8');
expect(routes).toMatch(/session\.mode === 'deepseek' && !session\.docker && !session\.remote/);
});
it('maps the harness lifecycle states onto real hook events', () => {
expect(DEEPSEEK_STATE_TO_HOOK_EVENT.idle).toBe('stop');
expect(DEEPSEEK_STATE_TO_HOOK_EVENT.blocked).toBe('permission_prompt');
expect(DEEPSEEK_STATE_TO_HOOK_EVENT.working).toBe('agent_working');
// Every mapped event must be one the hook endpoint actually accepts, or the
// bridge would post reports the schema silently rejects.
for (const event of Object.values(DEEPSEEK_STATE_TO_HOOK_EVENT)) {
expect(() => HookEventSchema.parse({ event, sessionId: 's1' })).not.toThrow();
}
});
});
describe('DeepSeek multi-user clamp', () => {
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('clamps a sent danger-full-access down to workspace-write, not read-only', () => {
// The clamp removes PRIVILEGE; it must not also break the session's ability
// to edit its own workspace, which read-only would.
return _clampExternalCliBypassForOwner('nobody', undefined, undefined, undefined, undefined, undefined, {
permissionMode: 'danger-full-access',
}).then((out) => {
expect(out.deepSeekConfig?.permissionMode).toBe('workspace-write');
});
});
it('leaves an ABSENT config absent (the only-if-sent branch)', async () => {
// Omitting DSH_PERMISSION_MODE leaves the harness on its own workspace-write
// preset, which still asks — so there is nothing to materialize, unlike pi.
const out = await _clampExternalCliBypassForOwner(
'nobody',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
);
expect(out.deepSeekConfig).toBeUndefined();
});
});
describe('DeepSeek multi-user clamp: the env-var half', () => {
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 DSH_PERMISSION_MODE, which would otherwise undo the config clamp on the same request', async () => {
// applyEnvOverrides() runs AFTER _configureDeepSeek() in tmux-manager, so an
// override sent alongside the config lands last and WINS. Clamping the config
// alone is therefore half a gate: this is the other half.
const out = await _clampEnvOverridesForOwner('nobody', {
DSH_PERMISSION_MODE: 'danger-full-access',
DSH_TELEMETRY_MODE: 'off',
});
expect(out).toEqual({ DSH_TELEMETRY_MODE: 'off' });
});
it('strips DSH_HOME, which points the launcher at a profile tree that executes at boot', async () => {
const out = await _clampEnvOverridesForOwner('nobody', { DSH_HOME: '/home/attacker/evil-dsh' });
expect(out).toEqual({});
});
it("strips DEEPSEEK_BASE_URL, which would aim the server's own forwarded API key at a foreign host", async () => {
// _configureDeepSeek() exports the SERVER's DEEPSEEK_API_KEY into every dsh
// pane, and applyEnvOverrides() lands after it — so a non-granted owner who
// could set the base URL would have the operator's key sent as a bearer
// credential to an endpoint of their choosing. Their OWN key stays settable:
// that removes privilege rather than granting it.
const out = await _clampEnvOverridesForOwner('nobody', {
DEEPSEEK_BASE_URL: 'https://attacker.example/v1',
DEEPSEEK_API_KEY: 'sk-their-own',
});
expect(out).toEqual({ DEEPSEEK_API_KEY: 'sk-their-own' });
});
it('leaves unrelated overrides alone, and returns the same object when there is nothing to strip', async () => {
const input = { DEEPSEEK_API_KEY: 'sk-test', CODEX_HOME: '/tmp/cx' };
const out = await _clampEnvOverridesForOwner('nobody', input);
expect(out).toBe(input);
expect(await _clampEnvOverridesForOwner('nobody', undefined)).toBeUndefined();
});
it('is a no-op in single-user mode', async () => {
delete process.env.CODEMAN_MULTIUSER;
const input = { DSH_PERMISSION_MODE: 'danger-full-access', DSH_HOME: '/opt/dsh' };
// canUsernameRunPrivilegedCommands() returns true when !isMultiUserMode(), so
// the single-user behaviour has to be byte-identical to before this clamp.
expect(await _clampEnvOverridesForOwner(undefined, input)).toBe(input);
});
});
describe('DeepSeek profile install is bounded for real', () => {
it('runs in its own process group and escalates the kill to the whole tree', () => {
// `dsh plugin add` fans out into package-manager resolver/build children, and
// spawn's own `timeout` signals only the direct child: survivors hold the
// inherited stdio pipes open, `close` never fires, and the held-open request
// leaks forever. Same failure and same fix as runGit() in git-clone.ts.
// Static, because reproducing it needs a real package manager that hangs.
const src = readFileSync(join(process.cwd(), 'src/web/routes/system-routes.ts'), 'utf-8');
const handler = src.slice(src.indexOf("app.post('/api/deepseek/install-profile'"));
const body = handler.slice(0, handler.indexOf('app.post(', 1) + 1 || handler.length);
expect(body).toContain('detached: true');
expect(body).toContain('process.kill(-child.pid, signal)');
expect(body).toContain("killTree('SIGTERM')");
expect(body).toContain("killTree('SIGKILL')");
// The built-in option is the thing that did NOT work here; it must not come back.
expect(body).not.toContain('timeout: DEEPSEEK_INSTALL_TIMEOUT_MS');
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* The generated DeepSeek Harness status shim.
*
* This file is the one piece of DeepSeek's wiring that is neither TypeScript we
* typecheck nor a route we can `inject()` into: it is a script emitted as a
* string, dropped in the data dir, and executed by a third-party TUI as a
* SUBPROCESS. So the assertions here run it the way the harness does — a real
* `node` process, real argv, real env, against a real listener — rather than
* inspecting the source text.
*
* The exit codes are the contract's load-bearing half: the caller retries with
* backoff on any non-zero, so "cannot ever succeed" (unknown verb, unmapped
* state) must exit 0 or one typo becomes four HTTP requests per state change,
* forever.
*/
import { describe, expect, it, beforeEach, beforeAll, afterAll } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import { createServer, type Server } from 'node:http';
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, chmodSync } from 'node:fs';
import { dirname } from 'node:path';
import {
ensureDeepSeekStatusShim,
deepSeekStatusShimPath,
resetDeepSeekStatusShimForTest,
DEEPSEEK_STATE_TO_HOOK_EVENT,
} from '../src/deepseek-status-shim.js';
const PORT = 3251;
describe('DeepSeek status shim: provisioning', () => {
beforeEach(() => {
resetDeepSeekStatusShimForTest();
});
it('writes an executable shim that node can actually parse', () => {
const path = ensureDeepSeekStatusShim();
expect(path).toBeTruthy();
expect(existsSync(path!)).toBe(true);
// 0700: the TUI execs it directly, so a lost exec bit means every report
// fails and is retried four times per state change.
expect(statSync(path!).mode & 0o777).toBe(0o700);
// `node --check` on the real file, because a template-literal typo in
// SHIM_SOURCE is invisible to tsc — the shim is a STRING as far as the
// compiler is concerned.
expect(() => execFileSync(process.execPath, ['--check', path!], { stdio: 'pipe' })).not.toThrow();
});
it('refreshes a shim written by an older Codeman, and leaves no temp file behind', () => {
const path = deepSeekStatusShimPath();
ensureDeepSeekStatusShim();
const current = readFileSync(path, 'utf-8');
// A v1 shim from an older install: right path, stale content.
writeFileSync(path, '#!/usr/bin/env node\n// codeman-dsh-status-shim v1\nprocess.exit(0)\n', { mode: 0o700 });
resetDeepSeekStatusShimForTest();
ensureDeepSeekStatusShim();
expect(readFileSync(path, 'utf-8')).toBe(current);
// The rewrite goes through a temp + rename so a TUI exec'ing this path mid
// refresh can never read a half-written file. The temp must not survive it.
const strays = readdirSync(dirname(path)).filter((f) => f.startsWith('dsh-status-shim') && f.endsWith('.tmp'));
expect(strays).toEqual([]);
});
it('re-asserts the exec bit even when the content already matches', () => {
const path = ensureDeepSeekStatusShim()!;
chmodSync(path, 0o600); // a restored backup / copied data dir
resetDeepSeekStatusShimForTest();
ensureDeepSeekStatusShim();
expect(statSync(path).mode & 0o777).toBe(0o700);
});
});
describe('DeepSeek status shim: the supervisor contract', () => {
let server: Server | undefined;
const received: Array<{ body: unknown; secret: string | undefined }> = [];
let status = 200;
const listen = () =>
new Promise<void>((resolve) => {
server = createServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
received.push({
body: (() => {
try {
return JSON.parse(raw);
} catch {
return raw;
}
})(),
secret: req.headers['x-codeman-hook-secret'] as string | undefined,
});
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end('{}');
});
});
server.listen(PORT, '127.0.0.1', resolve);
});
beforeAll(() => listen());
afterAll(() => {
server?.close();
});
/**
* Run the shim the way the TUI does, and ASYNCHRONOUSLY.
*
* Never spawnSync here: the listener above lives in this same process, so a
* synchronous spawn blocks the event loop that has to accept the connection.
* The shim then waits out its own 1500ms socket timeout and exits 1, which
* reads exactly like a broken shim (measured: `Socket._onTimeout` in its exit
* trace, and the server logging nothing).
*/
const run = (args: string[], env: Record<string, string> = {}) =>
new Promise<{ status: number | null; stderr: string }>((resolve) => {
const path = ensureDeepSeekStatusShim()!;
const child = spawn(process.execPath, [path, ...args], {
env: {
...process.env,
CODEMAN_API_URL: `http://127.0.0.1:${PORT}`,
CODEMAN_SESSION_ID: 'sess-from-env',
...env,
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (c: Buffer) => (stderr += c.toString('utf-8')));
child.on('close', (status) => resolve({ status, stderr }));
});
// The exact command line the harness TUI runs, from the Herdr contract.
const report = (state: string, extra: string[] = []) => [
'pane',
'report-agent',
'pane-arg-id',
'--source',
'custom:dsh-tui',
'--agent',
'dsh-tui',
'--state',
state,
...extra,
'--seq',
'7',
];
it('forwards each harness state as its mapped hook event, and exits 0 on delivery', async () => {
resetDeepSeekStatusShimForTest();
for (const [state, event] of Object.entries(DEEPSEEK_STATE_TO_HOOK_EVENT)) {
received.length = 0;
const out = await run(report(state, ['--message', 'needs a decision']));
expect(out.status, `${state}: ${out.stderr}`).toBe(0);
expect(received).toHaveLength(1);
const body = received[0].body as { event: string; sessionId: string; data: Record<string, unknown> };
expect(body.event).toBe(event);
// The ambient env wins over the pane argument: same code set both, and the
// argument is whatever the TUI chose to pass.
expect(body.sessionId).toBe('sess-from-env');
expect(body.data.agent).toBe('dsh-tui');
expect(body.data.message).toBe('needs a decision');
}
});
it('sends the hook secret read at EXECUTION time, so rotation needs no respawn', async () => {
resetDeepSeekStatusShimForTest();
const secretFile = `${deepSeekStatusShimPath()}.secret-fixture`;
writeFileSync(secretFile, 'rotated-secret\n', { mode: 0o600 });
received.length = 0;
const out = await run(report('idle'), { CODEMAN_HOOK_SECRET_FILE: secretFile });
expect(out.status).toBe(0);
expect(received[0].secret).toBe('rotated-secret');
});
it('exits 0 without posting for anything a retry could never fix', async () => {
resetDeepSeekStatusShimForTest();
for (const args of [
['pane', 'list'], // unknown verb
['something-else', 'report-agent', 'id', '--state', 'idle'], // unknown noun
[...report('rebooting')], // a state this bridge does not map
['pane', 'report-agent', 'id'], // no --state at all
]) {
received.length = 0;
const out = await run(args);
expect(out.status, `args ${args.join(' ')}`).toBe(0);
expect(received).toEqual([]);
}
});
it('exits non-zero when the post genuinely fails, so the caller retries', async () => {
resetDeepSeekStatusShimForTest();
// A rejecting server: transport worked, Codeman said no.
status = 500;
received.length = 0;
expect((await run(report('idle'))).status).not.toBe(0);
expect(received).toHaveLength(1);
status = 200;
// Nothing listening at all.
expect((await run(report('idle'), { CODEMAN_API_URL: 'http://127.0.0.1:1' })).status).not.toBe(0);
// No API url to post to.
expect((await run(report('idle'), { CODEMAN_API_URL: '' })).status).not.toBe(0);
});
});
+395
View File
@@ -0,0 +1,395 @@
/**
* Reading a DeepSeek Harness session transcript.
*
* Two of these assertions exist because the obvious implementation was measured
* to be wrong against real files:
*
* - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder stops
* at the first frame end. A 56-line transcript decoded as 1 line / 158 bytes,
* which reads as "the worker never answered" rather than as an error. The
* multi-frame fixtures below are the guard.
* - a fresh worker in a case directory that had been used before answered its
* first `last-response` with the PREVIOUS session's reply. Session-to-
* transcript pairing is therefore its own describe block.
*/
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import * as zlib from 'node:zlib';
import {
decodeZstdFrames,
findDeepSeekTranscript,
parseDeepSeekTranscript,
readDeepSeekLastResponse,
resetDeepSeekTranscriptMemoForTest,
resolveDeepSeekHome,
zstdFrameRanges,
zstdSupported,
} from '../src/deepseek-transcript.js';
const zstdCompressSync = (zlib as unknown as { zstdCompressSync?: (b: Buffer) => Buffer }).zstdCompressSync;
/** Compress each line into its own frame — exactly how dsh appends. */
function framed(lines: string[]): Buffer {
if (!zstdCompressSync) throw new Error('zstd unavailable');
return Buffer.concat(lines.map((line) => zstdCompressSync(Buffer.from(`${line}\n`, 'utf8'))));
}
const sessionHeader = (cwd: string, createdAt: number, id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') =>
JSON.stringify({ type: 'session', version: 0, id, createdAt, cwd, delegationDepth: 0 });
const userPrompt = (text: string) =>
JSON.stringify({
type: 'user/message',
data: { content: [{ type: 'text', text }], source: { kind: 'user' }, role: 'user' },
});
const pluginContext = (text: string) =>
JSON.stringify({
type: 'user/message',
data: { content: [{ type: 'text', text }], source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' } },
});
const assistantMessage = (text: string, turn = 1, step = 1, time = 1_700_000_000_000) =>
JSON.stringify({
type: 'assistant/message',
time,
data: { turn, step, message: { role: 'assistant', content: [{ type: 'text', text }] } },
});
const turnEnd = (turn: number, reason: Record<string, unknown>, time = 1_700_000_000_001) =>
JSON.stringify({ type: 'turn/end', time, data: { turn, reason } });
describe.skipIf(!zstdSupported())('zstd frame walking', () => {
it('decodes every frame, not just the first (the silent-truncation bug)', () => {
const lines = Array.from({ length: 40 }, (_, i) => JSON.stringify({ type: 'noise', seq: i }));
const buf = framed(lines);
// The one-shot decoder is what this module had to replace.
const oneShot = (zlib as unknown as { zstdDecompressSync?: (b: Buffer) => Buffer }).zstdDecompressSync!(buf);
expect(oneShot.toString('utf8').trim().split('\n')).toHaveLength(1);
expect(zstdFrameRanges(buf)).toHaveLength(40);
expect(decodeZstdFrames(buf).trim().split('\n')).toHaveLength(40);
});
it('round-trips a single-frame file', () => {
const buf = framed(['{"type":"session"}']);
expect(decodeZstdFrames(buf)).toBe('{"type":"session"}\n');
});
it('passes an uncompressed transcript straight through', () => {
const plain = Buffer.from('{"type":"session"}\n{"type":"turn/start"}\n', 'utf8');
expect(decodeZstdFrames(plain)).toBe('{"type":"session"}\n{"type":"turn/start"}\n');
});
it('keeps the whole frames before a torn tail instead of failing the read', () => {
const buf = framed(['{"a":1}', '{"b":2}', '{"c":3}']);
const torn = buf.subarray(0, buf.length - 4);
const decoded = decodeZstdFrames(torn);
expect(decoded).toContain('{"a":1}');
expect(decoded).toContain('{"b":2}');
expect(decoded).not.toContain('{"c":3}');
});
it('refuses to walk a buffer that is not zstd', () => {
expect(zstdFrameRanges(Buffer.from('not zstd at all', 'utf8'))).toEqual([]);
});
});
describe('parseDeepSeekTranscript', () => {
it('returns the last turn text and skips plugin-injected context', () => {
const raw = [
sessionHeader('/w', 1),
userPrompt('what is 2+2?'),
pluginContext('Current runtime context. This snapshot supersedes earlier snapshots.'),
assistantMessage('4.'),
turnEnd(1, { kind: 'completed' }),
].join('\n');
const result = parseDeepSeekTranscript(raw, { blocks: true });
expect(result.text).toBe('4.');
expect(result.cwd).toBe('/w');
expect(result.blocks.filter((b) => b.kind === 'prompt').map((b) => b.text)).toEqual(['what is 2+2?']);
expect(result.blocks.some((b) => b.text.includes('runtime context'))).toBe(false);
});
it('drops a leaked reasoning prefix at the closing tag', () => {
const raw = [
sessionHeader('/w', 1),
assistantMessage('I should read the file first.</think>\n\nThe add function is wrong.'),
turnEnd(1, { kind: 'completed' }),
].join('\n');
expect(parseDeepSeekTranscript(raw).text).toBe('The add function is wrong.');
});
it('renders tool calls and tool results as tool blocks', () => {
const raw = [
sessionHeader('/w', 1),
JSON.stringify({
type: 'assistant/message',
data: {
turn: 1,
step: 1,
message: {
role: 'assistant',
content: [
{ type: 'text', text: 'Reading it.' },
{ type: 'tool-call', id: 'c1', name: 'read', arguments: '{"file_path":"calc.py"}' },
],
},
},
}),
JSON.stringify({
type: 'tool/result',
data: {
turn: 1,
step: 1,
message: {
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'def add' }] }],
},
},
}),
assistantMessage('It subtracts instead of adding.', 1, 2),
turnEnd(1, { kind: 'completed' }),
].join('\n');
const result = parseDeepSeekTranscript(raw, { blocks: true });
expect(result.blocks.filter((b) => b.kind === 'tool').map((b) => b.text)).toEqual([
'read({"file_path":"calc.py"})',
'def add',
]);
// Both steps of the turn read back, in order.
expect(result.text).toBe('Reading it.\n\nIt subtracts instead of adding.');
});
it('uses streamed deltas only for a step the model never finalized', () => {
const raw = [
sessionHeader('/w', 1),
JSON.stringify({
type: 'assistant/chunk',
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'Par' } },
}),
JSON.stringify({
type: 'text-chunks',
seq: null,
data: { turn: 1, step: 1, index: 0, texts: ['is is ', 'the'] },
}),
JSON.stringify({
type: 'assistant/chunk',
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: ' capital.' } },
}),
].join('\n');
// Still streaming: the partial answer is readable.
expect(parseDeepSeekTranscript(raw).text).toBe('Paris is the capital.');
// Once finalized, the deltas must not be appended a second time.
const finalized = `${raw}\n${assistantMessage('Paris is the capital.')}\n${turnEnd(1, { kind: 'completed' })}`;
expect(parseDeepSeekTranscript(finalized).text).toBe('Paris is the capital.');
});
it('does not resurrect raw deltas for a step whose reply was all reasoning', () => {
// Measured on a real conversation: step 1 finalized as reasoning only, so
// its text stripped to '' and the (unstripped) deltas took its place,
// putting `</think>` and the monologue back in front of the caller.
const raw = [
sessionHeader('/w', 1),
JSON.stringify({
type: 'assistant/chunk',
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: "I'll read the file.</think>\n\n" } },
}),
assistantMessage("I'll read the file.</think>\n\n", 1, 1),
assistantMessage('The add function is wrong.', 1, 2),
turnEnd(1, { kind: 'completed' }),
].join('\n');
expect(parseDeepSeekTranscript(raw).text).toBe('The add function is wrong.');
});
it('answers a failed turn with its error rather than the previous turn text', () => {
const raw = [
sessionHeader('/w', 1),
assistantMessage('First answer.', 1),
turnEnd(1, { kind: 'completed' }),
turnEnd(2, { kind: 'error', error: { message: '400: model does not support tools', code: 'INVALID_REQUEST' } }),
].join('\n');
expect(parseDeepSeekTranscript(raw).text).toBe('Turn error: 400: model does not support tools');
});
it('calls an early stop an ending, not an error, and keeps the text it did produce', () => {
const raw = [sessionHeader('/w', 1), assistantMessage('Most'), turnEnd(1, { kind: 'max-tokens' })].join('\n');
const result = parseDeepSeekTranscript(raw, { blocks: true });
expect(result.text).toBe('Most');
expect(result.blocks.map((b) => b.text)).toContain('Turn ended: max-tokens');
});
it('survives a torn last line', () => {
const raw = [sessionHeader('/w', 1), assistantMessage('Complete.'), '{"type":"turn/e'].join('\n');
expect(parseDeepSeekTranscript(raw).text).toBe('Complete.');
});
it('is empty for a session that has said nothing', () => {
expect(parseDeepSeekTranscript(sessionHeader('/w', 1)).text).toBe('');
});
});
describe('resolveDeepSeekHome', () => {
it('prefers the session override over the environment', () => {
const previous = process.env.DSH_HOME;
process.env.DSH_HOME = '/from-env';
try {
expect(resolveDeepSeekHome({ deepSeekHomeOverride: '/from-session' })).toBe('/from-session');
expect(resolveDeepSeekHome({})).toBe('/from-env');
} finally {
if (previous === undefined) delete process.env.DSH_HOME;
else process.env.DSH_HOME = previous;
}
});
it('falls back to ~/.dsh', () => {
const previous = process.env.DSH_HOME;
delete process.env.DSH_HOME;
try {
expect(resolveDeepSeekHome({})).toMatch(/\.dsh$/);
} finally {
if (previous !== undefined) process.env.DSH_HOME = previous;
}
});
});
describe.skipIf(!zstdSupported())('session-to-transcript pairing', () => {
let dshHome: string;
const workspace = '/home/tester/cases/worker-1';
const sessionStart = 1_800_000_000_000;
/** Write a transcript for `cwd`, created at `createdAt`, mtime `mtime`. */
async function writeTranscript(name: string, cwd: string, createdAt: number, mtime: number, answer?: string) {
const dir = join(dshHome, 'sessions', '--home-tester-cases-worker-1--', name);
await mkdir(dir, { recursive: true });
const lines = [sessionHeader(cwd, createdAt, name)];
if (answer) lines.push(assistantMessage(answer), turnEnd(1, { kind: 'completed' }));
const path = join(dir, 'session.jsonl.zstd');
await writeFile(path, framed(lines));
await utimes(path, new Date(mtime), new Date(mtime));
return path;
}
beforeAll(async () => {
dshHome = await mkdtemp(join(tmpdir(), 'dsh-home-'));
});
afterAll(async () => {
await rm(dshHome, { recursive: true, force: true });
});
it("never hands a fresh session its predecessor's answer", async () => {
await writeTranscript('older', workspace, sessionStart - 600_000, sessionStart - 590_000, 'stale answer');
const found = await findDeepSeekTranscript({ dshHome, workingDir: workspace, startedAt: sessionStart });
expect(found).toBeNull();
const result = await readDeepSeekLastResponse({
workingDir: workspace,
createdAt: sessionStart,
deepSeekHomeOverride: dshHome,
});
expect(result).not.toBeNull();
expect(result?.text).toBe('');
});
it('pairs on the boot window even when a sibling wrote more recently', async () => {
await writeTranscript('mine', workspace, sessionStart + 2_000, sessionStart + 2_000, 'my answer');
await writeTranscript('sibling', workspace, sessionStart + 300_000, sessionStart + 400_000, 'sibling answer');
const found = await findDeepSeekTranscript({ dshHome, workingDir: workspace, startedAt: sessionStart });
expect(found).toContain('/mine/');
const result = await readDeepSeekLastResponse({
workingDir: workspace,
createdAt: new Date(sessionStart),
deepSeekHomeOverride: dshHome,
});
expect(result?.text).toBe('my answer');
});
it('ignores a transcript recorded for another workspace', async () => {
const other = await mkdtemp(join(tmpdir(), 'dsh-home-'));
try {
const dir = join(other, 'sessions', '--home-tester-cases-worker-1--', 'foreign');
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, 'session.jsonl.zstd'),
framed([sessionHeader('/somewhere/else', sessionStart + 1_000), assistantMessage('not yours')])
);
const found = await findDeepSeekTranscript({ dshHome: other, workingDir: workspace, startedAt: sessionStart });
expect(found).toBeNull();
} finally {
await rm(other, { recursive: true, force: true });
}
});
it('reads a transcript created later in the session (a /new conversation)', async () => {
const later = await mkdtemp(join(tmpdir(), 'dsh-home-'));
try {
const dir = join(later, 'sessions', '--home-tester-cases-worker-1--', 'after-new');
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, 'session.jsonl.zstd'),
framed([
sessionHeader(workspace, sessionStart + 1_800_000),
assistantMessage('after /new'),
turnEnd(1, { kind: 'completed' }),
])
);
const result = await readDeepSeekLastResponse({
workingDir: workspace,
createdAt: sessionStart,
deepSeekHomeOverride: later,
});
expect(result?.text).toBe('after /new');
} finally {
await rm(later, { recursive: true, force: true });
}
});
it('reports an unreadable home as empty, not as an error', async () => {
const result = await readDeepSeekLastResponse({
workingDir: workspace,
createdAt: sessionStart,
deepSeekHomeOverride: join(tmpdir(), 'dsh-home-that-does-not-exist'),
});
expect(result).toEqual({ text: '', timestamp: '', blocks: [] });
});
it('serves an unchanged transcript from the memo and re-reads when the stat moves', async () => {
const home = await mkdtemp(join(tmpdir(), 'dsh-home-'));
try {
const dir = join(home, 'sessions', '--home-tester-cases-worker-1--', 'memo');
await mkdir(dir, { recursive: true });
const path = join(dir, 'session.jsonl');
const stamp = new Date(sessionStart + 1_000);
const read = () =>
readDeepSeekLastResponse({ workingDir: workspace, createdAt: sessionStart, deepSeekHomeOverride: home });
const body = (answer: string) =>
`${[sessionHeader(workspace, sessionStart + 1_000), assistantMessage(answer), turnEnd(1, { kind: 'completed' })].join('\n')}\n`;
resetDeepSeekTranscriptMemoForTest();
await writeFile(path, body('AAAA'));
await utimes(path, stamp, stamp);
expect((await read())?.text).toBe('AAAA');
// Same byte length, same forced mtime: indistinguishable from unchanged
// by stat, and deliberately served from the memo — the 1s/poll skill loop
// must not decode an unchanged file, and dsh only ever APPENDS, so a
// same-stat rewrite does not exist outside a test.
await writeFile(path, body('BBBB'));
await utimes(path, stamp, stamp);
expect((await read())?.text).toBe('AAAA');
// An append moves mtime (and normally size), which is the invalidation.
await utimes(path, new Date(sessionStart + 2_000), new Date(sessionStart + 2_000));
expect((await read())?.text).toBe('BBBB');
} finally {
await rm(home, { recursive: true, force: true });
}
});
});
+73
View File
@@ -0,0 +1,73 @@
/**
* The background `dsh web` supervisor and the authority boundary in front of it.
*
* Two things here are worth pinning and neither is obvious from reading the
* module:
*
* 1. `authority` becomes an argv element of a spawned process (`--trusted-host
* <authority>`). The spawn is an argv ARRAY so a shell can never see it, but
* the schema is the layer that stops a value which is not a browser
* authority at all from reaching the command line, and a regex is easy to
* widen by accident.
* 2. The supervisor tracks at most ONE server. The status accessor is what every
* caller reads to decide whether to start another, so "no server" must report
* as absent rather than as a half-populated record.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import { DeepSeekWebStartSchema } from '../src/web/schemas.js';
import { getDeepSeekWebStatus, resetDeepSeekWebForTest, stopDeepSeekWeb } from '../src/deepseek-web-server.js';
describe('DeepSeekWebStartSchema: the authority reaching --trusted-host', () => {
it('accepts the authority shapes a browser can actually report', () => {
for (const authority of [
'localhost:3000',
'127.0.0.1:5013',
'tnode.tailf80371.ts.net:8444',
'codeman.example.com',
'[::1]:3000',
'host-with-dashes.local:80',
]) {
expect(DeepSeekWebStartSchema.safeParse({ authority }).success, authority).toBe(true);
}
});
it('rejects values that are not an authority at all', () => {
for (const authority of [
'',
'http://localhost:3000', // a URL, not an authority
'localhost:3000 --trusted-host evil', // an embedded second argument
'-oProxyCommand=evil', // leading dash, readable as a flag
'localhost:3000/../path',
'local host:3000',
'user:pass@localhost:3000',
'a'.repeat(256),
]) {
expect(DeepSeekWebStartSchema.safeParse({ authority }).success, authority).toBe(false);
}
});
it('is strict, so an unexpected field cannot ride along', () => {
expect(DeepSeekWebStartSchema.safeParse({ authority: 'localhost:3000', port: 1 }).success).toBe(false);
});
it('requires the field rather than defaulting it', () => {
// A guessed default would silently fence dsh's /api against the wrong
// origin, which presents as a dashboard whose every call 403s.
expect(DeepSeekWebStartSchema.safeParse({}).success).toBe(false);
});
});
describe('DeepSeek web supervisor: status', () => {
beforeEach(() => {
resetDeepSeekWebForTest();
});
it('reports absent as fully null, not a half-filled record', () => {
expect(getDeepSeekWebStatus()).toEqual({ running: false, port: null, url: null, authority: null });
});
it('stopping when nothing runs resolves rather than throwing', async () => {
await expect(stopDeepSeekWeb()).resolves.toBeUndefined();
expect(getDeepSeekWebStatus().running).toBe(false);
});
});
+26
View File
@@ -329,6 +329,32 @@ describe('resolveDockerCredentialArtifacts (isolated codex/gemini/gcloud/opencod
expect(mounts).toEqual([]);
expect(seedCopies).toEqual([]);
});
it('omp: shares sessions/ RW (host-side history/resume reads), seeds config files only', () => {
mkdirSync(join(home, '.omp', 'agent', 'sessions'), { recursive: true });
writeFileSync(join(home, '.omp', 'agent', 'config.yml'), '');
writeFileSync(join(home, '.omp', 'agent', 'mcp.json'), '{}');
writeFileSync(join(home, '.omp', 'agent', 'models.yml'), '');
writeFileSync(join(home, '.omp', 'agent', 'settings.yml'), '');
// Regenerable local state that must NOT be seeded (mirrors the pi/grok exclusions).
writeFileSync(join(home, '.omp', 'agent', 'agent.db'), '');
mkdirSync(join(home, '.omp', 'agent', 'terminal-sessions'), { recursive: true });
const { mounts, seedCopies } = resolveDockerCredentialArtifacts(home);
expect(mounts).toContainEqual({
src: join(home, '.omp', 'agent', 'sessions'),
dst: '/home/agent/.omp/agent/sessions',
});
const dests = seedCopies.map((s) => s.to);
expect(dests).toContain('/home/agent/.omp/agent/config.yml');
expect(dests).toContain('/home/agent/.omp/agent/mcp.json');
expect(dests).toContain('/home/agent/.omp/agent/models.yml');
expect(dests).toContain('/home/agent/.omp/agent/settings.yml');
expect(dests).not.toContain('/home/agent/.omp/agent/agent.db');
expect(mounts.some((m) => m.dst === '/home/agent/.omp/agent/terminal-sessions')).toBe(false);
// seed copies of individual files are NOT recursive
expect(seedCopies.filter((s) => s.to.startsWith('/home/agent/.omp')).every((s) => !s.recursive)).toBe(true);
});
});
describe('resolveDockerClaudeArtifacts (isolated claude state)', () => {
+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
+79
View File
@@ -125,6 +125,45 @@ describe('Inline rename input', () => {
expect(renameStillActive).toBe(true);
});
it('Escape cancels the rename instead of committing an empty name', async () => {
await resetState();
expect(await startRename('esc-cancel', 'rail-beta')).toBe(true);
// Escape used to clear the field and blur, and the blur handler commits —
// so cancelling a rename PUT an empty name, and the tab fell back to its
// folder label (measured against a live server, in the header strip as well
// as both vertical layouts). The observable here is the REQUEST: this
// harness's server has no such session, so a failed PUT would leave the
// local map looking innocent.
const result = await page.evaluate(async () => {
const app = (window as unknown as { app: { _activeRename: unknown } }).app;
const calls: string[] = [];
const origFetch = window.fetch;
window.fetch = (async (input: RequestInfo | URL) => {
calls.push(String(input));
return new Response('{"success":true}', { status: 200 });
}) as typeof window.fetch;
const inputEl = document.querySelector('input.tab-rename-input') as HTMLInputElement;
inputEl.value = 'typed-but-abandoned';
inputEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
// The blur that follows the input's removal must not resurrect the commit.
inputEl.dispatchEvent(new Event('blur'));
await new Promise((r) => setTimeout(r, 50));
window.fetch = origFetch;
return {
renamePuts: calls.filter((url) => url.includes('/api/sessions/esc-cancel/name')),
renameActive: !!app._activeRename,
inputStillInDom: document.body.contains(inputEl),
};
});
expect(result.renamePuts).toEqual([]);
expect(result.renameActive).toBe(false);
expect(result.inputStillInDom).toBe(false);
});
it('CJK guard: regular Enter (no IME) DOES commit', async () => {
await resetState();
expect(await startRename('regular-enter', 'OldName')).toBe(true);
@@ -502,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';
+13 -2
View File
@@ -424,7 +424,18 @@ describe('mobile overview run picker (CLI availability gating)', () => {
isCliAvailable: () => true,
});
const menu = app._buildMobileOverviewRunMenu();
expect(modeButtons(menu)).toEqual(['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']);
expect(modeButtons(menu)).toEqual([
'claude',
'opencode',
'codex',
'gemini',
'antigravity',
'pi',
'grok',
'deepseek',
'omp',
'shell',
]);
});
it('gates every mode the picker actually offers', () => {
@@ -437,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 }),
+114
View File
@@ -0,0 +1,114 @@
/** @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('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 } },
});
});
+25
View File
@@ -19,6 +19,8 @@ import { isGeminiAvailable } from '../src/utils/gemini-cli-resolver.js';
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';
@@ -55,6 +57,20 @@ vi.mock('../src/utils/grok-cli-resolver.js', () => ({
resolveGrokDir: vi.fn(() => null),
getGrokCliVersion: vi.fn(() => null),
}));
// DeepSeek is the one mode with a two-part availability answer (binary AND a
// pane-capable profile), so both probes are mocked independently.
vi.mock('../src/utils/deepseek-cli-resolver.js', () => ({
isDeepSeekAvailable: vi.fn(() => false),
isDeepSeekRunnable: vi.fn(() => false),
resolveDeepSeekDir: vi.fn(() => null),
getDeepSeekCliVersion: vi.fn(() => null),
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),
@@ -145,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({});
@@ -160,6 +179,9 @@ describe('WebServer.renderIndexHtml', () => {
antigravity: false,
pi: true,
grok: false,
deepseek: false,
deepseekBinary: false,
omp: true,
cloudflared: true,
git: true,
});
@@ -176,6 +198,9 @@ describe('WebServer.renderIndexHtml', () => {
isAntigravityAvailable,
isPiAvailable,
isGrokAvailable,
isDeepSeekAvailable,
isDeepSeekRunnable,
isOmpAvailable,
isCloudflaredAvailable,
isGitAvailable,
]) {
+143
View File
@@ -0,0 +1,143 @@
/**
* @fileoverview Upstream review fix (Ark0N/Codeman#353, PR #3): resumeHistorySession()
* threads the row's own mode through session creation via a `modeConfigKey` map
* (opencode/pi/grok/omp → `continueSession: true`), then retires the old row via
* DELETE. codex/gemini/antigravity were missing from that map, so resuming one of
* their rows created a session with NO continuation while still deleting the row
* it came from — data loss dressed as a fix. The correction: only retire the row
* when the new session actually continues something.
*
* Loaded via `vm` against a stub CodemanApp, same harness as resume-name.test.ts.
* `fetch` is a shared mutable stub so each test can inspect exactly which requests
* fired without a real network/server.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi, beforeEach } from 'vitest';
/* eslint-disable @typescript-eslint/no-explicit-any */
/** The fetch the vm's shipping code calls; swapped per test (see beforeEach). */
let currentFetch: (...args: unknown[]) => unknown = () => {
throw new Error('fetch not stubbed for this test');
};
function loadTerminalUiPrototype(): Record<string, (...args: unknown[]) => unknown> {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: vi.fn(() => null) },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
fetch: (...args: unknown[]) => currentFetch(...args),
});
vm.runInContext(`${source}\nglobalThis.__proto = CodemanApp.prototype;`, context);
return (context as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
}
const proto = loadTerminalUiPrototype();
function makeApp() {
return {
terminal: { clear: vi.fn(), writeln: vi.fn(), focus: vi.fn() },
cases: [],
resumeHistorySession: proto.resumeHistorySession as (...args: unknown[]) => Promise<void>,
_closeFolderHistoryModal: vi.fn(),
_resolveResumeName: () => 'w1-case',
loadAppSettingsFromStorage: () => ({}),
getCaseSettings: () => ({}),
buildEnvOverrides: () => ({}),
getEffortSetting: () => undefined,
selectSession: vi.fn(async () => {}),
};
}
/** DELETE calls the fetch mock recorded. */
function deleteCalls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls
.filter(([, opts]: [string, { method?: string }]) => opts?.method === 'DELETE')
.map(([url]: [string]) => url);
}
/** POST /api/sessions body the fetch mock recorded. */
function createBody(fetchMock: ReturnType<typeof vi.fn>): any {
const call = fetchMock.mock.calls.find(([url]: [string]) => url === '/api/sessions');
return call ? JSON.parse((call[1] as { body: string }).body) : undefined;
}
function stubFetch(newSessionId: string): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn(async (url: string) => {
if (url === '/api/sessions') {
return { json: async () => ({ success: true, data: { session: { id: newSessionId } } }) };
}
return { json: async () => ({ success: true }) };
});
currentFetch = fetchMock;
return fetchMock;
}
describe('resumeHistorySession: row retirement is gated on actual continuation', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = stubFetch('new-session-id');
});
it.each(['codex', 'gemini', 'antigravity'])(
'does NOT retire the old row for %s (no continuation is wired for it)',
async (mode) => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode);
expect(createBody(fetchMock)).toMatchObject({ mode });
expect(createBody(fetchMock).codexConfig).toBeUndefined();
expect(createBody(fetchMock).geminiConfig).toBeUndefined();
expect(createBody(fetchMock).antigravityConfig).toBeUndefined();
expect(deleteCalls(fetchMock)).toEqual([]);
}
);
it.each([
['opencode', 'openCodeConfig'],
['pi', 'piConfig'],
['grok', 'grokConfig'],
['omp', 'ompConfig'],
])('retires the old row for %s (continueSession is wired via %s)', async (mode, configKey) => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', mode);
expect(createBody(fetchMock)[configKey]).toEqual({ continueSession: true });
expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']);
});
it('retires the old row for deepseek (resumeSession is wired)', async () => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'old-id', '/repo', 'w1-repo', 'deepseek');
expect(createBody(fetchMock).deepSeekConfig).toEqual({ resumeSession: true });
expect(deleteCalls(fetchMock)).toEqual(['/api/sessions/old-id?killMux=true']);
});
it('never retires a claude row (resumeSessionId is a claudeSessionId, not a Codeman row id)', async () => {
const app = makeApp();
await app.resumeHistorySession.call(app, 'claude-uuid', '/repo', 'w1-repo', 'claude');
expect(createBody(fetchMock)).toMatchObject({ mode: 'claude', resumeSessionId: 'claude-uuid' });
expect(deleteCalls(fetchMock)).toEqual([]);
});
it('never retires when the new session id equals the old one (no-op resume)', async () => {
fetchMock = stubFetch('same-id');
const app = makeApp();
await app.resumeHistorySession.call(app, 'same-id', '/repo', 'w1-repo', 'omp');
expect(deleteCalls(fetchMock)).toEqual([]);
});
});
+30
View File
@@ -341,6 +341,36 @@ describe('session-routes', () => {
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('removes a persisted-only session (not live) via the state store, without touching cleanupSession', async () => {
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({
id: 'ghost-session',
owner: undefined,
} as never);
const res = await harness.app.inject({
method: 'DELETE',
url: '/api/sessions/ghost-session',
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx.store.demoteOrRemoveSession).toHaveBeenCalledWith('ghost-session');
expect(harness.ctx.cleanupSession).not.toHaveBeenCalled();
// Ark0N/Codeman#353 review: the persisted-only branch used to demote/remove
// with no broadcast, so other open tabs kept showing the retired row until
// their next unrelated fetch.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' });
});
it('404s a persisted-only session id the state store does not recognize either', async () => {
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce(null);
const res = await harness.app.inject({
method: 'DELETE',
url: '/api/sessions/truly-nonexistent',
});
expect(res.statusCode).toBe(404);
expect(harness.ctx.store.demoteOrRemoveSession).not.toHaveBeenCalled();
});
});
// ========== DELETE /api/sessions (delete all) ==========
+22 -2
View File
@@ -81,6 +81,16 @@ describe('run mode UI', () => {
expect(app.runMode).toBe('antigravity');
expect(runBtnLabel.textContent).toBe('Run AG');
});
it('accepts OMP mode from server sync and updates the run button label', async () => {
const { app, storage, runBtnLabel } = loadRunModeHarness();
storage.set('codeman_runMode', 'claude');
await app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'omp' }));
expect(app.runMode).toBe('omp');
expect(runBtnLabel.textContent).toBe('Run OMP');
});
});
describe('Run launch synchronization', () => {
@@ -367,12 +377,13 @@ describe('Codex quick start settings', () => {
'welcomeGeminiBtn',
'welcomePiBtn',
'welcomeGrokBtn',
'welcomeOmpBtn',
'welcomeTunnelBtn',
]) {
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
}
const modeBtns: Record<string, { style: { display: string } }> = {};
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'shell']) {
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'omp', 'shell']) {
modeBtns[mode] = { style: { display: 'PRISTINE' } };
}
const menu = {
@@ -405,6 +416,7 @@ describe('Codex quick start settings', () => {
antigravity: false,
pi: false,
grok: false,
omp: false,
cloudflared: false,
};
@@ -442,16 +454,23 @@ describe('Codex quick start settings', () => {
withAgy.app.applyWelcomeCliVisibility();
expect(withAgy.welcomeBtns.welcomeAntigravityBtn.style.display).toBe('flex');
expect(withAgy.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
// OMP is a first-class welcome action, gated on `omp` like the rest.
const withOmp = loadUi({ ...ALL_OFF, omp: true });
withOmp.app.applyWelcomeCliVisibility();
expect(withOmp.welcomeBtns.welcomeOmpBtn.style.display).toBe('flex');
expect(withOmp.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
});
it('gates every run mode in the dropdown, antigravity included, and never shell', () => {
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true });
const { app, modeBtns, menu } = loadUi({ ...ALL_OFF, claude: true, antigravity: true, omp: true });
app._refreshRunModeAvailability(menu);
expect(modeBtns.claude.style.display).toBe('flex');
expect(modeBtns.antigravity.style.display).toBe('flex');
expect(modeBtns.opencode.style.display).toBe('none');
expect(modeBtns.codex.style.display).toBe('none');
expect(modeBtns.gemini.style.display).toBe('none');
expect(modeBtns.omp.style.display).toBe('flex');
// Shell needs no external CLI, and leaving it alone is what guarantees the
// menu is never empty on a box with nothing installed.
expect(modeBtns.shell.style.display).toBe('PRISTINE');
@@ -468,6 +487,7 @@ describe('Codex quick start settings', () => {
expect(offered).toContain('antigravity');
expect(offered).toContain('pi');
expect(offered).toContain('grok');
expect(offered).toContain('omp');
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));
+166 -1
View File
@@ -54,6 +54,8 @@ interface LayoutApp {
_fullRenderSessionTabs(): void;
updateConnectionLines(): void;
isSessionSidebarRich(): boolean;
isTabRailRich(): boolean;
isRichTabRows(): boolean;
_sidebarRichRow(id: string, session: Record<string, unknown>): RichRow | null;
_sidebarRichMetaHTML(row: RichRow | null): string;
_updateSidebarRichRow(tab: Element, id: string, session: Record<string, unknown>): void;
@@ -709,6 +711,22 @@ describe('rich session sidebar', () => {
expect(html).toContain('data-i18n-skip');
});
it('carries both absolute stamps on the LINE, not only on the two items', () => {
// Below 288px the rail hides `.tab-meta-created` (tab-rail-tight), and a
// title on a `display: none` element has no hover target — so a tooltip
// living only there means the created stamp is gone, not shrunk. The line
// itself has to carry it for the CSS rule's "still reachable" to be true.
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
const html = app._sidebarRichMetaHTML(app._sidebarRichRow('s1', SESSION));
const line = html.slice(0, html.indexOf('>'));
expect(line).toContain('class="tab-meta"');
expect(line).toContain('title="');
expect(line).toContain('First created');
expect(line).toContain('working');
});
it('drops the second stamp when the session has never been active', () => {
const { app } = boot({ stored: { sessionListLayout: 'sidebar-rich' } });
stubOverview(app);
@@ -795,7 +813,7 @@ describe('rich session sidebar', () => {
// .tab-info is already a flex column, so the line needs no row-level
// wrapping — and the collapsed 44px rail hides .tab-info wholesale, which is
// what keeps the stamps out of it for free.
expect(APP).toContain('const richRows = this.isSessionSidebarRich();');
expect(APP).toContain('const richRows = this.isRichTabRows();');
expect(APP).toContain('const richMeta = this._sidebarRichMetaHTML(richRow);');
expect(APP).toContain('${richMeta}\n </span>');
});
@@ -819,3 +837,150 @@ describe('rich session sidebar', () => {
expect(MOBILE_CSS).toContain('html[data-session-list="sidebar"][data-sidebar-detail="rich"] .session-sidebar {');
});
});
describe('detailed rows in the vertical tab rail', () => {
/**
* The rail is the SECOND surface that draws rich rows. Everything about the
* row itself (model, markup, clock) is shared with the sidebar and covered
* above; what is new here is only the gate — which attribute turns it on,
* and the three ways it must turn back off.
*/
const railBoot = (stored: Record<string, unknown>) => {
const booted = boot({ stored: { sessionListLayout: 'header', ...stored } });
booted.app.applySessionListLayout();
booted.app.applyTabOrientation();
return booted;
};
it('defaults the rail to detailed rows, since a docked column is not a tab strip', () => {
const { win, app } = railBoot({ tabOrientation: 'vertical' });
expect(win.document.documentElement.dataset.tabOrientation).toBe('vertical');
expect(win.document.documentElement.dataset.tabRailDetail).toBe('rich');
expect(app.isTabRailRich()).toBe(true);
expect(app.isRichTabRows()).toBe(true);
// The stamps go stale with no event behind them, so the clock has to run.
expect(app._sidebarRichClock).toBeTruthy();
});
it("honors the 'simple' opt-out", () => {
const { win, app } = railBoot({ tabOrientation: 'vertical', tabRailDetail: 'simple' });
expect(win.document.documentElement.dataset.tabRailDetail).toBe('simple');
expect(app.isTabRailRich()).toBe(false);
expect(app.isRichTabRows()).toBe(false);
// Falsy rather than null: _stopSidebarRichClock() returns early when there
// is no interval to clear, which is the state a rail that never armed one is in.
expect(app._sidebarRichClock).toBeFalsy();
});
it('drops back to simple rows once the rail is dragged into compact width', () => {
// Below 240px the rail already hides the row actions; three lines of stamps
// in a ~208px column ellipsize into noise. _setTabRailWidth() re-renders
// whenever this class flips, so the gate is re-read at the right moment.
const { win, app } = railBoot({ tabOrientation: 'vertical' });
expect(app.isTabRailRich()).toBe(true);
win.document.documentElement.classList.add('tab-rail-compact');
expect(app.isTabRailRich()).toBe(false);
expect(app.isRichTabRows()).toBe(false);
});
it('never draws stamps in the horizontal header strip', () => {
// tabRailDetail stays 'rich' in storage while the orientation is horizontal:
// the gate has to read BOTH, or the header strip inherits a meta line that
// has nowhere to go.
const { win, app } = railBoot({ tabOrientation: 'horizontal', tabRailDetail: 'rich' });
expect(win.document.documentElement.dataset.tabRailDetail).toBe('rich');
expect(app.isTabRailRich()).toBe(false);
expect(app.isRichTabRows()).toBe(false);
});
it('leaves the simple sidebar simple even with the rail set to detailed', () => {
// The sidebar owns the tabs whenever it is active, which forces the
// orientation back to horizontal — so a rail preference must not leak a
// meta line into a list the user asked to keep compact.
const { app } = railBoot({ sessionListLayout: 'sidebar', tabOrientation: 'vertical', tabRailDetail: 'rich' });
expect(app.isSessionSidebarActive()).toBe(true);
expect(app.isSessionSidebarRich()).toBe(false);
expect(app.isTabRailRich()).toBe(false);
expect(app.isRichTabRows()).toBe(false);
});
it('re-renders when only the DETAIL changes, orientation untouched', () => {
const { win, app } = railBoot({ tabOrientation: 'vertical', tabRailDetail: 'simple' });
(app._fullRenderSessionTabs as unknown as { mockClear(): void }).mockClear();
win.localStorage.setItem(
'codeman-app-settings',
JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'vertical', tabRailDetail: 'rich' })
);
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applyTabOrientation();
// The stamps line is emitted by the row template, not toggled by CSS: a
// missed render here means the setting repaints nothing until the next tick.
expect(win.document.documentElement.dataset.tabRailDetail).toBe('rich');
expect(app._fullRenderSessionTabs).toHaveBeenCalled();
expect(app._sidebarRichClock).toBeTruthy();
});
it('still renders on the first call, when applyTabWrapSettings only sets its baseline', () => {
// The pre-paint script stamps the layout attributes; if it THREW it leaves
// them on the catch-branch fallbacks and applyTabOrientation() is the first
// thing to correct them, with `_tallTabsEnabled` still undefined.
// applyTabWrapSettings() renders only when it has a previous value to
// compare, so reading "the value changed" as "it rendered" skipped BOTH
// renders and left the rows stale.
const { win, app } = boot({ stored: { sessionListLayout: 'header', tabOrientation: 'vertical' } });
expect(app._tallTabsEnabled).toBeUndefined();
expect(win.document.documentElement.getAttribute('data-tab-orientation')).toBeNull();
app.applyTabOrientation();
expect(win.document.documentElement.dataset.tabOrientation).toBe('vertical');
// The folder row turned on in the same pass, so this is exactly the case
// where the two guards could point at each other and neither fires.
expect(app._tallTabsEnabled).toBe(true);
expect(app._fullRenderSessionTabs).toHaveBeenCalled();
});
it('does not render twice when applyTabWrapSettings already did', () => {
// The mirror case: a detail flip that turns the folder row off makes
// applyTabWrapSettings() re-render, and applyTabOrientation() must not
// stack a second full rebuild of the strip on top of it.
const { win, app } = railBoot({ tabOrientation: 'vertical', tabRailDetail: 'rich' });
expect(app._tallTabsEnabled).toBe(true);
(app._fullRenderSessionTabs as unknown as { mockClear(): void }).mockClear();
win.localStorage.setItem(
'codeman-app-settings',
JSON.stringify({ sessionListLayout: 'header', tabOrientation: 'vertical', tabRailDetail: 'simple' })
);
delete (app as unknown as { _cachedAppSettings?: unknown })._cachedAppSettings;
app.applyTabOrientation();
expect(app._tallTabsEnabled).toBe(false);
expect(app._fullRenderSessionTabs).toHaveBeenCalledTimes(1);
});
it('plumbs the rail detail through the settings UI, the schema and the pre-paint script', () => {
expect(INDEX_HTML).toContain('id="appSettingsTabRailDetail"');
expect(INDEX_HTML).toContain('<option value="rich">Detailed</option>');
// Pre-paint stamps it with the rest of the layout keys, or a detailed rail
// paints as a simple one for the first frame and then jumps a row taller.
expect(INDEX_HTML).toContain("dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich'");
expect(SETTINGS_UI).toContain("document.getElementById('appSettingsTabRailDetail').value");
const displayKeys = SETTINGS_UI.slice(SETTINGS_UI.indexOf('const displayKeys = new Set(['));
expect(displayKeys.slice(0, 1800)).toContain("'tabRailDetail'");
expect(SCHEMAS).toMatch(/tabRailDetail:\s*z\.enum\(\['simple',\s*'rich'\]\)\.optional\(\)/);
});
it('gives every rich paint rule a rail twin without raising the sidebar arm', () => {
// Comma-grouped, never :is() — an :is() list takes its most specific
// argument, which would lift the sidebar selectors from (0,3,1) to the
// rail's (0,5,1) and let them outrank rules they never used to.
const rail = "html[data-tab-orientation='vertical'][data-tab-rail-detail='rich']:not(.tab-rail-compact) .tab-rail";
for (const suffix of ['.tab-meta', '.tab-meta-key', '.tab-pill', '.tab-pill--working']) {
expect(STYLES_CSS).toContain(`${rail} ${suffix}`);
}
expect(STYLES_CSS).not.toContain(':is(html[data-sidebar-detail="rich"]');
});
});
+59
View File
@@ -53,6 +53,10 @@ describe('tab rail width policy', () => {
expect(policy.resolveKeyboardWidth({ ...base, key: 'Home' })).toBe(208);
expect(policy.resolveKeyboardWidth({ ...base, key: 'End' })).toBe(360);
expect(policy.resolveKeyboardWidth({ ...base, key: 'Enter' })).toBe(256);
// Enter resets to the CALLER's effective default: a rich rail passes 320
// (its unsized rendering width), so the reset cannot land it below the
// 288px tight threshold the way a hardcoded 256 did.
expect(policy.resolveKeyboardWidth({ ...base, key: 'Enter', defaultWidth: 320 })).toBe(320);
expect(policy.resolveKeyboardWidth({ ...base, key: 'Escape' })).toBeNull();
});
});
@@ -123,6 +127,61 @@ describe('tab rail resize wiring', () => {
expect(app._tabRailResizeOwnsObserver).toBe(false);
});
it('re-runs the wrap pass when the compact threshold flips, without double-rendering', () => {
const controller = readPublic('tab-rail-resize.js');
class FakeCodemanApp {}
const classes = new Set<string>();
const context = vm.createContext({
CodemanApp: FakeCodemanApp,
window: { CodemanTabRail: loadRailPolicy() },
document: {
documentElement: {
style: { setProperty: () => {} },
classList: {
contains: (c: string) => classes.has(c),
toggle: (c: string, force: boolean) => {
if (force) classes.add(c);
else classes.delete(c);
return force;
},
},
},
getElementById: () => null,
querySelector: () => null,
},
console,
clearTimeout,
setTimeout,
});
vm.runInContext(controller, context, { filename: 'tab-rail-resize.js' });
const app = new FakeCodemanApp() as FakeCodemanApp & Record<string, any>;
app._getTabRailBounds = () => ({});
app.syncTabRailWidthSetting = vi.fn();
app._fullRenderSessionTabs = vi.fn();
// Rich rail dragged below 240px: applyTabWrapSettings() owns the folder
// line and reads the compact class this call just toggled, so it must be
// re-consulted on the flip — and when its own conditional render fires
// (the folder flag changed), the explicit render must not double it.
app._tallTabsEnabled = true;
app.applyTabWrapSettings = vi.fn(() => {
app._tallTabsEnabled = false;
app._fullRenderSessionTabs();
});
app._setTabRailWidth(210);
expect(app.applyTabWrapSettings).toHaveBeenCalledOnce();
expect(app._fullRenderSessionTabs).toHaveBeenCalledOnce();
// Flip back up with an unchanged folder flag (simple-detail rail): the
// explicit render must still fire — the compact row-action affordance
// changed even though the wrap pass rendered nothing.
app.applyTabWrapSettings = vi.fn();
app._fullRenderSessionTabs = vi.fn();
app._setTabRailWidth(300);
expect(app.applyTabWrapSettings).toHaveBeenCalledOnce();
expect(app._fullRenderSessionTabs).toHaveBeenCalledOnce();
});
it('keeps resize-observer ownership for pointer drags longer than the watchdog', async () => {
vi.useFakeTimers();
const controller = readPublic('tab-rail-resize.js');
+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;

Some files were not shown because too many files have changed in this diff Show More