Compare commits

...
Author SHA1 Message Date
Codeman maintainer a164c07f92 chore: version packages 2026-09-07 22:54:36 +02:00
Codeman maintainer 4f2dfb4e6d fix(mobile): carry resumeId through the phone overview's past rows
#386 made Codex conversations resumable from Past Sessions, and
resumeMobileOverviewSession() correctly passes row.resumeId on to
resumeHistorySession(). The phone's own row projection never copied the
field off the unified-list item though, so row.resumeId was always
undefined there and a tapped Codex row started a FRESH session on a thread
that was already on disk. The desktop path worked; only the phone was blind.

The test fails without the projection line, and pins the other half too: a
claude row must not grow a resumeId, since the field is what distinguishes
"resume this conversation" from "start a new one".

Docs: CLAUDE.md and architecture-invariants both still described the unified
list as merging Claude transcript files. It has been three stores since this
PR (Claude's ~/.claude/projects, omp's ~/.omp/agent/sessions, codex's
~/.codex/sessions), the alias field keeps its Claude-era name without being
Claude-only, and the scanner-only rule behind resumeId was written down
nowhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 22:44:25 +02:00
Ark0N 344e93c824 Merge pull request #386 from irisitymichaelgrundberg/feat/codex-resume
Merging with the phone-overview resumeId fix and the two unified-list doc passages applied on master.
2026-09-07 22:43:46 +02:00
Codeman maintainer f1b7283393 fix(cli-registry): guard workDetect.workingLine like every other config regex
#385 made the composer glyph and the working status line per-CLI registry
data, which is right, but `workingLine` arrived as a config-supplied regex
validated with a bare `new RegExp()`. That skips `compileVersionRegex()`,
the helper the registry uses for exactly this: a `~/.codeman/clis.json`
override can set the field, the compiled pattern is run against every
accumulated PTY chunk and every pane capture, and a nested quantifier there
backtracks on the event loop for the whole server rather than one session.

Route it through the helper in both places, which are not redundant: the
schema refine rejects the entry at LOAD time so a bad pattern never reaches
a session, and `_workingLinePattern()` compiles through the same helper so
the runtime cannot hold a pattern the schema would have refused. The helper
returns null instead of throwing, so the Claude-pattern fallback stops being
a try/catch and becomes structural. Both shipped patterns compile unchanged,
and Claude's is behaviourally identical to CLAUDE_WORKING_LINE_PATTERN.

Also match the Codex footer case-insensitively on the E. It was
characterised against codex-cli 0.152.1, which prints a lowercase `esc`;
a version capitalising it would make the whole fix silently inert, since
the pane would simply never look like it was working.

Docs: CLAUDE.md, architecture-invariants and cli-registry.md all still
stated the Claude-mode-only rule this PR retires, and none of them named
the new capability or the regex guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 22:42:20 +02:00
Ark0N a49be03f96 Merge pull request #385 from irisitymichaelgrundberg/fix/work-detection-external-clis
Merging with follow-up fixes applied on master: workingLine routed through compileVersionRegex() in both the schema refine and _workingLinePattern(), the Codex footer matched case-insensitively on the E, plus the doc passages that stated the retired Claude-mode-only rule.
2026-09-07 22:41:25 +02:00
Codeman maintainer 7fde978ce8 chore: version packages 2026-09-07 19:11:56 +02:00
Codeman maintainer 8ee7926e27 feat(agent-cases): tag agent-spawned case dirs and sweep their leftovers
A long orchestration creates one case directory per worker and deleting the
sessions never removed them, so ~/codeman-cases accumulated scratch folders
that were indistinguishable from real projects. They are now labelled and
have a cleanup path.

- src/agent-case-marker.ts: a case dir quick-start CREATES for an agent-driven
  spawn gets a .codeman-agent-case.json marker (when, by whom, parent session,
  mode). Only the create branch writes it, so a linked case, a cloned repo or
  any pre-existing path is never labelled; reading is total, so a malformed
  marker means "not agent-created" rather than a half-trusted entry.
- The signal is the new X-Codeman-Agent-Origin header the skill preamble sets
  on its shared curl (preamble bumped to 1.22.0), or an agentOrigin body
  field, falling back to a resolved parentSessionId so a worker spawned by a
  stale skill copy is still labelled.
- GET /api/cases publishes it as agentCreated; GET /api/cases/agent-created is
  a read-only cleanup listing adding inUse and modifiedAt; Add Case -> Manage
  badges each case and offers a review-then-delete sweep that names every
  directory in its confirm and skips any case a live session is working in.
  Removal stays on the existing DELETE /api/cases/:name.
- Agent preamble caches are collected too: ~/.cache/codeman-agent-<id>.sh was
  written per claude session and never removed (236 leftovers measured on a
  working machine). Now deleted with the session and swept at boot, guarded by
  a live-session keep set plus a 7-day age floor.

Verified end to end on an isolated instance: marker written for header, body
and lineage-only spawns, absent with no agent signal and for a pre-existing
directory; inUse flipping on session end; badge, sticky bar, confirm and sweep
driven in a browser; preamble seeded on create, removed on delete, boot sweep
taking only the aged orphans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:09:24 +02:00
Michael GrundbergandClaude Opus 5 2f9663e389 Merge branch 'master' into feat/codex-resume
master and this branch both rewrote the two `_claudeSessionId` resets inside
`start()`, so `src/session.ts` conflicted at both of them.

master's commit ccfda623 puts `restoredConversation` at the head of each
fallback chain. A restored mux attach means the CLI never stopped, so a
`/clear` before the Codeman restart may already have moved it to a
conversation the launch id knows nothing about. The persisted chain's tail is
that conversation, and the CLI's own hook reported it first-hand.

This branch adds `this._codexConfig?.resumeSessionId` to the same two chains,
so a resumed codex session keeps its thread-id alias across every mux reattach
and boot recovery.

Both fixes belong. Each chain now reads restoredConversation, then
_resumeSessionId, then omp's alias, then codex's alias, then the launch id.
The comments from both sides are kept.

test/session-claude-conversation-chain.test.ts pins the shape of those two
assignments by matching the source text, and its pattern named omp's alias as
the last term before `this.id`. Codex's alias now sits between the two, so the
pattern widens to pin the ends of the chain and let the middle grow. A `[^;]`
run cannot cross a statement boundary, so each match is still one assignment.

Checked on the merged tree: typecheck, lint, prettier and the frontend syntax
check all pass, and the CI suite runs 6721 tests green across 349 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 08:48:31 +02:00
Codeman maintainer 61d22eee1c chore: version packages
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 00:06:00 +02:00
Codeman maintainer 92af855ce4 fix(base-path): keep the crash beacon under the mount, strip CODEMAN_BASE_URL in tests, add the wiring test
The merge-time items from the #381 review. navigator.sendBeacon is not fetch,
so the base-aware wrapper never saw the two crash-diag beacons and a sub-path
install posted them to the origin root every two seconds. The test suite now
strips CODEMAN_BASE_URL like CODEMAN_GESTURE, since the constructor reads it
as a fallback and an operator who exports it would see the root-install
byte-identity assertions fail. test/base-path-server.test.ts boots a real
WebServer under /codeman and checks the ingress strip, the base injection,
the rebased redirects, the 404 envelope and a prefixed WebSocket upgrade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:11:01 +02:00
Ark0N cfc8fe7e41 Merge pull request #381 from mtiller/feat/reverse-proxy-base-url
feat(web): support a reverse-proxy base URL
2026-09-06 23:10:23 +02:00
Codeman maintainer 80397fe140 fix(hooks,mobile): the merge-time items from the #367 and #368 reviews
#367 (UserPromptSubmit hook): `hook:prompt_submitted` went on the wire
unregistered; it is now in both SSE registries (158 = 158), and the hook only
lands in the run summary when the conversation actually moved, since one row
per prompt would evict useful rows from the 1000-event FIFO and clutter the
Summary timeline and /api/search.

#368 (Add Case header submit): the pending-state dimming targeted the footer
button, which the <=860px layout hides, so on a phone the only visible submit
control stayed at full brightness while a clone ran. The header button now
dims too, and a static test pins the header-submit contract so it cannot
silently disappear again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 23:05:26 +02:00
Ark0N 7991f481b6 Merge pull request #368 from shenlvkang-collab/pr/mobile-add-case-submit
fix(mobile): give the Add Case modal a reachable submit button
2026-09-06 23:02:45 +02:00
Ark0N bca1b764cc Merge pull request #367 from shenlvkang-collab/pr/claude-conversation-first-hand
fix(session): learn the live Claude conversation from the CLI's own hook
2026-09-06 23:02:33 +02:00
Ark0N 1c1773278f Merge pull request #369 from shenlvkang-collab/pr/claude-response-viewer-per-message
fix(web): render one Claude response-viewer message per model message
2026-09-06 23:02:13 +02:00
Michael Grundberg 327e440607 fix(codex): fold a codex session into its own rollout row
Review fixes for #386.

Duplicate rows. A codex conversation showed twice, once live and once as a
past rollout row, because nothing aliased a codex session to its thread id.
That is worse than cosmetic: the stale row still resumes, so clicking it
starts a second `codex resume` on a thread already open in another pane.

  - A RESUMED session knows its thread id up front, so it folds from its own
    side: add `codexConfig.resumeSessionId` to the `claudeSessionId` chain.
    Not only in the constructor — `start()` recomputes that id at two further
    points (the mux branch, and the unconditional "third reset point" whose
    own comment already warned that omitting omp's fallback there stomps the
    mux branch's resolved alias). Both listed Claude's and omp's ids only, so
    for codex every mux reattach and boot recovery reset the alias back to
    the Codeman id and the duplicate returned.
  - A FRESH session has no thread id until codex writes the rollout, so it is
    folded from the other side. The scanner now reports
    `session_meta.originator`, which is `codeman_<sessionId>` for every pane
    Codeman spawns, and `gatherUnifiedInputs()` stamps the matching live and
    persisted rows, newest rollout winning (`/new` inside the TUI leaves
    several rollouts sharing one originator).
  - Persisted rows read `codexConfig.resumeSessionId` too. A resumed session
    demoted to a persisted-only record would otherwise lose its alias, and
    the originator fallback cannot rescue that one: a resumed rollout keeps
    its ORIGINAL session_meta, so it still names the pane that created the
    thread rather than the pane that resumed it.

Identity cache. It was written as soon as the thread id was known, but codex
writes the first user message only when the user submits, so any scan in that
window pinned `firstPrompt: undefined` for the life of the process — and the
home screen, the command palette and the search-index refresh all scan.
`shouldCacheIdentity()` now keeps an identity only once the prompt is known or
the head read filled its whole window.

Also from review: both caps count emitted rows rather than file index, so a
store of sub-agent threads no longer spends the `lastPrompt` budget before the
first row that needed it; the cache is an `LRUMap` sized like the one beside
it; the unreachable filename fallback is gone; a rollout recording no cwd is
dropped rather than emitted with `workingDir: ''`; and the unified-session
module header names all three transcript stores.

Tests. The resume wiring now has cases for a row with a thread id, a row
without one, and a `resumeId` on a non-codex row; the "no continuation is
wired" case narrows to gemini/antigravity, which is no longer true of codex.
`codex-resume-alias-survives-start.test.ts` drives a real Session through
`start()` rather than asserting on pre-stamped inputs — that gap is why the
reset points went unnoticed. Plus the maintainer's own cache repro, the
tail-budget case, a no-cwd case, and merge cases for both folds.
2026-09-06 21:53:38 +02:00
Codeman maintainer a2aaea3c0e docs(file-picker): state the Home/cases nesting the right way round, and document the new fallback chain
The two merge-time edits the #383 review asked for. The comment above the
picker's fallback chain said Home is nested under Codeman Cases; on the
native default it is the other way round (~/codeman-cases sits inside ~).
And the "Filesystem path picker" paragraph in architecture-invariants still
said the picker falls back to /mnt/d, which #383 changed to: the session's
Current Folder, then the Codeman Cases root, then /mnt/d, then the first
root. No code behaviour changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:20:45 +02:00
Codeman maintainer 9f5010aa51 fix(pr-bot): announce a bot-made merge once, cap automatic retries, show why a review failed
Observed on the first live merge (#383 via the Telegram button): runConfirmed
announced the merge and the scan five seconds later announced it again as a
closed PR. The scan now stays quiet for PRs the bot itself merged or closed,
and a merge of a `merge-with-fixes` verdict reminds that merging applies none
of the listed fixes.

A failed review used to be re-queued on every scan with no limit (two PRs
failed once each and were retried fine, but a head that keeps failing would
cost a session every ten minutes forever): three failures on one head now
stop the automatic retries until /review N or a new push. The failure notice
carries the reviewer's last message, so "finished without writing
report.json" says what it wrote instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:09:47 +02:00
Codeman maintainer f33b37c008 feat(pr-bot): review open PRs in Codeman sessions and report over Telegram
Maintainer tooling in scripts/pr-bot/: a daemon (systemd user unit
codeman-pr-bot) that lists open PRs with gh, reviews each head commit once in
a Codeman claude session (`prbot-<n>`) running in a private `git clone
--shared`, and sends the verdict, ranked findings, checks and a recommendation
to Telegram with action buttons. Merge, close, post-comment and approve-CI
happen only from a Telegram command or button plus a confirmation tap; the
bot never writes to GitHub on its own. The Telegram token and chat id come
from the existing notifier bot's env file.

Verified live: three PRs reviewed end to end (383, 363, 368), reports
delivered with buttons, reviewer sessions on the pinned model. Findings
along the way, each fixed and documented: a linked worktree inherits the
main checkout's model pin (hence the shared clone), undici's 5-minute header
timeout cut off the first review, gh was missing from the service PATH, and
the periodic scan orphaned an in-flight review's record.

typecheck/lint/format now cover scripts/pr-bot; tests in
test/pr-bot-{report,state,commands}.test.ts; guide in docs/pr-bot.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:05:42 +02:00
Ark0N 097d585278 Merge pull request #383 from opticon454/fix/case-picker-default-root
fix(file-picker): default the case picker to Codeman Cases, not Home
2026-09-06 21:03:00 +02:00
Michael Grundberg 8285fff91c feat(codex): list codex conversations and resume them
Codex conversations never appeared in the session list, and the resume path
skipped codex, so picking one back up meant finding its thread id by hand and
POSTing codexConfig.resumeSessionId to /api/sessions.

Two gaps caused it:

- The unified list is built from ~/.claude/projects plus omp's own store.
  Codex writes to neither: its rollouts live in ~/.codex/sessions/<y>/<m>/<d>.
- terminal-ui.js sends a continuation only for the CLIs with a
  "continue most recent" flag. Codex has no such flag — it names a thread by an
  exact id — and nothing supplied one.

Add codex-transcript.ts, the codex analog of omp-transcript.ts, and wire it into
gatherUnifiedInputs() beside the omp scan. A rollout row carries `resumeId`, the
thread id `codex resume` takes, and the resume path sends it as
codexConfig.resumeSessionId.

`resumeId` is what keeps the two kinds of row apart: only a transcript scanner
sets it, so a LIVE codex row — whose sessionId is Codeman's own uuid — can never
ask codex for a thread that does not exist.

Three things measured against a real store of 519 rollouts rather than assumed:

- Rollouts are far too large to read whole (median 407 KiB, p90 1.3 MiB, max
  25 MiB, 381 MiB total), so this reads a 128 KiB head for the identity and the
  opening prompt and a bounded tail for the most recent one. session_meta is
  written once and never rewritten, so per-path identity is cached; a warm
  rescan of that store costs ~75ms against ~470ms cold.
- codex 0.152.1 emits no event_msg/user_message rows at all. It writes
  event_msg/item_completed carrying an item.type of UserMessage. Both shapes are
  read, plus response_item as a last resort.
- That last resort sees injected context, and the first such row is the repo's
  AGENTS.md every time, so injections are dropped rather than used as titles.

Sub-agent threads (thread_source: 'subagent') are left out; codex spawns them
for itself and on a real store they outnumber the resumable threads.
2026-09-06 19:45:36 +02:00
Michael Grundberg 51957e2ed4 fix(session): let each CLI declare how its own pane shows work
A Codex session reported `isWorking: false` for its entire life, including
mid-turn. Codeman has four paths that mark a session working, and all four were
inert for Codex:

- The spinner fast path tests eight braille frames, and Codex animates none.
- The activity-streak fallback was wrapped in `!isExternalCliMode(mode)`.
- The pane probe inside `_confirmIdle` would have matched, since Codex prints
  `esc to interrupt`, but arming it required the literal glyph `❯` and Codex
  draws `›` on its composer row.
- The text detector sat inside `_processExpensiveParsers`, whose first statement
  returns early for an external CLI.

Add an optional `workDetect: { promptGlyph, workingLine }` to CliCapabilities,
so the two strings that differ per CLI are registry data rather than constants
in the detector. Claude declares its existing pair and behaves as before. Codex
declares `›` and `esc to interrupt`. The text detector moves above the
external-CLI early return, guarded on the descriptor so a CLI without one still
skips the ANSI strip that the early return used to save it.

A CLI that declares no descriptor falls back to Claude's pair, and the
activity-streak gate now reads "has a descriptor, or is not external", so the
plain shell mode keeps the behaviour it had.

Rewrite the test that asserted the old premise in its own comment, so it makes
the same guarantee for a genuinely uncharacterised CLI, and add Codex coverage
built from verbatim pane captures on Codex CLI 0.152.1.
2026-09-06 17:41:01 +02:00
Codeman maintainer 8ad2215118 fix(docker): close the three adoption gaps the negative guarantee missed
Review follow-ups to #357. Each is a path that still touched, or still hid, a
container Codeman does not own.

**Export still mutated it.** The four fail-closed layers cover create/start/
stop/remove, but `POST /api/docker-cases/:name/export` reaches the container
twice through neither: a full export `docker commit`s it, and even a
workspace-only export `docker pause`s it first for snapshot consistency. Pause
freezes the owner's processes for as long as the tar takes, on a container we
promised not to touch. Full export is refused for an adopted case (it packages
someone else's container, with their logins, into a bundle Codeman hands out);
workspace-only keeps working and no longer pauses, accepting a live filesystem
the way `tar` does on any running host directory.

**A freshly linked OWNED case became unusable.** The run menu now probes the
container for its CLIs, and a failed probe hides every agent mode behind the
reason. For an adopted case that is right. For an owned one the container does
not exist until the first session launches it, so every newly linked Docker case
answered `container "codeman-case-x" not found (adoption never creates a
container — start it yourself first)` and offered nothing but Shell, for a
container the launch chain was about to create itself. A failed probe is
recorded only when the case is adopted; `CaseInfo.docker.owned` is on the wire
so the frontend can tell them apart. Verified in a browser: owned-with-no-
container offers all ten modes and no notice, adopted-but-stopped offers Shell
and says why.

**Multi-user gating.** Adoption is admin-only, unlike `docker-link` beside it.
Linking creates OUR container, whose sole bind mount `isWorkingDirAllowed` has
already confined to the caller's space; an adopted container's mounts are
whatever its owner gave it, so one mounting `/` hands the adopter a shell over
the whole host — exactly the workspace scoping multi-user mode exists to
enforce. Listing the engine's containers and browsing directories inside an
arbitrary one are machine-level reads and follow the docker-HOST policy for the
same reason. The preflight is deliberately not admin-only: the run menu fires it
for every docker case, so it admits a non-admin for a container already linked
to a case they can access, and nothing else.

Verified end to end against a real pre-existing root container (alpine + tmux,
no bind mounts): adopt, claude session inside it, workspace export, session
close and case unlink all left `StartedAt`, `RestartCount`, `Pid` and `Paused`
untouched; the pane ran the CONTAINER's claude, without
`--dangerously-skip-permissions`; a stopped container was refused at both
preflight and launch and was never started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TecFD9hvPYJ1mkkMtBQbT1
2026-09-05 16:22:54 +02:00
Codeman maintainer 3d8ffcb9a2 Merge pull request #357 from dignfei/feat/docker-adopt-existing-container
feat(docker): attach a case to an already-running container

Conflicts came from work that landed after the PR was opened, and each is
resolved onto the newer abstraction rather than by keeping the older code:

- `defaultDockerCommandForMode` is registry-driven since #347, so the PR's
  `runsAsRoot` arm became `overlays.docker.rootCommand` (claude only). Claude
  Code still refuses `--dangerously-skip-permissions` as root in 2.1.261 and the
  refusal is visible only inside the container, so an adopted root container
  otherwise just shows a dead pane. Which flag to drop is a per-CLI fact, and
  `test/cli-registry-no-id-branching.test.ts` forbids expressing it as a branch.

- The probe's mode list and its mode -> binary table both duplicated the
  registry. They now read `enabledCliIds()` / `discovery.binaries[0]`, which is
  also what fixes the merge's silent regression: the hand-written list predates
  `omp`, and the run menu gates every docker case on this probe, so owned
  containers would have lost that mode. `shell` needs no arm — it declares no
  binary, so it is dropped from the lookup and reported available regardless.

- The per-mode `mode === 'claude' && !cliDir` chain in `tmux-manager.ts` is one
  `missingCliMessage(mode)` gate since #347; the PR's docker exemption moved onto
  it. Its test now pins the single gate instead of counting seven arms.

- The create arm keeps #349's swap-limit warning filter, which the adopted arm
  never reaches; the run-mode list gains `omp` from #353.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TecFD9hvPYJ1mkkMtBQbT1
2026-09-05 16:21:51 +02:00
DevvynandClaude Sonnet 5 06febfa032 fix(file-picker): default the case picker to Codeman Cases, not Home
The "Link Existing" case picker opens with an empty path and no
sessionId, so the browse endpoint's fallback root picked whichever
root happened to be first in the list — which was always `Home`.

On the native default that's harmless (~/codeman-cases nests inside
Home anyway), but a Docker deployment binds CODEMAN_APPDATA_PATH
(Home) and CODEMAN_CASES_PATH at unrelated host paths, so the picker
opened somewhere with no cases in sight. Worse: if CODEMAN_CASES_PATH
is ever changed after cases already exist, the old cases directory
lingers, still reachable, under Home — indistinguishable at a glance
from the real one under the new Codeman Cases root.

Prefer the Codeman Cases root in the fallback chain, ahead of the
generic roots[0].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R9ZSTEenc8soSu9bTi8Xru
2026-09-05 17:14:05 +08:00
Michael TillerandClaude Opus 4.8 7e4914d991 feat(web): support a reverse-proxy base URL (--base-url / CODEMAN_BASE_URL)
Codeman can now be mounted under a sub-path behind a reverse proxy that
forwards the prefix unchanged (e.g. https://host/codeman/). Default is `/`
(root), which is byte-identical to the historical behavior.

Design — few choke points, mirrored ingress/egress:
- src/config/base-path.ts: pure single-source normalize/validate/join/strip.
- Server ingress: stripBasePath() inside Fastify rewriteUrl, so routes stay
  declared prefix-agnostic; un-prefixed requests (hooks, health, docker bridge
  hitting the raw port) pass through unchanged.
- Server egress: one onSend hook prepends the base to root-absolute Location
  headers (covers all redirects).
- HTML: renderIndexHtml points <base href> at the mount and injects
  window.__CODEMAN_BASE__ — ONLY when a base is set (inert at root).
- Frontend runtime URLs: CodemanBase.url() route builder in constants.js,
  applied transparently by a fetch wrapper and explicitly at the
  EventSource/WebSocket/window.open/<img|iframe|a>-src sites.
- sw.js derives its base from self.location; manifest uses relative start_url/scope.
- Web-tab proxy: proxyPrefixFor(cap, basePath) is the single base-aware root that
  cascades to the injected <base>, HTML/attr rewrites, runtimeUrlShim, Set-Cookie
  Path and Location; capabilityFromReferer strips the base off the browser Referer,
  while the ingress parsers stay base-agnostic (rewriteUrl already stripped it).

--base-url rides the daemon relaunch (buildWebArgs) and the service unit
(resolveServicePlan). constants.js is guarded against a missing `window` for
isolated unit-test contexts.

Tests: test/base-path.test.ts (pure helpers), base-path coverage in
webview-proxy/render-index-html/daemon-control; CodemanBase stubbed in the
vm-isolated panels-ui test contexts. Docs: Remote-Access.md (sub-path section +
nginx example), security-architecture.md env table, CLAUDE.md pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUkPBxbumnct6qSrx4JDju
2026-09-04 16:08:57 -04:00
Codeman maintainer 6f7add7ce4 chore: version packages 2026-09-04 20:46:19 +02:00
Codeman maintainer eeb5f9d0b2 docs: web-tab egress guard, capability revocation and referrer policy
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKtW48T1UjAaecHAJxKobE
2026-09-04 15:21:15 +02:00
Codeman maintainer 2ab21c1b32 fix(webview): revoke proxy capabilities on logout and stamp Referrer-Policy
WebviewCapabilityStore.revokeOwner() shipped for two releases with a docstring
claiming logout called it and no caller at all. The capability is a bearer
credential exempt from cookie auth with a rolling TTL refreshed on every use, so
a proxy URL that leaked (browser history, a screenshot, a dashboard with a loose
referrer policy) stayed valid for as long as anything kept polling it.

- POST /api/logout revokes the caller's capabilities (all of them in single-user
  mode), the admin forced logout revokes the target user's, and user deletion
  revokes whatever that user had open. revokeOwner returns the count for the
  admin audit line.
- Proxied responses carry `Referrer-Policy: same-origin` and the upstream's own
  policy is dropped: every URL inside the frame carries the capability, and a
  dashboard on no-referrer-when-downgrade or unsafe-url handed it to any
  third-party host it linked. Verified with Playwright that a sandboxed frame
  under an upstream `unsafe-url` sends no Referer to a third party while the
  root-absolute fetch and the CSS-triggered 404 fallback still reach the
  dashboard.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKtW48T1UjAaecHAJxKobE
2026-09-04 15:21:13 +02:00
Codeman maintainer 550e08a791 fix(webview): refuse link-local and cloud-metadata targets on the resolved address
The web-tab proxy, its Test probe and its WebSocket relay accepted any http(s)
host. A live PoC relayed an IMDSv2-shaped PUT with custom headers to a loopback
echo server through a capability and no cookie, and 169.254.169.254 (decimal,
hex, IPv6-mapped, or via a DNS name) was as valid a dashboard as any other.

Loopback and RFC1918 stay allowed on purpose: a localhost Grafana is the feature.
Only link-local and the fixed cloud-metadata addresses are refused
(169.254.0.0/16, fe80::/10, fd00:ec2::254, 168.63.129.16, 100.100.100.200,
metadata.google.internal), at three stages that are each load-bearing:

- the Zod schema, so a save gets a clear refusal;
- a synchronous hostname check at every connect site, because net.connect skips
  DNS for an IP literal and a lookup hook never sees one;
- a `lookup` hook on an undici Agent (webviewFetch) and on the ws client, which
  judges the RESOLVED addresses of a name and refuses when any is blocked. This
  is what closes DNS rebinding, which a hostname-string check cannot.

Adds undici@^6 so the proxy runs the package's own fetch with the package's own
Agent; a package Agent handed to Node's bundled fetch can mismatch protocols.

Verified live on an isolated beta: 169.254.169.254.nip.io (a real name resolving
to the metadata address) is refused by probe, proxy (403) and WS relay (4003),
while 127.0.0.1.nip.io still passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKtW48T1UjAaecHAJxKobE
2026-09-04 15:21:12 +02:00
Codeman maintainer 99ad9cb236 fix(docker): never exit the server unless something is known to restart it
#373 restarts the Compose container by exiting the server, which is right for
the shipped deployment: `restart: unless-stopped` relaunches it. The updater
verified that policy through the Docker socket and, when it could not (no
socket mounted), failed open and exited anyway. Failing open is the correct
choice for the GATE, where refusing would block every install without a
socket, but not for the kill: a container the daemon does not restart goes
down for good, with no UI left to recover it from. That is exactly the case a
plain `docker run` of this image without `--restart` produces, and the image
sets CODEMAN_IN_CONTAINER=1 itself, so it takes the container path.

The decision now happens server-side, where both the socket and the Compose
env are reachable, and rides down to the script as `--restart-by-exit 0|1`.
It is 1 when the Compose file declared `CODEMAN_RESTART_BY_EXIT=1` (added there
and only there, since that file is what sets the restart policy; the image ENV
deliberately does not) or when the daemon confirmed an auto-restart policy.
Otherwise the build still lands, the status becomes
`completed-needs-manual-restart` with the `docker restart` hint, and the
server keeps running. The shipped deployment is unchanged in effect: with the
socket it was already confirmed, and without it the declaration now covers it.

Also: a root-run `Start-Codeman.sh` (common on Unraid) created the
fingerprint baseline's `.codeman` directory before the container's first start
and left it root-owned, which the unprivileged server could then never write
its own state into. It is chowned to PUID:PGID when running as root.

Verified with a real image build of the merged tree (classic builder; this
box's BuildKit lacks buildx): runs as uid 1000, tsc/esbuild and the toolchain
present, the four CLIs at their pins, docker/.env absent, and `docker inspect
$HOSTNAME` returns the restart policy through the mounted socket as that user.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg6bcATm1pNNY4kQWGwzgu
2026-09-04 14:36:35 +02:00
Codeman maintainer 823f56a243 Merge pull request #373 from opticon454/feature/docker-self-update
feat(docker): restore in-app self-update in the Compose dep
2026-09-04 14:25:56 +02:00
Codeman maintainer 72fd231d11 test(setup): one answer for CODEMAN_DATA_DIR, the strip from #371
#356 and #371 fixed the same leak two ways. #356 pointed CODEMAN_DATA_DIR at a
second throwaway directory and cleaned it up in afterAll and on exit; #371
deletes the variable along with CODEMAN_INSTANCE and CODEMAN_TMUX_SOCKET, so
`getDataDir()` falls back to `homedir()`, which the temp HOME already redirects.
Merged as they were, setup.ts set the variable and deleted it a few lines
later, and the second directory was created for nothing.

The strip wins: same protection, one tree to clean up, and the isolation test
#371 adds pins the list statically. The extra directory, its restore and its
two rmSync calls go, the vitest config `env` entries that set the same variable
go (they were documented as inert and would now be contradicted by the setup
file either way), the two test comments that described the old mechanism are
reworded, and CLAUDE.md's testing paragraph names the three stripped variables
and why CODEMAN_INSTANCE has to be stripped in the setup file rather than a hook.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg6bcATm1pNNY4kQWGwzgu
2026-09-04 14:20:11 +02:00
Codeman maintainer 65d19c725e Merge pull request #371 from opticon454/fix/test-env-instance-isolation
fix(test): strip the instance-selection env vars in test/setup.ts
2026-09-04 14:20:04 +02:00
Codeman maintainer 80626567b2 chore: version packages 2026-09-04 14:01:20 +02:00
Codeman maintainer a81e87f440 fix(cli-registry): log why clis.json was ignored, and say 0600 when that is the rule
The loader refuses a `clis.json` with any group/world permission bit, read bits
included, so a file created with a normal umask (0644) is ignored. That is a
defensible posture for a file that chooses the binaries Codeman spawns, but two
things around it made the override feature look dead: the warning said
"group/world-writable", which a 0644 file is not, and `LoadResult.warnings` was
returned to a caller nobody wired up, so nothing anywhere printed it. A user
following the docs got silence.

The message now names the rule and the command that satisfies it, the loader
logs every warning once on first load (the result is memoized, so once per
process), the module header stops claiming that nothing ever writes (the
quarantine rename of a malformed file is a write, on first use) and the
registry doc gains a short section on the override file with the 0600
requirement in it. Whether the check should relax to writable bits only is a
separate decision; this keeps the shipped behaviour and makes it visible.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg6bcATm1pNNY4kQWGwzgu
2026-09-04 13:51:20 +02:00
Codeman maintainer 2e0129f1f8 docs(test): name the real reason the suite could reach ~/.codeman
#356 stopped a bare suite run from overwriting the production
`remote-hosts.json` by pointing `CODEMAN_DATA_DIR` at a throwaway dir, and it
gated every case-tree delete on the temp HOME. Both changes are right; the
explanation written next to them is not. It says `os.homedir()` reads
/etc/passwd rather than `$HOME` on Linux, which would mean the temp HOME in
test/setup.ts never worked. It does: libuv checks the env var before the passwd
entry (measured: `HOME=/tmp/x node -e 'console.log(os.homedir())'` prints
/tmp/x), and CLAUDE.md's testing section relies on exactly that.

What bypasses the temp HOME is `CODEMAN_DATA_DIR` itself. `getDataDir()` reads
it as an absolute override before it looks at `homedir()`, so one inherited from
the shell (a second instance, a beta run) sends the whole suite at the real data
dir. That is the case setup.ts now closes, and #371 names the same variable from
the other direction.

The comments in setup.ts, the `safeRmHomeTree` helper, the voice-routes and
case-clone tests now say that, and the containment gate is described as what it
is: defense in depth. CLAUDE.md's testing paragraph gets the same note so the
next reader does not chase a homedir() bug that does not exist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg6bcATm1pNNY4kQWGwzgu
2026-09-04 13:50:22 +02:00
Codeman maintainer 28b44237ae fix(remote): classify the has-session probe by exit status, and forget it once the pane is back
#355 made the remote auto-reconnect watcher revive a dead pane only when the
durable remote tmux session is verifiably still alive, which is the right rule:
a clean Ctrl-C / Ctrl-D / exit tears that session down and must never relaunch
a fresh agent. Its probe, though, read `has-session`'s stdout and treated an
empty string as "gone". `tmux has-session` prints NOTHING on success (measured
on a scratch socket: exit 0, empty stdout, the failure message goes to stderr),
so every live remote session classified as gone and transport-drop reconnects
were silently disabled along with the clean-exit revives.

The probe now goes by exit status through a pure, unit-tested mapping
(`classifyRemoteAliveExit`): 0 is alive; ssh's own 255, a timeout (`killed`,
no numeric code) and a spawn failure are unknown, which the watcher already
treats as do-not-revive; any other status is the remote command's and means
gone (tmux's 1 for a missing session, 127 when tmux is not installed there).

Two smaller things in the same area:

- The cached answer was never invalidated, so after one successful reattach a
  stale `true` would have revived the NEXT clean exit (the original bug back
  after the first transport drop), and a cached `false` from a clean exit would
  have left a manually restarted session with auto-reconnect permanently off.
  The tick now forgets the cache entry whenever the pane is seen alive.
- The fire-and-forget probe has a 15s timeout against a 5s tick, so an
  unreachable host stacked up to three ssh processes per dead session. An
  in-flight set caps it at one.

The probe command is pinned as a literal string, and the reattach-then-clean-exit
sequence is driven through the watcher in the tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg6bcATm1pNNY4kQWGwzgu
2026-09-04 13:50:22 +02:00
Ark0N 96960785d2 Merge pull request #356 from timkjr/pr/test-isolation
fix(test): isolate route tests from the production ~/.codeman data dir
2026-09-04 13:50:03 +02:00
Ark0N ee6a7af1d1 Merge pull request #355 from timkjr/pr/remote-exit
fix(remote): never auto-revive a remote session after a clean agent exit
2026-09-04 13:49:49 +02:00
Ark0N 850b00572c Merge pull request #347 from opticon454/feature/cli-registry-core
PR A: CLI registry core as a pure internal refactor
2026-09-04 13:49:35 +02:00
timkjr 4a63ab1604 test: extend CASES_DIR containment guard to the rest of the suite
#356 introduced safeRmHomeTree/isUnderTestHome to stop tests from deleting
the PRODUCTION ~/codeman-cases tree on platforms where os.homedir() ignores
the $HOME override -- but only applied it to the one file caught doing it
live. CASES_DIR has no CODEMAN_DATA_DIR-style env override at all, so every
other test file's raw rmSync(join(CASES_DIR, ...)) was the same unguarded
pattern, just not yet triggered.

Routes every CASES_DIR delete in these 10 files through safeRmHomeTree:
cli-skill-target, edge-cases, integration-flows, operation-lightspeed,
ralph-integration, routes/case-clone-routes, routes/voice-routes,
session-cleanup, sse-events, sse-subscription-filter.

Also fixes one instance in case-clone-routes.test.ts that mkdirSync'd then
rmSync'd a CASES_DIR path directly with no guard at all -- the exact
clobbering pattern #356 exists to prevent, found by extending the sweep.

Held as a separate commit (and intended as a separate PR once #356 merges)
rather than folding into #356 -- keeps the already-checked skinny fix
reviewable on its own; this is the same bug class applied broadly, not new
functionality.

Verified: all 10 files pass (180 tests), npm run typecheck clean.
2026-09-02 20:41:24 -05:00
timkjr 4068c02b9e fix(test): write the remote-hosts fixture where the route actually reads it
The "never writes hooks for a remote attach" test stubbed CODEMAN_DATA_DIR
to a separate throwaway dir just for this write, but session-routes.ts's
CODEMAN_CONFIG_DIR is a module-load-time constant frozen at test/setup.ts's
sandboxed dir before this test ever runs. The fixture landed somewhere the
route handler could never read, so the remote-host lookup silently failed
(NOT_FOUND) and the test passed for the wrong reason -- createErrorResponse
never sets reply.code(), so Fastify's default 200 made the NOT_FOUND branch
and the intended success branch indistinguishable by status code alone.

Write straight to getDataDir() instead, matching the docker-hosts fixture
convention already used elsewhere in this file. Verified the fix actually
exercises the success path (host resolves, 200 with a real session), not
just an accidental 200 from the error branch.
2026-09-02 20:41:24 -05:00
timkjr ff88b6957e fix(test): guard the CASES_DIR delete + harden the data-dir teardown
PR #356 stopped the remote-hosts.json fixture write from clobbering prod.
Two holes in the same file remain:

1. The quick-start afterEach still ran rmSync(CASES_DIR, recursive).
   CASES_DIR is join(homedir(), 'codeman-cases'), and on Linux builds
   where os.homedir() reads /etc/passwd instead of $HOME it resolves to
   the PROD case tree - so a full-suite run deleted the real
   ~/codeman-cases. Add a shared safeRmHomeTree() containment gate that
   only deletes a path under the redirected test HOME.

2. setup.ts teardown did rmSync(process.env.CODEMAN_DATA_DIR ?? '') AFTER
   restoring the env - if a pre-existing prod CODEMAN_DATA_DIR was set,
   that deleted prod. Capture the throwaway dir in a const and clean that.

A broader test-isolation sweep (10 files: cli-skill-target, edge-cases,
integration-flows, operation-lightspeed, ralph-integration,
case-clone-routes, voice-routes, session-cleanup, sse-events,
sse-subscription-filter) also applies the same containment gates to every
per-case delete. It is intentionally NOT included here to keep this PR
skinny; it is identified and available on request.
2026-09-02 20:41:24 -05:00
timkjr 2694d3f74a fix(test): isolate route tests from the production ~/.codeman data dir
session-routes-workspace-hooks.test.ts wrote its h1/box/10.0.0.5 host
fixture into getDataDir()/remote-hosts.json. getDataDir() resolves via
homedir() → ~/.codeman (INSTANCE_SUFFIX='' by default), and overriding
HOME in test/setup.ts does NOT change os.homedir() on Linux — so every
full-suite run silently overwrote the PRODUCTION remote-hosts.json,
wiping user-defined remote hosts, emptying the launch-case dropdown and
breaking remote session creation (found live 2026-08-29).

The vitest v4 test.env config key is ignored (probe confirmed the
worker still saw CODEMAN_DATA_DIR=undefined), so the reliable fix is
stubbing the env inside the test: the fixture write now goes to a
throwaway /tmp dir via vi.stubEnv + finally unstub. Verified: prod
remote-hosts.json hash is identical before and after the suite run.
2026-09-02 20:40:58 -05:00
DevvynandClaude Opus 5 66eb01ba8f feat(docker): restore in-app self-update in the Compose deployment
Codeman running under docker/docker-compose.yaml lost the ability to update
itself from App Settings -> Updates. The image had no .git (excluded by
.dockerignore), so the install reported as "unknown"; there was no init system
for detectSupervisor() to find; the runtime stage had neither devDependencies
nor a build toolchain; and a pull into the baked /opt/codeman would have landed
in the container's writable layer and been discarded by the next `up`.

Restore it through configuration rather than a second updater, so the release
channel, auto-stash, status file and boot reconcile are all reused unchanged:

- The checkout Compose builds from is bind-mounted over /opt/codeman, so the
  update's git checkout and rebuild land on the host and survive recreation.
- The restart is the server exiting; `restart: unless-stopped` relaunches the
  container on the new dist/. This is the one supervisor whose updater does NOT
  outlive the restart, which is safe only because the terminal "restarting"
  marker is written first.
- node_modules and dist are named volumes over the bind mount, so
  container-compiled native modules never enter the host checkout.
- The runtime image keeps devDependencies and gains python3/make/g++, since
  `npm run build` is tsc + esbuild and node-pty has no Linux prebuild.

An in-place container update applies code only, because a restart reuses the
existing image and config. evaluateEnvironmentGate() reads the target release's
own files with `git show <tag>:<path>` and refuses when server.Dockerfile or
docker-compose.yaml changed, when .env.example gained keys the user's .env
lacks, or when the restart policy would not bring the container back. The
missing-key check matters most: Compose resolves an unset ${VAR} to the empty
string and starts anyway, so a new required setting would otherwise arrive as a
silently blank variable. Every unknown fails open, and the gate is re-evaluated
server-side on POST /api/system/update.

The four global agent CLIs are pinned, because an unpinned CLI bump is the one
environment change no diff-derived gate can see; pinning turns it into a
Dockerfile change the gate already detects.

Adds test/docker-compose-env-parity.test.ts as the merge-side guard (every
compose ${VAR} has an .env.example entry and the reverse) and
test/docker-self-update.test.ts for the pure gate decisions.

Documented in docs/docker-self-update.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yAQ2y9t81jzSfpStUxx5T
2026-09-02 19:33:32 +08:00
Codeman maintainer 1e24817b51 chore: version packages 2026-09-02 10:49:36 +02:00
Ark0N f7cf15485e feat(models): offer Fable 5.1 in the model picker and task routing (#372)
Adds `claude-fable-5-1` to the App Settings model picker and the five task-routing selects, mirroring how Fable 5 is already offered: a base option with data-ctx="1" plus its [1m] companion row. No settings-ui.js logic change, since the cards and the 1M switch are built from those options.
2026-09-02 10:48:51 +02:00
DevvynandClaude Opus 5 1125f7c1c5 fix(test): strip the instance-selection env vars in test/setup.ts
`test/setup.ts` gives every test file a temp HOME so the suite cannot touch the
real Codeman tree, and strips the env vars that would leak past it — but the
list only covered auth and the gesture flag. The three vars
`src/config/instance.ts` derives the data dir and tmux socket from were missing,
and they reach past the temp HOME:

- **`CODEMAN_DATA_DIR` is the one that matters.** It is an ABSOLUTE override
  read in `getDataDir()`, so it bypasses HOME entirely: a developer who exports
  it — or a shell left over from `codeman web -d` — has the suite reading and
  WRITING their real `state.json`, `users.json`, `intents.json` and
  `hook-secret`.
- **`CODEMAN_INSTANCE`** moves the data dir to `~/.codeman-<name>` and the
  socket to `codeman-<name>`. Inside the temp HOME that is not data loss, but it
  silently changes the paths tests assert on — and `scripts/run-beta.sh` exports
  it, so any shell that has run a beta carries it.
- **`CODEMAN_TMUX_SOCKET`** renames the socket `resolveTmuxSocketName()`
  returns. `TmuxManager` no-ops its shell commands under vitest, so this is
  assertion drift rather than a stray `tmux -L` against prod — same class of
  leak, same one-line fix.

They are deleted in the setup file rather than in a hook because
`CODEMAN_INSTANCE` is captured into a module-level const the first time
`config/instance.ts` is imported; a `beforeEach` would already be too late.

`test/test-env-isolation.test.ts` pins the whole list in two halves, because the
obvious half is not enough: asserting the vars are unset passes trivially on a
machine that never set them, so a removed `delete` line would sail through on
almost every box and on CI. The static half reads `setup.ts` and asserts each
name is deleted there, which fails everywhere. An anti-drift check catches the
other direction — a var stripped in `setup.ts` but never given a reason in the
list — and is scoped to the strip section so the teardown's restores are not
mistaken for strips.

Verified by demonstrating the leak: with the `CODEMAN_DATA_DIR` line removed and
the var exported, the runtime assertion fails; with the line restored it passes.
Full suite: no new failures against an upstream/master baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
2026-09-02 09:49:09 +08:00
DevvynandClaude Opus 5 c5b84fb5f4 docs(cli-registry): annotate overlays.credStore as declared-for-later
Review item 4 named THREE live tables duplicating registry data. Two are now
read from the entry (`defaultRemoteCommandForMode`, `defaultDockerCommandForMode`);
the third, `resolveDockerCredentialArtifacts`, is not — and it was left neither
wired nor annotated, which is the state that item explicitly rules out.

It is not wired because the shape cannot express the live table: `credStore` is
ONE store per CLI, and `CRED_STORES` needs two for gemini (`.gemini` for the
CLI's own auth plus `.config/gcloud` for Vertex), while deepseek's entry declares
none at all even though `.dsh` is seeded. Wiring it means making the field an
array and correcting those two entries — a change to credential seeding, which
is at once the worst thing in that file to get wrong and the least covered by
tests, since every docker IO path is no-op'd under vitest. It belongs in its own
change, measured against a real container.

So it is annotated instead, at the field, in the type's declared-for-later
header, in docs/cli-registry.md, and in the pinned DECLARED_FOR_LATER list — the
last of which means wiring it later makes a test fail rather than leaving a
stale comment behind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
2026-09-02 09:35:55 +08:00
DevvynandClaude Opus 5 6acf0dea0f fix(cron): scope the launch pre-flight to launcher CLIs, not every mode
CI caught three cron-service failures. Both are mine, from converting cron's
per-mode ladders to capability reads without checking what each ladder's scope
actually was.

**The pre-flight.** cron only ever pre-flighted `deepseek` — dsh is a profile
LAUNCHER, so "installed" is not "runnable" and a bare `dsh` can boot a profile
that cannot drive a pane. I replaced that with an unscoped
`resolveCliLaunchError(mode)`, which pre-flights EVERY mode, so a claude cron
job on a box with no claude binary now failed with "Claude CLI not found"
instead of reaching tmux-manager's own throw. Three tests assert the latter.
It is now gated on `discovery.launcherProfile !== undefined`, which is
byte-identical to the `mode === 'deepseek'` check it replaces and generalises to
the next launcher. The equivalent HTTP-route conversion was already scoped (to
`capabilities.external`, matching what that route has always pre-flighted); I
simply failed to carry the same reasoning across.

**The model.** cron's ladder was `mode !== 'shell' && mode !== 'deepseek'`, and
I read it as `capabilities.model.source === 'claude-settings-file'` — which is
the HTTP route's question, not cron's. There, every external CLI reads its model
from its own config object earlier in the chain, so only claude reaches the
global default; cron has no such config, so the same expression silently
narrowed the default model from eight modes to one. Now `!== 'none'`, which is
exactly the two entries the ladder excluded. Not caught by a test — found by
re-deriving each ladder's scope after the first failure.

Also names a fourth deliberate behaviour change in the changeset, found while
tracing these: `session.ts` carried a hand-written list of modes with no
direct-PTY fallback and OMP was missing from it, though CLAUDE.md's own text
says "all eight require tmux". `requiresMux` comes off the entry now, so an omp
session whose mux creation fails refuses instead of silently starting outside
tmux.

Verified by diffing failing tests BY NAME against an upstream/master baseline,
rather than by file as before — which is how the regression slipped through: the
three new failures landed inside a file already failing for unrelated
Windows-path reasons, and the aggregate count happened to collide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
2026-09-02 08:49:04 +08:00
DevvynandClaude Opus 5 4830e662f9 refactor(cli-registry): make CLI backends data instead of per-mode branching
Every run mode is now a `CliEntry` in `src/config/cli-registry/` — discovery
(search dirs, version + identity probes), the launch argv template, env
handling, the `capabilities` flags that replace per-CLI branching, and the
`overlays` that back the remote/docker pane commands. Code that used to ask
"which CLI is this?" reads the entry instead.

Behaviour is unchanged. `test/cli-registry-spawn-golden.test.ts` pins every
spawn command as a literal string, captured from the hand-written builders
before they were deleted, and `test/location-overlay-commands.test.ts` does the
same for all 20 remote and in-container pane commands.

Config can never contain shell text: an entry declares typed argv tokens,
literals are validated against a safe-word pattern at LOAD time (a bad literal
rejects the whole entry — a silently dropped `--no-approve` is not cosmetic),
and values resolve through patterns NAMED in code, so a user `clis.json` cannot
widen its own validation. `~/.codeman/clis.json` overrides any entry, read-only
in this release.

OMP is included as a registry entry rather than a tenth hand-written builder,
so `buildOmpCommand()`, the omp availability pre-flight, the omp arm of
`buildPathExport()` and the omp entries in the truecolor/NO_COLOR, alt-screen
and doctor ladders all drop out.

Guard rails:

- `test/cli-registry-no-id-branching.test.ts` fails the build if per-CLI-id
  branching reappears outside `stock.ts`, in any of its four shapes (`===`,
  `!==`, `switch`/`case`, `includes`) — an `===`-only version would miss the
  negated forms, which is how 36 of them survived an earlier pass. Every
  allowlisted branch carries its reason.
- `external`, `hooks` and `altScreen` stay three INDEPENDENT capabilities;
  deriving one from another shipped the `until=stop`-hangs-on-shell bug.
- `param` is two namespaces. `launch.params` keys, `configSetenv.fromParam` and
  `privilegedParams[].param` all name a LAUNCH param; the legacy `<Mode>Config`
  wire field is separate, bridged only by `legacyConfigAliases`. Getting
  `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass
  clamp's only handle on a CLI's privilege switch, and a wrong name clamps
  nothing with no error and no failing test — so `schema.ts` rejects an entry
  naming a param it never declared.
- Registry data resolves AT CALL TIME (`sessionModeSchema()`,
  `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs`
  thunks). A module-level const freezes at first import, so a CLI enabled while
  the server ran moved the run menu but not that surface.
- Six fields are annotated DECLARED-FOR-LATER and read by nothing
  (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/
  `keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed
  rather than measured. A test pins the list so it cannot quietly grow.

Three user-visible changes, all deliberate and named:

- `probeDockerCliVersion()` derives the in-container binary from the registry
  rather than assuming it equals the mode name (`antigravity` runs `agy`).
- The remote CLI version probe now covers grok and deepseek, which the
  hardcoded map it replaces omitted while its own comment said the rule was
  "every mode except shell".
- `codeman doctor`'s CLI rows are generated from the entries, so Claude's
  install hint is the install command rather than a docs URL, five CLIs gain
  hints they never had, and the row order follows the catalog.

Also hardened along the way: `sessionModeSchema()` is bounded at 24 chars
(matching the `cliId` pattern) before its failure message quotes the value
back, and `deepMerge` skips `__proto__`/`constructor`/`prototype` when reading
the hand-editable `clis.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
2026-09-02 08:26:45 +08:00
Codeman maintainer 71ffbf18e4 chore: version packages 2026-09-01 21:55:00 +02:00
Codeman maintainer 826ddaa9aa build(docker): ship the Docker CLI in the Compose image, not the whole engine
docker/server.Dockerfile installed Debian's `docker.io` to get a client for the
socket mounted by Compose. That package is the full ENGINE: even with
--no-install-recommends it pulls 15 packages including containerd, runc, dmsetup
and iptables, none of which a container that only talks to a mounted socket can
use, and it ships Docker 20.10.24 (2023).

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

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

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

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

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

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

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

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

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

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

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

Full gate green in a merge worktree: 6360 tests, lint, format, frontend syntax,
public assets, lockfile.
2026-09-01 11:32:03 +02:00
shenlvkang-collab ccfda623fe fix(session): learn the live Claude conversation from the CLI's own hook
Which conversation a pane is on was re-derived by correlating
~/.claude/history.jsonl against Session.lastSubmitAt — and lastSubmitAt is
bumped only by input that flows through Codeman's own write path
(Session.write / writeViaMux). A user who attaches to the pane's tmux session
directly never set it, so resolveActiveClaudeSessionIdFromHistory() returned at
its first line for that pane's whole life and the response viewer stayed pinned
to the launch conversation, showing a pre-/clear transcript indefinitely.

A UserPromptSubmit hook reports the live conversation id from inside the CLI
process, delivered under the pane's own $CODEMAN_SESSION_ID. That binding is a
fact rather than a correlation: it never consults workingDir, so it cannot be
claimed by a sibling pane on the same folder, a closed tab, or a bare `claude`
in the user's terminal. A pane holding such an id skips the correlation
entirely, so the number of prompts eligible for cwd-based guessing goes DOWN,
never up — the naive alternative (relax the guard, or synthesize an anchor from
PTY activity) is the reverted bug the resolver's own comment describes.

The hook also stamps lastSubmitAt, so it finally means "a prompt was submitted"
rather than "typed into Codeman's web terminal". Conversations vouched for
first-hand — and only those — extend a persisted claudeSessionChain, whose tail
re-pins the conversation when a surviving tmux session is re-attached after a
restart. ⚠️ start() resets the id at THREE points and the last one runs
unconditionally after the mux branch, so the tail is applied there too; patching
only the mux branch looks right and silently does nothing.

⚠️ The hook's stdout is discarded with curl's own -o /dev/null. Claude Code
injects a UserPromptSubmit hook's stdout into the model's context ("Exit code 0
- stdout shown to Claude"), and a trailing >/dev/null does NOT work: curlCmd
already ends `... 2>/dev/null || true`, and in `pipeline || true >/dev/null` the
shell binds the redirection to `true`, which never runs on the success path. The
discard is opt-in so the five SSE-fed events keep byte-identical command text
and no workspace's settings file is rewritten for them. The staleness marker is
quote-free for the matching reason: hooksJson is JSON.stringify'd, so a quoted
needle never matches and the gate would rewrite every workspace on every spawn.

Existing workspaces heal on their next Claude spawn through the staleness sweep.
2026-09-01 12:33:24 +08:00
shenlvkang-collab 3eff1feb5d fix(web): render one Claude response-viewer message per model message
The Claude reader concatenated every assistant row between two human prompts
into one card, fusing up to 74 distinct model messages into a single card, and
it never read the attachment rows that hold a prompt typed while the agent was
working. Measured over 57 real transcripts on 2026-09-01, the viewer shows
1,806 messages instead of 356 and 353 user cards instead of 178, with the
assistant text sequence unchanged row for row and the response without
?context=full byte-identical on all 57 files.

One assistant row IS one whole model message: in that corpus no assistant row
carries more than one content block and no message id carries more than one
text block, so there was nothing to reassemble. Each row becomes its own
message carrying an additive {kind, label, turn}, and the frontend renders a
same-role run inside one turn as badge-less continuation segments — which is
what keeps a p90 of 11 messages per turn from reading as card spam. A numeric
turn gates that rendering, so Codex, the external-CLI pane parser and an older
server keep one badge per card.

A prompt typed while Claude is working is recorded ONLY as an
attachment/queued_command row. Taking it when origin.kind is 'human' and
commandMode is 'prompt' recovers 162 user cards from 163 such rows — one is a
verbatim repeat inside an unanswered user run and is collapsed by the existing
dedup guard — and restores the turn boundary whose absence let the assistant
runs fuse. The CLI's own queue entries are cleanly separable: of 322
queued_command rows, 159 are commandMode 'task-notification' and not one of
them carries an origin key.

This narrows #169 rather than reverting it: sidechain exclusion, the
restored-<uuid8> rebind, replayed-snapshot dedup and synthetic-row filtering
are all unchanged and still asserted.
2026-09-01 12:30:48 +08:00
shenlvkang-collab 5969a1df96 fix(mobile): give the Add Case modal a reachable submit button
mobile.css hides #createCaseModal's .set-foot below 860px, and that modal's
header — unlike Settings' — carries no set-head-save. So on a phone the
Create/Link button existed nowhere and the modal could not be submitted at all.

Adds the header button and drives both together through switchCaseModalTab()
and submitCaseModal(), so whichever one is pressed the other shows the same
pending state and is equally unclickable. Following the Settings pattern also
means Add Case picks up the existing .set-head-actions:has(.set-head-save) tray
and .set-head-save sizing with no new CSS; the mobile.css comment that still
listed Add Case as a lone-× sheet is corrected to match.
2026-09-01 12:29:02 +08:00
d fei 47ee49128c style: match the prettier version the lockfile pins
Format check failed twice, on different files each time, because three prettier
versions were in play: package.json says ^3.4.0, package-lock pins 3.8.3 (CI runs
npm ci, so that is the one CI uses), and the local node_modules had 3.9.6. Files
formatted with 3.9.6 were then "fixed" with 3.4.2, pushing session-routes and
system-routes onto a third style — every version change moved the failure to a
different set of files.

Line-break placement in `await import` and a union type only; no logic changes.
2026-08-29 23:49:04 -07:00
d fei 8e5e207386 fix(docker): send the probe body as an object, and explain an unreachable container
The run menu still offered every mode for an attached container. The browser's
actual request showed why:

  POST /api/docker-cases/adopt-preflight -> 400
  {"error":"Invalid input: expected object, received string"}

_api serializes `body` and sets Content-Type itself, and three call sites each
passed an already-stringified body, so it was encoded twice and the server saw a
JSON string where it expects an object. curl was fine throughout, so nothing in
the server logs pointed at it.

Also fixes the design defect underneath: a failed probe fell through to "do not
gate", which silently offered every mode. When the container has been recreated,
is stopped, or the engine is unreachable, the user sees claude, clicks it, and
it can only fail — with the reason visible nowhere. A failed probe now hides
every agent mode (Shell needs no CLI and stays) and shows the server's own
reason at the top of the menu.

Two static guards switched from a character window to brace matching. They
sliced between two call sites, and _loadRunModeHistory's call appears above its
definition, so the slice came out empty and the assertion verified nothing —
the same trap twice in one file.
2026-08-29 23:31:28 -07:00
d fei 5452ad5c5a feat(docker): add a folder picker to both path fields
Both paths in the adoption form had to be typed. Each gets a Browse button
using the same path-input-group markup Link Existing uses, so the two look and
behave alike.

What they can browse differs, and that is the point. The host workspace path
reuses the existing host picker. The container workdir cannot: an adopted
container has nothing mounted at a matching host path, so a host listing would
be a different filesystem — and getting this field wrong is the source of the
opaque OCI chdir error at launch, which makes it the field that most needs to
be clickable.

Adds a read-only POST /api/docker-cases/browse: one `ls` through docker exec, no
writes, no lifecycle, path shell-escaped like every other value. `ls -Ap` marks
directories with a trailing slash and keeps names with spaces intact.

PathPicker takes an optional fetchListing source rather than being forked: the
container variant only swaps where the rows come from, and reuses the rendering,
navigation, Up and Choose/Select unchanged.
2026-08-29 23:31:28 -07:00
d fei 23ab2e77fd fix(files): give the picker a root when the server runs as root
Link Existing's Browse did nothing: GET /api/filesystem/browse answered 403
"No filesystem browse roots are available".

Two rules were fighting. /root is a default blocked tree in the attachment
guard, and Codeman running as root — containers, plenty of servers — makes
homedir() exactly /root, so the picker's own allowlisted Home root was blocked;
the other candidates live under it or do not exist. The root list came out
empty and there was nothing the user could open.

The blocked trees exist to keep ~/.ssh and friends out of reach, not to seal off
the user's own home. Only trees that would swallow a configured root whole are
dropped now: /root goes when Home is it (or sits inside it), /etc holds no
configured root and is untouched. Secrets stay protected — isSensitivePath
independently matches .ssh/, .env and credentials* at any depth, and it is what
the directory probe asks about.

⚠️ Navigation must reuse the same narrowed list the roots were chosen with.
Handing the raw trees downstream admits a root and then refuses every path
inside it, which reads as a picker that opens and does nothing.
2026-08-29 23:31:28 -07:00
d fei 3685ad85bc fix(docker): stop requiring the CLI on the host for a container session
Attaching a container, picking claude and hitting Run gave one line —
`execvp(3) failed.: No such file or directory` — and the run-mode menu offered
every mode. Three separate defects, found on a real deployment.

TmuxManager.createSession resolved the CLI directory without distinguishing a
docker session, so a host with no claude threw, the catch fell back to a direct
PTY, and that PTY exec'd the CLI on the HOST. The failure surfaced as a bare
execvp error naming nothing. A docker session runs its CLI inside the container;
the host does not need it. All eight modes now sit behind a cliRunsInContainer
guard, and whether the container has the CLI is settled by the adoption
preflight or the image gate before launch.

The running check used a bare double quote and command substitution. The whole
chain is embedded in an outer `bash -c "…"`, so the unescaped quote closed that
string early and the remainder was re-tokenized. It is now a `grep -qx` pipeline
using only the single-quote form every other line in the builder already uses.

Claude Code refuses --dangerously-skip-permissions as root. Our base image runs
a non-root user, so an owned container never hit this; an adopted container's
user belongs to its owner and is frequently root, and keeping the flag killed
the pane with a message visible only inside the container. The preflight now
reports runsAsRoot and the launch chain drops the flag for it.

The menu also showed every mode because the container CLI probe only started
when the menu opened. It is warmed when the case is selected instead.
2026-08-29 23:31:28 -07:00
d fei 06e7cbe286 fix(docker): probe the container's CLIs live instead of trusting attach time
Storing the container's CLIs on the case at attach time left two gaps: a case
linked before that field existed has none at all, and a container's CLIs can be
installed or removed long after it was linked. A real deployment hit the first
one — the host had only codex, the container only claude, and with no stored
list the menu still gated on the host and hid the mode that actually worked.

The probe now runs when a container case is selected, reusing the existing
adopt-preflight endpoint, so there is no new backend surface. Results are cached
per case for the page's lifetime, since the menu opens often and the probe is a
`docker exec` round trip; a concurrent probe for the same case is deduplicated
with an in-flight marker.

A failed probe leaves the cache empty, which the caller reads as "unknown" and
therefore does not gate. Hiding every mode because one probe failed is worse
than offering one that turns out to be missing, which the launch path already
refuses with a specific message.

The repaint only happens while the menu is still open, so a late answer cannot
make the list jump under a user who already closed it.
2026-08-29 21:19:17 -07:00
d fei 8b20f5b1f8 fix(docker): probe container CLIs by their real binary name
The adoption preflight used the mode name as the binary name. claude, codex,
opencode, gemini and pi happen to match, so it never showed — but antigravity
ships as `agy` and deepseek as `dsh`, so a container that has either was
reported as not having it, and the mode was silently dropped from the case.

Adds a MODE_BINARIES map, single-sourced with defaultDockerCommandForMode, which
launches those same binaries. Probing and result filtering share one `binaryFor`
so the two cannot drift apart.
2026-08-29 21:02:19 -07:00
d fei 2f83a37c6d feat(docker): take run-mode availability from the container
The run-mode dropdown hides CLIs that are not installed on the HOST (#201). That
is right for local sessions and wrong for a container case, whose agents run
inside the container: a host with no claude installed hides the mode while the
container ships one, which is exactly what happened on a real deployment.

The adoption preflight already probes what the container has, so that result is
persisted on the case and surfaced through CaseInfo. Docker cases gate on it;
every other case keeps the host probe unchanged.

An absent list reads as "do not gate" rather than "nothing available": an owned
container runs our base image, which ships every CLI, and treating unknown as
empty would leave the menu with Shell alone.
2026-08-29 21:02:19 -07:00
d fei 34c12ca18b feat(docker): make the container field a picker you can also type into
Typing a container name from memory is error-prone. The field becomes a native
datalist: pick from the engine's containers, type to filter, or type a name that
is not listed (the engine may be remote, or the container may not exist yet).
A datalist gives all three natively, so no dropdown state machine is introduced.

Adds listDockerContainers and GET /api/docker-hosts/:hostId/containers, following
the listRemoteCodemanSessions discovery precedent: read-only and never throwing,
so an unreachable daemon returns an empty list and the field degrades to plain
text instead of erroring.

Stopped containers stay in the list, sorted after running ones and labelled.
Attaching does require a running container, but hiding stopped ones turns "my
container is not in the list" into a dead end, while showing
`Exited (137) 8 days ago` says exactly what to fix.
2026-08-29 21:02:19 -07:00
d fei e2f750cb30 i18n(docker): translate the attach panel, and unblock translation
The new strings were English only. Adding entries surfaced a deeper problem: the
translator matches whole text nodes and skips `code`/`pre`, so an inline `<code>`
mid-sentence splits a hint into fragments that can never match an entry — which is
why the panel's existing "Build it once with <code>...</code>" hint was never
translated either.

Drops the inline markup from the new hints so each is a single text node, then
adds the zh-CN entries. The brand name goes through the existing {name}
placeholder.

Server-side error bodies are deliberately not added: the client receives them
already interpolated with a concrete container name, so a template key could
never match.
2026-08-29 21:02:19 -07:00
d fei bb45909169 feat(docker): link to container attach from the Create New tab
Attaching lived only on the Docker tab, but the place users look for anything
container-shaped is the "Run in an isolated Docker container" checkbox on Create
New. A feature nobody can find is a feature nobody has.

Adds a one-click link there that switches to the Docker tab, turns the toggle on
and focuses the container field. Reuses switchCaseModalTab and the existing sync
helper; no new CSS.
2026-08-29 21:02:19 -07:00
d fei c98a59d709 fix(docker): verify the container workdir and end the probe with exit 0
Two defects that only a real container exposes.

The probe chained `command -v X && echo X` with semicolons, and a script's exit
status is its last command's. A container without the last probed CLI made the
whole `sh -lc` exit 1, so a perfectly healthy container with tmux and claude was
reported as "could not exec into the container". A missing CLI is data here, not
failure, so the script now ends with `exit 0`.

containerWorkdir defaulted to hostWorkspacePath. That default holds for an owned
container only because the create-time bind mount puts the host directory at that
exact path; attaching mounts nothing, so the two are independent facts. A host
path absent inside the container makes `docker exec --workdir` fail with an OCI
chdir error that surfaces in the pane as a bare "execvp failed". The preflight now
proves the directory exists inside the container and refuses at link time.
2026-08-29 21:02:19 -07:00
d fei bc55b6b0da feat(docker): add the attach-an-existing-container panel
The Docker tab gains an "Attach to an existing container" toggle. Ticking it
swaps the create-time fields (image, network, advanced) — which describe a
`docker create` attaching never runs — for the container name, and routes the
submit to the adopt endpoint.

Reuses the existing linkDockerCase flow end to end: only the final call differs.
The docker-host upsert still applies, since it is what resolves the
engine/context/daemon for `docker exec`; its create-time fields are simply never
read for an attached case.
2026-08-29 21:02:19 -07:00
d fei 15eebde832 feat(docker): attach a case to an already-running container
Docker cases could only run in a container Codeman created itself. Attaching to
one the user already built and runs means Codeman must leave that container's
lifecycle completely alone, which the launch chain could not do: it was
`image inspect` -> `inspect || create` -> `start` -> `exec`.

Adds `DockerCase.owned`, mirroring the `owned:false` contract remote-SSH already
uses for attached sessions. Absent (every existing case) means owned, so current
behaviour is byte-identical. `false` means the container belongs to the user and
Codeman may only exec into it.

The launch chain for an attached container only looks, then execs: no image gate
(the image is theirs), no create, and no `start` — starting a container we do not
own is the very mutation attaching promises not to perform. A missing or stopped
container fails closed with an actionable message instead. Credential seeding is
skipped too: those copies read from create-time read-only mounts that do not
exist here, and writing host credentials into someone's container is not ours to
do, so its CLIs must already be authenticated inside it.

Four fail-closed guards. buildDockerStopCommand and buildDockerRemoveCommand
throw during pure string construction, so no caller bug can turn into a
`docker stop`/`rm` on a container we do not own; removeDockerContainer refuses
again at the lowest layer; drift reports "none" for an attached container, which
carries no `codeman.confighash` label and would otherwise always look drifted and
409 the launch gate forever; and the orphan reaper skips attached containers
through a check deliberately independent of the two conditions already covering
them.

`owned` is applied AFTER the config hash is computed. dockerConfigHash takes an
explicit field list, so ownership can never shift an existing case's hash — if it
did, every pre-existing case would trip the drift gate at once, and the remedy
the UI offers is "recreate the container".

Adds POST /api/cases/docker-adopt and a read-only
POST /api/docker-cases/adopt-preflight. The preflight refuses at LINK time rather
than at session launch, where the only ways out would be a dead pane or starting
a container we do not own.

Tests assert the negative guarantee directly — that create, start, stop, rm,
restart and kill are absent from the generated commands while `docker exec -it`
and `new-session -A` remain — since it cannot be observed by using the feature.
2026-08-29 21:02:19 -07:00
timkjr da5f5447d0 fix(remote): never auto-revive a remote session after a clean agent exit
The COD-108 reconnect watcher treated any dead local pane as a dropped
transport and re-ran the pane command — so a normal ctrl-c/ctrl-d on a
remote claude/opencode/omp auto-spawned a FRESH agent (claude only
looked correct because its '--session-id || --resume' fallback resumed,
with a loud 'already in use' error first).

Distinguish a transport drop from an intentional exit: only reconnect
when the durable remote tmux session (codeman-ssh-*) is verifiably
still alive on the remote host. A clean exit tears that session down;
the watcher now probes it via ssh has-session and skips (remote-gone)
when it is gone OR unknown (fail closed). The probe is cached
per-session and fired async so the 5s tick never blocks on ssh.

Tests: 3 new cases pinning remote-gone / unknown / alive decisions.
Verified live: all remote CLIs stay dead after ctrl-c/ctrl-d.
2026-08-29 17:43:07 -05:00
Devvyn e2179bd530 chore(docker): remove local handover references 2026-08-27 19:42:37 +08:00
Devvyn b85f7659b7 feat(docker): add Compose deployment support 2026-08-27 19:38:38 +08:00
181 changed files with 19587 additions and 2025 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.agents
.claude
.codex
# `**/` matters: a .dockerignore pattern is matched against the WHOLE
# context-relative path, so a bare `.env` excludes ONLY the root file and
# `COPY . .` would bake docker/.env -- CODEMAN_PASSWORD and any provider API
# keys -- into the published image at /opt/codeman/docker/.env (verified).
**/.env
**/.env.*
!**/.env.example
node_modules
dist
coverage
out
test-results
tmp
*.log
+12 -4
View File
@@ -69,10 +69,18 @@ shared-host, multi-user, or tunneled deployments.
- **Multi-instance tmux socket is process-wide.** Two Codeman instances on the same `CODEMAN_INSTANCE` share a tmux socket and can attach each other's live sessions — isolate with distinct `CODEMAN_INSTANCE` values.
- **The live log-tail route reads `/var/log` and `~/logs`** in addition to the session working directory (read-only) — a deliberate choice for tailing system/app logs. On a password-protected remote deployment an authenticated user can therefore read those roots outside their session. See `docs/security-architecture.md` §5.
Recent hardening (this release): web-push subscription endpoints are restricted
to https public hosts (SSRF guard — rejects internal/metadata IPs, validated at
subscribe and send time), and tmux session names discovered on the shared socket
are validated against the safe-name pattern before reaching any shell call site.
- **The web-tab proxy fetches from the server's network position.** Any authenticated user can save a dashboard URL on loopback or a private range and have Codeman relay to it; that is the feature. Link-local and cloud-metadata addresses are the only refused targets (see below). On a shared host, restrict who holds an account.
Recent hardening (2026-09-04): the web-tab proxy, its "Test" probe and its
WebSocket relay refuse link-local and cloud-metadata targets (`169.254.0.0/16`,
`fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`,
`metadata.google.internal`), judged on the RESOLVED address so a DNS name pointing
there is refused too; proxy capabilities are revoked on logout, admin logout and
user deletion; proxied responses carry `Referrer-Policy: same-origin`. Earlier:
web-push subscription endpoints are restricted to https public hosts (SSRF guard,
rejects internal/metadata IP literals, validated at subscribe and send time), and
tmux session names discovered on the shared socket are validated against the
safe-name pattern before reaching any shell call site.
For the detailed rationale, defenses, and recommended secure setups, see
[`docs/security-architecture.md`](../docs/security-architecture.md).
+179
View File
@@ -1,5 +1,184 @@
# aicodeman
## 1.26.1
### Patch Changes
- Codex sessions no longer report idle for their entire life, and Codex conversations now appear in Past Sessions and can be resumed.
**Per-CLI work detection (#385, irisitymichaelgrundberg).** The composer glyph and the working status line are now registry data (`capabilities.workDetect`) rather than Claude constants. Claude keeps its exact current pair, Codex declares `›` plus its `esc to interrupt` footer, and any CLI that declares neither falls back to Claude's, which is what every session used before. Work detection had been gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` from the moment it started. `workingLine` is config-supplied and its compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in both the schema refine and the runtime compile: a nested quantifier there would backtrack on the event loop for the whole server. The Codex footer is matched case-insensitively on the E, so a future version capitalising it cannot make the fix silently inert.
**Codex conversations in Past Sessions (#386, irisitymichaelgrundberg).** A bounded scanner reads codex's `~/.codex/sessions` rollout store, so the unified session list now merges three transcript stores rather than one (Claude's `~/.claude/projects`, omp's `~/.omp/agent/sessions`, codex's `~/.codex/sessions`). A scanned row carries a `resumeId`, the rollout's own thread id, which lets it resume through `codexConfig.resumeSessionId`; a live session never carries one, so a row without it stays a genuinely fresh session. Live and resumed Codex sessions fold into their rollout row through the existing alias map, including a `session_meta.originator` match for fresh panes, so a conversation never shows up twice. The phone overview carries `resumeId` through its own row projection, without which a tapped Codex past row started a fresh session on a thread already on disk.
### Thanks
- @irisitymichaelgrundberg for both PRs (#385, #386), and for turning a full review round on #386 in a day.
## 1.26.0
### Minor Changes
- Tag the case directories agent workers create, and clean up what they leave behind.
A long agent orchestration creates one case directory per worker, and deleting the
sessions never removed them, so `~/codeman-cases` filled with scratch folders that
looked exactly like real projects.
- A case directory `POST /api/quick-start` **creates** for an agent-driven spawn now
carries a `.codeman-agent-case.json` marker recording when it was made, by whom,
from which session, and in which mode. Only the branch that creates the directory
writes it, so a linked case, a cloned repo or any pre-existing path is never
labelled, and deleting the marker file adopts a scratch case as a real one.
- The label comes from the new `X-Codeman-Agent-Origin` header that the packaged agent
skill sets on its shared curl invocation (preamble 1.22.0), or an `agentOrigin` body
field, falling back to a resolved `parentSessionId` so workers spawned by an older
skill copy are still labelled.
- `GET /api/cases` publishes it as `agentCreated`, and the new read-only
`GET /api/cases/agent-created` lists the scratch cases with `inUse` (a live session
is still working in it) and `modifiedAt`.
- Add Case -> Manage badges every agent-created case and adds a sticky **Clean up**
entry point that names each directory in its confirmation and skips any case a
running session is using. Removal still goes through `DELETE /api/cases/:name`.
- The agent skill's per-session preamble cache (`~/.cache/codeman-agent-<id>.sh`) is
now removed with the session and swept at boot. One was written per Claude session
and nothing ever deleted them (236 orphans on a working machine); the sweep keeps
every live session's file and only takes orphans older than seven days.
## 1.25.0
### Minor Changes
- Codeman can be mounted under a sub-path behind a reverse proxy (#381, @mtiller). `--base-url /codeman` (or `CODEMAN_BASE_URL`) makes the server strip the prefix on the way in, rebase redirects on the way out, inject `<base>` and `window.__CODEMAN_BASE__` into the shell, and route web-tab proxying and WebSocket upgrades under the mount, so one TLS name can front several apps. A root install is byte-identical to before. Applied on top: the crash-diag beacon stays under the mount (sendBeacon is not fetch, so the base-aware wrapper never saw it), the test suite strips `CODEMAN_BASE_URL`, and a wiring test boots a real server under a prefix.
A case can attach to a container that is already running (#357, @dignfei). `DockerCase.owned:false` mirrors the remote-SSH attach contract: Codeman only execs into such a container, never creates, starts, stops, removes, pauses or commits it, with the refusal enforced at string-construction time so no caller bug can reach `docker stop`. The Add Case dialog gets an attach panel with a container picker, the run menu takes its mode availability from the CLIs actually present in the container, and adoption is admin-only in multi-user mode. Three gaps closed after review: export no longer pauses or commits an adopted container, a freshly linked owned case no longer hides every agent mode behind a probe of a container that does not exist yet, and multi-user gating is explicit.
The Claude response viewer renders one message per model message (#369, @shenlvkang-collab). The reader used to fuse every assistant row between two human prompts into one card and never read the attachment rows that hold a prompt typed mid-turn; measured over 57 real transcripts it now shows 1,806 messages instead of 356 and recovers 162 absorbed user prompts, with the assistant text unchanged row for row.
A Claude pane learns its live conversation from the CLI's own `UserPromptSubmit` hook (#367, @shenlvkang-collab). The conversation id used to be re-derived by correlating `~/.claude/history.jsonl` against a stamp only Codeman's own input path set, so a pane driven straight from tmux stayed pinned to its launch conversation forever. The hook reports the id first-hand, addressed by the pane's own `$CODEMAN_SESSION_ID`, and the chain of conversations is persisted so a restart re-pins the right one. The new `hook:prompt_submitted` SSE event is registered (158 = 158), and it lands in the run summary only when the conversation actually moved.
The Add Case modal can be submitted from a phone again (#368, @shenlvkang-collab). Since 1.16.4 the layout below 860px hid the modal footer, which held the only Create/Clone/Link button. A header submit button now sits beside the close button, dims while a submit is pending, and a static test pins the contract so it cannot silently disappear again.
The Link Existing case picker opens in the Codeman Cases directory instead of Home (#383, @opticon454). Under Docker the two are unrelated trees and Home holds nothing but dot directories, so the picker showed no cases at all. The fallback chain is now Current Folder, then Codeman Cases, then `/mnt/d`, then the first root.
A PR review bot for the maintainer (`scripts/pr-bot/`, guide in `docs/pr-bot.md`). It reviews every open pull request in its own Codeman session inside a private clone and reports the verdict, ranked findings and a recommendation to Telegram with action buttons; merge, close, post-comment and approve-CI happen only from a confirmed tap. Maintainer tooling, not part of the server or the CLI.
### Thanks
- @mtiller for the reverse-proxy base URL (#381).
- @dignfei for attaching cases to running containers (#357).
- @shenlvkang-collab for the response viewer fix (#369), the first-hand conversation hook (#367) and the phone Add Case fix (#368).
- @opticon454 for the case picker default (#383).
## 1.24.7
### Patch Changes
- The web-tab proxy refuses link-local and cloud-metadata targets. Its Test probe, the proxy itself and the WebSocket relay accepted any http(s) host, so a saved dashboard URL could reach `169.254.169.254` (in decimal, hex, IPv6-mapped or DNS-name form) through a capability and no cookie. Loopback and RFC1918 addresses stay allowed on purpose, since a localhost Grafana is the feature; only link-local and the fixed cloud-metadata addresses are refused, at the schema, at every connect site, and through a DNS lookup hook that judges the resolved addresses, which is what closes DNS rebinding. Adds `undici` so the proxy runs its fetch through its own agent.
Proxy capabilities are revoked on logout. `revokeOwner()` had shipped with no caller, so a leaked proxy URL stayed valid for as long as anything kept polling it. `POST /api/logout`, the admin forced logout and user deletion now revoke the capabilities they should, and proxied responses carry `Referrer-Policy: same-origin` with the upstream's own policy dropped, so a dashboard on a loose referrer policy cannot hand the capability to a third-party host it links to.
The Docker Compose deployment updates itself from App Settings again (#373, @opticon454). The checkout Compose builds from is bind-mounted at `/opt/codeman`, so an update's `git checkout` and rebuild land on the host and survive container recreation; build artefacts live in named volumes so container-compiled native modules never enter the host checkout; the image keeps devDependencies and a build toolchain; and the restart is the server exiting under `restart: unless-stopped`. An in-place update applies code only, so the updater refuses a release that changes `server.Dockerfile` or `docker-compose.yaml`, or that adds keys to `.env.example` the user's `.env` has no value for (Compose interpolates an unset variable to the empty string and starts anyway), and points at `docker/Start-Codeman.sh` on the host instead. The four global agent CLIs in the image are pinned. A follow-up makes the final step fail safe: the server exits only when the Compose file declares `CODEMAN_RESTART_BY_EXIT=1` or the daemon confirms an auto-restart policy, and otherwise the build is staged for a manual restart, so a container nothing would restart is never taken down. Details in `docs/docker-self-update.md`.
The test suite strips `CODEMAN_INSTANCE`, `CODEMAN_DATA_DIR` and `CODEMAN_TMUX_SOCKET` before any application module loads (#371, @opticon454), with a two-half test whose static half reads `test/setup.ts` so a dropped line fails everywhere. This replaces the throwaway data dir #356 had set for the same variable.
### Thanks
- @opticon454 for the Compose self-update (#373) and the test isolation fix (#371).
## 1.24.6
### Patch Changes
- CLI backends are now a data-driven registry (#347, @opticon454). Every run mode (Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness and OMP) is a `CliEntry` in `src/config/cli-registry/`: binary discovery (search dirs, version and identity probes), the launch argv template, environment handling, the multi-user privileged-parameter and privileged-env-key clamps, the remote and Docker pane commands, and the capability flags the rest of the app reads instead of branching on a CLI's name. `~/.codeman/clis.json` can override any stock entry or add a custom CLI; it is read-only in this release, must be mode 0600, and every reason it was ignored is now logged once on first load (`docs/cli-registry.md`). Config never contains shell text: entries declare typed argv tokens, literals are validated at load time, and values resolve through patterns named in code. Registry data resolves at call time rather than at module import, so a CLI enabled while the server runs moves every surface at once, and a guard test fails the build if per-CLI-id branching reappears outside the stock catalog.
This is an internal refactor. The spawn command every CLI receives is byte-identical to the previous hand-written builders, verified by pinned golden strings in the test suite and by diffing both implementations across 11,602 option combinations for all ten modes. Five small deliberate changes ride along: the in-container version probe derives the binary from the registry (`antigravity` runs `agy`), the remote version probe now covers Grok and DeepSeek, `codeman doctor`'s CLI rows are generated from the registry (Claude's install hint is the install command, five CLIs gain hints, the row order follows the catalog), OMP now requires tmux like its siblings instead of silently falling back to a direct PTY, and an OMP session's attach client now receives `COLORTERM=truecolor` like the other truecolor CLIs.
Remote sessions are no longer auto-revived after a clean agent exit (#355, @timkjr). The reconnect watcher could not tell a transport drop from a Ctrl-C, Ctrl-D or `exit` inside the remote CLI, so a clean exit relaunched a fresh agent (OpenCode and OMP started a new conversation every time; Claude only looked fine because its `--resume` fallback masked it). The watcher now revives a dead pane only when the durable remote tmux session is verifiably still alive, via a `has-session` probe over ssh, and an unreachable host means do not revive. A follow-up classifies that probe by exit status, since `tmux has-session` prints nothing on success and reading its stdout had marked every live session as gone, forgets the cached answer whenever the pane is seen alive again so a stale result cannot revive a later clean exit, and caps the probe at one in flight per session.
The test suite can no longer reach the production `~/.codeman` data dir (#356, @timkjr). `test/setup.ts` now points `CODEMAN_DATA_DIR` at a throwaway directory, which is the absolute override that bypasses the suite's temporary HOME when inherited from the shell, and every test that deletes a case tree goes through a containment gate that refuses paths outside the temporary HOME. A bare suite run had overwritten a real `remote-hosts.json` with a route test's fixture. The comments around it and CLAUDE.md's testing section now name that variable as the cause; `os.homedir()` itself does follow `$HOME`.
### Thanks
- @opticon454 for the CLI registry (#347), the phased resubmission of #343, and the review rounds that hardened it.
- @timkjr for the remote auto-revive fix (#355) and the test-isolation sweep (#356).
## 1.24.5
### Patch Changes
- Fable 5.1 is selectable in App Settings.
`claude-fable-5-1` is in Claude Code's model catalog (display name "Fable 5.1", June 2026 knowledge cutoff), but the model picker only went up to Fable 5, so pinning it meant hand-editing a case's `.claude/settings.local.json`. It now appears as a card under **App Settings -> Models -> New Claude sessions**, and as an option in **Task routing** (Default for tasks, plus the Explore / Implement / Test / Review overrides).
It is offered exactly the way Fable 5 already is: the "1M capable" badge, the 1M context window switch stays live for it, and base + switch compose into `claude-fable-5-1[1m]`. Both strings are accepted by the CLI.
Deliberately not claimed: that a 1M window is what sets Fable 5.1 apart. The CLI's model catalog marks both fable entries as natively 1M with the same window, so an always-on window for 5.1 next to a switchable one for 5 would encode a difference the models do not have.
### Thanks
- @shenlvkang-collab for #370, which surfaced that Fable 5.1 was missing from the picker.
## 1.24.4
### Patch Changes
- The Compose deployment image ships the Docker CLI instead of the whole Docker engine.
`docker/server.Dockerfile` installed Debian's `docker.io` to get a client for the mounted
host socket. That package is the full **engine**: even with `--no-install-recommends` it
pulls 15 packages including containerd, runc, dmsetup and iptables, none of which a
container that only talks to a socket can use. It also ships Docker 20.10.24, from 2023.
The CLI and the buildx plugin are now copied from the official `docker:29-cli` image
instead. Measured on the same `node:22-bookworm-slim` base: **266 MB → 108 MB**, a 158 MB
saving, with the current CLI (29.7.2) in place of a two-year-old one.
Verified by building the real image and running it: the binaries are static Go builds, so
they work on this glibc image even though they come from an Alpine one, and `docker
--version`, `docker ps` and `docker build` all succeed against a mounted host socket as
the unprivileged runtime user. buildx is copied deliberately — `scripts/build-agent-image.mjs`
shells out to `docker build` and Codeman auto-builds the agent image on the first Docker
case, which without the plugin falls back to the classic builder Docker has deprecated.
`docker-compose` is not copied; Codeman never shells out to it.
## 1.24.3
### Patch Changes
- Docker Compose deployment, and the plan-usage chip stops losing its 5-hour window.
**Run Codeman itself in a container** (#349, @opticon454). `docker/` now carries a
local-image Compose deployment: copy `docker/.env.example` to `docker/.env`, set
`CODEMAN_PASSWORD`, run `bash docker/Start-Codeman.sh`. Docker cases then start as
**sibling** containers through the mounted host socket rather than nested ones, which
inverts an assumption the bare-host path takes for granted: the daemon no longer shares
Codeman's filesystem, so a bind source that is valid inside Codeman means nothing to it.
`CODEMAN_DOCKER_HOST_HOME` translates sources under HOME into the daemon's namespace and
`CODEMAN_CASES_PATH` points the cases dir at a host-absolute bind mount, so a workspace
resolves to the same absolute path on both sides. `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1`
drops `--memory-swap` for hosts without swap accounting (`--memory` still applies) and
filters only that one kernel warning. Guides: `docs/docker-compose.md`, `docker/README.md`.
Three things were fixed while landing it:
- **`docker/.env` was being baked into the image.** A `.dockerignore` pattern matches the
whole context-relative path, so the bare `.env` line excluded only the root file while
`COPY . .` picked up `docker/.env` — the file the deployment's own README tells you to
fill with `CODEMAN_PASSWORD` and provider API keys — and left it at
`/opt/codeman/docker/.env`. Now excluded via `**/.env`, verified in both directions
against a real build context with a canary secret.
- **`codeman skill install --case <name>` could not find a case under Compose.**
`CODEMAN_CASES_PATH` moved the server's cases dir but not the CLI's, which still
hardcoded `~/codeman-cases`. Both now resolve through one place.
- **A Docker case handed its Claude conversation id to every other CLI.** `resumeOnStart`
seeded `dockerResumeId` from `lastClaudeSessionId` regardless of mode, and
`appendResumeFlag()` maps a resume id onto codex/gemini/pi/grok/deepseek/omp/antigravity.
This one is a plain master bug, unrelated to Compose.
**The plan-usage chip keeps its 5-hour slot.** It silently shrank from `5h 4% · 7d 52%`
to a lone `7d 52%`, which reads as half the feature breaking. Nothing was broken: Claude
Code ships `rate_limits.five_hour` "only while the API reports it and its resets_at has
not passed", so between 5-hour session windows the key simply leaves the statusline
payload. The slot now stays with a dimmed em dash and the tooltip says "no active session
window". Claude only — a missing Codex bucket means that plan has no such limit, so those
stay omitted.
### Thanks
- @opticon454 for #349, and for a write-up that made an infrastructure PR quick to review
## 1.24.2
### Patch Changes
+28 -14
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -82,6 +82,8 @@ codeman users add alice --admin # create the first admin account
codeman web --multiuser # named logins + per-user case spaces
```
**Prefer Docker Compose?** A local-image Compose deployment ships in `docker/`: copy `docker/.env.example` to `docker/.env`, set `CODEMAN_PASSWORD`, then run `bash docker/Start-Codeman.sh` on Linux. Codeman runs in a container and spawns Docker cases as sibling containers through the host socket. See the [Docker deployment guide](docker/README.md) for direct Compose commands, storage and networking options.
Details in [Multi-User Mode](#multi-user-mode-opt-in) below.
<details>
+1
View File
@@ -4,6 +4,7 @@
"scripts/*.mjs",
"scripts/*.js",
"scripts/watch-subagents.ts",
"scripts/pr-bot/main.ts",
"scripts/remotion/Root.tsx",
"scripts/remotion/index.ts",
"test/**/*.test.ts",
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "..",
"noEmit": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["../scripts/pr-bot/**/*.ts"]
}
+76
View File
@@ -0,0 +1,76 @@
# =============================================================================
# Codeman Docker Compose environment template
# Copy this file to .env and set the values for the Docker host.
# =============================================================================
TZ=Australia/Perth
# Optional overrides for direct `docker compose` use. The Bash start script
# detects these values from CODEMAN_APPDATA_PATH automatically. Compose uses
# 1000:1000 when the variables are omitted.
# PUID=1000
# PGID=1000
# Name of the account that runs Codeman and all local CLI sessions. Changing
# this value rebuilds the image with a matching account.
CODEMAN_RUNTIME_USER=opencode
# Required. Persistent Codeman application data, CLI credentials, and session
# state are stored here on the host and mounted at the runtime account's home
# directory in the container.
CODEMAN_APPDATA_PATH=/mnt/user/appdata/Coding/codeman
# Optional. Absolute host path of this Codeman checkout, mounted at
# /opt/codeman so App Settings -> Updates can update Codeman in place. The Bash
# start script detects it from the compose file's own location, so it only needs
# setting for direct `docker compose` use or a checkout kept elsewhere. Point it
# at a directory that is not a git checkout and in-app updates are unavailable.
# CODEMAN_REPO_PATH=/mnt/user/appdata/Coding/codeman/app
# Required for Docker cases. This must be an absolute path on the Docker host.
# Codeman and each isolated case use this same path, so it cannot be a
# container-only path such as /home/opencode/codeman-cases.
CODEMAN_CASES_PATH=/mnt/user/appdata/Coding/codeman/codeman-cases
# Required. Network bind address, host port, and local image tag.
CODEMAN_HOST=0.0.0.0
CODEMAN_PORT=3000
CODEMAN_IMAGE=codeman:local
# Required for any network-accessible Codeman instance. Use a unique, strong
# password. This file is safe to commit; copy it to .env and set the value.
CODEMAN_PASSWORD=changeme
# Required. Username for Codeman HTTP Basic authentication.
CODEMAN_USERNAME=admin
# Optional: authenticate Gemini CLI without an interactive login.
GEMINI_API_KEY=
# Linux default. On Docker Desktop, use the socket path supported by your
# Docker installation when it differs from /var/run/docker.sock.
DOCKER_SOCKET=/var/run/docker.sock
# Optional override for direct `docker compose` use. The Bash start script
# detects this from DOCKER_SOCKET automatically. The direct Compose default is
# 999, but the correct value depends on the Docker host.
# DOCKER_SOCKET_GID=999
# Set to 1 only when Docker-case hook callbacks are required.
CODEMAN_DOCKER_BRIDGE_HOOKS=0
# Set to 1 when `docker info` reports `SwapLimit=false`. The case memory limit
# remains active; Codeman omits --memory-swap and filters the daemon's exact
# unsupported-swap warning while preserving all other Docker create errors.
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=0
# Required only when applying the macvlan example in README.md.
CODEMAN_MACVLAN_NETWORK=br0.11
CODEMAN_IPV4_ADDRESS=10.10.11.236
CODEMAN_MAC_ADDRESS=02:10:11:00:00:EC
# Required only when creating a new managed macvlan network, rather than using
# the external-network macvlan example.
CODEMAN_MACVLAN_PARENT=br0.11
CODEMAN_MACVLAN_SUBNET=10.10.11.0/24
CODEMAN_MACVLAN_GATEWAY=10.10.11.1
+108
View File
@@ -0,0 +1,108 @@
# Codeman Docker deployment
This folder contains the Compose configuration, server image Dockerfile, and environment template for a locally built Codeman server.
## Start
From the repository root, create the runtime environment file and set the required values, especially `CODEMAN_PASSWORD`.
```sh
cp docker/.env.example docker/.env
bash docker/Start-Codeman.sh
```
On PowerShell, use the following command instead.
```powershell
Copy-Item docker/.env.example docker/.env
docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d
```
Every required value is defined and explained in `.env.example`. `GEMINI_API_KEY` is intentionally optional and may remain blank.
On Linux, `Start-Codeman.sh` stops with an error when required paths are missing. It creates the application-data directory when safe, detects its numeric owner as `PUID:PGID`, and detects `DOCKER_SOCKET_GID` from the configured Docker socket. It rejects a root-owned application-data directory because Codeman and its local CLI sessions must remain unprivileged.
Codeman, Claude, OpenCode, and other local sessions run as the unprivileged account named by `CODEMAN_RUNTIME_USER`, which defaults to `opencode`. When Compose is run directly, `PUID` and `PGID` default to `1000:1000`; set them in `.env` when the application-data directory has a different owner. The Bash start script determines them automatically instead.
To retain Docker-case support without root when running Compose directly, set `DOCKER_SOCKET_GID` to the numeric group ID of the host socket. On a standard Linux Docker host, obtain it with `stat -c '%g' /var/run/docker.sock`. The Bash start script detects it automatically.
## Updating
Use **App Settings → Updates** in the web UI. The checkout Compose builds from is
also mounted at `/opt/codeman`, so an update's `git checkout` and rebuild persist
on the host, and the server exiting is what restarts the container onto the new
build.
Releases that change `server.Dockerfile`, `docker-compose.yaml`, or add a key to
`.env.example` cannot be applied that way — the updater detects them, names what
changed, and asks you to run `Start-Codeman.sh` here on the host instead. Details:
[`../docs/docker-self-update.md`](../docs/docker-self-update.md).
## Application data storage
The default configuration uses a host-folder bind mount:
```yaml
volumes:
- type: bind
source: ${CODEMAN_APPDATA_PATH}
target: /home/${CODEMAN_RUNTIME_USER}
```
Set `CODEMAN_APPDATA_PATH` in `.env` to a directory that the Docker daemon can access. The example value is `/mnt/user/appdata/Coding/codeman`.
`CODEMAN_CASES_PATH` is the separate host directory for managed case workspaces. It is mounted into Codeman at the same absolute path, allowing the host Docker daemon to bind it into an isolated case container. Set it to a child directory of `CODEMAN_APPDATA_PATH` unless you deliberately store workspaces elsewhere.
Compose also exposes `CODEMAN_APPDATA_PATH` to Codeman as `CODEMAN_DOCKER_HOST_HOME`. This lets Docker case seed files, CLI credentials and the hook secret be mounted using paths that exist in the host daemon's filesystem. Direct host installations do not set this variable and retain their existing behaviour.
Set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1` when `docker info` reports `SwapLimit=false`. Codeman continues to apply the configured case memory limit, omits Docker's unsupported `--memory-swap` option, and filters only the daemon's exact swap-capability warning. Every other Docker create error and its exit status remain visible.
For an existing installation created by a root-running image, change ownership of the application-data directory before upgrading so the configured `PUID` and `PGID` can read the saved credentials and state:
```sh
chown -R 99:100 /mnt/user/appdata/Coding/codeman
```
Replace `99:100` and the path with the values from your `.env` file.
Do not replace this bind mount with a Docker-managed named volume when Docker cases are enabled. Codeman passes seed, credential, transcript and hook-secret bind sources to the host Docker daemon, so their source files must have stable paths in the daemon's filesystem. A named volume does not provide the required host path mapping.
## Static macvlan networking
The default configuration publishes a host port. It does not use `network_mode: host`. To attach Codeman directly to an existing external macvlan network with a static IP address and MAC address, remove the `ports:` section and add the following to the `codeman` service:
```yaml
mac_address: ${CODEMAN_MAC_ADDRESS}
networks:
codeman_lan:
ipv4_address: ${CODEMAN_IPV4_ADDRESS}
```
Then add this top-level network declaration:
```yaml
networks:
codeman_lan:
external: true
name: ${CODEMAN_MACVLAN_NETWORK}
```
Set `CODEMAN_MACVLAN_NETWORK`, `CODEMAN_IPV4_ADDRESS`, and `CODEMAN_MAC_ADDRESS` in `.env`. The values in `.env.example` match the supplied Unraid example network and should be changed for other hosts.
### Create a managed macvlan network
If an external macvlan network does not already exist, use this top-level declaration instead. Do not use it together with the external-network declaration.
```yaml
networks:
codeman_lan:
driver: macvlan
driver_opts:
parent: ${CODEMAN_MACVLAN_PARENT}
ipam:
config:
- subnet: ${CODEMAN_MACVLAN_SUBNET}
gateway: ${CODEMAN_MACVLAN_GATEWAY}
```
Macvlan containers are ordinarily not reachable from their Docker host without additional host-network routing. Confirm the selected address, MAC address, parent interface, and subnet are reserved and valid for the target network before starting the stack.
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
env_file="$script_dir/.env"
compose_file="$script_dir/docker-compose.yaml"
if [[ ! -f "$env_file" ]]; then
printf 'Error: Docker environment file is missing: %s\n' "$env_file" >&2
printf 'Create it from %s/.env.example before starting Codeman.\n' "$script_dir" >&2
exit 1
fi
compose_command=(docker compose --env-file "$env_file" -f "$compose_file")
appdata_path=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "CODEMAN_APPDATA_PATH" { sub(/^[^=]*=/, ""); print; exit }'
)
docker_socket=$(
"${compose_command[@]}" config --environment |
awk -F= '$1 == "DOCKER_SOCKET" { sub(/^[^=]*=/, ""); print; exit }'
)
if [[ -z "$appdata_path" ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is not set in %s\n' "$env_file" >&2
exit 1
fi
if [[ ! -d "$appdata_path" ]]; then
if [[ "$EUID" == '0' ]]; then
printf 'Error: Refusing to create CODEMAN_APPDATA_PATH as root: %s\n' "$appdata_path" >&2
printf 'Create it as the unprivileged account that should run Codeman, then retry.\n' >&2
exit 1
fi
mkdir -p -- "$appdata_path"
fi
if owner_ids=$(stat -c '%u:%g' -- "$appdata_path" 2>/dev/null); then
:
elif owner_ids=$(stat -f '%u:%g' "$appdata_path" 2>/dev/null); then
:
else
printf 'Error: Cannot determine the owner of CODEMAN_APPDATA_PATH: %s\n' "$appdata_path" >&2
exit 1
fi
export PUID=${owner_ids%%:*}
export PGID=${owner_ids##*:}
if [[ "$PUID" == '0' ]]; then
printf 'Error: CODEMAN_APPDATA_PATH is owned by root: %s\n' "$appdata_path" >&2
printf 'Change the directory ownership to the unprivileged account that should run Codeman.\n' >&2
exit 1
fi
if [[ -z "$docker_socket" || ! -S "$docker_socket" ]]; then
printf 'Error: DOCKER_SOCKET is not a Unix socket: %s\n' "${docker_socket:-<unset>}" >&2
exit 1
fi
if socket_ids=$(stat -c '%u:%g' -- "$docker_socket" 2>/dev/null); then
:
elif socket_ids=$(stat -f '%u:%g' "$docker_socket" 2>/dev/null); then
:
else
printf 'Error: Cannot determine the owner of DOCKER_SOCKET: %s\n' "$docker_socket" >&2
exit 1
fi
export DOCKER_SOCKET_GID=${socket_ids##*:}
repo_path=${CODEMAN_REPO_PATH:-$(cd -- "$script_dir/.." && pwd)}
if [[ ! -d "$repo_path" ]]; then
printf 'Error: CODEMAN_REPO_PATH is not a directory: %s\n' "$repo_path" >&2
exit 1
fi
export CODEMAN_REPO_PATH="$repo_path"
# The in-app updater runs `git checkout` and `npm install` against this checkout
# as PUID:PGID. If the directory belongs to someone else, git refuses outright
# ("detected dubious ownership") and the update fails at the first step — so warn
# here, where the fix is obvious, rather than in a failed update hours later.
if repo_owner=$(stat -c '%u' -- "$repo_path" 2>/dev/null || stat -f '%u' "$repo_path" 2>/dev/null); then
if [[ "$repo_owner" != "$PUID" ]]; then
printf 'Warning: %s is owned by UID %s but Codeman runs as UID %s.\n' "$repo_path" "$repo_owner" "$PUID" >&2
printf 'In-app updates will fail until the ownership matches. Codeman itself still starts.\n' >&2
fi
fi
if [[ ! -d "$repo_path/.git" ]]; then
printf 'Note: %s is not a git checkout, so in-app updates are unavailable.\n' "$repo_path" >&2
fi
# Record what the container is about to be built and created FROM. The in-app
# updater compares these against the release it wants to apply: a release that
# changes either file cannot be applied by the container restarting itself (a
# restart reuses the existing image and config), so it is refused and the user
# is sent back here. Written on every start, so the baseline always describes
# the container that is actually running. See docs/docker-self-update.md.
if command -v sha256sum >/dev/null 2>&1; then
sha256_of() { sha256sum -- "$1" | cut -d' ' -f1; }
elif command -v shasum >/dev/null 2>&1; then
sha256_of() { shasum -a 256 -- "$1" | cut -d' ' -f1; }
else
sha256_of() { printf ''; }
fi
dockerfile_sha=$(sha256_of "$script_dir/server.Dockerfile")
compose_sha=$(sha256_of "$compose_file")
if [[ -n "$dockerfile_sha" && -n "$compose_sha" ]]; then
# $CODEMAN_APPDATA_PATH is mounted at the runtime account's home, so this is
# dataPath('docker-env-applied.json') as the server inside the container sees it.
state_dir="$appdata_path/.codeman"
mkdir -p -- "$state_dir"
printf '{\n "dockerfileSha256": "%s",\n "composeSha256": "%s"\n}\n' \
"$dockerfile_sha" "$compose_sha" >"$state_dir/docker-env-applied.json.tmp"
mv -- "$state_dir/docker-env-applied.json.tmp" "$state_dir/docker-env-applied.json"
# A root-run start (common on Unraid) would otherwise leave a root-owned
# `.codeman` on a FIRST start, before the container has created it as PUID,
# and the unprivileged server could then never write its own state there.
if [[ "$EUID" == '0' ]]; then
chown -- "$PUID:$PGID" "$state_dir" "$state_dir/docker-env-applied.json"
fi
else
printf 'Warning: no sha256 tool found; in-app updates will not detect environment changes.\n' >&2
fi
exec docker compose --env-file "$env_file" -f "$compose_file" up --build -d
+111
View File
@@ -0,0 +1,111 @@
name: codeman
services:
codeman:
build:
context: ..
dockerfile: docker/server.Dockerfile
args:
CODEMAN_RUNTIME_USER: ${CODEMAN_RUNTIME_USER}
PGID: ${PGID:-1000}
PUID: ${PUID:-1000}
image: ${CODEMAN_IMAGE}
init: true
restart: unless-stopped
ports:
- "${CODEMAN_PORT}:${CODEMAN_PORT}"
environment:
# Tells the self-updater to restart by exiting (the restart policy below
# relaunches it) rather than by looking for an init system that is not
# here. Also set in the image; repeated so a container started without the
# image default still self-identifies.
CODEMAN_IN_CONTAINER: "1"
# This file sets `restart: unless-stopped` below, so the updater may restart
# the server by EXITING. Declared here and only here, never in the image: a
# container started by plain `docker run` has no restart policy unless the
# operator gave it one, and there the updater asks the daemon instead and
# stages the update for a manual restart when it cannot get an answer.
CODEMAN_RESTART_BY_EXIT: "1"
CODEMAN_DOCKER_BRIDGE_HOOKS: ${CODEMAN_DOCKER_BRIDGE_HOOKS}
# Host-side equivalent of the runtime user's HOME. Docker case seed,
# credential and hook mounts are translated into the daemon namespace.
CODEMAN_DOCKER_HOST_HOME: ${CODEMAN_APPDATA_PATH}
CODEMAN_DOCKER_DISABLE_SWAP_LIMIT: ${CODEMAN_DOCKER_DISABLE_SWAP_LIMIT}
CODEMAN_CASES_PATH: ${CODEMAN_CASES_PATH}
CODEMAN_HOST: ${CODEMAN_HOST}
CODEMAN_PASSWORD: ${CODEMAN_PASSWORD}
CODEMAN_PORT: ${CODEMAN_PORT}
CODEMAN_USERNAME: ${CODEMAN_USERNAME}
GEMINI_API_KEY: ${GEMINI_API_KEY}
PGID: ${PGID:-1000}
PUID: ${PUID:-1000}
TZ: ${TZ}
group_add:
# Retain access to the host Docker socket without running as root.
- ${DOCKER_SOCKET_GID:-999}
volumes:
# Application data and CLI credentials persist on the configured host
# path, rather than in a Docker-managed volume.
- type: bind
source: ${CODEMAN_APPDATA_PATH}
target: /home/${CODEMAN_RUNTIME_USER}
# Docker cases are sibling containers on the host daemon. Their workspace
# must be visible to Codeman at the same absolute path used by that daemon.
- type: bind
source: ${CODEMAN_CASES_PATH}
target: ${CODEMAN_CASES_PATH}
# Codeman uses the host daemon to create isolated Docker cases. This is
# Docker-outside-of-Docker, not Docker-in-Docker.
- type: bind
source: ${DOCKER_SOCKET}
target: /var/run/docker.sock
# The application source, so App Settings -> Updates can update in place.
# This is the SAME checkout used as the build context above, mounted over
# the image's baked copy: a `git checkout` performed inside the container
# then lands on the host and survives the container being recreated.
# Without it the pull would go to the container's writable layer and be
# silently discarded by the next `up`. See docs/docker-self-update.md.
# Defaults to `..` — the build context above — which Compose resolves
# against the project directory, so plain `docker compose up` works with
# no extra configuration. Set CODEMAN_REPO_PATH only to point elsewhere.
- type: bind
source: ${CODEMAN_REPO_PATH:-..}
target: /opt/codeman
# Build artefacts live in named volumes layered OVER the repo bind mount,
# so `npm install` and `npm run build` inside the container never write
# into the host checkout. That keeps container-compiled native modules
# (node-pty is built from source here) out of a checkout that may also be
# used to run Codeman natively, and keeps `git status` clean. Docker seeds
# an EMPTY named volume from the image, so the first start inherits the
# image's already-built node_modules and dist rather than paying for a
# bootstrap build.
- type: volume
source: codeman-node-modules
target: /opt/codeman/node_modules
- type: volume
source: codeman-dist
target: /opt/codeman/dist
extra_hosts:
- "host.docker.internal:host-gateway"
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
healthcheck:
test:
- CMD-SHELL
- >-
node -e "fetch('http://127.0.0.1:${CODEMAN_PORT}/api/status').then((response) => process.exit(response.status < 500 ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
volumes:
# Container-owned build artefacts. They persist across container recreation,
# so an in-app update's `npm install` output is not thrown away by the next
# `up`, and they are seeded from the image on first use. Removing them (or
# `docker compose down -v`) is the supported reset: the next start rebuilds
# from the image.
codeman-node-modules:
codeman-dist:
+142
View File
@@ -0,0 +1,142 @@
# syntax=docker/dockerfile:1
# Build the application from the checkout supplied as the Docker build context.
# No published Codeman application image is required.
FROM node:22-bookworm-slim AS build
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt/codeman
COPY . .
# devDependencies are deliberately KEPT (no `npm prune --omit=dev`). The in-app
# updater rebuilds from inside this container, and `npm run build` is tsc +
# esbuild — both devDependencies. Pruning them saves image size and takes the
# self-updater with it. See docs/docker-self-update.md.
RUN npm ci \
&& npm run build \
&& npm cache clean --force
# The Docker CLI talks to the host daemon through the socket mounted by
# docker/docker-compose.yaml. It does not run a Docker daemon in this container.
FROM node:22-bookworm-slim
ARG CODEMAN_RUNTIME_USER=opencode
ARG PUID=1000
ARG PGID=1000
# python3/make/g++ are here for the SELF-UPDATER, not for this build. An update
# runs `npm install` inside the running container, and node-pty ships no Linux
# prebuild, so a release that bumps it compiles from source right here. Without
# a toolchain that install fails and the update rolls back — every time, on the
# releases that need it most. Same reason install.sh installs one on bare hosts.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
g++ \
git \
make \
openssh-client \
procps \
python3 \
ripgrep \
tmux \
&& rm -rf /var/lib/apt/lists/*
# The Docker CLI, taken from the official image rather than Debian's `docker.io`.
# That package is the full ENGINE: with --no-install-recommends it still pulls 15
# packages including containerd, runc, dmsetup and iptables, none of which a
# client that only talks to a mounted socket can use. Measured on top of this
# base image: `docker.io` costs 266 MB and ships Docker 20.10.24 (2023), while
# these two files cost 108 MB and ship the current CLI (493 MB vs 335 MB total).
#
# The binaries are STATIC Go builds, so they run on this glibc image even though
# the image they come from is Alpine (verified: `docker --version`, `docker ps`
# and `docker build` all work here against a mounted host socket).
#
# buildx is copied on purpose. `scripts/build-agent-image.mjs` shells out to
# `docker build` — Codeman auto-builds the agent image on the first Docker case —
# and without the plugin that silently falls back to the CLASSIC builder, which
# Docker has deprecated and will eventually drop. `docker-compose` is NOT copied:
# Codeman never shells out to it.
COPY --from=docker:29-cli /usr/local/bin/docker /usr/local/bin/docker
COPY --from=docker:29-cli \
/usr/local/libexec/docker/cli-plugins/docker-buildx \
/usr/local/libexec/docker/cli-plugins/docker-buildx
# Keep credentials out of the image. Users authenticate these CLIs at runtime
# through Codeman sessions, and the configured host bind mount retains state.
#
# ⚠️ PINNED ON PURPOSE. Unpinned, the agent CLI versions a user ends up with are
# a function of WHEN their image was built, not of any commit — so a Codeman
# release that depends on newer CLI behaviour (the trust-dialog handling is
# pinned to Claude Code 2.1.252's layout; wheel forwarding to >= 2.1.187) breaks
# on an older image with no diff anywhere to explain why. In-app updates make
# rebuilds RARER, which makes that drift worse. Pinning turns "this release needs
# a newer CLI" into a Dockerfile change, which the updater's environment gate
# already detects and refuses (docs/docker-self-update.md).
#
# Bump these deliberately, in a release. `--no-cache` is still needed to rebuild
# this layer when only the pins change upstream.
RUN npm install --global \
@anthropic-ai/claude-code@2.1.258 \
@google/gemini-cli@0.58.0 \
@openai/codex@0.152.1 \
opencode-ai@1.18.26 \
&& npm cache clean --force
# Keep the web server and every local Codeman session unprivileged. PUID and
# PGID match the host-owned application-data directory mounted by Compose. The
# requested GID may not exist in the base image, and a host UID such as 1000 may
# already belong to the baked `node` account, so handle both cases explicitly.
RUN set -eux; \
case "${PUID}" in ''|*[!0-9]*) echo "PUID must be numeric" >&2; exit 1;; esac; \
case "${PGID}" in ''|*[!0-9]*) echo "PGID must be numeric" >&2; exit 1;; esac; \
if [ "${PUID}" -eq 0 ]; then \
echo "PUID must identify an unprivileged account, not root" >&2; \
exit 1; \
fi; \
if ! getent group "${PGID}" >/dev/null; then \
groupadd --gid "${PGID}" codeman-runtime; \
fi; \
existing_user="$(getent passwd "${PUID}" | cut -d: -f1 || true)"; \
if [ -n "${existing_user}" ]; then \
usermod \
--login "${CODEMAN_RUNTIME_USER}" \
--gid "${PGID}" \
--home "/home/${CODEMAN_RUNTIME_USER}" \
--move-home \
--shell /bin/bash \
"${existing_user}"; \
else \
useradd \
--uid "${PUID}" \
--gid "${PGID}" \
--create-home \
--home-dir "/home/${CODEMAN_RUNTIME_USER}" \
--shell /bin/bash \
"${CODEMAN_RUNTIME_USER}"; \
fi
WORKDIR /opt/codeman
COPY --from=build /opt/codeman /opt/codeman
# CODEMAN_IN_CONTAINER tells the self-updater it must restart by exiting rather
# than by asking an init system that is not here (src/web/self-update.ts).
# NODE_ENV stays `production`; the updater passes `npm install --include=dev`
# explicitly, since that value would otherwise omit the build toolchain.
ENV CODEMAN_IN_CONTAINER=1 \
CODEMAN_PORT=3000 \
HOME=/home/${CODEMAN_RUNTIME_USER} \
NODE_ENV=production
EXPOSE 3000
USER ${CODEMAN_RUNTIME_USER}
CMD ["node", "dist/index.js", "web"]
File diff suppressed because one or more lines are too long
+137
View File
@@ -0,0 +1,137 @@
# The CLI registry
Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness and OMP — is a `CliEntry`: a data record describing how to find the binary, how to build its command line, what environment it needs, and what it can do. Code that used to ask "which CLI is this?" asks the entry instead.
## Where it lives
| File | What it holds |
| ------------- | ------------------------------------------------------------------------------------------------- |
| `types.ts` | The `CliEntry` interface and everything under it. Read this first. |
| `stock.ts` | The shipped catalog. **The only file allowed to name a CLI id.** |
| `schema.ts` | Zod validation, including the cross-field checks that reject an incoherent entry at LOAD time. |
| `argv.ts` | The argv engine: the only code that turns typed tokens into a command string. |
| `patterns.ts` | The NAMED value patterns (`model`, `uuid`, `path-segment`, …) and the regex-compilation guard. |
| `profiles.ts` | The names of behaviours that genuinely need code, kept import-free so `schema.ts` can validate one. |
| `registry.ts` | Loading, merging `~/.codeman/clis.json`, and the accessors (`getCli`, `enabledClis`). |
`src/session-cli-registry-bridge.ts` maps the legacy per-mode option bag onto the engine, and `src/utils/cli-resolver.ts` / `src/utils/cli-launcher.ts` do registry-driven binary resolution and launcher-profile dispatch.
## The override file
`~/.codeman/clis.json` (instance-scoped through `dataPath()`) holds overrides and custom entries only, never a copy of the stock catalog: `{ "clis": { "<id>": { ...partial entry... } } }`. Objects merge key-wise onto the stock entry, arrays replace wholesale. **The file must be mode 0600**; the loader refuses any group/world permission bit, read bits included, so a file created with a normal umask (0644) is ignored until you `chmod 600` it. Every reason a file was ignored or an entry dropped is logged once, prefixed `[cli-registry]`, on the first load. A stock entry whose override fails validation falls back to the shipped definition; a custom entry that fails is dropped. The file is read once per process and re-read only on restart.
## The shape of an entry
```ts
interface CliEntry {
id: CliId; // 'codex'
label: string; // 'Codex' — shown in menus
shortBadge: string; // tab badge, e.g. 'CX'
accent: string; // single hex colour
enabled: boolean;
stock: boolean; // set by the loader; a custom entry can never claim it
order: number;
kind: 'agent' | 'shell';
discovery: CliDiscovery; // how to find and prove the binary
launch: CliLaunch; // the structured argv template
env: CliEnv; // exports, tmux setenv keys, the env-override allowlist
capabilities: CliCapabilities; // what every call site reads instead of the id
// .workDetect?: { promptGlyph, workingLine } — how this CLI's pane shows work
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
}
```
`capabilities` is the important part. It is what `isExternalCliMode()`, `isAltScreenStripMode()`, `hooksAvailableForMode()` and every other former per-mode branch actually read.
### Regexes that come from config
Two capability fields carry a regular expression an override file can set: `discovery.version.regex` and `capabilities.workDetect.workingLine`. Both go through `compileVersionRegex()`, which caps the source at 200 characters, refuses the nested-quantifier shapes that cause catastrophic backtracking, and returns `null` rather than throwing so every caller degrades instead of crashing.
`workingLine` is the one that matters most, because it is compiled once per session and then run against every accumulated PTY chunk and every pane capture. A nested quantifier there is a ReDoS against the event loop for the whole server, not just that session. The guard therefore runs in two places, and neither is redundant: `schema.ts` rejects the entry at LOAD time so a bad pattern never reaches a session, and `_workingLinePattern()` in `session.ts` compiles through the same helper so the runtime cannot end up with a pattern the schema would have refused.
### Three capabilities that must stay independent
`external`, `hooks` and `altScreen` describe three different, deliberately unequal sets, and deriving any one from another has already shipped a bug. `shell` has no hooks but is **not** an external CLI, so a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session and then blocked the caller for their entire timeout. `deepseek` is the mirror image: it IS external and it DOES have hooks.
`test/cli-capability-predicates.test.ts` asserts that no two of the three are equivalent across the catalog, so collapsing them fails the build rather than a user's session.
## Arg-template safety
The composed command line is interpolated into `bash -c "…"` inside tmux, which makes command construction a security boundary. Four independent layers keep config out of it:
1. **Config contains no shell text.** There is no `command: "..."` field anywhere in the schema. An entry declares a sequence of typed tokens; `argv.ts` is the only place that turns them into a string, and it owns every separator itself — one space between tokens, ` || ` between fallback variants. Neither can originate from config, because config has no field that could hold either.
2. **Every literal is validated at LOAD time** against a safe-word pattern (no space, quote, backtick, `$`, `;`, `&`, `|`, redirection, parens, braces, newline or backslash). A bad literal **rejects the whole entry** rather than being dropped, because a silently dropped flag would change security-relevant behaviour — losing `--no-approve` is not a cosmetic difference.
3. **Values resolve through NAMED patterns.** A value placeholder selects a `TokenPattern` (`model`, `uuid`, `slug`, `path-segment`, `tool-list`, …) from `patterns.ts`; config can never supply its own regex for a value, so a `clis.json` structurally cannot widen its own validation. A value that fails its pattern drops the whole argument, exactly as the hand-written builders did: an invalid `--model` omits `--model`, it never substitutes something else.
4. **Escaping is independent of validation.** `renderToken()` re-checks the resolved value before emitting it unquoted, and single-quotes anything else — so even a value that somehow bypassed validation is quoted, never concatenated raw.
The only config-supplied regexes are `discovery.version.regex` and `discovery.identity.regex`. Both run against **command output** rather than a shell token, both are compiled through `compileVersionRegex()` (length cap, nested-quantifier rejection, never the `g` flag), and the output they see is truncated first.
## Named profiles: the escape hatch
Some differences genuinely need to run code rather than be described. Those are **named profiles**: a capability field holds a profile NAME, and the implementation lives in one place keyed by that name — never by CLI id.
- `discovery.launcherProfile` — for a CLI whose binary is not the agent. `dsh` boots `$DSH_HOME/profiles/<name>`, so "installed" and "runnable" have different answers; the profile answers both, plus why a specifically-named target will not work. Implemented in `utils/cli-launcher.ts`.
- `env.setenvProfile` — per-CLI environment setup that is more than a list of keys, such as DeepSeek's status bridge.
- `capabilities.transcript` — which on-disk history reader understands this CLI (`claude-jsonl`, `codex-rollout`, `deepseek-zstd`, `omp-jsonl`, `none`).
- `capabilities.echo.predictProfile` — the predictive-echo model a composer needs.
The names live in `profiles.ts`, which is kept free of imports so `schema.ts` can validate a name at load time. A profile this build does not implement is a load-time error naming the field, rather than a CLI that silently looks permanently uninstalled.
## DeepSeek: the four assumptions it breaks
DeepSeek is worth reading before assuming an entry looks like its siblings — the schema carries four extensions because of it.
| What it breaks | How the registry expresses it |
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `dsh` is a profile LAUNCHER, not the agent, so "installed" is not "runnable". | `discovery.launcherProfile` + `discovery.launcherTargetParam`. |
| Its permission switch is the **`DSH_PERMISSION_MODE` env var**, not a flag — the harness has none. | `env.configSetenv` (so the ordinary `privilegedParams` clamp still reaches it) **and** `capabilities.privilegedEnvKeys`. |
| It is the only non-claude mode with real hook signals, and for it that is a per-SESSION question. | `capabilities.hooks: 'supervised'` — a third state, not a boolean. |
| Its transcript is zstd session files, one frame per write. | `capabilities.transcript: 'deepseek-zstd'`. |
## Identity probes
`discovery.identity` asks the binary whether it is the program we meant, and it runs **before** the version probe, because a version probe cannot tell an impostor from the real thing. Debian ships an unrelated `dsh` (dancer's shell) that answers `--version` perfectly happily, and npm carries squatters for both `pi` and `grok`.
`discovery.version.requireVersionMatch` is the weaker companion: a binary whose version output has the wrong shape counts as ABSENT rather than present-with-unknown-version. That is what a short, generic binary name needs, and it is what keeps `codeman doctor` and the run mode from telling the user opposite things about the same binary — both read the same regex off the same entry.
## The no-id-branching rule
`test/cli-registry-no-id-branching.test.ts` fails the build if a CLI id comparison appears outside the stock catalog. It builds its id list from the live catalog, blanks comment lines before scanning (comments legitimately quote the pattern to explain why a branch was removed, and blanking rather than dropping is what keeps reported line numbers pointing at the real file), and keeps an allowlist in which **every entry carries its reason**.
It matches four shapes, not one: `mode === '<id>'`, `mode !== '<id>'`, `case '<id>':`, and `['<id>', …].includes(mode)`. The first version matched `===` only, and that gap was not academic — the refactor it guards converted the `===` sites and left the negated ones, so 36 `!==` branches survived it, including a seven-mode chain auto-enabling Ralph under a comment asking the next person to keep it in step with a predicate by hand while the sibling code path already read the capability. A guard that sees half the shapes reports a count measured over the half it happens to catch.
The allowlist is not a formality. If a branch is about what a CLI can DO it belongs in `CliCapabilities`; the entries that remain are things that are not CLI-behaviour branches at all — chiefly the legacy per-mode `<Mode>Config` objects on `POST /api/sessions`, which are a fact about the public HTTP API rather than about any CLI, plus a few documented cases where `mode === 'claude'` is genuinely the right question (Read My Mind reads Claude's _own_ transcript, so a capability there would be actively wrong).
## Two namespaces called `param`
`launch.params` keys, `env.configSetenv[].fromParam` and `capabilities.privilegedParams[].param` all name a **launch param**. The **legacy wire field** a param arrives as is a separate namespace, and `launch.legacyConfigAliases` is the only bridge between the two.
This matters because it is invisible when it is wrong. `capabilities.privilegedParams[].param` is the multi-user bypass clamp's only handle on a CLI's privilege switch, and a name from the wrong namespace clamps **nothing**: no load error, no failing test, the clamp simply stops running. Codex is the entry where the two names differ (`bypassApprovals` as the param, `dangerouslyBypassApprovals` on the wire), so it is the one that catches a regression. `schema.ts` rejects any entry naming a param it never declared, on both `configSetenv.fromParam` and `privilegedParams.param`.
## Fields declared for later
`shortBadge`, `accent`, `capabilities.echo`, `capabilities.wheelForward`, `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` are **declared but not yet read**. They all describe frontend behaviour, and the frontend is deliberately untouched here: `app.js`, `terminal-ui.js` and `styles.css` keep their own hand-authored per-CLI rules, and moving them is its own piece of work verified by a browser/mobile suite the CI gate cannot see.
Treat those values as **transcribed, not authoritative** — nothing enforces that `echo.policy` matches `_updateLocalEchoState`'s fallthrough, or that `accent` matches the gradient CSS paints, so re-measure before wiring one up. A field that is both wrong and unread is worse than an absent one, because the next reader trusts it; `test/cli-registry-no-id-branching.test.ts` pins the list so it cannot quietly grow, and wiring one up makes its line there fail, which is the direction you want.
`overlays.credStore` is in the same category, for a sharper reason: the Docker credential-seeding path still reads its own `CRED_STORES` table, because this shape allows ONE store per CLI and the live table needs two for gemini (`.gemini` for the CLI's own auth plus `.config/gcloud` for Vertex), while deepseek declares none here even though `.dsh` is seeded. Wiring it means making the field an array and correcting those two entries — a change to credential seeding, which is simultaneously the worst thing here to get wrong and the least covered by tests, since every docker IO path is no-op'd under vitest.
Everything else in the interface is live, including `overlays.remote` / `overlays.docker`, which back `defaultRemoteCommandForMode()` and `defaultDockerCommandForMode()` directly. Those two used to be hardcoded `Record<…CommandMode, string>` tables duplicating the registry with nothing keeping the two in step; `test/location-overlay-commands.test.ts` pins every resulting command as a literal string.
## Resolve at call time, never at import
Anything reading the registry must resolve it when it is asked, not when its module is first imported. `sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()` and each resolver's `searchDirs` thunk all re-read the catalog per call.
A module-level const freezes at first import, and the failure is asymmetric: a CLI enabled while the server is running moved the run menu but not the frozen surface, so validation rejected a mode the menu offered, or `codeman doctor` reported a catalog nobody had any more.
## Adding a CLI
1. Add a `CliEntry` to `stock.ts`.
2. Add a golden spawn-command pin to `test/cli-registry-spawn-golden.test.ts`, a row to `test/cli-capability-predicates.test.ts`, and its remote/docker commands to `test/location-overlay-commands.test.ts`.
3. That is usually all. If you find yourself wanting to add an `if` somewhere, the guard test will tell you — and the answer is a capability field, or a named profile if it genuinely needs to run code.
## See also
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
- `docs/architecture-invariants.md` — the mechanics and the history behind the rules above.
- `docs/deepseek-integration.md` — why DeepSeek is shaped the way it is.
+43
View File
@@ -78,6 +78,49 @@ curl -X POST localhost:3000/api/cases/docker-link -d '{"name":"sandbox","hostId"
curl -X POST localhost:3000/api/quick-start -d '{"caseName":"sandbox","mode":"claude"}'
```
## Attach to a container you already run
The tab's **Attach to an existing container** toggle points a case at a container **you**
built and run. Codeman only ever `docker exec`s into it: it never creates, starts, stops,
restarts or removes it, and it seeds no credentials into it, so the CLIs inside must already
be installed and logged in. A missing or stopped container is an error to report, not a state
to fix — start it yourself and reopen the session.
- **Container Name** is a picker over the engine's containers that you can also type into
(the engine may be remote, or the container may not exist yet when you fill the form).
Stopped containers are listed too, sorted last and labelled, so "mine isn't here" is never
a dead end.
- **Container Workdir** is a path that must already exist **inside** the container. Adoption
mounts nothing, so it need not match the host workspace path; **Browse** lists directories
inside the container itself. Without this check, a wrong path fails at launch as a bare
`execvp failed` inside the pane.
- **Workspace Path** is still a real host directory. It backs file previews, attachments and
watchers exactly as it does for an owned case, but here it is only a mirror: nothing is
bind-mounted, so point it at whatever host directory your container already exposes.
- **Check container** runs a read-only preflight and reports what is inside before you commit
to a case name (running or not, tmux present, which CLIs resolved).
- **Run modes come from the container**, not the host: a host with no `claude` still offers
Claude if the container ships it, and a mode the container lacks is hidden.
- Claude is launched **without** `--dangerously-skip-permissions` when the container's exec
user is root, because Claude Code refuses that flag as root and the refusal is only visible
inside the container.
- Image, network and resource settings disappear from the form: they describe a
`docker create` that adoption never runs.
Recreate is refused for an adopted case, full-image export is refused (it would commit a
container that is not ours), unlinking the case leaves the container running, and the boot
reaper skips it. Workspace-only export still works and never pauses the container.
Equivalent API:
```bash
curl -X POST localhost:3000/api/docker-cases/adopt-preflight -d '{"hostId":"local","container":"my-dev-box","containerWorkdir":"/workspace"}'
curl -X POST localhost:3000/api/cases/docker-adopt -d '{"name":"devbox","hostId":"local","container":"my-dev-box","hostWorkspacePath":"/home/you/projects/devbox","containerWorkdir":"/workspace"}'
```
In multi-user mode adoption is **admin-only**, unlike `docker-link`: an adopted container's
mounts belong to whoever built it, so one mounting `/` would hand the adopter the whole host.
## Lifecycle
- **Reconnect after a Codeman restart** lands back in the same live agent (the in-container tmux survives).
+78
View File
@@ -0,0 +1,78 @@
# Docker Compose deployment
This configuration builds the Codeman application image locally from this checkout. It does not download or depend on a pre-built Codeman image.
For the Compose configuration, environment settings, storage migration, and macvlan networking examples, see the [Docker deployment guide](../docker/README.md).
The image includes Claude Code, Codex, Gemini CLI, and OpenCode. Authenticate a CLI from its Codeman session; credentials are never baked into the image.
## Prerequisites
- Docker Engine or Docker Desktop with Docker Compose v2
- A reachable Docker daemon
The application container mounts the Docker daemon socket so Codeman can create and manage its isolated Docker cases. Treat anyone who can administer this Compose project as having Docker-host-equivalent access.
## Start
Copy the environment template, set a strong password, and confirm `CODEMAN_APPDATA_PATH`. The example maps `/mnt/user/appdata/Coding/codeman` on the host to `/home/${CODEMAN_RUNTIME_USER}` in the container, preserving Codeman state and CLI credentials outside Docker-managed volumes.
```sh
cp docker/.env.example docker/.env
```
On PowerShell, use the following command instead.
```powershell
Copy-Item docker/.env.example docker/.env
```
On Linux, run the stack with the start script. It determines `PUID` and `PGID` from the owner of `CODEMAN_APPDATA_PATH`, and `DOCKER_SOCKET_GID` from the configured Docker socket, before invoking Compose. A root-owned application-data directory is rejected so the runtime account cannot become UID 0.
```sh
bash docker/Start-Codeman.sh
```
On other platforms, run Compose directly. `PUID` and `PGID` default to `1000:1000`; set them in `docker/.env` when the application-data directory has a different owner.
```sh
docker compose --env-file docker/.env -f docker/docker-compose.yaml up --build -d
```
Open `http://localhost:3000` and sign in with the username and password from `docker/.env`.
## Operations
The local image is tagged `codeman:local` by default. Change `CODEMAN_IMAGE` in `docker/.env` if a different local tag suits your environment.
```sh
docker compose --env-file docker/.env -f docker/docker-compose.yaml logs -f codeman
bash docker/Start-Codeman.sh
docker compose --env-file docker/.env -f docker/docker-compose.yaml down
```
`CODEMAN_APPDATA_PATH` holds Codeman state and survives container recreation. Remove that host directory only when deliberately resetting the installation.
`CODEMAN_CASES_PATH` must be an absolute path on the Docker host. Compose mounts it at the same path inside Codeman, so the host daemon can bind the managed workspace into isolated Docker cases. Do not set it to `/home/${CODEMAN_RUNTIME_USER}/codeman-cases`.
Compose passes `CODEMAN_APPDATA_PATH` into Codeman as `CODEMAN_DOCKER_HOST_HOME`. Codeman uses that value to translate generated Docker seed, credential and hook-secret bind sources from the container's home path into paths visible to the host Docker daemon.
If `docker info` reports `SwapLimit=false`, set `CODEMAN_DOCKER_DISABLE_SWAP_LIMIT=1`. Isolated cases retain their configured memory limit. Codeman omits the unsupported swap-limit option and filters only the daemon's exact swap-capability warning while retaining every other Docker create error.
If that directory was created by an earlier root-running image, change its ownership to the configured `PUID:PGID` before starting this version. This preserves existing CLI credentials and session state while allowing the unprivileged runtime account to use them.
## Updating
Codeman updates itself from **App Settings → Updates**, as it does on a bare host. The checkout mounted at `/opt/codeman` is the same directory Compose builds from, so the update's `git checkout` and rebuild land on the host and survive container recreation; the restart is the server exiting, which `restart: unless-stopped` turns into a relaunch on the new build.
That applies application code only. A release that changes `docker/server.Dockerfile`, `docker/docker-compose.yaml`, or adds a key to `docker/.env.example` needs the image rebuilt or the container recreated, which a container cannot do to itself. The updater detects each case and refuses with a message naming what changed; run `docker/Start-Codeman.sh` on the host to apply those.
`CODEMAN_REPO_PATH` overrides which checkout is mounted. It defaults to the compose project's parent directory, so it normally needs no setting. Point it at a directory that is not a git checkout and in-app updates are reported as unavailable.
Full detail, including the fingerprint baseline and the troubleshooting table: [`docker-self-update.md`](docker-self-update.md).
## Docker cases
The default socket path is `/var/run/docker.sock`, which works with a standard Linux Docker Engine. The Bash start script detects its numeric group ID. When running Compose directly, set `DOCKER_SOCKET_GID`, for example using `stat -c '%g' /var/run/docker.sock`, so the unprivileged `CODEMAN_RUNTIME_USER` account can create Docker cases. Docker Desktop users should set `DOCKER_SOCKET` in `docker/.env` only when their Docker installation exposes a different compatible socket path.
Codeman Docker cases are sibling containers on the host daemon, not children of the application container. The Compose configuration handles their workspace bind mount through `CODEMAN_CASES_PATH`; the `/home/${CODEMAN_RUNTIME_USER}` application-data mapping is for Codeman state and ordinary in-container sessions, not sibling-case workspaces.
+218
View File
@@ -0,0 +1,218 @@
# Self-update in the Docker Compose deployment
Codeman running as a container updates itself from **App Settings → Updates**, the
same place and the same button as a bare-host install. This document explains how
that works, what it deliberately refuses to do, and how to recover when it stops.
The bare-host updater is documented in
[`architecture-invariants.md#self-update`](architecture-invariants.md#self-update);
this file covers only what the container changes.
## The short version
| Change in the release | Applied by |
| -------------------------------- | ------------------------------------------------ |
| Application code | The in-app updater |
| `docker/server.Dockerfile` | `docker/Start-Codeman.sh` on the host |
| `docker/docker-compose.yaml` | `docker/Start-Codeman.sh` on the host |
| New key in `docker/.env.example` | Add it to `docker/.env`, then `Start-Codeman.sh` |
The in-app updater detects all three of the bottom rows itself and refuses with a
message naming what changed, so you never have to work out which case you are in.
## Why the container needs its own path
The bare-host updater does `git checkout <tag> && npm install && npm run build`,
then asks systemd or launchd to restart the service. Two of those assumptions are
false in a container:
1. **There is no init system.** A container's supervisor is the Docker daemon,
which acts on the container, not on processes inside it.
2. **The image is immutable.** A `git pull` into the image's baked `/opt/codeman`
would land in the container's writable layer, survive `docker restart`, and be
silently discarded by the next `docker compose up`.
Both are solved by configuration rather than by a second updater:
- **The checkout is a host bind mount.** `docker-compose.yaml` mounts the repo
(the same directory used as the build context) over `/opt/codeman`, so the
updater's `git checkout` writes to the host filesystem and survives the
container being recreated.
- **The restart is the server exiting.** `restart: unless-stopped` relaunches the
container whenever its main process ends, including on a clean exit — so the
updater's final step is to signal the server, and Docker starts it again on the
freshly built `dist/`.
Everything else — the release-tag channel, the auto-stash, the atomic
`update-status.json` the browser polls across the connection drop, the boot-time
reconcile that flips `restarting` to `completed` — is the existing machinery,
unchanged. The container path is a new `SupervisorKind`, not a new updater.
## What the pieces are
| Piece | Role |
| ---------------------------------------------- | ------------------------------------------------------------------- |
| Repo bind mount at `/opt/codeman` | Makes the pull persistent. Without it, self-update is unavailable. |
| `codeman-node-modules`, `codeman-dist` volumes | Container-owned build artefacts, layered over the bind mount. |
| `CODEMAN_IN_CONTAINER=1` | Tells `detectSupervisor()` to restart by exiting. |
| `restart: unless-stopped` | Turns that exit into a restart. Verified before every update. |
| `CODEMAN_RESTART_BY_EXIT=1` | The Compose file's declaration of that policy, so the updater may exit even with no Docker socket. |
| Toolchain + devDependencies in the image | Lets `npm install` and `npm run build` run inside the container. |
| `docker-env-applied.json` | Fingerprint baseline, written by `Start-Codeman.sh` on every start. |
### Why build artefacts are in named volumes
`node_modules` and `dist` are mounted as named volumes **on top of** the repo bind
mount. Without that, an update's `npm install` would write into the host checkout,
leaving container-compiled native modules (node-pty builds from source here) in a
directory that may also be used to run Codeman natively, and leaving `git status`
permanently noisy.
Docker seeds an empty named volume from the image, so the first start inherits the
image's already-built `node_modules` and `dist` and pays no bootstrap cost.
`docker compose down -v` is the supported reset: the next start re-seeds them.
### Why the runtime image carries a build toolchain
`npm run build` is `tsc` plus `esbuild`, both devDependencies, so the image no
longer runs `npm prune --omit=dev`. And `npm install` may rebuild node-pty, which
ships no Linux prebuild, so `python3`, `make` and `g++` are installed as well.
This is the real cost of in-place updates: a noticeably larger image than a
runtime-only one. It buys an update that takes about a minute instead of a full
image rebuild, and it is why `NODE_ENV=production` is paired with an explicit
`npm install --include=dev` in the updater.
## The environment gate
An in-place update applies **code only**. A restarted container reuses its existing
image and configuration, so a release that changes the environment cannot take
effect that way — and would half-apply: new code against an old environment. The
updater therefore checks the **target release's own files**, read straight out of
git with `git show <tag>:<path>` before anything is checked out.
### 1. `server.Dockerfile` changed, so the image must be rebuilt
Compared by sha256 against the fingerprint `Start-Codeman.sh` recorded when the
running container was built.
### 2. `docker-compose.yaml` changed, so the container must be recreated
Same mechanism. A restart cannot pick up a new mount, port or environment
variable; only recreating the container can.
### 3. `.env.example` gained keys your `.env` has no value for
The check that matters most, because **Compose will not tell you**. An unset
`${VAR}` interpolates to the empty string; Compose prints a warning to a terminal
nobody is watching and starts anyway. A new required setting therefore arrives as
a silently blank environment variable and misbehaves later, far from the cause.
The updater names the missing keys instead.
Commented-out lines in `.env.example` are deliberately *not* keys — that is how
the file marks optional overrides such as `# PUID=1000`, and counting them would
block updates on settings you are meant to leave alone.
### 4. A restart policy that would not bring the container back
Before signalling the server, the updater asks the Docker daemon for its own
container's restart policy. If it is `no`, the update is refused: applying it
would take Codeman down and leave no UI to recover from.
If the policy cannot be read at all (no Docker socket mounted) the update is
still allowed, but the final step changes: the server exits only when the
Compose file declared `CODEMAN_RESTART_BY_EXIT=1` (the shipped one does, because
it is the file that sets `restart: unless-stopped`) or the daemon confirmed an
auto-restart policy. Otherwise the build completes and the panel asks you to
restart the container by hand. A container started by plain `docker run` with no
restart policy therefore gets a staged update, never an outage.
### What the gate deliberately does not do
Every unknown fails **open**:
- A missing fingerprint baseline (a container started before this feature existed)
is not treated as a change, or those installs could never update at all.
- An unreadable `.env`, an unreachable Docker socket, or a target tag whose files
cannot be read all yield "no blocker" rather than a refusal.
The one place an unknown does NOT fail open is the kill itself: with neither the
Compose declaration nor a daemon answer, the updater stages the build and asks
for a manual restart rather than exiting a server nothing may bring back.
The gate catches a specific, detectable class of mistake; it is not a last line of
defence. It is also re-evaluated server-side on `POST /api/system/update`, so
hiding the button in the UI is a courtesy rather than the control.
## The one residual risk
The gate is derived from the diff, so it cannot see a release that needs a newer
environment **without changing any of those files** — for example, code that
depends on newer agent-CLI behaviour.
That is why the four global CLIs in `server.Dockerfile` are **pinned**. Unpinned,
the versions a user ends up with are a function of when their image was built
rather than of any commit, and in-app updates make rebuilds rarer, which makes
that drift worse over time. Pinned, "this release needs a newer CLI" becomes a
Dockerfile change, which check 1 already detects. Bump them deliberately, as part
of a release.
The complementary merge-side guard is `test/docker-compose-env-parity.test.ts`,
which fails CI when a variable is added to `docker-compose.yaml` without an entry
in `.env.example`, or the reverse.
## Sequence of an in-place update
1. **Check** — `GET /api/system/update/check` finds the latest release tag, fetches
that one ref so the gate can read the target's files, and returns any blockers.
2. **Start** — `POST /api/system/update` re-evaluates the gate, writes `queued` to
`update-status.json`, stages `self-update.sh` outside the repo and runs it.
3. **Apply** — stash if dirty, fetch the tag, check it out, `npm install
--include=dev`, `npm run build`. A failure at any step rolls back to the
previous commit, rebuilds it and reports `failed`; the server is never
restarted into a broken build.
4. **Restart** — write the terminal `restarting` marker, then signal the server.
The container exits and Docker restarts it.
5. **Reconcile** — the rebooted server compares its own version against the target
and flips the status to `completed` or `failed`. The browser, still polling,
picks that up.
Step 4 kills the updater script along with the container — unlike the systemd
path, it does not outlive the restart. That is safe only because the terminal
marker is written first, which is why nothing may be appended after the kill.
## Troubleshooting
**"This install can't update itself (unknown)"** — the repo bind mount is missing,
so the container is running the baked image copy. Check `CODEMAN_REPO_PATH` and
confirm the mounted directory really contains `.git`.
**The update fails immediately with a git ownership or permission error** — the
mounted checkout belongs to a different user than the one Codeman runs as
(`PUID`), so git refuses it as "dubious ownership". `Start-Codeman.sh` warns
about this at start; fix it by chowning the checkout to the same account that
owns `CODEMAN_APPDATA_PATH`.
**A rebuild is reported as required every time** — the fingerprint baseline does
not match the checkout. `Start-Codeman.sh` writes it on every start, so start
through that script rather than a bare `docker compose up` after either file
changes.
**Codeman does not come back after an update** — the build succeeded, since the
updater gates the restart on it, so read the container logs with `docker compose
logs codeman`. To roll back, check out the previous tag in the host checkout and
run `docker/Start-Codeman.sh`.
**The update failed during `npm install`** — most likely a native rebuild with no
toolchain, meaning the image predates the toolchain being added. Rebuild once from
the host and the in-app path works from then on.
**Resetting the build artefacts** — `docker compose down -v`, then
`Start-Codeman.sh`. This discards the named volumes and re-seeds them from a fresh
image.
## Disabling it
Set `CODEMAN_DISABLE_SELF_UPDATE=1` in `docker/.env` and pass it through in the
compose file's `environment:` block. The Updates panel then reports that in-app
updates are disabled, and the host-side script is the only way to update.
+145
View File
@@ -0,0 +1,145 @@
# PR bot: automatic pull-request reviews, reported over Telegram
The PR bot is maintainer tooling that lives in `scripts/pr-bot/`. It watches the
repository's open pull requests, reviews each one in a Codeman claude session running in
a private clone of the repository, and sends the verdict to a Telegram chat with the ranked
findings, a recommendation and action buttons. The maintainer decides what happens next
from the phone: merge, post the drafted review comment, close, approve a waiting CI run,
or ask the reviewer session a follow-up question.
It reviews on its own. It never writes to GitHub on its own.
## How a review runs
1. Every poll (default 10 minutes) the bot lists open PRs with `gh`. A PR is queued
when its head commit differs from the one last reviewed, so a push re-reviews and an
untouched PR is never reviewed twice. Draft PRs and bot PRs are skipped. The backlog
is ordered mergeable-and-small first, conflicting-and-huge last.
2. The PR head is fetched into a private ref (`refs/pr-bot/<n>`) of the main repository
and checked out (detached) in a private clone under
`~/.codeman/pr-bot/worktrees/pr-<n>`, made with `git clone --shared` so the object
store stays shared and nothing is duplicated. The maintainer's own checkout is never
checked out or reset by the bot. A clone rather than a linked worktree because Claude
Code reads a linked worktree's project settings from the MAIN checkout, whose model
pin would silently override the bot's. `node_modules` is a symlink to the main
checkout's tree when the PR itself leaves the dependency files untouched (judged
against the PR's merge base, not against current master), and a real `npm ci`
otherwise (the symlink is unlinked first, so npm can never write through it; an
install interrupted by a restart is discarded, never reused).
3. A review brief is written to `~/.codeman/pr-bot/jobs/pr-<n>/brief.md`: the PR
metadata, CI state, mergeability, the file list, the body verbatim, the ground rules
(nothing reaches GitHub, no installs, no builds, no services, never port 3000), the
review protocol (CLAUDE.md and CONTRIBUTING first, then correctness, security,
invariants, tests, contract, scope), the checks to run, the verdict vocabulary and
the exact JSON to produce.
4. A Codeman session named `prbot-<n>` is created in the clone over the HTTP API,
the composer is awaited (the folder-trust dialog is read off the screen and answered
one key at a time), and one prompt points the session at the brief. The bot waits on
the `stop`/`blocked`/`exit` hook signals, never on the heuristic `idle`, with a hard
timeout (default 40 minutes).
5. The session writes `report.json` and `report.md` next to the brief and replies
`REVIEW COMPLETE`. The bot parses the JSON leniently, records the Claude session id
for follow-ups, deletes the Codeman session, keeps the clone, and sends the
summary to Telegram. Reviews run one at a time.
Verdicts: `merge`, `merge-with-fixes`, `request-changes`, `close`, `needs-discussion`.
Findings are ranked `blocker` / `major` / `minor` / `nit`, each with file and line.
## The Telegram side
Each review arrives as one message: PR number and title, author, size, CI state,
mergeability, the verdict with confidence, the summary, the top findings, the checks
that were run, the recommendation, and buttons:
| Button / command | What it does |
| --- | --- |
| 📄 Full report · `/report N` | Sends `report.md` (as a file when long). |
| 💬 Draft comment · `/draft N` | Shows the comment drafted for the contributor. Nothing is posted. |
| 📮 Post comment · `/post N` | Shows the draft again and asks for confirmation, then posts it under your GitHub account. |
| ✅ Merge · `/merge N` | Re-checks mergeability and CI, lists warnings (red CI, new commits since the review, a non-merge verdict), asks for confirmation, then merges with a merge commit. Refuses a conflicting PR. |
| 🗑 Close · `/close N reason` | Asks for the closing comment if none was given, asks for confirmation, then closes with that comment. |
| ▶️ Approve CI run · `/approve N` | Approves a workflow run that GitHub holds for a first-time contributor. Shown only when one is waiting. |
| 🔁 Re-review · `/review N` | Queues a fresh review at the front of the queue. |
| `/ask N question`, or reply to any review message | Resumes the reviewer's Claude conversation in the same clone and relays the answer. It can inspect, run checks, or make uncommitted changes there; it still never pushes. |
| `/status` · `/scan` · `/pause` · `/resume` · `/help` | Housekeeping. |
Merge, close and post always take a second tap. Confirmations expire after 15 minutes.
Only messages from the configured chat are acted on; anyone else gets silence.
When a PR is merged or closed, the bot announces it, removes the clone and the
private ref, and keeps the record.
## Setup
Requirements on the machine that runs the bot: a running Codeman (the sessions are
spawned there), `gh` logged in as the account that should merge and comment, `git`,
Node 22, and the repository checkout with its `node_modules`.
Config is `~/.codeman/pr-bot.env` (`KEY=VALUE`, keep it mode 0600). The Telegram token
and chat id are read from the existing notifier bot's env file
(`~/codeman-cases/telegram/.env`) when present, so on the maintainer's machine no key
has to be copied; set them here to use a different bot.
| Key | Default | Meaning |
| --- | --- | --- |
| `TELEGRAM_BOT_TOKEN` | from the shared env file | BotFather token. |
| `TELEGRAM_CHAT_ID` | from the shared env file | The one chat that receives reports and may issue commands. |
| `GITHUB_REPO` | `Ark0N/Codeman` | `owner/name`. |
| `CODEMAN_API_URL` | `https://127.0.0.1:3000` | The Codeman that spawns the review sessions. A self-signed certificate is accepted. |
| `CODEMAN_USERNAME` / `CODEMAN_PASSWORD` | unset | Only when that Codeman has a password. |
| `PR_BOT_POLL_INTERVAL` | `600` | Seconds between GitHub polls (minimum 60). |
| `PR_BOT_MAIN_CHECKOUT` | the repo this script is in | The repository the clones share objects with and fetch from. |
| `PR_BOT_DATA_DIR` | `~/.codeman/pr-bot` | State, briefs, reports, clones. |
| `PR_BOT_MODEL` | unset (the session default) | Codeman `modelOverride` for the review sessions, e.g. `claude-fable-5-1`. |
| `PR_BOT_EFFORT` | unset | Codeman `effort` for the review sessions. |
| `PR_BOT_REVIEW_TIMEOUT` | `40` | Minutes before a review is abandoned. |
| `PR_BOT_FOLLOWUP_TIMEOUT` | `20` | Minutes before a follow-up is abandoned. |
| `PR_BOT_AUTO_REVIEW` | `1` | `0` reviews only on `/review N`. |
| `PR_BOT_REVIEW_DRAFTS` | `0` | `1` reviews draft PRs too. |
| `PR_BOT_TELEGRAM_ENV_FILE` | `~/codeman-cases/telegram/.env` | Where the shared token and chat id are read from. |
```bash
npm run pr-bot -- check # config, gh, git, Codeman, Telegram, open PR count
npm run pr-bot -- scan # the open PRs in review order, with what is new
npm run pr-bot -- review 383 --no-telegram # one review now, printed instead of sent
npm run pr-bot -- run # the daemon
npm run pr-bot -- install-service # systemd user unit codeman-pr-bot, enabled and started
npm run pr-bot -- status # what the state file knows
tail -f ~/.codeman/pr-bot/bot.log # the service logs to a file, not the journal
```
## Safety properties worth knowing before changing it
- **GitHub writes happen in exactly one place** (`runConfirmed` in `bot.ts`) and only
after a confirmation tap on a nonce that expires. The review session's brief forbids
`gh` writes, pushes and merges, and the session has no reason to have the token
anyway: it runs as the same user as the maintainer's own sessions, so the prompt rule
is the guard, and the clone's checkout is detached so an accidental push has no
branch to land on.
- **The maintainer's checkout is shared with other agent sessions**, so the bot never
runs `git checkout`, `reset`, `stash` or `clean` there. It only fetches into
`refs/pr-bot/*` there; everything else happens inside the per-PR clone.
- **The clones are `git clone --shared`.** Their objects live in the main checkout, so
the `refs/pr-bot/<n>` ref there is what keeps a PR's commits safe from `git gc`; it
is deleted together with the clone when the PR closes.
- **`node_modules` may be a symlink into the live checkout.** The brief forbids
installs, and `worktree.ts` unlinks the symlink before any `npm ci`. `src/web/public/vendor`
is copied per file, never linked, because postinstall regenerates it in place.
- **Sessions are named `prbot-<n>`** and tracked by id; the bot deletes only those, on
completion, on shutdown, and (by name) as a sweep at startup after a crash. It never
touches the maintainer's `w<n>-*` sessions.
- **Readiness and end-of-turn follow the codeman skill's rules**: composer first
(`shift+tab` in the pane), trust dialog read from the screen, `stop,blocked,exit`
signals rather than `idle`. A session that asks a question is reported as a failed
review with the pane's last lines, not left hanging.
- **Telegram input is data.** Command parsing is a fixed grammar; free text is only ever
relayed to a reviewer session as the maintainer's own follow-up, or used as a closing
comment after confirmation.
Tests: `test/pr-bot-report.test.ts` (parsing, formatting, CI classification, command
grammar, trust-dialog reader, config), `test/pr-bot-state.test.ts`, and
`test/pr-bot-commands.test.ts` (the command and confirmation flows against a stubbed
`gh` and Telegram: a GitHub write happens once, after the tap, never for a foreign chat
or a reused nonce). Type-checked by
`npm run typecheck` through `config/tsconfig.pr-bot.json`, linted and formatted with
the main sources.
+2 -1
View File
@@ -518,7 +518,7 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from).
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, `metadata.google.internal`): `webview-egress-policy.ts` refuses them at save time, and `webview-egress.ts` re‑judges the RESOLVED address at connect time through a `lookup` hook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability‑bearing URL to a third‑party host it links.
---
@@ -529,6 +529,7 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
| `CODEMAN_PASSWORD` (+ `CODEMAN_USERNAME`) | Enable HTTP Basic auth |
| `--host` / `CODEMAN_HOST` | Bind host (default `127.0.0.1`) |
| `CODEMAN_ALLOWED_HOSTS` | Extra `Host`/`Origin` allowlist entries for reverse proxies (comma‑separated; exact host, or leading‑dot `.suffix` for subdomains) — see §3 |
| `--base-url` / `CODEMAN_BASE_URL` | Sub‑path prefix Codeman is mounted under behind a reverse proxy, e.g. `/codeman` (default `/`); the proxy must forward the prefix unchanged. Independent of `CODEMAN_ALLOWED_HOSTS` |
| `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledge an unauthenticated non‑loopback bind (downgrades the warning) |
| `--https` | Enable TLS (adds HSTS) |
| `CODEMAN_INSTANCE` | Scope tmux socket + data dir for isolation |
+15 -4
View File
@@ -161,10 +161,21 @@ then every API call fails, which looks like the dashboard being broken.
then streams the body without any time bound; a header timeout is logged
server-side and answered as a 502 that names the limit. WebSocket handshakes use
the separate `CODEMAN_WEBVIEW_WS_HANDSHAKE_TIMEOUT_MS` (default 30s).
- **Not a security boundary.** The proxy reaches whatever the Codeman server can
reach. That is not an escalation for someone who already commands
`--dangerously-skip-permissions` agents, but in multi-user mode it does mean a
non-admin user's dashboard is fetched from the server's network position.
- **Not a security boundary, with one carve-out.** The proxy reaches whatever the
Codeman server can reach (a `localhost` dashboard is the point), so it is not an
escalation for someone who already commands `--dangerously-skip-permissions`
agents, but in multi-user mode it does mean a non-admin user's dashboard is
fetched from the server's network position. The carve-out: link-local and
cloud-metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`,
Azure's `168.63.129.16`, Alibaba's `100.100.100.200`, the
`metadata.google.internal` alias) are refused at save time AND at connect
time, judged on the address a name actually resolves to. Nothing anyone embeds
as a dashboard lives there; an instance's IAM credentials do.
- **The proxy URL is a bearer credential.** `/webview/<cap>/...` needs no cookie,
so treat it like a password. It is revoked when you log out, when an admin logs
you out, and when your account is deleted, and it expires after 12 hours
without use. Proxied responses carry `Referrer-Policy: same-origin`, so a
dashboard that links to third-party sites does not hand them the URL.
## Where the code lives
+42
View File
@@ -167,6 +167,47 @@ and is not one.
Also make sure the proxy forwards WebSocket upgrades. The terminal is a WebSocket, and the
upgrade runs the same Host and Origin checks, closing with code `4003` on failure.
### Mounting under a sub-path
By default Codeman assumes it is served at the origin root (`/`). To mount it under a
sub-path — e.g. `https://example.com/codeman/` — start it with `--base-url` (or the
`CODEMAN_BASE_URL` env var):
```bash
codeman web --base-url /codeman
# or
CODEMAN_BASE_URL=/codeman codeman web
```
The value is a plain path prefix; `/` (the default) means "mounted at the root". With a
prefix set, Codeman emits every URL — the HTML shell and its assets, API/SSE/WebSocket
calls, redirects, the PWA manifest and the service worker — under that prefix, so a browser
loading `https://example.com/codeman/` stays inside the mount.
**Forward the prefix unchanged — do NOT strip it.** Codeman expects the proxy to pass the
full path (including `/codeman/`) straight through. A minimal nginx block:
```nginx
location /codeman/ {
proxy_pass http://127.0.0.1:3000; # note: no trailing slash — keep the /codeman/ prefix
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade; # WebSocket
proxy_set_header Connection "upgrade";
}
```
Notes and current limits:
- The prefix must still be paired with `CODEMAN_ALLOWED_HOSTS` for your domain, exactly as
above — the two are independent.
- Health checks, Claude Code hooks and the docker bridge connect to the raw port directly
(bypassing the proxy), so Codeman also keeps answering at the un-prefixed paths on the port
itself. Nothing about those flows changes.
- **Web-tab (dashboard) proxying** is base-path aware: proxied dashboards have their injected
`<base>` tag, root-absolute asset rewrites, runtime `fetch`/XHR shim, `Set-Cookie` paths, and
redirects all rebased onto the mount, so they load the same under `--base-url` as at the root.
## Session cookies and rate limits
The first request prompts for HTTP Basic credentials. On success the server issues an opaque
@@ -198,6 +239,7 @@ for the full guide.
| Symptom | Cause and fix |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `403 host not allowed` | Your domain is not in the allowlist. Set `CODEMAN_ALLOWED_HOSTS`. |
| Assets 404 / blank page under a sub-path | Start Codeman with `--base-url /<prefix>` and have the proxy forward the prefix unchanged (don't strip it). |
| Phone shows the login page but the terminal never connects | The proxy is not forwarding WebSocket upgrades. |
| Browser warns about the certificate | Expected with `--https` and its self-signed certificate. Tailscale gives you a real one instead. |
| LAN IP does not respond, but a tunnel to the same box works | The server is bound to loopback. That is the default. A tunnel reaches it; a LAN browser cannot. |
+12 -2
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.24.2",
"version": "1.26.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.24.2",
"version": "1.26.1",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -32,6 +32,7 @@
"jpeg-js": "^0.4.4",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"undici": "^6.28.0",
"uuid": "^14.0.0",
"web-push": "^3.6.7",
"ws": "^8.21.0",
@@ -11550,6 +11551,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici": {
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+9 -7
View File
@@ -1,6 +1,6 @@
{
"name": "aicodeman",
"version": "1.24.2",
"version": "1.26.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",
@@ -28,18 +28,19 @@
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
"fix:node-pty": "node scripts/fix-node-pty.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
"format": "prettier --write 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
"format:check": "prettier --check 'src/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.pr-bot.json",
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts' 'scripts/pr-bot/**/*.ts'",
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' 'scripts/pr-bot/**/*.ts' --fix",
"format": "prettier --write 'src/**/*.ts' 'scripts/pr-bot/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
"format:check": "prettier --check 'src/**/*.ts' 'scripts/pr-bot/**/*.ts' 'src/web/public/**/*.{js,css,html,json}'",
"check:public-assets": "node scripts/check-public-assets.mjs",
"capture:subagents": "node scripts/capture-subagent-screenshots.mjs",
"changeset": "changeset",
"version-packages": "changeset version && npm install --package-lock-only && node scripts/check-lockfile-sync.mjs",
"check:lockfile": "node scripts/check-lockfile-sync.mjs",
"knip": "npx --yes knip@latest --config config/knip.json",
"release": "changeset publish"
"release": "changeset publish",
"pr-bot": "tsx scripts/pr-bot/main.ts"
},
"prettier": {
"singleQuote": true,
@@ -102,6 +103,7 @@
"jpeg-js": "^0.4.4",
"node-pty": "^1.1.0",
"qrcode": "^1.5.4",
"undici": "^6.28.0",
"uuid": "^14.0.0",
"web-push": "^3.6.7",
"ws": "^8.21.0",
File diff suppressed because it is too large Load Diff
+268
View File
@@ -0,0 +1,268 @@
/**
* @fileoverview Codeman HTTP client for the PR bot: spawn a claude session in a
* directory, wait until its composer is up, run one prompt to the END of its turn,
* read the answer, delete the session.
*
* This is the `skills/codeman` §0 preamble translated to TypeScript, and it keeps
* the traps that preamble documents:
* - readiness is the rendered composer (`shift+tab` in the pane), never `idle`;
* - the folder-trust dialog is READ off the screen and answered one keystroke at a
* time (Claude Code 2.1.252 highlights "No, exit" by default, so a blind Enter kills
* the session);
* - send-and-wait waits on `stop,blocked,exit`, never on the flapping `idle`, with a
* short first wait, one Enter nudge for a stranded prompt, and tagged-duplicate
* resends that re-wait without retyping (the server treats an already-applied
* (clientId, seq) frame as "wait only");
* - the bot deletes only sessions it created, by exact id.
*
* The production server is HTTPS with a self-signed certificate on loopback, so the
* undici Agent skips certificate verification for that one connection.
*/
import { Agent, fetch as undiciFetch } from 'undici';
export interface CodemanClientOptions {
apiUrl: string;
username?: string;
password?: string;
}
export interface CreateSessionOptions {
workingDir: string;
name: string;
modelOverride?: string;
effort?: string;
resumeSessionId?: string;
}
export interface WaitResult {
ended: boolean;
timedOut: boolean;
signal?: string;
}
export interface SessionRecord {
id: string;
name: string;
status: string;
pid: number | null;
claudeSessionId?: string | null;
workingDir: string;
mode: string;
}
export type TurnOutcome = { kind: 'stop' } | { kind: 'blocked' } | { kind: 'exit' } | { kind: 'timeout' };
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export function stripAnsi(text: string): string {
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\x1b[()][AB0]/g, '');
}
/** Which key answers the trust dialog right now, read from the rendered pane. */
export function trustDialogKey(screen: string): 'confirm' | 'move' | null {
const compact = stripAnsi(screen).replace(/\s+/g, '');
const matches = compact.match(/❯[0-9.]*(yes,itrustthisfolder|no,exit)/gi);
if (!matches || matches.length === 0) return null;
const last = matches[matches.length - 1].toLowerCase();
return last.includes('yes,') ? 'confirm' : 'move';
}
export class CodemanClient {
// headersTimeout/bodyTimeout default to 300 s in undici, which is shorter than one
// long-poll slice on the wait endpoints (up to 580 s): the first review died at
// exactly five minutes with a bare "fetch failed". The per-request AbortSignal is
// the only ceiling here.
private readonly agent = new Agent({ connect: { rejectUnauthorized: false }, headersTimeout: 0, bodyTimeout: 0 });
private readonly authHeader?: string;
constructor(private readonly opts: CodemanClientOptions) {
if (opts.password) {
this.authHeader = 'Basic ' + Buffer.from(`${opts.username || 'admin'}:${opts.password}`).toString('base64');
}
}
private async request<T>(
method: string,
path: string,
body?: unknown,
query?: Record<string, string | number | undefined>,
timeoutMs = 60_000
): Promise<T> {
const url = new URL(this.opts.apiUrl + path);
for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined) url.searchParams.set(k, String(v));
const headers: Record<string, string> = { Accept: 'application/json' };
if (this.authHeader) headers.Authorization = this.authHeader;
if (body !== undefined) headers['Content-Type'] = 'application/json';
let res;
try {
res = await undiciFetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
dispatcher: this.agent,
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const cause = (err as { cause?: { message?: string; code?: string } }).cause;
const detail = cause ? ` (${cause.code ?? ''} ${cause.message ?? ''})`.replace(/\(\s+/, '(').trim() : '';
throw new Error(`${method} ${path}: ${(err as Error).message}${detail}`);
}
const text = await res.text();
let json: { success?: boolean; data?: T; error?: string; errorCode?: string } & Record<string, unknown> = {};
try {
json = text ? JSON.parse(text) : {};
} catch {
throw new Error(`${method} ${path}: non-JSON ${res.status} response: ${text.slice(0, 200)}`);
}
if (!res.ok || json.success === false) {
throw new Error(
`${method} ${path}: ${res.status} ${json.errorCode ?? ''} ${json.error ?? text.slice(0, 200)}`.trim()
);
}
// Most routes use the {success, data} envelope; a few legacy GETs return the raw shape.
return (json.success === true && json.data !== undefined ? json.data : json) as T;
}
async status(): Promise<{ version?: string }> {
return this.request<{ version?: string }>('GET', '/api/status');
}
async listSessions(): Promise<SessionRecord[]> {
const data = await this.request<SessionRecord[] | { sessions: SessionRecord[] }>('GET', '/api/sessions');
return Array.isArray(data) ? data : (data.sessions ?? []);
}
async getSession(id: string): Promise<SessionRecord> {
return this.request<SessionRecord>('GET', `/api/sessions/${id}`);
}
/** Create + start. Creation alone leaves pid null and no pane, so the two are one step here. */
async createInteractiveSession(opts: CreateSessionOptions): Promise<string> {
const created = await this.request<{ session: { id: string } }>('POST', '/api/sessions', {
workingDir: opts.workingDir,
mode: 'claude',
name: opts.name,
modelOverride: opts.modelOverride,
effort: opts.effort,
resumeSessionId: opts.resumeSessionId,
});
const id = created.session?.id;
if (!id) throw new Error('POST /api/sessions returned no session id');
await this.request('POST', `/api/sessions/${id}/interactive`, {});
return id;
}
async deleteSession(id: string): Promise<void> {
if (!id || id.length < 8) throw new Error(`refusing to delete session "${id}"`);
await this.request('DELETE', `/api/sessions/${id}`);
}
async waitOutput(id: string, match: string, from: 'now' | 'buffer', timeoutMs: number): Promise<boolean> {
const data = await this.request<{ wait?: { matched?: boolean } }>(
'GET',
`/api/sessions/${id}/wait-output`,
undefined,
{ match, from, timeout: timeoutMs },
timeoutMs + 15_000
);
return Boolean(data.wait?.matched);
}
async waitSignal(id: string, until: string, timeoutMs: number): Promise<WaitResult> {
const data = await this.request<{ wait?: WaitResult }>(
'GET',
`/api/sessions/${id}/wait`,
undefined,
{ until, timeout: timeoutMs },
timeoutMs + 15_000
);
return data.wait ?? { ended: false, timedOut: true };
}
async terminalText(id: string): Promise<string> {
const data = await this.request<{ terminalBuffer?: string }>('GET', `/api/sessions/${id}/terminal`, undefined, {
full: '1',
});
return data.terminalBuffer ?? '';
}
async sendKeys(id: string, input: string, clientId: string, seq: number): Promise<void> {
await this.request('POST', `/api/sessions/${id}/input`, { input, useMux: true, clientId, seq });
}
async lastResponse(id: string): Promise<string> {
const data = await this.request<{ text?: string }>('GET', `/api/sessions/${id}/last-response`);
return data.text ?? '';
}
/** Composer wait, trust-dialog fallback, composer wait again. Throws when the pane never gets there. */
async ensureReady(id: string, log: (m: string) => void): Promise<void> {
if (await this.waitOutput(id, 'shift+tab', 'buffer', 5000)) return;
for (let i = 1; i <= 6; i++) {
const key = trustDialogKey(await this.terminalText(id));
if (!key) break;
log(`trust dialog on screen: ${key === 'confirm' ? 'Enter' : 'arrow down'}`);
await this.sendKeys(id, key === 'confirm' ? '\r' : '\x1b[B', `prbot-trust-${id}`, i);
if (key === 'confirm') break;
await sleep(1000);
}
if (await this.waitOutput(id, 'shift+tab', 'buffer', 45_000)) return;
throw new Error('the session never drew its composer (no `shift+tab` in the pane after 50s)');
}
/**
* Send ONE prompt and block until the turn ends, the session blocks on a question,
* the pane exits, or `deadlineMs` passes. `isDone` lets the caller finish early on
* an out-of-band signal (the report file appearing), which also covers a stop edge
* that fired between two waits.
*/
async runTurn(
id: string,
prompt: string,
opts: { deadlineMs: number; isDone?: () => boolean; log: (m: string) => void }
): Promise<TurnOutcome> {
if (prompt.includes('\n'))
throw new Error('runTurn prompts must be single-line (embedded newlines are stripped by tmux)');
const clientId = `prbot-${id}`;
const seq = Math.floor(Date.now() / 1000);
const frame = { input: prompt + '\r', useMux: true, clientId, seq, wait: 'stop,blocked,exit', waitTimeout: 20_000 };
const started = Date.now();
const post = (body: unknown, timeout: number) =>
this.request<{ delivered?: boolean; wait?: WaitResult }>(
'POST',
`/api/sessions/${id}/input`,
body,
undefined,
timeout + 15_000
);
let r = await post(frame, 20_000);
if (!r.delivered) throw new Error('the prompt was not delivered (pane dead?)');
let wait = r.wait;
let nudged = false;
while (true) {
if (wait && !wait.timedOut) return toOutcome(wait);
if (opts.isDone?.()) return { kind: 'stop' };
const remaining = opts.deadlineMs - (Date.now() - started);
if (remaining <= 0) return { kind: 'timeout' };
if (!nudged) {
// An Ink repaint occasionally eats the Enter: a bare \r is the missing key when
// the prompt is stranded and a no-op when the turn is genuinely running.
nudged = true;
await this.sendKeys(id, '\r', clientId, seq + 1);
}
const slice = Math.min(remaining, 580_000);
opts.log(`still working (${Math.round((Date.now() - started) / 60_000)} min)`);
r = await post({ ...frame, waitTimeout: slice }, slice);
wait = r.wait;
}
}
}
function toOutcome(wait: WaitResult): TurnOutcome {
const signal = wait.signal ?? '';
if (signal === 'blocked') return { kind: 'blocked' };
if (signal === 'exit') return { kind: 'exit' };
return { kind: 'stop' };
}
+194
View File
@@ -0,0 +1,194 @@
/**
* @fileoverview PR bot configuration.
*
* Read from `~/.codeman/pr-bot.env` (KEY=VALUE lines, mode 0600, the same shape as
* the data dir's `.env`) with the process environment layered on top, then validated
* into a typed config. `parseEnvFile` and `buildConfig` are pure so the validation
* rules are unit-testable without touching the filesystem.
*
* Nothing here reads Codeman's own settings: the bot is maintainer tooling that
* drives a running Codeman over HTTP, it is not part of the server.
*/
import { existsSync, readFileSync } from 'fs';
import { homedir } from 'os';
import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url';
export interface PrBotConfig {
/** Telegram bot token from BotFather. */
telegramBotToken: string;
/** The ONE chat the bot talks to and accepts commands from. Everything else is ignored. */
telegramChatId: string;
/** `owner/name` of the repository whose PRs are reviewed. */
githubRepo: string;
/** Codeman server the review sessions are spawned on. */
codemanApiUrl: string;
codemanUsername?: string;
codemanPassword?: string;
/** How often open PRs are listed. */
pollIntervalMs: number;
/** The maintainer's checkout; worktrees are added from its git dir. Never checked out by the bot. */
mainCheckout: string;
/** State, reports and worktrees live under here. */
dataDir: string;
worktreesDir: string;
/** Optional model / effort for the review sessions (Codeman `modelOverride` / `effort`). */
model?: string;
effort?: string;
/** Hard ceiling for one review turn. */
reviewTimeoutMs: number;
/** Hard ceiling for one follow-up turn. */
followupTimeoutMs: number;
/** When false, PRs are only reviewed on an explicit `/review N`. */
autoReview: boolean;
/** Draft PRs are skipped unless this is on. */
reviewDrafts: boolean;
}
export const CONFIG_FILE_NAME = 'pr-bot.env';
/**
* The maintainer's existing Telegram notifier bot (a separate, send-only process)
* keeps its token and chat id here. The PR bot shares that bot identity by default,
* so it reads those two keys from the same file rather than making anyone copy a
* secret around. Override with `PR_BOT_TELEGRAM_ENV_FILE`.
*/
export const DEFAULT_TELEGRAM_ENV_FILE = join('codeman-cases', 'telegram', '.env');
const SHARED_TELEGRAM_KEYS = ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_CHAT_ID'] as const;
/** The keys the env file understands, for `check` and the docs. */
export const CONFIG_KEYS = [
'TELEGRAM_BOT_TOKEN',
'TELEGRAM_CHAT_ID',
'GITHUB_REPO',
'CODEMAN_API_URL',
'CODEMAN_USERNAME',
'CODEMAN_PASSWORD',
'PR_BOT_POLL_INTERVAL',
'PR_BOT_MAIN_CHECKOUT',
'PR_BOT_DATA_DIR',
'PR_BOT_MODEL',
'PR_BOT_EFFORT',
'PR_BOT_REVIEW_TIMEOUT',
'PR_BOT_FOLLOWUP_TIMEOUT',
'PR_BOT_AUTO_REVIEW',
'PR_BOT_REVIEW_DRAFTS',
'PR_BOT_TELEGRAM_ENV_FILE',
] as const;
/** Parse `KEY=VALUE` lines. Comments, blanks, `export ` prefixes and matching quotes are handled. */
export function parseEnvFile(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq <= 0) continue;
const key = line
.slice(0, eq)
.trim()
.replace(/^export\s+/, '');
let value = line.slice(eq + 1).trim();
if (value.length >= 2) {
const first = value[0];
const last = value[value.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) value = value.slice(1, -1);
}
if (/^[A-Z_][A-Z0-9_]*$/.test(key)) out[key] = value;
}
return out;
}
function intFrom(raw: string | undefined, fallback: number, min: number): number {
const n = parseInt(raw ?? '', 10);
if (!Number.isFinite(n) || n <= 0) return fallback;
return Math.max(min, n);
}
function flagFrom(raw: string | undefined, fallback: boolean): boolean {
if (raw === undefined || raw === '') return fallback;
return !['0', 'false', 'no', 'off'].includes(raw.trim().toLowerCase());
}
/** Build the typed config from an env map. Throws with every missing key named at once. */
export function buildConfig(
env: Record<string, string | undefined>,
defaults: { home: string; repoRoot: string }
): PrBotConfig {
const missing: string[] = [];
const telegramBotToken = env.TELEGRAM_BOT_TOKEN?.trim() ?? '';
const telegramChatId = env.TELEGRAM_CHAT_ID?.trim() ?? '';
if (!telegramBotToken) missing.push('TELEGRAM_BOT_TOKEN');
if (!telegramChatId) missing.push('TELEGRAM_CHAT_ID');
if (missing.length) throw new Error(`pr-bot config is missing: ${missing.join(', ')}`);
const githubRepo = env.GITHUB_REPO?.trim() || 'Ark0N/Codeman';
if (!/^[\w.-]+\/[\w.-]+$/.test(githubRepo)) throw new Error(`GITHUB_REPO must be owner/name, got "${githubRepo}"`);
const codemanApiUrl = (env.CODEMAN_API_URL?.trim() || 'https://127.0.0.1:3000').replace(/\/+$/, '');
if (!/^https?:\/\//.test(codemanApiUrl))
throw new Error(`CODEMAN_API_URL must be http(s)://..., got "${codemanApiUrl}"`);
const dataDir = resolve(env.PR_BOT_DATA_DIR?.trim() || join(defaults.home, '.codeman', 'pr-bot'));
const mainCheckout = resolve(env.PR_BOT_MAIN_CHECKOUT?.trim() || defaults.repoRoot);
return {
telegramBotToken,
telegramChatId,
githubRepo,
codemanApiUrl,
codemanUsername: env.CODEMAN_USERNAME?.trim() || undefined,
codemanPassword: env.CODEMAN_PASSWORD || undefined,
pollIntervalMs: intFrom(env.PR_BOT_POLL_INTERVAL, 600, 60) * 1000,
mainCheckout,
dataDir,
worktreesDir: join(dataDir, 'worktrees'),
model: env.PR_BOT_MODEL?.trim() || undefined,
effort: env.PR_BOT_EFFORT?.trim() || undefined,
reviewTimeoutMs: intFrom(env.PR_BOT_REVIEW_TIMEOUT, 40, 5) * 60_000,
followupTimeoutMs: intFrom(env.PR_BOT_FOLLOWUP_TIMEOUT, 20, 2) * 60_000,
autoReview: flagFrom(env.PR_BOT_AUTO_REVIEW, true),
reviewDrafts: flagFrom(env.PR_BOT_REVIEW_DRAFTS, false),
};
}
/** The repository this script lives in (scripts/pr-bot/ -> repo root). */
export function scriptRepoRoot(): string {
return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
}
export function configFilePath(): string {
return join(process.env.CODEMAN_DATA_DIR || join(homedir(), '.codeman'), CONFIG_FILE_NAME);
}
export function telegramEnvFilePath(fromFile: Record<string, string>): string {
return resolve(
process.env.PR_BOT_TELEGRAM_ENV_FILE ||
fromFile.PR_BOT_TELEGRAM_ENV_FILE ||
join(homedir(), DEFAULT_TELEGRAM_ENV_FILE)
);
}
/**
* Layers, lowest first: the shared Telegram notifier's `.env` (token + chat id only),
* then `~/.codeman/pr-bot.env`, then the process environment, so a one-off
* `PR_BOT_MODEL=... npx tsx ...` wins over everything.
*/
export function loadConfig(): PrBotConfig {
const file = configFilePath();
const fromFile = existsSync(file) ? parseEnvFile(readFileSync(file, 'utf8')) : {};
const sharedFile = telegramEnvFilePath(fromFile);
const shared = existsSync(sharedFile) ? parseEnvFile(readFileSync(sharedFile, 'utf8')) : {};
const merged: Record<string, string | undefined> = {};
for (const key of SHARED_TELEGRAM_KEYS) if (shared[key]) merged[key] = shared[key];
Object.assign(merged, fromFile);
for (const key of CONFIG_KEYS) {
const v = process.env[key];
if (v !== undefined && v !== '') merged[key] = v;
}
try {
return buildConfig(merged, { home: homedir(), repoRoot: scriptRepoRoot() });
} catch (err) {
throw new Error(`${(err as Error).message} (config file: ${file}; shared Telegram env: ${sharedFile})`);
}
}
+230
View File
@@ -0,0 +1,230 @@
/**
* @fileoverview GitHub access for the PR bot, entirely through the `gh` CLI.
*
* `gh` carries the maintainer's own login, so the bot needs no token of its own and
* every write (merge, close, comment, CI approval) lands under that account. That is
* why every write here is only ever reached from an explicit, confirmed Telegram
* command (see bot.ts); nothing in this file is called on a timer.
*
* `classifyCi` and `latestRunPerWorkflow` are pure and unit-tested.
*/
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
export interface PrSummary {
number: number;
title: string;
author: string;
headSha: string;
baseRef: string;
headRef: string;
isDraft: boolean;
mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN';
mergeState: string;
additions: number;
deletions: number;
changedFiles: number;
updatedAt: string;
url: string;
isCrossRepository: boolean;
labels: string[];
}
export interface PrFile {
path: string;
additions: number;
deletions: number;
}
export interface PrDetail extends PrSummary {
body: string;
files: PrFile[];
authorAssociation: string;
linkedIssues: { number: number; title: string }[];
commitCount: number;
commentCount: number;
reviewDecision: string;
headRepo: string;
}
export interface WorkflowRun {
id: number;
name: string;
status: string;
conclusion: string | null;
}
export type CiState = 'passed' | 'failed' | 'pending' | 'awaiting-approval' | 'none';
export interface CiStatus {
state: CiState;
runs: WorkflowRun[];
}
const PR_LIST_FIELDS =
'number,title,author,headRefOid,baseRefName,headRefName,isDraft,mergeable,mergeStateStatus,additions,deletions,changedFiles,updatedAt,url,isCrossRepository,labels';
export async function gh(args: string[], opts: { timeoutMs?: number; input?: string } = {}): Promise<string> {
const child = execFileAsync('gh', args, {
maxBuffer: 32 * 1024 * 1024,
timeout: opts.timeoutMs ?? 60_000,
env: { ...process.env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1' },
});
if (opts.input !== undefined && child.child.stdin) {
child.child.stdin.end(opts.input);
}
const { stdout } = await child;
return stdout;
}
interface RawPr {
number: number;
title: string;
author?: { login?: string };
headRefOid: string;
baseRefName: string;
headRefName: string;
isDraft: boolean;
mergeable: string;
mergeStateStatus: string;
additions: number;
deletions: number;
changedFiles: number;
updatedAt: string;
url: string;
isCrossRepository: boolean;
labels?: { name: string }[];
}
function toSummary(raw: RawPr): PrSummary {
const mergeable = raw.mergeable === 'MERGEABLE' || raw.mergeable === 'CONFLICTING' ? raw.mergeable : 'UNKNOWN';
return {
number: raw.number,
title: raw.title ?? '',
author: raw.author?.login ?? 'unknown',
headSha: raw.headRefOid,
baseRef: raw.baseRefName,
headRef: raw.headRefName,
isDraft: Boolean(raw.isDraft),
mergeable,
mergeState: raw.mergeStateStatus ?? 'UNKNOWN',
additions: raw.additions ?? 0,
deletions: raw.deletions ?? 0,
changedFiles: raw.changedFiles ?? 0,
updatedAt: raw.updatedAt ?? '',
url: raw.url,
isCrossRepository: Boolean(raw.isCrossRepository),
labels: (raw.labels ?? []).map((l) => l.name),
};
}
export async function listOpenPrs(repo: string): Promise<PrSummary[]> {
const out = await gh(['pr', 'list', '--repo', repo, '--state', 'open', '--limit', '100', '--json', PR_LIST_FIELDS]);
const raw = JSON.parse(out) as RawPr[];
return raw.map(toSummary);
}
export async function getPrDetail(repo: string, number: number): Promise<PrDetail> {
const fields = `${PR_LIST_FIELDS},body,files,commits,comments,reviewDecision,closingIssuesReferences,headRepository,headRepositoryOwner`;
const out = await gh(['pr', 'view', String(number), '--repo', repo, '--json', fields]);
const raw = JSON.parse(out) as RawPr & {
body?: string;
files?: { path: string; additions: number; deletions: number }[];
commits?: unknown[];
comments?: unknown[];
reviewDecision?: string;
closingIssuesReferences?: { number: number; title: string }[];
headRepository?: { name?: string };
headRepositoryOwner?: { login?: string };
};
let authorAssociation = 'NONE';
try {
const assoc = await gh(['api', `repos/${repo}/pulls/${number}`, '--jq', '.author_association']);
authorAssociation = assoc.trim() || 'NONE';
} catch {
// Metadata only; a failed lookup must not fail the review.
}
const owner = raw.headRepositoryOwner?.login;
const name = raw.headRepository?.name;
return {
...toSummary(raw),
body: raw.body ?? '',
files: (raw.files ?? []).map((f) => ({ path: f.path, additions: f.additions ?? 0, deletions: f.deletions ?? 0 })),
authorAssociation,
linkedIssues: (raw.closingIssuesReferences ?? []).map((i) => ({ number: i.number, title: i.title })),
commitCount: raw.commits?.length ?? 0,
commentCount: raw.comments?.length ?? 0,
reviewDecision: raw.reviewDecision ?? '',
headRepo: owner && name ? `${owner}/${name}` : '',
};
}
/** The API returns newest first; keep only the newest run of each workflow. */
export function latestRunPerWorkflow(runs: WorkflowRun[]): WorkflowRun[] {
const seen = new Set<string>();
const out: WorkflowRun[] = [];
for (const run of runs) {
if (seen.has(run.name)) continue;
seen.add(run.name);
out.push(run);
}
return out;
}
/**
* Collapse workflow runs into one word the report can show. `action_required` is
* the fork-PR case where GitHub waits for a maintainer to approve the run: the PR
* looks unchecked and stays that way until someone clicks, so it gets its own state.
*/
export function classifyCi(runs: WorkflowRun[]): CiState {
const latest = latestRunPerWorkflow(runs);
if (latest.length === 0) return 'none';
if (latest.some((r) => r.conclusion === 'action_required')) return 'awaiting-approval';
if (latest.some((r) => ['queued', 'in_progress', 'waiting', 'pending', 'requested'].includes(r.status)))
return 'pending';
if (latest.some((r) => ['failure', 'timed_out', 'cancelled', 'startup_failure'].includes(r.conclusion ?? '')))
return 'failed';
if (latest.every((r) => ['success', 'skipped', 'neutral'].includes(r.conclusion ?? ''))) return 'passed';
return 'pending';
}
export async function getCiStatus(repo: string, headSha: string): Promise<CiStatus> {
const out = await gh([
'api',
`repos/${repo}/actions/runs?head_sha=${headSha}&event=pull_request&per_page=30`,
'--jq',
'[.workflow_runs[] | {id, name, status, conclusion}]',
]);
const runs = JSON.parse(out) as WorkflowRun[];
return { state: classifyCi(runs), runs: latestRunPerWorkflow(runs) };
}
export async function approveWorkflowRun(repo: string, runId: number): Promise<void> {
await gh(['api', '-X', 'POST', `repos/${repo}/actions/runs/${runId}/approve`]);
}
/** Merge commits, matching the repository's history (`Merge pull request #N from ...`). */
export async function mergePr(repo: string, number: number): Promise<string> {
return gh(['pr', 'merge', String(number), '--repo', repo, '--merge'], { timeoutMs: 120_000 });
}
export async function closePr(repo: string, number: number, comment: string): Promise<string> {
const args = ['pr', 'close', String(number), '--repo', repo];
if (comment.trim()) args.push('--comment', comment);
return gh(args);
}
export async function commentPr(repo: string, number: number, body: string): Promise<string> {
return gh(['pr', 'comment', String(number), '--repo', repo, '--body-file', '-'], { input: body });
}
export async function ghAuthOk(): Promise<boolean> {
try {
await gh(['auth', 'status']);
return true;
} catch {
return false;
}
}
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env -S npx tsx
/**
* @fileoverview CLI entry for the PR bot.
*
* npx tsx scripts/pr-bot/main.ts run # the daemon (what the service runs)
* npx tsx scripts/pr-bot/main.ts check # config, gh, Codeman, Telegram, git
* npx tsx scripts/pr-bot/main.ts scan # list open PRs and what would be queued
* npx tsx scripts/pr-bot/main.ts review N [--no-telegram] # one review, now
* npx tsx scripts/pr-bot/main.ts status # what the state file knows
* npx tsx scripts/pr-bot/main.ts notify N # resend PR N's review message to Telegram
* npx tsx scripts/pr-bot/main.ts install-service # systemd user unit, enabled + started
* npx tsx scripts/pr-bot/main.ts uninstall-service
*
* User guide: docs/pr-bot.md
*/
import { execFileSync } from 'child_process';
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { PrBot, type TelegramLike } from './bot.js';
import { CodemanClient } from './codeman-client.js';
import { configFilePath, loadConfig, type PrBotConfig } from './config.js';
import { ghAuthOk, listOpenPrs } from './github.js';
import { orderBacklog } from './report.js';
import { StateStore } from './state.js';
import { TelegramClient } from './telegram.js';
const SERVICE_NAME = 'codeman-pr-bot';
function log(msg: string): void {
console.log(`${new Date().toISOString()} ${msg}`);
}
/** Prints what the bot would have sent; used by `review --no-telegram`. */
class ConsoleTelegram implements TelegramLike {
private nextId = 1;
isOurChat(): boolean {
return true;
}
async sendMessage(text: string): Promise<number> {
console.log(`\n--- telegram (html) ---\n${text}\n---`);
return this.nextId++;
}
async sendPlain(text: string): Promise<number> {
console.log(`\n--- telegram (plain) ---\n${text}\n---`);
return this.nextId++;
}
async editReplyMarkup(): Promise<void> {}
async deleteMessage(): Promise<void> {}
async answerCallback(): Promise<void> {}
async sendDocument(filename: string, content: string): Promise<void> {
console.log(`\n--- telegram document ${filename} (${content.length} chars) ---`);
}
async getUpdates(): Promise<[]> {
return [];
}
async setMyCommands(): Promise<void> {}
}
function makeCodeman(cfg: PrBotConfig): CodemanClient {
return new CodemanClient({ apiUrl: cfg.codemanApiUrl, username: cfg.codemanUsername, password: cfg.codemanPassword });
}
export function logFilePath(cfg: PrBotConfig): string {
return join(cfg.dataDir, 'bot.log');
}
function unitFile(cfg: PrBotConfig): string {
const tsx = join(cfg.mainCheckout, 'node_modules', '.bin', 'tsx');
// A user service gets a minimal PATH, which is where `gh` (and an nvm/Homebrew
// node) are not: the first run failed its scan with `spawn gh ENOENT`. Bake the
// installing shell's PATH in, as `codeman service install` does.
const seen = new Set<string>();
const path = (process.env.PATH || '/usr/local/bin:/usr/bin:/bin')
.split(':')
.filter((p) => p && !p.endsWith('/node_modules/.bin') && !seen.has(p) && seen.add(p))
.join(':');
return `[Unit]
Description=Codeman PR review bot (Telegram)
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
WorkingDirectory=${cfg.mainCheckout}
ExecStart=${tsx} scripts/pr-bot/main.ts run
Restart=always
RestartSec=15
Environment=HOME=${homedir()}
Environment=NODE_ENV=production
Environment=PATH=${path}
# A file rather than the journal: on some boxes \`journalctl --user\` cannot read
# the user journal at all, and a review bot whose logs cannot be found is not
# debuggable from a phone.
StandardOutput=append:${logFilePath(cfg)}
StandardError=append:${logFilePath(cfg)}
SyslogIdentifier=${SERVICE_NAME}
[Install]
WantedBy=default.target
`;
}
async function cmdCheck(): Promise<void> {
const cfg = loadConfig();
console.log(
`config file: ${configFilePath()}${existsSync(configFilePath()) ? '' : ' (absent, defaults + shared Telegram env)'}`
);
console.log(`repo: ${cfg.githubRepo}`);
console.log(`codeman: ${cfg.codemanApiUrl}`);
console.log(`main checkout: ${cfg.mainCheckout}`);
console.log(`data dir: ${cfg.dataDir}`);
console.log(`model: ${cfg.model ?? '(session default)'}, effort: ${cfg.effort ?? '(default)'}`);
console.log(
`poll: every ${cfg.pollIntervalMs / 60_000} min; review timeout ${cfg.reviewTimeoutMs / 60_000} min; auto-review ${cfg.autoReview}`
);
let ok = true;
const step = async (name: string, fn: () => Promise<string>) => {
try {
console.log(`✔ ${name}: ${await fn()}`);
} catch (err) {
ok = false;
console.log(`✘ ${name}: ${(err as Error).message}`);
}
};
await step('gh auth', async () =>
(await ghAuthOk()) ? 'logged in' : Promise.reject(new Error('run `gh auth login`'))
);
await step('git', async () =>
execFileSync('git', ['-C', cfg.mainCheckout, 'rev-parse', '--git-dir'], { encoding: 'utf8' }).trim()
);
await step('codeman', async () => {
const s = await makeCodeman(cfg).status();
return `up (version ${s.version ?? 'unknown'})`;
});
await step('telegram', async () => {
const me = await new TelegramClient(cfg.telegramBotToken, cfg.telegramChatId).getMe();
return `@${me.username ?? '?'} for chat ${cfg.telegramChatId}`;
});
await step('open PRs', async () => `${(await listOpenPrs(cfg.githubRepo)).length}`);
if (!ok) process.exit(1);
}
async function cmdScan(): Promise<void> {
const cfg = loadConfig();
const store = new StateStore(join(cfg.dataDir, 'state.json'));
const open = await listOpenPrs(cfg.githubRepo);
const rows = orderBacklog(open).map((pr) => {
const rec = store.pr(pr.number);
const state =
rec?.reviewedSha === pr.headSha ? `reviewed (${rec?.verdict ?? '?'})` : rec?.reviewedSha ? 'updated' : 'new';
const flags = [pr.isDraft ? 'draft' : '', pr.mergeable === 'CONFLICTING' ? 'conflicts' : '']
.filter(Boolean)
.join(', ');
return `#${pr.number}\t${state}\t+${pr.additions}/-${pr.deletions}\t${pr.author}\t${pr.title}${flags ? ` [${flags}]` : ''}`;
});
console.log(`${open.length} open PRs in review order:\n${rows.join('\n')}`);
}
async function cmdStatus(): Promise<void> {
const cfg = loadConfig();
const store = new StateStore(join(cfg.dataDir, 'state.json'));
console.log(`paused: ${store.state.paused}; telegram offset: ${store.state.telegramOffset}`);
for (const rec of Object.values(store.state.prs).sort((a, b) => b.number - a.number)) {
console.log(
`#${rec.number}\t${rec.status}\t${rec.verdict ?? '-'}\t${rec.reviewedSha?.slice(0, 8) ?? '-'}\t${rec.author}\t${rec.title}${
rec.lastError ? `\n\t${rec.lastError.split('\n')[0]}` : ''
}`
);
}
}
async function cmdReview(args: string[]): Promise<void> {
const number = parseInt(args.find((a) => /^\d+$/.test(a)) ?? '', 10);
if (!Number.isFinite(number)) throw new Error('usage: review <pr-number> [--no-telegram]');
const cfg = loadConfig();
const telegram = args.includes('--no-telegram')
? new ConsoleTelegram()
: new TelegramClient(cfg.telegramBotToken, cfg.telegramChatId);
const bot = new PrBot(cfg, { telegram, codeman: makeCodeman(cfg), log });
const rec = await bot.reviewPr(number);
console.log(
`\n#${number}: ${rec.status}${rec.verdict ? ` (${rec.verdict})` : ''}${rec.lastError ? `\n${rec.lastError}` : ''}`
);
if (rec.reportMdPath) console.log(`report: ${rec.reportMdPath}`);
process.exit(rec.status === 'reviewed' ? 0 : 1);
}
async function cmdNotify(args: string[]): Promise<void> {
const number = parseInt(args[0] ?? '', 10);
if (!Number.isFinite(number)) throw new Error('usage: notify <pr-number>');
const cfg = loadConfig();
const bot = new PrBot(cfg, {
telegram: new TelegramClient(cfg.telegramBotToken, cfg.telegramChatId),
codeman: makeCodeman(cfg),
log,
});
const rec = bot.store.pr(number);
if (!rec?.report) throw new Error(`no review of #${number} in ${cfg.dataDir}`);
await bot.sendSummary(rec);
console.log(`sent the review message for #${number}`);
}
async function cmdRun(): Promise<void> {
const cfg = loadConfig();
const bot = new PrBot(cfg, {
telegram: new TelegramClient(cfg.telegramBotToken, cfg.telegramChatId),
codeman: makeCodeman(cfg),
log,
});
let stopping = false;
const shutdown = (signal: string) => {
if (stopping) return;
stopping = true;
log(`${signal}: stopping`);
bot
.stop()
.catch((err) => log(`stop: ${(err as Error).message}`))
.finally(() => process.exit(0));
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
log(`starting: repo ${cfg.githubRepo}, codeman ${cfg.codemanApiUrl}, data ${cfg.dataDir}`);
await bot.start();
}
function cmdInstallService(): void {
const cfg = loadConfig();
const dir = join(homedir(), '.config', 'systemd', 'user');
mkdirSync(dir, { recursive: true });
const path = join(dir, `${SERVICE_NAME}.service`);
mkdirSync(cfg.dataDir, { recursive: true });
writeFileSync(path, unitFile(cfg));
execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
execFileSync('systemctl', ['--user', 'enable', SERVICE_NAME], { stdio: 'inherit' });
// `restart` rather than `enable --now`: a re-install must pick up the new unit.
execFileSync('systemctl', ['--user', 'restart', SERVICE_NAME], { stdio: 'inherit' });
console.log(`installed ${path}\nlogs: tail -f ${logFilePath(cfg)}`);
}
function cmdUninstallService(): void {
const path = join(homedir(), '.config', 'systemd', 'user', `${SERVICE_NAME}.service`);
execFileSync('systemctl', ['--user', 'disable', '--now', SERVICE_NAME], { stdio: 'inherit' });
if (existsSync(path)) execFileSync('rm', ['-f', path]);
execFileSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
console.log(`removed ${SERVICE_NAME}`);
}
async function main(): Promise<void> {
const [cmd = 'run', ...rest] = process.argv.slice(2);
switch (cmd) {
case 'run':
return cmdRun();
case 'check':
return cmdCheck();
case 'scan':
return cmdScan();
case 'status':
return cmdStatus();
case 'review':
return cmdReview(rest);
case 'notify':
return cmdNotify(rest);
case 'install-service':
return cmdInstallService();
case 'uninstall-service':
return cmdUninstallService();
default:
console.error(
'usage: main.ts run | check | scan | status | review <N> [--no-telegram] | install-service | uninstall-service'
);
process.exit(2);
}
}
main().catch((err) => {
console.error((err as Error).stack ?? String(err));
process.exit(1);
});
+368
View File
@@ -0,0 +1,368 @@
/**
* @fileoverview Pure report handling: parse the reviewer's JSON (leniently, it is
* model output), render the Telegram summary (HTML, under the 4096-char cap), the
* status list, the inline keyboard, and the backlog order. Unit-tested.
*/
import type { CiState, PrSummary } from './github.js';
import { VERDICTS, type Verdict } from './review-task.js';
export type Severity = 'blocker' | 'major' | 'minor' | 'nit';
export interface Finding {
severity: Severity;
title: string;
file?: string;
line?: number;
detail: string;
invariant?: string;
}
export interface CheckResult {
name: string;
command?: string;
result: 'pass' | 'fail' | 'skipped';
notes?: string;
}
export interface ReviewReport {
verdict: Verdict;
confidence: 'high' | 'medium' | 'low';
summary: string;
changes: string[];
findings: Finding[];
checks: CheckResult[];
scope: 'focused' | 'mixed';
risk: string;
recommendation: string;
draftComment: string;
assumptions: string[];
}
export const TELEGRAM_MAX = 4096;
/** Leave room for HTML tags the counter cannot see and for the keyboard-less fallback. */
const SUMMARY_BUDGET = 3600;
const SEVERITY_ORDER: Severity[] = ['blocker', 'major', 'minor', 'nit'];
const SEVERITY_ICON: Record<Severity, string> = { blocker: '🔴', major: '🟠', minor: '🟡', nit: '⚪' };
const VERDICT_LABEL: Record<Verdict, string> = {
merge: '✅ MERGE',
'merge-with-fixes': '🟢 MERGE WITH FIXES',
'request-changes': '🟠 REQUEST CHANGES',
close: '❌ CLOSE',
'needs-discussion': '💬 NEEDS DISCUSSION',
};
const CI_LABEL: Record<CiState, string> = {
passed: 'CI ✅',
failed: 'CI ❌',
pending: 'CI ⏳',
'awaiting-approval': 'CI ⏸ needs your approval',
none: 'CI none',
};
export function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function str(v: unknown, fallback = ''): string {
return typeof v === 'string' ? v : fallback;
}
function strList(v: unknown): string[] {
if (!Array.isArray(v)) return [];
return v.filter((x): x is string => typeof x === 'string' && x.trim().length > 0);
}
/** Extract the first JSON object from text that may carry fences or prose around it. */
export function extractJsonObject(text: string): unknown {
const trimmed = text.trim();
try {
return JSON.parse(trimmed);
} catch {
// fall through
}
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
if (fence) {
try {
return JSON.parse(fence[1]);
} catch {
// fall through
}
}
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start >= 0 && end > start) {
try {
return JSON.parse(trimmed.slice(start, end + 1));
} catch {
return null;
}
}
return null;
}
/** Normalize model output into a ReviewReport. Returns null only when there is no verdict at all. */
export function parseReport(raw: unknown): ReviewReport | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Record<string, unknown>;
const verdictRaw = str(o.verdict).trim().toLowerCase().replace(/[_ ]/g, '-');
const verdict = (VERDICTS as readonly string[]).includes(verdictRaw) ? (verdictRaw as Verdict) : null;
if (!verdict) return null;
const confidenceRaw = str(o.confidence).trim().toLowerCase();
const confidence = confidenceRaw === 'high' || confidenceRaw === 'low' ? confidenceRaw : 'medium';
const findings: Finding[] = [];
if (Array.isArray(o.findings)) {
for (const f of o.findings) {
if (!f || typeof f !== 'object') continue;
const fo = f as Record<string, unknown>;
const sevRaw = str(fo.severity).trim().toLowerCase();
const severity = (SEVERITY_ORDER as string[]).includes(sevRaw) ? (sevRaw as Severity) : 'minor';
const title = str(fo.title).trim();
if (!title) continue;
const line = typeof fo.line === 'number' && Number.isFinite(fo.line) ? Math.trunc(fo.line) : undefined;
findings.push({
severity,
title,
file: str(fo.file).trim() || undefined,
line,
detail: str(fo.detail).trim(),
invariant: str(fo.invariant).trim() || undefined,
});
}
}
findings.sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity));
const checks: CheckResult[] = [];
if (Array.isArray(o.checks)) {
for (const c of o.checks) {
if (!c || typeof c !== 'object') continue;
const co = c as Record<string, unknown>;
const name = str(co.name).trim();
if (!name) continue;
const resRaw = str(co.result).trim().toLowerCase();
const result = resRaw === 'pass' || resRaw === 'fail' ? resRaw : 'skipped';
checks.push({
name,
command: str(co.command).trim() || undefined,
result,
notes: str(co.notes).trim() || undefined,
});
}
}
return {
verdict,
confidence,
summary: str(o.summary).trim(),
changes: strList(o.changes),
findings,
checks,
scope: str(o.scope).trim().toLowerCase() === 'mixed' ? 'mixed' : 'focused',
risk: str(o.risk).trim(),
recommendation: str(o.recommendation).trim(),
draftComment: str(o.draftComment).trim(),
assumptions: strList(o.assumptions),
};
}
export function countBySeverity(findings: Finding[]): Record<Severity, number> {
const out: Record<Severity, number> = { blocker: 0, major: 0, minor: 0, nit: 0 };
for (const f of findings) out[f.severity]++;
return out;
}
function findingLine(f: Finding): string {
const where = f.file ? ` <code>${escapeHtml(f.file)}${f.line ? `:${f.line}` : ''}</code>` : '';
return `${SEVERITY_ICON[f.severity]} ${escapeHtml(f.title)}${where}`;
}
function checksLine(checks: CheckResult[]): string {
if (!checks.length) return '';
const parts = checks.map((c) => {
const icon = c.result === 'pass' ? '✅' : c.result === 'fail' ? '❌' : '⏭';
return `${escapeHtml(c.name)} ${icon}`;
});
return `<b>Checks:</b> ${parts.join(' · ')}`;
}
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return text.slice(0, Math.max(0, max - 1)).trimEnd() + '…';
}
export interface SummaryMeta {
ci: CiState;
/** Time the review took, for the footer. */
durationMin?: number;
}
/** The message the maintainer reads on the phone. HTML parse mode. */
export function formatTelegramSummary(pr: PrSummary, report: ReviewReport, meta: SummaryMeta): string {
const header =
`🔍 <b>PR #${pr.number}</b> · ${escapeHtml(truncate(pr.title, 120))}\n` +
`<i>by ${escapeHtml(pr.author)} · +${pr.additions}/−${pr.deletions} · ${pr.changedFiles} files · ${CI_LABEL[meta.ci]} · ${
pr.mergeable === 'CONFLICTING'
? 'conflicts ⚠️'
: pr.mergeable === 'MERGEABLE'
? 'mergeable'
: 'mergeability unknown'
}${pr.isDraft ? ' · draft' : ''}</i>\n` +
`<a href="${escapeHtml(pr.url)}">${escapeHtml(pr.url)}</a>\n`;
const verdict = `\n<b>${VERDICT_LABEL[report.verdict]}</b> <i>(confidence ${report.confidence}${report.scope === 'mixed' ? ', mixed scope' : ''})</i>\n`;
const summary = report.summary ? `\n${escapeHtml(report.summary)}\n` : '';
const counts = countBySeverity(report.findings);
const countStr = SEVERITY_ORDER.filter((s) => counts[s] > 0)
.map((s) => `${counts[s]} ${s}${counts[s] === 1 ? '' : 's'}`)
.join(', ');
const findingsHeader = report.findings.length ? `\n<b>Findings</b> (${countStr}):\n` : '\n<b>Findings:</b> none\n';
const checks = checksLine(report.checks);
const recommendation = report.recommendation ? `\n<b>Recommendation:</b> ${escapeHtml(report.recommendation)}\n` : '';
const footer = meta.durationMin !== undefined ? `\n<i>review took ${meta.durationMin} min</i>` : '';
const fixed = header + verdict + summary + findingsHeader;
const tail = (checks ? `\n${checks}\n` : '') + recommendation + footer;
let budget = SUMMARY_BUDGET - fixed.length - tail.length;
const lines: string[] = [];
let shown = 0;
for (const f of report.findings) {
const line = findingLine(f) + '\n';
if (line.length > budget) break;
lines.push(line);
budget -= line.length;
shown++;
}
const hidden = report.findings.length - shown;
const more = hidden > 0 ? `<i>… ${hidden} more in the full report</i>\n` : '';
return fixed + lines.join('') + more + tail;
}
export function formatReviewFailure(
pr: Pick<PrSummary, 'number' | 'title' | 'author' | 'url'>,
reason: string
): string {
return (
`⚠️ <b>PR #${pr.number}</b> · ${escapeHtml(truncate(pr.title, 120))}\n` +
`<i>by ${escapeHtml(pr.author)}</i>\n<a href="${escapeHtml(pr.url)}">${escapeHtml(pr.url)}</a>\n\n` +
`The review did not complete: ${escapeHtml(truncate(reason, 1500))}\n\n` +
`Use /review ${pr.number} to try again.`
);
}
/** Split on line boundaries so no chunk exceeds Telegram's cap. */
export function splitTelegramMessage(text: string, max = TELEGRAM_MAX): string[] {
if (text.length <= max) return [text];
const chunks: string[] = [];
let current = '';
for (const line of text.split('\n')) {
let piece = line;
while (piece.length > max) {
if (current) {
chunks.push(current);
current = '';
}
chunks.push(piece.slice(0, max));
piece = piece.slice(max);
}
const candidate = current ? `${current}\n${piece}` : piece;
if (candidate.length > max) {
chunks.push(current);
current = piece;
} else {
current = candidate;
}
}
if (current) chunks.push(current);
return chunks;
}
export interface InlineButton {
text: string;
callback_data: string;
}
/** Callback data is capped at 64 bytes by Telegram; these stay far under it. */
export function buildReportKeyboard(prNumber: number, opts: { ci: CiState; hasDraft: boolean }): InlineButton[][] {
const rows: InlineButton[][] = [
[
{ text: '📄 Full report', callback_data: `report:${prNumber}` },
...(opts.hasDraft ? [{ text: '💬 Draft comment', callback_data: `draft:${prNumber}` }] : []),
{ text: '🔁 Re-review', callback_data: `review:${prNumber}` },
],
[
{ text: '✅ Merge', callback_data: `merge:${prNumber}` },
...(opts.hasDraft ? [{ text: '📮 Post comment', callback_data: `post:${prNumber}` }] : []),
{ text: '🗑 Close', callback_data: `close:${prNumber}` },
],
];
if (opts.ci === 'awaiting-approval')
rows.push([{ text: '▶️ Approve CI run', callback_data: `approveci:${prNumber}` }]);
return rows;
}
export function confirmKeyboard(action: string, prNumber: number, nonce: string): InlineButton[][] {
return [
[
{ text: `Yes, ${action} #${prNumber}`, callback_data: `confirm:${action}:${prNumber}:${nonce}` },
{ text: 'Cancel', callback_data: `cancel:${action}:${prNumber}:${nonce}` },
],
];
}
export interface StatusRow {
number: number;
title: string;
author: string;
verdict?: Verdict;
status: string;
ci?: CiState;
mergeable: PrSummary['mergeable'];
isDraft: boolean;
}
export function formatStatusList(rows: StatusRow[], paused: boolean): string {
if (!rows.length) return 'No open pull requests.';
const lines = rows.map((r) => {
const v = r.verdict
? VERDICT_LABEL[r.verdict].split(' ')[0]
: r.status === 'reviewing'
? '⏳'
: r.status === 'queued'
? '🕓'
: '·';
const flags = [
r.ci ? CI_LABEL[r.ci].replace('CI ', '') : '',
r.mergeable === 'CONFLICTING' ? 'conflicts' : '',
r.isDraft ? 'draft' : '',
]
.filter(Boolean)
.join(', ');
return `${v} <b>#${r.number}</b> ${escapeHtml(truncate(r.title, 60))} <i>(${escapeHtml(r.author)}${flags ? `; ${flags}` : ''})</i>`;
});
return `${paused ? '⏸ auto-review paused\n' : ''}<b>Open PRs (${rows.length})</b>\n${lines.join('\n')}`;
}
/**
* Backlog order for a fresh sweep: the ones you can act on first (mergeable, small),
* conflicting and huge ones last. Ties keep the newer PR first.
*/
export function orderBacklog<T extends Pick<PrSummary, 'number' | 'mergeable' | 'additions' | 'deletions'>>(
prs: T[]
): T[] {
const size = (p: T) => p.additions + p.deletions;
return [...prs].sort((a, b) => {
const ca = a.mergeable === 'CONFLICTING' ? 1 : 0;
const cb = b.mergeable === 'CONFLICTING' ? 1 : 0;
if (ca !== cb) return ca - cb;
const sa = size(a);
const sb = size(b);
if (sa !== sb) return sa - sb;
return b.number - a.number;
});
}
export function verdictLabel(v: Verdict): string {
return VERDICT_LABEL[v];
}
+241
View File
@@ -0,0 +1,241 @@
/**
* @fileoverview The review brief handed to each reviewer session, and the follow-up
* brief. Pure: the bot writes the result to a file and sends the session one short
* line pointing at it (prompts are single-line over tmux, and a brief this size
* belongs on disk anyway).
*
* The brief is opinionated on purpose. It names the repository's own rules (CLAUDE.md,
* CONTRIBUTING.md), the checks to run, the verdict vocabulary, and the exact JSON the
* bot parses. Everything the maintainer would say out loud before delegating a
* review lives here.
*/
import type { CiStatus, PrDetail } from './github.js';
export const VERDICTS = ['merge', 'merge-with-fixes', 'request-changes', 'close', 'needs-discussion'] as const;
export type Verdict = (typeof VERDICTS)[number];
export interface ReviewBriefInput {
pr: PrDetail;
ci: CiStatus;
mergeBase: string;
worktreeDir: string;
mainCheckout: string;
reportJsonPath: string;
reportMdPath: string;
}
function ciLine(ci: CiStatus): string {
const detail = ci.runs.map((r) => `${r.name}: ${r.conclusion ?? r.status}`).join(', ');
switch (ci.state) {
case 'passed':
return `passed (${detail})`;
case 'failed':
return `FAILED (${detail}); read the failing job's log with \`gh run view <id> --log-failed\` before you trust or dismiss it`;
case 'pending':
return `still running (${detail})`;
case 'awaiting-approval':
return 'never ran: the workflow is waiting for a maintainer to approve it (first-time contributor), so run the checks yourself';
default:
return 'no workflow runs found for this head (a conflicting PR gets no CI at all); run the checks yourself';
}
}
export function buildReviewBrief(input: ReviewBriefInput): string {
const { pr, ci, mergeBase, worktreeDir, mainCheckout, reportJsonPath, reportMdPath } = input;
const files = pr.files.map((f) => `- \`${f.path}\` (+${f.additions}/-${f.deletions})`).join('\n');
const linked = pr.linkedIssues.length
? pr.linkedIssues.map((i) => `- #${i.number} ${i.title}`).join('\n')
: '- none linked';
const mergeability =
pr.mergeable === 'CONFLICTING'
? 'CONFLICTING with master. It cannot be merged as-is and GitHub runs no CI for it. Review the PR head as it stands, and say in the report whether the conflicts look mechanical or structural (`git merge-tree` against origin/master helps).'
: pr.mergeable === 'MERGEABLE'
? 'mergeable'
: 'unknown (GitHub has not computed it yet)';
return `# Review brief: PR #${pr.number} ${pr.title}
You are reviewing a pull request against Codeman on behalf of the maintainer. You are
in a private clone at \`${worktreeDir}\`, checked out (detached) at the PR head. The
maintainer reads your report on a phone and decides what happens next, so write for
someone who has not seen the diff.
## Ground rules (read twice)
- Nothing you do here reaches GitHub. Do NOT push, comment, merge, close, label, or
create anything with \`gh\`; \`gh\` is for READING only (\`gh pr view\`, \`gh run view\`,
\`gh api\` GETs).
- Do NOT run \`npm install\`, \`npm ci\`, \`npm update\` or \`npm run build\`: \`node_modules\`
may be a symlink into the maintainer's live checkout. Everything else in package.json
scripts is fine (\`npm run typecheck\`, \`npm run lint\`, \`npm test -- <file>\`, ...).
- Do NOT restart, stop or install any service, and never bind port 3000: the
maintainer's production Codeman runs there. Test ports are 3150 and up.
- \`${mainCheckout}\` is the maintainer's shared checkout. You may READ it for comparison;
never run a git command there that changes anything (no checkout, reset, stash, clean).
- Stay inside this clone for writes. Do not create files elsewhere except the two
report files named below.
- Do not ask questions. Nobody is watching this session. Where something is ambiguous,
decide, and list the assumption in the report.
## The pull request
- **#${pr.number}** ${pr.title}
- Author: ${pr.author} (${pr.authorAssociation.toLowerCase().replace(/_/g, ' ')})${pr.headRepo ? `, from \`${pr.headRepo}\`` : ''}
- URL: ${pr.url}
- Base: \`${pr.baseRef}\` at merge base \`${mergeBase.slice(0, 12)}\`; head: \`${pr.headSha.slice(0, 12)}\` (${pr.commitCount} commits)
- Size: +${pr.additions} / -${pr.deletions} across ${pr.changedFiles} files
- Mergeability: ${mergeability}
- CI: ${ciLine(ci)}
- Draft: ${pr.isDraft ? 'yes' : 'no'}; existing comments: ${pr.commentCount}${pr.labels.length ? `; labels: ${pr.labels.join(', ')}` : ''}
### Linked issues
${linked}
### Files changed
${files || '- (none reported)'}
### PR description, verbatim
\`\`\`text
${pr.body.trim() || '(empty)'}
\`\`\`
## How to review
1. Read \`CLAUDE.md\` at the root and \`.github/CONTRIBUTING.md\`. Most review feedback on
this repository traces back to a rule already written there, and a change that
contradicts one of those rules is a finding even when the code works. Open the
\`docs/architecture-invariants.md\` sections the change touches.
2. Understand the change: \`git log --oneline ${mergeBase.slice(0, 12)}..HEAD\` and
\`git diff ${mergeBase.slice(0, 12)}..HEAD\`. Read the surrounding code, not only the
hunks: the file's \`@fileoverview\` first, then the call sites of anything changed.
3. Look for, in this order: correctness bugs (wrong logic, races, missed error paths,
lost state across restart); security (auth and ownership checks, path confinement,
the env-prefix allowlist, shell/command injection, SSRF, secrets on the command
line or in state files); violations of CLAUDE.md rules (cite the rule); behaviour
changes without tests; contract changes (\`/api/v1\` paths, response envelope,
\`errorCode\` values, SSE event names are public and stable, see
\`docs/versioning-policy.md\`); scope (one change per PR: flag unrelated changes
bundled in); docs and registries that must move with the code (CLAUDE.md and
architecture-invariants when a rule changes, \`sse-events.ts\` and \`constants.js\`
parity, \`docs/api-reference.md\`); housekeeping that does not belong in a PR
(version bumps, CHANGELOG edits, files pulled back into Prettier's scope, committed
vendor bundles, changeset files are fine).
4. Run the checks and record what you ran and what came back:
\`npm run typecheck\`, \`npm run lint\`, \`npm run check:frontend-syntax\`,
\`npm run format:check\`, then the tests covering the touched areas
(\`npm test -- test/<file>.test.ts\`, several files at once is fine). Run the full
\`npm test\` when the change is broad or touches shared infrastructure (session,
tmux, routes, state); it takes minutes, which is acceptable. A red check that is
also red on origin/master is not the PR's fault: say so rather than blaming it.
Other test suites may be running on this machine at the same time and they share
the 3150+ port range, so re-run a failed file on its own (\`npm test -- <file>\`)
before you read an EADDRINUSE or a timeout as the PR's regression.
5. Verify before you report. A finding that could be a misread must be confirmed by
reading the full code path, by a tiny test, or by running it. Every finding names a
file and line. Rank: **blocker** (must be fixed before merge: data loss, security,
breaks a documented invariant, breaks the build or tests), **major** (should be
fixed: a real bug in an edge the PR introduces, a missing test for new behaviour),
**minor**, **nit**.
6. Judge the PR, not the author. Contributors here are volunteers and the maintainer
thanks them by name in every release; be exact and be kind.
## Verdict vocabulary
- \`merge\`: no blockers or majors, checks green; merge as-is.
- \`merge-with-fixes\`: mergeable, but with small things the maintainer would rather fix
at merge time than round-trip (list them so they can be applied on top).
- \`request-changes\`: blockers or majors the author should fix.
- \`close\`: wrong direction, superseded, or not wanted; say what should happen instead.
- \`needs-discussion\`: a design question the maintainer must answer before anyone
spends more time (name the question).
## Output, mandatory
Write BOTH files, then reply with exactly one line: \`REVIEW COMPLETE\`.
1. \`${reportJsonPath}\`: a single JSON object, no markdown fences, this shape:
\`\`\`json
{
"verdict": "merge | merge-with-fixes | request-changes | close | needs-discussion",
"confidence": "high | medium | low",
"summary": "Two or three sentences: what the PR does, and the review's bottom line.",
"changes": ["one bullet per thing the PR actually changes"],
"findings": [
{
"severity": "blocker | major | minor | nit",
"title": "one line",
"file": "path/from/repo/root.ts",
"line": 123,
"detail": "what is wrong, why it matters, what to do instead",
"invariant": "the CLAUDE.md / CONTRIBUTING rule it breaks, or omit"
}
],
"checks": [
{ "name": "typecheck", "command": "npm run typecheck", "result": "pass | fail | skipped", "notes": "" }
],
"_checks_note": "result is from the PR's point of view: a regression test you deliberately ran against master to prove it fails is a pass (say so in notes), a red run caused by another suite on the machine is skipped with the reason, only a genuine problem with the PR is fail",
"scope": "focused | mixed",
"risk": "One or two sentences naming the judgment calls a second reviewer should look at.",
"recommendation": "Two to four sentences for the maintainer: what to do next and why.",
"draftComment": "A comment to the contributor, in markdown, ready to post (rules below).",
"assumptions": ["anything you had to decide alone"]
}
\`\`\`
2. \`${reportMdPath}\`: the full report in markdown for the maintainer, in this order:
what the PR does; the verdict with the reasoning; findings in severity order with
file:line and the fix; checks run with results; CLAUDE.md rules touched; scope and
risk; recommendation; assumptions. Include the diff stat. No length limit, but no
padding either.
### Draft comment rules
The draft is written AS the maintainer TO the contributor and must stand alone: the
reader has not seen this brief. Open by thanking them and saying in one sentence what
the PR does. Then the findings that need action, each with file:line and the concrete
ask, blockers first. Close with what happens next (merge after fixes, will fix at merge
time, and so on). When the verdict is \`merge\`, the whole comment is a short thank-you
naming anything you would touch at merge time. Plain markdown. No em-dashes (use
commas, colons or parentheses). No emojis. No "Generated with Claude Code" or similar
attribution line. No hedging words. The maintainer reads it before it is posted and may
edit it.
`;
}
/** Sent as ONE line; the brief above is on disk. */
export function reviewKickoffLine(briefPath: string): string {
return `Read ${briefPath} and carry out the review it describes. Do not ask questions. Finish by writing both report files it names, then reply with exactly: REVIEW COMPLETE`;
}
export function followupKickoffLine(followupPath: string): string {
return `Read ${followupPath}: it holds a follow-up from the maintainer about the pull request you reviewed. Do what it asks within the ground rules of the original brief (no pushing, no gh writes, no npm install, no builds, no services), then answer in plain text. Do not ask questions.`;
}
export function buildFollowupBrief(input: {
prNumber: number;
title: string;
instruction: string;
worktreeDir: string;
reportMdPath: string;
briefPath: string;
}): string {
return `# Follow-up on PR #${input.prNumber} ${input.title}
The maintainer read your review report (\`${input.reportMdPath}\`; the original brief is
\`${input.briefPath}\`, and its ground rules still apply: nothing reaches GitHub, no
installs, no builds, no services, writes stay inside \`${input.worktreeDir}\`).
Their message:
\`\`\`text
${input.instruction.trim()}
\`\`\`
Answer concisely and concretely, for a phone screen: lead with the answer, then the
evidence (commands run, file:line). If the message asks you to change code, make the
change in this clone, run the relevant checks, and describe the diff (\`git diff
--stat\` plus the essential hunks). Keep the changes uncommitted unless asked to commit;
never push. If it asks for something outside the ground rules, say so and stop.
`;
}
+166
View File
@@ -0,0 +1,166 @@
/**
* @fileoverview The bot's persisted state: one record per PR (what was reviewed at
* which head, the parsed report, the Claude session to resume for follow-ups, the
* Telegram messages that belong to it), the Telegram update offset, pending
* confirmations, and the pause flag. One JSON file, written atomically (tmp + rename)
* with mode 0600, since reports quote code and draft comments.
*/
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import type { CiState, PrSummary } from './github.js';
import type { ReviewReport } from './report.js';
import type { Verdict } from './review-task.js';
export type PrStatus = 'new' | 'queued' | 'reviewing' | 'reviewed' | 'failed' | 'skipped' | 'closed';
export interface PrRecord {
number: number;
title: string;
author: string;
url: string;
headSha: string;
isDraft: boolean;
mergeable: PrSummary['mergeable'];
additions?: number;
deletions?: number;
changedFiles?: number;
status: PrStatus;
ci?: CiState;
reviewedSha?: string;
reviewedAt?: string;
reviewDurationMin?: number;
verdict?: Verdict;
report?: ReviewReport;
briefPath?: string;
reportJsonPath?: string;
reportMdPath?: string;
/** The Claude conversation to resume for follow-ups. */
claudeSessionId?: string;
/** The live Codeman session while a turn is running; cleared afterwards. */
activeSessionId?: string;
worktreeDir?: string;
telegramMessageId?: number;
lastError?: string;
/** Consecutive failed attempts at `failedSha`; the scan stops auto-retrying at MAX_AUTO_RETRIES. */
failedAttempts?: number;
failedSha?: string;
closedAs?: 'merged' | 'closed';
updatedAt: string;
}
export interface PendingConfirm {
action: 'merge' | 'close' | 'post';
prNumber: number;
createdAt: string;
messageId?: number;
/** Closing comment for `close`. */
reason?: string;
}
export interface BotState {
version: 1;
paused: boolean;
telegramOffset: number;
prs: Record<string, PrRecord>;
pending: Record<string, PendingConfirm>;
/** Telegram message id -> PR number, so a reply to any of the bot's messages finds its PR. */
messages: Record<string, number>;
/** Telegram message id -> PR number for "reply with the closing reason" prompts. */
reasonPrompts: Record<string, number>;
}
export function emptyState(): BotState {
return { version: 1, paused: false, telegramOffset: 0, prs: {}, pending: {}, messages: {}, reasonPrompts: {} };
}
const MAX_MESSAGE_MAP = 2000;
export class StateStore {
state: BotState;
constructor(private readonly path: string) {
this.state = emptyState();
if (existsSync(path)) {
try {
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<BotState>;
this.state = { ...emptyState(), ...parsed, version: 1 };
} catch (err) {
throw new Error(`state file ${path} is unreadable: ${(err as Error).message}`);
}
}
}
save(): void {
mkdirSync(dirname(this.path), { recursive: true });
this.pruneMessageMap();
const tmp = join(dirname(this.path), `.state.${process.pid}.${Date.now()}.tmp`);
writeFileSync(tmp, JSON.stringify(this.state, null, 2), { mode: 0o600 });
renameSync(tmp, this.path);
}
pr(number: number): PrRecord | undefined {
return this.state.prs[String(number)];
}
/**
* Refresh a PR's metadata, keeping its review. Mutates the EXISTING record in place:
* a review in flight holds a reference to it, and a scan that replaced the object
* with a copy made that review write its verdict into an orphan (first daemon run:
* PR 363 reported to Telegram, state still said `reviewing`).
*/
upsertPr(summary: PrSummary): PrRecord {
const key = String(summary.number);
const existing = this.state.prs[key];
const record: PrRecord = existing ?? {
number: summary.number,
title: summary.title,
author: summary.author,
url: summary.url,
headSha: summary.headSha,
isDraft: summary.isDraft,
mergeable: summary.mergeable,
status: 'new',
updatedAt: new Date().toISOString(),
};
record.title = summary.title;
record.author = summary.author;
record.url = summary.url;
record.headSha = summary.headSha;
record.isDraft = summary.isDraft;
record.mergeable = summary.mergeable;
record.additions = summary.additions;
record.deletions = summary.deletions;
record.changedFiles = summary.changedFiles;
if (record.status === 'closed') {
// Reopened.
record.status = record.reviewedSha ? 'reviewed' : 'new';
record.closedAs = undefined;
}
record.updatedAt = new Date().toISOString();
this.state.prs[key] = record;
return record;
}
openPrs(): PrRecord[] {
return Object.values(this.state.prs)
.filter((r) => r.status !== 'closed')
.sort((a, b) => b.number - a.number);
}
rememberMessage(messageId: number, prNumber: number): void {
this.state.messages[String(messageId)] = prNumber;
}
prForMessage(messageId: number | undefined): number | undefined {
if (messageId === undefined) return undefined;
return this.state.messages[String(messageId)];
}
private pruneMessageMap(): void {
const keys = Object.keys(this.state.messages);
if (keys.length <= MAX_MESSAGE_MAP) return;
// Message ids grow monotonically per chat; drop the oldest.
keys.sort((a, b) => Number(a) - Number(b));
for (const key of keys.slice(0, keys.length - MAX_MESSAGE_MAP)) delete this.state.messages[key];
}
}
+188
View File
@@ -0,0 +1,188 @@
/**
* @fileoverview Minimal Telegram Bot API client (long polling, no webhook: the box sits
* behind Tailscale) plus the pure command / callback parsers.
*
* Only updates from the configured chat are ever acted on; everything else is dropped
* without an answer, so a stranger who finds the bot gets silence, not a menu.
*/
export interface TelegramMessage {
message_id: number;
chat: { id: number | string };
from?: { id: number; username?: string };
text?: string;
reply_to_message?: { message_id: number; text?: string };
}
export interface TelegramCallbackQuery {
id: string;
from: { id: number; username?: string };
message?: TelegramMessage;
data?: string;
}
export interface TelegramUpdate {
update_id: number;
message?: TelegramMessage;
callback_query?: TelegramCallbackQuery;
}
export interface SendOptions {
replyMarkup?: unknown;
replyToMessageId?: number;
disablePreview?: boolean;
}
export class TelegramClient {
private readonly base: string;
constructor(
token: string,
private readonly chatId: string
) {
this.base = `https://api.telegram.org/bot${token}`;
}
private async call<T>(method: string, body?: Record<string, unknown>, timeoutMs = 30_000): Promise<T> {
const res = await fetch(`${this.base}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {}),
signal: AbortSignal.timeout(timeoutMs),
});
const json = (await res.json()) as { ok: boolean; result?: T; description?: string };
if (!json.ok) throw new Error(`telegram ${method}: ${json.description ?? res.status}`);
return json.result as T;
}
isOurChat(chatId: number | string | undefined): boolean {
return chatId !== undefined && String(chatId) === this.chatId;
}
async getMe(): Promise<{ username?: string }> {
return this.call<{ username?: string }>('getMe');
}
async sendMessage(text: string, opts: SendOptions = {}): Promise<number> {
const result = await this.call<{ message_id: number }>('sendMessage', {
chat_id: this.chatId,
text,
parse_mode: 'HTML',
disable_web_page_preview: opts.disablePreview ?? true,
reply_markup: opts.replyMarkup,
reply_to_message_id: opts.replyToMessageId,
});
return result.message_id;
}
/** Plain text, no parse mode: for content the bot did not write (reviewer answers, drafts). */
async sendPlain(text: string, opts: SendOptions = {}): Promise<number> {
const result = await this.call<{ message_id: number }>('sendMessage', {
chat_id: this.chatId,
text,
disable_web_page_preview: opts.disablePreview ?? true,
reply_markup: opts.replyMarkup,
reply_to_message_id: opts.replyToMessageId,
});
return result.message_id;
}
async editReplyMarkup(messageId: number, replyMarkup: unknown): Promise<void> {
try {
await this.call('editMessageReplyMarkup', {
chat_id: this.chatId,
message_id: messageId,
reply_markup: replyMarkup,
});
} catch (err) {
// "message is not modified" is Telegram's way of saying the keyboard already looks like that.
if (!String(err).includes('not modified')) throw err;
}
}
async deleteMessage(messageId: number): Promise<void> {
try {
await this.call('deleteMessage', { chat_id: this.chatId, message_id: messageId });
} catch {
// Already gone, or older than Telegram allows a bot to delete; the message was informational.
}
}
async answerCallback(callbackId: string, text?: string): Promise<void> {
await this.call('answerCallbackQuery', { callback_query_id: callbackId, text });
}
async sendDocument(filename: string, content: string, caption?: string): Promise<void> {
const form = new FormData();
form.set('chat_id', this.chatId);
if (caption) form.set('caption', caption);
form.set('document', new Blob([content], { type: 'text/markdown' }), filename);
const res = await fetch(`${this.base}/sendDocument`, {
method: 'POST',
body: form,
signal: AbortSignal.timeout(60_000),
});
const json = (await res.json()) as { ok: boolean; description?: string };
if (!json.ok) throw new Error(`telegram sendDocument: ${json.description ?? res.status}`);
}
async getUpdates(offset: number, timeoutSec: number): Promise<TelegramUpdate[]> {
return this.call<TelegramUpdate[]>(
'getUpdates',
{ offset, timeout: timeoutSec, allowed_updates: ['message', 'callback_query'] },
(timeoutSec + 15) * 1000
);
}
async setMyCommands(commands: { command: string; description: string }[]): Promise<void> {
await this.call('setMyCommands', { commands });
}
}
export interface ParsedCommand {
command: string;
prNumber?: number;
rest: string;
}
/** `/merge 381 force` -> {command:'merge', prNumber:381, rest:'force'}; `/help@botname` is handled. */
export function parseCommand(text: string | undefined): ParsedCommand | null {
if (!text) return null;
const m = text.trim().match(/^\/([a-zA-Z_]+)(?:@\w+)?(?:\s+([\s\S]*))?$/);
if (!m) return null;
const command = m[1].toLowerCase();
const argText = (m[2] ?? '').trim();
const numMatch = argText.match(/^#?(\d+)\b\s*([\s\S]*)$/);
if (numMatch) return { command, prNumber: parseInt(numMatch[1], 10), rest: numMatch[2].trim() };
return { command, rest: argText };
}
export interface ParsedCallback {
action: string;
prNumber: number;
nonce?: string;
/** For confirm/cancel: the action being confirmed. */
target?: string;
}
export function parseCallback(data: string | undefined): ParsedCallback | null {
if (!data) return null;
const parts = data.split(':');
if (parts[0] === 'confirm' || parts[0] === 'cancel') {
if (parts.length !== 4) return null;
const prNumber = parseInt(parts[2], 10);
if (!Number.isFinite(prNumber)) return null;
return { action: parts[0], target: parts[1], prNumber, nonce: parts[3] };
}
if (parts.length !== 2) return null;
const prNumber = parseInt(parts[1], 10);
if (!Number.isFinite(prNumber)) return null;
return { action: parts[0], prNumber };
}
/** Find the PR number a report message is about, from its first line (`🔍 PR #381 · ...`). */
export function prNumberFromMessageText(text: string | undefined): number | null {
if (!text) return null;
const m = text.match(/PR #(\d+)/);
return m ? parseInt(m[1], 10) : null;
}
+253
View File
@@ -0,0 +1,253 @@
/**
* @fileoverview Per-PR checkouts for the review sessions.
*
* The maintainer's checkout is SHARED with other agent sessions (CLAUDE.md, Session
* Safety), so the bot never runs `git checkout` there. It fetches the PR head into a
* private ref (`refs/pr-bot/<n>`) of the main repository, which anchors the objects,
* and checks the PR out in a private clone under the bot's own data dir; every
* in-tree git command runs with `-C <clone>`.
*
* Why a `git clone --shared` and not a linked worktree: Claude Code resolves a linked
* worktree's project settings through the git common dir, i.e. the MAIN checkout's
* `.claude/settings.local.json`, whose model pin then silently overrides anything
* written into the worktree (measured 2026-09-05: a worktree pinned to
* `claude-fable-5-1` reported `claude-opus-5[1m]`). A shared clone has its own
* project root, so Codeman's `modelOverride` and hooks land where the CLI reads them,
* while `objects/info/alternates` keeps the object store shared (no duplication).
*
* Dependencies: a clone has no `node_modules`. When the PR leaves the lockfile
* untouched, `node_modules` is a SYMLINK to the main checkout's tree (read-only use:
* tsc, vitest, eslint). When the PR changes dependencies, the symlink is unlinked
* first and `npm ci` installs a real tree, so npm can never write through the link
* into the live server's modules. `src/web/public/vendor` is COPIED per file, never
* linked: postinstall regenerates it in place, and a link would let a PR's bundle
* overwrite the bundle the production server is serving.
*/
import { execFile } from 'child_process';
import {
cpSync,
existsSync,
lstatSync,
mkdirSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
unlinkSync,
writeFileSync,
} from 'fs';
import { join } from 'path';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
export interface WorktreeInfo {
dir: string;
headSha: string;
mergeBase: string;
deps: 'linked' | 'installed' | 'kept';
}
export type Logger = (msg: string) => void;
async function git(args: string[], cwd: string, timeoutMs = 120_000): Promise<string> {
const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: 64 * 1024 * 1024, timeout: timeoutMs });
return stdout;
}
export function prRef(prNumber: number): string {
return `refs/pr-bot/${prNumber}`;
}
/** The upstream master, as fetched into the main repository, mirrored into the clone. */
const MASTER_REF = 'refs/remotes/origin/master';
export function worktreeDirFor(worktreesDir: string, prNumber: number): string {
return join(worktreesDir, `pr-${prNumber}`);
}
const DEP_FILES = [
'package.json',
'package-lock.json',
'packages/xterm-zerolag-input/package.json',
'packages/gesture-control/package.json',
];
async function originUrl(mainCheckout: string): Promise<string> {
return (await git(['remote', 'get-url', 'origin'], mainCheckout)).trim();
}
/** A linked worktree from the first version of this file: `.git` is a FILE there. */
function isLegacyWorktree(dir: string): boolean {
const dotGit = join(dir, '.git');
try {
return statSync(dotGit).isFile();
} catch {
return false;
}
}
function isOwnClone(dir: string): boolean {
try {
return statSync(join(dir, '.git')).isDirectory();
} catch {
return false;
}
}
/** Fetch the PR head, (re)create the clone at it, and make node_modules usable. */
export async function preparePrWorktree(opts: {
mainCheckout: string;
worktreesDir: string;
prNumber: number;
/** Reset a reused clone to the fetched head (drops edits a follow-up may have made). */
reset: boolean;
log: Logger;
}): Promise<WorktreeInfo> {
const { mainCheckout, worktreesDir, prNumber, log } = opts;
const ref = prRef(prNumber);
const dir = worktreeDirFor(worktreesDir, prNumber);
mkdirSync(worktreesDir, { recursive: true });
log(`fetching origin master + pull/${prNumber}/head`);
await git(
['fetch', '--quiet', 'origin', `+refs/heads/master:${MASTER_REF}`, `+refs/pull/${prNumber}/head:${ref}`],
mainCheckout,
300_000
);
const headSha = (await git(['rev-parse', ref], mainCheckout)).trim();
if (existsSync(dir) && isLegacyWorktree(dir)) {
log(`replacing the linked worktree at ${dir} with a clone`);
await git(['worktree', 'remove', '--force', dir], mainCheckout).catch(() =>
rmSync(dir, { recursive: true, force: true })
);
await git(['worktree', 'prune'], mainCheckout);
}
if (existsSync(dir) && !isOwnClone(dir)) {
log(`removing stale directory ${dir}`);
rmSync(dir, { recursive: true, force: true });
}
if (!existsSync(dir)) {
log(`cloning (shared objects) into ${dir}`);
await git(['clone', '--quiet', '--shared', '--no-checkout', mainCheckout, dir], mainCheckout, 300_000);
// `origin` of the clone should mean GitHub, like everywhere else, not the main
// checkout's path; the refs below are fetched from the main checkout by path.
await git(['remote', 'set-url', 'origin', await originUrl(mainCheckout)], dir);
}
// Mirror the two refs from the main repository (objects are already reachable via
// alternates, so this only moves refs). `+` because both can move backwards.
await git(['fetch', '--quiet', mainCheckout, `+${MASTER_REF}:${MASTER_REF}`, `+${ref}:${ref}`], dir);
const current = (await git(['rev-parse', '--verify', '--quiet', 'HEAD'], dir).catch(() => '')).trim();
if (current !== headSha) {
log(`checking out ${headSha.slice(0, 8)}${current ? ` (was ${current.slice(0, 8)})` : ''}`);
await git(['checkout', '--quiet', '--detach', ref], dir);
}
if (opts.reset) {
await git(['reset', '--hard', '--quiet', ref], dir);
}
const mergeBase = (await git(['merge-base', MASTER_REF, 'HEAD'], dir)).trim();
const deps = await ensureDependencies({ mainCheckout, dir, ref, mergeBase, log });
ensureVendorCopy(mainCheckout, dir, log);
return { dir, headSha, mergeBase, deps };
}
/** Written into a clone's own node_modules once `npm ci` has finished; its absence means a half install. */
const INSTALL_MARKER = '.pr-bot-installed';
async function ensureDependencies(opts: {
mainCheckout: string;
dir: string;
ref: string;
mergeBase: string;
log: Logger;
}): Promise<WorktreeInfo['deps']> {
const { mainCheckout, dir, ref, mergeBase, log } = opts;
const target = join(dir, 'node_modules');
// Against the MERGE BASE, not master: master's own version bumps since the PR
// branched would otherwise make every older PR look like a dependency change and
// cost a full npm ci each. Only what the PR itself did to the dependency files counts.
let depsChanged = false;
try {
await git(['diff', '--quiet', mergeBase, ref, '--', ...DEP_FILES], mainCheckout);
} catch {
depsChanged = true;
}
let existing = existsSync(target) || isSymlink(target) ? lstatSync(target) : null;
if (existing?.isDirectory() && !existsSync(join(target, INSTALL_MARKER))) {
// A real tree without the marker is an install that was interrupted (service
// restart mid `npm ci`); never trust it.
log('discarding an incomplete node_modules install');
rmSync(target, { recursive: true, force: true });
existing = null;
}
if (!depsChanged) {
if (existing?.isSymbolicLink()) return 'linked';
if (existing?.isDirectory()) return 'kept';
symlinkSync(join(mainCheckout, 'node_modules'), target, 'dir');
log('node_modules linked to the main checkout (dependencies unchanged by the PR)');
return 'linked';
}
// The PR changes dependencies: a real install, and NEVER through the symlink.
if (existing?.isSymbolicLink()) unlinkSync(target);
if (existing?.isDirectory()) return 'kept';
log('the PR changes dependencies: running npm ci in the clone (this can take minutes)');
await execFileAsync('npm', ['ci', '--no-audit', '--no-fund', '--loglevel=error'], {
cwd: dir,
timeout: 20 * 60_000,
maxBuffer: 64 * 1024 * 1024,
});
writeFileSync(join(target, INSTALL_MARKER), new Date().toISOString());
return 'installed';
}
function isSymlink(path: string): boolean {
try {
return lstatSync(path).isSymbolicLink();
} catch {
return false;
}
}
function ensureVendorCopy(mainCheckout: string, dir: string, log: Logger): void {
const rel = join('src', 'web', 'public', 'vendor');
const src = join(mainCheckout, rel);
const dst = join(dir, rel);
if (!existsSync(src)) return;
// Two of the vendor files are tracked in git, so the directory already exists in a
// fresh checkout; copy whatever is MISSING (the postinstall-built xterm bundles).
mkdirSync(dst, { recursive: true });
let copied = 0;
for (const entry of readdirSync(src)) {
const target = join(dst, entry);
if (existsSync(target)) continue;
cpSync(join(src, entry), target, { recursive: true });
copied++;
}
if (copied) log(`${copied} vendor bundle(s) copied from the main checkout`);
}
export async function removePrWorktree(opts: {
mainCheckout: string;
worktreesDir: string;
prNumber: number;
log: Logger;
}): Promise<void> {
const dir = worktreeDirFor(opts.worktreesDir, opts.prNumber);
if (existsSync(dir)) {
opts.log(`removing ${dir}`);
if (isLegacyWorktree(dir)) {
await git(['worktree', 'remove', '--force', dir], opts.mainCheckout).catch(() => undefined);
await git(['worktree', 'prune'], opts.mainCheckout).catch(() => undefined);
}
rmSync(dir, { recursive: true, force: true });
}
try {
await git(['update-ref', '-d', prRef(opts.prNumber)], opts.mainCheckout);
} catch {
// The ref may never have been created; nothing to delete.
}
}
+55 -7
View File
@@ -7,18 +7,25 @@
# the repo (the server stages it at ~/.codeman/self-update-runner.sh) — `git
# checkout` rewrites the in-repo copy and bash reads scripts lazily.
#
# ⚠️ The `docker-compose` supervisor is the exception to "outlives": there the
# restart IS the container exiting, which kills this script too. That is safe
# because the terminal "restarting" marker is written before the kill and the
# rebooted server reconciles it — but nothing may be added after that kill.
#
# Reports progress by writing ~/.codeman/update-status.json atomically; the
# browser polls GET /api/system/update/status across the restart drop. The
# freshly-booted server reconciles the final "restarting" → "completed"/"failed".
#
# Cross-platform: restarts via systemd (Linux), launchd (macOS), or prints a
# manual command (foreground installs). Linux launches inside a transient
# systemd scope so `systemctl restart codeman-web` can't kill it mid-build.
# Cross-platform: restarts via systemd (Linux), launchd (macOS), a container exit
# under Docker Compose (the restart policy relaunches it), or prints a manual
# command (foreground installs). Linux launches inside a transient systemd scope
# so `systemctl restart codeman-web` can't kill it mid-build.
#
# Args (all from the server, never user input — tag is validated server-side):
# --repo <dir> --tag <codeman@X.Y.Z> --supervisor <systemd|launchd|none>
# --repo <dir> --tag <codeman@X.Y.Z> --supervisor <systemd|launchd|docker-compose|none>
# --status-file <path> --update-id <uuid> --from-version <ver> --node <path>
# --log <path> [--prev-sha <sha>] [--stash]
# --log <path> [--prev-sha <sha>] [--stash] [--server-pid <pid>]
# [--restart-by-exit 0|1] (docker-compose only: may we exit the server?)
#
set -uo pipefail
@@ -32,6 +39,7 @@ REPO=""
TAG=""
SUPERVISOR="none"
SERVER_PID=""
RESTART_BY_EXIT="0"
STATUS_FILE=""
UPDATE_ID=""
FROM_VERSION=""
@@ -52,6 +60,7 @@ while [[ $# -gt 0 ]]; do
--log) LOG="$2"; shift 2 ;;
--prev-sha) PREV_SHA="$2"; shift 2 ;;
--server-pid) SERVER_PID="$2"; shift 2 ;;
--restart-by-exit) RESTART_BY_EXIT="$2"; shift 2 ;;
--stash) DO_STASH=1; shift ;;
*) shift ;;
esac
@@ -144,7 +153,7 @@ rollback_and_fail() {
echo "[self-update] $msg — rolling back to ${PREV_SHA:-<none>}"
if [[ -n "$PREV_SHA" ]]; then
git checkout --force "$PREV_SHA" >/dev/null 2>&1 || true
npm install --no-fund --no-audit >/dev/null 2>&1 || true
npm install --no-fund --no-audit --include=dev >/dev/null 2>&1 || true
npm run build >/dev/null 2>&1 || true
fi
fail "$msg — rolled back to the previous version" "$msg"
@@ -176,7 +185,9 @@ write_status "checkout" "Checking out $TAG…"
git -c advice.detachedHead=false checkout --force "$TAG" || rollback_and_fail "Could not check out $TAG"
# 4) Install dependencies (heartbeat keeps the UI live during this slow step).
run_step "installing" "Installing dependencies" npm install --no-fund --no-audit \
# --include=dev: tsc and esbuild are devDependencies, and the Compose image sets
# NODE_ENV=production, which would otherwise omit them and fail the build below.
run_step "installing" "Installing dependencies" npm install --no-fund --no-audit --include=dev \
|| rollback_and_fail "Dependency install failed"
# 5) Build (gate the restart on success — never restart into a torn dist/).
@@ -200,6 +211,43 @@ case "$SUPERVISOR" in
|| fail "Build succeeded but launchd restart failed" "launchctl"
}
;;
docker-compose)
# In the Compose deployment there is no init system to ask: the "restart" is
# the server EXITING, so the container's `restart: unless-stopped` policy
# relaunches it on the dist/ we just built. The repo and dist/ live on host
# mounts, so the new build survives the container being replaced.
#
# ⚠️ This script dies WITH the container it is restarting — it is a child of
# the server process, not a survivor like the systemd-scope path. That is
# fine, and load-bearing: the terminal "restarting" marker is already written
# above, and the freshly-booted server reconciles it. Nothing may be appended
# after the kill that the update depends on.
#
# ⚠️ The server is signalled by PID rather than `docker restart`: this
# container's own Docker CLI talks to the HOST daemon, and a self-directed
# restart there races the client's own death. Exiting is the one path that
# needs no cooperation from anything outside the container.
#
# ⚠️ Only when the SERVER said the container comes back (`--restart-by-exit 1`:
# the Compose file declared it, or the daemon reported an auto-restart policy).
# An unknown policy stages the build and asks for a restart instead. Exiting
# blind would take a container the daemon does not restart down for good,
# with no UI left to recover it from.
if [[ "$RESTART_BY_EXIT" != "1" ]]; then
MANUAL_CMD="docker restart \$(hostname) # from the Docker host"
write_status "completed-needs-manual-restart" "Update built — restart the Codeman container to apply v$TO_VERSION."
echo "[self-update] docker-compose: restart-by-exit not confirmed — not exiting; manual restart required"
exit 0
fi
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
: # container exit + restart policy take it from here
else
MANUAL_CMD="docker restart \$(hostname) # from the Docker host"
write_status "completed-needs-manual-restart" "Update staged — restart the Codeman container to apply v$TO_VERSION."
echo "[self-update] docker-compose: could not signal server pid '$SERVER_PID' — manual restart required"
exit 0
fi
;;
launchd-daemon)
# System-level KeepAlive LaunchDaemon (headless Mac): kickstarting the system
# domain needs root, but we don't need it — kill the server and launchd
+14 -10
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.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.22.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.21.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
grep -qs '^CODEMAN_PREAMBLE=1.22.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE'
# ---- Codeman agent preamble 1.22.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
@@ -96,7 +96,10 @@ AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:
# draw the lineage. Set once here and every present and future create call carries it;
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
# fail a spawn, so there is no case where you would want to leave it off.
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF")
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF" -H "X-Codeman-Agent-Origin: codeman-skill")
CID=codeman-agent-1 # FIXED literal, never "agent-$$": see below
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
@@ -322,10 +325,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.21.0
CODEMAN_PREAMBLE=1.22.0
PREAMBLE
)
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; }
. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.22.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
@@ -376,7 +379,7 @@ and no per-call body to hand-build.
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader
[ "${CODEMAN_PREAMBLE:-}" = 1.21.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.22.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'
@@ -441,7 +444,8 @@ Four things this block leans on, each one link away, no detour needed to run it:
strands the prompt on the composer until a bare `\r` follows: all three are reasons
to let `sendwait` build the call rather than hand-rolling 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.
- Deleting the sessions does **not** remove the case directories. They are marked as
agent-created, so `GET /api/v1/cases/agent-created` lists them for cleanup: §5.14.
### DeepSeek Harness workers
@@ -494,7 +498,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de
| find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) |
| read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) |
| talk to a claude worker directly | `ListAgents` / `SendMessage`, when the feature is on at both ends | [§5.13](reference/verbs.md#513-messaging-claude-workers) |
| clean up | `delete_session "$SID"` per id you created. Case directories and git worktrees are **not** removed with it | [§5.14](reference/verbs.md#514-clean-up) |
| clean up | `delete_session "$SID"` per id you created. Case directories and git worktrees are **not** removed with it; `GET /api/v1/cases/agent-created` lists the scratch case dirs your spawns left behind, for you to report | [§5.14](reference/verbs.md#514-clean-up) |
## 3. Rules digest
@@ -595,7 +599,7 @@ these**; open the one row you actually hit.
| [5.11 List and find yourself](reference/verbs.md#511-list-and-find-yourself) | enumerate sessions, or match `$SELF` by prefix |
| [5.12 Read My Mind](reference/verbs.md#512-read-my-mind) | read or record what the user wants for a case |
| [5.13 Messaging claude workers](reference/verbs.md#513-messaging-claude-workers) | `ListAgents` / `SendMessage` instead of the HTTP path |
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove |
| [5.14 Clean up](reference/verbs.md#514-clean-up) | what deleting a session does **not** remove, and how to list the case dirs you left |
## 6. Setup and auth
+6 -3
View File
@@ -1,4 +1,4 @@
# ---- Codeman agent preamble 1.21.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ----
# ---- Codeman agent preamble 1.22.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
@@ -18,7 +18,10 @@ AUTH=(); [ -n "${CODEMAN_PASSWORD:-}" ] && AUTH=(-u "${CODEMAN_USERNAME:-admin}:
# draw the lineage. Set once here and every present and future create call carries it;
# it is ignored on every other endpoint. Purely cosmetic (see §5.1) and it can never
# fail a spawn, so there is no case where you would want to leave it off.
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF")
# X-Codeman-Agent-Origin: marks a case directory a spawn CREATES as agent scratch, so the
# user can find and delete it long after your workers are gone (§5.14). Same deal: set
# once, cosmetic, never fails a spawn, and it labels only directories Codeman creates.
CURL=(curl -sk "${AUTH[@]}" -H "X-Codeman-Parent-Session: $SELF" -H "X-Codeman-Agent-Origin: codeman-skill")
CID=codeman-agent-1 # FIXED literal, never "agent-$$": see below
# Fail-CLOSED session delete. The DELETE lives INSIDE the guard on purpose: the older
@@ -244,4 +247,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.21.0
CODEMAN_PREAMBLE=1.22.0
+10
View File
@@ -283,6 +283,7 @@ than into an existing checkout.
| 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/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 the whole conversation | `GET /api/v1/sessions/:id/last-response?context=full` → `.data.messages[]`. ⚠️ **Only `{role,text}` is present for every mode.** `kind`/`label` come from claude (`prompt`/`response`), deepseek and the pane parser (which also emit `status`/`tool`) but NOT from codex; `timestamp` from claude and codex but not deepseek/pane; `turn` and `queued:true` (a prompt typed while the agent was working) from claude only. `.data.text` is unchanged by `context=full` — it stays the last assistant message, never `messages[-1]` |
| 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` |
@@ -363,6 +364,15 @@ the global 50, or the per-user 25 in multi-user mode, never the waiter cap),
`CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a
loop.
⚠️ A case directory quick-start **creates** for you is labelled agent-created (a
`.codeman-agent-case.json` marker, written because the §0 preamble sends
`X-Codeman-Agent-Origin`), which is what lets the user find it afterwards:
`GET /api/v1/cases/agent-created` returns `.data.cases[]` of
`{name, path, createdAt, createdBy, parentSessionId, inUse, modifiedAt}`, newest first,
read-only, scoped to the caller's own case space. Report it when you finish; deleting is
`DELETE /api/v1/cases/:name` and is the user's call by name ([§5.14](verbs.md#514-clean-up)).
A directory that already existed is never labelled.
⚠️ `caseName` resolves through the linked-cases registry first, so a name that happens
to match a case the user linked in lands in that **real repo**, not a fresh scratch
directory. Pick distinctive scratch names, and use a linked name deliberately when you
+1 -1
View File
@@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version
```bash
. "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null
[ "${CODEMAN_PREAMBLE:-}" = 1.18.3 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
[ "${CODEMAN_PREAMBLE:-}" = 1.22.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; }
```
Do **not** re-paste the preamble body into each call. Sourcing it is what retires the
+26 -1
View File
@@ -407,7 +407,17 @@ done
printf '%s\n' "$TXT"
```
`.data` is `{text, timestamp}`. ⚠️ **On a hook-less workspace this reads the PREVIOUS
`.data` is `{text, timestamp}`. Add `?context=full` for the whole conversation in
`.data.messages[]`. ⚠️ **The four readers do not emit the same fields — only `{role, text}`
is guaranteed.** `kind`/`label` come from claude (`prompt`/`response`), deepseek and the pane
parser (the last two also emit `status`/`tool`), but **not** from codex; `timestamp` comes
from claude and codex but not from deepseek or the pane parser. A claude worker additionally
carries `turn` (a run of same-speaker messages inside one `turn` is one utterance split into
segments, not separate exchanges) and `queued: true` on a prompt the user typed while the
agent was still working. Filter on `role`, not on `kind`, unless you know the mode.
`.data.text` does not change under `context=full`: it stays the
last **assistant** message, so never read it as `messages[-1]`, which can be a prompt.
⚠️ **On a hook-less workspace this reads the PREVIOUS
turn.** `last-response` returns whatever the transcript last flushed, so it is only as
correct as your end-of-turn signal: pair it with a `stop` signal or a marker, never
with a bare `idle` ([§5.1](#51-where-to-spawn)). ⚠️ **Poll it, do not read it once.** `text` is written
@@ -721,6 +731,21 @@ Deleting a session ends the agent and its pane. It does **not** remove:
it, and ask before running `git worktree remove`, which discards uncommitted work
inside it.
Those case directories are **labelled** rather than left anonymous. A directory
`quick-start` creates for a spawn carrying the preamble's `X-Codeman-Agent-Origin`
header gets a `.codeman-agent-case.json` marker, which is what puts it in the web UI's
agent-case cleanup list (Add Case → Manage) and in:
```bash
"${CURL[@]}" "$API/api/v1/cases/agent-created" | jq -r '.data.cases[] | "\(.name)\t\(.createdAt)\tinUse=\(.inUse)"'
```
Read-only, scoped to the user's own case space, and `inUse` is true while a live
session is still working in that directory. Report that list when you finish a run
with workers, so the user knows exactly what to sweep; the deletion is still theirs to
ask for by name. Only a directory Codeman **created** is ever labelled, so a linked
case, a cloned repo or a worktree never appears there.
Confirm cleanup with `GET /api/v1/sessions`, never with `/api/v1/sessions/unified`
(that one folds in transcript history from the whole machine and will keep showing
your worker forever).
+183
View File
@@ -0,0 +1,183 @@
/**
* @fileoverview The marker file that records a case directory as one Codeman scaffolded
* FOR an agent-spawned session, so scratch worker workspaces can be told apart from the
* user's real projects long after the sessions that created them are gone.
*
* Why a file in the case directory rather than a central registry in `~/.codeman`:
* the thing being labelled is a directory on the user's disk, and the label has to
* survive everything that can happen to Codeman's own state (a wiped data dir, a
* different instance, a hand-moved case). A registry would also need stale-entry
* pruning and owner scoping of its own, while a marker is deleted by the same `rm -rf`
* that deletes the case, and is discoverable by a user who just runs `ls -a`.
*
* ⚠️ Written ONLY on the path that CREATES the directory (`POST /api/quick-start`'s
* `!existsSync` branch). A linked case, a cloned repo, a git worktree or any other
* pre-existing directory must never be labelled agent-created: the label drives a
* cleanup affordance, and mislabelling someone's repo there is the one failure mode
* that costs real work. `POST /api/sessions` takes an existing `workingDir` and so
* writes no marker at all, by construction.
*
* ⚠️ Reading is strict and total: anything that does not parse as a version-1 marker
* (truncated write, hand-edited junk, a user's unrelated file of the same name) reads
* as "not agent-created" rather than as a partially-trusted entry. A marker is
* metadata; deleting the file is the supported way to adopt a scratch case as a real
* one, which is what the `note` field written into it tells the user.
*/
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
/** Marker filename inside the case directory. Dot-prefixed so it stays out of the way. */
export const AGENT_CASE_MARKER_FILE = '.codeman-agent-case.json';
/** Current marker schema version. A marker of any other version reads as absent. */
export const AGENT_CASE_MARKER_VERSION = 1;
/**
* Origin recorded when a create request carried a resolvable spawning session but no
* explicit origin of its own (an agent driving the API by hand, or an older copy of
* the skill). Nothing in the browser UI sets lineage, so this really does mean "another
* session spawned this", not "a human clicked Run".
*/
export const AGENT_ORIGIN_SPAWNED_BY_SESSION = 'agent-session';
/** Origin the packaged agent skill sends on its shared curl invocation. */
export const AGENT_ORIGIN_CODEMAN_SKILL = 'codeman-skill';
/** Longest accepted origin token (the value is echoed into the UI and the marker). */
const MAX_ORIGIN_LENGTH = 32;
/** Longest accepted free-text field read back out of a marker. */
const MAX_MARKER_FIELD_LENGTH = 200;
/** Lowercase token: what an origin may look like on the wire and on disk. */
const AGENT_ORIGIN_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
/** Explains the file to whoever finds it in their case directory. */
const MARKER_NOTE =
'Created by a Codeman agent worker (see the Manage tab in Add Case). ' +
'Delete this file to keep the case out of the agent-case cleanup list; ' +
'deleting the whole directory removes the case.';
/**
* What a case directory records about the agent spawn that created it.
* Every field beyond `version`/`createdAt`/`createdBy` is decoration for the cleanup UI.
*/
export interface AgentCaseMarker {
version: typeof AGENT_CASE_MARKER_VERSION;
/** ISO timestamp of the spawn that created the directory. */
createdAt: string;
/** Who asked: `codeman-skill`, `agent-session`, or another caller's own token. */
createdBy: string;
/** Full id of the session that spawned the worker, when one resolved. */
parentSessionId?: string;
/** That session's display name at spawn time, so the user recognises it later. */
parentSessionName?: string;
/** Run mode the worker was started in (`claude`, `deepseek`, …). */
mode?: string;
/** Owner the case was created for, in multi-user mode. */
owner?: string;
}
/**
* Validate an origin token coming off the wire (`agentOrigin` body field or the
* `X-Codeman-Agent-Origin` header). Returns `undefined` for anything that is not a
* short lowercase token — the value reaches the UI and a JSON file, so it is
* allowlisted rather than escaped at each use.
*/
export function normalizeAgentOrigin(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined;
const value = raw.trim().toLowerCase();
if (!value || value.length > MAX_ORIGIN_LENGTH) return undefined;
return AGENT_ORIGIN_PATTERN.test(value) ? value : undefined;
}
/** Trim an optional free-text marker field to something safe to store and render. */
function normalizeField(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined;
const value = raw.trim();
return value ? value.slice(0, MAX_MARKER_FIELD_LENGTH) : undefined;
}
/**
* Build a marker from a spawn's details. Pure, so the route can hand it straight to
* the writer and the tests can assert on the shape without touching a disk.
*/
export function buildAgentCaseMarker(input: {
createdBy: string;
createdAt?: Date;
parentSessionId?: string;
parentSessionName?: string;
mode?: string;
owner?: string;
}): AgentCaseMarker {
const marker: AgentCaseMarker = {
version: AGENT_CASE_MARKER_VERSION,
createdAt: (input.createdAt ?? new Date()).toISOString(),
createdBy: normalizeAgentOrigin(input.createdBy) ?? AGENT_ORIGIN_SPAWNED_BY_SESSION,
};
const parentSessionId = normalizeField(input.parentSessionId);
const parentSessionName = normalizeField(input.parentSessionName);
const mode = normalizeField(input.mode);
const owner = normalizeField(input.owner);
if (parentSessionId) marker.parentSessionId = parentSessionId;
if (parentSessionName) marker.parentSessionName = parentSessionName;
if (mode) marker.mode = mode;
if (owner) marker.owner = owner;
return marker;
}
/**
* Parse marker JSON. Returns `null` for anything that is not a well-formed version-1
* marker, including a valid-JSON object of the wrong shape — see the strictness note
* in the file header.
*/
export function parseAgentCaseMarker(raw: string): AgentCaseMarker | null {
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
return null;
}
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
if (record.version !== AGENT_CASE_MARKER_VERSION) return null;
const createdAt = normalizeField(record.createdAt);
const createdBy = normalizeAgentOrigin(record.createdBy);
if (!createdAt || !createdBy || Number.isNaN(Date.parse(createdAt))) return null;
return buildAgentCaseMarker({
createdBy,
createdAt: new Date(createdAt),
parentSessionId: normalizeField(record.parentSessionId),
parentSessionName: normalizeField(record.parentSessionName),
mode: normalizeField(record.mode),
owner: normalizeField(record.owner),
});
}
/**
* Write the marker into `casePath`. Best-effort by design: the marker is metadata for
* a later cleanup, and a failed write must never fail the worker spawn that is the
* point of the request. Returns whether it landed.
*/
export async function writeAgentCaseMarker(casePath: string, marker: AgentCaseMarker): Promise<boolean> {
try {
const body = JSON.stringify({ ...marker, note: MARKER_NOTE }, null, 2);
await writeFile(join(casePath, AGENT_CASE_MARKER_FILE), `${body}\n`, 'utf-8');
return true;
} catch {
return false;
}
}
/** Read the marker out of `casePath`, or `null` if there isn't a valid one. */
export async function readAgentCaseMarker(casePath: string): Promise<AgentCaseMarker | null> {
try {
return parseAgentCaseMarker(await readFile(join(casePath, AGENT_CASE_MARKER_FILE), 'utf-8'));
} catch {
return null;
}
}
+31 -7
View File
@@ -15,6 +15,8 @@ import { existsSync, readFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';
import { homedir } from 'node:os';
import { dataPath } from './config/instance.js';
import { casePath } from './config/cases-dir.js';
import { assertValidBasePath } from './config/base-path.js';
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
import { getSessionManager } from './session-manager.js';
import { getTaskQueue } from './task-queue.js';
@@ -146,7 +148,9 @@ export function resolveCliCasePath(name: string): string {
} catch {
// no registry yet, or unreadable/invalid JSON: fall through to the cases dir
}
return join(homedir(), 'codeman-cases', name);
// Same resolver the server uses, so CODEMAN_CASES_PATH (Docker Compose) moves
// the CLI's idea of a case with it instead of leaving it on the home default.
return casePath(name);
}
/**
@@ -840,6 +844,11 @@ function addWebLaunchOptions(cmd: Command): Command {
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
.option(
'--base-url <path>',
'Sub-path Codeman is mounted under behind a reverse proxy, e.g. /codeman (env: CODEMAN_BASE_URL)',
process.env.CODEMAN_BASE_URL || '/'
)
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
.option(
'--allow-unauthenticated-network',
@@ -856,6 +865,7 @@ function toWebLaunchOptions(options: {
host: string;
port: string;
https?: boolean;
baseUrl?: string;
titleHostname?: string;
allowUnauthenticatedNetwork?: boolean;
multiuser?: boolean;
@@ -865,10 +875,18 @@ function toWebLaunchOptions(options: {
console.error(palette.err(`✗ Invalid port: ${options.port}`));
process.exit(1);
}
let basePath: string;
try {
basePath = assertValidBasePath(options.baseUrl);
} catch (err) {
console.error(palette.err(`✗ ${err instanceof Error ? err.message : String(err)}`));
process.exit(1);
}
return {
host: options.host,
port,
https: !!options.https,
basePath,
titleHostname: options.titleHostname,
allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork,
multiuser: !!options.multiuser,
@@ -958,14 +976,21 @@ webCmd.action(async (options) => {
const https = launch.https;
const titleHostname = options.titleHostname;
const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false;
const basePath = launch.basePath ?? '';
// Single source of truth for subsystems that read it directly (e.g. renderers).
if (basePath) process.env.CODEMAN_BASE_URL = basePath;
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
console.log(palette.info(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
console.log(
palette.info(
`Starting Codeman web interface on ${displayHost}:${port}${basePath ? basePath + '/' : ''}${https ? ' (HTTPS)' : ''}...`
)
);
try {
// The server prints its own "running at" line (it also covers the daemon and
// service launch paths), so this one used to be a duplicate of it.
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork, basePath);
if (https) {
console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit'));
}
@@ -1254,7 +1279,7 @@ program
.action(async (options) => {
const { createRealHost, checkAll } = await import('./utils/dependency-checker.js');
const { renderTable, renderJson, computeExitCode } = await import('./utils/dependency-report.js');
const { DEPENDENCY_REGISTRY, TOOL_CATEGORIES } = await import('./config/dependency-registry.js');
const { dependencyRegistry, TOOL_CATEGORIES } = await import('./config/dependency-registry.js');
if (options.category && !(TOOL_CATEGORIES as readonly string[]).includes(options.category)) {
console.error(`Unknown category "${options.category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`);
@@ -1262,9 +1287,8 @@ program
}
const host = createRealHost();
const registry = options.category
? DEPENDENCY_REGISTRY.filter((t) => t.category === options.category)
: DEPENDENCY_REGISTRY;
const allTools = dependencyRegistry();
const registry = options.category ? allTools.filter((t) => t.category === options.category) : allTools;
const results = checkAll(registry, host);
if (options.json) {
+393
View File
@@ -0,0 +1,393 @@
/**
* @fileoverview Scan `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-*.jsonl` for Past
* Sessions rows, the codex analog of what `scanOmpSessionsHistory()`
* (omp-transcript.ts) does for omp and `scanProjectDir()` (session-routes.ts)
* does for Claude's own `~/.claude/projects` transcripts.
*
* Without this a codex conversation is invisible to Codeman the moment its
* session record goes away, even though codex itself never forgot it: the
* unified list is built from `~/.claude/projects` plus omp's own store, and
* codex writes to neither. A user who wanted to pick a codex thread back up had
* to find its id by hand and pass `codexConfig.resumeSessionId` to the API.
*
* ## Why this reads windows rather than whole files
*
* An omp session file is the conversation only, so its scanner reads each file
* whole. A codex rollout is not comparable: it carries every reasoning block and
* every tool call, and its `session_meta` line alone embeds the full base
* instructions. Measured on a real store of 519 rollouts, the median file is
* 407 KiB, the 90th percentile 1.3 MiB and the largest 25 MiB, for 381 MiB in
* total. So this reads a head window for the identity and the opening prompt,
* and a tail window for the most recent one.
*
* The head budget is 128 KiB because `session_meta` runs to roughly 19 KiB and
* the first real user message lands near 69 KiB behind it, both measured on
* codex 0.152.1.
*
* ## Where the prompt text comes from
*
* Codex has emitted user input under three shapes, and this reads all of them,
* preferring the ones that carry real input only:
*
* - `event_msg` / `item_completed` with an `item.type` of `UserMessage`, which
* is what codex 0.152.1 writes.
* - `event_msg` / `user_message`, which older versions wrote.
* - `response_item` rows with `role: 'user'`, the last resort. These mix real
* input with injected context (AGENTS.md, environment context, compaction
* summaries), so they are read only when neither shape above appears, and
* the obvious injections are dropped.
*
* @module codex-transcript
*/
import { open, readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { LRUMap } from './utils/lru-map.js';
/** Covers `session_meta` (~19 KiB) plus the first user message (~69 KiB behind it). */
const HEAD_BYTES = 131072;
/** Enough to hold the last few turns' worth of lines without re-reading the file. */
const TAIL_BYTES = 65536;
/**
* Newest rollouts to REPORT. Counted in emitted rows, not files scanned: the
* store is mostly sub-agent threads this never returns, so capping files first
* would spend the budget on rows nobody sees.
*/
const MAX_ROLLOUTS = 400;
/**
* How many emitted rows also get a tail read for `lastPrompt`. The head read is
* cached (see below) but the tail cannot be, because appending to a rollout is
* exactly what changes it, so this is the one genuinely per-request cost and it
* stays bounded. Counted in emitted rows for the same reason as above — against
* file index a store of sub-agent threads spends the whole budget before the
* first row that needed it.
*/
const MAX_TAIL_READS = 100;
/** Directory nesting under `sessions/` is year/month/day; stop well past that. */
const MAX_WALK_DEPTH = 5;
/** A rollout shorter than this cannot hold a complete `session_meta` line. */
const MIN_ROLLOUT_BYTES = 100;
export interface CodexHistorySession {
/** The rollout's own thread id — the token `codex resume <id>` expects. */
sessionId: string;
/**
* `session_meta.originator`, which codex stamps from
* CODEX_INTERNAL_ORIGINATOR_OVERRIDE — `codeman_<sessionId>` for every pane
* Codeman spawns. The only link between a FRESH codex pane and the rollout it
* is writing, since such a pane knows no thread id of its own.
*/
originator?: string;
workingDir: string;
sizeBytes: number;
/** ISO timestamp, from the file's own mtime. */
lastModified: string;
firstPrompt?: string;
lastPrompt?: string;
}
/** The half of a rollout that never changes once codex has written it. */
interface RolloutIdentity {
threadId?: string;
cwd?: string;
/** `'subagent'` marks a thread codex spawned for itself. */
threadSource?: string;
/** `codeman_<sessionId>` for a pane Codeman spawned; codex's own default otherwise. */
originator?: string;
firstPrompt?: string;
}
/**
* `session_meta` is written once and never rewritten — the same fact
* `readCodexRolloutMetaCached()` in session-routes.ts relies on — so a path's
* identity is cached, and a rescan costs a `stat` per file plus head reads for
* rollouts this process has not seen before.
*
* ⚠️ The first user message is NOT written up front: codex writes it when the
* user submits. Caching before then pins `firstPrompt: undefined` for the life
* of the process, and every scan of the home screen, the command palette and the
* search-index refresh can land in that window — so the row reads as having no
* prompt until a restart. `shouldCacheIdentity()` is the guard.
*
* Bounded, unlike a plain Map: this process runs for days and every sub-agent
* rollout adds an entry. Same reason and same size as `codexRolloutMetaCache`.
*/
const identityCache = new LRUMap<string, RolloutIdentity>({ maxSize: 4096 });
/**
* Is this identity settled enough to keep?
*
* A known `firstPrompt` settles it. So does a head read that FILLED its window,
* which means the prompt is genuinely not in the first `HEAD_BYTES` rather than
* not written yet. A short file with no prompt is the ambiguous case — codex is
* still to write one — so that one is re-read next scan.
*/
function shouldCacheIdentity(identity: RolloutIdentity, fileSize: number): boolean {
if (!identity.threadId) return false;
return identity.firstPrompt !== undefined || fileSize >= HEAD_BYTES;
}
function codexSessionsRoot(): string {
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
return join(home, 'sessions');
}
/** Read at most `bytes` from the front of a file. Returns '' when unreadable. */
async function readHead(path: string, bytes: number): Promise<string> {
const fh = await open(path, 'r').catch(() => null);
if (!fh) return '';
try {
const buf = Buffer.alloc(bytes);
const { bytesRead } = await fh.read(buf, 0, bytes, 0);
return buf.subarray(0, bytesRead).toString('utf-8');
} catch {
return '';
} finally {
await fh.close().catch(() => {});
}
}
/**
* Read at most `bytes` from the end of a file, dropping the leading partial
* line so every line handed back parses.
*/
async function readTail(path: string, size: number, bytes: number): Promise<string> {
const fh = await open(path, 'r').catch(() => null);
if (!fh) return '';
try {
const want = Math.min(bytes, size);
const buf = Buffer.alloc(want);
const { bytesRead } = await fh.read(buf, 0, want, size - want);
const text = buf.subarray(0, bytesRead).toString('utf-8');
if (want >= size) return text; // whole file, nothing was cut
const nl = text.indexOf('\n');
return nl === -1 ? '' : text.slice(nl + 1);
} catch {
return '';
} finally {
await fh.close().catch(() => {});
}
}
/** Flatten codex's message content, which is a string or an array of text blocks. */
function contentText(content: unknown): string {
if (typeof content === 'string') return content.trim();
if (!Array.isArray(content)) return '';
return content
.filter(
(b): b is { text: string } => !!b && typeof b === 'object' && typeof (b as { text?: unknown }).text === 'string'
)
.map((b) => b.text)
.join('\n')
.trim();
}
/** One line's user-prompt text, whichever of the three shapes it is. */
function userPromptFromLine(entry: {
type?: string;
payload?: {
type?: string;
role?: string;
content?: unknown;
message?: unknown;
item?: { type?: string; content?: unknown };
};
}): { text: string; injectionProne: boolean } | null {
const p = entry.payload;
if (!p) return null;
if (entry.type === 'event_msg' && p.type === 'item_completed' && p.item?.type === 'UserMessage') {
const text = contentText(p.item.content);
return text ? { text, injectionProne: false } : null;
}
if (entry.type === 'event_msg' && p.type === 'user_message') {
const text = typeof p.message === 'string' ? p.message.trim() : contentText(p.message);
return text ? { text, injectionProne: false } : null;
}
if (entry.type === 'response_item' && p.role === 'user') {
const text = contentText(p.content);
return text ? { text, injectionProne: true } : null;
}
return null;
}
/**
* Injected context rather than something the user typed. Codex prepends the
* repository's AGENTS.md and wraps environment context in a tag, and both arrive
* as `response_item` user rows.
*/
function isInjectedContext(text: string): boolean {
return text.startsWith('#') || text.startsWith('<');
}
/** Collapse to one line and cap, so a row carries a title rather than an essay. */
function asPreview(text: string): string {
const flat = text.replace(/\s+/g, ' ').trim();
return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat;
}
/** Parse a head window into the facts about a rollout that never change. */
function parseIdentity(head: string): RolloutIdentity {
const out: RolloutIdentity = {};
let fallback: string | undefined;
for (const line of head.split('\n')) {
if (!line) continue;
let entry: {
type?: string;
payload?: {
id?: string;
session_id?: string;
cwd?: string;
thread_source?: string;
originator?: string;
type?: string;
role?: string;
content?: unknown;
message?: unknown;
item?: { type?: string; content?: unknown };
};
};
try {
entry = JSON.parse(line);
} catch {
continue; // truncated tail of the window, or a malformed line
}
const p = entry.payload;
if (entry.type === 'session_meta' && p) {
out.threadId ??= p.id || p.session_id;
out.cwd ??= p.cwd;
out.threadSource ??= p.thread_source;
out.originator ??= p.originator;
} else if (entry.type === 'turn_context' && p) {
out.cwd ??= p.cwd;
}
if (out.firstPrompt) continue;
const prompt = userPromptFromLine(entry);
if (!prompt) continue;
if (!prompt.injectionProne) {
out.firstPrompt = asPreview(prompt.text);
} else if (!fallback && !isInjectedContext(prompt.text)) {
fallback = asPreview(prompt.text);
}
}
out.firstPrompt ??= fallback;
return out;
}
/** The most recent user prompt in a tail window, or undefined. */
function parseLastPrompt(tail: string): string | undefined {
let best: string | undefined;
let fallback: string | undefined;
for (const line of tail.split('\n')) {
if (!line) continue;
try {
const prompt = userPromptFromLine(JSON.parse(line));
if (!prompt) continue;
if (!prompt.injectionProne) best = asPreview(prompt.text);
else if (!isInjectedContext(prompt.text)) fallback = asPreview(prompt.text);
} catch {
// Malformed line — keep scanning.
}
}
return best ?? fallback;
}
/** Every rollout file under `sessions/`, newest first. */
async function listRollouts(root: string): Promise<Array<{ path: string; mtimeMs: number; size: number }>> {
const files: Array<{ path: string; mtimeMs: number; size: number }> = [];
const walk = async (dir: string, depth: number): Promise<void> => {
if (depth > MAX_WALK_DEPTH) return;
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
if (!entries) return;
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
await walk(full, depth + 1);
continue;
}
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
const st = await stat(full).catch(() => null);
if (!st || st.size < MIN_ROLLOUT_BYTES) continue;
files.push({ path: full, mtimeMs: st.mtimeMs, size: st.size });
}
};
await walk(root, 0);
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
return files;
}
/**
* Codex conversations on this host, newest first, for the unified session list.
*
* Sub-agent threads are left out: codex spawns them for itself, they are not
* something a person picks back up, and on a real store they outnumber the
* threads that are.
*/
export async function scanCodexSessionsHistory(): Promise<CodexHistorySession[]> {
const files = await listRollouts(codexSessionsRoot());
const out: CodexHistorySession[] = [];
for (const file of files) {
if (out.length >= MAX_ROLLOUTS) break;
let identity = identityCache.get(file.path);
if (!identity) {
identity = parseIdentity(await readHead(file.path, HEAD_BYTES));
if (shouldCacheIdentity(identity, file.size)) identityCache.set(file.path, identity);
}
if (!identity.threadId || identity.threadSource === 'subagent') continue;
// A row with no directory has nowhere to resume INTO, and emitting an empty
// one makes a click post `workingDir: ''`. omp drops such a row; so does this.
if (!identity.cwd) continue;
const lastPrompt =
out.length < MAX_TAIL_READS ? parseLastPrompt(await readTail(file.path, file.size, TAIL_BYTES)) : undefined;
out.push({
sessionId: identity.threadId,
originator: identity.originator,
workingDir: identity.cwd,
sizeBytes: file.size,
lastModified: new Date(file.mtimeMs).toISOString(),
firstPrompt: identity.firstPrompt,
lastPrompt: lastPrompt ?? identity.firstPrompt,
});
}
return out;
}
/**
* Which codex thread each Codeman-spawned pane is writing, keyed by Codeman
* session id.
*
* Codeman spawns every codex pane with
* CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, and codex stamps that
* into `session_meta.originator`. That is the ONLY link between a fresh codex
* pane and the rollout it is writing: such a pane knows no thread id of its own,
* so it cannot be folded into its own Past-Sessions row from its own side.
*
* Newest wins. `/new` typed inside the codex TUI leaves several rollouts sharing
* one originator, and the pane is on the most recent — so this expects `rows`
* newest-first, as `scanCodexSessionsHistory()` returns them.
*/
export function codexThreadBySessionId(rows: CodexHistorySession[]): Map<string, string> {
const out = new Map<string, string>();
for (const row of rows) {
const owner = /^codeman_(.+)$/.exec(row.originator ?? '')?.[1];
if (owner && !out.has(owner)) out.set(owner, row.sessionId);
}
return out;
}
/** Test seam: drop the per-path identity cache. */
export function __clearCodexIdentityCache(): void {
identityCache.clear();
}
+101
View File
@@ -0,0 +1,101 @@
/**
* @fileoverview Reverse-proxy base-path support — the single source of truth for
* the URL prefix Codeman is mounted under.
*
* When Codeman runs behind a reverse proxy at a sub-path (e.g. `/codeman/`), the
* proxy forwards the FULL request path INCLUDING that prefix (it does not strip
* it). Every URL the server emits to the browser (the HTML shell, redirects,
* the manifest/service-worker) and every URL the browser builds (fetch/SSE/WS)
* must therefore carry the prefix too.
*
* This module normalizes the operator-supplied value (`--base-url` / the
* `CODEMAN_BASE_URL` env var) into ONE canonical form used everywhere:
* - `''` — mounted at the origin root (the default, `/`)
* - `/foo` — mounted at a sub-path (leading slash, NO trailing slash)
*
* Keeping the normalized form free of a trailing slash means `basePath + '/api/x'`
* and `basePath + '/'` both compose cleanly, and `''` degrades to the historical
* root behavior with no special-casing at the call sites.
*
* @module config/base-path
*/
/**
* A normalized base path is either empty (root) or one-or-more `/segment`
* groups, where a segment is a conservative, proxy-safe subset of path
* characters. This deliberately excludes anything that could change routing
* meaning (`?`, `#`, `:`, whitespace, `%`) so the prefix is a plain path.
*/
const VALID_BASE_PATH = /^(?:\/[A-Za-z0-9._~-]+)+$/;
/**
* Normalize an operator-supplied base path into the canonical form.
*
* Accepts loose input (`codeman`, `/codeman`, `/codeman/`, `//codeman//`) and
* returns `''` for root or `/codeman` otherwise. Does NOT validate the character
* set — call {@link assertValidBasePath} (or {@link isValidBasePath}) for that.
*/
export function normalizeBasePath(input: string | undefined | null): string {
if (input === undefined || input === null) return '';
let p = String(input).trim();
if (p === '' || p === '/') return '';
if (!p.startsWith('/')) p = '/' + p;
p = p.replace(/\/{2,}/g, '/'); // collapse duplicate slashes
p = p.replace(/\/+$/, ''); // drop trailing slash(es)
return p;
}
/** True if `normalized` is a legal canonical base path (`''` or `/seg[/seg...]`). */
export function isValidBasePath(normalized: string): boolean {
return normalized === '' || VALID_BASE_PATH.test(normalized);
}
/**
* Normalize AND validate, throwing a human-readable error on bad input. Used by
* the CLI so a typo (`--base-url /a b`, `--base-url ?x`) fails loudly at startup
* instead of silently producing broken URLs.
*/
export function assertValidBasePath(input: string | undefined | null): string {
const normalized = normalizeBasePath(input);
if (!isValidBasePath(normalized)) {
throw new Error(
`Invalid --base-url ${JSON.stringify(input)}: use a plain path like "/codeman" ` +
`(letters, digits, and ._~- in each segment).`
);
}
return normalized;
}
/**
* Join the base path onto a root-absolute application path (`/api/x` → `/base/api/x`).
*
* Leaves alone anything that is not a root-absolute app path: empty strings,
* protocol-relative (`//host`) and absolute URLs (`http://`, `ws://`, `data:`),
* fragments/queries, and paths already carrying the prefix. This is the one
* function the whole codebase routes URL construction through.
*/
export function joinBasePath(basePath: string, path: string): string {
if (!basePath) return path;
if (typeof path !== 'string' || path.length === 0) return path;
if (!path.startsWith('/')) return path; // relative / fragment / query — resolved against <base>
if (path.startsWith('//')) return path; // protocol-relative
if (path === basePath || path.startsWith(basePath + '/') || path.startsWith(basePath + '?')) {
return path; // already prefixed
}
return basePath + path;
}
/**
* Strip the base path off an INCOMING request URL so internal routing stays
* prefix-agnostic. Requests that arrive WITHOUT the prefix (health checks,
* hooks, the docker bridge — all of which hit the raw port, bypassing the proxy)
* are returned unchanged, so the server answers at both `/api/x` and
* `/base/api/x`.
*/
export function stripBasePath(basePath: string, url: string): string {
if (!basePath) return url;
if (url === basePath) return '/';
if (url.startsWith(basePath + '/')) return url.slice(basePath.length);
if (url.startsWith(basePath + '?')) return '/' + url.slice(basePath.length);
return url;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* @fileoverview Where case (project) folders live.
*
* Deliberately NOT instance-scoped, unlike `dataPath()`: `~/codeman-cases` is
* shared by every Codeman on the machine, the same way `~/codeman-users/<u>`
* user spaces are, so a beta instance sees the same projects as prod.
*
* `CODEMAN_CASES_PATH` overrides the location. Docker Compose deployments set
* it to a host-absolute bind mount so a Docker case's workspace resolves to the
* SAME absolute path inside Codeman and on the host daemon that mounts it.
*
* ⚠️ **One resolver, every caller.** This started life as three hardcoded
* `join(homedir(), 'codeman-cases')` copies. When only the web server's copy
* learned the override, `codeman skill install --case <name>` still looked in
* the home default and reported "Case not found" on exactly the deployment the
* override exists for. A new cases-dir consumer imports this; it does not
* rebuild the path.
*
* (`state-store.ts` keeps its own literal on purpose: that one migrates the
* historical `~/claudeman-cases` directory to `~/codeman-cases` by name, and is
* about the old default location rather than the active one.)
*
* @module config/cases-dir
*/
import { homedir } from 'node:os';
import { join } from 'node:path';
/** Absolute path to the shared cases directory. */
export function getCasesDir(): string {
return process.env.CODEMAN_CASES_PATH || join(homedir(), 'codeman-cases');
}
/** Absolute path to one case folder inside it. */
export function casePath(name: string): string {
return join(getCasesDir(), name);
}
+190
View File
@@ -0,0 +1,190 @@
/**
* @fileoverview The argv rendering engine — turns a `CliLaunch` spec plus a set of resolved
* parameter values into the shell command string that goes into `bash -c "..."`.
*
* SECURITY MODEL (read before touching this file):
*
* 1. Config contains no shell text. There is no `command: "..."` field anywhere in the
* schema. An entry declares a sequence of typed tokens (`ArgSpec`); this module is the
* ONLY place that turns them into a string, and it owns every separator itself: a single
* space between tokens, and ` || ` between fallback variants. Neither can originate from
* config, because config has no field that could hold either.
* 2. Every literal (`lit`, `flag`, `value`) is validated against `SAFE_BARE_TOKEN` — no
* space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens, braces, newline or
* backslash — at LOAD time (see schema.ts), so a bad literal fails registry validation
* rather than reaching this renderer.
* 3. Every `valueFrom` resolves through a declared `ParamSpec`, whose `token` variant names
* a PATTERN rather than accepting one — see patterns.ts. A value that fails its pattern
* causes the WHOLE ArgSpec to be dropped, exactly like the hand-written builders this
* replaces (an invalid `--model` value silently omits `--model`, it does not substitute
* something else).
* 4. Escaping and validation are independent. `renderToken()` always re-checks the resolved
* value against `SAFE_BARE_TOKEN` before emitting it unquoted; anything else is
* single-quote-escaped. So even a value that somehow bypassed pattern validation is still
* quoted, never concatenated raw.
*
* @module config/cli-registry/argv
*/
import type { ArgSpec, CliEntry, CliLaunch, Cond, EngineValue, ParamSpec, QuoteStyle } from './types.js';
import { matchesPattern } from './patterns.js';
import { SAFE_BARE_TOKEN } from './patterns.js';
/** Resolved parameter values, keyed by the name declared in `CliLaunch.params`. */
export type ParamValues = Record<string, string | boolean | undefined>;
/** Values the caller supplies for the reserved engine params. */
export type EngineValues = Partial<Record<EngineValue, string>>;
/**
* POSIX single-quote escaping: end-quote, escaped-literal-quote, restart-quote. Identical in
* shape to the three copies already in the codebase (tmux-manager.ts, remote-hosts.ts,
* docker-hosts.ts) — kept local rather than importing one of them so this module has no
* dependency on the files it is replacing.
*/
function singleQuoteEscape(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function doubleQuoteEscape(value: string): string {
// Escape the characters that are special inside a double-quoted bash string. SAFE_BARE_TOKEN
// already excludes all of them, so in practice this never fires; kept as defense in depth.
return `"${value.replace(/([$`"\\])/g, '\\$1')}"`;
}
/**
* Render a single resolved value per its requested quote style. `auto` (the default) emits
* bare only when the value is provably safe; every other case single-quotes.
*/
function renderToken(value: string, style: QuoteStyle | undefined): string {
const safe = SAFE_BARE_TOKEN.test(value);
switch (style) {
case 'double':
return doubleQuoteEscape(value);
case 'single':
return singleQuoteEscape(value);
case 'bare':
return safe ? value : singleQuoteEscape(value);
case 'auto':
default:
return safe ? value : singleQuoteEscape(value);
}
}
/** Resolve one parameter to a plain string, or undefined if it is unset / invalid. */
function resolveParam(
name: string,
spec: ParamSpec | undefined,
params: ParamValues,
engineValues: EngineValues
): string | undefined {
if (!spec) return undefined;
if (spec.type === 'engine') return engineValues[spec.source];
const raw = params[name];
if (raw === undefined) return spec.type === 'enum' ? spec.default : undefined;
if (spec.type === 'bool') return typeof raw === 'boolean' ? String(raw) : undefined;
if (spec.type === 'enum') {
const s = String(raw);
return spec.values.includes(s) ? s : spec.default;
}
// token
const s = String(raw);
return matchesPattern(spec.pattern, s) ? s : undefined;
}
/** Is the resolved value "set" for the purposes of a `state` condition? */
function isSet(name: string, params: ParamValues, resolved: (n: string) => string | undefined): boolean {
if (name in params) {
const raw = params[name];
if (typeof raw === 'boolean') return true; // a bool param is always "set" once declared
}
return resolved(name) !== undefined;
}
function evalCond(
cond: Cond | undefined,
params: ParamValues,
resolved: (n: string) => string | undefined,
gatesPassed: ReadonlySet<string>
): boolean {
if (!cond) return true;
if ('allOf' in cond) return cond.allOf.every((c) => evalCond(c, params, resolved, gatesPassed));
if ('anyOf' in cond) return cond.anyOf.some((c) => evalCond(c, params, resolved, gatesPassed));
if ('not' in cond) return !evalCond(cond.not, params, resolved, gatesPassed);
if ('capabilityGate' in cond) return gatesPassed.has(cond.capabilityGate);
if ('state' in cond) {
const set = isSet(cond.param, params, resolved);
return cond.state === 'set' ? set : !set;
}
// { param, is }
const raw = params[cond.param];
if (typeof cond.is === 'boolean') return raw === cond.is;
return resolved(cond.param) === cond.is;
}
function renderArg(
spec: ArgSpec,
params: ParamValues,
resolved: (n: string) => string | undefined,
gatesPassed: ReadonlySet<string>
): string | null {
if (!evalCond(spec.when, params, resolved, gatesPassed)) return null;
if ('lit' in spec) return spec.lit;
if ('flag' in spec && !('value' in spec) && !('valueFrom' in spec)) return spec.flag;
if ('flag' in spec && 'value' in spec) return `${spec.flag} ${renderToken(spec.value, spec.quote)}`;
if ('flag' in spec && 'valueFrom' in spec) {
const v = resolved(spec.valueFrom);
return v === undefined ? null : `${spec.flag} ${renderToken(v, spec.quote)}`;
}
// bare positional
const v = resolved((spec as { valueFrom: string }).valueFrom);
return v === undefined ? null : renderToken(v, (spec as { quote?: QuoteStyle }).quote);
}
/**
* Render one CLI's launch command. Returns the full `bash -c` payload — never a shell
* fragment with embedded newlines or unescaped separators, by construction (see file header).
*
* `gatesPassed` — the set of `capabilities.gates` keys whose version requirement is
* currently satisfied. Callers compute this once per spawn (it depends on a version probe),
* never inside the renderer, keeping this function pure and easy to test byte-for-byte.
*/
export function renderLaunch(
launch: CliLaunch,
params: ParamValues,
engineValues: EngineValues,
gatesPassed: ReadonlySet<string> = new Set()
): string {
const cache = new Map<string, string | undefined>();
const resolved = (name: string): string | undefined => {
if (cache.has(name)) return cache.get(name);
const v = resolveParam(name, launch.params[name], params, engineValues);
cache.set(name, v);
return v;
};
const passing = launch.variants.filter((variant) => evalCond(variant.when, params, resolved, gatesPassed));
const chosen = launch.chain === 'fallback' ? passing : passing.slice(0, 1);
const rendered = chosen.map((variant) =>
variant.args
.map((arg) => renderArg(arg, params, resolved, gatesPassed))
.filter((tok): tok is string => tok !== null)
.join(' ')
);
return rendered.join(' || ');
}
/** Convenience: render an entry's launch command straight from a `CliEntry`. */
export function renderCliCommand(
entry: CliEntry,
params: ParamValues,
engineValues: EngineValues,
gatesPassed?: ReadonlySet<string>
): string {
return renderLaunch(entry.launch, params, engineValues, gatesPassed);
}
+61
View File
@@ -0,0 +1,61 @@
/**
* @fileoverview Barrel for the CLI registry module.
* @module config/cli-registry
*/
export type {
ArgSpec,
CliCapabilities,
CliCredStore,
CliDiscovery,
CliEntry,
CliEnv,
CliId,
CliIdentityProbe,
CliLaunch,
CliOverlays,
CliRegistryFile,
CliVariant,
CliVersionProbe,
Cond,
EngineValue,
ParamSpec,
QuoteStyle,
} from './types.js';
export {
matchesPattern,
TOKEN_PATTERNS,
SAFE_BARE_TOKEN,
compileVersionRegex,
MAX_VERSION_OUTPUT,
} from './patterns.js';
export type { TokenPattern } from './patterns.js';
export { renderLaunch, renderCliCommand } from './argv.js';
export type { EngineValues, ParamValues } from './argv.js';
export { CliEntrySchema } from './schema.js';
export type { ValidatedCliEntry } from './schema.js';
export { STOCK_CLIS } from './stock.js';
export {
asCliId,
cliIds,
enabledCliIds,
enabledClis,
getCli,
listClis,
loadCliRegistry,
reloadCliRegistry,
resolveInstallCommandForPlatform,
resolveRegistry,
} from './registry.js';
export type { LoadResult } from './registry.js';
export {
COMPOSER_ANCHOR_KINDS,
isKnownLauncherProfile,
isKnownPredictProfile,
isKnownSetenvProfile,
LAUNCHER_PROFILE_NAMES,
PREDICT_PROFILES,
SETENV_PROFILE_NAMES,
TRANSCRIPT_READER_NAMES,
} from './profiles.js';
export type { LauncherProfileName, SetenvProfileName } from './profiles.js';
+121
View File
@@ -0,0 +1,121 @@
/**
* @fileoverview Named value patterns for the CLI registry's argv engine.
*
* Config entries select a pattern BY NAME; the regexes themselves live here, in code.
* That is deliberate and is the reason a user-editable `clis.json` cannot widen its own
* validation: there is no field anywhere in the schema that accepts a raw regex for a
* shell token, so no entry can supply `.*` (nor a catastrophically backtracking one).
*
* The sole user-supplied regex in the whole registry is `discovery.version.regex`, which
* is applied to `--version` OUTPUT rather than to a shell token, and goes through
* `compileVersionRegex()` below.
*
* Every pattern here is transcribed from the builder it replaces in tmux-manager.ts, so
* the argv engine accepts and rejects exactly the values the hand-written builders did.
*
* @module config/cli-registry/patterns
*/
/** Names a value pattern. Config may only reference these. */
export type TokenPattern =
| 'model'
| 'model-claude'
| 'model-pi'
| 'id'
| 'id-dotted'
| 'uuid'
| 'slug'
| 'path-segment'
| 'tool-list'
| 'config-kv';
/**
* The patterns, each traced to the builder it came from.
*
* ⚠️ These are ALLOWLISTS (`^...$` over a safe character class), never blocklists — with
* one deliberate exception, `tool-list`, which mirrors the existing `--allowedTools`
* sanitizer. That one is a metacharacter REJECTION because tool specs legitimately contain
* `(`, `)`, `*`, `:` and spaces (`Bash(git:*), Read`), so an allowlist of safe words cannot
* express it. Keeping it byte-identical to the original matters more than making it uniform.
*/
const PATTERNS: Record<TokenPattern, RegExp> = {
// buildOpenCodeCommand / buildCodexCommand / buildGeminiCommand / buildAntigravityCommand
model: /^[a-zA-Z0-9._\-/]+$/,
// buildSpawnCommand's claude branch — `[` and `]` for bracketed model aliases
'model-claude': /^[a-zA-Z0-9._\-[\]]+$/,
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
// opencode --session, codex resume
id: /^[a-zA-Z0-9_-]+$/,
// gemini --resume, antigravity --conversation, pi --session
'id-dotted': /^[a-zA-Z0-9._-]+$/,
// claude --resume / --session-id
uuid: /^[a-f0-9-]+$/,
// pi --provider
slug: /^[a-z0-9-]+$/,
// dsh --profile. Deliberately STRICTER than `id-dotted`: a profile name is both
// interpolated into the shell line AND joined into a filesystem path, so it must be a
// single path segment. Requiring a leading alphanumeric is what rules out `.`, `..` and
// dotfile names, which `id-dotted` would happily accept.
'path-segment': /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/,
// codex --config tui.animations=false
'config-kv': /^[A-Za-z0-9._-]+=[A-Za-z0-9._-]+$/,
// Placeholder; `tool-list` is handled by isSafeToolList() below, not by a match.
'tool-list': /^$/,
};
/**
* Shell metacharacters rejected in an `--allowedTools` value. Transcribed verbatim from
* buildClaudePermissionFlags so the accepted set does not move.
*/
const TOOL_LIST_DANGEROUS = /[;&|$`\\{}<>'"[\]\n\r]/;
/** Does `value` satisfy the named pattern? */
export function matchesPattern(pattern: TokenPattern, value: string): boolean {
if (pattern === 'tool-list') return value.length > 0 && !TOOL_LIST_DANGEROUS.test(value);
return PATTERNS[pattern].test(value);
}
/** Every pattern name, for schema validation and error messages. */
export const TOKEN_PATTERNS = Object.keys(PATTERNS) as TokenPattern[];
/**
* Characters a token may contain and still be emitted UNQUOTED into the `bash -c "..."`
* command string. Intentionally narrower than "what bash tolerates": anything outside it
* gets single-quoted, so the classification can only ever err toward more quoting.
*/
export const SAFE_BARE_TOKEN = /^[A-Za-z0-9._:@=+/,-]+$/;
/**
* Longest `--version` output we will run a user-supplied regex over. A version banner is a
* line or two; anything larger is a misconfiguration, and capping the input is what keeps a
* sloppy (not necessarily malicious) regex from becoming a stall.
*/
export const MAX_VERSION_OUTPUT = 200;
/** Longest permitted `discovery.version.regex` source. */
const MAX_VERSION_REGEX_SOURCE = 200;
/**
* Nested quantifiers — `(a+)+`, `(a*)*`, `(a+)*` and friends — the classic catastrophic
* backtracking shape. Rejected outright rather than analysed: this field exists to pull a
* semver out of a banner, and nothing legitimate for that job needs a nested quantifier.
*/
const NESTED_QUANTIFIER = /\([^)]*[+*][^)]*\)\s*[+*{]/;
/**
* Compile a user-supplied version regex, or return null if it is not one we are willing to
* run. Returning null (rather than throwing) lets the caller degrade to "version unknown",
* which every consumer already handles.
*/
export function compileVersionRegex(source: string): RegExp | null {
if (source.length > MAX_VERSION_REGEX_SOURCE) return null;
if (NESTED_QUANTIFIER.test(source)) return null;
try {
// No `g`: a global regex carries lastIndex state across calls, which is a documented
// footgun in this codebase (see utils/regex-patterns.ts).
return new RegExp(source);
} catch {
return null;
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* @fileoverview The NAMES of code profiles a `CliEntry` field may select, and the helpers
* that validate them.
*
* A profile is the escape hatch for behaviour that is genuinely code-shaped and cannot be
* expressed as data — codex's predictive write-through echo, deepseek's profile-launcher
* runnability check, deepseek's status bridge — without letting any of that code branch on
* a CLI's id. A registry field names a profile; the implementation lives beside whatever it
* needs, and looks its name up here.
*
* ⚠️ This module is PURE and must stay that way: names, types and predicates only, no
* imports outside this directory. The implementations pull in resolvers and the status
* shim, which in turn reach back into the registry, so holding them here would close an
* import cycle (profiles → deepseek-cli-resolver → cli-resolver → registry → schema →
* profiles). Keeping the names here and the implementations at their call sites is what
* lets `schema.ts` validate a profile name at LOAD time — a custom entry naming a profile
* this build does not implement fails loudly instead of silently failing closed later.
*
* The rule all of this enforces: `test/cli-registry-no-id-branching.test.ts` fails on any
* `mode === '<stock id>'` comparison outside `stock.ts`, so a NEW behavioural special case
* must be added here, named, and referenced from a registry field — never inlined as an id
* check at the call site.
*
* ⚠️ A profile is a LAST resort, not a convenience. Reach for one only when the behaviour
* needs to run code (a side effect, a computed value, a probe); anything that is a list, a
* flag, or a string belongs in the entry as data, where a custom CLI can also use it.
*
* @module config/cli-registry/profiles
*/
/**
* Predictive local-echo profiles, selected via `capabilities.echo.predictProfile`.
*
* Implementation: packages/xterm-zerolag-input/src/predictive-echo-addon.ts.
*
* ⚠️ Unlike the other two registries, an unknown name here degrades to the 'buffer' policy
* rather than failing. Echo is a comfort feature — a worse-but-working overlay beats a
* refused session — which is why `predictProfile` alone is not schema-validated below.
*/
export const PREDICT_PROFILES: Record<string, true> = {
codex: true,
};
/**
* Launcher profiles, selected via `discovery.launcherProfile`.
*
* For a CLI whose binary launches some further target, and so cannot answer two questions
* from the binary alone: is it RUNNABLE (stricter than "is the binary on disk?"), and what
* is the DEFAULT target when the caller names none? A CLI naming no profile is runnable
* exactly when its binary resolves, and has no default target.
*
* Implementation: `src/utils/cli-launcher.ts`.
*/
export const LAUNCHER_PROFILE_NAMES = [
// `dsh` is a launcher over $DSH_HOME/profiles/<name>, and the profiles DeepSeek itself
// ships (web, headless) cannot drive a terminal pane. Binary AND a pane-capable profile.
'deepseek-profile',
] as const;
/**
* Extra `tmux setenv` work, selected via `env.setenvProfile`.
*
* Implementation: `src/tmux-manager.ts`, which already owns every setenv call.
*
* ⚠️ Anything that is merely "forward this name from the server's own env" belongs in
* `env.tmuxSetenvKeys` as data and must NOT be given a profile.
*/
export const SETENV_PROFILE_NAMES = [
// DeepSeek's terminal front door reports idle/working/blocked to a supervisor over the
// generic env-gated Herdr contract; this makes Codeman that supervisor. It needs a
// profile rather than key names because it writes an executable shim to disk and then
// exports that shim's path along with the session's own pane id.
'deepseek-status-bridge',
] as const;
export type LauncherProfileName = (typeof LAUNCHER_PROFILE_NAMES)[number];
export type SetenvProfileName = (typeof SETENV_PROFILE_NAMES)[number];
/**
* Transcript readers, selected via `capabilities.transcript`. Unlike the profile registries
* above this one is closed over the schema enum itself rather than an open string, since
* transcript format is a small, genuinely fixed set — see CliCapabilities['transcript'].
*/
export const TRANSCRIPT_READER_NAMES = ['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'none'] as const;
/** Composer-row finders, selected via `capabilities.echo.anchor.kind`. Also schema-closed. */
export const COMPOSER_ANCHOR_KINDS = ['glyph', 'cursor', 'none'] as const;
/** True when `name` is a predictive-echo profile this build actually implements. */
export function isKnownPredictProfile(name: string | undefined): boolean {
return name !== undefined && Object.prototype.hasOwnProperty.call(PREDICT_PROFILES, name);
}
export function isKnownLauncherProfile(name: string): name is LauncherProfileName {
return (LAUNCHER_PROFILE_NAMES as readonly string[]).includes(name);
}
export function isKnownSetenvProfile(name: string): name is SetenvProfileName {
return (SETENV_PROFILE_NAMES as readonly string[]).includes(name);
}
+235
View File
@@ -0,0 +1,235 @@
/**
* @fileoverview Loads, merges and re-validates the CLI registry.
*
* `~/.codeman/clis.json` holds OVERRIDES and CUSTOM entries only — never a full copy of the
* stock catalog — so a shipped fix to a stock definition actually reaches an existing
* install, and the file stays small enough to hand-edit.
*
* Resolution: start from `STOCK_CLIS` → deep-merge each override by id (objects merge
* key-wise, arrays replace wholesale) → validate every resulting entry. A stock entry that
* fails validation after merge falls back to its pristine stock definition (a fat-fingered
* override cannot brick a shipped CLI); a custom entry that fails is dropped with a warning
* rather than failing the whole load. Stock entries are always emitted, so `shell` and
* `claude` can be disabled but can never go missing — large parts of the app assume at
* minimum that a shell fallback exists.
*
* ⚠️ READ-ONLY. Nothing in this module writes, creates or migrates the file. That is a
* deliberate property, not a missing feature: there is no settings UI and no write API yet,
* so there is nothing to persist, and it means importing the registry — which
* `src/web/schemas.ts` does, transitively, just to validate a request — performs no
* filesystem writes. A `seededStockIds` ratchet belongs with the write API that needs it.
* The one exception is the quarantine RENAME of a file that fails to parse (see
* `readRegistryFile`), which happens on first use rather than at import.
*
* ⚠️ The file must be mode 0600. `isUnsafePermissions` refuses ANY group/world bit, read
* bits included, so a file created with a normal umask (0644) is ignored. Every reason the
* file was ignored, or an entry in it dropped, is logged ONCE on first load: the warnings
* used to be returned to a caller that nobody wired up, so a normally-created file was
* ignored with no feedback anywhere (found reviewing #347).
*
* @module config/cli-registry/registry
*/
import { existsSync, readFileSync, renameSync, statSync } from 'node:fs';
import { dataPath } from '../instance.js';
import type { CliEntry, CliId, CliRegistryFile } from './types.js';
import { CliEntrySchema } from './schema.js';
import { STOCK_CLIS } from './stock.js';
/** Construct a validated CliId. Throws if `raw` is not a well-formed id — call at API boundaries. */
export function asCliId(raw: string): CliId {
if (!/^[a-z][a-z0-9-]{0,23}$/.test(raw)) {
throw new Error(`invalid CLI id: ${JSON.stringify(raw)}`);
}
return raw as CliId;
}
function filePath(): string {
return dataPath('clis.json');
}
/**
* Keys that must never be merged out of a hand-editable JSON file.
*
* `JSON.parse` produces `__proto__` as an ORDINARY own property, but `result[key] = …` on a
* plain object walks the setter chain and would set the merged object's PROTOTYPE instead.
* Not exploitable today — every merged entry is spread into `{ ...merged, id, stock }` and
* then Zod-parsed before anything reads it, which drops the effect — but "not exploitable
* because of what a caller happens to do afterwards" is a property that quietly stops
* holding. A `continue` in the loop that reads the file is the cheap end of that trade.
*/
const UNMERGEABLE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/** Plain-object deep merge: nested objects merge key-wise, arrays and primitives replace. */
function deepMerge<T>(base: T, override: unknown): T {
if (override === null || typeof override !== 'object' || Array.isArray(override)) {
return (override === undefined ? base : (override as T)) ?? base;
}
if (base === null || typeof base !== 'object' || Array.isArray(base)) {
return override as T;
}
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
for (const [key, value] of Object.entries(override as Record<string, unknown>)) {
if (UNMERGEABLE_KEYS.has(key)) continue;
result[key] = deepMerge((base as Record<string, unknown>)[key], value);
}
return result as T;
}
export interface LoadResult {
entries: CliEntry[];
warnings: string[];
}
/**
* Refuse a registry file with any group/world permission bit — same posture as the ssh-key
* discipline, so 0600 is the only accepted mode. This file selects the binaries Codeman
* spawns, so a writable one is a way to redirect every session.
*
* POSIX only: Windows has no meaningful group/world bits on NTFS (Node reports every file
* as mode 0o666 there regardless of its actual ACL), so this check would flag every file on
* Windows and silently ignore all user config. `win32` relies on NTFS ACLs instead, which
* this check cannot see and does not attempt to.
*/
function isUnsafePermissions(path: string): boolean {
if (process.platform === 'win32') return false;
try {
const mode = statSync(path).mode & 0o777;
return (mode & 0o077) !== 0;
} catch {
return false;
}
}
function readRegistryFile(path: string, warnings: string[]): CliRegistryFile | null {
if (!existsSync(path)) return null;
if (isUnsafePermissions(path)) {
warnings.push(
`${path} must be mode 0600 (no group/world permission bits; run \`chmod 600 ${path}\`); ignoring it and falling back to stock CLIs.`
);
return null;
}
let raw: string;
try {
raw = readFileSync(path, 'utf-8');
} catch (err) {
warnings.push(`Failed to read ${path}: ${(err as Error).message}. Falling back to stock CLIs.`);
return null;
}
try {
const parsed = JSON.parse(raw) as CliRegistryFile;
if (typeof parsed !== 'object' || parsed === null || typeof parsed.clis !== 'object') {
throw new Error('missing "clis" object');
}
return parsed;
} catch (err) {
// QUARANTINE, never overwrite: the file is hand-editable, so a syntax error is far more
// likely to be a half-finished edit than junk. Renaming keeps the user's work.
const quarantined = `${path}.invalid-${Date.now()}`;
try {
renameSync(path, quarantined);
warnings.push(`${path} was not valid JSON (${(err as Error).message}); moved to ${quarantined}.`);
} catch {
warnings.push(
`${path} was not valid JSON (${(err as Error).message}); left in place, falling back to stock CLIs.`
);
}
return null;
}
}
/**
* Merge the stock catalog with a (possibly absent) registry file. PURE — no IO, which is
* what lets the load tests drive every merge case directly.
*/
export function resolveRegistry(stock: CliEntry[], file: CliRegistryFile | null, warnings: string[]): LoadResult {
const stockById = new Map(stock.map((e) => [e.id as string, e]));
const overrides = file?.clis ?? {};
const entries: CliEntry[] = [];
for (const stockEntry of stock) {
const id = stockEntry.id as string;
const override = overrides[id];
const merged = override ? deepMerge(stockEntry, override) : stockEntry;
// `stock: true` is forced here rather than read from the merged object, so an override
// can never flip a custom entry's provenance or vice versa.
const parsed = CliEntrySchema.safeParse({ ...merged, id, stock: true });
if (parsed.success) {
entries.push(parsed.data as CliEntry);
} else {
warnings.push(
`Override for stock CLI "${id}" failed validation; using the shipped definition. ${parsed.error.message}`
);
entries.push(stockEntry);
}
}
for (const [id, raw] of Object.entries(overrides)) {
if (stockById.has(id)) continue; // already merged above
// Same forcing in the other direction: a custom entry claiming `stock: true` cannot
// shadow or impersonate a shipped one.
const parsed = CliEntrySchema.safeParse({ ...(raw as object), id, stock: false });
if (parsed.success) {
entries.push(parsed.data as CliEntry);
} else {
warnings.push(`Custom CLI "${id}" failed validation and was dropped. ${parsed.error.message}`);
}
}
entries.sort((a, b) => a.order - b.order);
return { entries, warnings };
}
let cache: LoadResult | null = null;
/**
* Load the effective registry (stock + user overrides). Memoized for the process lifetime;
* `reloadCliRegistry()` invalidates.
*/
export function loadCliRegistry(): LoadResult {
if (cache) return cache;
const warnings: string[] = [];
const existing = readRegistryFile(filePath(), warnings);
cache = resolveRegistry(STOCK_CLIS, existing, warnings);
// Once per process (the result is memoized): silence here is what made a 0644 file look
// like "the override feature does nothing".
for (const warning of warnings) console.warn(`[cli-registry] ${warning}`);
return cache;
}
/** Drop the memoized registry so the next `loadCliRegistry()` re-reads the file. */
export function reloadCliRegistry(): void {
cache = null;
}
export function listClis(): CliEntry[] {
return loadCliRegistry().entries;
}
export function enabledClis(): CliEntry[] {
return listClis().filter((e) => e.enabled);
}
export function getCli(id: string): CliEntry | undefined {
return listClis().find((e) => (e.id as string) === id);
}
export function cliIds(): string[] {
return listClis().map((e) => e.id as string);
}
/** Every enabled entry's id, in registry order. */
export function enabledCliIds(): string[] {
return enabledClis().map((e) => e.id as string);
}
/**
* Resolve the install command for the current platform, falling back to the linux one (the
* common case for a `curl | bash` or `npm install -g` line) and then to whatever is
* declared. Display text only — never executed. See CliDiscovery.install.command.
*/
export function resolveInstallCommandForPlatform(entry: CliEntry): string | undefined {
const { command } = entry.discovery.install;
const platform = process.platform as 'linux' | 'darwin' | 'win32';
return command[platform] ?? command.linux ?? Object.values(command)[0];
}
+445
View File
@@ -0,0 +1,445 @@
/**
* @fileoverview Zod validation for CLI registry entries.
*
* Every object here is `.strict()`: an unknown key is a hard validation error, not a
* silently-ignored one. That matters for a security-relevant schema — a typo in a field name
* must never degrade to "field absent, so the permissive default applies".
*
* The load-bearing rule enforced here is `SHELL_TOKEN`: it is what makes it impossible for a
* `clis.json` entry to smuggle shell metacharacters into the eventual `bash -c "..."` string
* (see argv.ts's file header for the full model).
*
* @module config/cli-registry/schema
*/
import { z } from 'zod';
import { compileVersionRegex, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z
.string()
.regex(/^[a-z][a-z0-9-]{0,23}$/, 'id must be lowercase, start with a letter, and be at most 24 chars');
/** An env var name. */
const envName = z
.string()
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
.max(64);
/**
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
* fixed values) must satisfy this — see argv.ts's file header.
*/
const shellToken = z
.string()
.min(1)
.max(256)
.regex(/^[A-Za-z0-9._:@=+/,-]+$/, 'must be a plain word with no shell metacharacters');
const flagToken = z.string().regex(/^--?[A-Za-z0-9][A-Za-z0-9-]*$/, 'must look like -x or --long-flag');
const quoteStyle = z.enum(['auto', 'bare', 'double', 'single']);
const condSchema: z.ZodType<import('./types.js').Cond> = z.lazy(() =>
z.union([
z.object({ param: z.string(), is: z.union([z.string(), z.boolean()]) }).strict(),
z.object({ param: z.string(), state: z.enum(['set', 'unset']) }).strict(),
z.object({ allOf: z.array(condSchema).min(1).max(8) }).strict(),
z.object({ anyOf: z.array(condSchema).min(1).max(8) }).strict(),
z.object({ not: condSchema }).strict(),
z.object({ capabilityGate: z.string() }).strict(),
])
);
const paramSpecSchema = z.union([
z
.object({ type: z.literal('enum'), values: z.array(z.string()).min(1).max(16), default: z.string().optional() })
.strict(),
z.object({ type: z.literal('bool') }).strict(),
z.object({ type: z.literal('token'), pattern: z.enum(TOKEN_PATTERNS as [string, ...string[]]) }).strict(),
z
.object({
type: z.literal('engine'),
source: z.enum([
'sessionId',
'sessionName',
'muxName',
'effortLevel',
'effortSettingsJson',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
})
.strict(),
]);
const argSpecSchema = z.union([
z.object({ lit: shellToken, when: condSchema.optional() }).strict(),
z.object({ flag: flagToken, when: condSchema.optional() }).strict(),
z.object({ flag: flagToken, value: shellToken, quote: quoteStyle.optional(), when: condSchema.optional() }).strict(),
z
.object({ flag: flagToken, valueFrom: z.string(), quote: quoteStyle.optional(), when: condSchema.optional() })
.strict(),
z.object({ valueFrom: z.string(), quote: quoteStyle.optional(), when: condSchema.optional() }).strict(),
]);
const variantSchema = z
.object({
id: z.string().min(1).max(40),
when: condSchema.optional(),
// min(0): the `shell` entry declares a variant with no args — tmux-manager resolves the
// real login shell in code, since it varies per remote user's /etc/passwd entry.
args: z.array(argSpecSchema).max(32),
})
.strict();
const launchSchema = z
.object({
params: z.record(z.string(), paramSpecSchema),
chain: z.enum(['first', 'fallback']).optional(),
variants: z.array(variantSchema).min(1).max(4),
legacyConfigAliases: z.record(z.string(), z.string()).optional(),
legacyConfigField: z.string().min(1).max(40).optional(),
resumeAppend: z
.union([
z.object({ style: z.literal('flag'), flag: flagToken }).strict(),
z.object({ style: z.literal('positional'), token: shellToken }).strict(),
])
.optional(),
})
.strict()
.superRefine((launch, ctx) => {
const paramNames = new Set(Object.keys(launch.params));
const checkValueFrom = (name: string, path: (string | number)[]) => {
if (!paramNames.has(name)) {
ctx.addIssue({ code: 'custom', message: `valueFrom "${name}" is not a declared param`, path });
}
};
launch.variants.forEach((variant, vi) => {
variant.args.forEach((arg, ai) => {
if ('valueFrom' in arg) checkValueFrom(arg.valueFrom, ['variants', vi, 'args', ai, 'valueFrom']);
});
});
if (launch.chain === 'fallback') {
const last = launch.variants.at(-1);
if (last?.when) {
ctx.addIssue({
code: 'custom',
message: 'the last variant of a fallback chain must have no `when` (it must be the guaranteed terminal case)',
path: ['variants', launch.variants.length - 1, 'when'],
});
}
}
if (launch.legacyConfigAliases) {
for (const paramName of Object.keys(launch.legacyConfigAliases)) {
if (!paramNames.has(paramName)) {
ctx.addIssue({
code: 'custom',
message: `legacyConfigAliases key "${paramName}" is not a declared param`,
path: ['legacyConfigAliases', paramName],
});
}
}
}
});
const versionProbeSchema = z
.object({
arg: shellToken,
regex: z.string().max(200).optional(),
requireVersionMatch: z.boolean().optional(),
retryOnTransientFailure: z.boolean().optional(),
})
.strict();
const identityProbeSchema = z
.object({
arg: shellToken,
// Same 200-char cap as version.regex, and compiled through the same compileVersionRegex()
// guard at use time. This is the second and last config-supplied regex in the registry.
regex: z.string().min(1).max(200),
})
.strict();
const discoverySchema = z
.object({
// min(0): the `shell` entry has no binary of its own (it resolves the login shell in code).
binaries: z.array(shellToken).max(4),
searchDirs: z.array(z.string().max(300)).max(16),
version: versionProbeSchema.optional(),
identity: identityProbeSchema.optional(),
launcherProfile: z.string().max(40).optional(),
launcherTargetParam: z.string().max(40).optional(),
install: z
.object({
// z.record with an enum key type requires every enum member in Zod v4; the install
// command legitimately varies by platform and most entries only need one or two, so
// this is a plain object of optional platform keys instead.
command: z
.object({
linux: z.string().max(500).optional(),
darwin: z.string().max(500).optional(),
wsl: z.string().max(500).optional(),
win32: z.string().max(500).optional(),
})
.strict(),
npmPackage: z.string().max(200).optional(),
docsUrl: z.url().optional(),
})
.strict(),
})
.strict();
const envExportSchema = z
.object({
name: envName,
value: z.union([
shellToken,
z
.object({
engine: z.enum([
'sessionId',
'sessionName',
'muxName',
'effortLevel',
'effortSettingsJson',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
})
.strict(),
]),
when: condSchema.optional(),
})
.strict();
const envSchema = z
.object({
exports: z.array(envExportSchema).max(16),
unset: z.array(envName).max(16),
tmuxSetenvKeys: z.array(envName).max(32),
dockerExecEnvNames: z.array(envName).max(32),
configSetenv: z
.array(z.object({ name: envName, fromParam: z.string().min(1).max(40) }).strict())
.max(8)
.optional(),
allowedPrefixes: z
.array(
z
.string()
.min(3)
.max(32)
.regex(/^[A-Z][A-Z0-9_]*_$/)
)
.max(8),
allowedKeys: z.array(envName).max(8),
configContentVar: envName.optional(),
setenvProfile: z.string().max(40).optional(),
})
.strict();
const echoSchema = z
.object({
policy: z.enum(['buffer', 'predict', 'off']),
anchor: z.union([
z
.object({ kind: z.literal('glyph'), glyph: z.string().min(1).max(4), offset: z.number().int().min(0).max(16) })
.strict(),
z.object({ kind: z.literal('cursor') }).strict(),
z.object({ kind: z.literal('none') }).strict(),
]),
predictProfile: z.string().max(40).optional(),
})
.strict();
const capabilitiesSchema = z
.object({
external: z.boolean(),
requiresMux: z.boolean(),
hooks: z.enum(['none', 'always', 'supervised']),
transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']),
altScreen: z.enum(['strip-full', 'strip-mux-only', 'preserve']),
echo: echoSchema,
wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
.strict(),
keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']),
stripInkBloat: z.boolean(),
ralph: z.boolean(),
respawn: z.boolean(),
effort: z.boolean(),
agentSkillInjection: z.boolean(),
statusLineTelemetry: z.boolean(),
workDetect: z
.object({
promptGlyph: z.string().min(1).max(8),
// Config-supplied regex, so it goes through the same guard as `version.regex`:
// ~/.codeman/clis.json can set this, and the compiled pattern runs on the PTY
// hot path, where a nested quantifier would be a ReDoS against the event loop.
// A broken pattern must also fail at LOAD time rather than inside a data handler.
workingLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'workingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
),
})
.strict()
.optional(),
model: z
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
.strict(),
privilegedParams: z
.array(
z
.object({
param: z.string(),
clampTo: z.union([z.boolean(), z.string()]),
materializeWhenAbsent: z.boolean().optional(),
})
.strict()
)
.max(8),
// Exact env var NAMES, not prefixes: this list is a targeted deny, and a prefix here
// would let one entry silently strip a whole namespace off every owner's overrides.
privilegedEnvKeys: z.array(envName).max(8),
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
maxFrameBytes: z.number().int().positive().optional(),
})
.strict();
const credStoreSchema = z
.object({
rel: z.string().min(1).max(100),
shareDirs: z.array(z.string().max(100)).optional(),
shareFiles: z.array(z.string().max(100)).optional(),
seedFiles: z.array(z.string().max(100)).optional(),
seedWhole: z.boolean().optional(),
})
.strict();
/**
* A remote/docker default pane command: space-separated bare words from the SAME safe
* charset as `shellToken` (no shell metacharacters), so `claude --dangerously-skip-permissions`
* is expressible while still excluding `;`, `|`, `$`, backticks and quotes — this is not an
* escape hatch into arbitrary shell text, it is one bare command plus bare flags.
*/
const commandLine = z
.string()
.min(1)
.max(200)
.regex(
/^[A-Za-z0-9._:@=+/,-]+( [A-Za-z0-9._:@=+/,-]+)*$/,
'must be space-separated bare words with no shell metacharacters'
);
const overlayTargetSchema = z.union([
z.object({ command: commandLine.optional(), rootCommand: commandLine.optional() }).strict(),
z.object({ disabled: z.literal(true) }).strict(),
]);
const overlaysSchema = z
.object({
remote: overlayTargetSchema.optional(),
docker: overlayTargetSchema.optional(),
credStore: credStoreSchema.optional(),
})
.strict();
export const CliEntrySchema = z
.object({
id: cliId,
label: z.string().min(1).max(60),
shortBadge: z.string().min(1).max(6),
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'accent must be a 6-digit hex colour'),
enabled: z.boolean(),
stock: z.boolean(),
order: z.number().int(),
kind: z.enum(['agent', 'shell']),
discovery: discoverySchema,
launch: launchSchema,
env: envSchema,
capabilities: capabilitiesSchema,
overlays: overlaysSchema,
})
.strict()
.superRefine((entry, ctx) => {
const gateNames = new Set(Object.keys(entry.capabilities.gates));
const walkConds = (cond: import('./types.js').Cond | undefined) => {
if (!cond) return;
if ('capabilityGate' in cond && !gateNames.has(cond.capabilityGate)) {
ctx.addIssue({
code: 'custom',
message: `capabilityGate "${cond.capabilityGate}" is not declared in capabilities.gates`,
});
}
if ('allOf' in cond) cond.allOf.forEach(walkConds);
if ('anyOf' in cond) cond.anyOf.forEach(walkConds);
if ('not' in cond) walkConds(cond.not);
};
for (const variant of entry.launch.variants) {
walkConds(variant.when);
for (const arg of variant.args) walkConds(arg.when);
}
// Reject a profile name this build does not implement, rather than letting it fail
// closed at use time. An unimplemented `launcherProfile` would make the CLI look
// permanently uninstalled, and an unimplemented `setenvProfile` would silently skip
// setup the CLI needs; both are far easier to diagnose as a load-time error naming the
// field. (`echo.predictProfile` is deliberately NOT checked here — see profiles.ts.)
const { launcherProfile } = entry.discovery;
if (launcherProfile !== undefined && !isKnownLauncherProfile(launcherProfile)) {
ctx.addIssue({
code: 'custom',
message: `discovery.launcherProfile "${launcherProfile}" is not a profile this build implements`,
path: ['discovery', 'launcherProfile'],
});
}
// An env var exported from a param that does not exist would silently export nothing,
// and for DSH_PERMISSION_MODE that means silently losing a permission clamp.
const declaredParams = new Set(Object.keys(entry.launch.params));
entry.env.configSetenv?.forEach((mapping, i) => {
if (!declaredParams.has(mapping.fromParam)) {
ctx.addIssue({
code: 'custom',
message: `configSetenv fromParam "${mapping.fromParam}" is not a declared launch param`,
path: ['env', 'configSetenv', i, 'fromParam'],
});
}
});
// Same class of silent failure on the OTHER privileged surface, and this one is a
// security control: `privilegedParams[].param` is the multi-user bypass clamp's only
// handle on a CLI's privilege switch, and a name that is not a declared param clamps
// NOTHING — no load error, no failing test, the clamp simply stops running. The clamp
// resolves the name through `legacyConfigAliases`, so this check is what keeps the two
// in ONE namespace rather than two that merely coincide today: they do not for codex
// (`bypassApprovals` vs `dangerouslyBypassApprovals`), and giving deepseek's
// `permissionMode` an alias later would otherwise have removed its clamp with nothing
// saying so.
entry.capabilities.privilegedParams.forEach((clamp, i) => {
if (!declaredParams.has(clamp.param)) {
ctx.addIssue({
code: 'custom',
message: `privilegedParams param "${clamp.param}" is not a declared launch param`,
path: ['capabilities', 'privilegedParams', i, 'param'],
});
}
});
const { setenvProfile } = entry.env;
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
ctx.addIssue({
code: 'custom',
message: `env.setenvProfile "${setenvProfile}" is not a profile this build implements`,
path: ['env', 'setenvProfile'],
});
}
});
export type ValidatedCliEntry = z.infer<typeof CliEntrySchema>;
File diff suppressed because it is too large Load Diff
+559
View File
@@ -0,0 +1,559 @@
/**
* @fileoverview Type definitions for the CLI registry — the single source of truth for
* which agent CLIs Codeman supports and how each one is discovered, launched and treated.
*
* This replaces the hard-coded `SessionMode` union and the ~123 per-mode branches that grew
* out of it. The guiding rule: NO code may branch on a CLI's id. Behaviour that genuinely
* differs between CLIs is expressed either as data here, or as a named PROFILE selected by
* a capability field (see profiles.ts) — never as `mode === 'codex'`.
*
* @module config/cli-registry/types
*/
import type { TokenPattern } from './patterns.js';
/**
* A CLI identifier. Branded so an arbitrary string cannot be passed where a validated id is
* expected; construct with `asCliId()` at the API boundary.
*/
export type CliId = string & { readonly __cliId: unique symbol };
// ---------------------------------------------------------------------------
// Launch argv DSL
// ---------------------------------------------------------------------------
/** Values the ENGINE supplies. Config may reference these by name but never author them. */
export type EngineValue =
| 'sessionId'
| 'sessionName'
| 'muxName'
| 'effortLevel'
| 'effortSettingsJson'
/** `sessionId` prefixed `codeman_<id>` — codex's unique per-pane rollout originator. */
| 'codemanPrefixedSessionId'
/**
* For a launcher CLI (`discovery.launcherProfile`), the target to launch when the caller
* named none — deepseek's default `dsh` profile. Resolved at spawn time, never frozen
* into config, because it depends on what is installed on this machine right now.
*/
| 'launcherDefaultTarget';
/**
* A declared launch parameter. `token` params carry caller-supplied data and are therefore
* the only ones that need a pattern; `engine` params are produced in code.
*/
export type ParamSpec =
| { type: 'enum'; values: string[]; default?: string }
| { type: 'bool' }
| { type: 'token'; pattern: TokenPattern }
| { type: 'engine'; source: EngineValue };
/** A boolean guard over parameter state. */
export type Cond =
| { param: string; is: string | boolean }
| { param: string; state: 'set' | 'unset' }
| { allOf: Cond[] }
| { anyOf: Cond[] }
| { not: Cond }
/** Names an entry in `capabilities.gates`. Fail-closed gates omit when version is unknown. */
| { capabilityGate: string };
/**
* How a token is quoted when emitted into the bash command string.
*
* This exists ONLY to preserve byte-identical output with the hand-written builders being
* replaced (claude wraps its values in double quotes; the other builders emit bare words).
* It is never a safety lever: `renderToken()` verifies the value is metacharacter-free
* before honouring an explicit style, and falls back to single-quote escaping if it is not.
* So the worst a wrong `quote` can do is make output uglier, never unsafe.
*/
export type QuoteStyle = 'auto' | 'bare' | 'double' | 'single';
/** One argv element. */
export type ArgSpec =
/** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */
| { lit: string; when?: Cond }
/** A valueless flag, e.g. `--no-approve`. */
| { flag: string; when?: Cond }
/** A flag with a fixed literal value. */
| { flag: string; value: string; quote?: QuoteStyle; when?: Cond }
/** A flag whose value comes from a declared param. */
| { flag: string; valueFrom: string; quote?: QuoteStyle; when?: Cond }
/** A bare positional value from a param, e.g. codex's `resume <id>`. */
| { valueFrom: string; quote?: QuoteStyle; when?: Cond };
/** One alternative command form. */
export interface CliVariant {
/** Stable name for diagnostics and tests, e.g. 'resume' / 'new'. */
id: string;
when?: Cond;
args: ArgSpec[];
}
export interface CliLaunch {
params: Record<string, ParamSpec>;
/**
* 'first' — emit the first variant whose `when` passes (the usual case).
* 'fallback' — emit EVERY passing variant joined by the engine's own ` || `, which is how
* claude's `--resume X || --session-id Y` shell fallback is expressed without
* config ever containing shell text. The engine owns the operator.
*/
chain?: 'first' | 'fallback';
variants: CliVariant[];
/**
* Maps a declared param name to the field name it arrives under on the legacy
* `POST /api/sessions` wire shape (`OpenCodeConfig.continueSession`, etc — the per-mode
* config objects predate this registry and stay on the wire for compatibility). A param
* with no entry here is looked up under its own name. This is what lets the spawn-command
* bridge (`session-cli-registry-bridge.ts`) stay generic: it reads the raw legacy config
* object through this DATA-declared alias table instead of a per-mode `if (mode === ...)`.
*/
legacyConfigAliases?: Record<string, string>;
/**
* The field on the legacy spawn option bag holding this CLI's `<Mode>Config` object
* (`openCodeConfig`, `codexConfig`, …). Those per-mode objects predate this registry and
* stay on the wire for API compatibility, so SOMETHING has to know which one to read —
* declaring it here as data is what keeps the bridge a generic reader instead of a
* `switch (mode)`.
*
* ABSENT means this CLI's launch fields live at the TOP LEVEL of the option bag rather
* than nested in a config object. That is claude, whose discrete `claudeMode` /
* `allowedTools` / `model` / `resumeSessionId` fields predate the `<Mode>Config` pattern
* entirely — so "read the option bag itself" is not a special case for it, it is just
* the other shape.
*/
legacyConfigField?: string;
/**
* How to APPEND a resume id onto an already-built base command, for the docker in-container
* "tmux was re-created, resume the surviving transcript" path (`appendResumeFlag` in
* tmux-manager.ts) — a narrower, append-only sibling of the full `variants` shape above,
* which builds a whole command from scratch. Absent = this CLI has no resume flag to
* append (shell, opencode: opencode's docker resume goes through its own config object).
*/
resumeAppend?: { style: 'flag'; flag: string } | { style: 'positional'; token: string };
}
// ---------------------------------------------------------------------------
// Discovery
// ---------------------------------------------------------------------------
export interface CliVersionProbe {
arg: string;
/** Serialized regex, applied to `--version` output only. See compileVersionRegex(). */
regex?: string;
/**
* Treat a binary whose version output does not match as ABSENT rather than as
* present-with-unknown-version. For CLIs with short, generic binary names (`pi`), where a
* `which` hit is not by itself evidence the right program is installed.
*/
requireVersionMatch?: boolean;
/** Retry a failed probe with backoff instead of caching the failure (claude's behaviour). */
retryOnTransientFailure?: boolean;
}
/**
* An identity probe: proof that the binary we found is the program we meant, not an
* unrelated one that happens to share the name.
*
* A version probe is not enough on its own. Debian ships a `dsh` (dancer's shell) that
* answers `--version` perfectly happily, and npm carries squatters for `pi` and `grok`.
* `requireVersionMatch` catches a binary whose version output has the WRONG SHAPE; this
* catches one whose output has the right shape but names the wrong program.
*
* Ordering matters and belongs to the resolver, not to config: identity is checked FIRST,
* so an impostor is rejected before its version string is ever parsed.
*/
export interface CliIdentityProbe {
/** Argument that makes the binary describe itself, e.g. `--help`. */
arg: string;
/**
* Serialized regex the output must match. Compiled through `compileVersionRegex()`, so
* it inherits the same length cap and nested-quantifier rejection — this is the second
* (and last) config-supplied regex in the registry, and it runs against truncated
* command output exactly like the first.
*/
regex: string;
}
export interface CliDiscovery {
/**
* Binary name(s), first hit wins.
*
* This is why the registry fixes a live bug: the mode name is NOT always the binary
* name (`antigravity` runs `agy`), and `probeDockerCliVersion` assumed it was.
*/
binaries: string[];
/** Extra directories probed after `which`. A leading `~` expands to homedir; nothing else. */
searchDirs: string[];
version?: CliVersionProbe;
/** Proof the binary is the right program, checked BEFORE the version probe. */
identity?: CliIdentityProbe;
/**
* Names a LAUNCHER profile (profiles.ts): this CLI's binary is a launcher over some
* further target, so two questions the registry normally answers from the binary alone
* have to be asked of that target instead.
*
* - Is it RUNNABLE? Stricter than "is the binary on disk?".
* - What is the DEFAULT target, when the caller names none?
*
* DeepSeek is why this exists and is its only user. `dsh` launches a profile from
* `$DSH_HOME/profiles/<name>`, and the profiles DeepSeek itself ships (`web`,
* `headless`) cannot drive a terminal pane — so a perfectly-installed `dsh` with no
* third-party TUI profile is installed-but-NOT-runnable. The Run button gates on
* runnability while the "add a profile" affordance gates on mere availability;
* collapsing the two would either hide the affordance that fixes the problem or offer a
* run that always fails.
*
* The default target reaches the launch spec as the `launcherDefaultTarget` engine
* value, so it stays a runtime lookup rather than a value frozen into config.
*
* Absent (the normal case) means the binary IS the program, and its presence IS
* runnability.
*/
launcherProfile?: string;
/**
* The launch param naming the target a caller asked for, so the launcher profile can say
* why THAT specific target will not start rather than only whether any will. Meaningless
* without `launcherProfile`.
*/
launcherTargetParam?: string;
install: {
/**
* DISPLAY TEXT ONLY. Shown verbatim in "CLI not found. Install with: ...".
*
* ⚠️ NEVER executed by the server. That is a documented invariant, not an oversight:
* running it would turn a config file into a code-execution surface. A proposal to
* execute this on enable is deliberately deferred to its own change so the trust
* model can be decided on its own merits rather than inside a refactor.
*/
command: Partial<Record<'linux' | 'darwin' | 'wsl' | 'win32', string>>;
/** Package name for an npm-installable CLI. Display/tooling metadata only. */
npmPackage?: string;
docsUrl?: string;
};
}
// ---------------------------------------------------------------------------
// Environment
// ---------------------------------------------------------------------------
export interface CliEnv {
/** `export K=V` in the bash prelude. Values are literals or engine values, never secrets. */
exports: Array<{ name: string; value: string | { engine: EngineValue }; when?: Cond }>;
/** `unset K` — e.g. claude's CLAUDECODE, the truecolor CLIs' NO_COLOR. */
unset: string[];
/**
* NAMES ONLY. Values are read from the server's own process.env and pushed via
* `tmux setenv`, so a secret is structurally unable to reach the command line.
*/
tmuxSetenvKeys: string[];
/** NAMES ONLY, forwarded as `docker exec -e NAME`. */
dockerExecEnvNames: string[];
/**
* Env vars set via `tmux setenv` from a LAUNCH PARAM rather than from the server's own
* environment — for a CLI whose switch is an env var instead of a flag.
*
* DeepSeek's `DSH_PERMISSION_MODE` is the case this exists for. Routing it through a
* declared param (rather than a bespoke configure step) is what lets the ordinary
* `privilegedParams` clamp apply to it: the clamp rewrites the param, and whatever the
* param ends up as is what gets exported.
*
* ⚠️ Values are read from a declared, schema-validated param, never from free text, and
* they reach the pane through `tmux setenv` rather than the command line.
*/
configSetenv?: Array<{ name: string; fromParam: string }>;
/** This entry's contribution to the env-override allowlist. Never widens BLOCKED_ENV_KEYS. */
allowedPrefixes: string[];
allowedKeys: string[];
/**
* Env var carrying a JSON config blob pushed via `tmux setenv` (opencode's
* OPENCODE_CONFIG_CONTENT). Generic so it is not an opencode special case.
*/
configContentVar?: string;
/**
* Names an entry in `SETENV_PROFILES` (profiles.ts): extra `tmux setenv` work that is
* genuinely code-shaped rather than a list of key names.
*
* DeepSeek's status bridge is the only current user. It has to write an executable shim
* to disk (`ensureDeepSeekStatusShim()`), then export the shim's path and this session's
* pane id — a side effect and two computed values, none of which `tmuxSetenvKeys` (a
* list of names forwarded from the server's own env) can express.
*
* Plain secret forwarding stays in `tmuxSetenvKeys` and must NOT move here.
*/
setenvProfile?: string;
}
// ---------------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------------
/**
* The closed set of behavioural switches. Each field replaces an id-check somewhere.
*
* `hooks`, `transcript` and `altScreen` are INDEPENDENT on purpose. The three predicates
* they back (`hooksAvailableForMode`, `isExternalCliMode`, `isAltScreenStripMode`) describe
* three different, deliberately unequal sets, and deriving any one from another has already
* caused a real bug — a `shell` session has no hooks but is not an "external CLI", so
* `!isExternalCliMode()` wrongly accepted `until=stop` on it and hung for the full timeout.
* Keeping them as separate fields makes that invariant structural rather than commented.
*/
export interface CliCapabilities {
/**
* Non-Claude run mode that uses its own TUI and output format (`isExternalCliMode`):
* no Claude transcript, no hooks, no Claude-format token/BashTool parsing. An explicit
* field rather than derived from `hooks`/`kind`, precisely because it must stay
* independent — see this interface's own doc comment.
*/
external: boolean;
/**
* How to read this CLI's own TUI for whether it is mid-turn.
*
* Codeman infers a working agent from the pane, so the two strings it needs are the
* ones that differ per CLI: the glyph on the composer row, and the status line the CLI
* draws while a turn runs. Holding them here is what lets a non-Claude CLI report work
* at all — `external` used to gate the whole detector, so every external CLI reported
* itself permanently idle even mid-turn.
*
* `promptGlyph` only ARMS the idle confirmation and is never on its own evidence that a
* turn ended, because a CLI redraws its composer throughout a turn. `workingLine` is
* the evidence, and `_confirmIdle` consults it before believing the pane went quiet.
*
* An entry that omits this field keeps Codeman's historical behaviour: the Claude glyph
* arms the confirmation and the Claude working line answers it. Leave it out for a CLI
* whose TUI nobody has characterised, and its sessions report work exactly as before.
*/
workDetect?: {
/** The glyph this CLI draws on its composer row, e.g. Claude's `❯`, Codex's `›`. */
promptGlyph: string;
/** Source of a regex matching the status line this CLI draws while a turn runs. */
workingLine: string;
};
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
requiresMux: boolean;
/**
* Whether `stop`/`blocked` wait signals can ever fire for this CLI.
*
* ⚠️ A TRI-STATE, not a boolean, because for one CLI this is a per-SESSION question:
* 'none' — no hook signals, ever (every external CLI, and `shell`).
* 'always' — the CLI installs Codeman's hooks (claude).
* 'supervised' — the CLI REPORTS its own idle/working/blocked state to a supervisor
* over a generic env-gated contract, and Codeman is that supervisor
* (deepseek, via deepseek-status-shim.ts). Definitive rather than
* inferred, so it earns real signals — but the session can disarm the
* bridge (`deepSeekConfig.statusReporting: false`), and a docker or
* remote session cannot reach it at all.
*
* That last case is why `hooksAvailableForMode()` takes per-session options and why
* every call site must pass `sessionHookOptions(session)`. Answering from the mode alone
* would promise a `stop` that never arrives, which is the infinite-wait-dressed-as-a-
* timeout the predicate exists to prevent.
*/
hooks: 'none' | 'always' | 'supervised';
/**
* Which transcript reader, if any, understands this CLI's on-disk history.
*
* `deepseek-zstd` is the odd one out: dsh writes zstd-compressed session files and
* appends ONE FRAME PER WRITE, so it needs a reader that walks frame headers itself
* rather than the stock decoder. It exists because the pane segmenter served dsh's
* ASCII-art splash as the worker's first answer.
*/
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none';
/**
* 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs).
* 'strip-mux-only' — only tmux's own attach-time smcup (the safe default).
* 'preserve' — leave everything (a direct-PTY shell running vim/less/htop).
*/
altScreen: 'strip-full' | 'strip-mux-only' | 'preserve';
echo: {
policy: 'buffer' | 'predict' | 'off';
/** How the local-echo overlay locates the composer row. */
anchor: { kind: 'glyph'; glyph: string; offset: number } | { kind: 'cursor' } | { kind: 'none' };
/** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */
predictProfile?: string;
};
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean;
startMode: 'interactive' | 'shell';
stripInkBloat: boolean;
ralph: boolean;
respawn: boolean;
effort: boolean;
agentSkillInjection: boolean;
statusLineTelemetry: boolean;
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
/**
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
*
* `materializeWhenAbsent` distinguishes two real shapes, not one:
* - only-if-sent (false/omitted; codex, antigravity, grok): the CLI's own
* absent-config default already spawns safe, so the clamp should only touch
* a config the caller actually sent.
* - materialize (true; gemini, pi): the absent-config default is ITSELF unsafe
* for a non-granted owner (gemini defaults to `yolo`; pi's absent default is
* an interactive trust prompt the session user could just answer "yes" to),
* so the clamp must CREATE a config object even when none was sent.
*
* ⚠️ `param` names the LAUNCH PARAM, like every other `param` in this file — never the
* legacy wire field. The clamp translates it through `legacyConfigAliases` on the way out,
* the same hop `env.configSetenv` makes. The two names coincide for most entries and
* DELIBERATELY do not for codex (`bypassApprovals` here, `dangerouslyBypassApprovals` on
* the wire), which is what keeps the distinction visible. `schema.ts` rejects an entry
* naming a param it never declared, because getting this wrong is a SILENT no-op: no load
* error, no failing test, the clamp just stops clamping.
*/
privilegedParams: Array<{ param: string; clampTo: boolean | string; materializeWhenAbsent?: boolean }>;
/**
* Env var names a non-granted multi-user owner may not set at all, DROPPED from
* `envOverrides` before spawn.
*
* ⚠️ This is a second, structurally different privileged surface from `privilegedParams`
* above, and one cannot substitute for the other. `privilegedParams` clamps a field on a
* per-CLI config object, which reaches the CLI as an argv flag. These clamp env vars,
* which reach it through `tmux setenv` — a path no argv clamp can see.
*
* DeepSeek is why this exists. Its permission switch IS an env var
* (`DSH_PERMISSION_MODE`), not a flag, so a config-level clamp alone leaves a real
* multi-user control with nothing enforcing it. Worse, `DSH_*` is an allowlisted
* `envOverrides` prefix and `applyEnvOverrides()` runs AFTER the per-CLI env configure
* step, so a non-granted owner sending that key on the SAME request would land last and
* hand back exactly the privilege the config clamp just removed.
*
* Dropping (rather than rewriting) is deliberate: the value then falls through to what
* the CLI's own env configuration exports, which is already the clamped one.
*
* The other two DeepSeek keys are here for reasons worth keeping written down:
* - `DSH_HOME` points the launcher at a profile tree whose plugin code runs at BOOT,
* before any approval row could apply.
* - `DEEPSEEK_BASE_URL` would redirect the server's OWN forwarded `DEEPSEEK_API_KEY`
* to a host of the caller's choosing.
*
* Every other CLI's bypass is a command-line flag reachable only through its config
* object, which is why `privilegedParams` alone is the whole gate for them.
*/
privilegedEnvKeys: string[];
/** Version gates referenced by `capabilityGate` conditions. */
gates: Record<string, { minVersion: string; failClosed: boolean }>;
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
maxFrameBytes?: number;
}
// ---------------------------------------------------------------------------
// Location overlays (remote SSH / docker)
// ---------------------------------------------------------------------------
/** Docker credential seeding policy — which host dirs are copied or shared into a container. */
export interface CliCredStore {
rel: string;
shareDirs?: string[];
shareFiles?: string[];
seedFiles?: string[];
seedWhole?: boolean;
}
export interface CliOverlays {
/**
* The remote/docker DEFAULT pane command: just the CLI invocation (e.g. `claude
* --dangerously-skip-permissions`), independent of each location's own wrapping
* (remote: login-shell `-c`; docker: `exec`). Absent `command` = the bare
* `discovery.binaries[0]`. `disabled: true` = this location has no story for this CLI at
* all (docker for `shell`) — distinct from "no override", which still gets a default.
*/
remote?: { command?: string } | { disabled: true };
/**
* `rootCommand` is the same invocation for a container whose exec user is uid 0. Only
* declare it when the normal `command` would be REFUSED as root: claude's carries
* `--dangerously-skip-permissions`, which Claude Code rejects outright under root, and
* the rejection is visible only inside the container, so the pane dies with no clue on
* the outside. Codeman's own base image runs a non-root user and never selects this; an
* ADOPTED container belongs to its owner and is frequently root. Absent = use `command`.
*/
docker?: { command?: string; rootCommand?: string } | { disabled: true };
/**
* ⚠️ DECLARED-FOR-LATER, unlike `remote`/`docker` above, which are live.
*
* The Docker credential-seeding path still reads its own `CRED_STORES` table in
* `docker-hosts.ts`, because this shape cannot yet express that table: it allows ONE store
* per CLI, and the live table needs two for gemini (`.gemini` for the CLI's own auth plus
* `.config/gcloud` for Vertex), while deepseek's entry here declares none at all even
* though `.dsh` is seeded. Wiring it therefore means making this an ARRAY and correcting
* those two entries — a change to credential seeding, which is both the highest-consequence
* thing in this file to get wrong and the least covered by tests, since every docker IO
* path is no-op'd under vitest. It belongs in its own change, measured against a real
* container.
*/
credStore?: CliCredStore;
}
// ---------------------------------------------------------------------------
// The entry
// ---------------------------------------------------------------------------
/**
* ⚠️ DECLARED-FOR-LATER: fields no code reads yet.
*
* `shortBadge`, `accent`, `overlays.credStore`, `capabilities.echo`, `capabilities.wheelForward`,
* `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` all describe FRONTEND
* behaviour, and the frontend is deliberately untouched by the change that introduced this
* registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
* hand-authored per-CLI rules, and moving them is its own piece of work with its own way of
* being verified (a mobile/browser suite the CI gate cannot see).
*
* They are declared now because each entry should describe its CLI completely, and because
* transcribing them while the hand-written source is still on screen is when the values are
* actually known. But an unread field is a promise, not a fact: nothing enforces that
* `echo.policy` here matches `_updateLocalEchoState`'s fallthrough, or that `accent` matches
* the gradient CSS paints. Treat every value in this group as TRANSCRIBED, not authoritative,
* and re-measure against the frontend before wiring one up.
*
* The rest of the interface is live: something reads it, and `test/cli-registry-*.test.ts`
* pins what it does with it.
*/
export interface CliEntry {
id: CliId;
label: string;
/** Two-ish character tab badge, e.g. 'OC'. */
shortBadge: string;
/** Single hex colour. CSS derives every per-CLI gradient from it via --cli-accent. */
accent: string;
enabled: boolean;
/** Set by the loader from the shipped catalog; a user entry can never claim it. */
stock: boolean;
order: number;
/** 'shell' unlocks the raw-shell code paths; everything else is an agent CLI. */
kind: 'agent' | 'shell';
discovery: CliDiscovery;
launch: CliLaunch;
env: CliEnv;
capabilities: CliCapabilities;
overlays: CliOverlays;
}
/**
* The on-disk shape of ~/.codeman/clis.json — overrides and custom entries only, never the
* full catalog. Small and hand-readable by design.
*
* ⚠️ READ-ONLY in this build. Nothing here writes this file: there is no settings UI and no
* write API yet, so there is nothing to persist. That also means importing the registry
* (and therefore `schemas.ts`, which validates against it) performs no filesystem writes —
* an import side effect worth not having.
*/
export interface CliRegistryFile {
schemaVersion: number;
/**
* Stock ids already introduced to this install — the ratchet that lets one file both gain
* newly-shipped CLIs on upgrade AND remember that the user disabled one.
*
* Read and IGNORED here, and never written: the ratchet only earns its keep once a CLI
* can be disabled, which needs the write API. Declared now purely so a file written by a
* later version still loads cleanly under this one instead of failing `.strict()`.
*/
seededStockIds?: string[];
/** Keyed by id: a partial override of a stock entry, or a complete custom entry. */
clis: Record<string, unknown>;
}
+159 -204
View File
@@ -7,10 +7,8 @@
* @module config/dependency-registry
*/
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';
import { enabledClis } from './cli-registry/registry.js';
import { compileVersionRegex } from './cli-registry/patterns.js';
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
@@ -59,207 +57,164 @@ export interface ToolDependency {
const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
export const DEPENDENCY_REGISTRY: ToolDependency[] = [
{
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
minVersion: '22.0.0',
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }],
installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' },
},
{
id: 'claude',
label: 'Claude CLI',
category: 'core',
required: false,
usedBy: ['Claude Code sessions (default backend)'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['claude'], versionArg: '--version' } }],
installHint: { linux: 'https://docs.claude.com/claude-code', darwin: 'https://docs.claude.com/claude-code' },
},
{
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' },
},
{
id: 'opencode',
label: 'OpenCode CLI',
category: 'core',
required: false,
usedBy: ['OpenCode sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['opencode'], versionArg: '--version' } }],
},
{
id: 'codex',
label: 'Codex CLI',
category: 'core',
required: false,
usedBy: ['Codex sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }],
},
{
id: 'gemini',
label: 'Gemini CLI',
category: 'core',
required: false,
usedBy: ['Gemini sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }],
},
{
id: 'antigravity',
label: 'Antigravity CLI',
category: 'core',
required: false,
usedBy: ['Antigravity sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['agy'], versionArg: '--version' } }],
},
{
id: 'pi',
label: 'Pi CLI',
category: 'core',
required: false,
usedBy: ['Pi sessions'],
// The only entry that requires a version match, for the same reason
// pi-cli-resolver.ts probes: `pi` is a short generic name (Raspberry Pi tooling,
// personal scripts), so a `which pi` hit alone is not the coding agent. Both sides
// share PI_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: ['pi'],
versionArg: '--version',
versionRegex: PI_VERSION_REGEX,
requireVersionMatch: true,
/**
* The doctor's ROW IDENTITY for a CLI, where it differs from the registry id.
*
* These are two separate contracts and they have never been the same thing: `codeman doctor`
* prints a tool table whose ids predate the registry, and `dsh` names the BINARY while the
* run mode is `deepseek`. Keeping the historical id here means the doctor's output does not
* shift under a refactor that was supposed to change nothing a user can see.
*
* `usedBy` is likewise preserved verbatim rather than generated, because the strings are
* shown to the user and claude's does not follow the pattern.
*/
const DOCTOR_ROW_OVERRIDES: Record<string, { id?: string; label?: string; usedBy: string[] }> = {
claude: { usedBy: ['Claude Code sessions (default backend)'] },
opencode: { usedBy: ['OpenCode sessions'] },
codex: { usedBy: ['Codex sessions'] },
gemini: { usedBy: ['Gemini sessions'] },
antigravity: { usedBy: ['Antigravity sessions'] },
pi: { usedBy: ['Pi sessions'] },
grok: { usedBy: ['Grok sessions'] },
// Both the id and the label are historical: `dsh` names the binary, and the doctor has
// always spelled this row out in full rather than as `${label} CLI`.
deepseek: { id: 'dsh', label: 'DeepSeek Harness CLI', usedBy: ['DeepSeek sessions'] },
};
/**
* Build one `codeman doctor` row per enabled CLI, straight from its registry entry.
*
* This replaces eight hand-written rows that had to be kept in step with the run modes by
* hand — and were not: an earlier draft of this refactor silently dropped the Grok and
* DeepSeek rows, so `codeman doctor` stopped reporting two shipped CLIs at all. Deriving
* the list makes that class of omission impossible.
*
* ⚠️ The version regex is compiled through `compileVersionRegex()`, NOT `new RegExp()`. It
* is a config-supplied pattern, so it goes through the same length cap and
* nested-quantifier rejection the argv engine applies; the doctor runs it over command
* output exactly like the resolver does, and skipping the guard here would leave one
* unguarded path into a user-supplied regex.
*
* ⚠️ Sharing the entry's regex with the resolver is what stops the doctor and the run mode
* telling the user opposite things about the same binary — the Dependencies panel reporting
* "Pi CLI ✓" on a box where Run Pi stays hidden.
*/
function cliDependencyEntries(): ToolDependency[] {
const rows: ToolDependency[] = [];
for (const cli of enabledClis()) {
// `shell` has no binary of its own (the login shell is resolved at spawn time), so
// there is nothing for the doctor to probe.
const bin = cli.discovery.binaries[0];
if (!bin) continue;
const override = DOCTOR_ROW_OVERRIDES[cli.id as string];
const version = cli.discovery.version;
const versionRegex = version?.regex ? (compileVersionRegex(version.regex) ?? undefined) : undefined;
rows.push({
id: override?.id ?? (cli.id as string),
label: override?.label ?? `${cli.label} CLI`,
category: 'core',
required: false,
usedBy: override?.usedBy ?? [`${cli.label} sessions`],
resolvers: [
{
match: ALL,
resolver: {
kind: 'path',
bins: [bin],
versionArg: version?.arg ?? '--version',
versionRegex,
// Only meaningful for a CLI whose binary name is short, generic or squatted
// (pi, grok, dsh): a bare `which` hit there is not evidence of the right
// program, so a version mismatch means MISSING rather than unknown-version.
requireVersionMatch: version?.requireVersionMatch,
},
},
},
],
},
{
id: 'grok',
label: 'Grok CLI',
category: 'core',
required: false,
usedBy: ['Grok sessions'],
// Version match required for the same reason as pi: `grok` has known squatters
// (the unrelated @vibe-kit/grok-cli npm package also installs a `grok` bin), so a
// bare `which grok` hit is not the coding agent. Both sides share
// GROK_VERSION_REGEX, so the doctor and the run mode cannot drift.
resolvers: [
{
match: ALL,
resolver: {
kind: 'path',
bins: ['grok'],
versionArg: '--version',
versionRegex: GROK_VERSION_REGEX,
requireVersionMatch: true,
},
},
],
},
{
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',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['linux', 'darwin', 'wsl'],
resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' },
},
],
installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' },
},
{
id: 'pdftoppm',
label: 'pdftoppm',
category: 'office',
required: false,
usedBy: ['document preview', 'PDF/Office first-page thumbnails'],
// poppler's pdftoppm prints its version to stderr; presence is what matters here.
resolvers: [
{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['pdftoppm'], versionArg: '-v' } },
],
installHint: {
linux: 'sudo apt install poppler-utils',
darwin: 'brew install poppler',
wsl: 'sudo apt install poppler-utils',
],
installHint: cli.discovery.install.command,
});
}
return rows;
}
/**
* The tools `codeman doctor` probes, resolved AT CALL TIME.
*
* ⚠️ A FUNCTION, not a module-level const, and for the same reason `sessionModeSchema()` and
* `allowedEnvPrefixes()` are functions: `cliDependencyEntries()` reads the CLI registry, and
* a const would have frozen the doctor's rows at first import while every schema resolved
* per parse. A CLI enabled while the server was running — or a `reloadCliRegistry()` — then
* moved the run menu and the validation but never the doctor, which would keep reporting the
* catalog as it stood when something first imported this module. Building the array per call
* costs a handful of object literals on a command that shells out to probe binaries anyway.
*/
export function dependencyRegistry(): ToolDependency[] {
return [
{
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
minVersion: '22.0.0',
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }],
installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' },
},
},
{
id: 'msoffice',
label: 'MS Office',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['wsl', 'win32'],
resolver: {
kind: 'windows-side',
appDirs: ['Microsoft Office/root/Office16'],
exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'],
{
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' },
},
...cliDependencyEntries(),
{
id: 'libreoffice',
label: 'LibreOffice',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['linux', 'darwin', 'wsl'],
resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' },
},
],
installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' },
},
{
id: 'pdftoppm',
label: 'pdftoppm',
category: 'office',
required: false,
usedBy: ['document preview', 'PDF/Office first-page thumbnails'],
// poppler's pdftoppm prints its version to stderr; presence is what matters here.
resolvers: [
{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['pdftoppm'], versionArg: '-v' } },
],
installHint: {
linux: 'sudo apt install poppler-utils',
darwin: 'brew install poppler',
wsl: 'sudo apt install poppler-utils',
},
],
},
];
},
{
id: 'msoffice',
label: 'MS Office',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['wsl', 'win32'],
resolver: {
kind: 'windows-side',
appDirs: ['Microsoft Office/root/Office16'],
exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'],
},
},
],
},
];
}
+51 -16
View File
@@ -10,6 +10,8 @@
import { v4 as uuidv4 } from 'uuid';
import { readFile } from 'node:fs/promises';
import { statSync, realpathSync } from 'node:fs';
import { getCli } from '../config/cli-registry/registry.js';
import { resolveCliLaunchError } from '../utils/cli-launcher.js';
import { Session } from '../session.js';
import { applyWorkspaceHooks } from '../hooks-config.js';
import { SseEvent } from '../web/sse-events.js';
@@ -58,9 +60,26 @@ export function clampCronExternalCliConfigs(
ownerGranted: boolean
): { geminiConfig: GeminiConfig | undefined; piConfig: PiConfig | undefined } {
if (ownerGranted) return { geminiConfig: undefined, piConfig: undefined };
// A cron job carries no per-CLI config at all, so ONLY the materialize-when-absent params
// can apply here — an only-if-sent clamp has nothing to clamp. Reading them off the
// registry rather than naming gemini and pi means a future CLI whose bare spawn is unsafe
// is covered the moment its entry says so, instead of silently missing this path.
const entry = getCli(mode);
const aliases = entry?.launch.legacyConfigAliases ?? {};
const materialized: Record<string, unknown> = {};
for (const { param, clampTo, materializeWhenAbsent } of entry?.capabilities.privilegedParams ?? []) {
// Same registry-param → legacy-wire-field hop the HTTP clamp makes. Neither gemini's
// `approvalMode` nor pi's `approveProjectTrust` is aliased today, so this changes nothing
// now — but the two are DIFFERENT namespaces, and writing the raw param here would make
// this path stop clamping the moment one of them gained an alias, silently.
if (materializeWhenAbsent) materialized[aliases[param] ?? param] = clampTo;
}
const has = Object.keys(materialized).length > 0;
const field = entry?.launch.legacyConfigField;
return {
geminiConfig: mode === 'gemini' ? { approvalMode: 'auto_edit' } : undefined,
piConfig: mode === 'pi' ? { approveProjectTrust: false } : undefined,
geminiConfig: has && field === 'geminiConfig' ? (materialized as GeminiConfig) : undefined,
piConfig: has && field === 'piConfig' ? (materialized as PiConfig) : undefined,
};
}
@@ -387,7 +406,7 @@ export class CronService {
// Section 6.3: re-resolve the owner's grant at FIRE time (it may have been revoked
// since create). Gates shell/launchCommand AND clamps the external-CLI bypass below.
const ownerGranted = await canUsernameRunPrivilegedCommands(job.owner);
if ((job.agentType === 'shell' || job.launchCommand) && !ownerGranted) {
if ((getCli(job.agentType)?.capabilities.privilegedCommandGate || job.launchCommand) && !ownerGranted) {
return this.failRun(job, run, 'Owner lacks the can-bypass-permissions grant for shell/launchCommand jobs');
}
@@ -395,23 +414,37 @@ 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);
// A LAUNCHER CLI's binary is not its agent, so "installed" is not "runnable": without
// this, a job on a box carrying only dsh's 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 an actionable message.
//
// ⚠️ Scoped to `discovery.launcherProfile`, which is byte-identical to the
// `mode === 'deepseek'` check this replaces (dsh is the only launcher today) and
// generalises to the next one. Deliberately NOT every CLI: cron has never pre-flighted
// a merely-missing binary, and doing so replaces tmux-manager's own not-found throw
// ("Session launch failed") with a different message for claude and shell. An earlier
// draft of this line was unscoped and did exactly that — three cron tests caught it.
if (getCli(mode)?.discovery.launcherProfile !== undefined) {
const cronLaunchError = await resolveCliLaunchError(mode);
if (cronLaunchError) return this.failRun(job, run, cronLaunchError);
}
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);
// 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;
// Cron carries no per-CLI config object, so the only model it can supply is the global
// default — and only to a CLI that takes a model at all.
//
// ⚠️ `!== 'none'` is the faithful reading of the `mode !== 'shell' && mode !== 'deepseek'`
// ladder this replaces: those two are exactly the entries declaring `model.source: 'none'`
// (shell has no model; deepseek's is a profile composition entry, not a session flag).
// NOT `=== 'claude-settings-file'`, which is the HTTP route's question — there, every
// external CLI reads its model from its own config object earlier in the chain, so only
// claude reaches the global default. Cron has no such config, so the same expression
// means something different here.
const model =
getCli(mode)?.capabilities.model.source !== 'none' ? 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).
@@ -560,7 +593,9 @@ export class CronService {
private sendPromptWhenReady(sessionId: string, prompt: string, job: CronJob, run: CronJobRun): void {
setImmediate(() => {
const poll = async (): Promise<void> => {
if (job.agentType !== 'shell') {
// A shell pane is ready the moment it exists; an agent CLI has a TUI to paint
// first. That is the `kind` the registry already records, not a fact about shell.
if (getCli(job.agentType)?.kind !== 'shell') {
for (let attempt = 0; attempt < CRON_READY_MAX_ATTEMPTS; attempt++) {
await delay(500);
const s = this.deps.sessions.get(sessionId);
+3
View File
@@ -45,6 +45,8 @@ export interface WebLaunchOptions {
host: string;
port: number;
https: boolean;
/** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */
basePath?: string;
titleHostname?: string;
allowUnauthenticatedNetwork?: boolean;
multiuser?: boolean;
@@ -87,6 +89,7 @@ export interface DaemonStatus {
export function buildWebArgs(options: WebLaunchOptions): string[] {
const args = ['web', '--host', options.host, '--port', String(options.port)];
if (options.https) args.push('--https');
if (options.basePath) args.push('--base-url', options.basePath);
if (options.titleHostname) args.push('--title-hostname', options.titleHostname);
if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network');
if (options.multiuser) args.push('--multiuser');
+8 -1
View File
@@ -30,6 +30,7 @@ import { spawn } from 'node:child_process';
import { pipeline } from 'node:stream/promises';
import type { DockerEngine, SessionDocker } from './types.js';
import { runWithConversionLimit } from './document-conversion-limiter.js';
import { isAdoptedContainer } from './docker-hosts.js';
const IS_TEST_MODE = !!process.env.VITEST;
@@ -287,7 +288,13 @@ export async function exportDockerCase(params: {
const bundlePath = join(exportsDir, exportBundleName(caseName, timestamp, mode));
const stageDir = join(exportsDir, `.stage-${caseName}-${timestamp}`);
mkdirSync(stageDir, { recursive: true });
const wasRunning = await isContainerRunning(argv, docker.containerName);
// ⚠️ NEVER pause an ADOPTED container. The freeze exists only to make the committed
// image and the workspace tar mutually consistent, and it is a lifecycle mutation on a
// container that belongs to the user — it stops their processes for however long the
// tar takes. A workspace-only export of an adopted case therefore accepts a live
// filesystem, the same guarantee `tar` gives on any running host directory. Full-image
// export is refused for an adopted case at the route, before reaching here.
const wasRunning = !isAdoptedContainer(docker) && (await isContainerRunning(argv, docker.containerName));
let commitTag: string | undefined;
try {
+373 -27
View File
@@ -23,7 +23,8 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { enabledCliIds, getCli } from './config/cli-registry/registry.js';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { createHash } from 'node:crypto';
@@ -32,7 +33,6 @@ import { promisify } from 'node:util';
import { dataPath } from './config/instance.js';
import type {
DockerCase,
DockerCommandMode,
DockerEngine,
DockerHost,
DockerNetworkMode,
@@ -55,6 +55,30 @@ export const DEFAULT_AGENT_IMAGE = 'codeman/agent:base';
/** HOME inside the base image (the `agent` user). Cred mounts + hook-secret land under it. */
export const CONTAINER_HOME = '/home/agent';
/**
* Modes the adoption preflight probes for inside an existing container, derived from the
* CLI registry so a newly-enabled CLI is probed without a second list to remember.
*
* No arm for `shell` here: it declares no binary, so `probeAdoptableContainer` drops it
* from the `command -v` list and reports it available unconditionally, which is the same
* answer a special case would have produced.
*/
export function dockerAdoptProbeModes(): SessionMode[] {
return enabledCliIds() as SessionMode[];
}
/**
* The BINARY a mode looks for inside a container. ⚠️ NOT always the mode name:
* `antigravity` ships as `agy` and `deepseek` as `dsh`, so probing by mode name
* would report those two as missing on a container that has them. Same source
* `probeDockerCliVersion` reads, and the same one `defaultDockerCommandForMode`
* launches from — a local table here duplicated the registry with nothing
* keeping the two in step.
*/
function containerBinaryFor(mode: SessionMode): string | undefined {
return getCli(mode)?.discovery.binaries[0];
}
/** Per-case container name prefix. The `case` letters deliberately do NOT matter to
* tmux; this is a DOCKER name (`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`), and case names are
* already validated `^[a-zA-Z0-9_-]+$`, so `codeman-case-<name>` is always valid. */
@@ -134,22 +158,30 @@ export function dockerContainerName(caseName: string): string {
return `${CONTAINER_NAME_PREFIX}${caseName}`;
}
/** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */
export function defaultDockerCommandForMode(mode: SessionMode): string {
const commands: Record<DockerCommandMode, string> = {
shell: 'exec bash -l',
// Mirror the LOCAL claude default so the in-container agent runs non-interactively.
claude: 'exec claude --dangerously-skip-permissions',
opencode: 'exec opencode',
codex: 'exec codex',
gemini: 'exec gemini',
antigravity: 'exec agy',
pi: 'exec pi',
grok: 'exec grok',
deepseek: 'exec dsh',
omp: 'exec omp',
};
return commands[mode as DockerCommandMode] || commands.shell;
/**
* Default in-container pane command per CLI mode (mirror of defaultRemoteCommandForMode).
*
* ⚠️ Read from the registry (`overlays.docker`), not from a hardcoded
* `Record<DockerCommandMode, string>`. That table duplicated the registry exactly with
* nothing keeping the two in step. `shell` is the one arm still written here, because it is
* the entry that declares `docker: { disabled: true }` — a container has no per-user login
* shell to resolve, so it gets a plain `bash -l` rather than a CLI invocation.
*
* ⚠️ `runsAsRoot` selects the overlay's `rootCommand` when it declares one. Claude Code
* REFUSES `--dangerously-skip-permissions` under uid 0 ("cannot be used with root/sudo
* privileges", still true in 2.1.261), and the refusal is only visible INSIDE the
* container, so the pane just dies. Our own base image runs a non-root user and never hits
* it; an ADOPTED container's user belongs to its owner and is frequently root. Which flag
* to drop is a per-CLI fact, so it lives in the registry rather than in a branch here.
*/
export function defaultDockerCommandForMode(mode: SessionMode, runsAsRoot = false): string {
const entry = getCli(mode);
const overlay = entry?.overlays.docker;
if (!entry || (overlay && 'disabled' in overlay)) return 'exec bash -l';
// Mirrors the LOCAL default for each CLI; claude's carries
// `--dangerously-skip-permissions` so the in-container agent runs non-interactively.
const cli = (runsAsRoot ? overlay?.rootCommand : undefined) ?? overlay?.command ?? entry.discovery.binaries[0];
return cli ? `exec ${cli}` : 'exec bash -l';
}
/** `container:/workdir` display string (mirror of remoteDisplayPath's `user@host:path`). */
@@ -252,7 +284,22 @@ export function toSessionDocker(host: DockerHost, dockerCase: DockerCase): Sessi
extraCreateArgs: host.extraCreateArgs,
extraExecArgs: host.extraExecArgs,
};
return { ...base, configHash: dockerConfigHash(base) };
// `owned` is deliberately applied AFTER the hash: dockerConfigHash() picks an
// explicit field list, so ownership can never shift an existing case's hash and
// mass-trip the drift gate.
const session: SessionDocker = { ...base, configHash: dockerConfigHash(base) };
if (dockerCase.owned === false) session.owned = false;
return session;
}
/**
* An ADOPTED container is one the user built and runs themselves. Codeman may
* only exec into it; it must never create, start, stop, restart or remove it.
* Every lifecycle branch routes through this one predicate so a new call site
* cannot silently opt out.
*/
export function isAdoptedContainer(docker: Pick<SessionDocker, 'owned'>): boolean {
return docker.owned === false;
}
// ========== Shell escaping ==========
@@ -277,6 +324,24 @@ export interface DockerMount {
readonly?: boolean;
}
/**
* Resolve a bind source into the Docker daemon's filesystem namespace.
*
* A bare-host Codeman process and its Docker daemon see the same HOME, so the
* source is returned unchanged. In Docker-outside-of-Docker deployments,
* `runtimeHome` is the path inside Codeman while `daemonHome` is the host path
* bind-mounted there. Sources beneath HOME must therefore be translated before
* they are sent through the Docker socket.
*/
export function resolveDockerDaemonMountSource(source: string, runtimeHome: string, daemonHome?: string): string {
const configuredDaemonHome = daemonHome?.trim();
if (!configuredDaemonHome) return source;
const relativeSource = relative(resolve(runtimeHome), resolve(source));
if (relativeSource.startsWith('..') || isAbsolute(relativeSource)) return source;
return resolve(configuredDaemonHome, relativeSource);
}
/**
* Resolved, IO-free context for buildDockerCreateArgs. The caller (tmux-manager)
* resolves the environment-dependent bits (host uid, existing cred mounts, the
@@ -300,6 +365,8 @@ export interface DockerCreateContext {
addHostGateway: boolean;
/** Engine host-gateway alias (host.docker.internal / host.containers.internal). */
gatewayAlias: string;
/** Omit --memory-swap when the host kernel cannot enforce swap limits. */
disableSwapLimit?: boolean;
}
/**
@@ -317,12 +384,15 @@ function mountSpec(m: DockerMount): string {
return `type=bind,src=${m.src},dst=${m.dst}${m.readonly ? ',readonly' : ''}`;
}
function resourceFlags(resources?: DockerResourceLimits): string[] {
function resourceFlags(resources?: DockerResourceLimits, disableSwapLimit = false): string[] {
if (!resources) return [];
const flags: string[] = [];
if (resources.memory) {
// memory-swap == memory disables swap, making --memory a REAL OOM cap.
flags.push('--memory', resources.memory, '--memory-swap', resources.memory);
flags.push('--memory', resources.memory);
// memory-swap == memory disables swap where the daemon supports swap
// accounting. Some kernels, including the deployed Unraid host, do not;
// requesting it there emits a warning and Docker ignores the value.
if (!disableSwapLimit) flags.push('--memory-swap', resources.memory);
}
if (resources.cpus) flags.push('--cpus', resources.cpus);
if (resources.pidsLimit) flags.push('--pids-limit', String(resources.pidsLimit));
@@ -387,7 +457,7 @@ export function buildDockerCreateArgs(ctx: DockerCreateContext): string[] {
if (addHostGateway) args.push('--add-host', `${gatewayAlias}:host-gateway`);
args.push(
...resourceFlags(docker.resources),
...resourceFlags(docker.resources, ctx.disableSwapLimit),
// GPU passthrough (needs the NVIDIA container toolkit on the host). No storage
// cap is set, so the container's writable layer + volumes grow elastically as
// data flows in (bounded only by host disk).
@@ -730,9 +800,15 @@ export interface DockerDriftStatus {
* daemon down) means there is nothing to drift. No-op under VITEST.
*/
export async function checkDockerConfigDrift(
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'configHash'>
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'configHash' | 'owned'>
): Promise<DockerDriftStatus> {
if (IS_TEST_MODE) return { exists: false, running: false, drifted: false };
// An ADOPTED container carries no `codeman.confighash` label — it was never
// created from our config — so every comparison would report drift and the
// launch gate would demand a recreate we are not allowed to perform. Ownership
// of its configuration belongs to the user; report "no drift" and never offer
// to rebuild it.
if (isAdoptedContainer(docker)) return { exists: true, running: false, drifted: false };
const argv = dockerEngineArgv(docker);
try {
const { stdout } = await execFileAsync(
@@ -760,8 +836,15 @@ export async function checkDockerConfigDrift(
* case's lastClaudeSessionId. No-op under VITEST.
*/
export async function removeDockerContainer(
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName' | 'owned'>
): Promise<void> {
// Fail CLOSED at the lowest layer: an adopted container is the user's, and no
// caller — recreate-on-drift, case delete, a future teardown — may remove it.
if (isAdoptedContainer(docker)) {
throw new Error(
`Refusing to remove adopted container "${docker.containerName}": Codeman does not own its lifecycle.`
);
}
if (IS_TEST_MODE) return;
const argv = dockerEngineArgv(docker);
await execFileAsync(argv[0], [...argv.slice(1), 'rm', '-f', docker.containerName], { timeout: 30_000 });
@@ -1007,6 +1090,255 @@ export async function checkDockerTmuxAvailable(
}
}
/** Preflight facts about an ALREADY-RUNNING container the user wants to adopt. */
export interface AdoptedContainerProbe {
ok: boolean;
exists: boolean;
running: boolean;
/** The container's own image ref (informational — we never enforce ours on it). */
image?: string;
/** `command -v tmux` inside the container; required for durable sessions. */
tmuxPath?: string;
/** Modes whose CLI resolved inside the container (`command -v <mode>`). */
availableModes?: SessionMode[];
/** Whether the requested working directory exists INSIDE the container. */
workdirExists?: boolean;
/** Whether the container's exec user is root (uid 0). */
runsAsRoot?: boolean;
error?: string;
}
/** One container on the engine, as offered to the adoption picker. */
export interface DockerContainerInfo {
name: string;
image: string;
running: boolean;
/** Engine's own status string, e.g. "Up 3 hours" / "Exited (0) 2 days ago". */
status: string;
}
/**
* List the engine's containers for the adoption picker (mirror of
* `listRemoteCodemanSessions`). Read-only and NEVER throws: an unreachable
* daemon, a missing engine or zero containers all return `[]`, because this
* feeds a convenience picker whose input the user can always type by hand.
*
* Stopped containers ARE included, sorted after running ones and carrying their
* status: adoption requires a running container, but hiding a stopped one turns
* "my container is not in the list" into a dead end with no explanation, while
* showing `my-box (Exited (0) 2 days ago)` says exactly what to fix.
*/
export async function listDockerContainers(
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost'>
): Promise<DockerContainerInfo[]> {
if (IS_TEST_MODE) return [];
const argv = dockerEngineArgv(docker);
try {
const { stdout } = await execFileAsync(
argv[0],
[...argv.slice(1), 'ps', '-a', '--format', '{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}'],
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
);
const rows = stdout
.split('\n')
.map((line) => line.split('\t'))
.filter((parts) => parts.length >= 4 && parts[0])
.map(([name, image, state, status]) => ({
name,
image: image || '',
running: state === 'running',
status: status || '',
}));
// Running first, then by name, so the containers a user can actually adopt
// are the ones at the top of the list.
return rows.sort((a, b) => Number(b.running) - Number(a.running) || a.name.localeCompare(b.name));
} catch {
return [];
}
}
/**
* Preflight an EXISTING container for adoption. Read-only by construction: it
* runs `inspect` plus one `exec` of `command -v`, and never creates, starts or
* modifies anything. Refusing here is what keeps the failure at link time — a
* clear message — instead of at session launch, where the only alternatives
* would be a dead pane or starting a container we do not own.
*
* `--pull=never` is irrelevant here: adoption never touches images. The image
* ref is reported only so the UI can show what the user is attaching to.
*/
export async function probeAdoptableContainer(
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>,
modes: SessionMode[] = [],
containerWorkdir?: string
): Promise<AdoptedContainerProbe> {
if (IS_TEST_MODE) {
return {
ok: true,
exists: true,
running: true,
tmuxPath: '/usr/bin/tmux',
availableModes: modes,
workdirExists: true,
};
}
const argv = dockerEngineArgv(docker);
let running = false;
let image: string | undefined;
try {
const { stdout } = await execFileAsync(
argv[0],
[...argv.slice(1), 'inspect', '-f', '{{.State.Running}}\t{{.Config.Image}}', docker.containerName],
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
);
const [state = '', img = ''] = stdout.trim().split('\t');
running = state === 'true';
image = img || undefined;
} catch {
return {
ok: false,
exists: false,
running: false,
error: `container "${docker.containerName}" not found (adoption never creates a container — start it yourself first)`,
};
}
if (!running) {
return {
ok: false,
exists: true,
running: false,
image,
error: `container "${docker.containerName}" exists but is not running (Codeman never starts a container it does not own — start it yourself, then retry)`,
};
}
// One exec resolves tmux plus every requested CLI, so adoption costs a single
// round trip. Binaries are fixed mode names, never user input.
// A mode with no binary of its own (`shell`) is dropped: there is nothing to look up,
// and `command -v ''` would make the whole probe meaningless.
const wanted = modes.filter((m) => !!containerBinaryFor(m));
const probes = ['tmux', ...wanted.map((m) => containerBinaryFor(m) as string)];
// `; exit 0` is load-bearing: the script's status is its LAST command's, so a
// missing final CLI made the whole `sh -lc` exit 1 and the probe reported
// "could not exec into the container" for a container that was perfectly fine.
// Absence of a CLI is data here, not failure — only a real exec error is.
const steps = probes.map((bin) => `command -v ${bin} >/dev/null 2>&1 && echo ${bin}`);
// The workdir is checked INSIDE the container, and that is a fact independent
// of hostWorkspacePath: an owned container gets the host dir bind-mounted at the
// same absolute path at create time, but adoption mounts nothing, so the two
// paths only coincide if the user mounted it there themselves. `docker exec
// --workdir <missing>` fails with an OCI chdir error the pane surfaces as a bare
// "execvp failed", so it is resolved here into an actionable message.
if (containerWorkdir) steps.push(`[ -d ${shellescape(containerWorkdir)} ] && echo __workdir__`);
// Claude Code REFUSES --dangerously-skip-permissions as root. Our own base
// image runs a non-root user so an owned container never hits it; an adopted
// container's user belongs to its owner and is frequently root.
steps.push(`[ "$(id -u)" = 0 ] && echo __root__`);
const script = `${steps.join('; ')}; exit 0`;
try {
const { stdout } = await execFileAsync(
argv[0],
[...argv.slice(1), 'exec', docker.containerName, 'sh', '-lc', script],
{ timeout: DOCKER_PROBE_TIMEOUT_MS }
);
const found = new Set(
stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
);
if (!found.has('tmux')) {
return {
ok: false,
exists: true,
running: true,
image,
error: `container "${docker.containerName}" has no tmux (required for durable sessions; install it inside the container)`,
};
}
const workdirExists = containerWorkdir ? found.has('__workdir__') : undefined;
if (containerWorkdir && !workdirExists) {
return {
ok: false,
exists: true,
running: true,
image,
workdirExists: false,
error: `"${containerWorkdir}" does not exist inside container "${docker.containerName}". Adoption mounts nothing, so the container workdir must already exist there — set it to a path inside the container (it need not match the host workspace path).`,
};
}
return {
ok: true,
exists: true,
running: true,
image,
tmuxPath: 'tmux',
availableModes: modes.filter((m) => {
const bin = containerBinaryFor(m);
return bin ? found.has(bin) : true; // `shell` needs no binary
}),
workdirExists,
runsAsRoot: found.has('__root__'),
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, exists: true, running: true, image, error: `could not exec into the container: ${msg}` };
}
}
/** One directory listing from INSIDE a container, shaped like the host picker's. */
export interface DockerBrowseResult {
path: string;
parent: string | null;
entries: Array<{ name: string; path: string; type: 'directory' | 'file' }>;
error?: string;
}
/**
* List a directory INSIDE a container, for the adoption form's container-workdir
* picker. The host filesystem picker cannot serve this: the path lives in the
* container, and for an adopted container nothing is mounted at a matching host
* location, so the user would otherwise be typing a path blind.
*
* Read-only: one `ls` through `docker exec`, no writes, no lifecycle. The path
* is shell-escaped like every other value this module interpolates, and output
* is parsed as NUL-free lines with a leading type marker so a filename with
* spaces survives.
*/
export async function browseInContainer(
docker: Pick<SessionDocker, 'engine' | 'context' | 'daemonHost' | 'containerName'>,
path: string
): Promise<DockerBrowseResult> {
const target = path && path.startsWith('/') ? path : '/';
const parent = target === '/' ? null : target.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
if (IS_TEST_MODE) return { path: target, parent, entries: [] };
const argv = dockerEngineArgv(docker);
// `-p` marks directories with a trailing slash; `-A` shows dotfiles but not
// the . and .. entries the picker navigates with its own Up control.
const script = `cd ${shellescape(target)} 2>/dev/null && ls -Ap 2>/dev/null || echo __ERR__`;
try {
const { stdout } = await execFileAsync(
argv[0],
[...argv.slice(1), 'exec', docker.containerName, 'sh', '-lc', script],
{ timeout: DOCKER_PROBE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 }
);
if (stdout.includes('__ERR__')) return { path: target, parent, entries: [], error: 'Not a readable directory' };
const base = target.endsWith('/') ? target : `${target}/`;
const entries = stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((name) => {
const isDir = name.endsWith('/');
const clean = isDir ? name.slice(0, -1) : name;
return { name: clean, path: `${base}${clean}`, type: (isDir ? 'directory' : 'file') as 'directory' | 'file' };
})
.sort((a, b) => Number(b.type === 'directory') - Number(a.type === 'directory') || a.name.localeCompare(b.name));
return { path: target, parent, entries };
} catch (err) {
return { path: target, parent, entries: [], error: err instanceof Error ? err.message : String(err) };
}
}
/**
* Resolve the host's IP on the default docker bridge (the address a container
* reaches as `host.docker.internal`), so the server can bind a hooks-only listener
@@ -1069,9 +1401,18 @@ export async function reapOrphanedDockerContainers(
}
const cases = await readDockerCases(configDir);
const expected = new Set(cases.map((c) => c.container ?? dockerContainerName(c.name)));
// ADOPTED containers are never reapable, and this guard is deliberately
// independent of the two conditions that already cover them (we never applied
// the `codeman.managed=1` label filtered on above, and they are referenced by a
// live case so they are in `expected`). An adopted container is the user's
// property; it must survive even if a future edit narrows either condition.
const adopted = new Set(
cases.filter((item) => item.owned === false).map((item) => item.container ?? dockerContainerName(item.name))
);
const reaped: string[] = [];
for (const { name, inst } of rows) {
if (inst !== instance) continue; // only THIS instance's containers
if (adopted.has(name)) continue; // never reap a container we do not own
if (expected.has(name)) continue; // still referenced by a live case
try {
await execFileAsync(bin, ['rm', '-f', name], { timeout: DOCKER_PROBE_TIMEOUT_MS });
@@ -1094,8 +1435,13 @@ export async function probeDockerCliVersion(
mode: SessionMode
): Promise<string | undefined> {
if (IS_TEST_MODE) return undefined;
const bin = mode === 'shell' ? null : mode;
if (!bin) return undefined;
// ⚠️ The MODE NAME IS NOT ALWAYS THE BINARY NAME — `antigravity` runs `agy`. This used
// to pass the mode straight through as the command, which would have probed a binary that
// does not exist. Only claude reaches this today (it is the one CLI with a version gate),
// so nothing was actually broken, but the registry is what makes it correct for the next
// CLI that needs a version.
const bin = getCli(mode)?.discovery.binaries[0];
if (!bin) return undefined; // `shell` has no binary of its own
const argv = dockerEngineArgv(docker);
try {
const { stdout } = await execFileAsync(
+114 -6
View File
@@ -366,19 +366,33 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
// never lands in this config and rotation needs no respawn. If the var/file is
// missing the header is empty — the middleware then allows the request only on
// the plain loopback bypass (tunnel down), same as pre-secret behavior.
const curlCmd = (event: HookEventType) =>
const curlCmd = (event: HookEventType, options: { discardStdout?: boolean } = {}) =>
`HOOK_DATA=$(cat 2>/dev/null || echo '{}'); ` +
`printf '{"event":"${event}","sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$HOOK_DATA" | ` +
// `-k`, same as the statusline exporter: CODEMAN_API_URL is loopback HTTPS with
// a self-signed cert on --https/tailscale installs. Without it curl exits 60,
// the `|| true` swallows it, and ALL SIX hook events die silently: respawn loses
// its definitive idle signals and the wait endpoints lose stop/blocked.
`curl -sk -X POST "$CODEMAN_API_URL/api/hook-event" ` +
`curl -sk ${options.discardStdout ? '-o /dev/null ' : ''}-X POST "$CODEMAN_API_URL/api/hook-event" ` +
`-H 'Content-Type: application/json' ` +
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
`--data @- ` +
`2>/dev/null || true`;
// The same POST with stdout DISCARDED, via curl's own `-o`. UserPromptSubmit is
// one of the hook events whose stdout Claude Code injects into the model's
// context (the CLI's own hook reference: "Exit code 0 - stdout shown to
// Claude"), so an undiscarded curl pastes Codeman's `{"success":true,…}`
// envelope into the user's prompt on every single turn.
// ⚠️ It MUST be curl's flag, not a trailing redirect. `curlCmd` already ends
// `… 2>/dev/null || true`, and in `pipeline || true >/dev/null` the shell binds
// the redirection to `true` — which never runs on the success path — so the
// envelope still reaches stdout. Verified in dash and bash.
// ⚠️ The flag is opt-in so the other events' command text stays byte-identical:
// their stdout feeds the SSE stream harmlessly, and changing it would rewrite
// every workspace's settings file for no gain.
const curlCmdSilent = (event: HookEventType) => curlCmd(event, { discardStdout: true });
return {
hooks: {
Notification: [
@@ -410,6 +424,16 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: HOOK_TIMEOUT_SECONDS }],
},
],
// The pane's LIVE conversation id, reported by the CLI process itself.
// Without it the response viewer has to guess which `<uuid>.jsonl` a pane
// is on after a `/clear`, and the only anchor it can guess from is an
// Enter that went THROUGH Codeman — so a user who attaches to tmux
// directly never gets one and stays pinned to the launch conversation.
UserPromptSubmit: [
{
hooks: [{ type: 'command', command: curlCmdSilent('prompt_submitted'), timeout: HOOK_TIMEOUT_SECONDS }],
},
],
SubagentStop: [
{
hooks: [
@@ -735,9 +759,22 @@ export async function refreshStaleCodemanHooks(casePath: string): Promise<void>
// Approvals Inbox needs the elicitation_complete/elicitation_response
// matchers; their absence marks a pre-inbox hooks block.
const hasElicitationComplete = hooksJson.includes('elicitation_complete');
// The UserPromptSubmit event is what gives a tmux-driven pane a first-hand
// conversation id; its absence marks a pre-prompt_submitted hooks block.
// ⚠️ No surrounding quotes: `hooksJson` is JSON.stringify'd, so the marker
// inside the command reads \"prompt_submitted\" and a quoted needle never
// matches — which would make this gate permanently false and rewrite every
// workspace's settings file on every Claude spawn. The sibling markers are
// quote-free for the same reason.
const hasPromptSubmit = hooksJson.includes('prompt_submitted');
if (
!isOurs ||
(hasSecret && hasBackgroundWake && hasSubagentStopGuard && hasElicitationComplete && !hasTlsFlaglessCurl)
(hasSecret &&
hasBackgroundWake &&
hasSubagentStopGuard &&
hasElicitationComplete &&
hasPromptSubmit &&
!hasTlsFlaglessCurl)
)
return;
const generated = generateHooksConfig();
@@ -1029,9 +1066,80 @@ export async function installAgentSkillInto(skillDir: string): Promise<AgentSkil
*/
export async function seedAgentSessionPreamble(sessionId: string): Promise<void> {
const content = await readFile(join(agentSkillSourceDir(), 'preamble.sh'), 'utf-8');
const cacheDir = process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
await mkdir(cacheDir, { recursive: true });
await writeFile(join(cacheDir, `codeman-agent-${sessionId}.sh`), content, { mode: 0o600 });
await mkdir(agentPreambleCacheDir(), { recursive: true });
await writeFile(agentPreamblePath(sessionId), content, { mode: 0o600 });
}
/** Where the preamble caches live. One formula, shared by seed / remove / prune. */
function agentPreambleCacheDir(): string {
return process.env.XDG_CACHE_HOME || join(homedir(), '.cache');
}
/** `codeman-agent-<sessionId>.sh` in that directory. */
function agentPreamblePath(sessionId: string): string {
return join(agentPreambleCacheDir(), `codeman-agent-${sessionId}.sh`);
}
/** Matches exactly what seedAgentSessionPreamble writes, and nothing else in ~/.cache. */
const AGENT_PREAMBLE_FILE_PATTERN = /^codeman-agent-(.+)\.sh$/;
/** How long a preamble cache with no live session behind it is kept before the sweep takes it. */
export const AGENT_PREAMBLE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
/**
* Drop one session's preamble cache. Called when a session is deleted, which is the
* precise counterpart to seeding it at create: one file per claude session was being
* written and nothing ever removed them (236 leftovers measured on a working machine,
* the oldest three weeks old). Best-effort — a file that will not delete is litter,
* never a reason to fail a teardown.
*/
export async function removeAgentSessionPreamble(sessionId: string): Promise<void> {
await unlink(agentPreamblePath(sessionId)).catch(() => {});
}
/**
* Sweep preamble caches left by sessions that are gone: the delete path above covers
* an orderly teardown, and this covers everything else (a crash, a killed server, a
* session deleted by an older build, another instance's leftovers).
*
* ⚠️ Two guards, and both matter: a file whose session is in `keepSessionIds` is never
* touched however old it is, and everything else needs `maxAgeMs` of age on top. A live
* session's cache is load-bearing — remove it and the skill's two-line loader fails its
* version check mid-run — and the age floor is what keeps a session belonging to
* ANOTHER instance (whose ids this process cannot see) out of the blast radius. Losing
* one is degradation rather than breakage: the §0 fallback block rewrites it.
*
* Returns how many it removed. Best-effort throughout; a missing cache dir is 0.
*/
export async function pruneAgentSessionPreambles(
keepSessionIds: Iterable<string>,
maxAgeMs: number = AGENT_PREAMBLE_MAX_AGE_MS
): Promise<number> {
const cacheDir = agentPreambleCacheDir();
const keep = new Set(keepSessionIds);
const cutoff = Date.now() - maxAgeMs;
let removed = 0;
let entries: string[];
try {
entries = await readdir(cacheDir);
} catch {
return 0;
}
for (const entry of entries) {
const sessionId = AGENT_PREAMBLE_FILE_PATTERN.exec(entry)?.[1];
if (!sessionId || keep.has(sessionId)) continue;
const path = join(cacheDir, entry);
try {
if ((await lstat(path)).mtimeMs > cutoff) continue;
await unlink(path);
removed++;
} catch {
/* best-effort — a vanished or unreadable file is not our problem */
}
}
return removed;
}
/**
+142 -46
View File
@@ -4,9 +4,9 @@ import { join } from 'node:path';
import { homedir } from 'node:os';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { getCli } from './config/cli-registry/registry.js';
import type {
RemoteCase,
RemoteCommandMode,
RemoteHost,
RemoteSessionInfo,
RemoteSshOptions,
@@ -89,39 +89,54 @@ export function remoteLoginShellCommand(command: string): string {
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
}
/**
* The CLI text a location overlay should launch for `mode`, or null when this build has no
* entry for it. `overlays.<location>.command` when the entry names one, otherwise the bare
* binary — which is what every non-claude CLI wants, and why only claude declares a command.
*
* ⚠️ This returns the CLI INVOCATION only. Each location wraps it its own way (remote: a
* login-shell `-c`; docker: `exec`), which is exactly why the overlay stores the unwrapped
* form rather than a ready-made line.
*/
function overlayCliCommand(mode: SessionMode, location: 'remote' | 'docker'): string | null {
const entry = getCli(mode);
if (!entry) return null;
const overlay = entry.overlays[location];
if (overlay && 'disabled' in overlay) return null;
return overlay?.command ?? entry.discovery.binaries[0] ?? null;
}
/**
* The default remote pane command for `mode`.
*
* Agent CLIs (claude/opencode/codex/gemini/antigravity/…) are typically installed under
* per-user paths like ~/.local/bin or ~/.opencode/bin, added to PATH only by the remote
* user's interactive-login shell startup files (~/.zshrc etc.). ssh's remote-command
* execution is neither interactive nor login, so a bare `exec claude` sees only sshd's
* minimal default PATH and fails with "command not found" (exit 127) — confirmed via
* `tmux capture-pane` on the remain-on-exit-preserved dead pane. Route through
* `$SHELL -i -l -c`, the same fix shell mode uses, so PATH is fully resolved first.
*
* ⚠️ The per-CLI half is now READ FROM THE REGISTRY (`overlays.remote`), not from a
* hardcoded `Record<RemoteCommandMode, string>`. The table it replaces duplicated the
* registry exactly, with nothing keeping the two in step — a capability that is both wrong
* and unread is worse than an absent one, because the next person trusts it. Notes that were
* attached to individual rows and are still true:
* - claude carries `--dangerously-skip-permissions` so the remote agent runs
* non-interactively (no trust-folder prompt nothing on the remote can answer);
* `overlays.remote.command` on the claude entry is where that now lives.
* - `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 names one.
* The per-host `commands.*` override remains the escape hatch for every mode.
*/
export function defaultRemoteCommandForMode(mode: SessionMode): string {
// Agent CLIs (claude/opencode/codex/gemini/antigravity) are typically installed
// under per-user paths like ~/.local/bin or ~/.opencode/bin, added to PATH only by
// the remote user's interactive-login shell startup files (~/.zshrc etc.). ssh's
// remote-command execution is neither interactive nor login, so a bare `exec
// claude` sees only sshd's minimal default PATH and fails with "command not
// found" (exit 127) — confirmed via `tmux capture-pane` on the
// remain-on-exit-preserved dead pane. Route through `$SHELL -i -l -c`, the same
// fix already used for shell mode below, so PATH is fully resolved before the
// CLI name is looked up.
const commands: Record<RemoteCommandMode, string> = {
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's
// /etc/passwd entry, so this launches their actual login shell (zsh,
// fish, etc.). -i -l so it sources rc files (~/.zshrc etc.), matching
// the local shell-mode launch.
shell: `exec ${REMOTE_LOGIN_SHELL} -i -l`,
// Mirror the LOCAL claude default so the remote agent runs non-interactively
// (no trust-folder/permission prompt that nothing on the remote answers). The
// per-host `commands.claude` override stays the escape hatch.
claude: remoteLoginShellCommand('claude --dangerously-skip-permissions'),
opencode: remoteLoginShellCommand('opencode'),
codex: remoteLoginShellCommand('codex'),
gemini: remoteLoginShellCommand('gemini'),
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;
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, so
// this launches their actual login shell (zsh, fish, …). `-i -l` so it sources rc files,
// matching the local shell-mode launch. Not templatable as overlay data: the shell is
// whatever the REMOTE passwd says, which is why `shell` is the one arm still written here.
const shellCommand = `exec ${REMOTE_LOGIN_SHELL} -i -l`;
const cli = overlayCliCommand(mode, 'remote');
return cli === null ? shellCommand : remoteLoginShellCommand(cli);
}
export function remoteSshTarget(host: Pick<RemoteHost, 'username' | 'host'>): string {
@@ -264,19 +279,22 @@ export async function checkRemoteTmuxAvailable(
}
/**
* The CLI binary each session mode runs on the remote host. Antigravity's
* binary is `agy` (the mode name is not the command); shell has no CLI to
* probe, so it is absent.
* The CLI binary a session mode runs on the remote host, read from the registry rather than
* from a hardcoded map. `shell` has no CLI to probe and resolves to undefined, which is what
* makes the probe return null for it.
*
* ⚠️ Deriving this CHANGES BEHAVIOUR, deliberately and in one direction. The map it replaces
* listed claude/opencode/codex/gemini/antigravity/pi/omp and simply omitted `grok` and
* `deepseek` — its own comment said the rule was "every mode except shell", so the two were
* an oversight from when those CLIs were added, not a decision. A remote grok or deepseek
* session therefore reported no version at all. It now probes `grok --version` /
* `dsh --version` through the same login-shell wrapper as its siblings.
*
* (`antigravity` is why this cannot be the mode name: its binary is `agy`.)
*/
const REMOTE_CLI_BIN: Partial<Record<SessionMode, string>> = {
claude: 'claude',
opencode: 'opencode',
codex: 'codex',
gemini: 'gemini',
antigravity: 'agy',
pi: 'pi',
omp: 'omp',
};
function remoteCliBin(mode: SessionMode): string | undefined {
return getCli(mode)?.discovery.binaries[0];
}
/**
* Build the SSH command that reads the remote CLI's version (`claude --version`
@@ -292,7 +310,7 @@ export function buildRemoteCliVersionProbeCommand(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions,
mode: SessionMode
): string | null {
const bin = REMOTE_CLI_BIN[mode];
const bin = remoteCliBin(mode);
if (!bin) return null;
return [
...buildSshConnectionArgs(host),
@@ -328,6 +346,84 @@ export async function probeRemoteCliVersion(
}
}
/**
* COD-108 — build the SSH command that asks whether THIS Codeman's durable
* remote tmux session (`-L codeman-remote -s codeman-ssh-<id>`) is still alive
* on the remote host.
*
* `has-session` exits 0 when the session exists, non-zero otherwise (and
* stderr is swallowed). Connection options come from the shared
* `buildSshConnectionArgs` so this probe reaches exactly the hosts the launch
* can reach — same port/identity/proxy/jump-host as `buildRemoteLaunchCommand`.
*/
export function buildRemoteSessionAliveCommand(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions,
remoteSessionName: string
): string {
const [ssh, ...connectionArgs] = buildSshConnectionArgs(host);
const remoteCmd = `tmux -L codeman-remote has-session -t ${shellescape(remoteSessionName)} 2>/dev/null`;
return [ssh, ...connectionArgs, remoteSshTarget(host), shellescape(remoteCmd)].join(' ');
}
/**
* COD-108 — resolve whether THIS Codeman's durable remote tmux session is still
* alive on the remote host, for the auto-reconnect watcher.
*
* Returns:
* - `true` → the remote tmux session exists (the agent is still running
* on the remote; the LOCAL pane died from a transport drop →
* safe to auto-reconnect).
* - `false` → the remote session is gone (the agent exited cleanly and
* the remote tmux tore down; reviving would relaunch a fresh
* agent — must NOT auto-reconnect).
* - `undefined` → probe failed (host unreachable, ssh error, tmux missing).
* Callers MUST treat this as "do not reconnect": an
* unreachable host is not a reason to relaunch the agent.
*
* VITEST guard — returns `true` under test so a real ssh never runs; the
* command construction is covered by `buildRemoteSessionAliveCommand`.
*/
export async function remoteTmuxSessionAlive(
remote: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions,
remoteSessionName: string
): Promise<boolean | undefined> {
if (process.env.VITEST) return true;
const command = buildRemoteSessionAliveCommand(remote, remoteSessionName);
try {
await execAsync(command, { timeout: 15_000 });
return classifyRemoteAliveExit(0, false);
} catch (err) {
const e = err as { code?: unknown; killed?: boolean };
return classifyRemoteAliveExit(typeof e.code === 'number' ? e.code : null, e.killed === true);
}
}
/**
* Map the `has-session` probe's exit status onto the tri-state the watcher
* reads. Pure, so the mapping is unit-tested even though the probe itself is
* VITEST-guarded.
*
* ⚠️ `tmux has-session` prints NOTHING on success (measured: exit 0, empty
* stdout; the failure message goes to stderr), so the exit status is the ONLY
* signal. An earlier version read stdout and therefore classified every live
* remote session as gone, which silently disabled transport-drop reconnects.
*
* - exit 0 → the durable remote session exists → `true`.
* - exit 255 is ssh's own failure (unreachable host, auth, proxy/jump error)
* and a timeout arrives as `killed` with no numeric code: we learned
* nothing about the session → `undefined`, which the watcher treats as
* "do not revive".
* - any other non-zero status is the REMOTE command's: tmux's 1 for a missing
* session, or 127 when tmux is not installed there (no durable session can
* exist without it) → `false`.
*/
export function classifyRemoteAliveExit(code: number | null, killed: boolean): boolean | undefined {
if (killed) return undefined;
if (code === 0) return true;
if (code === null || code === 255) return undefined;
return false;
}
/**
* COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a
* remote host's canonical `-L codeman` socket.
+19 -1
View File
@@ -108,6 +108,15 @@ export interface ReconnectSessionView {
isRemote: boolean;
/** Result of `isPaneDead(muxName)` for this session. */
paneDead: boolean;
/**
* Whether the DURABLE remote tmux session is still alive on the remote host.
* Tri-state: `true` = transport drop with the agent still running (safe to
* reattach); `false` = the remote session is gone (the agent exited cleanly
* via ctrl-c/ctrl-d/exit and the remote tmux tore down); `undefined` =
* unknown/unresolvable. The watcher must NOT revive when the remote session
* is gone or unknown — a clean exit must never auto-relaunch the agent.
*/
remoteAlive: boolean | undefined;
}
/**
@@ -130,7 +139,8 @@ export type ReconnectSkipReason =
| 'in-flight'
| 'not-due'
| 'exhausted'
| 'disabled';
| 'disabled'
| 'remote-gone';
export interface DecideReconnectInput {
session: ReconnectSessionView;
@@ -166,6 +176,14 @@ export function decideReconnect(input: DecideReconnectInput): ReconnectAction {
if (!session.paneDead) return { kind: 'skip', reason: 'pane-alive' };
// Intentional kill / detach must NEVER be auto-revived.
if (guarded) return { kind: 'skip', reason: 'guarded' };
// A clean exit tears down the durable remote tmux (the session's only pane
// exiting destroys it). Reviving is ONLY correct for a transport drop: the
// agent is still running on the remote, so the durable session must still
// exist. When it is gone (or status is unknown — probe failed/unreachable),
// the agent exited intentionally and must not be auto-relaunched (found
// live 2026-08-29: remote omp/opencode ctrl-c/ctrl-d auto-respawned fresh
// sessions; only claude's `|| --resume` accidentally masked it).
if (session.remoteAlive !== true) return { kind: 'skip', reason: 'remote-gone' };
const s = state ?? freshReconnectState();
+29 -10
View File
@@ -2,12 +2,16 @@
* @fileoverview Pure merge/filter logic for the unified session list (COD-121).
*
* Combines four read-only views of a session — live (in-memory `Session`),
* persisted (`state.json`), transcript history (`~/.claude/projects`), and the
* lifecycle audit log — plus mux process stats, into one de-duplicated list
* keyed by sessionId. Transcript-history rows are keyed by the Claude
* conversation UUID (the `.jsonl` filename stem), which diverges from the
* Codeman id for resumed sessions — an alias map (claudeSessionId → Codeman id,
* built from the live/persisted views) folds them into the owning session item.
* persisted (`state.json`), transcript history, and the lifecycle audit log —
* plus mux process stats, into one de-duplicated list keyed by sessionId.
*
* Transcript history is not one source but three, because the CLIs keep their
* conversations in their own stores: Claude's `~/.claude/projects`, omp's
* `~/.omp/agent/sessions` and codex's `~/.codex/sessions`. Each row is keyed by
* whatever id that CLI names the conversation with, which diverges from the
* Codeman id for a resumed session and for every non-Claude one — an alias map
* (claudeSessionId → Codeman id, built from the live/persisted views) folds them
* into the owning session item.
* Higher-precedence sources overwrite scalar fields when present
* (history < lifecycle < persisted < live), while the `sources` array
* always accumulates every contributing view. A "meaningfulness floor" drops
@@ -39,6 +43,11 @@ export type UnifiedSessionItem = {
/** Main repo root a worktree belongs to (#266). */
worktreeRepo?: string;
remote?: boolean;
/**
* Token this row's CLI resumes by, when that is not `sessionId`. Set only from
* a transcript scanner — see the field of the same name on `HistoryInput`.
*/
resumeId?: string;
/** Pinned to the top of the session manager list (COD-139). */
pinned?: boolean;
/** When the session was pinned (epoch ms) — orders the pinned group desc. */
@@ -100,12 +109,21 @@ export type HistoryInput = {
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.
* Set only by a non-claude transcript source (currently omp and codex); 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;
/**
* The token this CLI's own resume command expects, when it is NOT the row's
* `sessionId`. Codex names a thread by an id of its own that lives in the
* rollout, and a live codex session's `sessionId` is Codeman's uuid instead —
* so a resume that reused `sessionId` would ask codex for a thread that does
* not exist. Only a transcript scanner sets this, which is what keeps the two
* kinds of row apart.
*/
resumeId?: string;
};
/** Mux process-stat view. */
@@ -186,6 +204,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
// 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);
overwrite(item, 'resumeId', h.resumeId);
const ms = Date.parse(h.lastModified);
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
}
+202
View File
@@ -0,0 +1,202 @@
/**
* @fileoverview Bridges the legacy per-mode spawn options (`buildSpawnCommand`'s option bag
* in tmux-manager.ts, unchanged on the wire since before this registry existed) onto the CLI
* registry's generic argv engine (`renderLaunch`).
*
* The per-mode `<Mode>Config` objects on `POST /api/sessions` predate the registry and stay
* on the wire for API compatibility (`docs/versioning-policy.md`), so SOMETHING has to know
* which field holds which CLI's config. That knowledge is DATA — `launch.legacyConfigField`
* and `launch.legacyConfigAliases`, declared once per entry in `config/cli-registry/stock.ts`
* — which is what lets this file stay a generic reader rather than a `switch (mode)`.
*
* An entry declaring NO `legacyConfigField` reads its params straight off the top-level
* option bag. That is claude, whose discrete `claudeMode`/`allowedTools`/`model`/
* `resumeSessionId` fields predate the `<Mode>Config` pattern — not a special case for
* claude, just the other of the two shapes the wire has always had.
*
* @module session-cli-registry-bridge
*/
import type { CliEntry } from './config/cli-registry/types.js';
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
import { compareVersions } from './utils/dependency-checker.js';
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
import { launcherDefaultTarget } from './utils/cli-launcher.js';
import { getCli } from './config/cli-registry/registry.js';
import type {
AntigravityConfig,
ClaudeMode,
CodexConfig,
DeepSeekConfig,
EffortLevel,
GeminiConfig,
GrokConfig,
OmpConfig,
OpenCodeConfig,
PiConfig,
} from './types/session.js';
export interface SpawnBridgeOptions {
mode: string;
sessionId: string;
model?: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
resumeSessionId?: string;
effort?: EffortLevel;
sessionName?: string;
claudeCliVersion?: string | null;
}
/**
* The raw legacy config object this entry's params should be read from: the declared
* `<Mode>Config` field, or the option bag itself when none is declared.
*/
function legacyConfigFor(entry: CliEntry, options: SpawnBridgeOptions): Record<string, unknown> | undefined {
const field = entry.launch.legacyConfigField;
if (field === undefined) return options as unknown as Record<string, unknown>;
return (options as unknown as Record<string, unknown>)[field] as Record<string, unknown> | undefined;
}
/**
* Same lookup, addressed by mode rather than by entry, for callers holding only a mode and an
* option bag (tmux-manager's env configuration). Returns undefined for an unregistered mode.
*/
export function legacyConfigForMode(
mode: string,
options: Record<string, unknown>
): Record<string, unknown> | undefined {
const entry = getCli(mode);
if (!entry) return undefined;
return legacyConfigFor(entry, options as unknown as SpawnBridgeOptions);
}
/**
* Build `ParamValues` for every declared `token`/`bool`/`enum` param by reading it out of the
* legacy config object through `legacyConfigAliases` (falling back to the param's own name).
* `engine`-sourced params are skipped — those come from `EngineValues`, never legacy config.
*/
function buildParamsFromLegacyConfig(entry: CliEntry, rawConfig: Record<string, unknown> | undefined): ParamValues {
const params: ParamValues = {};
if (!rawConfig) return params;
const aliases = entry.launch.legacyConfigAliases ?? {};
for (const [paramName, spec] of Object.entries(entry.launch.params)) {
if (spec.type === 'engine') continue;
const legacyKey = aliases[paramName] ?? paramName;
const value = rawConfig[legacyKey];
if (value === undefined) continue;
// Anything that is not already a string or boolean is DROPPED rather than coerced: the
// wire shape is Zod-validated upstream, so a surprise here means something is wrong,
// and `String({})` would happily produce a token nobody intended.
if (typeof value === 'string' || typeof value === 'boolean') {
params[paramName] = value;
}
}
return params;
}
/**
* The env vars this CLI declares in `env.configSetenv`, resolved from its legacy config
* object — i.e. the ones whose value comes from the CALLER rather than the server's own
* environment.
*
* ⚠️ Re-validated here against the declared `ParamSpec` even though the wire shape is already
* Zod-checked upstream. These values reach `tmux setenv`, and for DeepSeek the value IS a
* permission level: a builder must never trust its caller on a security-relevant field, and
* the cost of re-checking an enum is nothing.
*
* A value that fails validation is DROPPED, not defaulted — which is the safe direction: the
* var goes unset, and the CLI falls back to its own default (for dsh, `workspace-write`,
* which asks) rather than to something we guessed.
*/
export function configSetenvValues(
entry: CliEntry,
rawConfig: Record<string, unknown> | undefined
): Record<string, string> {
const out: Record<string, string> = {};
const mappings = entry.env.configSetenv;
if (!mappings || !rawConfig) return out;
const aliases = entry.launch.legacyConfigAliases ?? {};
for (const { name, fromParam } of mappings) {
const spec = entry.launch.params[fromParam];
if (!spec) continue; // schema-validated at load; belt and braces
const raw = rawConfig[aliases[fromParam] ?? fromParam];
if (typeof raw !== 'string') continue;
if (spec.type === 'enum' && !spec.values.includes(raw)) continue;
if (spec.type === 'token' && !matchesPattern(spec.pattern, raw)) continue;
out[name] = raw;
}
return out;
}
/**
* Which `capabilities.gates` are currently satisfied. `resolveVersion` is called AT MOST
* ONCE, and only when the entry actually declares a gate — a `--version` subprocess probe
* has no reason to run for an entry with none.
*/
function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null): Set<string> {
const passed = new Set<string>();
const gateEntries = Object.entries(entry.capabilities.gates);
if (gateEntries.length === 0) return passed;
const cliVersion = resolveVersion();
if (!cliVersion) return passed; // fail-closed: an unknown version satisfies no gate
for (const [name, gate] of gateEntries) {
if (compareVersions(cliVersion, gate.minVersion) >= 0) passed.add(name);
}
return passed;
}
/**
* Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a
* `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall
* back to the local login-shell resolution" — shell has no CLI to template.
*/
export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBridgeOptions): string | undefined {
if (entry.kind === 'shell' || entry.launch.variants.length === 0) return undefined;
const params = buildParamsFromLegacyConfig(entry, legacyConfigFor(entry, options));
const engineValues: EngineValues = {
sessionId: options.sessionId,
// Allowlist-sanitized (Unicode letters/digits + ` . _ : -`, 64 chars), matching
// buildNameCliArgs exactly — sanitizeCliSessionName is the injection guard for this
// value, NOT the `quote: 'double'` escaping on the --name arg (which only makes an
// unsafe value inert, it does not launder one into something meaningful).
sessionName: sanitizeCliSessionName(options.sessionName),
};
// Only a launcher CLI has one, and resolving it means a filesystem scan of the launcher's
// profile tree, so skip the lookup entirely for the eight entries that declare no profile.
if (entry.discovery.launcherProfile !== undefined) {
engineValues.launcherDefaultTarget = launcherDefaultTarget(entry) ?? undefined;
}
// Mirrors buildEffortCliArgs exactly: ultracode carries a fixed settings blob, every other
// level rides a plain `--effort <level>` flag. Reusing the canonical builder here (rather
// than re-deriving the ultracode special case) keeps the EFFORT_LEVELS allowlist and the
// settings-JSON shape single-sourced in session-cli-builder.ts.
const [effortFlag, effortValue] = buildEffortCliArgs(options.effort);
if (effortFlag === '--settings') engineValues.effortSettingsJson = effortValue;
else if (effortFlag === '--effort') engineValues.effortLevel = effortValue;
// Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes
// the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means
// "known to be unresolvable" and must not probe. The probe only ever runs from
// resolveGatesPassed, and only for an entry that actually declares a gate, so this stays
// generic without spawning a stray `claude --version` for every other CLI's launch.
const gatesPassed = resolveGatesPassed(entry, () =>
options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion()
);
return renderLaunch(entry.launch, params, engineValues, gatesPassed);
}
+257 -116
View File
@@ -105,6 +105,9 @@ import {
} from './config/buffer-limits.js';
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { getCli } from './config/cli-registry/registry.js';
import { compileVersionRegex } from './config/cli-registry/patterns.js';
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
import {
buildInteractiveArgs,
buildPromptArgs,
@@ -154,6 +157,11 @@ const WIRE_ACTIVITY_SETTLE_MS = 15_000;
/** Graceful shutdown delay when stopping session (100ms) */
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
// Conversations kept in a pane's chain. A pane that /clears repeatedly would
// otherwise grow state.json without bound; 32 covers any real session's history
// and the oldest entries are the ones whose transcripts Claude Code has pruned.
const MAX_CLAUDE_SESSION_CHAIN = 32;
// Filter out terminal focus escape sequences (focus in/out reports)
// ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences
// eslint-disable-next-line no-control-regex
@@ -175,43 +183,50 @@ const CTRL_L_PATTERN = /\x0c/g;
/** Pattern to split by newlines (CR or LF) */
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
/**
* True for external-CLI run modes (non-Claude) that use their own TUI and output format:
* no Claude transcript, no hooks, no Claude-format token/BashTool parsing.
*
* ⚠️ Reads its OWN capability flag rather than being derived from `hooks` or `kind`, and
* that independence is load-bearing. `shell` has no hooks but is NOT external, so a
* predicate derived from hooks would sweep it in here; `deepseek` HAS hooks but IS
* external. Deriving one of these three predicates from another has already shipped a bug
* (see CliCapabilities' own doc comment), which is why they are three separate fields.
*
* An UNREGISTERED mode is treated as external — the conservative answer, since it disables
* Claude-specific parsing rather than pointing it at output that was never Claude's.
*/
export function isExternalCliMode(mode: SessionMode): boolean {
return (
mode === 'opencode' ||
mode === 'codex' ||
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek' ||
mode === 'omp'
);
return getCli(mode)?.capabilities.external ?? true;
}
/** Display name for a run mode. Falls back to the raw id for an unregistered one. */
function getModeLabel(mode: SessionMode): string {
switch (mode) {
case 'opencode':
return 'OpenCode';
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
case 'antigravity':
return 'Antigravity';
case 'pi':
return 'Pi';
case 'grok':
return 'Grok';
case 'deepseek':
return 'DeepSeek';
case 'omp':
return 'OMP';
case 'shell':
return 'Shell';
case 'claude':
return 'Claude';
}
return getCli(mode)?.label ?? mode;
}
/**
* Does this CLI's launch spec gate anything on its own version?
*
* Only such a CLI needs its version probed at session start — probing one with no gates
* would spawn a `--version` subprocess whose answer nothing reads. Today that is claude
* (the `--name` flag, gated at 2.1.224), which is why the probe used to be written as
* `mode === 'claude'`.
*/
function cliNeedsVersionProbe(mode: SessionMode): boolean {
return Object.keys(getCli(mode)?.capabilities.gates ?? {}).length > 0;
}
/**
* Does this CLI ask for `COLORTERM=truecolor`?
*
* Read off the SAME `env.exports` list that `buildEnvExports()` emits into the tmux
* session, so the attach client and the pane cannot disagree about colour depth. These
* used to be two hand-maintained lists of mode names in two files that had to be edited
* together, with a comment in each asking the next person to remember.
*/
function cliExportsTruecolor(mode: SessionMode): boolean {
return (getCli(mode)?.env.exports ?? []).some((entry) => entry.name === 'COLORTERM' && entry.value === 'truecolor');
}
/**
@@ -240,7 +255,7 @@ function getModeLabel(mode: SessionMode): string {
* vim inside a tmux `shell` session.
*/
export function isAltScreenStripMode(mode: SessionMode): boolean {
return mode === 'codex' || mode === 'claude' || mode === 'gemini';
return getCli(mode)?.capabilities.altScreen === 'strip-full';
}
/**
@@ -429,6 +444,17 @@ export class Session extends EventEmitter {
private _wireActivityAt: number;
private _wireActivitySettleUntil: number;
private _claudeSessionId: string | null = null;
// Set only when the id came from the CLI's own UserPromptSubmit/Stop hook
// payload, keyed on this pane's $CODEMAN_SESSION_ID. That binding is a fact,
// not a correlation: it never consults cwd, so a sibling pane on the same
// folder cannot steal it. Runtime-only — a restart must re-earn it from the
// next hook rather than trust a persisted claim.
private _claudeSessionIdIsFirstHand = false;
// Conversations this pane has been on, oldest first, current last. Grows only
// through a first-hand adoption, so it can never splice in a foreign
// conversation. Persisted, because `/clear` is otherwise unrecoverable: the
// predecessor id exists nowhere else once the pane moves on.
private _claudeSessionChain: string[] = [];
private _totalCost: number = 0;
private _messages: ClaudeMessage[] = [];
private _lineBuffer: string = '';
@@ -456,6 +482,8 @@ export class Session extends EventEmitter {
private _activityStreak: ActivityStreak | null = null; // Unbroken run of PTY repaints (working detection)
private _lastPaneProbeAt = 0; // Throttle for the tmux screen probe
private _lastPaneProbeWorking: boolean | null = null; // Its last verdict (null = could not read)
/** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */
private _workingLineRe: RegExp | undefined = undefined;
private _trustDialogAccepted: boolean = false; // Stops the trust-dialog scan (answered, or given up)
private _trustDialogAttempts = 0; // Keystrokes sent at the trust dialog
private _lastTrustDialogScanAt = 0; // Throttle for the trust-dialog screen read
@@ -651,6 +679,8 @@ export class Session extends EventEmitter {
attachmentHistory?: SessionAttachmentHistoryItem[];
/** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */
lastSubmitAt?: number;
/** Restored conversation chain, oldest first (see `claudeSessionChain`). */
claudeSessionChain?: string[];
/** Restored wall-clock ms of the pane's last output (recovery only; see `_wireActivityAt`). */
lastActivityAt?: number;
/** Remote execution metadata for sessions launched through SSH inside local tmux. */
@@ -694,19 +724,33 @@ 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.
// 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;
// For omp and codex, `claudeSessionId` doubles as the generic "external
// transcript id" alias key mergeUnifiedSessions() folds a history row into
// its owning session by: each mints its OWN thread id, unrelated to this
// Codeman id, so without this the conversation's Past-Sessions row (keyed by
// that thread id) would never merge with its own live/persisted row (keyed
// by this id) — it would just show up a second time. For codex a duplicate
// is worse than cosmetic: the stale row still resumes, so clicking it starts
// a SECOND `codex resume` on a thread already open in another pane.
//
// This covers a RESUMED codex session, which knows its thread id up front. A
// fresh one learns its id only once codex writes the rollout, so it is folded
// from the other side — see the originator stamping in `gatherUnifiedInputs()`.
this._claudeSessionId =
config.resumeSessionId || config.ompConfig?.resumeSessionId || config.codexConfig?.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
// response viewer re-derive the live conversation without waiting for the
// user to type again.
this._lastSubmitAt = config.lastSubmitAt ?? 0;
// Restored chain: its tail is the conversation the CLI was actually on when
// the server stopped, which outranks the launch id seeded just above. The
// FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a
// fact, so the pane re-earns the guess-free path from its next hook.
this._claudeSessionChain = Array.isArray(config.claudeSessionChain) ? [...config.claudeSessionChain] : [];
const restoredConversation = this._claudeSessionChain[this._claudeSessionChain.length - 1];
if (restoredConversation) this._claudeSessionId = restoredConversation;
this._mux = config.mux || null;
this._useMux = config.useMux ?? (this._mux !== null && this._mux.isAvailable());
this._muxSession = config.muxSession || null;
@@ -905,6 +949,20 @@ export class Session extends EventEmitter {
return this._claudeSessionId;
}
/**
* True when `claudeSessionId` came from the CLI's own hook payload rather than
* from the launch config or a history correlation. The response viewer uses it
* to skip guessing entirely — see resolveActiveClaudeSessionIdFromHistory().
*/
get claudeSessionIdIsFirstHand(): boolean {
return this._claudeSessionIdIsFirstHand;
}
/** Conversations this pane has been on, oldest first, current last. */
get claudeSessionChain(): readonly string[] {
return this._claudeSessionChain;
}
/** Docker execution metadata when this session runs inside a container, else undefined. */
get docker(): SessionDocker | undefined {
return this._docker;
@@ -962,11 +1020,38 @@ export class Session extends EventEmitter {
// payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so
// `_handleJsonMessage` never sees `session_id`; hooks are the only signal
// that conveys a post-/clear conversation switch.
adoptClaudeSessionId(newId: string): void {
if (!newId || newId === this._claudeSessionId) return;
//
// `firstHand` marks an id that came from the CLI process itself — a hook
// payload whose delivery was keyed on this pane's $CODEMAN_SESSION_ID. Only
// those extend the chain: a history-correlated guess must never be able to
// write a foreign conversation into this pane's permanent record.
adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void {
if (!newId) return;
if (options.firstHand) {
this._claudeSessionIdIsFirstHand = true;
this._recordClaudeSessionInChain(newId);
}
if (newId === this._claudeSessionId) return;
this._claudeSessionId = newId;
}
/**
* Append to the conversation chain, oldest first. A repeat of the current tail
* is a no-op (every prompt in a conversation reports the same id), and an id
* already in the chain moves to the tail rather than duplicating, which is
* what a `/resume` back to an earlier conversation does.
*/
private _recordClaudeSessionInChain(id: string): void {
if (this._claudeSessionChain[this._claudeSessionChain.length - 1] === id) return;
const existing = this._claudeSessionChain.indexOf(id);
if (existing !== -1) this._claudeSessionChain.splice(existing, 1);
this._claudeSessionChain.push(id);
// A pane that /clears in a loop must not grow this without bound.
if (this._claudeSessionChain.length > MAX_CLAUDE_SESSION_CHAIN) {
this._claudeSessionChain.splice(0, this._claudeSessionChain.length - MAX_CLAUDE_SESSION_CHAIN);
}
}
/** The tmux session name, if the session is running inside a mux */
get muxName(): string | null {
return this._muxSession?.muxName ?? null;
@@ -1400,6 +1485,12 @@ export class Session extends EventEmitter {
respawnBlocked: this._respawnBlocked || undefined,
attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined,
lastSubmitAt: this._lastSubmitAt || undefined,
// Only a chain the CLI's own hooks vouched for is persisted, and only when
// the pane actually moved conversation. Its LAST entry is the live one, so
// it is also what restores `claudeSessionId` across a restart — `start()`
// resets that field to the launch id at three separate points, which is
// why a recovered pane otherwise shows its pre-/clear transcript forever.
claudeSessionChain: this._claudeSessionChain.length > 0 ? [...this._claudeSessionChain] : undefined,
// envOverrides intentionally NOT on the public SessionState type — they must not
// leak into SSE / GET /api/sessions broadcasts (schema allows OPENCODE_*, which
// can carry secrets). For disk persistence, session-manager calls
@@ -1564,16 +1655,11 @@ export class Session extends EventEmitter {
cols: ptyCols,
rows: ptyRows,
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
// COD-75: codex/gemini/antigravity/pi get COLORTERM=truecolor — mirrors buildEnvExports()
// in tmux-manager.ts so the attach client and the tmux session agree.
env: buildMuxAttachEnv(
this.mode === 'codex' ||
this.mode === 'gemini' ||
this.mode === 'antigravity' ||
this.mode === 'pi' ||
this.mode === 'grok' ||
this.mode === 'deepseek'
),
// COD-75: a CLI that declares `export COLORTERM=truecolor` gets it on the ATTACH
// client too. Both sides read the same registry entry, which is what stops the
// attach client and the tmux session from disagreeing — they used to be two
// hand-maintained lists of mode names that had to be edited in lockstep.
env: buildMuxAttachEnv(cliExportsTruecolor(this.mode)),
})
);
} catch (spawnErr) {
@@ -1677,7 +1763,9 @@ export class Session extends EventEmitter {
* session that already carries an explicit id pass through untouched.
*/
private _pinOmpRespawnId(): void {
if (this.mode !== 'omp') return;
// The omp-jsonl transcript reader is what this pin exists to feed, so ask for the
// reader rather than for the CLI's name.
if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl') 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
@@ -1810,7 +1898,11 @@ export class Session extends EventEmitter {
// `Saved to: file://...` — that scanner (and its relaxed trust policy) is
// only enabled for codex-mode sessions. The web server applies the trust
// boundary for each request source.
const attachmentRequests = parseTerminalAttachmentRequests(data, { codexArtifacts: this.mode === 'codex' });
// Codex is the only CLI that announces generated artifacts in its pane output, and it
// is also the only one whose transcript is a rollout file — one implies the other.
const attachmentRequests = parseTerminalAttachmentRequests(data, {
codexArtifacts: getCli(this.mode)?.capabilities.transcript === 'codex-rollout',
});
for (const request of attachmentRequests) {
const seenKey = `${request.source}:${request.path}`;
if (this._attachmentMagicSeen.has(seenKey)) continue;
@@ -1874,8 +1966,8 @@ export class Session extends EventEmitter {
// repaint/alt-screen mode; issue #154). Remote sessions run claude on
// another host, so a local probe wouldn't reflect their version; they get
// their own over-ssh probe below. Cached process-wide, best-effort.
if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) {
const probedVersion = getClaudeCliVersion();
if (cliNeedsVersionProbe(this.mode) && !this._remote && !this._docker && !this._cliVersion) {
const probedVersion = resolveSessionCliVersion(this.mode);
if (probedVersion) {
this._cliVersion = probedVersion;
this.emit('cliInfoUpdated', {
@@ -1891,7 +1983,7 @@ export class Session extends EventEmitter {
// reports the HOST claude (wrong version, and leaving cliVersion undefined
// silently disables wheel-forwarding, #154). Probe the IN-CONTAINER version
// instead — deferred so the container is up after the mux attach below.
if (this.mode === 'claude' && this._docker && !this._cliVersion) {
if (cliNeedsVersionProbe(this.mode) && this._docker && !this._cliVersion) {
const dockerMeta = this._docker;
setTimeout(() => {
if (this._isStopped || this._cliVersion) return;
@@ -1917,7 +2009,7 @@ export class Session extends EventEmitter {
// is the unreliable path #154 was filed for, so remote Claude cases silently
// never got wheel-forwarding (noted in the #205 analysis). Probe over ssh,
// deferred so session start never waits on the ssh round-trip.
if (this.mode === 'claude' && this._remote && !this._cliVersion) {
if (cliNeedsVersionProbe(this.mode) && this._remote && !this._cliVersion) {
const remoteMeta = this._remote;
setTimeout(() => {
if (this._isStopped || this._cliVersion) return;
@@ -1938,6 +2030,11 @@ export class Session extends EventEmitter {
}, REMOTE_CLI_VERSION_PROBE_DELAY_MS);
}
// ⚠️ Hoisted, because the "third reset point" below runs unconditionally
// AFTER the mux branch and would otherwise stomp the restored conversation
// straight back to the launch id.
let restoredConversation: string | undefined;
// If mux wrapping is enabled, create or attach to a mux session
if (this._useMux && this._mux) {
try {
@@ -1978,8 +2075,23 @@ export class Session extends EventEmitter {
// 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;
// on every single respawn. codex needs the same fallback for the same
// reason: its thread id lives in `_codexConfig`, so without it every
// respawn drops a resumed codex session's alias and its Past-Sessions
// row springs back as a duplicate that still resumes.
// ⚠️ A RESTORED mux session is the one case where the launch id is a
// lie: the CLI never stopped, so a `/clear` before the Codeman restart
// already moved it to a conversation `this.id` knows nothing about. The
// persisted chain's tail is that conversation, reported first-hand by
// the CLI's own hook, so it outranks every fallback here. A NEW pane has
// an empty chain and falls through to the resume/alias fallbacks.
restoredConversation = isRestored ? this._claudeSessionChain[this._claudeSessionChain.length - 1] : undefined;
this._claudeSessionId =
restoredConversation ||
this._resumeSessionId ||
this._ompConfig?.resumeSessionId ||
this._codexConfig?.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
@@ -2036,35 +2148,16 @@ export class Session extends EventEmitter {
// Fallback to direct PTY if mux is not used
if (!this.ptyProcess) {
// OpenCode sessions require tmux for env var injection (API keys via setenv)
if (this.mode === 'opencode') {
throw new Error('OpenCode sessions require tmux. Direct PTY fallback is not supported.');
}
// Codex sessions require tmux for OPENAI_API_KEY injection via setenv
if (this.mode === 'codex') {
throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.');
}
// Gemini sessions require tmux for Gemini/Google auth env injection via setenv
if (this.mode === 'gemini') {
throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.');
}
// Antigravity sessions require tmux for env override injection via setenv
if (this.mode === 'antigravity') {
throw new Error('Antigravity sessions require tmux. Direct PTY fallback is not supported.');
}
// Pi sessions require tmux for env override injection via setenv
if (this.mode === 'pi') {
throw new Error('Pi sessions require tmux. Direct PTY fallback is not supported.');
}
// Grok sessions require tmux for XAI_API_KEY / GROK_* injection via setenv
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.');
// Every external CLI requires tmux and has NO direct-PTY fallback, because its
// secrets are injected with socket-scoped `tmux setenv` and so must never touch a
// spawn command line. DeepSeek additionally needs it for the HERDR_* status-bridge
// triple, without which the mode silently loses its definitive idle/blocked signals.
//
// Refusing is the only safe answer: falling back to a direct PTY would start the CLI
// unauthenticated (or, worse, tempt a future change into passing the key as an
// argument, where every process on the box can read it).
if (getCli(this.mode)?.capabilities.requiresMux) {
throw new Error(`${getModeLabel(this.mode)} sessions require tmux. Direct PTY fallback is not supported.`);
}
try {
// Pass --session-id to use the SAME ID as the Codeman session
@@ -2099,10 +2192,19 @@ export class Session extends EventEmitter {
// Set claudeSessionId — when resuming, the Claude conversation ID is the resumed one.
// 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;
// the ompConfig and codexConfig fallbacks or it stomps the mux branch's
// correctly-resolved OMP/codex alias back to this.id on every mux/plain-
// reattach boot recovery (the "third reset point" — see DECISIONS.md).
// For the same reason it needs `restoredConversation`: on a RESTORED mux
// attach the CLI never stopped and may have `/clear`ed before the restart,
// so the launch id is a lie and the chain's tail is the live conversation.
// It is empty on every other path, so those paths keep the alias chain.
this._claudeSessionId =
restoredConversation ||
this._resumeSessionId ||
this._ompConfig?.resumeSessionId ||
this._codexConfig?.resumeSessionId ||
this.id;
this._pid = this.ptyProcess.pid;
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
@@ -2307,12 +2409,14 @@ export class Session extends EventEmitter {
* @param data raw PTY chunk, ANSI included
*/
private _detectInteractiveActivity(data: string): void {
// The prompt line contains "❯" when Claude is waiting for input. It only ARMS
// the check and is NOT evidence the turn ended: Claude redraws the composer
// about once a second all the way through a turn, which is exactly how a
// working session used to flip to idle two seconds in. _confirmIdle() waits
// for the pane to actually go quiet before believing it.
if (data.includes('❯')) {
const workDetect = getCli(this.mode)?.capabilities.workDetect;
// The composer row carries this glyph when the CLI is waiting for input. It only
// ARMS the check and is NOT evidence the turn ended: a CLI redraws its composer
// about once a second all the way through a turn, which is exactly how a working
// session used to flip to idle two seconds in. _confirmIdle() waits for the pane to
// actually go quiet before believing it. A CLI that declares no glyph keeps Claude's,
// which is the glyph every such session has been armed by until now.
if (data.includes(workDetect?.promptGlyph ?? '❯')) {
// Only start a new timeout if we're not already awaiting idle confirmation.
// This prevents status bar redraws (which include the prompt) from resetting it.
if (!this._awaitingIdleConfirmation) {
@@ -2331,9 +2435,10 @@ export class Session extends EventEmitter {
// new status line does not rescue it either (tmux repaints partially, so the
// complete line reaches the PTY only every few tens of seconds). An unbroken run
// of repaints is the signal that survives. See session-activity.ts for the
// measurement. Claude only: an external CLI's TUI has no ❯, so nothing would
// ever arm the idle confirmation and such a session would latch busy forever.
if (!isExternalCliMode(this.mode)) {
// measurement. This needs a pane Codeman can read: without a glyph to arm the idle
// confirmation, a session latches busy forever. A CLI that declares work detection
// supplies its own glyph, and the non-external modes keep the run they always had.
if (workDetect || !isExternalCliMode(this.mode)) {
this._activityStreak = trackActivityStreak(this._activityStreak, Date.now());
// A streak is the TRIGGER to look, not the verdict: typing into the composer
// also produces a steady stream of repaints. The screen settles it, and only
@@ -2368,10 +2473,30 @@ export class Session extends EventEmitter {
if (now - this._lastPaneProbeAt < PANE_PROBE_MIN_INTERVAL_MS) return this._lastPaneProbeWorking;
this._lastPaneProbeAt = now;
const text = this._mux.capturePaneText?.(this._muxSession.muxName) ?? null;
this._lastPaneProbeWorking = text === null ? null : CLAUDE_WORKING_LINE_PATTERN.test(text);
this._lastPaneProbeWorking = text === null ? null : this._workingLinePattern().test(text);
return this._lastPaneProbeWorking;
}
/**
* The regex matching this CLI's "a turn is running" status line.
*
* Compiled once per session and cached: `_probePaneWorking` runs it against a whole
* pane capture on a timer, and the throttled text detector runs it against every
* accumulated chunk. A CLI that declares no pattern falls back to Claude's, which is
* the pattern every session used before the registry carried one.
*/
private _workingLinePattern(): RegExp {
if (this._workingLineRe === undefined) {
const src = getCli(this.mode)?.capabilities.workDetect?.workingLine;
// Same guard the schema applies, not a second opinion: `compileVersionRegex()` is
// what keeps a nested quantifier out of this pattern, and this one runs on the PTY
// hot path. It returns null rather than throwing, and Claude's pattern is the
// fallback every session used before the registry carried one.
this._workingLineRe = (src ? compileVersionRegex(src) : null) ?? CLAUDE_WORKING_LINE_PATTERN;
}
return this._workingLineRe;
}
/**
* Mark the pane as working. Idempotent: `working` is emitted on the transition
* only, so the per-chunk detectors can all call it freely.
@@ -2442,7 +2567,7 @@ export class Session extends EventEmitter {
* this capture or a resume/respawn that already resolved one).
*/
private _maybeCaptureOmpSessionId(): void {
if (this.mode !== 'omp' || this._claudeSessionId !== this.id) return;
if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl' || this._claudeSessionId !== this.id) return;
try {
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
if (resolvedId) {
@@ -2460,10 +2585,6 @@ export class Session extends EventEmitter {
* PTY data chunk. Receives accumulated raw data to process in one batch.
*/
private _processExpensiveParsers(rawData: string): void {
// Skip Claude-specific parsers for external CLI sessions (Ralph tracker,
// BashToolParser, token + CLI-info parsing all depend on Claude's output format).
if (isExternalCliMode(this.mode)) return;
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
let _cleanData: string | null = null;
const getCleanData = (): string => {
@@ -2473,6 +2594,19 @@ export class Session extends EventEmitter {
return _cleanData;
};
// Work detection by status line, ahead of the external-CLI gate below. The pattern
// comes from the CLI's own registry entry, so this is the one parser here that is not
// Claude-specific — and it sat under that gate, which is why an external CLI reported
// itself idle through an entire turn. Guarded on the descriptor so a CLI without one
// still skips the ANSI strip the gate used to save it.
if (!this._isWorking && getCli(this.mode)?.capabilities.workDetect) {
if (this._workingLinePattern().test(getCleanData())) this._markWorking();
}
// Skip Claude-specific parsers for external CLI sessions (Ralph tracker,
// BashToolParser, token + CLI-info parsing all depend on Claude's output format).
if (isExternalCliMode(this.mode)) return;
// Forward to Ralph tracker to detect Ralph loops and todos
// (opencode sessions already returned early at line 1209)
if (this._ralphTracker.enabled || !this._ralphTracker.autoEnableDisabled) {
@@ -2504,16 +2638,13 @@ export class Session extends EventEmitter {
this.parseTaskDescriptionsFromTerminalData(getCleanData());
}
// Work detection (text-based, needs clean data: the status line is coloured,
// so raw data has escape sequences between the `…` and the elapsed timer).
// Only check if a faster path didn't already trigger working state.
// Legacy gerunds, Claude-only. The status-line pattern above already ran for every
// CLI that declares one, so this adds only the older wording. Current Claude
// randomizes the word ("Actualizing…", "Finagling…"), so these catch a fraction of
// turns; the pattern above and the activity streak carry the rest.
if (!this._isWorking) {
const cleanData = getCleanData();
if (
CLAUDE_WORKING_LINE_PATTERN.test(cleanData) ||
// Legacy gerunds. Current Claude randomizes the word ("Actualizing…",
// "Finagling…"), so these catch only a fraction of turns; the pattern
// above and the activity streak carry the rest.
cleanData.includes('Thinking') ||
cleanData.includes('Writing') ||
cleanData.includes('Reading') ||
@@ -3229,6 +3360,16 @@ export class Session extends EventEmitter {
}
}
/**
* A prompt was submitted, reported by the CLI's own UserPromptSubmit hook.
* `_trackSubmit` only sees input that flows through Codeman's write path, so
* a pane the user drives by attaching to tmux directly never stamped this and
* `lastSubmitAt` stayed 0 for its whole life.
*/
markPromptSubmitted(): void {
this._lastSubmitAt = Date.now();
}
/**
* Per-client highest-applied input sequence, for exactly-once input delivery.
* Keyed by the web client's stable `clientId`. Bounded so many devices over a
+357 -686
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -110,6 +110,10 @@ export type HookEventType =
| 'stop'
| 'teammate_idle'
| 'task_completed'
// Claude Code's UserPromptSubmit. The payload's `session_id` is the pane's
// LIVE conversation id, reported by the CLI process itself, so it survives a
// `/clear` without any cwd/timestamp correlation.
| 'prompt_submitted'
// 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.
@@ -153,6 +157,20 @@ export interface CaseInfo {
location?: 'local' | 'linked-local' | 'remote' | 'docker';
/** Whether this is a linked local folder */
linked?: boolean;
/**
* Present when Codeman scaffolded this case directory for an AGENT-spawned session
* (the packaged skill's workers, or any spawn naming a parent session), read back
* from the case's own marker file — see `src/agent-case-marker.ts`. Absent for every
* case a human created, linked or cloned, which is what makes it usable as the
* "safe to clean up" signal in the Manage tab.
*/
agentCreated?: {
createdAt: string;
createdBy: string;
parentSessionId?: string;
parentSessionName?: string;
mode?: string;
};
/** Remote case metadata for display and session creation */
remote?: {
hostId: string;
@@ -167,9 +185,46 @@ export interface CaseInfo {
image?: string;
path: string;
network?: string;
/**
* CLIs available INSIDE the container. A container case runs its agents in
* the container, so HOST CLI availability says nothing about what it can
* run. Absent = unknown (an owned container runs our base image, which ships
* every CLI), which the UI reads as "do not gate".
*/
availableModes?: string[];
/**
* `false` for an ADOPTED container (mirror of `DockerCase.owned`); absent = owned.
*
* ⚠️ The UI needs this to read a FAILED container probe correctly. For an adopted
* case a missing container is a real fault worth reporting, because the user is the
* only one who can start it. For an owned case it is the NORMAL state before the
* first session: the container is created on demand by the launch chain, so treating
* "not found" as a fault there hid every agent mode behind an error telling the user
* to start a container Codeman was about to create itself.
*/
owned?: boolean;
};
}
/**
* One agent-created case as `GET /api/cases/agent-created` reports it: the cleanup
* view over `CaseInfo.agentCreated`, with the two facts a human needs before deleting
* a directory — whether an agent is still working in it, and when it was last touched.
*/
export interface AgentCaseSummary {
name: string;
path: string;
createdAt: string;
createdBy: string;
parentSessionId?: string;
parentSessionName?: string;
mode?: string;
/** A live session's working directory is this case — deleting it would pull the rug. */
inUse: boolean;
/** Directory mtime, so "nothing has touched this in a week" is answerable. */
modifiedAt?: string;
}
// ========== Error Handling Utilities ==========
/**
+49
View File
@@ -244,6 +244,33 @@ export interface DockerCase {
containerWorkdir?: string;
/** Container name (default codeman-case-<slug>). */
container?: string;
/**
* Whether THIS Codeman created the container (mirror of `SessionRemote.owned`).
*
* - `true` (default for cases Codeman linked/quick-created): we own the
* container; drift may recreate it, case-delete may `docker rm -f` it, and
* the launch chain may create + start it.
* - `false` (ADOPTED: an already-running container the user built and runs
* themselves): Codeman must never create, start, stop, restart or remove it.
* The launch chain fails closed when the container is missing or not running
* instead of touching its lifecycle, drift is not evaluated (there is no
* `codeman.confighash` label to compare), and no credential seed is copied
* into its HOME. Only the in-container tmux session is ever created or
* killed — exactly the `owned:false` remote-SSH contract.
*
* Absent is treated as owned (cases persisted before this field existed were
* all created by us).
*/
owned?: boolean;
/**
* CLIs found INSIDE the container by the adoption preflight. A container case
* runs its agents in the container, so host CLI availability says nothing about
* what this case can run — the base image ships every CLI, and an adopted
* container ships whatever its owner installed. Absent = unknown (owned cases,
* or a case linked before this field existed), which callers read as "do not
* gate".
*/
availableModes?: SessionMode[];
/** Last captured Claude conversation id, replayed via --resume on a fresh launch. */
lastClaudeSessionId?: string;
}
@@ -276,6 +303,19 @@ export interface SessionDocker {
extraExecArgs?: string[];
/** Stable hash of the drift-relevant create args (recreate-on-drift detection). */
configHash?: string;
/**
* Whether the container's exec user is root. Claude Code REFUSES
* `--dangerously-skip-permissions` as root, and an adopted container's user
* belongs to its owner, so the flag is omitted rather than letting the pane
* die with a message only visible inside the container.
*/
runsAsRoot?: boolean;
/**
* Mirror of `DockerCase.owned`, flattened onto the live session so every
* lifecycle decision (launch chain, drift, stop, remove) can see it without
* re-reading docker-cases.json. Absent = owned. See `DockerCase.owned`.
*/
owned?: boolean;
}
/**
@@ -648,6 +688,15 @@ export interface SessionState {
* again until the pane's own Enter is known.
*/
lastSubmitAt?: number;
/**
* Claude conversations this pane has been on, oldest first, current last.
* Written ONLY from a first-hand `UserPromptSubmit`/`Stop` hook payload —
* never from the history correlation — so it cannot record a sibling pane's
* conversation. Persisted because `/clear` is otherwise unrecoverable: once
* the pane moves on, the predecessor id exists nowhere else, and the last
* entry is what re-pins `claudeSessionId` past `start()`'s three resets.
*/
claudeSessionChain?: string[];
/**
* PTY-exit circuit breaker tripped — respawn blocked until an explicit restart
* (COD-118). Runtime-only: never restored on boot (fresh server = fresh breaker).
+54 -3
View File
@@ -7,6 +7,10 @@
* (see `dataPath('update-status.json')`) that the browser polls across the
* restart boundary.
*
* The Docker Compose deployment updates in place too (same script, same status
* file) — see `docs/docker-self-update.md` for how the container restarts itself
* and what the environment gate refuses.
*
* Backend logic: `src/web/self-update.ts`. Routes: `src/web/routes/system-routes.ts`
* (`/api/system/update/check`, `POST /api/system/update`, `/api/system/update/status`).
*
@@ -17,11 +21,56 @@
* Which init system supervises the running server (decides how we restart it).
* `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI
* login): restart works by killing the server and letting launchd respawn it.
* `docker-compose` = the Compose deployment (`docker/docker-compose.yaml`): the
* "restart" is the server exiting so the container's `restart: unless-stopped`
* policy relaunches it on the freshly built `dist/`.
*/
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'none';
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'docker-compose' | 'none';
/** How Codeman was installed — only `git` installs can self-update in place. */
export type InstallKind = 'git' | 'npm' | 'unknown';
/**
* How Codeman was installed. `git` and `docker-compose` can self-update in
* place; `docker-compose` is a git checkout bind-mounted into the container, so
* the pull/build happen on the host filesystem and survive container recreation.
*/
export type InstallKind = 'git' | 'docker-compose' | 'npm' | 'unknown';
/**
* Why an in-place container update is refused. Each is derived mechanically from
* the target release's own files — nothing here depends on a human remembering
* to declare something at release time.
*
* - `dockerfile-changed` / `compose-changed`: the release changes the ENVIRONMENT,
* which a self-restart cannot apply (a restart reuses the existing container's
* image and config). Needs a rebuild + recreate from the host.
* - `env-keys-missing`: the release's `docker/.env.example` gained keys the user's
* `docker/.env` has no value for. Compose interpolates an unset `${VAR}` to the
* EMPTY STRING and starts anyway, so without this check a new required setting
* arrives as a silently blank env var.
* - `no-auto-restart`: the container's restart policy would not bring it back
* after the server exits, so applying the update would take Codeman down.
*/
export type EnvironmentBlockerKind = 'dockerfile-changed' | 'compose-changed' | 'env-keys-missing' | 'no-auto-restart';
/** One reason an in-place container update is refused, with UI-ready text. */
export interface EnvironmentBlocker {
kind: EnvironmentBlockerKind;
/** One-line explanation shown in App Settings → Updates. */
message: string;
/** Optional specifics (e.g. the names of the missing env keys). */
details?: string[];
}
/**
* Result of the environment gate for a candidate release. `checked: false` means
* the gate did not run (not a container install, or the target tag's files could
* not be read) — callers must not treat that as "no blockers".
*/
export interface EnvironmentGate {
checked: boolean;
blockers: EnvironmentBlocker[];
/** The host command that resolves every blocker. */
hostCommand: string;
}
/**
* Lifecycle of a single update run. `idle`/`completed`/`failed`/
@@ -96,6 +145,8 @@ export interface UpdateCheckResult {
/** epoch ms of the check. */
checkedAt: number;
source: 'github-api' | 'git-ls-remote' | 'none';
/** Environment gate for THIS candidate release (container installs only). */
environment?: EnvironmentGate;
error?: string;
}
+8 -10
View File
@@ -7,8 +7,8 @@
* @module utils/antigravity-cli-resolver
*/
import { join } from 'node:path';
import { homedir } from 'node:os';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
@@ -16,14 +16,12 @@ import {
} from './cli-executable-resolver.js';
/** Common directories where the Antigravity CLI binary may be installed */
const ANTIGRAVITY_SEARCH_DIRS = [
join(homedir(), '.local', 'bin'),
join(homedir(), '.antigravity', 'bin'),
'/usr/local/bin',
join(homedir(), '.bun', 'bin'),
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const ANTIGRAVITY_SEARCH_DIRS = (): string[] => (getCli('antigravity')?.discovery.searchDirs ?? []).map(expandHome);
const ANTIGRAVITY_NOT_FOUND =
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash';
+13 -3
View File
@@ -207,13 +207,23 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
export function createCliExecutableResolver<T = undefined>(
options: {
binary: string;
searchDirs: string[];
/**
* Where to look after the process PATH. A THUNK is accepted alongside an array so a
* caller sourcing its dirs from the CLI registry can defer the lookup: passing
* `searchDirs: FOO_SEARCH_DIRS()` evaluates at module import, which froze the dirs
* before a user `clis.json` or a `reloadCliRegistry()` could be seen. Resolved on each
* probe and each `diagnostics()` call — a handful of string ops, and only when a probe
* actually runs.
*/
searchDirs: string[] | (() => string[]);
validateCandidate?: (path: string) => CandidateValidation<T>;
/** Clock injection for tests driving the failure backoff. Defaults to `Date.now`. */
now?: () => number;
},
host: CliResolverHost = createProductionCliResolverHost()
): CliExecutableResolver<T> {
const resolveSearchDirs = (): string[] =>
typeof options.searchDirs === 'function' ? options.searchDirs() : options.searchDirs;
if (!SAFE_BINARY_NAME.test(options.binary)) {
throw new Error(`Unsafe CLI binary name: ${options.binary}`);
}
@@ -248,7 +258,7 @@ export function createCliExecutableResolver<T = undefined>(
cached = accept(host.findOnProcessPath(options.binary), 'process-path');
if (!cached) {
for (const dir of options.searchDirs) {
for (const dir of resolveSearchDirs()) {
cached = accept(join(dir, options.binary), 'common-directory');
if (cached) break;
}
@@ -271,7 +281,7 @@ export function createCliExecutableResolver<T = undefined>(
processPath: host.processPath,
shellPath: host.shellPath,
shellArgs: [...host.shellArgs],
searchDirs: [...options.searchDirs],
searchDirs: [...resolveSearchDirs()],
}),
};
}
+113
View File
@@ -0,0 +1,113 @@
/**
* @fileoverview Implementations of the LAUNCHER profiles named by `discovery.launcherProfile`.
*
* A launcher CLI's binary is not the agent — it boots some further target — so two questions
* the registry normally answers from the binary alone have to be asked of that target:
*
* - `isCliRunnable(id)` — stricter than "is the binary on disk?"
* - `launcherDefaultTarget(entry)` — what to launch when the caller names no target
*
* The profile NAMES and their validation live in `config/cli-registry/profiles.ts`, which is
* kept free of imports so `schema.ts` can validate a name at load time. The implementations
* live here because they reach into resolvers that reach back into the registry, and holding
* them next to the names would close an import cycle.
*
* ⚠️ Everything in this file is keyed by PROFILE NAME, never by CLI id. A new launcher CLI
* adds a profile here and names it from its entry; it does not add a branch anywhere else.
*
* @module utils/cli-launcher
*/
import { isDeepSeekRunnable, resolveDefaultDeepSeekProfile } from './deepseek-cli-resolver.js';
import { getCli } from '../config/cli-registry/registry.js';
import type { CliEntry } from '../config/cli-registry/types.js';
import { missingCliMessage, resolveCliBinDir } from './cli-resolver.js';
interface LauncherProfile {
/** Is the launcher usable, given that its binary resolved? */
isRunnable(): boolean;
/** The target to launch when the caller named none, or null when there is none. */
defaultTarget(): string | null;
/**
* Why a session cannot start, or null when it can — including why a SPECIFICALLY
* requested target will not work, which "is it runnable" alone cannot say.
*/
launchError(requestedTarget?: string): Promise<string | null>;
}
const LAUNCHER_PROFILES: Record<string, LauncherProfile> = {
// `dsh` launches a profile from $DSH_HOME/profiles/<name>. DeepSeek ships only
// `web`/`headless`/`base`, none of which can drive a terminal pane, so the terminal front
// door is always third-party: a perfectly-installed dsh with no TUI profile is installed
// but NOT runnable, and the two questions have genuinely different answers.
'deepseek-profile': {
isRunnable: isDeepSeekRunnable,
defaultTarget: resolveDefaultDeepSeekProfile,
// Three distinct, actionable messages (binary missing / no pane-capable profile /
// the named profile is not pane-capable). Worth keeping distinct: a pane that dies
// instantly is the most confusing failure this mode can produce, and "not installed"
// would send the user to fix the wrong thing.
launchError: async (requestedTarget) => {
const { resolveDeepSeekLaunchError } = await import('./deepseek-cli-resolver.js');
return resolveDeepSeekLaunchError(requestedTarget);
},
},
};
/**
* Why a session in this mode cannot start, or null when it can.
*
* For an ordinary CLI this is just "is the binary there?", answered with the not-found
* message that names where resolution looked. For a launcher CLI it defers to that CLI's own
* profile, which can be far more specific.
*
* `rawConfig` is the caller's per-CLI config object, read for the target the caller named
* (declared as `discovery.launcherTargetParam`) so the error can be about THAT target.
*/
export async function resolveCliLaunchError(mode: string, rawConfig?: Record<string, unknown>): Promise<string | null> {
const entry = getCli(mode);
if (!entry) return null;
const profileName = entry.discovery.launcherProfile;
if (profileName !== undefined) {
const profile = LAUNCHER_PROFILES[profileName];
if (!profile) return `${entry.label} is not runnable: its launcher profile is unavailable in this build.`;
const targetParam = entry.discovery.launcherTargetParam;
const requested = targetParam ? rawConfig?.[targetParam] : undefined;
return profile.launchError(typeof requested === 'string' ? requested : undefined);
}
// No binary to find (`shell`) is never an error.
if (entry.discovery.binaries.length === 0) return null;
return resolveCliBinDir(mode) === null ? missingCliMessage(mode) : null;
}
/**
* Is this CLI actually usable? For an ordinary CLI that is exactly "its binary resolved".
* For a launcher it is that AND whatever its profile demands.
*
* ⚠️ A named-but-unimplemented profile fails CLOSED. In practice `schema.ts` rejects such an
* entry at load time, so this is the second line of defence rather than the first — but the
* direction matters: offering a Run that always fails is worse than reporting unavailable.
*/
export function isCliRunnable(id: string): boolean {
const entry = getCli(id);
if (!entry) return false;
// No binary to find (`shell`): tmux-manager resolves the login shell in code.
const resolved = entry.discovery.binaries.length === 0 ? true : resolveCliBinDir(id) !== null;
const profileName = entry.discovery.launcherProfile;
if (profileName === undefined) return resolved;
const profile = LAUNCHER_PROFILES[profileName];
if (!profile) return false;
return resolved && profile.isRunnable();
}
/**
* The launcher's default target, for the `launcherDefaultTarget` engine value. Null for
* every non-launcher CLI, which is what makes the corresponding launch arg drop out.
*/
export function launcherDefaultTarget(entry: CliEntry): string | null {
const profileName = entry.discovery.launcherProfile;
if (profileName === undefined) return null;
return LAUNCHER_PROFILES[profileName]?.defaultTarget() ?? null;
}
+269
View File
@@ -0,0 +1,269 @@
/**
* @fileoverview Registry-driven CLI binary resolution: look up ANY registered CLI's binary
* directory, version and not-found message from its `CliEntry`, with no per-CLI branch.
*
* This is a LAYER over `cli-executable-resolver.ts`, not a replacement for it. That module
* still owns the lookup chain (process PATH → the entry's search dirs → an interactive
* login shell), the negative cache and its doubling backoff, the marker-fenced login-shell
* parse, the `SIGKILL` timeouts and the vitest hermeticity gate — all of it deliberately
* untouched here, because those guards are load-bearing and separately tested. What this
* module adds is: where the parameters come from (the registry, rather than seven
* hand-written constant blocks) and what makes a candidate acceptable.
*
* CANDIDATE VALIDATION runs in a fixed order, and the order is the point:
*
* 1. IDENTITY (`discovery.identity`) — does the binary say it is the program we meant?
* Checked FIRST, because a version probe cannot tell an impostor from the real thing:
* Debian's `dsh` (dancer's shell) answers `--version` perfectly happily, and npm
* carries squatters for both `pi` and `grok`.
* 2. VERSION (`discovery.version`) — does its version output have the right shape? With
* `requireVersionMatch`, a mismatch means ABSENT rather than present-with-unknown-
* version, which is what a short, generic binary name needs.
*
* Both probes EXECUTE the candidate, which is exactly why both are gated off under vitest:
* a suite must never depend on — let alone run — whatever binary of that name the machine
* running it happens to carry. Tests inject probes instead.
*
* @module utils/cli-resolver
*/
import { execFileSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { compileVersionRegex, MAX_VERSION_OUTPUT } from '../config/cli-registry/patterns.js';
import { getCli, resolveInstallCommandForPlatform } from '../config/cli-registry/registry.js';
import { getClaudeCliVersion } from './claude-cli-resolver.js';
import type { CliEntry } from '../config/cli-registry/types.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
type CliExecutableResolver,
type CliResolverHost,
} from './cli-executable-resolver.js';
/** Expand a leading `~` to the home directory. Nothing else is interpreted. */
export function expandHome(dir: string): string {
if (dir === '~') return homedir();
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
return dir;
}
/**
* Run `<binPath> <arg>` and return its trimmed output, truncated to the cap a
* config-supplied regex is allowed to see.
*
* Returns null under vitest — see this file's header. This is defense in depth rather than
* the only gate (the shared resolver host is already inert under vitest), and it is what
* makes the "resolve nothing even against a real on-disk fixture" behaviour hold for a
* test that opts back into real filesystem IO.
*/
function probeCommandOutput(binPath: string, arg: string, logPrefix: string): string | null {
if (process.env.VITEST) return null;
try {
return execFileSync(binPath, [arg], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
// execFileSync's `timeout` only SENDS the signal and then keeps waiting. A stuck or
// hostile binary that ignores SIGTERM would survive it and block the server.
killSignal: 'SIGKILL',
})
.trim()
.slice(0, MAX_VERSION_OUTPUT);
} catch (err) {
console.warn(`[${logPrefix}] Ignoring ${binPath}: "${arg}" failed (${(err as Error).message})`);
return null;
}
}
/** What a candidate probe reports back. `version` is undefined when none was declared. */
export interface CliCandidateProbeResult {
accepted: boolean;
version?: string;
}
/** A probe hook, so tests can drive resolution without executing anything. */
export type CliCandidateProbe = (binPath: string, entry: CliEntry) => CliCandidateProbeResult;
/**
* The production probe: identity first, then version. A CLI declaring neither is accepted
* on existence alone, which is the common case (opencode, codex, gemini, antigravity).
*/
export function probeCliCandidate(binPath: string, entry: CliEntry): CliCandidateProbeResult {
const logPrefix = `CliResolver:${entry.id as string}`;
const { identity, version } = entry.discovery;
if (identity) {
const pattern = compileVersionRegex(identity.regex);
if (!pattern) {
console.warn(`[${logPrefix}] identity.regex was rejected as unsafe; refusing every candidate.`);
return { accepted: false };
}
const out = probeCommandOutput(binPath, identity.arg, logPrefix);
if (out === null || !pattern.test(out)) {
console.warn(`[${logPrefix}] Ignoring ${binPath}: "${identity.arg}" did not identify it as ${entry.label}.`);
return { accepted: false };
}
}
if (!version) return { accepted: true };
const out = probeCommandOutput(binPath, version.arg, logPrefix);
const pattern = version.regex ? compileVersionRegex(version.regex) : null;
const found = out !== null && pattern ? (pattern.exec(out)?.[1] ?? undefined) : undefined;
if (found === undefined && version.requireVersionMatch) {
// A `which` hit is not evidence for a short, generic or squatted binary name.
console.warn(`[${logPrefix}] Ignoring ${binPath}: "${version.arg}" printed ${JSON.stringify(out?.slice(0, 80))}`);
return { accepted: false };
}
return { accepted: true, version: found };
}
/**
* A resolver for one registry entry. An entry may declare several binary names (first hit
* wins), so this holds one underlying resolver per name and returns the first that
* resolves — which is also what keeps each name's own negative cache and backoff intact.
*/
interface RegistryResolver {
resolveDir(): string | null;
getVersion(): string | null;
notFoundMessage(base: string): string;
}
function createRegistryResolver(
entry: CliEntry,
probe: CliCandidateProbe = probeCliCandidate,
host?: CliResolverHost,
now?: () => number
): RegistryResolver {
const searchDirs = entry.discovery.searchDirs.map(expandHome);
const perBinary: CliExecutableResolver<string>[] = entry.discovery.binaries.map((binary) =>
createCliExecutableResolver<string>(
{
binary,
searchDirs,
validateCandidate: (binPath) => {
const result = probe(binPath, entry);
return result.accepted ? { accepted: true, metadata: result.version } : { accepted: false };
},
now,
},
host
)
);
const first = () => {
for (const resolver of perBinary) {
const resolution = resolver.resolve();
if (resolution) return resolution;
}
return null;
};
return {
resolveDir: () => first()?.directory ?? null,
getVersion: () => first()?.metadata ?? null,
notFoundMessage: (base) =>
// Diagnostics come from the FIRST declared binary: every name shares the same search
// dirs, PATH and login shell, so the extra copies would say the same thing twice.
perBinary.length > 0 ? formatCliNotFoundMessage(base, perBinary[0].diagnostics()) : base,
};
}
/**
* Build an isolated resolver for `entry` around an injected probe, host and clock — the
* test seam. Omitting `probe` keeps the ambient, VITEST-gated one, which is exactly what
* the hermeticity tests exercise.
*/
export function createCliResolverForTest(
entry: CliEntry,
probe?: CliCandidateProbe,
host?: CliResolverHost,
now?: () => number
): RegistryResolver {
return createRegistryResolver(entry, probe ?? probeCliCandidate, host, now);
}
/**
* One memoized resolver per id, for the process lifetime — the same caching the per-CLI
* modules already do for themselves, just keyed by id so generic code holding only a
* `CliId` string can resolve a CLI it knows nothing else about, custom entries included.
*/
const resolvers = new Map<string, RegistryResolver>();
function resolverFor(id: string): RegistryResolver | null {
const cached = resolvers.get(id);
if (cached) return cached;
const entry = getCli(id);
// `shell` declares no binary: tmux-manager resolves the real login shell in code.
if (!entry || entry.discovery.binaries.length === 0) return null;
const resolver = createRegistryResolver(entry);
resolvers.set(id, resolver);
return resolver;
}
/**
* Drop the memoized resolver for `id` so the next lookup re-probes from scratch instead of
* replaying a cached negative result and waiting out a backoff window already in progress.
*/
export function invalidateCliResolverCache(id?: string): void {
if (id === undefined) resolvers.clear();
else resolvers.delete(id);
}
/** The directory containing this CLI's binary, or null when it cannot be found. */
export function resolveCliBinDir(id: string): string | null {
return resolverFor(id)?.resolveDir() ?? null;
}
/** Is this CLI's binary present? Note: for a launcher CLI this is NOT the same as runnable. */
export function isCliAvailable(id: string): boolean {
return resolveCliBinDir(id) !== null;
}
/** The version the resolved binary reported, or null when unresolved or none was declared. */
export function resolveCliVersion(id: string): string | null {
return resolverFor(id)?.getVersion() ?? null;
}
/**
* "CLI not found" message for `id`, with bounded PATH/login-shell/search-dir diagnostics
* appended so the error names where resolution actually looked. Returns null for an id with
* no binary to find (`shell`) or one that is not registered at all.
*/
export function missingCliMessage(id: string): string | null {
const entry = getCli(id);
if (!entry || entry.discovery.binaries.length === 0) return null;
const install = resolveInstallCommandForPlatform(entry);
const base = install
? `${entry.label} CLI not found. Install with: ${install}`
: `${entry.label} CLI not found (looked for ${entry.discovery.binaries.join(', ')}).`;
return resolverFor(id)?.notFoundMessage(base) ?? base;
}
/**
* The version to stamp on a SESSION in this mode.
*
* ⚠️ Dispatched on DATA, not on an id, and the field it dispatches on is the one that
* describes the difference: `discovery.version.retryOnTransientFailure`.
*
* Claude needs a probe policy no other CLI does. A single failed `claude --version` — a 5s
* timeout, a PATH-starved systemd unit, a transient fs hiccup — used to be cached forever,
* which silently disabled wheel-forwarding to Claude's own transcript for every session
* until the server restarted (the only route to history in repaint mode: a dead wheel on
* every device at once). `getClaudeCliVersion()` caches success forever and retries failure
* with backoff, and that policy has to be preserved exactly, so this routes to it rather
* than reimplementing it generically.
*
* Everything else goes through the ordinary registry resolver, which is the point: the
* caller asks `cliNeedsVersionProbe()` whether this CLI gates anything on its version and
* then asks HERE for that CLI's version. Before this, all three call sites asked
* `cliNeedsVersionProbe()` a generic question and then called `getClaudeCliVersion()`
* unconditionally — so the first non-claude entry to declare a `capabilities.gates` would
* have had CLAUDE's version stamped on its sessions and its gate evaluated against it.
*/
export function resolveSessionCliVersion(mode: string): string | null {
return getCli(mode)?.discovery.version?.retryOnTransientFailure ? getClaudeCliVersion() : resolveCliVersion(mode);
}
+8 -11
View File
@@ -7,21 +7,18 @@
* @module utils/codex-cli-resolver
*/
import { join } from 'node:path';
import { homedir } from 'node:os';
import { spawn } from 'node:child_process';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
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 = [
join(homedir(), '.codex', 'bin'), // Default install location
join(homedir(), '.local', 'bin'), // Alternative install location
'/usr/local/bin', // Homebrew / system
join(homedir(), '.bun', 'bin'), // Bun global
join(homedir(), '.npm-global', 'bin'), // npm global
join(homedir(), 'bin'), // User bin
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const CODEX_SEARCH_DIRS = (): string[] => (getCli('codex')?.discovery.searchDirs ?? []).map(expandHome);
const CODEX_BINARY = process.platform === 'win32' ? 'codex.exe' : 'codex';
const codexResolver = createCliExecutableResolver({ binary: CODEX_BINARY, searchDirs: CODEX_SEARCH_DIRS });
+8 -6
View File
@@ -35,6 +35,8 @@ 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 { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
@@ -49,12 +51,12 @@ import {
* 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'),
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const DEEPSEEK_SEARCH_DIRS = (): string[] => (getCli('deepseek')?.discovery.searchDirs ?? []).map(expandHome);
/**
* A real `dsh --version` prints a bare `0.1.1-rc.2` (measured, 0.1.1-rc.2), so
+8 -10
View File
@@ -7,19 +7,17 @@
* @module utils/gemini-cli-resolver
*/
import { join } from 'node:path';
import { homedir } from 'node:os';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
/** Common directories where the Gemini CLI binary may be installed */
const GEMINI_SEARCH_DIRS = [
join(homedir(), '.gemini', 'bin'),
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
join(homedir(), '.bun', 'bin'),
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const GEMINI_SEARCH_DIRS = (): string[] => (getCli('gemini')?.discovery.searchDirs ?? []).map(expandHome);
const geminiResolver = createCliExecutableResolver({ binary: 'gemini', searchDirs: GEMINI_SEARCH_DIRS });
const GEMINI_NOT_FOUND = 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli';
+8 -8
View File
@@ -20,9 +20,9 @@
*/
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 { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
@@ -30,12 +30,12 @@ import {
} from './cli-executable-resolver.js';
/** Common directories where the Grok CLI binary may be installed */
const GROK_SEARCH_DIRS = [
join(homedir(), '.grok', 'bin'),
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
join(homedir(), 'bin'),
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const GROK_SEARCH_DIRS = (): string[] => (getCli('grok')?.discovery.searchDirs ?? []).map(expandHome);
/**
* A real `grok --version` prints `grok 1.0.5 (5115b46bc9)` (measured, 1.0.5).
+10 -15
View File
@@ -14,9 +14,9 @@
*/
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 { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
@@ -24,20 +24,15 @@ import {
} 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.
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*
* `~/.local/bin` still leads, for the reason it always did: omp.sh's installer targets it
* with no `--dir` override (verified against a real `--no-cache` docker build), while
* `~/.omp/bin` was an unverified guess that turned out wrong and is kept as a 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'),
];
const OMP_SEARCH_DIRS = (): string[] => (getCli('omp')?.discovery.searchDirs ?? []).map(expandHome);
/**
* A real `omp --version` prints `omp/<semver>` (e.g. `omp/17.4.0`).
+8 -11
View File
@@ -7,20 +7,17 @@
* @module utils/opencode-cli-resolver
*/
import { join } from 'node:path';
import { homedir } from 'node:os';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
/** Common directories where the OpenCode CLI binary may be installed */
const OPENCODE_SEARCH_DIRS = [
join(homedir(), '.opencode', 'bin'), // Default install location
join(homedir(), '.local', 'bin'), // Alternative install location
'/usr/local/bin', // Homebrew / system
join(homedir(), 'go', 'bin'), // Go install
join(homedir(), '.bun', 'bin'), // Bun global
join(homedir(), '.npm-global', 'bin'), // npm global
join(homedir(), 'bin'), // User bin
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const OPENCODE_SEARCH_DIRS = (): string[] => (getCli('opencode')?.discovery.searchDirs ?? []).map(expandHome);
const openCodeResolver = createCliExecutableResolver({ binary: 'opencode', searchDirs: OPENCODE_SEARCH_DIRS });
const OPENCODE_NOT_FOUND = 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash';
+8 -9
View File
@@ -16,9 +16,9 @@
*/
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 { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
@@ -26,13 +26,12 @@ import {
} from './cli-executable-resolver.js';
/** Common directories where the Pi CLI binary may be installed */
const PI_SEARCH_DIRS = [
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
join(homedir(), '.bun', 'bin'),
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const PI_SEARCH_DIRS = (): string[] => (getCli('pi')?.discovery.searchDirs ?? []).map(expandHome);
/**
* A real `pi --version` prints a semver-shaped string (e.g. `0.84.1`).
+17 -11
View File
@@ -142,7 +142,9 @@ function isPasswordChangeExempt(req: FastifyRequest): boolean {
* match the prefix at all. The Host allowlist is NOT bypassed, so DNS-rebinding
* protection still applies to these requests.
*/
function hasValidWebviewCapability(req: FastifyRequest): boolean {
function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean {
// req.url is already base-stripped by the server's rewriteUrl, so the path form
// needs no base; the Referer form below is browser-supplied and does.
const url = (req.url ?? '').split('?')[0];
const fromPath = capabilityFromProxyPath(url);
@@ -167,7 +169,10 @@ function hasValidWebviewCapability(req: FastifyRequest): boolean {
// class the 404 relay could never rescue. See matchesRegisteredRoute.
if (matchesRegisteredRoute(req, url)) return false;
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
const fromReferer = capabilityFromReferer(
typeof req.headers.referer === 'string' ? req.headers.referer : undefined,
basePath
);
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
}
@@ -210,7 +215,7 @@ function matchesRegisteredRoute(req: FastifyRequest, url: string): boolean {
*
* @returns AuthState for lifecycle management (dispose on server stop)
*/
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState {
export function registerAuthMiddleware(app: FastifyInstance, https: boolean, basePath = ''): AuthState {
const state: AuthState = {
authSessions: null,
authFailures: null,
@@ -270,7 +275,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
ttlMs: AUTH_FAILURE_WINDOW_MS,
refreshOnGet: false,
});
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures);
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures, basePath);
return state;
}
@@ -293,7 +298,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
}
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
if (hasValidWebviewCapability(req)) {
if (hasValidWebviewCapability(req, basePath)) {
done();
return;
}
@@ -381,7 +386,8 @@ function registerMultiUserAuthHook(
authSessions: StaleExpirationMap<string, AuthSessionRecord>,
authFailures: StaleExpirationMap<string, number>,
hookSecretFailures: StaleExpirationMap<string, number>,
userFailures: StaleExpirationMap<string, number>
userFailures: StaleExpirationMap<string, number>,
basePath = ''
): void {
const setSessionCookie = (reply: FastifyReply, token: string) =>
reply.setCookie(AUTH_COOKIE_NAME, token, {
@@ -432,7 +438,7 @@ function registerMultiUserAuthHook(
// `req.authUser` stays undefined here on purpose: the proxy handler enforces
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
// than re-deriving it from a request that carries no credentials.
if (hasValidWebviewCapability(req)) return;
if (hasValidWebviewCapability(req, basePath)) return;
const clientIp = req.ip;
@@ -552,7 +558,7 @@ const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
*
* WebSocket upgrades are validated separately in the ws route handler.
*/
export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPolicy): void {
export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPolicy, basePath = ''): void {
app.addHook('onRequest', (req, reply, done) => {
const policy = getPolicy();
if (!isAllowedRequestHost(req.headers.host, policy)) {
@@ -568,7 +574,7 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
if (
!SAFE_HTTP_METHODS.has(req.method) &&
!isAllowedRequestOrigin(req.headers.origin, policy) &&
!hasValidWebviewCapability(req)
!hasValidWebviewCapability(req, basePath)
) {
reply.code(403).send('Forbidden: cross-site request blocked');
return;
@@ -580,7 +586,7 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
/**
* Register security headers and CORS middleware on every response.
*/
export function registerSecurityHeaders(app: FastifyInstance, https: boolean): void {
export function registerSecurityHeaders(app: FastifyInstance, https: boolean, basePath = ''): void {
// Gesture-control overlay (opt-in via CODEMAN_GESTURE=1) runs MediaPipe, which
// needs WebAssembly eval (script-src) and blob workers (worker-src). Its wasm
// runtime + model are self-hosted under /gesture/ (same-origin, covered by
@@ -634,7 +640,7 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean): v
// net::ERR_FAILED while the page itself renders fine (script/css/img loads
// are not CORS-checked). Falling through lets the proxy route reply with the
// right headers.
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req)) {
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req, basePath)) {
reply.code(204).send();
done();
return;
+84 -21
View File
@@ -80,7 +80,7 @@ try {
const prev = localStorage.getItem('codeman-crash-diag');
if (prev) {
console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev);
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' }));
navigator.sendBeacon(CodemanBase.url('/api/crash-diag'), JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' }));
}
} catch {}
_crashDiag.log('PAGE LOAD');
@@ -89,7 +89,7 @@ _crashDiag.log('PAGE LOAD');
function _crashDiagBeacon() {
try {
if (_crashDiag._entries.length > 0) {
navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId }));
navigator.sendBeacon(CodemanBase.url('/api/crash-diag'), JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId }));
}
} catch {}
}
@@ -1268,7 +1268,11 @@ class CodemanApp {
if (typeof window !== 'undefined' && typeof window.__CODEMAN_SOLO__ === 'string' && window.__CODEMAN_SOLO__) {
return window.__CODEMAN_SOLO__;
}
const m = location.pathname.match(/^\/session\/([^/]+)\/?$/);
// Strip the reverse-proxy base so the match works under a sub-path mount.
const base = window.CodemanBase?.base || '';
let path = location.pathname;
if (base && path.startsWith(base)) path = path.slice(base.length) || '/';
const m = path.match(/^\/session\/([^/]+)\/?$/);
return m ? decodeURIComponent(m[1]) : null;
} catch { return null; }
}
@@ -1293,7 +1297,7 @@ class CodemanApp {
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
let win = null;
try { win = window.open('/session/' + encodeURIComponent(id), 'codeman-session-' + id, features); } catch {}
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
if (!win) {
this.showToast?.('Pop-out blocked — allow popups for this site to detach a session', 'error');
return;
@@ -1545,7 +1549,7 @@ class CodemanApp {
// regardless of filter (server side).
const _sseParams = new URLSearchParams({ clientId: this._clientId });
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`);
this.eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
// Store all event listeners for cleanup on reconnect.
//
@@ -2183,15 +2187,26 @@ class CodemanApp {
}
/** Build one response-viewer message so the brief and full views share markup and CSS. */
_buildResponseViewerMessage(text, role, agentLabel) {
_buildResponseViewerMessage(text, role, agentLabel, meta) {
const div = document.createElement('div');
const isUser = role === 'user';
div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant');
// Consecutive messages from one speaker inside one turn are segments of a
// single utterance: one badge, a hairline seam. Claude emits a median of 3
// messages per turn (p90 11, max 51), so a badge per message would be the
// card spam the old concatenation was introduced to avoid. `meta` is
// optional so the brief view's 3-argument call keeps its exact shape.
const continuation = !!(meta && meta.continuation);
if (continuation) div.classList.add('rv-msg-cont');
if (meta && meta.kind) div.dataset.kind = meta.kind;
if (meta && meta.queued) div.dataset.queued = '1';
const roleBadge = document.createElement('div');
roleBadge.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
roleBadge.textContent = isUser ? 'You' : agentLabel;
div.appendChild(roleBadge);
if (!continuation) {
const roleBadge = document.createElement('div');
roleBadge.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
roleBadge.textContent = isUser ? 'You' : agentLabel;
div.appendChild(roleBadge);
}
const renderedText = document.createElement('div');
renderedText.className = 'rv-text';
@@ -2351,19 +2366,49 @@ class CodemanApp {
if (!body) return;
if (messages.length === 0) {
body.textContent = 'No conversation history available';
// Never destroy what the eye button already rendered: the brief view has
// a terminal-buffer fallback (see toggleResponseViewer) that this
// endpoint does not, so an empty full-context result must not wipe a
// real answer the user is reading.
// ⚠️ Idempotent, because More deliberately stays live here: the branch
// returns before the button is hidden so a transcript that appears a
// moment later can still be loaded, and appending would then stack a
// second identical notice on every retry.
// `:scope >` keeps the lookup off model-rendered markdown inside .rv-text.
let notice = body.querySelector(':scope > .rv-notice');
if (!notice) {
notice = document.createElement('div');
notice.className = 'rv-notice';
body.appendChild(notice);
}
const emptyText = 'No full conversation history available for this session';
notice.textContent = window.codemanT?.(emptyText) || emptyText;
return;
}
// Render conversation thread
const agentLabel = this._getResponseViewerAgentLabel();
body.innerHTML = '';
let previous = null;
for (const msg of messages) {
body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel));
// ⚠️ A numeric `turn` is REQUIRED, never same-role adjacency alone.
// Only the Claude reader emits turns; Codex and the external-CLI pane
// parser emit adjacent assistant/response blocks with no turn at all, and
// an older server emits none either — all three must keep rendering one
// badged card per message exactly as they do today.
const continuation =
!!previous && previous.role === msg.role && typeof msg.turn === 'number' && previous.turn === msg.turn;
body.appendChild(this._buildResponseViewerMessage(msg.text, msg.role, agentLabel, { ...msg, continuation }));
previous = msg;
}
this._bindResponseViewerInteractions(body);
if (title) title.textContent = `Conversation (${messages.length} messages)`;
const turns = new Set(messages.filter((msg) => typeof msg.turn === 'number').map((msg) => msg.turn)).size;
if (title) {
title.textContent = turns
? `Conversation (${messages.length} messages, ${turns} turns)`
: `Conversation (${messages.length} messages)`;
}
if (moreBtn) moreBtn.style.display = 'none';
// Scroll to bottom (latest message)
body.scrollTop = body.scrollHeight;
@@ -2634,8 +2679,19 @@ class CodemanApp {
// string field (e.g. modelDisplayName, which the route also broadcasts) is
// ever shown in this chip, render it via textContent — never interpolate an
// untrusted string into this template.
const seg = (label, p) => {
if (p === null) return '';
// `idle: true` keeps a missing window's SLOT with a dimmed em dash instead of
// dropping it. Claude only: Claude Code documents `five_hour` as "present
// only while the API reports it and its resets_at has not passed", so that
// key leaves the statusline payload whenever no 5-hour session window is
// open, and a chip that silently shrank from two windows to one read as a
// broken feature rather than as an idle window (reported 2026-09-01). A
// missing CODEX bucket means the opposite — that plan has no such limit —
// so those stay omitted rather than showing a dash forever.
const seg = (label, p, idle) => {
if (p === null) {
if (!idle) return '';
return `<span class="pu-win pu-win-idle"><span class="pu-label">${label}</span><span class="pu-val">—</span></span>`;
}
const n = Math.round(Number(p));
if (!Number.isFinite(n)) return '';
return `<span class="pu-win"><span class="pu-label">${label}</span><span class="pu-val ${colorClass(n)}">${n}%</span></span>`;
@@ -2644,24 +2700,31 @@ class CodemanApp {
// 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);
const row = (provider, usage, idle) => {
// hasWindows() gates the row, so a placeholder can only ever appear
// ALONGSIDE a real reading — a provider reporting nothing still renders
// nothing, never a row of em dashes.
if (!hasWindows(usage)) return '';
const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean);
if (!windows.length) return '';
const label = labelled ? `<span class="pu-provider">${provider}</span>` : '';
return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`;
};
const rows = [row('Claude', data), row('Codex', data.codex)].filter(Boolean);
const rows = [row('Claude', data, true), row('Codex', data.codex, false)].filter(Boolean);
chip.innerHTML = rows.length ? rows.join('') : '—';
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
const details = (provider, usage) => {
const details = (provider, usage, idle) => {
const lines = [];
const five = pct(usage?.fiveHour);
const seven = pct(usage?.sevenDay);
if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`);
else if (idle && seven !== null) lines.push('5-hour limit: no active session window');
if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`);
return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : '';
};
chip.title = [details('Claude', data), details('Codex', data.codex)].filter(Boolean).join('\n\n') || 'Plan usage limits';
chip.title =
[details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') ||
'Plan usage limits';
}
// Scheduled runs
@@ -2735,7 +2798,7 @@ class CodemanApp {
// up to the limit).
const cid = this._clientId ? `${this._clientId}:${this._wsTabNonce}` : '';
const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal${cidQuery}`;
const url = `${proto}//${location.host}${CodemanBase.base}/ws/sessions/${sessionId}/terminal${cidQuery}`;
const ws = new WebSocket(url);
this._ws = ws;
this._wsSessionId = sessionId;
+58
View File
@@ -22,6 +22,63 @@
// Codeman — Shared constants and utility functions for frontend modules
// ═══════════════════════════════════════════════════════════════
// Reverse-proxy base path
// ═══════════════════════════════════════════════════════════════
// When Codeman is served behind a reverse proxy under a sub-path (e.g. /codeman/),
// the server injects `window.__CODEMAN_BASE__` (normalized: '' for root, or '/foo').
// The `<base href>` tag in index.html already rewrites the RELATIVE asset refs, but
// every URL the frontend builds at RUNTIME is root-absolute (`/api/...`, `/ws/...`)
// and root-absolute URLs ignore `<base>` — so those must be prefixed here instead.
// Rather than touch ~190 call sites, all runtime URL construction routes through this
// ONE choke point: `CodemanBase.url()` is the route builder, and a thin wrapper over
// `fetch` applies it transparently. The handful of EventSource/WebSocket sites call
// `CodemanBase.url()` / `CodemanBase.base` explicitly. No-op when mounted at root.
const CodemanBase = (function () {
// `window` is absent in some unit-test vm contexts that load this module in
// isolation; guard so the module still evaluates (base degrades to root).
const _win = typeof window !== 'undefined' ? window : undefined;
const base = String((_win && _win.__CODEMAN_BASE__) || '').replace(/\/+$/, '');
/**
* Prefix a root-absolute application path with the mount base. Leaves untouched:
* relative paths and fragments/queries (resolved against `<base>`), protocol-relative
* (`//host`) and absolute URLs, and paths already carrying the prefix.
*/
function url(path) {
if (!base) return path;
if (typeof path !== 'string' || path.length === 0) return path;
if (path[0] !== '/') return path; // relative / fragment / query
if (path[1] === '/') return path; // protocol-relative
if (path === base || path.startsWith(base + '/') || path.startsWith(base + '?')) return path;
return base + path;
}
return { base, url };
})();
if (typeof window !== 'undefined') window.CodemanBase = CodemanBase;
// Transparently prefix root-absolute app paths on every fetch, so the many
// `/api/...` string literals across the frontend need no per-call edit.
if (typeof window !== 'undefined' && CodemanBase.base && typeof window.fetch === 'function') {
const _origFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
if (typeof input === 'string') return _origFetch(CodemanBase.url(input), init);
if (typeof Request !== 'undefined' && input instanceof Request) {
try {
const u = new URL(input.url);
if (u.origin === location.origin) {
const prefixed = CodemanBase.url(u.pathname);
if (prefixed !== u.pathname) {
return _origFetch(new Request(u.origin + prefixed + u.search + u.hash, input), init);
}
}
} catch (_e) {
/* not a parseable URL — fall through */
}
}
return _origFetch(input, init);
};
}
// ═══════════════════════════════════════════════════════════════
// Web Push Utilities
// ═══════════════════════════════════════════════════════════════
@@ -958,6 +1015,7 @@ const SSE_EVENTS = {
HOOK_AGENT_WORKING: 'hook:agent_working',
HOOK_TEAMMATE_IDLE: 'hook:teammate_idle',
HOOK_TASK_COMPLETED: 'hook:task_completed',
HOOK_PROMPT_SUBMITTED: 'hook:prompt_submitted',
// Approvals Inbox
APPROVAL_PENDING: 'approval:pending',
+14
View File
@@ -86,6 +86,7 @@
'Instance count': '实例数量',
'No response yet': '暂无回复',
'No response yet — send a message in this session first.': '暂无回复,请先在此会话中发送一条消息。',
'No full conversation history available for this session': '此会话没有可显示的完整对话历史',
'Last Response': '最近一次回复',
More: '更多',
'Codeman version': '{name}版本',
@@ -713,6 +714,19 @@
'Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.':
'在加固的隔离容器中运行此案例。首次使用时会自动构建基础镜像;必须安装 Docker/Podman。',
'Run in an isolated Docker container': '在隔离的 Docker 容器中运行',
'Attach to an existing container': '接入已在运行的容器',
'On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.':
'开启后,{name}只会 docker exec 进入你自己构建并运行的容器,绝不创建、启动、停止或删除它;容器内必须已安装并登录好相应 CLI。',
'Container Name': '容器名称',
'Pick from the running containers or type a name.': '从正在运行的容器中选择,或直接输入名称。',
'Check container': '检查容器',
'Container Workdir': '容器内工作目录',
'A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.':
'容器内已存在的路径。接入不挂载任何目录,因此它不必与主机工作区路径相同。',
'Already have a container running?': '已经有正在运行的容器?',
'Attach to it instead': '改为接入该容器',
'Codeman only runs docker exec into it and never touches its lifecycle.':
'{name}只会 docker exec 进入它,绝不触碰其生命周期。',
'Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.':
'绑定挂载到容器中的主机绝对目录;{name}会在其中生成 CLAUDE.md 和 hooks。',
'A reusable docker host profile. Reuse the same ID across cases to share settings.':
+41 -5
View File
@@ -2028,6 +2028,8 @@
<div class="set-modelgrid" id="appSettingsModelCards" role="radiogroup" aria-label="Model for new Claude sessions" data-search="model opus sonnet haiku fable claude"></div>
<select id="appSettingsClaudeModel" class="set-select set-field-hidden" aria-hidden="true" tabindex="-1">
<option value="" data-meta="Whatever the CLI picks">Default (CLI setting)</option>
<option value="claude-fable-5-1" data-meta="Latest" data-base="claude-fable-5-1" data-ctx="1">Fable 5.1</option>
<option value="claude-fable-5-1[1m]" data-variant="1m" data-base="claude-fable-5-1">Fable 5.1 (1M context)</option>
<option value="claude-fable-5" data-meta="Most powerful" data-base="claude-fable-5" data-ctx="1">Fable 5</option>
<option value="claude-fable-5[1m]" data-variant="1m" data-base="claude-fable-5">Fable 5 (1M context)</option>
<option value="opus" data-meta="Most capable" data-base="opus" data-ctx="1">Opus</option>
@@ -2040,7 +2042,7 @@
<div class="set-row" id="appSettingsContextRow" data-search="1m context window opus long">
<div class="set-row-text">
<span class="set-row-label">1M context window</span>
<span class="set-row-desc" id="appSettingsContextDesc">Available for Fable 5, Opus and Opus 4.6.</span>
<span class="set-row-desc" id="appSettingsContextDesc">Available for Fable 5.1, Fable 5, Opus and Opus 4.6.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="appSettingsOpusContext1m"><span class="slider"></span></label>
</div>
@@ -2080,6 +2082,7 @@
</div>
<select id="appSettingsDefaultModel" class="set-select">
<option value="">Default (CLI default)</option>
<option value="claude-fable-5-1">Fable 5.1 (Latest)</option>
<option value="claude-fable-5">Fable 5 (Most powerful)</option>
<option value="opus">Opus (Most capable)</option>
<option value="sonnet">Sonnet (Balanced)</option>
@@ -2094,6 +2097,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5-1">Fable 5.1</option>
<option value="claude-fable-5">Fable 5</option>
</select>
</div>
@@ -2104,6 +2108,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5-1">Fable 5.1</option>
<option value="claude-fable-5">Fable 5</option>
</select>
</div>
@@ -2114,6 +2119,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5-1">Fable 5.1</option>
<option value="claude-fable-5">Fable 5</option>
</select>
</div>
@@ -2124,6 +2130,7 @@
<option value="haiku">Haiku</option>
<option value="sonnet">Sonnet</option>
<option value="opus">Opus</option>
<option value="claude-fable-5-1">Fable 5.1</option>
<option value="claude-fable-5">Fable 5</option>
</select>
</div>
@@ -2582,8 +2589,15 @@
<div class="modal-content modal-lg set-shell">
<div class="modal-header set-shell-head">
<h3>Add Case</h3>
<!-- mobile.css hides this modal's .set-foot, so on a phone the footer's
Create/Link button is unreachable and the modal cannot be submitted
at all. Mirrors the Settings modal's header Save: close first in the
DOM so the focus trap still lands on it, row-reverse puts this to
its left. Both buttons are driven together by switchCaseModalTab()
and submitCaseModal(). -->
<div class="set-head-actions">
<button class="modal-close" onclick="app.closeCreateCaseModal()" aria-label="Close create case">&times;</button>
<button class="set-head-save" id="caseModalSubmitMobile" onclick="app.submitCaseModal()">Create</button>
</div>
</div>
<div class="set-body">
@@ -2637,6 +2651,7 @@
<div class="form-row docker-quick-row">
<label class="checkbox-row"><input type="checkbox" id="newCaseDocker"> 🐳 Run in an isolated Docker container</label>
<span class="form-hint">Runs this case in a hardened, isolated container. The base image is built automatically on first use. Docker/Podman must be installed.</span>
<span class="form-hint">Already have a container running? <button type="button" class="btn-inline-check" id="dockerAdoptJumpBtn">Attach to it instead</button> Codeman only runs docker exec into it and never touches its lifecycle.</span>
</div>
<details class="advanced-options docker-quick-settings" id="dockerQuickSettings">
<summary><svg class="set-adv-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg><span>Container settings (optional, sensible defaults)</span></summary>
@@ -2840,6 +2855,24 @@
<h2>Docker</h2>
</div>
<p class="set-section-blurb">Run the case inside a container: one per case, shared by all its sessions.</p>
<div class="form-row">
<label class="checkbox-row"><input type="checkbox" id="dockerAdoptExisting"> Attach to an existing container</label>
<span class="form-hint">On: Codeman only runs docker exec into a container you already built and run — it never creates, starts, stops or removes it. The CLIs must already be installed and logged in inside it.</span>
</div>
<div class="form-row docker-adopt-only">
<label>Container Name</label>
<input type="text" id="dockerContainerName" list="dockerContainerList" placeholder="my-dev-box" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
<datalist id="dockerContainerList"></datalist>
<span class="form-hint">Pick from the running containers or type a name. <button type="button" class="btn-inline-check" id="dockerAdoptCheckBtn">Check container</button></span>
</div>
<div class="form-row docker-adopt-only">
<label>Container Workdir</label>
<div class="path-input-group">
<input type="text" id="dockerAdoptWorkdir" placeholder="/workspace" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<button type="button" class="btn path-input-browse" onclick="app.openDockerWorkdirPicker()">Browse&hellip;</button>
</div>
<span class="form-hint">A path that already exists inside the container. Adoption mounts nothing, so this need not match the host workspace path.</span>
</div>
<div class="form-row">
<label>Case Name</label>
<input type="text" id="dockerCaseName" placeholder="sandbox" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
@@ -2847,7 +2880,10 @@
</div>
<div class="form-row">
<label>Workspace Path</label>
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<div class="path-input-group">
<input type="text" id="dockerWorkspacePath" placeholder="/home/user/projects/sandbox" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<button type="button" class="btn path-input-browse" onclick="app.openDockerWorkspacePathPicker()">Browse&hellip;</button>
</div>
<span class="form-hint">Absolute HOST directory, bind-mounted into the container. Codeman scaffolds CLAUDE.md + hooks into it.</span>
</div>
<div class="form-row">
@@ -2855,12 +2891,12 @@
<input type="text" id="dockerHostId" placeholder="local" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">A reusable docker host profile. Reuse the same ID across cases to share settings.</span>
</div>
<div class="form-row">
<div class="form-row docker-create-only">
<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/dsh + tmux.</span>
</div>
<div class="form-row">
<div class="form-row docker-create-only">
<label>Network</label>
<select id="dockerNetwork">
<option value="bridge">bridge (internet on, default)</option>
@@ -2868,7 +2904,7 @@
<option value="custom">custom bridge</option>
</select>
</div>
<details class="advanced-options">
<details class="advanced-options docker-create-only">
<summary><svg class="set-adv-chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg><span>Advanced container settings</span></summary>
<div class="advanced-options-content">
<div class="form-row">
+8 -4
View File
@@ -185,9 +185,13 @@ const PathPicker = {
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
if (this._showHidden) params.set('showHidden', 'true');
try {
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
const result = await response.json();
if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder');
// A caller may supply its own source (the container-workdir picker browses
// INSIDE a container, which the host filesystem endpoint cannot answer).
// It returns the same shape, so everything below is unchanged.
const result = this._options.fetchListing
? await this._options.fetchListing(path)
: await (await fetch(`/api/filesystem/browse?${params.toString()}`)).json();
if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder');
if (!this.overlay || loadSequence !== this._loadSequence) return;
this.render(result.data);
} catch (error) {
@@ -304,7 +308,7 @@ const PathPicker = {
// A hidden file is only reachable while the toggle is on, and the preview
// endpoint re-resolves the path independently, so it needs the flag too.
if (this._showHidden) params.set('showHidden', 'true');
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
const previewUrl = (window.CodemanBase?.url || ((p) => p))(`/api/filesystem/preview?${params.toString()}`);
const overlay = document.createElement('div');
overlay.className = 'path-preview-overlay';
+2 -1
View File
@@ -2,7 +2,8 @@
"name": "Codeman",
"short_name": "Codeman",
"description": "Claude Code session manager",
"start_url": "/",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "any",
"background_color": "#0a0a0a",
+12 -1
View File
@@ -228,6 +228,11 @@ Object.assign(CodemanApp.prototype, {
name: item.name || '',
title: title || item.name || dir.split('/').pop() || item.sessionId.slice(0, 8),
mode: item.mode || 'claude',
// Only the scanner sets this, and only for a codex rollout. Dropping it here
// is not cosmetic: resumeMobileOverviewSession() passes row.resumeId on to
// resumeHistorySession(), so without it a tapped Codex row starts a FRESH
// session on a thread that is already on disk.
resumeId: item.resumeId || undefined,
caseName: matched ? matched.name : '',
dir: this._shortenHomePath ? this._shortenHomePath(dir) : dir,
at: item.lastActivityAt || item.createdAt || 0,
@@ -389,7 +394,13 @@ 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, row.mode);
await this.resumeHistorySession(
row.claudeSessionId || row.id,
row.workingDir,
row.name || undefined,
row.mode,
row.resumeId
);
},
// ═══════════════════════════════════════════════════════════════
+13 -2
View File
@@ -3333,8 +3333,10 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
recessed tray and matching pill geometry instead of reading as a fat
accent pill parked beside a stray × glyph. Tray colors come from skin
tokens, never a hardcoded black alpha, or the light skins get a grey slab.
`:has()` keeps the tray off the two sheets that carry a lone × (Session
Options and Add Case save from inside their own forms). */
`:has()` keeps the tray off Session Options, the one sheet left carrying a
lone × because it saves from inside its own per-section forms. Add Case
now has a header save of its own (its footer is hidden below 860px, so
that button is the only way to submit it there), and picks up the tray. */
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions:has(.set-head-save) {
padding: 3px;
border: 1px solid var(--border);
@@ -3353,6 +3355,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
font-size: 0.86rem;
}
/* Add Case's pending state has to show on the header button: below 860px it is
the only submit control (the footer is hidden), and the #caseModalSubmit
.loading rule in the phone block dims a button nobody can see. Measured at
390px before this: header opacity 1 for the whole clone, hidden footer 0.6. */
#createCaseModal .set-head-save.loading {
opacity: 0.6;
pointer-events: none;
}
:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal) .set-head-actions .modal-close {
width: 36px;
height: 36px;
+1 -1
View File
@@ -390,7 +390,7 @@ class NotificationManager {
const notif = new Notification(`${this.originalTitle}: ${localizedTitle}`, {
body: localizedBody,
tag, // Groups same-tag notifications
icon: '/favicon.ico',
icon: (window.CodemanBase?.url || ((p) => p))('/favicon.ico'),
silent: true, // We handle audio ourselves
});
+46 -33
View File
@@ -670,7 +670,13 @@ 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, undefined, s.mode);
void this.resumeHistorySession(
s.claudeSessionId || s.sessionId,
record.workingDir,
undefined,
s.mode,
s.resumeId
);
}
},
});
@@ -3424,7 +3430,7 @@ Object.assign(CodemanApp.prototype, {
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
const downloadBtn = !isDir
? `<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>`
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">&#x2B07;</a>`
: '';
html.push(`
@@ -3599,7 +3605,7 @@ Object.assign(CodemanApp.prototype, {
: '';
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>`
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/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)}">
@@ -3998,18 +4004,20 @@ Object.assign(CodemanApp.prototype, {
// (html/htm arrive as a download there by design — file-raw serves them
// attachment-only so widening READ never widens RUN.)
const officeDoc = ext === 'docx' || ext === 'pptx';
this.filePreviewDetachUrl = attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
: officeDoc
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
this.filePreviewDetachUrl = CodemanBase.url(
attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
: officeDoc
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
);
if (detachBtn) detachBtn.hidden = false;
// Registered attachment: render straight from its by-id routes — images and
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
// raw. (Workspace-path previews fall through to the file-content endpoint.)
if (attachmentId) {
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
const base = CodemanBase.url(`/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`);
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
// (src/attachment-registry.ts, the single source); the frontend cannot import
@@ -4067,13 +4075,13 @@ Object.assign(CodemanApp.prototype, {
// to file-content below, which would dump the binary bytes as mojibake.
if (ext === 'docx' || ext === 'pptx') {
footerEl.textContent = ext.toUpperCase();
const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`;
const previewSrc = CodemanBase.url(`/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`);
bodyEl.innerHTML = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
return;
}
if (ext === 'pdf') {
footerEl.textContent = 'PDF';
const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
const rawSrc = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
return;
}
@@ -4107,19 +4115,19 @@ Object.assign(CodemanApp.prototype, {
const data = result.data;
if (data.type === 'image') {
bodyEl.innerHTML = `<img src="${data.url}" alt="${escapeHtml(filePath)}">`;
bodyEl.innerHTML = `<img src="${escapeHtml(CodemanBase.url(data.url))}" alt="${escapeHtml(filePath)}">`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'video') {
// playsinline: iOS otherwise hijacks playback into its fullscreen
// player, which leaves the overlay behind it and its own close button
// as the only way back.
bodyEl.innerHTML = `<video src="${escapeHtml(data.url)}" controls autoplay playsinline preload="metadata"></video>`;
bodyEl.innerHTML = `<video src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay playsinline preload="metadata"></video>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'audio') {
bodyEl.innerHTML = `<audio src="${escapeHtml(data.url)}" controls autoplay preload="metadata"></audio>`;
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
} else if (data.type === 'binary') {
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
footerEl.textContent = data.extension || 'binary';
} else {
@@ -4416,9 +4424,11 @@ Object.assign(CodemanApp.prototype, {
},
openAttachmentInNewTab(sessionId, filePath, attachmentId = null) {
const url = attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
const url = CodemanBase.url(
attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
);
window.open(url, '_blank');
},
@@ -4454,19 +4464,22 @@ Object.assign(CodemanApp.prototype, {
const stack = this.ensureAttachmentCardStack();
const session = this.sessions.get(sessionId);
const sessionName = session?.name || sessionId.substring(0, 8);
const attachmentRawUrl =
const attachmentRawUrl = CodemanBase.url(
rawUrl ||
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
const attachmentPreviewUrl =
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`)
);
const attachmentPreviewUrl = CodemanBase.url(
previewUrl ||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null);
const attachmentThumbnailUrl =
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null)
);
const attachmentThumbnailUrl = CodemanBase.url(
thumbnailUrl ||
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`);
(attachmentId
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`)
);
const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`;
const typeLabel = (extension || attachmentType || 'file').toUpperCase();
@@ -4730,7 +4743,7 @@ Object.assign(CodemanApp.prototype, {
.join(' • ');
const thumb =
item.thumbnailUrl && !item.missing
? `<img class="attachment-history-thumb-img" src="${escapeHtml(item.thumbnailUrl)}" alt="">`
? `<img class="attachment-history-thumb-img" src="${escapeHtml(CodemanBase.url(item.thumbnailUrl))}" alt="">`
: '';
const disabled = item.missing ? 'disabled aria-disabled="true"' : '';
return `
@@ -4770,7 +4783,7 @@ Object.assign(CodemanApp.prototype, {
const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing) return;
if (item.rawUrl || item.url) {
window.open(item.rawUrl || item.url, '_blank');
window.open(CodemanBase.url(item.rawUrl || item.url), '_blank');
return;
}
this.openAttachmentInNewTab(item.sessionId, item.relativePath || item.fileName, item.attachmentId || null);
@@ -4779,7 +4792,7 @@ Object.assign(CodemanApp.prototype, {
downloadAttachmentHistoryItem(itemId) {
const item = this.getAttachmentHistoryItem(itemId);
if (!item || item.missing || !item.downloadUrl) return;
window.open(item.downloadUrl, '_blank');
window.open(CodemanBase.url(item.downloadUrl), '_blank');
},
reshowAttachmentCard(itemId) {
@@ -4925,7 +4938,7 @@ Object.assign(CodemanApp.prototype, {
// Connect to SSE stream
const eventSource = new EventSource(
`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`
CodemanBase.url(`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`)
);
eventSource.onmessage = (e) => {
@@ -5067,7 +5080,7 @@ Object.assign(CodemanApp.prototype, {
// Build image URL using the existing file-raw endpoint
// Use relativePath (path from working dir) instead of fileName (basename) for subdirectory images
const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`;
const imageUrl = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`);
// Create window element
const win = document.createElement('div');
+430 -22
View File
@@ -187,6 +187,13 @@ Object.assign(CodemanApp.prototype, {
this.closeCasePicker();
this.updateDirDisplayForCase(select.value);
this.updateMobileCaseLabel(select.value);
// Warm the container's CLI list HERE rather than when the run menu opens.
// The probe is a `docker exec` round trip, so gating it on the menu meant the
// menu painted every mode first and only narrowed a moment later — which
// reads as "it shows all of them" and lets a mode be picked that the
// container does not have.
const picked = (this.cases || []).find((c) => c.name === select.value);
if (picked?.location === 'docker') void this._probeDockerCaseModes(picked, null);
if (save) {
this.saveLastUsedCase(select.value);
}
@@ -477,10 +484,40 @@ Object.assign(CodemanApp.prototype, {
* run modes like the rest, and neither `agy` nor `pi` is likely to be installed.
*/
_refreshRunModeAvailability(menu) {
// A DOCKER case runs its agents INSIDE the container, so host CLI
// availability answers the wrong question: the host may have no claude at
// all while the container ships one, and gating on the host hides a mode
// that would have worked. Adoption records what the container really has
// (`availableModes`); an owned container runs our base image, which ships
// every CLI, so an absent list means "do not gate" rather than "nothing".
// Same source every run* path reads the selected case from.
const caseName = document.getElementById('quickStartCase')?.value;
const activeCase = caseName ? (this.cases || []).find((c) => c.name === caseName) : null;
const isDocker = activeCase?.location === 'docker';
// Prefer a LIVE probe over the value stored at attach time: a container's
// CLIs can be installed or removed long after the case was linked, and a
// case linked before that field existed has none at all.
const containerModes = isDocker
? this._dockerCaseModes?.[caseName] || activeCase.docker?.availableModes || null
: null;
if (isDocker && !this._dockerCaseModes?.[caseName]) void this._probeDockerCaseModes(activeCase, menu);
// An unreachable container hides every agent mode and explains why, instead
// of silently offering modes that cannot start.
//
// ⚠️ ADOPTED cases only. For an OWNED case a missing container is the normal
// state before the first session — the launch chain creates and starts it — so
// reporting it as a fault hid every agent mode on a freshly linked Docker case
// behind "start it yourself first", for a container Codeman was about to create.
const probeError = isDocker ? this._dockerCaseProbeError?.[caseName] : null;
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';
if (!btn) continue;
let available;
if (isDocker) available = probeError ? false : containerModes ? containerModes.includes(mode) : true;
else available = this.isCliAvailable(mode);
btn.style.display = available ? 'flex' : 'none';
}
this._renderRunModeNotice(menu, probeError);
// 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",
@@ -622,6 +659,81 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* One-line explanation at the top of the run menu. Only a container that could
* not be read produces one; everything else removes it, so a stale reason can
* never outlive the condition that caused it.
*/
_renderRunModeNotice(menu, message) {
if (!menu) return;
let el = menu.querySelector('.run-mode-notice');
if (!message) {
el?.remove();
return;
}
if (!el) {
el = document.createElement('div');
el.className = 'run-mode-notice';
menu.prepend(el);
}
// Server-supplied text: set it, never parse it as markup.
el.textContent = message;
},
/**
* Ask the container which CLIs it actually has, and re-gate the menu once the
* answer lands. Cached per case for the page's lifetime: the menu re-opens
* often and the probe is a `docker exec` round trip.
*
* Best-effort by design — an unreachable daemon or a stopped container leaves
* the cache empty, which the caller reads as "unknown" and therefore does not
* gate. Hiding every mode because a probe failed would be worse than showing
* one that turns out to be missing, which the launch path already refuses with
* a specific message.
*/
async _probeDockerCaseModes(activeCase, menu) {
const name = activeCase?.name;
const container = activeCase?.docker?.container;
const hostId = activeCase?.docker?.hostId;
if (!name || !container || !hostId) return;
this._dockerCaseModes = this._dockerCaseModes || {};
if (this._dockerModeProbeInFlight?.[name]) return;
this._dockerModeProbeInFlight = this._dockerModeProbeInFlight || {};
this._dockerModeProbeInFlight[name] = true;
try {
// ⚠️ _api serializes `body` and sets Content-Type itself. Passing an
// already-stringified body double-encodes it and the server rejects a
// JSON string where it expects an object (400 INVALID_INPUT).
const probe = await this._apiJson('/api/docker-cases/adopt-preflight', {
method: 'POST',
body: { hostId, container },
});
if (probe?.ok && Array.isArray(probe.availableModes)) {
this._dockerCaseModes[name] = probe.availableModes;
delete this._dockerCaseProbeError?.[name];
} else {
// An ADOPTED container that cannot be probed — recreated, stopped, engine
// down — must NOT fall through to "show everything". Offering claude on a
// container that is not running is a click that can only fail, with the
// reason visible nowhere. Record the reason and say it in the menu.
//
// ⚠️ An OWNED container gets no error: it does not exist until the first
// session launches it, so "not found" is the expected answer for every
// newly linked Docker case, and gating on it made those cases unusable.
// Leaving the cache empty reads as "unknown", which does not gate.
if (activeCase?.docker?.owned === false) {
this._dockerCaseProbeError = this._dockerCaseProbeError || {};
this._dockerCaseProbeError[name] = probe?.error || `Could not read container "${container}".`;
}
delete this._dockerCaseModes[name];
}
// Only repaint while the menu the user opened is still on screen.
if (menu?.classList.contains('active')) this._refreshRunModeAvailability(menu);
} finally {
delete this._dockerModeProbeInFlight[name];
}
},
async _loadRunModeHistory() {
const container = document.getElementById('runModeHistory');
if (!container) return;
@@ -692,7 +804,7 @@ Object.assign(CodemanApp.prototype, {
btn.append(...parts);
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode);
this.resumeHistorySession(s.sessionId, s.workingDir, s.name, s.mode, s.resumeId);
});
container.appendChild(btn);
}
@@ -2387,6 +2499,28 @@ Object.assign(CodemanApp.prototype, {
modal.querySelectorAll('.set-rail-item').forEach(btn => {
btn.onclick = () => this.switchCaseModalTab(btn.dataset.tab);
});
// Adopt-an-existing-container toggle + its read-only preflight. Assigned (not
// addEventListener) so reopening the modal cannot stack duplicate handlers,
// matching the rail wiring right above.
const adoptToggle = document.getElementById('dockerAdoptExisting');
if (adoptToggle) adoptToggle.onchange = () => this._syncDockerAdoptMode();
const adoptCheck = document.getElementById('dockerAdoptCheckBtn');
if (adoptCheck) adoptCheck.onclick = () => this._dockerAdoptPreflight();
const adoptJump = document.getElementById('dockerAdoptJumpBtn');
if (adoptJump) adoptJump.onclick = () => this.jumpToDockerAdopt();
// Containers come from the host profile, so switching Host ID invalidates the
// suggestions. Dropping the marker (rather than refetching here) keeps the
// fetch lazy — it happens when adopt mode is actually on.
const hostIdInput = document.getElementById('dockerHostId');
if (hostIdInput) {
hostIdInput.onchange = () => {
delete document.getElementById('dockerContainerList')?.dataset.loadedFor;
if (document.getElementById('dockerAdoptExisting')?.checked) void this._loadDockerContainerOptions();
};
}
// A fresh open re-reads the engine: containers start and stop between visits.
delete document.getElementById('dockerContainerList')?.dataset.loadedFor;
this._syncDockerAdoptMode();
// Scroll-into-view on focus for mobile keyboard visibility
modal.querySelectorAll('input[type="text"]').forEach(input => {
if (!input._mobileScrollWired) {
@@ -2416,15 +2550,19 @@ Object.assign(CodemanApp.prototype, {
// A switched-to panel starts at its own top.
const doc = document.getElementById('createCaseDoc');
if (doc) doc.scrollTop = 0;
// Update submit button (hide for manage tab)
const submitBtn = document.getElementById('caseModalSubmit');
// Update submit buttons (hide for manage tab). Two of them: mobile.css hides
// this modal's .set-foot, so phones submit through the header button instead.
const submitBtns = ['caseModalSubmit', 'caseModalSubmitMobile']
.map((id) => document.getElementById(id))
.filter(Boolean);
if (tabName === 'case-manage') {
submitBtn.style.display = 'none';
submitBtns.forEach((btn) => {
btn.style.display = 'none';
});
this.renderCaseManageList();
this.refreshDockerExports();
} else {
submitBtn.style.display = '';
submitBtn.textContent =
const label =
tabName === 'case-create'
? 'Create'
: tabName === 'case-clone'
@@ -2434,6 +2572,10 @@ Object.assign(CodemanApp.prototype, {
: tabName === 'case-docker'
? 'Link Docker'
: 'Link';
submitBtns.forEach((btn) => {
btn.style.display = '';
btn.textContent = label;
});
}
// Focus appropriate input
if (tabName === 'case-create') {
@@ -2454,14 +2596,19 @@ Object.assign(CodemanApp.prototype, {
},
async submitCaseModal() {
const btn = document.getElementById('caseModalSubmit');
const originalText = btn.textContent;
btn.classList.add('loading');
btn.textContent =
// Both submit buttons move together: whichever one the user pressed, the
// other must show the same pending state and be equally unclickable.
const btns = ['caseModalSubmit', 'caseModalSubmitMobile'].map((id) => document.getElementById(id)).filter(Boolean);
const originalText = btns.map((btn) => btn.textContent);
const pendingText =
this.caseModalTab === 'case-create' ? 'Creating...' : this.caseModalTab === 'case-clone' ? 'Cloning...' : 'Linking...';
// A clone holds this request open for minutes; without disabling the button a
// second click fires a second clone (the loser then fails on ALREADY_EXISTS).
btn.disabled = true;
btns.forEach((btn) => {
btn.classList.add('loading');
btn.textContent = pendingText;
btn.disabled = true;
});
try {
if (this.caseModalTab === 'case-create') {
await this.createCase();
@@ -2475,9 +2622,11 @@ Object.assign(CodemanApp.prototype, {
await this.linkCase();
}
} finally {
btn.classList.remove('loading');
btn.disabled = false;
btn.textContent = originalText;
btns.forEach((btn, index) => {
btn.classList.remove('loading');
btn.disabled = false;
btn.textContent = originalText[index];
});
}
},
@@ -2862,6 +3011,61 @@ Object.assign(CodemanApp.prototype, {
});
},
/** HOST workspace directory — the same picker Link Existing uses. */
openDockerWorkspacePathPicker() {
const pathInput = document.getElementById('dockerWorkspacePath');
PathPicker.open({
title: 'Select Host Workspace Folder',
initialPath: pathInput.value.trim(),
directoriesOnly: true,
onSelect: (path) => {
pathInput.value = path;
const nameInput = document.getElementById('dockerCaseName');
if (nameInput && !nameInput.value.trim()) {
const folder = path.split('/').filter(Boolean).pop() || '';
if (/^[a-zA-Z0-9_-]+$/.test(folder)) nameInput.value = folder;
}
},
});
},
/**
* Container workdir. Browses INSIDE the container, because for an adopted
* container nothing is mounted at a matching host path — the host picker would
* be listing a different filesystem, and typing this field blind is exactly
* what makes the launch fail with an OCI chdir error.
*/
openDockerWorkdirPicker() {
const pathInput = document.getElementById('dockerAdoptWorkdir');
const container = document.getElementById('dockerContainerName')?.value.trim();
const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local';
if (!container) {
this.showToast('Enter the container name first', 'error');
return;
}
PathPicker.open({
title: `Select Folder Inside ${container}`,
initialPath: pathInput.value.trim() || '/',
directoriesOnly: true,
fetchListing: async (path) => {
const data = await this._apiJson('/api/docker-cases/browse', {
method: 'POST',
body: { hostId, container, path: path || '/' },
});
if (!data) return { success: false, error: `Could not read ${container}. Is it running?` };
if (data.error) return { success: false, error: data.error };
// Shape it like the host endpoint: one root, so Up/Location behave.
return {
success: true,
data: { ...data, root: '/', roots: [{ label: container, path: '/' }], truncated: false },
};
},
onSelect: (path) => {
pathInput.value = path;
},
});
},
async linkRemoteCase() {
const name = document.getElementById('remoteCaseName').value.trim();
const remotePath = document.getElementById('remoteCasePath').value.trim();
@@ -2943,10 +3147,107 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Reflect the "attach to an existing container" checkbox onto the modal so CSS
* can swap which half of the Docker panel applies. An attribute rather than
* per-row inline styles: the panel is rebuilt by nothing, but the create-time
* rows are a SET (image, network, advanced block) and one attribute keeps them
* in lockstep with the container-name row.
*/
_syncDockerAdoptMode() {
const modal = document.getElementById('createCaseModal');
if (!modal) return;
const adopting = document.getElementById('dockerAdoptExisting')?.checked;
if (adopting) modal.setAttribute('data-docker-adopt', '1');
else modal.removeAttribute('data-docker-adopt');
if (adopting) void this._loadDockerContainerOptions();
},
/**
* Fill the container-name `<datalist>`. A native datalist is deliberate: the
* field must accept a free-typed name (the engine may be remote, or the
* container may not exist yet when the form is filled), and datalist gives
* type-to-filter over the suggestions without a custom dropdown.
*
* Best-effort by design — the endpoint returns [] for an unreachable daemon,
* and an empty list simply leaves the field as plain text input.
*/
async _loadDockerContainerOptions() {
const list = document.getElementById('dockerContainerList');
if (!list) return;
const hostId = document.getElementById('dockerHostId')?.value.trim() || 'local';
if (list.dataset.loadedFor === hostId) return; // one fetch per host per open
const data = await this._apiJson(`/api/docker-hosts/${encodeURIComponent(hostId)}/containers`);
const containers = data?.containers || [];
list.textContent = '';
for (const c of containers) {
const option = document.createElement('option');
option.value = c.name;
// Engine-supplied strings: set as text, never as markup.
option.textContent = c.running ? `${c.image} · ${c.status}` : `${c.image} · ${c.status} (not running)`;
list.appendChild(option);
}
list.dataset.loadedFor = hostId;
},
/**
* Cross-link from the Create New tab's "Run in an isolated Docker container"
* row. Adoption lives on the Docker tab, but the place users actually look for
* anything container-shaped is that checkbox, so this jumps them there with the
* toggle already on rather than leaving the feature undiscoverable.
*/
jumpToDockerAdopt() {
this.switchCaseModalTab('case-docker');
const toggle = document.getElementById('dockerAdoptExisting');
if (toggle) toggle.checked = true;
this._syncDockerAdoptMode();
document.getElementById('dockerContainerName')?.focus();
},
/**
* Read-only preflight against an existing container. It links nothing, so the
* user can find out "not running" / "no tmux" / "codex present, claude missing"
* before committing to a case name — the same reason the server refuses at link
* time rather than at session launch.
*/
async _dockerAdoptPreflight() {
const statusEl = document.getElementById('dockerLinkStatus');
const container = document.getElementById('dockerContainerName')?.value.trim();
const containerWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim();
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
if (!container) {
if (statusEl) statusEl.textContent = 'Enter a container name first.';
return;
}
if (statusEl) statusEl.textContent = 'Inspecting container...';
// _apiJson folds every failure to null, and a preflight's whole value is the
// reason it failed, so the envelope is unwrapped by hand here.
const probe = await this._apiJson('/api/docker-cases/adopt-preflight', {
method: 'POST',
body: { hostId, container, ...(containerWorkdir ? { containerWorkdir } : {}) },
});
if (!statusEl) return;
if (!probe) {
statusEl.textContent = 'Could not reach the docker host profile. Save a Host ID first.';
return;
}
if (!probe.ok) {
statusEl.textContent = probe.error || 'Container is not adoptable.';
return;
}
const modes = (probe.availableModes || []).filter((m) => m !== 'shell');
statusEl.textContent = modes.length
? `Running (${probe.image || 'unknown image'}). Available: ${modes.join(', ')}.`
: `Running (${probe.image || 'unknown image'}), but no agent CLI found inside — only Shell will work.`;
},
async linkDockerCase() {
const name = document.getElementById('dockerCaseName').value.trim();
const hostWorkspacePath = document.getElementById('dockerWorkspacePath').value.trim();
const hostId = document.getElementById('dockerHostId').value.trim() || 'local';
const adopting = !!document.getElementById('dockerAdoptExisting')?.checked;
const container = document.getElementById('dockerContainerName')?.value.trim() || '';
const adoptWorkdir = document.getElementById('dockerAdoptWorkdir')?.value.trim() || '';
const image = document.getElementById('dockerImage').value.trim() || 'codeman/agent:base';
const network = document.getElementById('dockerNetwork').value;
const memory = document.getElementById('dockerMemory').value.trim();
@@ -2967,9 +3268,15 @@ Object.assign(CodemanApp.prototype, {
this.showToast('Workspace path must be absolute', 'error');
return;
}
if (adopting && !container) {
this.showToast('Enter the name of the running container to attach to', 'error');
return;
}
try {
if (statusEl) statusEl.textContent = 'Checking docker daemon + base image...';
if (statusEl) {
statusEl.textContent = adopting ? 'Inspecting the existing container...' : 'Checking docker daemon + base image...';
}
// omitted optionals sent as UNDEFINED (never null — Zod .optional() rejects null)
const resources = {};
if (memory) resources.memory = memory;
@@ -3000,16 +3307,27 @@ Object.assign(CodemanApp.prototype, {
}
if (!hostData.success) throw new Error(hostData.error || 'Failed to save docker host');
const caseRes = await fetch('/api/cases/docker-link', {
// Adoption reuses this whole flow and differs only in the final call: a
// different endpoint (which never creates a container) plus the container
// name. The host upsert above still applies — it is what resolves the
// engine/context/daemon for the `docker exec`; its create-time fields are
// simply never read for an adopted case.
const caseRes = await fetch(adopting ? '/api/cases/docker-adopt' : '/api/cases/docker-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, hostId, hostWorkspacePath }),
body: JSON.stringify(
adopting
? { name, hostId, hostWorkspacePath, container, ...(adoptWorkdir ? { containerWorkdir: adoptWorkdir } : {}) }
: { name, hostId, hostWorkspacePath }
),
});
const caseData = await caseRes.json();
if (caseData.success) {
this.closeCreateCaseModal();
const caps = caseData.data?.capsEnforced === false ? ' (resource caps are advisory on this engine)' : '';
this.showToast(`Docker case "${name}" linked${caps}`, 'success');
const modes = (caseData.data?.availableModes || []).filter((m) => m !== 'shell');
const found = adopting && modes.length ? ` — found ${modes.join(', ')}` : '';
this.showToast(`Docker case "${name}" ${adopting ? 'attached' : 'linked'}${caps}${found}`, 'success');
await this.loadQuickStartCases(name);
await this.saveLastUsedCase(name);
} else {
@@ -3046,7 +3364,7 @@ Object.assign(CodemanApp.prototype, {
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
<span style="flex-shrink:0;">
<a class="btn-toolbar" href="/api/docker-exports/${encodeURIComponent(e.name)}" download>Download</a>
<a class="btn-toolbar" href="${CodemanBase.url(`/api/docker-exports/${encodeURIComponent(e.name)}`)}" download>Download</a>
<button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button>
<button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button>
</span>
@@ -3277,17 +3595,33 @@ Object.assign(CodemanApp.prototype, {
return;
}
let html = '';
// Cases an agent worker created (server-side marker file, see agent-case-marker.ts).
// A long orchestration leaves one scratch directory per worker behind, so they get
// a badge and a bulk cleanup entry point rather than having to be recognised by name.
const agentCases = cases.filter(c => c.agentCreated);
let html = agentCases.length > 0
? `<div class="case-manage-agent-bar">
<span class="case-manage-agent-count">${agentCases.length} case${agentCases.length === 1 ? '' : 's'} created by agent workers</span>
<button class="case-manage-btn case-manage-btn-cleanup" onclick="app.cleanupAgentCases()"
title="Review and delete the scratch cases agent workers left behind">Clean up</button>
</div>`
: '';
cases.forEach((c, idx) => {
const isFirst = idx === 0;
const isLast = idx === cases.length - 1;
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
// case path on a Linux host rendered in full, unabbreviated.
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
const agentTitle = c.agentCreated
? `Created by an agent worker${c.agentCreated.parentSessionName ? ` from ${c.agentCreated.parentSessionName}` : ''}` +
` (${c.agentCreated.createdBy})${c.agentCreated.createdAt ? ` on ${new Date(c.agentCreated.createdAt).toLocaleString()}` : ''}`
: '';
html += `
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
<div class="case-manage-info">
<span class="case-manage-name">${escapeHtml(c.name)}</span>
<span class="case-manage-name">${escapeHtml(c.name)}${
c.agentCreated ? `<span class="case-manage-tag-agent" title="${escapeHtml(agentTitle)}" data-i18n-skip>agent</span>` : ''
}</span>
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
</div>
<div class="case-manage-actions">
@@ -3365,6 +3699,80 @@ Object.assign(CodemanApp.prototype, {
}
},
/**
* Review-then-delete the scratch cases agent workers left behind.
*
* ⚠️ Never silently bulk-deletes: the confirm names every directory, and a case a
* LIVE session is still working in is excluded outright rather than confirmed away
* (`inUse` from the server, which knows every session's working directory). Removal
* reuses `DELETE /api/cases/:name` one name at a time, so there is no second
* recursive-delete path to keep in step with the first.
*/
async cleanupAgentCases() {
let agentCases;
try {
const res = await fetch('/api/cases/agent-created');
const body = await res.json();
if (!body.success) {
this.showToast(body.error || 'Failed to list agent cases', 'error');
return;
}
agentCases = body.data.cases || [];
} catch (err) {
this.showToast('Failed to list agent cases: ' + err.message, 'error');
return;
}
const busy = agentCases.filter(c => c.inUse);
const removable = agentCases.filter(c => !c.inUse);
if (removable.length === 0) {
this.showToast(
busy.length > 0
? `All ${busy.length} agent case(s) are still in use by a running session`
: 'No agent-created cases to clean up',
'info'
);
return;
}
const names = removable.map(c => ` ${c.name}`).join('\n');
const busyNote = busy.length > 0 ? `\n\nSkipping ${busy.length} case(s) still in use by a running session.` : '';
if (!confirm(`Permanently delete ${removable.length} agent-created case folder(s) and everything in them?\n\n${names}${busyNote}`)) {
return;
}
let deleted = 0;
const failed = [];
for (const item of removable) {
try {
const res = await fetch(`/api/cases/${encodeURIComponent(item.name)}`, { method: 'DELETE' });
const body = await res.json();
if (body.success) deleted++;
else failed.push(item.name);
} catch {
failed.push(item.name);
}
}
this.showToast(
failed.length === 0
? `Deleted ${deleted} agent case(s)`
: `Deleted ${deleted}, failed: ${failed.join(', ')}`,
failed.length === 0 ? 'success' : 'error'
);
// Refresh the picker (its selected case may be one we just deleted) and the list.
const select = document.getElementById('quickStartCase');
const currentCase = select?.value;
const currentDeleted = removable.some(c => c.name === currentCase);
if (currentDeleted) select?.blur?.();
await this.loadQuickStartCases(currentDeleted ? null : currentCase);
if (currentDeleted) {
await this.saveLastUsedCase(document.getElementById('quickStartCase')?.value || 'testcase');
}
this.renderCaseManageList();
},
async saveCaseOrder(order) {
try {
await fetch('/api/cases/order', {
+37 -6
View File
@@ -187,7 +187,10 @@ Object.assign(CodemanApp.prototype, {
registerServiceWorker() {
if (!('serviceWorker' in navigator)) return;
navigator.serviceWorker.register('/sw.js').then((reg) => {
// Behind a sub-path mount the worker is served at <base>/sw.js and controls
// <base>/ (Service-Worker-Allowed is '/', so this narrower scope is permitted).
const _swBase = window.CodemanBase?.base || '';
navigator.serviceWorker.register(_swBase + '/sw.js', { scope: _swBase + '/' }).then((reg) => {
this._swRegistration = reg;
// Listen for messages from service worker (notification clicks)
navigator.serviceWorker.addEventListener('message', (event) => {
@@ -922,7 +925,7 @@ Object.assign(CodemanApp.prototype, {
desc.textContent = inert
? 'The selected model has no 1M variant.'
: base
? 'Available for Fable 5, Opus and Opus 4.6.'
? 'Available for Fable 5.1, Fable 5, Opus and Opus 4.6.'
: 'With no model pinned, this starts new sessions on Opus with a 1M window.';
}
},
@@ -1079,10 +1082,14 @@ Object.assign(CodemanApp.prototype, {
const verEl = this.$('updateCurrentVersion');
if (verEl && data.currentVersion) verEl.textContent = `v${data.currentVersion}`;
if (data.installKind && data.installKind !== 'git') {
this._setUpdateResult(
`This install can't update itself (${escapeHtml(data.installKind)}). Update with <code>npm i -g aicodeman@latest</code>.`
);
// `docker-compose` self-updates in place like `git` does — the container
// restarts itself. Anything else cannot.
if (data.installKind && data.installKind !== 'git' && data.installKind !== 'docker-compose') {
const hint =
data.supervisor === 'docker-compose'
? 'Update from the Docker host with <code>docker/Start-Codeman.sh</code>.'
: 'Update with <code>npm i -g aicodeman@latest</code>.';
this._setUpdateResult(`This install can't update itself (${escapeHtml(data.installKind)}). ${hint}`);
return;
}
if (data.selfUpdateEnabled === false) {
@@ -1093,6 +1100,30 @@ Object.assign(CodemanApp.prototype, {
this._setUpdateResult(escapeHtml(data.error));
return;
}
// A container release that changes the ENVIRONMENT (Dockerfile, compose file
// or new .env keys) cannot be applied by the container restarting itself, so
// the update button is never offered — the host command is, instead. The
// server re-checks this on POST, so hiding the button is UX, not the gate.
const blockers = data.environment?.blockers || [];
if (data.updateAvailable && blockers.length > 0) {
const reasons = blockers
.map((b) => {
const details = b.details?.length ? `<br><code>${escapeHtml(b.details.join(' '))}</code>` : '';
return `<li>${escapeHtml(b.message)}${details}</li>`;
})
.join('');
this._setUpdateResult(
`<strong>v${escapeHtml(data.latestVersion || '')}</strong> needs a rebuild on the Docker host` +
` (current v${escapeHtml(data.currentVersion || '')}):<ul>${reasons}</ul>` +
`Run <code>${escapeHtml(data.environment?.hostCommand || 'docker/Start-Codeman.sh')}</code> there to apply it.`
);
if (notes && data.notes) {
notes.style.display = 'block';
notes.textContent = data.notes;
}
return;
}
if (data.updateAvailable && data.latestVersion) {
this._setUpdateResult(
`Update available: <strong>v${escapeHtml(data.latestVersion)}</strong> &nbsp;(current v${escapeHtml(data.currentVersion || '')})`
+133
View File
@@ -5941,6 +5941,57 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
color: #ef4444;
}
/* Agent-created cases: the badge on a scratch case, and the bulk cleanup bar above
the list. Tokens only (no hardcoded ink), so the light skins repaint with the rest. */
.case-manage-tag-agent {
display: inline-block;
margin-left: 6px;
padding: 0 5px;
border: 1px solid var(--control-border);
border-radius: 3px;
background: var(--control-bg);
color: var(--text-muted);
font-size: 0.58rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
vertical-align: 1px;
}
.case-manage-agent-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 4px;
padding: 8px 10px;
border: 1px solid var(--control-border);
border-radius: 6px;
/* Sticky, and therefore OPAQUE: it is the first child of the scrolling list
(.case-manage-list is a 320px-tall flex scroller), so a translucent bar would
have case rows sliding visibly under it, and a static one would put the cleanup
button out of reach the moment a long case list is scrolled. */
position: sticky;
top: 0;
z-index: 1;
background: var(--bg-card);
}
.case-manage-agent-count {
font-size: 0.7rem;
color: var(--text-dim);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* The shared .case-manage-btn is a 26px icon square; this one carries a word. */
.case-manage-btn-cleanup {
width: auto;
padding: 0 10px;
white-space: nowrap;
}
.toolbar-input {
padding: 0.4rem 0.5rem;
background: var(--bg-input);
@@ -12470,6 +12521,13 @@ kbd {
color: var(--text-dim);
opacity: 0.45;
}
/* A window Claude is not currently reporting: the slot stays, dimmed, so the
chip keeps its shape instead of looking like half of it broke. */
.header-plan-usage .pu-win-idle .pu-label,
.header-plan-usage .pu-win-idle .pu-val {
color: var(--text-dim);
opacity: 0.55;
}
/* Green/yellow/red by how much of the window is used up. */
.header-plan-usage .pu-green {
color: #3fb950;
@@ -12600,6 +12658,43 @@ kbd {
background: color-mix(in srgb, var(--green) 12%, transparent);
}
/* Consecutive messages from one speaker inside one turn are segments of a
single utterance, not separate cards: no repeated badge, a hairline seam.
The role accent survives because the colour rules above match on BOTH
:has(.rv-role-*) and .rv-msg-* — a badge-less continuation still hits the
class arm. Do not drop either arm. */
.rv-message.rv-msg-cont {
margin-top: -18px;
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
padding-top: 0;
}
.rv-message.rv-msg-cont > .rv-text {
border-top: 1px solid var(--border);
padding-top: 12px;
}
.rv-message:has(+ .rv-msg-cont) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding-bottom: 0;
}
/* A prompt the user typed while the agent was working (absorbed mid-turn).
A pseudo-element, not a text node, so the i18n MutationObserver cannot
rewrite it. */
.rv-message[data-queued='1'] .rv-role::after {
content: ' ⏱';
}
.rv-notice {
opacity: 0.7;
font-style: italic;
margin-top: 12px;
}
/* Markdown rendered content inside response viewer.
Prose uses a proportional font for readability; code keeps monospace. */
.rv-text,
@@ -16638,6 +16733,44 @@ html[data-tab-orientation='vertical'] .home-sessions {
label as a row label, its `.form-hint` as a row description. Scoped to the
document, so `.form-row` everywhere else is untouched.
─────────────────────────────────────────────────────────────────────────── */
/* Adopt-an-existing-container mode swaps which half of the Docker panel applies:
the create-time fields (image, network, resources, credential mounts) describe
a `docker create` that adoption never runs, and the container name is the one
field only adoption needs. `.docker-adopt-only` is hidden by default so the
panel stays exactly as it was until the checkbox is ticked. Rules carry
`!important` because the adapter block above paints `.form-row` as a row card
and `details.advanced-options` has its own display. */
/* Run-menu notice: why a container case is offering no agent modes. Lives at the
top of the menu so the reason is where the missing entries would have been. */
.run-mode-notice {
padding: 8px 12px;
margin: 0 0 4px;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted, #9aa0a6);
border-bottom: 1px solid var(--border, #333);
white-space: normal;
}
#createCaseModal .docker-adopt-only {
display: none !important;
}
#createCaseModal[data-docker-adopt='1'] .docker-adopt-only {
display: block !important;
}
#createCaseModal[data-docker-adopt='1'] .docker-create-only {
display: none !important;
}
#createCaseModal .btn-inline-check {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--accent, #4a9eff);
cursor: pointer;
text-decoration: underline;
}
#createCaseModal .set-doc .form-row {
margin: 0 0 3px;
padding: 7px 10px;
+13 -6
View File
@@ -20,6 +20,13 @@
const CACHE_NAME = 'codeman-v1';
// Reverse-proxy base path: the worker is served at `<base>/sw.js`, so its own
// location tells us the mount prefix ('' at root, or '/codeman'). Every URL below
// is prefixed through B() so the cached shell, icons and API calls resolve under
// the mount instead of escaping to the origin root.
const SW_BASE = self.location.pathname.replace(/\/sw\.js$/, '');
const B = (p) => (p && p[0] === '/' ? SW_BASE + p : p);
// Core app shell -- cached on install for instant startup
const APP_SHELL = [
'/',
@@ -45,7 +52,7 @@ const APP_SHELL = [
'/icon-192.png',
'/icon-512.png',
'/manifest.json',
];
].map(B);
// --- Install: precache app shell ---
@@ -116,9 +123,9 @@ self.addEventListener('push', (event) => {
const options = {
body: body || '',
tag: tag || 'codeman-default',
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { sessionId, approvalId, url: sessionId ? `/?session=${sessionId}` : '/' },
icon: B('/icon-192.png'),
badge: B('/icon-192.png'),
data: { sessionId, approvalId, url: sessionId ? B(`/?session=${sessionId}`) : B('/') },
renotify: true,
requireInteraction: urgency === 'critical',
};
@@ -143,7 +150,7 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { sessionId, approvalId, url } = event.notification.data || {};
const targetUrl = url || '/';
const targetUrl = url || B('/');
const action = event.action || null;
// Approve/Deny action buttons answer the Approvals Inbox item directly from
@@ -152,7 +159,7 @@ self.addEventListener('notificationclick', (event) => {
// because a service worker fetch carries the worker's own (same) origin.
if ((action === 'approve' || action === 'deny') && approvalId) {
event.waitUntil(
fetch(`/api/approvals/${encodeURIComponent(approvalId)}/answer`, {
fetch(B(`/api/approvals/${encodeURIComponent(approvalId)}/answer`), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
+26 -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, s.mode);
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode, s.resumeId);
}
})
);
@@ -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, s.mode);
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '', s.name, s.mode, s.resumeId);
}
this.closeSessionManager?.();
closeMenu();
@@ -2904,7 +2904,7 @@ Object.assign(CodemanApp.prototype, {
return `w${startNumber}-${dirName}`;
},
async resumeHistorySession(sessionId, workingDir, existingName, mode) {
async resumeHistorySession(sessionId, workingDir, existingName, mode, resumeId) {
// Close the run mode menu if open
document.getElementById('runModeMenu')?.classList.remove('active');
// Close folder history modal if open
@@ -2942,19 +2942,27 @@ Object.assign(CodemanApp.prototype, {
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.
// codex names a thread by an id of its own, not by Codeman's session id,
// so it continues only when the row carried that id: `resumeId` is set by
// the rollout scanner (codex-transcript.ts) and by nothing else, which is
// what stops a LIVE codex row — whose sessionId is Codeman's uuid — from
// asking codex for a thread that does not exist.
//
// gemini/antigravity still have no wired continuation here (same reason
// codex used to have none: an exact conversation id nothing supplies) —
// `continuesSomething` below is what keeps their row from being retired
// for a resume that didn't actually continue anything.
const codexResumeId = effectiveMode === 'codex' ? resumeId : undefined;
const modeConfig =
modeConfigKey
? { [modeConfigKey]: { continueSession: true } }
: effectiveMode === 'deepseek'
? { deepSeekConfig: { resumeSession: true } }
: {};
const continuesSomething = Boolean(modeConfigKey) || effectiveMode === 'deepseek';
: codexResumeId
? { codexConfig: { resumeSessionId: codexResumeId } }
: {};
const continuesSomething =
Boolean(modeConfigKey) || effectiveMode === 'deepseek' || Boolean(codexResumeId);
const createRes = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -2982,11 +2990,16 @@ Object.assign(CodemanApp.prototype, {
// 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
// Gated on `continuesSomething`: for gemini/antigravity, and for a codex
// row carrying no `resumeId`, 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.
//
// A codex row that DOES continue passes this gate, but the DELETE is a
// no-op for it: `sessionId` there is codex's thread id and no Codeman
// session carries that id. Its duplicate is cleared from the other side
// instead, by the alias fold in gatherUnifiedInputs()/Session.
if (effectiveMode !== 'claude' && continuesSomething && sessionId !== newSessionId) {
fetch(`/api/sessions/${sessionId}?killMux=true`, { method: 'DELETE' }).catch(() => {});
}
+1 -1
View File
@@ -322,7 +322,7 @@ const ClaudeVoiceProvider = {
if (opts.keyterms?.length) params.set('keyterms', opts.keyterms.join(','));
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
try {
this._ws = new WebSocket(`${proto}//${location.host}/ws/voice/stream?${params}`);
this._ws = new WebSocket(`${proto}//${location.host}${window.CodemanBase?.base || ''}/ws/voice/stream?${params}`);
} catch (err) {
this._onError?.('Failed to open voice stream: ' + err.message);
this._cleanup();
+3 -1
View File
@@ -182,7 +182,9 @@ Object.assign(CodemanApp.prototype, {
if (webview.trusted) sandbox.push('allow-same-origin');
frame.setAttribute('sandbox', sandbox.join(' '));
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
frame.src = src;
// Proxied dashboards carry a root-absolute `/webview/<cap>/` embedUrl that must
// ride the mount prefix; external (trusted) URLs are absolute and pass through.
frame.src = CodemanBase.url(src);
const failure = document.createElement('div');
failure.className = 'webview-failure';
+37 -2
View File
@@ -8,7 +8,6 @@
import { join, resolve, relative, isAbsolute } from 'node:path';
import { realpathSync, existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import type { z } from 'zod';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { Session } from '../session.js';
@@ -21,12 +20,16 @@ import type { EventPort } from './ports/event-port.js';
import type { AuthSessionRecord } from './ports/auth-port.js';
import type { StaleExpirationMap } from '../utils/index.js';
import { dataPath } from '../config/instance.js';
import { getCasesDir } from '../config/cases-dir.js';
import { isMultiUserMode, maxSessionsPerUser, userCasesDir } from '../config/multiuser.js';
import { SYNTHETIC_ADMIN, findUser } from '../user-store.js';
import { AGENT_ORIGIN_SPAWNED_BY_SESSION, normalizeAgentOrigin } from '../agent-case-marker.js';
// Shared path constants used across route modules. CASES_DIR (project folders)
// stays shared across instances; SETTINGS_PATH is per-instance runtime state.
export const CASES_DIR = join(homedir(), 'codeman-cases');
// The cases dir is resolved in ONE place (config/cases-dir.ts) because the CLI
// resolves it too, and CODEMAN_CASES_PATH must move both or neither.
export const CASES_DIR = getCasesDir();
export const SETTINGS_PATH = dataPath('settings.json');
/**
@@ -359,6 +362,33 @@ export function resolveParentSessionId(
return parent.id;
}
/**
* Resolve "an agent asked for this", the signal that labels a case directory
* Codeman is about to CREATE as an agent scratch workspace (see agent-case-marker.ts).
*
* Two signals, in order:
* 1. an explicit `agentOrigin` body field, or the `X-Codeman-Agent-Origin` header the
* packaged skill sets once on its shared curl invocation, so every spawn recipe
* carries it without a per-recipe edit. The body wins, mirroring parentSessionId;
* 2. failing that, an already-RESOLVED parent session id. A create request that names
* the session that spawned it came from an agent by construction: nothing in the
* browser UI sets lineage. This is what still labels workers spawned by a stale
* skill copy or by hand-rolled curl that only carries the lineage header.
*
* ⚠️ Decoration, like parentSessionId: never an ownership or permission signal, and
* never a reason to fail a spawn. An unrecognised origin token is dropped by
* `normalizeAgentOrigin` rather than rejected.
*/
export function resolveAgentCaseOrigin(
req: FastifyRequest,
bodyValue: string | undefined,
resolvedParentSessionId: string | undefined
): string | undefined {
const header = req.headers['x-codeman-agent-origin'];
const raw = bodyValue ?? (Array.isArray(header) ? header[0] : header);
return normalizeAgentOrigin(raw) ?? (resolvedParentSessionId ? AGENT_ORIGIN_SPAWNED_BY_SESSION : undefined);
}
/**
* Parse and validate a request body against a Zod schema, or throw a structured 400 error.
* Replaces the repeated pattern: `const r = Schema.safeParse(body); if (!r.success) return createErrorResponse(...)`.
@@ -422,6 +452,11 @@ export function sanitizeHookData(data: Record<string, unknown> | null | undefine
'stop_hook_active',
'transcript_path',
'message',
// UserPromptSubmit identity fields. `prompt` is deliberately NOT here: the
// prompt text would land in the SSE broadcast, and Read My Mind already
// captures intent through transcript-watcher.
'prompt_id',
'source',
];
for (const key of allowedKeys) {
+6 -1
View File
@@ -35,6 +35,7 @@ import {
UserStoreError,
} from '../../user-store.js';
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
import { webviewCapabilities } from '../../webview-capabilities.js';
import { appendAdminAudit } from '../admin-audit.js';
import { SseEvent } from '../sse-events.js';
import type { AuthPort } from '../ports/auth-port.js';
@@ -179,7 +180,10 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
if (!gate(req, reply)) return;
const { username } = req.params as { username: string };
const revoked = revokeUserSessions(ctx.authSessions, username);
audit(req, 'user.logout', username, { revoked });
// Web-tab proxy capabilities are a second credential the cookie purge does not
// touch; a forced logout that left them alive would not be a logout.
const revokedWebviews = webviewCapabilities.revokeOwner(normalizeUsername(username));
audit(req, 'user.logout', username, { revoked, revokedWebviews });
return { success: true, data: { revoked } };
});
@@ -201,6 +205,7 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut
await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {});
}
revokeUserSessions(ctx.authSessions, username);
webviewCapabilities.revokeOwner(normalizeUsername(username));
if (deleteSpace) await deleteUserSpace(username);
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
ctx.broadcast(SseEvent.AdminUsersChanged, {});
+315 -5
View File
@@ -13,7 +13,15 @@ import fs from 'node:fs/promises';
import { join, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import type { ApiResponse, CaseInfo, DockerHost, RemoteSessionInfo, SessionDocker } from '../../types.js';
import type {
AgentCaseSummary,
ApiResponse,
CaseInfo,
DockerHost,
RemoteSessionInfo,
SessionDocker,
SessionMode,
} from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import {
CreateCaseSchema,
@@ -24,6 +32,9 @@ import {
RemoteCaseLinkSchema,
RemoteHostSchema,
DockerCaseLinkSchema,
DockerCaseAdoptSchema,
DockerAdoptPreflightSchema,
DockerBrowseSchema,
DockerHostSchema,
DockerExportSchema,
DockerImportSchema,
@@ -39,6 +50,7 @@ import {
} from '../../git-clone.js';
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
import {
canAccessOwned,
@@ -66,6 +78,10 @@ import {
DEFAULT_AGENT_IMAGE,
dockerContainerName,
dockerDisplayPath,
probeAdoptableContainer,
listDockerContainers,
browseInContainer,
dockerAdoptProbeModes,
readDockerCases,
readDockerHosts,
removeDockerContainer,
@@ -73,6 +89,7 @@ import {
writeDockerCases,
writeDockerHosts,
} from '../../docker-hosts.js';
import type { AdoptedContainerProbe, DockerBrowseResult, DockerContainerInfo } from '../../docker-hosts.js';
import { buildDockerRemoveCommand } from '../../tmux-manager.js';
import {
checkRemoteTmuxAvailable,
@@ -136,6 +153,21 @@ function repoShipsClaudeSettings(casePath: string): boolean {
return ['settings.json', 'settings.local.json'].some((file) => existsSync(join(casePath, '.claude', file)));
}
/**
* Project a case's marker onto the wire shape `CaseInfo.agentCreated` carries.
* `owner` stays server-side: the listings are already owner-scoped, and it is not
* something the case list needs to publish.
*/
function agentCreatedInfo(marker: AgentCaseMarker): NonNullable<CaseInfo['agentCreated']> {
return {
createdAt: marker.createdAt,
createdBy: marker.createdBy,
...(marker.parentSessionId ? { parentSessionId: marker.parentSessionId } : {}),
...(marker.parentSessionName ? { parentSessionName: marker.parentSessionName } : {}),
...(marker.mode ? { mode: marker.mode } : {}),
};
}
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
async function readLinkedCases(): Promise<Record<string, string>> {
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
@@ -214,11 +246,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
const entries = await fs.readdir(listBase, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory() && SAFE_CASE_NAME.test(e.name)) {
const casePath = join(listBase, e.name);
// Only a directory Codeman scaffolded for an agent spawn carries a marker,
// so this stays absent for every human-created, linked or cloned case.
const marker = await readAgentCaseMarker(casePath);
cases.push({
name: e.name,
path: join(listBase, e.name),
hasClaudeMd: existsSync(join(listBase, e.name, 'CLAUDE.md')),
path: casePath,
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
location: 'local',
...(marker ? { agentCreated: agentCreatedInfo(marker) } : {}),
});
}
}
@@ -291,6 +328,8 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
image: host.image,
path: dockerCase.hostWorkspacePath,
network: host.network ?? 'bridge',
...(dockerCase.availableModes ? { availableModes: dockerCase.availableModes } : {}),
...(dockerCase.owned === false ? { owned: false } : {}),
},
};
const existingIndex = cases.findIndex((item) => item.name === dockerCase.name);
@@ -316,6 +355,61 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return cases;
});
// ========== Agent-created cases (cleanup listing) ==========
/**
* The scratch workspaces agent workers left behind, newest first.
*
* A long orchestration creates one case directory per worker, and deleting the
* sessions does not remove them, so without this the only way to tell an agent's
* `alpha`/`beta` from a real project was to remember which was which. Reads the same
* marker `GET /api/cases` exposes and adds the two facts a human needs before
* deleting a directory: whether a live session is still working in it, and when it
* was last touched.
*
* ⚠️ Read-only on purpose: removal goes through the existing `DELETE /api/cases/:name`,
* one name at a time, so this file keeps exactly one recursive-delete path. Scoped by
* construction — it only ever walks the caller's own case space.
*/
app.get('/api/cases/agent-created', async (req): Promise<ApiResponse<{ cases: AgentCaseSummary[] }>> => {
const user = getAuthUser(req);
const listBase = resolveCasesDir(user);
const inUsePaths = new Set(
Array.from(ctx.sessions.values())
.filter((session) => canAccessOwned(user, session.owner))
.map((session) => session.workingDir)
);
let entries;
try {
entries = await fs.readdir(listBase, { withFileTypes: true });
} catch {
return { success: true, data: { cases: [] } }; // case space not created yet
}
const summaries: AgentCaseSummary[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || !SAFE_CASE_NAME.test(entry.name)) continue;
const casePath = join(listBase, entry.name);
const marker = await readAgentCaseMarker(casePath);
if (!marker) continue;
const modifiedAt = await fs
.stat(casePath)
.then((stat) => stat.mtime.toISOString())
.catch(() => undefined);
summaries.push({
name: entry.name,
path: casePath,
...agentCreatedInfo(marker),
inUse: inUsePaths.has(casePath),
...(modifiedAt ? { modifiedAt } : {}),
});
}
summaries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return { success: true, data: { cases: summaries } };
});
app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, description } = parseBody(CreateCaseSchema, req.body);
@@ -771,6 +865,191 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
);
/**
* ADOPT an already-running container (`owned: false`). The mirror of the
* remote-SSH attach path: Codeman execs into a container the user built and
* runs, and never creates, starts, stops, restarts or removes it.
*
* Everything here is read-only toward the container. The preflight refuses at
* LINK time — missing, stopped, or no tmux inside — because the alternative is
* failing at session launch, where the only ways out would be a dead pane or
* starting a container we do not own. There is no image gate and no
* `ensureCaseImage`: adoption never runs `docker create`, so the container's
* image is the user's business.
*/
app.post(
'/api/cases/docker-adopt',
async (req, reply): Promise<ApiResponse<{ case: unknown; image?: string; availableModes?: SessionMode[] }>> => {
// ⚠️ Admin-only in multi-user mode, unlike `docker-link` right above. Linking
// creates OUR container, whose only bind mount is a workspace `isWorkingDirAllowed`
// has already confined. Adoption names a container someone else built, and its
// mounts are whatever its owner gave it — a container mounting `/` hands the
// adopter a shell over the whole host, which is exactly the workspace scoping this
// mode exists to enforce. Same machine-level reasoning as the docker HOST routes.
const denied = adminOnly(req, reply);
if (denied) return denied;
const dockerCase = {
...parseBody(DockerCaseAdoptSchema, req.body),
type: 'docker' as const,
owner: ownerFor(req),
owned: false as const,
};
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const linkedCases = await readLinkedCases();
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
if (
dockerCases.some((item) => item.name === dockerCase.name) ||
linkedCases[dockerCase.name] ||
existsSync(join(resolveCasesDir(getAuthUser(req)), dockerCase.name))
) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
// Two cases must never share one adopted container: session close kills the
// in-container tmux by session id, but a shared adoption would let one case's
// teardown and another's launch race over the same tmux server.
const container = dockerCase.container;
if (dockerCases.some((item) => (item.container ?? dockerContainerName(item.name)) === container)) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `Container "${container}" is already linked to a case`);
}
if (!isWorkingDirAllowed(getAuthUser(req), dockerCase.hostWorkspacePath)) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'hostWorkspacePath is outside your workspace');
}
// The workspace must ALREADY exist: it mirrors a path inside a container we
// did not create, so silently mkdir-ing it would invent a host directory that
// does not correspond to whatever is actually mounted there.
if (!existsSync(dockerCase.hostWorkspacePath)) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
'hostWorkspacePath does not exist. Adoption mirrors an existing container, so point this at the real host directory already mounted into it.'
);
}
const availability = await checkDockerAvailable(host.engine);
if (!availability.ok) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
availability.error || 'docker daemon is not available'
);
}
// The container workdir is validated INSIDE the container. It defaults to
// hostWorkspacePath only because that is what an owned container's bind
// mount guarantees; adoption mounts nothing, so the probe has to prove it.
const adoptDocker = toSessionDocker(host, dockerCase);
const probe = await probeAdoptableContainer(adoptDocker, dockerAdoptProbeModes(), adoptDocker.containerWorkdir);
if (!probe.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, probe.error || 'container is not adoptable');
}
// Persist what the container actually has: the run-mode picker gates on
// HOST CLIs, which is the wrong question for a case whose agents run inside
// a container the host knows nothing about.
const adoptedCase = { ...dockerCase, availableModes: probe.availableModes };
await writeDockerCases(CODEMAN_CONFIG_DIR, [...dockerCases, adoptedCase]);
ctx.broadcast(SseEvent.CaseLinked, {
name: adoptedCase.name,
path: adoptedCase.hostWorkspacePath,
type: 'docker',
});
return {
success: true,
data: { case: adoptedCase, image: probe.image, availableModes: probe.availableModes },
};
}
);
/**
* Preflight an existing container WITHOUT linking anything, so the UI can tell
* the user "not running" / "no tmux" / "codex present, claude missing" before
* they commit to a case name. Read-only; never touches container lifecycle.
*/
/**
* Containers on the host's engine, for the adoption picker. Read-only and
* best-effort (mirror of the remote `:hostId/sessions` discovery route): an
* unreachable daemon yields an empty list rather than an error, because the
* container name is a free-text field the user can always type by hand.
*/
app.get(
'/api/docker-hosts/:hostId/containers',
async (req, reply): Promise<ApiResponse<{ containers: DockerContainerInfo[] }>> => {
// Enumerating every container on the engine is machine-level information (names,
// images, uptime), so it follows the docker-host policy rather than the case one.
const denied = adminOnly(req, reply);
if (denied) return denied;
const { hostId } = req.params as { hostId: string };
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const containers = await listDockerContainers({
engine: host.engine ?? 'docker',
context: host.context,
daemonHost: host.daemonHost,
});
return { success: true, data: { containers } };
}
);
/**
* Browse a directory INSIDE a container, for the adoption form's
* container-workdir picker. The host picker cannot answer this: for an adopted
* container nothing is mounted at a matching host path, so the field would
* otherwise be typed blind. Read-only — one `ls` through `docker exec`.
*/
app.post('/api/docker-cases/browse', async (req, reply): Promise<ApiResponse<DockerBrowseResult>> => {
// Reads a directory listing inside an ARBITRARY named container, so it is gated with
// the adopt flow it serves rather than with the (owner-scoped) case file routes.
const denied = adminOnly(req, reply);
if (denied) return denied;
const body = parseBody(DockerBrowseSchema, req.body);
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const result = await browseInContainer(
{
engine: host.engine ?? 'docker',
context: host.context,
daemonHost: host.daemonHost,
containerName: body.container,
},
body.path || '/'
);
return { success: true, data: result };
});
app.post('/api/docker-cases/adopt-preflight', async (req, reply): Promise<ApiResponse<AdoptedContainerProbe>> => {
const body = parseBody(DockerAdoptPreflightSchema, req.body);
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
// ⚠️ NOT plain `adminOnly`, unlike the two routes above: the run menu probes this for
// every docker case to learn which CLIs the CONTAINER has, so an admin-only gate would
// hide every agent mode from a non-admin's own docker case. A non-admin may therefore
// probe a container ALREADY linked to a case they can access — never an arbitrary one,
// which is the adopt-time question and stays admin-only with the rest of that flow.
if (!isAdmin(req)) {
const owns = dockerCases.some(
(item) =>
(item.container ?? dockerContainerName(item.name)) === body.container &&
canAccessOwned(getAuthUser(req), item.owner)
);
if (!owns) {
reply.code(403);
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
}
}
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === body.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const probe = await probeAdoptableContainer(
{
engine: host.engine ?? 'docker',
context: host.context,
daemonHost: host.daemonHost,
containerName: body.container,
},
dockerAdoptProbeModes(),
body.containerWorkdir
);
return { success: true, data: probe };
});
// One-click "Run in Docker": create a NORMAL case (folder in CASES_DIR, scaffolded)
// AND link it to a hardened container with default settings, auto-provisioning a
// shared `default` docker host so the user never touches host/image/network fields.
@@ -899,6 +1178,17 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const sessionDocker = toSessionDocker(host, dockerCase);
// A full export `docker commit`s the container into an image. For an ADOPTED
// container that means packaging someone else's container — with whatever
// credentials its owner logged in with — into a bundle Codeman then hands out,
// and it is the one export step that touches the container at all. The
// workspace-only export is a plain host-directory tar and stays available.
if (mode === 'full' && dockerCase.owned === false) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
`Case "${name}" adopted an existing container. Codeman does not own it and will not commit it to an image — use a workspace-only export, or build the image yourself.`
);
}
if (mode === 'full' && !sessionDocker.mountCredentials) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
@@ -1042,8 +1332,22 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
'/api/docker-cases/:name/recreate',
async (req): Promise<ApiResponse<{ name: string; container: string }>> => {
const { name } = req.params as { name: string };
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find((item) => item.name === name);
// Ownership gate: recreate DESTROYS a container, so it must be scoped like
// delete is (`canAccessOwned`). Without it any user could rebuild another
// user's container by name.
const dockerCase = (await readDockerCases(CODEMAN_CONFIG_DIR)).find(
(item) => item.name === name && canAccessOwned(getAuthUser(req), item.owner)
);
if (!dockerCase) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker case not found');
// An ADOPTED container is the user's own: there is nothing to recreate it
// from (no create-config, no image gate) and destroying it is exactly what
// adoption promises never to do.
if (dockerCase.owned === false) {
return createErrorResponse(
ApiErrorCode.FORBIDDEN,
`Case "${name}" adopted an existing container. Codeman does not own its lifecycle and will not recreate it — rebuild it yourself, or unlink the case.`
);
}
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Docker host not found');
const sessionDocker = toSessionDocker(host, dockerCase);
@@ -1154,7 +1458,13 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
);
// Best-effort `docker rm -f` the per-case container (case-delete is the
// explicit teardown that removes it; the bind-mounted workspace survives).
const host = (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
// An ADOPTED container is skipped entirely: unlinking the case must leave
// the user's own container running and untouched. The seed file is skipped
// with it — adoption never wrote one.
const host =
dockerCase.owned === false
? undefined
: (await readDockerHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === dockerCase.hostId);
if (host) {
const sessionDocker = toSessionDocker(host, dockerCase);
try {
+3 -2
View File
@@ -7,6 +7,7 @@
*/
import { FastifyInstance } from 'fastify';
import { getCli } from '../../config/cli-registry/registry.js';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js';
import { canAccessOwned, getAuthUser, isWorkingDirAllowed, ownerFor, parseBody } from '../route-helpers.js';
@@ -45,7 +46,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
// Resolve the owner's grant from the store (AuthUser.role alone can't tell a GRANTED
// regular user from a plain one); mirrors session-routes + the cron fire-time re-check.
if (
(body.agentType === 'shell' || body.launchCommand) &&
(getCli(body.agentType)?.capabilities.privilegedCommandGate || body.launchCommand) &&
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
) {
return createErrorResponse(
@@ -72,7 +73,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
}
if (
(body.agentType === 'shell' || body.launchCommand) &&
(getCli(body.agentType ?? 'claude')?.capabilities.privilegedCommandGate || body.launchCommand) &&
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
) {
return createErrorResponse(

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