feat(jcode): add jcode as a run mode

jcode (1jehuang/jcode) joins the CLI registry as the `jcode` run mode:
Run menu, phone overview, TUI, quick-start and POST /api/sessions
(`jcodeConfig: { model?, resumeSessionId? }`), local, SSH and Docker
(dedicated agent-image layer), plus codeman doctor and install.sh
through the generated catalogue. Measured on a live jcode 0.98.1 pane.

- Work detection reads jcode's braille status row and `<n>>` composer;
  alt screen with mouse strip and PageUp/PageDown paging like opencode.
- Every jcode TUI is a client of one shared per-user daemon whose tools
  run with the daemon's env, so no agent skill injection and no JCODE_*
  envOverrides prefix.
- New `capabilities.sharedDaemon`: a session kill spares descendants
  outside the pane's process group, so closing the tab that started the
  daemon no longer cuts every other jcode session off (measured).
- last-response answers an honest empty for an external CLI with no
  transcript reader that the pane segmenter cannot read (jcode alone).
- GET /api/jcode/status via the generic registry resolver.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-16 17:04:28 +02:00
1 parent ce809eac41
commit 1709b0f04e
66 files changed
+1285 -119

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---
**jcode as a run mode.** Codeman can now start [jcode](https://github.com/1jehuang/jcode) sessions from the Run menu, the phone overview and the API (`mode: "jcode"`, with `jcodeConfig: { model?, resumeSessionId? }`), locally, over SSH and in Docker cases (the agent image installs it). Codeman reads jcode's spinner row to tell a working session from an idle one, the wheel pages jcode's own transcript, and `codeman doctor` and the installer find it. jcode runs every session's tools in one shared background daemon, so closing a jcode tab never stops that daemon, its sessions get no Codeman agent skill and take no `JCODE_*` environment overrides, and a jcode worker's answer is read from its terminal rather than `last-response`; see `docs/jcode-integration.md`.
+2 -2
View File
@@ -58,7 +58,7 @@ When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`d
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs. Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`) and OMP (`omp`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'`). **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`), OMP (`omp`) and jcode (`jcode`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'jcode'`).
**TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`. **TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`.
@@ -172,7 +172,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` **CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, jcode)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **jcode**: every pane is a client of ONE shared per-user daemon and the tools run in the daemon with ITS env, so jcode gets no agent skill and no `JCODE_*` envOverrides prefix (a per-session value would act as another session or reach every one); `last-response` is an honest empty (external + `transcript: 'none'`, never the segmenter or the Claude scan); ⚠️ that daemon is a descendant of whichever pane started it, so `capabilities.sharedDaemon` makes `killSession` spare descendants outside the pane's process group (`descendantsInPaneGroup()`, `proc-tree.ts`), or closing that one tab cuts every other jcode session off; it resolves through the generic registry resolver, with no per-CLI resolver module (`docs/jcode-integration.md`). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp-jcode](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp-jcode)
**DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui) **DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui)
+8 -8
View File
@@ -5,7 +5,7 @@
<h2 align="center">Mission control for AI coding agents</h2> <h2 align="center">Mission control for AI coding agents</h2>
<p align="center"> <p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; Terminal - One Dashboard &bull; Any Device</em> <em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; jcode &bull; Terminal - One Dashboard &bull; Any Device</em>
</p> </p>
<p align="center"> <p align="center">
@@ -27,7 +27,7 @@
<img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800"> <img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
</p> </p>
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time. **Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, OMP, or jcode inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
Get started in one line (macOS & Linux, Windows via WSL): Get started in one line (macOS & Linux, Windows via WSL):
@@ -42,7 +42,7 @@ codeman web
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation). The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
- **One dashboard, nine CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, or OMP](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them - **One dashboard, ten CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, or jcode](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications - **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs - **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts - **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
@@ -70,7 +70,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist. - **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist.
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation. - **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install: You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), or [jcode](https://jcode.sh) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the ten is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
```bash ```bash
codeman web codeman web
@@ -259,7 +259,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
| Field | What it does | | Field | What it does |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | | ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). | | **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, or `Terminal` (plain shell). | | **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, `jcode`, or `Terminal` (plain shell). |
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. | | **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. | | **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
@@ -463,7 +463,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions. - **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions.
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials - **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode. - **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md) - **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, or **jcode** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md), [`docs/omp-integration.md`](docs/omp-integration.md) and [`docs/jcode-integration.md`](docs/jcode-integration.md) (jcode takes no env prefix: its tools run in one shared daemon)
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md) - **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md) - **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md) - **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
@@ -935,7 +935,7 @@ for _ in $(seq 1 10); do
done done
printf '%s\n' "$TXT" printf '%s\n' "$TXT"
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp) have no transcript: # 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp/jcode) have no transcript:
# read the terminal. ⚠️ Use terminal?tail=, NOT /output: the latter's textOutput # read the terminal. ⚠️ Use terminal?tail=, NOT /output: the latter's textOutput
# is empty for every tmux-backed (i.e. every interactive) session. tail counts # is empty for every tmux-backed (i.e. every interactive) session. tail counts
# BYTES, and what comes back is terminal data, ANSI included. # BYTES, and what comes back is terminal data, ANSI included.
@@ -1155,7 +1155,7 @@ flowchart TB
end end
subgraph External["External"] subgraph External["External"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP</small>"] CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / jcode</small>"]
BG["Background Agents<br/><small>(Task tool)</small>"] BG["Background Agents<br/><small>(Task tool)</small>"]
end end
end end
+8 -8
View File
@@ -5,7 +5,7 @@
<h2 align="center">AI 编程智能体的任务控制中心</h2> <h2 align="center">AI 编程智能体的任务控制中心</h2>
<p align="center"> <p align="center">
<em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; 终端 —— 统一仪表盘 &bull; 任意设备</em> <em>Claude Code &bull; OpenCode &bull; Codex &bull; Antigravity &bull; Gemini &bull; Pi &bull; Grok &bull; DeepSeek &bull; OMP &bull; jcode &bull; 终端 —— 统一仪表盘 &bull; 任意设备</em>
</p> </p>
<p align="center"> <p align="center">
@@ -29,7 +29,7 @@
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。 > 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness 或 OMP,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。 **Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness、OMP 或 jcode,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL): 一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
@@ -44,7 +44,7 @@ codeman web
安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。 安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。
- **一个仪表盘,九个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek 或 OMP](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开 - **一个仪表盘,十个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP 或 jcode](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知 - **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行 - **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录 - **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
@@ -72,7 +72,7 @@ curl -fsSL https://getcodeman.com/install | bash
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。`install.sh status` 会再次打印 URL 和二维码;另外还有 `install.sh update`、`install.sh tailscale` 和 `install.sh uninstall`。 - **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。`install.sh status` 会再次打印 URL 和二维码;另外还有 `install.sh update`、`install.sh tailscale` 和 `install.sh uninstall`。
- **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。 - **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后: 你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)、[OMP](https://github.com/can1357/oh-my-pi) 或 [jcode](https://jcode.sh)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这十个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
```bash ```bash
codeman web codeman web
@@ -261,7 +261,7 @@ codeman service install # systemd/launchd 服务:重启后自动回
| 字段 | 作用 | | 字段 | 作用 |
| ---------------------- | ------------------------------------------------------------------------------------------- | | ---------------------- | ------------------------------------------------------------------------------------------- |
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。**Add Case** 可以从零创建、链接一个已有文件夹,或把一个 GitHub 仓库直接克隆成 case(**Clone Repo**)。 | | **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。**Add Case** 可以从零创建、链接一个已有文件夹,或把一个 GitHub 仓库直接克隆成 case(**Clone Repo**)。 |
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP` 或 `Terminal`(普通 shell)。 | | **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP`、`jcode` 或 `Terminal`(普通 shell)。 |
| **模型** | 每会话模型(App Settings → Models → New Claude sessions)。软默认值 —— 会话内 `/model` 依然有效。 | | **模型** | 每会话模型(App Settings → Models → New Claude sessions)。软默认值 —— 会话内 `/model` 依然有效。 |
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 | | **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
@@ -465,7 +465,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
- **底栏里的 Git 状态**:默认关闭(**App Settings → Header & Panels → Bottom bar → Git status**,按设备生效)。底栏右侧的一个小指示器让你一眼看清当前会话所在仓库的状态:`● 3` 表示未提交的文件,`↑ 2` 表示未推送的提交,`⚠` 表示合并冲突,`✓` 表示全部已提交并推送。点击它会打开一个可拖动的窗口,列出已暂存、未暂存、未跟踪和有冲突的文件(按折叠的文件夹分组,关掉该设置则显示为平铺列表)以及未推送的提交;**点击文件即可查看它的 diff**(新文件全部显示为新增,已删除的文件全部显示为删除),**Open file** 可直接跳到查看器。一个包含多个项目的文件夹,会为向下两层内找到的每个仓库各给一个可折叠分区,打开之前全部折叠。只读且离线(Codeman 绝不 fetch,也绝不改动仓库);Docker 和远程会话不显示 - **底栏里的 Git 状态**:默认关闭(**App Settings → Header & Panels → Bottom bar → Git status**,按设备生效)。底栏右侧的一个小指示器让你一眼看清当前会话所在仓库的状态:`● 3` 表示未提交的文件,`↑ 2` 表示未推送的提交,`⚠` 表示合并冲突,`✓` 表示全部已提交并推送。点击它会打开一个可拖动的窗口,列出已暂存、未暂存、未跟踪和有冲突的文件(按折叠的文件夹分组,关掉该设置则显示为平铺列表)以及未推送的提交;**点击文件即可查看它的 diff**(新文件全部显示为新增,已删除的文件全部显示为删除),**Open file** 可直接跳到查看器。一个包含多个项目的文件夹,会为向下两层内找到的每个仓库各给一个可折叠分区,打开之前全部折叠。只读且离线(Codeman 绝不 fetch,也绝不改动仓库);Docker 和远程会话不显示
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据 - **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
- **在自定义文件夹里创建 case**:在 **Add Case → Create New** 里勾选 **Create in a custom folder**,选一个父文件夹(可用 Browse 浏览)和一个名字,Codeman 就会在那里而不是 `~/codeman-cases` 里搭建新 case。目标必须是一个新的或空的文件夹;系统目录、你的主目录本身、`~/.ssh` 这类凭据目录,以及 Codeman 自己的数据目录都会被拒绝。多用户模式下仅管理员可用 - **在自定义文件夹里创建 case**:在 **Add Case → Create New** 里勾选 **Create in a custom folder**,选一个父文件夹(可用 Browse 浏览)和一个名字,Codeman 就会在那里而不是 `~/codeman-cases` 里搭建新 case。目标必须是一个新的或空的文件夹;系统目录、你的主目录本身、`~/.ssh` 这类凭据目录,以及 Codeman 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness** 或 **OMP**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*` 与 `OMP_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md) 与 [`docs/omp-integration.md`](docs/omp-integration.md) - **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness**、**OMP** 或 **jcode**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*` 与 `OMP_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md)、[`docs/omp-integration.md`](docs/omp-integration.md) 与 [`docs/jcode-integration.md`](docs/jcode-integration.md)(jcode 不接受环境变量前缀:它的工具运行在一个共享的守护进程里)
- **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md) - **自定义模型端点**(1.29.0 新增,目前仅 HTTP API)—— 让某个会话的 CLI 指向任意 OpenAI 兼容端点,而不是它自己的官方后端:本地的 llama.cpp、llama-swap、Ollama 或 vLLM 机器,也可以是 Azure AI Foundry、OpenRouter 这类云端网关。端点只需保存一次(`POST /api/model-endpoints`,模型列表从它的 `/v1/models` 自动发现),再应用到会话(`POST /api/sessions/:id/custom-model`),CLI 就会在原地重启并接上该端点。Claude、OpenCode、Pi、Grok 与 OMP 已实测通过;Codex、Gemini 与 DeepSeek 存在已记录的缺口,Antigravity 没有可用机制。工具栏选择器是下一步。详见 [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
- **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md) - **Web 标签页** —— 把 Grafana、Uptime Kuma、一个 Vite 开发服务器或任何仪表盘 URL 作为标签页打开在会话旁边(Run 下拉菜单 → **Web / URL** → **Add URL**)。仪表盘通过 Codeman 自己的源代理,因此 `http://` 目标在手机上走 HTTPS 也能用、走隧道也能用;单页应用能在自己的路径上正常路由,页面自己重载后也能自行恢复。智能体打印出的 `localhost` 链接会自动以 Web 标签页打开。详见 [`docs/web-tabs.md`](docs/web-tabs.md)
- **Docker 会话** —— 在隔离且加固的容器中运行 case。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一 case 的多个会话共享一个容器,也可以把 case 挂到你已经在跑的容器上;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md) - **Docker 会话** —— 在隔离且加固的容器中运行 case。**Create New** 上勾选一个复选框即可用合理的默认值启动容器并在其中启动智能体;同一 case 的多个会话共享一个容器,也可以把 case 挂到你已经在跑的容器上;可将容器连同工作区导出为可移植的 `.tar.gz`,迁移到另一台机器。详见 [`docs/docker-cases.md`](docs/docker-cases.md)
@@ -933,7 +933,7 @@ for _ in $(seq 1 10); do
done done
printf '%s\n' "$TXT" printf '%s\n' "$TXT"
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp)没有 transcript,读终端。 # 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp/jcode)没有 transcript,读终端。
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的 # ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。 # (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer' curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
@@ -1154,7 +1154,7 @@ flowchart TB
end end
subgraph External["外部"] subgraph External["外部"]
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP</small>"] CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / jcode</small>"]
BG["后台智能体<br/><small>(Task 工具)</small>"] BG["后台智能体<br/><small>(Task 工具)</small>"]
end end
end end
+29
View File
@@ -277,5 +277,34 @@
"docsUrl": "https://omp.sh" "docsUrl": "https://omp.sh"
} }
} }
},
{
"id": "jcode",
"label": "jcode",
"shortBadge": "JC",
"enabled": true,
"order": 95,
"kind": "agent",
"discovery": {
"binaries": [
"jcode"
],
"searchDirs": [
"~/.local/bin",
"/usr/local/bin",
"~/bin"
],
"install": {
"command": {
"linux": "curl -fsSL https://jcode.sh/install | bash",
"darwin": "curl -fsSL https://jcode.sh/install | bash"
},
"docsUrl": "https://jcode.sh/docs",
"agentImageLayer": {
"kind": "dedicated",
"reason": "Not on npm: jcode ships a standalone Rust binary through jcode.sh/install, whose launcher is a script that execs the binary beside it."
}
}
}
} }
] ]
+15
View File
@@ -203,6 +203,21 @@ RUN curl -fsSL https://omp.sh/install | sh \
&& rm -f /root/.local/bin/omp \ && rm -f /root/.local/bin/omp \
&& omp --version && omp --version
# jcode is NOT on npm: a standalone Rust binary via jcode.sh's installer, which
# writes $HOME/.jcode/builds/versions/<v>/ and links $HOME/.local/bin/jcode to it.
# The linked `jcode` is a small sh LAUNCHER that resolves its own path and execs
# the `jcode-<os>-<arch>.bin` beside it, so (unlike grok/omp above) the whole
# version directory is copied, to /opt/jcode, and /usr/local/bin/jcode links the
# launcher there. Root's ~/.jcode is dropped in the same layer so the image does
# not carry the ~160MB binary twice. JCODE_SKIP_SERVER_RELOAD: there is no jcode
# daemon at build time to hand off to.
RUN curl -fsSL https://jcode.sh/install | JCODE_SKIP_SERVER_RELOAD=1 bash \
&& mkdir -p /opt/jcode \
&& cp -a "$(dirname "$(readlink -f /root/.local/bin/jcode)")/." /opt/jcode/ \
&& ln -sfn /opt/jcode/jcode /usr/local/bin/jcode \
&& rm -rf /root/.jcode /root/.local/bin/jcode \
&& jcode --version
# `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is # `agent` user (gid 0) with an arbitrary-uid-writable HOME. The uid is
# auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at # auto-assigned (node:22-slim already occupies uid 1000 with its `node` user); at
# runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid # runtime Codeman overrides with `--user <hostUid>:0` on Linux, so the baked uid
+17
View File
@@ -1125,6 +1125,23 @@ Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route
| `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. | | `PUT` | `/api/clis/custom/:id` | `{ label, shortBadge, binaries, argv, enabled? }` | Replace an existing custom entry. An absent `enabled` keeps the entry's current state. `400` for a stock id, `404` for an unknown one. |
| `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. | | `DELETE` | `/api/clis/:id` | none | Delete a custom entry. `400` for a stock id, `404` for an unknown one. |
### Per-CLI availability probes
Each agent CLI has a read-only probe, `GET /api/<mode>/status`, which answers
`{ available, path }` for the binary the server would launch (no session needed, not gated).
`pi`, `grok`, `omp` and `jcode` add `version`, since their binary names are short or shared
with unrelated programs and the resolver rejects a candidate whose `--version` has the wrong
shape; `deepseek` answers a wider shape (`runnable`, `profiles`, ...). `GET /api/jcode/status`
is answered by the generic registry resolver, the same one a jcode spawn uses, so it applies
the entry's `jcode v<semver>` version anchor.
A jcode session takes `jcodeConfig: { model?, resumeSessionId? }` on `POST /api/sessions`
and `POST /api/quick-start` (`--model`, `--resume`; the resume value is a jcode session id or
short name and must start with a letter or digit). jcode takes no `envOverrides` prefix, and
`GET /api/sessions/:id/last-response` answers an empty `text` (and `messages: []` with
`?context=full`) for it: read its screen with `GET /api/sessions/:id/terminal?tail=` instead.
See `docs/jcode-integration.md`.
## MCP server sync ## MCP server sync
Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`. Copies MCP servers between the agent CLIs' own user-level config files (`docs/cli-registry.md`, "MCP server sync"). **Opt-in:** both routes answer `403 FORBIDDEN` while the synced `mcpSyncEnabled` setting is off (the default), and for a non-admin in multi-user mode, because the routes write files in the server user's home. A second `POST` while one is running answers `409 CONFLICT`.
+7 -5
View File
@@ -33,11 +33,11 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test.
## Session launch modes ## Session launch modes
**Terminal colour env** — the stock registry decides each CLI's colour vars. Claude, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor`; `shell` and `opencode` unset it. All of those except Claude also unset `NO_COLOR`, so a user who exports `NO_COLOR` globally keeps monochrome Claude panes. The variable matters because a CLI inheriting no `COLORTERM` quantizes every RGB color it draws down to whatever palette `TERM` alone implies, and the pane's `TERM` is not a constant. ⚠️ Codeman sets no `default-terminal` and passes tmux no `-f`, so it is tmux's default (`tmux-256color` since 3.2) unless the user's own `~/.tmux.conf` says otherwise, which Codeman's server DOES read. At `tmux-256color` supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but not the color the theme named. Where `TERM` resolves to a 16-color entry instead (tmux older than 3.2, or a conf setting `default-terminal screen`) every dark background collapses to `ESC[40m`, the terminal's own black, and the block disappears entirely. That is why the same Claude theme looks different on two machines, and why a bug report here is worth pairing with the reporter's `tmux -V` and their pane's real `TERM`. ⚠️ These declarations reach the local tmux pane via `buildEnvExports()`, its attach client via `cliExportsTruecolor()`, and the direct-PTY fallback via `buildClaudeEnv()` — they do NOT reach a remote pane, which `buildRemoteLaunchCommand()` builds with no env exports at all. A Docker pane takes `COLORTERM=truecolor` from the hardcoded `envCreate`/`execEnv` in `tmux-manager.ts`, which apply to every mode including the two the registry says must unset it. A `~/.codeman/clis.json` override replaces these arrays wholesale (`deepMerge`), so a custom entry can drop either list. That is why both consumers apply the entry BEFORE Codeman's own variables rather than after: `buildEnvExports()` emits `...cliEnv` ahead of `export CODEMAN_MUX=1`, and `buildClaudeEnv()` assigns `PATH`/`TERM`/`CODEMAN_*` after its unset/export loop. Reversed, a config-supplied `unset` naming `CODEMAN_HOOK_SECRET_FILE` would strip it on one path and not the other. **Terminal colour env** — the stock registry decides each CLI's colour vars. Claude, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP and jcode export `COLORTERM=truecolor`; `shell` and `opencode` unset it. All of those except Claude also unset `NO_COLOR`, so a user who exports `NO_COLOR` globally keeps monochrome Claude panes. The variable matters because a CLI inheriting no `COLORTERM` quantizes every RGB color it draws down to whatever palette `TERM` alone implies, and the pane's `TERM` is not a constant. ⚠️ Codeman sets no `default-terminal` and passes tmux no `-f`, so it is tmux's default (`tmux-256color` since 3.2) unless the user's own `~/.tmux.conf` says otherwise, which Codeman's server DOES read. At `tmux-256color` supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but not the color the theme named. Where `TERM` resolves to a 16-color entry instead (tmux older than 3.2, or a conf setting `default-terminal screen`) every dark background collapses to `ESC[40m`, the terminal's own black, and the block disappears entirely. That is why the same Claude theme looks different on two machines, and why a bug report here is worth pairing with the reporter's `tmux -V` and their pane's real `TERM`. ⚠️ These declarations reach the local tmux pane via `buildEnvExports()`, its attach client via `cliExportsTruecolor()`, and the direct-PTY fallback via `buildClaudeEnv()` — they do NOT reach a remote pane, which `buildRemoteLaunchCommand()` builds with no env exports at all. A Docker pane takes `COLORTERM=truecolor` from the hardcoded `envCreate`/`execEnv` in `tmux-manager.ts`, which apply to every mode including the two the registry says must unset it. A `~/.codeman/clis.json` override replaces these arrays wholesale (`deepMerge`), so a custom entry can drop either list. That is why both consumers apply the entry BEFORE Codeman's own variables rather than after: `buildEnvExports()` emits `...cliEnv` ahead of `export CODEMAN_MUX=1`, and `buildClaudeEnv()` assigns `PATH`/`TERM`/`CODEMAN_*` after its unset/export loop. Reversed, a config-supplied `unset` naming `CODEMAN_HOOK_SECRET_FILE` would strip it on one path and not the other.
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP) ### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, jcode)
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, which is now `isMuxMouseStripMode`, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessionsLine truncated **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, jcode)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, which is now `isMuxMouseStripMode`, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sLine truncated
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it. **DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
@@ -61,6 +61,8 @@ Model is NOT a session field: it is a composition entry in the profile's config
**OMP (`omp`, Oh My Pi) specifics** (`docs/omp-integration.md`): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in `~/.omp` config files (default `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever emits `--model`/`--resume <id>`/`--continue`, and there is no bypass-permissions flag for Codeman to wire or clamp. ⚠️ **That does NOT make the multi-user clamp a no-op**: `OMP_*` is an allowlisted `envOverrides` prefix and admits `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` (where omp resolves credentials from), both dropped for a non-granted owner in `clampEnvOverridesForOwner()` — the same shape as `DEEPSEEK_BASE_URL` — even though, unlike DeepSeek, Codeman forwards no operator-held key into an omp pane today (found in Ark0N/Codeman#353 review). `--continue` alone is ambiguous the moment any other omp conversation has touched the same working directory more recently, since it just picks the newest session file on disk — `resolveAndClaimOmpSessionId()` (`src/utils/omp-session-resolver.ts`) resolves and PINS the real id instead, verifying each candidate's own file header (`{"type":"session","id",cwd"}`, not just the mangled-directory match) and tracking already-claimed ids in a process-wide registry so two omp tabs in the same case dir can't alias onto each other's conversation. ⚠️ Resolution/pinning happens ONLY at the point a respawn is actually confirmed (`_pinOmpRespawnId()`, called from `_setupOrAttachMuxSession()`'s dead-pane branch and `reattachRemote()`) — earlier code resolved eagerly while merely building respawn options, which could mis-pin a still-ALIVE session's id purely from boot-recovery timing. `src/omp-transcript.ts` independently scans `~/.omp/agent/sessions/**/*.jsonl` for Past Sessions history, the omp analog of Claude's own transcript scan, so a conversation survives even a full "Kill Tmux". ⚠️ omp's own env knobs are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`), and `PI_*` is already allowlisted globally for pi — so a redirected `PI_CONFIG_DIR` silently moves the `~/.omp` tree the resolver and transcript scanner hardcode, degrading pinning/history with no error; a known gap shared with pi, not fixed here. ⚠️ Docker: `appendResumeFlag()`'s `case 'omp'` keys off the top-level `resumeSessionId`, which Docker panes never receive for omp (built from `defaultDockerCommandForMode`, with no `ompConfig` threaded through) — host-side history recovery and pinning work through the shared `sessions/` mount, but `--resume` does not currently reach an in-container omp process on respawn (flagged in review, not yet fixed). Stays out of `isAltScreenStripMode()` (narrow scrollback strip, alt-screen toggles only) and lands on the `'buffer'` local-echo policy via the `_updateLocalEchoState` fallthrough, same as grok and pi. Resolver: `omp-cli-resolver.ts` version-probes like pi/grok (`omp` is a short, generic name) and requires `omp/<semver>`-shaped output; `OMP_SEARCH_DIRS` leads with `~/.local/bin` (omp.sh's installer targets `$HOME/.local/bin` with no `--dir` override — verified against a real `--no-cache` Docker build, `~/.omp/bin` was the wrong first guess). Tests: `test/omp-mode.test.ts`, `test/omp-cli-resolver.test.ts`, `test/omp-session-resolver.test.ts`, `test/omp-fresh-run-no-resume.test.ts`. **OMP (`omp`, Oh My Pi) specifics** (`docs/omp-integration.md`): architecturally the simplest of the family — omp owns its own auth, provider routing, and trust decisions entirely in `~/.omp` config files (default `tools.approvalMode: yolo`), so `buildOmpCommand()` only ever emits `--model`/`--resume <id>`/`--continue`, and there is no bypass-permissions flag for Codeman to wire or clamp. ⚠️ **That does NOT make the multi-user clamp a no-op**: `OMP_*` is an allowlisted `envOverrides` prefix and admits `OMP_AUTH_BROKER_URL`/`OMP_AUTH_BROKER_TOKEN` (where omp resolves credentials from), both dropped for a non-granted owner in `clampEnvOverridesForOwner()` — the same shape as `DEEPSEEK_BASE_URL` — even though, unlike DeepSeek, Codeman forwards no operator-held key into an omp pane today (found in Ark0N/Codeman#353 review). `--continue` alone is ambiguous the moment any other omp conversation has touched the same working directory more recently, since it just picks the newest session file on disk — `resolveAndClaimOmpSessionId()` (`src/utils/omp-session-resolver.ts`) resolves and PINS the real id instead, verifying each candidate's own file header (`{"type":"session","id",cwd"}`, not just the mangled-directory match) and tracking already-claimed ids in a process-wide registry so two omp tabs in the same case dir can't alias onto each other's conversation. ⚠️ Resolution/pinning happens ONLY at the point a respawn is actually confirmed (`_pinOmpRespawnId()`, called from `_setupOrAttachMuxSession()`'s dead-pane branch and `reattachRemote()`) — earlier code resolved eagerly while merely building respawn options, which could mis-pin a still-ALIVE session's id purely from boot-recovery timing. `src/omp-transcript.ts` independently scans `~/.omp/agent/sessions/**/*.jsonl` for Past Sessions history, the omp analog of Claude's own transcript scan, so a conversation survives even a full "Kill Tmux". ⚠️ omp's own env knobs are mostly `PI_*`, not `OMP_*` (`PI_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, `PI_SUBPROCESS_CMD`, `PI_SHELL_PREFIX`), and `PI_*` is already allowlisted globally for pi — so a redirected `PI_CONFIG_DIR` silently moves the `~/.omp` tree the resolver and transcript scanner hardcode, degrading pinning/history with no error; a known gap shared with pi, not fixed here. ⚠️ Docker: `appendResumeFlag()`'s `case 'omp'` keys off the top-level `resumeSessionId`, which Docker panes never receive for omp (built from `defaultDockerCommandForMode`, with no `ompConfig` threaded through) — host-side history recovery and pinning work through the shared `sessions/` mount, but `--resume` does not currently reach an in-container omp process on respawn (flagged in review, not yet fixed). Stays out of `isAltScreenStripMode()` (narrow scrollback strip, alt-screen toggles only) and lands on the `'buffer'` local-echo policy via the `_updateLocalEchoState` fallthrough, same as grok and pi. Resolver: `omp-cli-resolver.ts` version-probes like pi/grok (`omp` is a short, generic name) and requires `omp/<semver>`-shaped output; `OMP_SEARCH_DIRS` leads with `~/.local/bin` (omp.sh's installer targets `$HOME/.local/bin` with no `--dir` override — verified against a real `--no-cache` Docker build, `~/.omp/bin` was the wrong first guess). Tests: `test/omp-mode.test.ts`, `test/omp-cli-resolver.test.ts`, `test/omp-session-resolver.test.ts`, `test/omp-fresh-run-no-resume.test.ts`.
**jcode specifics** (`docs/jcode-integration.md`): every jcode TUI is a CLIENT of one shared per-user daemon (`jcode serve`, `setsid`, but still the first client's child by parent pid, so ⚠️ `capabilities.sharedDaemon` makes `killSession` spare descendants outside the pane's process group via `descendantsInPaneGroup()` in `proc-tree.ts`; without it, closing the tab that started the daemon cut every other jcode session off, measured), and the agent's tools run in the DAEMON with the daemon's environment (measured: a second client's env var read back empty from its bash tool). So ⚠️ the agent skill is NOT injected (`agentSkillInjection: false`: the tools would carry the `CODEMAN_*` identity of whichever session started the daemon), and ⚠️ `JCODE_*` is NOT an `envOverrides` prefix (`allowedPrefixes: []`: that namespace is daemon-read and includes `JCODE_HOOK_*`, `JCODE_SPAWN_HOOK`, `JCODE_*_COMMAND` and `JCODE_*_API_BASE`, so a per-session value does nothing or reaches every jcode session). No approval prompts and no bypass flag (pi-shaped: `privilegedParams: []`, no `blockedLine`). ⚠️ `last-response` answers an honest empty for it: the generic rule `capabilities.external && transcript === 'none'` placed AFTER the pane-segmenter branch (it matches jcode alone today) keeps it off the segmenter, which served jcode's welcome banner as the answer, and off the Claude transcript scan, which could adopt a Claude conversation from the same folder. Binary resolution is the generic registry resolver (`/api/jcode/status` included); there is no per-CLI resolver module.
**Codex input path (issues #218/#219/#220/#222)**: codex-mode sessions use **predictive write-through echo, never the buffer overlay**. The buffer overlay stays disabled exactly as 1.12.2 left it (`_updateLocalEchoState` in terminal-ui.js, same branch as shell; `_localEchoEnabled` remains false for codex), and the additive `_localEchoPolicy` field selects `'predict'` for codex when `localEchoEnabled` is on. Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. **Predictive write-through echo invariants** (the codex echo mode, `PredictiveEchoAddon` in `packages/xterm-zerolag-input`): (1) the onData hook `_predictHookOnData` is a PLAIN STATEMENT between the buffer block and Normal Mode — no `return`, try/catch-wrapped, never touches `_pendingInput` — so the wire path is byte-identical with the predictor active, absent or throwing (pinned at vm level and by an end-to-end trace-equality E2E); (2) it ships as a SEPARATE bundle `vendor/xterm-predictive-echo.js` so the zerolag bundle stays byte-identical, and a missing/broken bundle degrades codex to plain 1.12.2 echo (`typeof PredictiveEchoOverlay !== 'undefined'` guard); (3) predictions paint only while the cursor sits on the measured composer row (`isCodexComposerRow`, `CODEX_COMPOSER_ROW_RE = /^› /` — matches the empty-composer placeholder, typing, and the slash picker; rejects modal rows and 2-space wrapped continuation rows, the #220 ghost zone, which deliberately fall back to real echo); (4) reconciliation reads the PARSED buffer with `baseY + row` (xterm's `cursorY` is baseY-relative; `viewportY` only coincides while scrolled to bottom), confirms prefix-only on cell match PLUS cursor advance, cascades only on TWO consecutive foreign NON-BLANK passes (blanks are neutral: codex clears its placeholder on first echo), and TTL-bounds the rest; (5) after an UNPREDICTED wire edit (backspace into echoed text, any 'clear'-classified input, an IME/plain-paste 'text' commit, or every bypass send incl. `_handleCjkInput`) the addon holds new predictions until the next PARSED write: the displayed cursor is stale for one RTT and anchoring on it paints ghosts one cell off; (6) the per-device `localEchoEnabled` toggle is the kill switch returning exact 1.12.2 behavior. Measured constants + fixtures: `docs/predictive-echo-plan.md`, recorded via `scripts/dev/record-codex-frames.mjs` through the production tmux+strip pipeline. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key + predict classifier truth tables, policy matrix, wire-neutrality pins), `packages/xterm-zerolag-input/test/` (addon laws, real-fixture replay, seeded fuzz), `test/codex-predictive-echo.test.ts` (E2E vs real codex incLine truncated **Codex input path (issues #218/#219/#220/#222)**: codex-mode sessions use **predictive write-through echo, never the buffer overlay**. The buffer overlay stays disabled exactly as 1.12.2 left it (`_updateLocalEchoState` in terminal-ui.js, same branch as shell; `_localEchoEnabled` remains false for codex), and the additive `_localEchoPolicy` field selects `'predict'` for codex when `localEchoEnabled` is on. Codex's composer is interactive per keystroke: typing "/" pops a live-filtering command picker (#222 was "picker never appears" because the "/" sat in the overlay until Enter), the composer grows/rewraps as it fills (#220: a long typed prompt existed ONLY in the overlay DOM, so codex never grew the composer), arrows and Ctrl+Backspace edit server-side state (#218: arrows were forwarded to an EMPTY composer while the typed text sat pending; the `\x08` control-char flush then left the overlay stateless so `\x7f` was swallowed as "nothing to remove"), and pastes arrive bracketed (#219: `terminal.paste()` wraps in `\x1b[200~..201~`, which the multi-byte-ESC branch forwarded WITHOUT flushing pending text, so the paste landed before it). The shared overlay branch (claude/gemini/opencode still buffer) gained three fixes: bracketed pastes flush pending text first, composer nav keys (`isComposerNavKey` allowlist in `CodemanTerminalInput` — arrows/Home/End/Delete/PgUp/PgDn incl. modifiers, deliberately excluding DA/CPR/DSR query responses) flush and hand the session to **pass-through** (plain PTY echo until Enter/Ctrl+C, because after cursor movement the append-only overlay cannot track edits), and a backspace that finds no overlay state is FORWARDED instead of swallowed. ⚠️ **Codex drops keystrokes that arrive in the same PTY read as a bracketed paste** (upstream `bottom_pane/paste_burst.rs` holds rapid chars for paste classification; verified against codex 0.147.0 by writing `hello\x1b[200~PASTED\x1b[201~` into the tmux client PTY in one write → composer shows only `PASTED`, while a 100ms gap yields `helloPASTED`), so the flush sends the typed text immediately and delays the paste sequence by 80ms — the same two-phase shape as the Enter branch's delayed `\r`. Related protocol fact: xterm.js sends `0x08` for Ctrl+Backspace, which codex's keymap binds to delete-ONE-char (`ctrl(Char('h'))`); real word-delete needs the kitty CSI-u encoding (`\x1b[127;5u`), which xterm.js 6.0.0 cannot emit (kitty support lands in 6.1.0-beta) — an upstream limitation, not a Codeman bug. E2E technique: codex 0.147 reaches its composer with any dummy key in `$CODEX_HOME/auth.json` (`{"OPENAI_API_KEY":"sk-test-..."}`), so a real TUI can be driven headlessly (envOverrides `CODEX_HOME` rides the `CODEX_*` allowlist) without real credentials. **Predictive write-through echo invariants** (the codex echo mode, `PredictiveEchoAddon` in `packages/xterm-zerolag-input`): (1) the onData hook `_predictHookOnData` is a PLAIN STATEMENT between the buffer block and Normal Mode — no `return`, try/catch-wrapped, never touches `_pendingInput` — so the wire path is byte-identical with the predictor active, absent or throwing (pinned at vm level and by an end-to-end trace-equality E2E); (2) it ships as a SEPARATE bundle `vendor/xterm-predictive-echo.js` so the zerolag bundle stays byte-identical, and a missing/broken bundle degrades codex to plain 1.12.2 echo (`typeof PredictiveEchoOverlay !== 'undefined'` guard); (3) predictions paint only while the cursor sits on the measured composer row (`isCodexComposerRow`, `CODEX_COMPOSER_ROW_RE = /^› /` — matches the empty-composer placeholder, typing, and the slash picker; rejects modal rows and 2-space wrapped continuation rows, the #220 ghost zone, which deliberately fall back to real echo); (4) reconciliation reads the PARSED buffer with `baseY + row` (xterm's `cursorY` is baseY-relative; `viewportY` only coincides while scrolled to bottom), confirms prefix-only on cell match PLUS cursor advance, cascades only on TWO consecutive foreign NON-BLANK passes (blanks are neutral: codex clears its placeholder on first echo), and TTL-bounds the rest; (5) after an UNPREDICTED wire edit (backspace into echoed text, any 'clear'-classified input, an IME/plain-paste 'text' commit, or every bypass send incl. `_handleCjkInput`) the addon holds new predictions until the next PARSED write: the displayed cursor is stale for one RTT and anchoring on it paints ghosts one cell off; (6) the per-device `localEchoEnabled` toggle is the kill switch returning exact 1.12.2 behavior. Measured constants + fixtures: `docs/predictive-echo-plan.md`, recorded via `scripts/dev/record-codex-frames.mjs` through the production tmux+strip pipeline. Tests: `test/local-echo-codex-gating.test.ts` (vm harness: nav-key + predict classifier truth tables, policy matrix, wire-neutrality pins), `packages/xterm-zerolag-input/test/` (addon laws, real-fixture replay, seeded fuzz), `test/codex-predictive-echo.test.ts` (E2E vs real codex incLine truncated
Further detail: with the registry, a CLI that declares its own composer glyph and working line in `capabilities.workDetect` gets the same screen-probed idle confirmation claude gets (see the ❯ note in CLAUDE.md's idle-detection paragraph), and one that declares neither keeps the output-stabilization behaviour. The tmux-only rule now covers all EIGHT external modes (OMP included): none has a direct PTY fallback, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. Further detail: with the registry, a CLI that declares its own composer glyph and working line in `capabilities.workDetect` gets the same screen-probed idle confirmation claude gets (see the ❯ note in CLAUDE.md's idle-detection paragraph), and one that declares neither keeps the output-stabilization behaviour. The tmux-only rule now covers all EIGHT external modes (OMP included): none has a direct PTY fallback, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line.
@@ -221,7 +223,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
### Terminal scrollback: strip flavors and wheel/touch forwarding ### Terminal scrollback: strip flavors and wheel/touch forwarding
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`. **Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode, jcode) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. ⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
@@ -272,7 +274,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t
⚠️ **The workspace-trust dialog is not a composer.** Its `❯` sits on `No, exit`, so it arms the confirmation, and the dialog is quiet with no working line, so a fresh case used to be concluded idle about 4 s after launch while the dialog was still up (measured on Claude Code 2.1.294: `GET .../wait?until=idle` answered `idle` at 3.8 s in 6 of 12 fresh-dir launches). An agent that waited for idle and then prompted typed into the dialog, and the prompt's Enter confirmed `No, exit`: the pane died and the wait reported `exit`. `_probePaneWorking()` therefore also records whether its capture shows `isTrustDialogScreen()` (a failed capture keeps the last answer, since it proves nothing), and `_confirmIdle()` re-checks on `PANE_PROBE_RECHECK_MS` instead of concluding while it does, but only inside the auto-accept's own `TRUST_DIALOG_WINDOW_MS`: a later screen that merely quotes the dialog must not hold a session busy. The status is left alone rather than marked working, since nothing is running. Tests: `test/session-trust-dialog.test.ts`. ⚠️ **The workspace-trust dialog is not a composer.** Its `❯` sits on `No, exit`, so it arms the confirmation, and the dialog is quiet with no working line, so a fresh case used to be concluded idle about 4 s after launch while the dialog was still up (measured on Claude Code 2.1.294: `GET .../wait?until=idle` answered `idle` at 3.8 s in 6 of 12 fresh-dir launches). An agent that waited for idle and then prompted typed into the dialog, and the prompt's Enter confirmed `No, exit`: the pane died and the wait reported `exit`. `_probePaneWorking()` therefore also records whether its capture shows `isTrustDialogScreen()` (a failed capture keeps the last answer, since it proves nothing), and `_confirmIdle()` re-checks on `PANE_PROBE_RECHECK_MS` instead of concluding while it does, but only inside the auto-accept's own `TRUST_DIALOG_WINDOW_MS`: a later screen that merely quotes the dialog must not hold a session busy. The status is left alone rather than marked working, since nothing is running. Tests: `test/session-trust-dialog.test.ts`.
⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life. ⚠️ **The composer glyph and the working line are per-CLI registry DATA** (`capabilities.workDetect`, #385), not Claude constants: claude declares `❯` plus the pattern above, codex declares `›` plus `[Ee]sc to interrupt`, pi declares its composer rule `─` plus the spinner it embeds there, opencode declares its composer bar `┃` plus its 8-cell footer spinner `[⬝■]{8}`, omp declares its input row `╰─` plus the spinner and elapsed time that replace the `π` in its status bar, gemini declares its composer's bottom bar `▀` plus its `(esc to cancel, <n>s)` spinner line, jcode declares the bare `>` of its `<n>> ` composer (the one cell its turn-ending repaint redraws) plus a braille spinner row carrying an elapsed time, and a CLI that declares neither falls back to Claude's pair, which is what every session used before the registry carried one. Before that, this whole mechanism was gated Claude-mode-only on the reasoning that an external CLI has no `❯`, which was true and still left every Codex session reporting `idle` for its entire life.
⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural. ⚠️ `workingLine` is config-supplied (a user `clis.json` can set it) and the compiled pattern runs on the PTY hot path, so it goes through `compileVersionRegex()` in BOTH the schema refine and `_workingLinePattern()`: a nested quantifier there is a ReDoS against the event loop, and the helper returns null rather than throwing so the fallback is structural.
+9 -3
View File
@@ -1,6 +1,6 @@
# The CLI registry # 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. Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness, OMP and jcode — 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 ## Where it lives
@@ -193,7 +193,9 @@ still reading. A caller that asked only for `idle` keeps getting `idle`.
| --- | --- | | --- | --- |
| claude | `.claude/skills/codeman` | | claude | `.claude/skills/codeman` |
| codex, gemini, opencode, antigravity, grok, deepseek, omp | `.agents/skills/codeman` | | codex, gemini, opencode, antigravity, grok, deepseek, omp | `.agents/skills/codeman` |
| pi, shell | none | | pi, shell, jcode | none |
jcode reads `.agents/skills` too, but it is left out (`agentSkillInjection: false`): its tools run in one shared per-user daemon with the CODEMAN_* identity of whichever session started it, so an injected skill would act as that session ([jcode-integration.md](jcode-integration.md#the-shared-daemon)).
Measured 2026-10-11 against the installed builds or their source: every CLI in the middle row scans `<workspace>/.agents/skills/` (gemini only in its cwd, dsh only at the git root, else cwd), and Claude Code does not read it, so a case shared with a claude session lists the skill once there (opencode, grok and omp read both directories and see the same skill twice, identical, under one name). ⚠️ pi is left out on purpose: any project-level `.agents/skills` or `.pi/skills` makes pi ask "Trust project folder?" at startup, which would stall a spawned worker on a dialog; it reads the user-level `~/.agents/skills` without the prompt (`npx skills add Ark0N/Codeman --skill codeman -g`). A codex, gemini or other session's copy in a case that pi sessions also use triggers that prompt once for the human there. ⚠️ `agentSkillDir` is a closed enum (`AGENT_SKILL_DIRS`, schema.ts), never a free path: `clis.json` can set it and it decides where the server writes. ⚠️ Two codex traps the skill names rather than fixes: codex's default sandbox blocks network even to loopback (the skill needs **Bypass approvals and sandbox**), and codex 0.162 runs tool commands through a shared `app-server` daemon whose environment (`CODEMAN_SESSION_ID`, `CODEMAN_API_URL`) is that of the session that started it. Measured 2026-10-11 against the installed builds or their source: every CLI in the middle row scans `<workspace>/.agents/skills/` (gemini only in its cwd, dsh only at the git root, else cwd), and Claude Code does not read it, so a case shared with a claude session lists the skill once there (opencode, grok and omp read both directories and see the same skill twice, identical, under one name). ⚠️ pi is left out on purpose: any project-level `.agents/skills` or `.pi/skills` makes pi ask "Trust project folder?" at startup, which would stall a spawned worker on a dialog; it reads the user-level `~/.agents/skills` without the prompt (`npx skills add Ark0N/Codeman --skill codeman -g`). A codex, gemini or other session's copy in a case that pi sessions also use triggers that prompt once for the human there. ⚠️ `agentSkillDir` is a closed enum (`AGENT_SKILL_DIRS`, schema.ts), never a free path: `clis.json` can set it and it decides where the server writes. ⚠️ Two codex traps the skill names rather than fixes: codex's default sandbox blocks network even to loopback (the skill needs **Bypass approvals and sandbox**), and codex 0.162 runs tool commands through a shared `app-server` daemon whose environment (`CODEMAN_SESSION_ID`, `CODEMAN_API_URL`) is that of the session that started it.
@@ -201,6 +203,10 @@ Measured 2026-10-11 against the installed builds or their source: every CLI in t
`capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think. `capabilities.newline` (`'line-feed'` | `'esc-enter'`, absent = line feed) is the byte sequence the `send-key` route types into the pane for Shift+Enter. A line feed (`0x0a`, also Ctrl+Enter) is what Claude Code's Ink input reads as "insert a newline"; `esc-enter` (`ESC CR`, the Option/Alt+Enter chord) is there for a composer that ignores a bare line feed. No stock CLI declares it today: the bytes are typed by tmux on the server, so the browser's OS cannot change what a CLI reads, and Codex 0.147.0 was checked to take a line feed (a Shift+Enter that submits is the keypress leak fixed in #520, not a byte problem). A user `clis.json` can set it for a CLI that needs it. It is an enum rather than a byte string on purpose: config never carries bytes that get typed into a pane. Settings → Terminal & Input → **Key tester** prints what a browser reports for keydown/keypress/keyup, to see whether a device is sending what you think.
## A CLI with a shared daemon
`capabilities.sharedDaemon: true` says the CLI's TUI is a client of one per-user daemon that its first client starts (in its own session and process group) and every later session shares. jcode is the one stock entry that declares it. A session kill normally signals every descendant of the pane by parent pid, and the daemon is still a descendant of the pane that started it, so closing that one tab cut every other session of the CLI off (measured with jcode). With the flag set, `killSession` signals only the descendants still in the pane's own process group (`descendantsInPaneGroup()` in `src/proc-tree.ts`, one bounded `ps -o pid=,pgid=` lookup; a lookup that fails keeps the full kill). `test/proc-walk-bounds.test.ts` pins that jcode alone declares it, so no other CLI's kill changes.
## Arg-template safety ## 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: 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:
@@ -334,7 +340,7 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
## MCP server sync ## MCP server sync
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (plus GitHub Copilot CLI as a sync-only target, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`. `capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (plus GitHub Copilot CLI as a sync-only target, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek, jcode) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
**Tools that are not run modes.** GitHub Copilot CLI keeps an MCP list worth syncing but Codeman does not launch it, so it has no registry entry. `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing. **Tools that are not run modes.** GitHub Copilot CLI keeps an MCP list worth syncing but Codeman does not launch it, so it has no registry entry. `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
+6 -3
View File
@@ -34,8 +34,8 @@ must not change what is inside an image tagged `codeman/agent:base`, or two mach
that tag hold different images and every cache decision downstream is a lie. Each entry's that tag hold different images and every cache decision downstream is a lie. Each entry's
`enabled` flag IS honoured, so a CLI that ships disabled is never baked in. `enabled` flag IS honoured, so a CLI that ships disabled is never baked in.
Five CLIs keep hand-written layers, for two different reasons that are easy to conflate. Six CLIs keep hand-written layers, for two different reasons that are easy to conflate.
`antigravity`, `grok` and `omp` declare no `npmPackage` at all, so they never enter the shared `antigravity`, `grok`, `omp` and `jcode` declare no `npmPackage` at all, so they never enter the shared
npm layer and each gets a vendor-installer layer instead. `pi` and `deepseek` ARE on npm but npm layer and each gets a vendor-installer layer instead. `pi` and `deepseek` ARE on npm but
carry `discovery.install.agentImageLayer` in `stock.ts` (a REGISTRY field, rather than an carry `discovery.install.agentImageLayer` in `stock.ts` (a REGISTRY field, rather than an
id-keyed table duplicated between the two producers of the image's build args), which pulls id-keyed table duplicated between the two producers of the image's build args), which pulls
@@ -47,6 +47,7 @@ them out of the shared layer because a plain `npm install -g` is not enough for
| `deepseek` | Needs `pnpm` alongside it (`dsh plugin`, issue #352) plus a `dsh-tui` profile install. | | `deepseek` | Needs `pnpm` alongside it (`dsh plugin`, issue #352) plus a `dsh-tui` profile install. |
| `antigravity` | Not on npm — Google ships a standalone binary (~190MB, the largest layer). | | `antigravity` | Not on npm — Google ships a standalone binary (~190MB, the largest layer). |
| `grok`, `omp` | Not on npm — standalone vendor installers. | | `grok`, `omp` | Not on npm — standalone vendor installers. |
| `jcode` | Not on npm: jcode.sh's installer, then the WHOLE version directory copied to `/opt/jcode` (the launcher is an `sh` script that execs the binary beside it). |
`test/docker-agent-image-coverage.test.ts` requires every special case to carry a written `test/docker-agent-image-coverage.test.ts` requires every special case to carry a written
reason AND still be present in the Dockerfile, so an exclusion cannot silently become an reason AND still be present in the Dockerfile, so an exclusion cannot silently become an
@@ -62,7 +63,7 @@ A zero exit code only proves the layers ran, not that the toolchain works. Verif
```bash ```bash
docker run --rm codeman/agent:base bash -lc \ docker run --rm codeman/agent:base bash -lc \
'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done' 'for c in claude codex gemini opencode agy pi grok dsh omp jcode; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
``` ```
⚠️ `dsh --version` is the one line above that answers a different question than the ⚠️ `dsh --version` is the one line above that answers a different question than the
@@ -81,6 +82,8 @@ Antigravity (`agy`) and Grok (`grok`) are the two CLIs not installed from npm (G
Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md). Pi's credentials are seeded per-FILE rather than as a whole directory (`auth.json`, `settings.json`, `trust.json`, `models.json`, `models-store.json` out of `~/.pi/agent`), because that directory also holds `sessions/`, `extensions/`, `skills/` and the installed package trees — gigabytes on an active host. Consequence: in-container pi sessions are invisible host-side, so `pi -c` inside a Docker case only sees that container's own history. See [`pi-integration.md`](./pi-integration.md). Grok is seeded per-file for the same reason (`auth.json`, `config.toml`, `pager.toml` out of `~/.grok`, which also holds `sessions/`, `memory/` and the ~160MB binary under `downloads/`), with the same consequence for `grok -c`. See [`grok-integration.md`](./grok-integration.md). OMP is the one CLI in this family where `sessions/` is the EXCEPTION rather than the rule: `~/.omp/agent/{config.yml,mcp.json,models.yml,settings.yml}` are seeded per-file (the dir also holds SQLite caches and `terminal-sessions/`), but `~/.omp/agent/sessions/` is shared RW like codex's, not seeded, because Codeman reads it host-side for history recovery and `--resume` pinning. See [`omp-integration.md`](./omp-integration.md).
jcode's credentials are not seeded at all: `~/.jcode` keeps its OAuth tokens beside ~160MB of builds, its sessions and SQLite caches, and its API keys live in `~/.config/jcode`, so there is no small set of files to copy. Sign in to jcode inside the container (`docs/jcode-integration.md`).
The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated. The image can also carry the GitHub CLI (`gh`) and the Azure CLI (`az` + the `azure-devops` extension, in `AZURE_EXTENSION_DIR=/opt/az-extensions` so it stays out of the seeded HOME), wired into the system git config as credential helpers for github.com and dev.azure.com / *.visualstudio.com, exactly as in `docker/server.Dockerfile`. Their sign-ins are seeded per-FILE like pi's: `~/.config/gh/{hosts.yml,config.yml}` and `~/.azure/{azureProfile.json,msal_token_cache.json,service_principal_entries.json,clouds.config,config}`, never `~/.azure`'s logs, command index or extensions. A token kept in a desktop keyring, or in the encrypted MSAL cache az uses on Windows/macOS, is not in those files and does not carry. None of the three is version-pinned; the `--no-cache` rebuild recommended above is also what refreshes them. Both CLIs are opt-in and OFF by default: `CODEMAN_AGENT_IMAGE_INSTALL_GH=1` / `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` in the environment of `scripts/build-agent-image.mjs`, or of the Codeman server for its own auto-build (in the Compose deployment, `environment:` in `docker-compose.override.yml`), become the `CODEMAN_INSTALL_GH` / `CODEMAN_INSTALL_AZ` build args and put that CLI, its extension and its helper entry into the image. Unset passes nothing, so a default build's argv is unchanged and the image has neither. The sign-in seeds follow the same switches, read when a case container is created: `.config/gh` only with `CODEMAN_AGENT_IMAGE_INSTALL_GH=1`, `.azure` only with `CODEMAN_AGENT_IMAGE_INSTALL_AZ=1` (`enabledByEnv` in `CRED_STORES`), never merely because the files exist. Seeds are create-time mounts and deliberately not part of the config hash (hashing them would trip the drift gate for every case), so an existing case container picks them up only when it is recreated.
Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image. Set `CODEMAN_AGENT_IMAGE_GIT_USER_NAME` and `CODEMAN_AGENT_IMAGE_GIT_USER_EMAIL` together to configure the agent image's Git identity. Rebuild an existing `codeman/agent:base` with `node scripts/build-agent-image.mjs --no-cache`, then recreate Docker-case containers so they use the rebuilt image.
+277
View File
@@ -0,0 +1,277 @@
# jcode sessions
Codeman can drive [jcode](https://github.com/1jehuang/jcode) (1jehuang/jcode, MIT, a
coding-agent TUI written in Rust) as a session backend, alongside Claude Code, OpenCode,
Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP. `jcode` is its own **run mode**:
its own PTY, its own tmux session, its own tab identity (amber, `JC` badge, a plain ring
standing in for jcode's halftone donut). It is not a location overlay like Docker or
remote-SSH cases, and it is not a web tab.
Everything here was measured against jcode v0.98.1 on 2026-10-16 (see
[How it was measured](#how-it-was-measured)). The registry entry in
`src/config/cli-registry/stock.ts` carries the same facts in its comments and is the
source of truth when the two disagree.
## Install
```bash
curl -fsSL https://jcode.sh/install | bash
```
The installer downloads a checksummed release binary into
`~/.jcode/builds/versions/<version>/` and links `~/.local/bin/jcode` (its
`JCODE_INSTALL_DIR` default) to it. The linked `jcode` is a small `sh` **launcher** that
resolves its own path and execs the `jcode-<os>-<arch>.bin` beside it, so the version
directory is what matters, not the single file the link points at.
Codeman resolves the binary through the generic registry resolver (there is no
jcode-specific resolver module): the server PATH, then `~/.local/bin`, `/usr/local/bin`
and `~/bin`, then an interactive login shell. npm carries an unrelated package also called
`jcode`, so a PATH hit is not trusted on its own: `jcode --version` must print
`jcode v<semver>` (the real one prints `jcode v0.98.1 (1c7426eb1)`), and anything else
counts as absent. Check what it resolved:
```bash
curl -s localhost:3000/api/jcode/status | jq
# { "available": true, "path": "/home/you/.local/bin", "version": "0.98.1" }
```
## Authenticate
jcode keeps its own logins: OAuth accounts in `~/.jcode/auth.json`, API keys in
`~/.config/jcode/<provider>.env`, the default provider and model in `~/.jcode/config.toml`.
Set them up with jcode itself (`jcode login`, `/login` inside the TUI, or
`jcode provider add <name> --base-url ... --model ...` for an OpenAI-compatible server).
jcode can also offer to reuse logins other CLIs left behind; it asks before reading any of
them. Codeman forwards no credentials into a jcode pane.
## What Codeman wires up
`JcodeConfig` (per session, persisted in `state.json`, round-trips through respawn and
server restart):
| Field | Flag | Notes |
| ----------------- | --------------- | ---------------------------------------------------------------------------- |
| `model` | `--model <v>` | e.g. `claude-opus-4-8`, `gpt-5.5` |
| `resumeSessionId` | `--resume <v>` | a session id (`session_fox_1792160401272_f5b47ce47d631006`) or its short name (`fox`) |
Both values are regex-validated at the route and checked again by the argv engine, and a
value that fails is **dropped**, never escaped into the command. `resumeSessionId` uses the
`path-segment` pattern (a leading letter or digit) on purpose: jcode's `--resume` takes an
OPTIONAL value (a bare `--resume` opens its session picker), so a value starting with `-`
would be parsed as a flag of its own (`--resume --no-update` is two flags).
The Run button sends **no** `jcodeConfig`: jcode has no approval prompts and no bypass
flag, so there is nothing to opt into (the same shape as pi). A bare spawn is just `jcode`.
There is **no** `envOverrides` prefix for jcode: `JCODE_*` is not allowlisted. See
[The shared daemon](#the-shared-daemon) for why.
## The shared daemon
Every jcode TUI is a CLIENT of one shared per-user daemon (`jcode serve`). The first client
starts it, in its own session and process group (`setsid`), and every later client attaches
to it. It stays the first client's CHILD by parent pid, though, which is what the
[session kill](#closing-a-tab-never-takes-the-daemon-down) has to account for. The agent and its tools run in the DAEMON, with the daemon's
environment. Measured: a second client started with `CODEMAN_PROBE=second-client` asked its
bash tool to `echo "probe=[$CODEMAN_PROBE]"` and got `probe=[]`.
Three things follow, and each one is a deliberate decision:
- **The agent skill is not injected.** jcode does read `./.agents/skills` (and
`./.jcode/skills`, `./.claude/skills`), but a jcode agent's tools see the
`CODEMAN_SESSION_ID`, `CODEMAN_API_URL` and `CODEMAN_MUX` of whichever session started
the daemon, or none at all if it was started outside Codeman. An injected skill would act
as THAT session: spawn children under the wrong parent, prompt another session's
workers. A user-level copy of the skill (`~/.agents/skills`, which jcode also reads) has
the same problem; this is the same trap as codex's shared `app-server`, only it is jcode's
normal case rather than an edge.
- **No `JCODE_*` envOverrides prefix.** Most of that namespace is read by the daemon, so a
per-session value would do nothing, and where it did act (in the session that happens to
start the daemon) it would reach every jcode session on the machine. The namespace
includes variables that run code or redirect traffic: `JCODE_HOOK_*`, `JCODE_SPAWN_HOOK`,
`JCODE_*_COMMAND`, `JCODE_*_API_BASE`. Configure jcode in `~/.jcode/config.toml`, or in
the Codeman server's own environment.
- **Deleting a Codeman jcode session leaves the daemon running**, by design: the other
sessions live there (next section). `jcode server stop --force` stops it by hand.
### Closing a tab never takes the daemon down
Codeman's session kill signals every descendant of the pane (by parent pid), so it used to
kill the shared daemon whenever the closed tab happened to be the one that started it.
Measured on a beta before the fix: two jcode sessions, the first one deleted, and the second
one's client looped on `Connection lost - retrying (attempt 9) - server closed the
connection` with its prompt queued (`⏳`), while a send-and-wait on it timed out.
The jcode entry declares `capabilities.sharedDaemon: true`, and for such a CLI the kill
signals only the descendants still in the pane's own process group
(`descendantsInPaneGroup()`, `src/proc-tree.ts`, one bounded `ps -o pid=,pgid=`). The daemon
left that group when it called `setsid`, and everything it runs inherits its group, so the
daemon and its tools are spared while the client and anything else in the pane's group are
killed as before. A lookup that fails keeps the old behaviour (kill every descendant). Every
other CLI is unaffected. Measured after the fix: the kill logs `Sparing 1 process(es)
outside pane <pid>'s group (the CLI's shared daemon)`, the daemon keeps its pid, and the
remaining session answered a send-and-wait in 5 s.
The flip side: a process a jcode agent started (a dev server, a watcher) is a child of the
daemon, not of the pane, so closing the tab does not stop it. That is how jcode behaves in a
plain terminal too.
## Approvals and multi-user mode
jcode has no approval dialog. Its bash tool runs ungated apart from jcode's own model-side
reflection gate on risky commands and a hard deny on catastrophic paths. So there is no
bypass flag to clamp, nothing for the multi-user clamp to force off, and no dialog for
Codeman to read as `blocked`. In multi-user mode a jcode session is like a pi session: an
agent that runs tools without asking, under the one OS account every session shares.
## Terminal behavior
jcode is a **full-screen alternate-screen TUI with mouse tracking on** (tmux
`alternate_on=1`, `mouse_any_flag=1`, `mouse_sgr_flag=1`). It takes the opencode strip,
`altScreen: 'strip-mux-and-mouse'`: the mouse DECSETs are stripped, so a drag selects text
locally (Auto Copy and copy-on-select work) instead of being reported to jcode.
Scrolling: jcode pages its own transcript on PageUp/PageDown and also scrolls on SGR wheel
reports (both measured). It declares `transcriptPageKeys: true`, so over the hollow
alt-screen buffer a wheel or a swipe becomes PageUp/PageDown instead of doing nothing.
Input: a long prompt sent the way Codeman sends one (a bracketed paste, then a separate
Enter) submits cleanly (measured with a 170-character prompt).
### How Codeman reads a working pane
At rest the composer row is `<n>> ` (the prompt number, then `>`); a submitted prompt is
echoed into the transcript as `<n>› <text>`. While a turn runs the composer row reads
`<n>…` and a status row appears directly above it: a braille spinner (the gemini/omp frame
set, about 12 fps), then the phase and the elapsed time:
```
⠋ sending… 0s
⠦ thinking… 2s · https
⠼ waiting for response… 3s · https
⠸ running bash · <detail> · 12s · Alt+B bg
⠼ 12s · 45.1 tps · ↑16k ↓20 (streaming: the time comes first)
↻ network disconnected, waiting to retry · … · 5s
```
Past a minute the time reads `1m 3s`, past an hour `1h 2m`. At rest that row is empty or a
dim tip, never a spinner, and the turn leaves a summary row (` 21s · 199.6 tps · ↑17k ↓2k`)
that has a time but no spinner. The `workingLine` pattern therefore needs a spinner (or the
`↻ network disconnected` row) at the start of a row AND an elapsed time on it.
The composer glyph is the bare `>`. tmux redraws only the cells that changed, and on the
composer row at the end of a turn that is the `…` turning into `>` (the recorded client
stream carries `\x1b[43;2H...> `), never the prompt number, so `>` is what reliably arrives.
Mid-turn stream chunks never contain it. The glyph only ARMS the idle check; the screen and
the silence decide.
Silence is a sound signal for jcode: the spinner repaints the pane at least every 100 ms for
the whole turn, and at rest jcode sends nothing at all (12 seconds recorded: zero bytes).
The submit verifier stays inert for jcode, which is what it wants (it has no early-Enter
defect): every composer row starts with its prompt number, so no screen row starts with
`>` the way the verifier requires.
### Reading a jcode worker's answer
`GET /api/sessions/:id/last-response` returns an **honest empty** (`text: ""`, and
`messages: []` with `?context=full`) for jcode. The pane segmenter the other TUIs use cannot
read a repainting alternate-screen stream (measured: it served jcode's welcome banner as
"the answer"), and without this the route would fall through to the Claude transcript scan,
which could adopt a Claude conversation from the same folder. Read a jcode worker's screen
instead:
- `codeman agent read <id> --tail 3000`
- MCP `read_output` with `source: "terminal"`
- `GET /api/sessions/:id/terminal?tail=3000`
There are no `stop`/`blocked` hook signals for jcode: wait for `idle`, or use `wait-output`
markers, as with every hook-less CLI.
## First run: the onboarding wizard
A fresh jcode install opens an onboarding wizard ("Welcome to jcode onboarding / Log in to
OpenAI?") instead of the composer, on every launch, until its launch counter passes 5 or
10 sessions exist. A prompt typed into the wizard is lost (measured: it wedged that client).
Before driving jcode from Codeman, run `jcode` once in a terminal and finish or skip the
wizard, or press Esc in the Codeman pane.
## jcode's own defaults Codeman does not change
- **Telemetry** is on by default in jcode (install count, version, OS, session activity,
tool counts; jcode's `TELEMETRY.md`). Opt out with `jcode telemetry disable`, or set
`JCODE_NO_TELEMETRY=1` in the Codeman SERVER's environment (it is not a per-session env
override, see above).
- **Auto-update** is on by default for release builds; `--no-update` is jcode's own flag and
Codeman does not pass it.
- **Self-dev mode**: jcode switches into it when started inside a checkout of jcode itself.
## Docker cases
jcode is not on npm, so the agent image installs it in a dedicated Dockerfile step through
jcode.sh's installer, then copies the WHOLE version directory to `/opt/jcode` (the launcher
execs the binary beside it, so copying only the linked file, as the grok and omp steps do,
would leave a launcher with nothing to run) and links `/usr/local/bin/jcode` to the launcher.
Root's `~/.jcode` is dropped in the same layer so the image does not carry the ~160 MB
binary twice. Verified in `node:22-bookworm-slim`, including `jcode --version` as the
unprivileged `agent` user. Rebuild with the mandatory `--no-cache`:
```bash
node scripts/build-agent-image.mjs --no-cache
```
No credentials are seeded into the container: `~/.jcode` holds the OAuth tokens beside the
builds, sessions and SQLite caches, and API keys live in `~/.config/jcode`. Sign in inside
the container instead.
## Remote SSH cases
`jcode` mode is routed through an interactive login shell
(`exec "$SHELL" -i -l -c 'jcode'`), because sshd's remote-command PATH does not include
`~/.local/bin`. Per-session config does not cross ssh and is rejected rather than silently
ignored; a host whose `jcode` lives elsewhere takes the per-host `commands.jcode` override.
## Not supported yet, and why
- **A transcript reader** (a real `last-response`, Response Viewer content). jcode writes
sessions as `~/.jcode/sessions/session_<name>_<ms>_<hex>.json` plus a `.journal.jsonl`,
which is easy to parse, but pairing a Codeman pane with ITS session file is ambiguous: the
session JSON's `last_pid` is the shared daemon's, not the pane's, so two jcode panes in
one folder cannot be told apart from the files alone.
- **Custom Model Endpoint Profiles** (`customModelInjection: unsupported`). jcode's provider
is resolved in the daemon, not the pane, so a per-session redirect has nothing reliable
to land on.
- **The model in tile headers** (`modelDetect`). The status bar names provider and model
(`qwen5090 qwen3.8 27b`), but its layout varies by provider and width and was not
measured enough to anchor a pattern on. A session shows its launch model, if any.
- **Hook-based `stop`/`blocked` signals.** jcode has lifecycle hooks (`session_start`,
`turn_start`, `turn_end`, `session_end`), but they run in the daemon too, with the
daemon's environment, so they cannot tell Codeman which session they belong to.
- **Ralph, respawn heuristics, token/CLI-info parsing and the `❯` readiness probe are off**
for jcode, as for every external CLI.
A per-session daemon (`jcode --socket <path>`) would give each pane its own environment,
but it gives up the shared daemon that is jcode's main design point (cross-session swarm
awareness, a few MB per extra session), so it was not done here.
## How it was measured
- jcode installed with its own installer into a scratch `HOME` with telemetry off
(`DO_NOT_TRACK=1`, `JCODE_NO_TELEMETRY=1`) and a short `XDG_RUNTIME_DIR` (the daemon's
Unix socket path must stay under the `SUN_LEN` limit), so nothing touched the real home.
- A provider set up with `jcode provider add` pointing at a local OpenAI-compatible server
(an RTX 5090 serving Qwen 3.8 27B), so no subscription login was involved.
- jcode run in a scratch tmux socket at 160x45; `tmux capture-pane -p` every 200 ms through
real turns (a plain reply, a bash tool call, a streamed answer); `pipe-pane` at rest; and a
tmux CLIENT attached through a PTY to record exactly what Codeman's PTY reads.
- PageUp/PageDown sent with `send-keys`; SGR wheel reports (`\x1b[<64;40;10M`) pasted raw.
- A second client with its own environment to prove where tools run.
- The Docker step run in a throwaway `node:22-bookworm-slim` container.
- End to end through an isolated beta Codeman built from this branch (its own
`CODEMAN_INSTANCE`, port and tmux socket): `POST /api/quick-start` with `mode: "jcode"`,
`/api/jcode/status`, send-and-wait (`wait: "idle"` returned after the turn, 12.7 s for a
turn with a 5 s tool call, not during it), two sessions with the first deleted, and a
Playwright pass over the Run menu entry, the `Run JC` button and the rendered transcript.
The fixtures in `test/jcode-mode.test.ts` are verbatim from these captures.
+1 -1
View File
@@ -30,7 +30,7 @@ Types live in `src/types/session.ts`; persistence in `src/remote-hosts.ts`.
| `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). | | `RemoteHost` (extends `RemoteSshOptions`) | A saved host: `id`, `label`, `host`, `username`, `port?`, `commands?` (per-mode launch command override). |
| `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. | | `RemoteCase` | A working directory on a host: `name`, `type: 'remote'`, `hostId`, `remotePath`. |
| `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. | | `SessionRemote` (extends `RemoteSshOptions`) | The resolved bundle stamped onto a live session: host coordinates + `remotePath` + `commands`, plus **`owned?`** and **`remoteSessionName?`** (COD-105 — see [Ownership](#ownership-launched-vs-discovered-and-attached-cod-105)). Built by `toSessionRemote(host, case)` (sets `owned: true`) for the launch path, or `toAttachedSessionRemote(host, name, path)` (sets `owned: false`) for the attach path. Both copy the advanced SSH options through so every connection is identical. |
| `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok' \| 'deepseek' \| 'omp'>` — the modes that can run remotely. | | `RemoteCommandMode` | `Extract<SessionMode, 'shell' \| 'claude' \| 'opencode' \| 'codex' \| 'gemini' \| 'antigravity' \| 'pi' \| 'grok' \| 'deepseek' \| 'omp' \| 'jcode'>` — the modes that can run remotely. |
| `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. | | `RemoteSessionInfo` (COD-105) | One discovered remote tmux session: `name` (always `codeman-*`), `attached` (a client is connected), `created` (epoch s), `windows`. Returned by `listRemoteCodemanSessions()`. |
Persistence is two flat JSON arrays in the instance data dir: Persistence is two flat JSON arrays in the instance data dir:
+37 -7
View File
@@ -1,9 +1,9 @@
# Agent CLIs # Agent CLIs
Codeman drives ten run modes: nine agent CLIs plus a plain shell. This page covers picking Codeman drives eleven run modes: ten agent CLIs plus a plain shell. This page covers picking
one, setting it up, and the differences that actually change how you work. one, setting it up, and the differences that actually change how you work.
## The ten modes ## The eleven modes
| Mode | CLI | Get it | | Mode | CLI | Get it |
| -------------------- | ---------------------------- | ---------------------------------------------------------------------- | | -------------------- | ---------------------------- | ---------------------------------------------------------------------- |
@@ -16,6 +16,7 @@ one, setting it up, and the differences that actually change how you work.
| **Grok Build** | `grok` | [github.com/xai-org/grok-build](https://github.com/xai-org/grok-build) | | **Grok Build** | `grok` | [github.com/xai-org/grok-build](https://github.com/xai-org/grok-build) |
| **DeepSeek Harness** | `dsh` | [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) | | **DeepSeek Harness** | `dsh` | [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) |
| **OMP** | `omp` | [github.com/can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) | | **OMP** | `omp` | [github.com/can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) |
| **jcode** | `jcode` | [jcode.sh](https://jcode.sh) |
| **Terminal / Shell** | your `$SHELL` | Already installed. | | **Terminal / Shell** | your `$SHELL` | Already installed. |
Any combination works, including all of them. The run mode is chosen per session from the Any combination works, including all of them. The run mode is chosen per session from the
@@ -50,10 +51,11 @@ If a CLI is installed but a Run button for it never appears:
precisely to avoid this; a hand-written plist or unit will not. precisely to avoid this; a hand-written plist or unit will not.
3. Restart the server after installing a new CLI. 3. Restart the server after installing a new CLI.
`pi`, `grok`, `omp` and `dsh` are additionally identity-probed rather than trusted by name: `pi`, `grok`, `omp`, `dsh` and `jcode` are additionally identity-probed rather than trusted by name:
`pi` and `omp` are generic enough that something else on your PATH may answer to them, `pi` and `omp` are generic enough that something else on your PATH may answer to them,
`grok` has npm squatters, and Debian ships an unrelated `dsh` (dancer's shell). Each has a `grok` has npm squatters, an unrelated npm package is also called `jcode`, and Debian ships an
status endpoint (`/api/grok/status`, `/api/deepseek/status`, `/api/omp/status`) that reports unrelated `dsh` (dancer's shell). Each has a status endpoint (`/api/grok/status`,
`/api/deepseek/status`, `/api/omp/status`, `/api/jcode/status`) that reports
the path and version that actually resolved, so a misresolution is visible rather than the path and version that actually resolved, so a misresolution is visible rather than
presenting as "the mode just does not work". presenting as "the mode just does not work".
@@ -69,7 +71,7 @@ output. The other CLIs expose no equivalent.
| Respawn cycling and unattended runs | Yes | Yes | | Respawn cycling and unattended runs | Yes | Yes |
| Cron jobs | Yes | Yes | | Cron jobs | Yes | Yes |
| Docker cases, remote SSH cases | Yes | Yes | | Docker cases, remote SSH cases | Yes | Yes |
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP and Gemini: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser | | Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Gemini and jcode: same screen check, via their own prompt and working line. DeepSeek: reports its state itself. Others: output stabilization, coarser |
| Auto-resume when a usage limit resets | Yes | No | | Auto-resume when a usage limit resets | Yes | No |
| Plan usage chip | Yes | No | | Plan usage chip | Yes | No |
| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no | | Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no |
@@ -262,6 +264,33 @@ an OMP session that had started a turn showed as working for good.
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md). Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
### jcode
A coding agent written in Rust, installed with `curl -fsSL https://jcode.sh/install | bash`
into `~/.local/bin`. jcode keeps its own logins and provider choice (`jcode login`, `/login`,
`~/.jcode/config.toml`); there is no Codeman-side login, key field, or bypass switch, and Run
sends no jcode config at all, because jcode never asks before running a tool. A session can
be started on a model (`--model`) or resume a jcode session by its id or short name
(`--resume`).
Four things worth knowing:
- **Run `jcode` once in a terminal first.** A fresh install opens an onboarding wizard
instead of the prompt for its first few launches, and a prompt typed into that wizard is
lost. Finish or skip it once (Esc), and Codeman sessions open straight at the prompt.
- **All jcode sessions share one background daemon**, which jcode starts the first time and
keeps running after the tab that started it is closed. jcode's tools run inside that
daemon, so they see the daemon's environment, not the tab's. For that reason Codeman does
not inject its agent skill into jcode, and accepts no per-session `JCODE_*` environment
variables: set those in `~/.jcode/config.toml` or in the server's own environment.
- **Read a jcode worker from its terminal.** Codeman cannot read jcode's answers back yet,
so the Response Viewer and `last-response` stay empty for it; `codeman agent read <id>
--tail 3000` (or the terminal tail) shows what it said.
- **Idle detection is the precise kind**: Codeman reads jcode's spinner row above the
prompt, and the wheel or a swipe pages jcode's own transcript.
Guide: [`docs/jcode-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/jcode-integration.md).
### Terminal / Shell ### Terminal / Shell
A plain shell in a tmux session. No agent, no hooks, no idle detection. A plain shell in a tmux session. No agent, no hooks, no idle detection.
@@ -288,6 +317,7 @@ respawns. Which variables are accepted depends on the mode:
| Grok | `GROK_*`, `XAI_*` | | Grok | `GROK_*`, `XAI_*` |
| DeepSeek | `DSH_*`, `DEEPSEEK_*` | | DeepSeek | `DSH_*`, `DEEPSEEK_*` |
| OMP | `OMP_*` | | OMP | `OMP_*` |
| jcode | none (its tools run in a shared daemon, see [jcode](#jcode)) |
Anything outside the allowlist is rejected at the schema. This is intentional: the allowlist Anything outside the allowlist is rejected at the schema. This is intentional: the allowlist
is one global list, so widening it for one CLI widens it for all of them. In multi-user mode is one global list, so widening it for one CLI widens it for all of them. In multi-user mode
@@ -303,7 +333,7 @@ into the case's `.claude/settings.local.json` so that `/model` keeps working.
- **Claude Code** if you want every Codeman feature. Unattended overnight runs, usage-limit - **Claude Code** if you want every Codeman feature. Unattended overnight runs, usage-limit
auto-resume, the Approvals Inbox, and subagent visualization all assume it. auto-resume, the Approvals Inbox, and subagent visualization all assume it.
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP** when you prefer that agent or that - **Codex, OpenCode, Gemini, Antigravity, Grok, OMP, jcode** when you prefer that agent or that
model. You get the session layer, respawn, cron, Docker, and remote SSH; you do not get the model. You get the session layer, respawn, cron, Docker, and remote SSH; you do not get the
hook-driven features. hook-driven features.
- **DeepSeek Harness** if you want DeepSeek's models with real status signals. It is the one - **DeepSeek Harness** if you want DeepSeek's models with real status signals. It is the one
+1 -1
View File
@@ -120,7 +120,7 @@ Conventions for wiki pages:
- Images are referenced from the main repository over raw URLs rather than being copied into - Images are referenced from the main repository over raw URLs rather than being copied into
the wiki. the wiki.
- Say what the default is, especially when it is off. Most of Codeman is opt-in. - Say what the default is, especially when it is off. Most of Codeman is opt-in.
- Label Claude-only behaviour every time it appears. Nine of the ten run modes are not - Label Claude-only behaviour every time it appears. Ten of the eleven run modes are not
Claude. Claude.
## Conduct ## Conduct
+5 -5
View File
@@ -59,10 +59,10 @@ A session carries state the case does not:
## Run mode ## Run mode
The **run mode** is which CLI the session runs: `claude`, `opencode`, `codex`, `gemini`, The **run mode** is which CLI the session runs: `claude`, `opencode`, `codex`, `gemini`,
`antigravity`, `pi`, `grok`, `deepseek`, `omp`, or `shell`. It is chosen at start and does not change afterwards; to `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `jcode`, or `shell`. It is chosen at start and does not change afterwards; to
switch, start another session. switch, start another session.
Claude is the reference mode. Nine of the ten are not Claude, and a number of Codeman Claude is the reference mode. Ten of the eleven are not Claude, and a number of Codeman
features are Claude-only for structural reasons rather than missing effort: they depend on features are Claude-only for structural reasons rather than missing effort: they depend on
Claude Code's hook system or on parsing its terminal output. Every such feature is labelled Claude Code's hook system or on parsing its terminal output. Every such feature is labelled
Claude-only where it appears, and [Agent CLIs](Agent-CLIs) lists them in one place. Claude-only where it appears, and [Agent CLIs](Agent-CLIs) lists them in one place.
@@ -78,7 +78,7 @@ Where a case runs is **separate from** which CLI it runs. There are three locati
| **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). | | **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). |
This matters because it is a common source of confusion: Docker is **not** an eleventh run This matters because it is a common source of confusion: Docker is **not** an eleventh run
mode. All ten run modes work in all three locations. A case is docker-backed or mode. All eleven run modes work in all three locations. A case is docker-backed or
ssh-backed; a session is claude or codex or shell. ssh-backed; a session is claude or codex or shell.
**Web tabs** are the other thing that is not a session. A saved dashboard URL renders as a **Web tabs** are the other thing that is not a session. A saved dashboard URL renders as a
@@ -178,7 +178,7 @@ See [Hooks And Integrations](Hooks-And-Integrations).
| --------------- | ---------------------------------------------------------------------------- | | --------------- | ---------------------------------------------------------------------------- |
| **Case** | Named working directory. | | **Case** | Named working directory. |
| **Session** | One CLI in one tmux session. | | **Session** | One CLI in one tmux session. |
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, shell. | | **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, jcode, shell. |
| **Respawn** | Restarting the CLI on idle to keep an unattended run going. | | **Respawn** | Restarting the CLI on idle to keep an unattended run going. |
| **Ralph loop** | An autonomous single-session task loop. | | **Ralph loop** | An autonomous single-session task loop. |
| **Orchestrator**| A phased plan driven across multiple agents. | | **Orchestrator**| A phased plan driven across multiple agents. |
@@ -189,6 +189,6 @@ See [Hooks And Integrations](Hooks-And-Integrations).
## Read next ## Read next
- [The Dashboard](The-Dashboard) - what the UI is showing you. - [The Dashboard](The-Dashboard) - what the UI is showing you.
- [Agent CLIs](Agent-CLIs) - the ten run modes in detail. - [Agent CLIs](Agent-CLIs) - the eleven run modes in detail.
- [Keeping Agents Running](Keeping-Agents-Running) - respawn, idle detection, usage limits. - [Keeping Agents Running](Keeping-Agents-Running) - respawn, idle detection, usage limits.
- [`docs/architecture-invariants.md`](https://github.com/Ark0N/Codeman/blob/master/docs/architecture-invariants.md) - the mechanisms behind all of this, for contributors. - [`docs/architecture-invariants.md`](https://github.com/Ark0N/Codeman/blob/master/docs/architecture-invariants.md) - the mechanisms behind all of this, for contributors.
+5 -3
View File
@@ -4,7 +4,7 @@ Run a case inside its own container instead of directly on your host: for isolat
reproducible toolchain, and for the ability to pick the whole environment up and move it to reproducible toolchain, and for the ability to pick the whole environment up and move it to
another machine. another machine.
A docker case is a **location overlay**, not a run mode. All ten run modes work inside a A docker case is a **location overlay**, not a run mode. All eleven run modes work inside a
container. See [Core Concepts](Core-Concepts). container. See [Core Concepts](Core-Concepts).
## One-time setup: the base image ## One-time setup: the base image
@@ -26,7 +26,7 @@ A zero exit code proves the layers ran, not that the toolchain works. Verify:
```bash ```bash
docker run --rm codeman/agent:base bash -lc \ docker run --rm codeman/agent:base bash -lc \
'for c in claude codex gemini opencode agy pi grok dsh omp; do printf "%-9s " $c; $c --version 2>&1 | head -1; done' 'for c in claude codex gemini opencode agy pi grok dsh omp jcode; do printf "%-9s " $c; $c --version 2>&1 | head -1; done'
``` ```
The image is secret-free. Credentials are delivered at runtime, never baked in, so exports The image is secret-free. Credentials are delivered at runtime, never baked in, so exports
@@ -116,7 +116,9 @@ as whole directories, because those directories also hold sessions, extensions,
installed packages, which can be gigabytes. So in-container Pi and Grok sessions are installed packages, which can be gigabytes. So in-container Pi and Grok sessions are
invisible from the host (`pi -c` and `grok -c` inside a docker case see only that invisible from the host (`pi -c` and `grok -c` inside a docker case see only that
container's history). OMP's `sessions/` is the exception and is shared read-write, because container's history). OMP's `sessions/` is the exception and is shared read-write, because
Codeman reads it host-side for history and resume. Codeman reads it host-side for history and resume. jcode's credentials are not seeded at all
(`~/.jcode` keeps its OAuth tokens beside its builds, sessions and caches): sign in to jcode
inside the container.
**Git hosts.** The agent image can also include the GitHub CLI (`gh`) and the Azure CLI (`az`, **Git hosts.** The agent image can also include the GitHub CLI (`gh`) and the Azure CLI (`az`,
with the `azure-devops` extension), off by default, and its git then uses them as credential with the `azure-devops` extension), off by default, and its git then uses them as credential
+2 -2
View File
@@ -23,8 +23,8 @@ stores, or refreshes your credentials.
### Which agent CLIs does it support? ### Which agent CLIs does it support?
Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness and Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP
OMP, plus a plain shell, chosen per session. Claude is the reference mode and a few features and jcode, plus a plain shell, chosen per session. Claude is the reference mode and a few features
are Claude-only; [Agent CLIs](Agent-CLIs) has the table. are Claude-only; [Agent CLIs](Agent-CLIs) has the table.
### Does Codeman send my code or prompts anywhere? ### Does Codeman send my code or prompts anywhere?
+3 -3
View File
@@ -6,7 +6,7 @@
Codeman runs your coding agents on your own machine and puts them behind one dashboard you Codeman runs your coding agents on your own machine and puts them behind one dashboard you
can open from any device. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, can open from any device. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi,
Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to the browser, and keeps Grok, DeepSeek Harness, OMP, or jcode inside persistent tmux sessions, streams the real terminal to the browser, and keeps
working while you are away from the keyboard: it re-prompts idle agents, resumes when a working while you are away from the keyboard: it re-prompts idle agents, resumes when a
subscription limit resets, runs jobs on a schedule, and shows every background subagent subscription limit resets, runs jobs on a schedule, and shows every background subagent
live. live.
@@ -33,7 +33,7 @@ codeman web # then open http://localhost:3000
**Already running it** **Already running it**
- [Agent CLIs](Agent-CLIs) - the ten run modes, their setup, and which features are Claude-only. - [Agent CLIs](Agent-CLIs) - the eleven run modes, their setup, and which features are Claude-only.
- [Mobile Guide](Mobile-Guide) - phone and tablet use, QR login, the touch keyboard bar. - [Mobile Guide](Mobile-Guide) - phone and tablet use, QR login, the touch keyboard bar.
- [Remote Access](Remote-Access) - Tailscale, Cloudflare tunnel, LAN plus password, QR login. - [Remote Access](Remote-Access) - Tailscale, Cloudflare tunnel, LAN plus password, QR login.
- [Keeping Agents Running](Keeping-Agents-Running) - idle detection, respawn cycling, auto-resume on usage limits. - [Keeping Agents Running](Keeping-Agents-Running) - idle detection, respawn cycling, auto-resume on usage limits.
@@ -127,7 +127,7 @@ codeman web # then open http://localhost:3000
| OS | macOS or Linux. Windows works through WSL2. | | OS | macOS or Linux. Windows works through WSL2. |
| Node.js | 22 or newer. | | Node.js | 22 or newer. |
| tmux | Required. Sessions live in tmux, which is what makes them survive restarts. | | tmux | Required. Sessions live in tmux, which is what makes them survive restarts. |
| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP. Plain shell sessions need none. | | An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP, jcode. Plain shell sessions need none. |
| Network | Binds to `127.0.0.1` by default. Reaching it from another device is a deliberate step: see [Remote Access](Remote-Access). | | Network | Binds to `127.0.0.1` by default. Reaching it from another device is a deliberate step: see [Remote Access](Remote-Access). |
Codeman is MIT licensed, self-hosted, and sends no telemetry. Everything runs on your Codeman is MIT licensed, self-hosted, and sends no telemetry. Everything runs on your
+2 -1
View File
@@ -9,7 +9,7 @@ Getting Codeman onto a machine, verifying it works, updating it, and removing it
| **macOS or Linux** | Windows works through WSL2. See [Windows](#windows-wsl) below. | | **macOS or Linux** | Windows works through WSL2. See [Windows](#windows-wsl) below. |
| **Node.js 22+** | The installer offers to install it if missing. | | **Node.js 22+** | The installer offers to install it if missing. |
| **tmux** | Not optional. Sessions live inside tmux, which is what makes them survive a server restart, a dropped connection, or a closed laptop. | | **tmux** | Not optional. Sessions live inside tmux, which is what makes them survive a server restart, a dropped connection, or a closed laptop. |
| **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). | | **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), [jcode](https://jcode.sh). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). |
Codeman itself sends no telemetry and phones no home. The only network traffic is your Codeman itself sends no telemetry and phones no home. The only network traffic is your
browser to your server, and whatever the agent CLI you chose does on its own. browser to your server, and whatever the agent CLI you chose does on its own.
@@ -168,6 +168,7 @@ Codeman drives CLIs, it does not bundle them. Install at least one:
| **Grok Build** | `curl -fsSL https://x.ai/cli/install.sh \| bash` | xAI. Lands in `~/.grok/bin`; `grok login --device-auth` for headless hosts. | | **Grok Build** | `curl -fsSL https://x.ai/cli/install.sh \| bash` | xAI. Lands in `~/.grok/bin`; `grok login --device-auth` for headless hosts. |
| **DeepSeek Harness** | `npm i -g @deepseek-ai/dsh pnpm`, then a terminal profile | The npm package is only a launcher. Codeman's Run menu installs the community terminal profile for you. See [Agent CLIs](Agent-CLIs). | | **DeepSeek Harness** | `npm i -g @deepseek-ai/dsh pnpm`, then a terminal profile | The npm package is only a launcher. Codeman's Run menu installs the community terminal profile for you. See [Agent CLIs](Agent-CLIs). |
| **OMP** | `curl -fsSL https://omp.sh/install \| sh` | Oh My Pi. Run it once by hand to finish its own onboarding. | | **OMP** | `curl -fsSL https://omp.sh/install \| sh` | Oh My Pi. Run it once by hand to finish its own onboarding. |
| **jcode** | `curl -fsSL https://jcode.sh/install \| bash` | Run it once by hand to finish (or skip) its onboarding wizard. |
Log each CLI in once, by hand, before pointing Codeman at it. Codeman never collects or Log each CLI in once, by hand, before pointing Codeman at it. Codeman never collects or
stores your CLI credentials. stores your CLI credentials.
+1
View File
@@ -70,6 +70,7 @@ one:
| **Grok Build** | xAI's CLI. | | **Grok Build** | xAI's CLI. |
| **DeepSeek Harness** | Needs a terminal profile; the menu offers to install one. | | **DeepSeek Harness** | Needs a terminal profile; the menu offers to install one. |
| **OMP** | Oh My Pi, configured entirely through its own `~/.omp`. | | **OMP** | Oh My Pi, configured entirely through its own `~/.omp`. |
| **jcode** | Configured through its own `~/.jcode`; no permission prompts. |
| **Terminal / Shell** | A plain shell, no agent. Also the **Run Shell** button. | | **Terminal / Shell** | A plain shell, no agent. Also the **Run Shell** button. |
The dropdown also lists any saved dashboard URLs ([Web Tabs](Web-Tabs)) and your recent The dropdown also lists any saved dashboard URLs ([Web Tabs](Web-Tabs)) and your recent
+1 -1
View File
@@ -4,7 +4,7 @@ Point a case at another machine and the agent runs **there**, with the same dash
mobile UI, and autonomy features. Your laptop becomes a window onto a session living on the mobile UI, and autonomy features. Your laptop becomes a window onto a session living on the
remote host. remote host.
Like Docker, this is a **location overlay** on a case, not a run mode. All ten run modes Like Docker, this is a **location overlay** on a case, not a run mode. All eleven run modes
work remotely. See [Core Concepts](Core-Concepts). work remotely. See [Core Concepts](Core-Concepts).
## Why bother ## Why bother
+13 -13
View File
@@ -168,19 +168,19 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
# script will ever execute, and they arrive embedded in this file — same TLS fetch, same # script will ever execute, and they arrive embedded in this file — same TLS fetch, same
# commit as the script itself. Nothing fetched at install time is ever executed; there is # commit as the script itself. Nothing fetched at install time is ever executed; there is
# no network refresh of these arrays. See cli_catalog_select_platform below. # no network refresh of these arrays. See cli_catalog_select_platform below.
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp') CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp' 'jcode')
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP') CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP' 'jcode')
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1) CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1 1)
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0) CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0 0)
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh') CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh' 'https://jcode.sh/docs')
CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'curl -fsSL https://omp.sh/install | sh') CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'curl -fsSL https://omp.sh/install | sh' 'curl -fsSL https://jcode.sh/install | bash')
CLI_CMD_DARWIN=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'brew install can1357/tap/omp') CLI_CMD_DARWIN=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'brew install can1357/tap/omp' 'curl -fsSL https://jcode.sh/install | bash')
CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp') CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp' 'jcode')
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8) CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8 9)
CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1) CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1 1)
CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/go/bin/opencode" "$HOME/.bun/bin/opencode" "$HOME/.npm-global/bin/opencode" "$HOME/bin/opencode" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp") CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/go/bin/opencode" "$HOME/.bun/bin/opencode" "$HOME/.npm-global/bin/opencode" "$HOME/bin/opencode" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp" "$HOME/.local/bin/jcode" "/usr/local/bin/jcode" "$HOME/bin/jcode")
CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41) CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41 47)
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6) CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 3)
# <<< END GENERATED CLI CATALOGUE # <<< END GENERATED CLI CATALOGUE
# ============================================================================ # ============================================================================
+1 -1
View File
@@ -553,7 +553,7 @@ Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor co
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
instead of guessed-from-silence ones — and it writes a structured transcript, which is instead of guessed-from-silence ones — and it writes a structured transcript, which is
what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`, what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`,
`pi`, `grok` and `omp` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)). `pi`, `grok`, `omp` and `jcode` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
Three things to know before you spawn one: Three things to know before you spawn one:
@@ -237,7 +237,7 @@ minutes, never retry the credential.
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
returns is too early (verified live: empty on the first call, full prose seconds later). returns is too early (verified live: empty on the first call, full prose seconds later).
It is also `""` before the worker's first completed turn, and permanently `""` for It is also `""` before the worker's first completed turn, and permanently `""` for
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `omp`, which write no transcript at `shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp` and `jcode`, which write no transcript at
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
for the same reason claude does (the harness finalizes the assistant message just after for the same reason claude does (the harness finalizes the assistant message just after
it reports `idle`), so poll it the same way. it reports `idle`), so poll it the same way.
@@ -341,7 +341,7 @@ ESC=$(printf '\033')
`POST /api/v1/quick-start` body (all optional): `POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}` `{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is , `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|jcode`; response is
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory `.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
on the user's disk) if missing, do not retry it in a loop, and remember the name. on the user's disk) if missing, do not retry it in a loop, and remember the name.
@@ -356,9 +356,10 @@ a soft default the worker can change with `/advisor`. Remote and docker cases re
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick `OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`, the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`, `GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
`GET /api/v1/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed). `GET /api/v1/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/jcode/status` each return `.data.{available, path}` (no session needed).
Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name, Pi's, grok's, OMP's and jcode's also carry `.data.version`, because `pi` is a short generic name,
`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated `grok` is a name with npm squatters, an unrelated npm package is also called `jcode`, and `omp` is
a similarly short name, so an unrelated
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
not version-shaped, so `available:false` there can mean "a different program of the same not version-shaped, so `available:false` there can mean "a different program of the same
name is in front" rather than "nothing is installed". `shell` has no CLI to probe. name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
@@ -486,8 +487,8 @@ Quirks that will bite you:
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser, - ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers` which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`. ⚠️ **`shell` is NOT one of those** `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`jcode`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does (`isExternalCliMode` covers only those external CLIs), so the parser does
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper: bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
a shell worker running `cat build.log` really does populate this. In practice it stays a shell worker running `cat build.log` really does populate this. In practice it stays
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) | | synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
| liveness / death check | HTTP `wait?until=exit` | | liveness / death check | HTTP `wait?until=exit` |
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` | | interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`) | HTTP only (no other CLI has messaging) | | non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`jcode`) | HTTP only (no other CLI has messaging) |
| delete | HTTP, via SKILL.md's `delete_session` guard | | delete | HTTP, via SKILL.md's `delete_session` guard |
## Availability: probe, never assume ## Availability: probe, never assume
@@ -352,7 +352,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
### Mixed fleets: the pairing matrix ### Mixed fleets: the pairing matrix
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`) cannot be peers Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `jcode`) cannot be peers
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
messaging in their briefs. The claude half of the fleet can use messaging among itself, messaging in their briefs. The claude half of the fleet can use messaging among itself,
subject to the namespace rule: **messaging works between two sessions that share one subject to the namespace rule: **messaging works between two sessions that share one
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
done done
printf '%s\n' "$TXT" printf '%s\n' "$TXT"
# (.data is {text,timestamp}; text is also "" before the first completed turn and # (.data is {text,timestamp}; text is also "" before the first completed turn and
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use # always "" for shell/opencode/gemini/antigravity/pi/grok/omp/jcode, which have no transcript, use
# the terminal tail there, and here only to diagnose an unsubmitted prompt.) # the terminal tail there, and here only to diagnose an unsubmitted prompt.)
# 6. clean up: exact id, own list only, through the fail-closed preamble helper # 6. clean up: exact id, own list only, through the fail-closed preamble helper
@@ -376,7 +376,7 @@ recovered by submitting it with `{"input":"\r"}`.
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a `deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
real end-of-turn signal rather than a guess. On real end-of-turn signal rather than a guess. On
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a `shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`jcode`, requesting them explicitly is a
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval, 400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
question or trust dialog is up; a wait for it answers at once if one already is), and question or trust dialog is up; a wait for it answers at once if one already is), and
lifecycle transitions there are coarse (a short shell command may emit **no** lifecycle transitions there are coarse (a short shell command may emit **no**
@@ -430,7 +430,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
single read taken the instant send-and-wait returns comes back `""` even though the single read taken the instant send-and-wait returns comes back `""` even though the
turn finished (verified live: empty on the first call, full text seconds later). `text` turn finished (verified live: empty on the first call, full text seconds later). `text`
is also `""` before the worker's first completed turn, and always `""` for modes with is also `""` before the worker's first completed turn, and always `""` for modes with
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`; the first four no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `jcode`; the first four
verified live, pi from the same source path), which is verified live, pi from the same source path), which is
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
@@ -516,7 +516,7 @@ turn), and both better than diffing terminal samples:
``` ```
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for ⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`** (those parsers are skipped wholesale) and `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`jcode`** (those parsers are skipped wholesale) and
in practice empty for `shell`. Source-verified, not measured live. in practice empty for `shell`. Source-verified, not measured live.
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
+1 -1
View File
@@ -553,7 +553,7 @@ Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor co
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
instead of guessed-from-silence ones — and it writes a structured transcript, which is instead of guessed-from-silence ones — and it writes a structured transcript, which is
what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`, what `last-response` reads for it. `shell`, `opencode`, `codex`, `gemini`, `antigravity`,
`pi`, `grok` and `omp` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)). `pi`, `grok`, `omp` and `jcode` have neither and still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
Three things to know before you spawn one: Three things to know before you spawn one:
+8 -7
View File
@@ -237,7 +237,7 @@ minutes, never retry the credential.
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
returns is too early (verified live: empty on the first call, full prose seconds later). returns is too early (verified live: empty on the first call, full prose seconds later).
It is also `""` before the worker's first completed turn, and permanently `""` for It is also `""` before the worker's first completed turn, and permanently `""` for
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `omp`, which write no transcript at `shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp` and `jcode`, which write no transcript at
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
for the same reason claude does (the harness finalizes the assistant message just after for the same reason claude does (the harness finalizes the assistant message just after
it reports `idle`), so poll it the same way. it reports `idle`), so poll it the same way.
@@ -341,7 +341,7 @@ ESC=$(printf '\033')
`POST /api/v1/quick-start` body (all optional): `POST /api/v1/quick-start` body (all optional):
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}` `{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is , `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|jcode`; response is
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory `.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
on the user's disk) if missing, do not retry it in a loop, and remember the name. on the user's disk) if missing, do not retry it in a loop, and remember the name.
@@ -356,9 +356,10 @@ a soft default the worker can change with `/advisor`. Remote and docker cases re
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick `OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`, the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`, `GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
`GET /api/v1/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed). `GET /api/v1/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/jcode/status` each return `.data.{available, path}` (no session needed).
Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name, Pi's, grok's, OMP's and jcode's also carry `.data.version`, because `pi` is a short generic name,
`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated `grok` is a name with npm squatters, an unrelated npm package is also called `jcode`, and `omp` is
a similarly short name, so an unrelated
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
not version-shaped, so `available:false` there can mean "a different program of the same not version-shaped, so `available:false` there can mean "a different program of the same
name is in front" rather than "nothing is installed". `shell` has no CLI to probe. name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
@@ -486,8 +487,8 @@ Quirks that will bite you:
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser, - ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers` which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`. ⚠️ **`shell` is NOT one of those** `[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`jcode`. ⚠️ **`shell` is NOT one of those**
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does (`isExternalCliMode` covers only those external CLIs), so the parser does
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper: bare `tail|cat|head|less|grep|watch|multitail <path>` lines with no `● Bash(` wrapper:
a shell worker running `cat build.log` really does populate this. In practice it stays a shell worker running `cat build.log` really does populate this. In practice it stays
+2 -2
View File
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) | | synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
| liveness / death check | HTTP `wait?until=exit` | | liveness / death check | HTTP `wait?until=exit` |
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` | | interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`) | HTTP only (no other CLI has messaging) | | non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`jcode`) | HTTP only (no other CLI has messaging) |
| delete | HTTP, via SKILL.md's `delete_session` guard | | delete | HTTP, via SKILL.md's `delete_session` guard |
## Availability: probe, never assume ## Availability: probe, never assume
@@ -352,7 +352,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
### Mixed fleets: the pairing matrix ### Mixed fleets: the pairing matrix
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`) cannot be peers Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `jcode`) cannot be peers
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
messaging in their briefs. The claude half of the fleet can use messaging among itself, messaging in their briefs. The claude half of the fleet can use messaging among itself,
subject to the namespace rule: **messaging works between two sessions that share one subject to the namespace rule: **messaging works between two sessions that share one
+1 -1
View File
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
done done
printf '%s\n' "$TXT" printf '%s\n' "$TXT"
# (.data is {text,timestamp}; text is also "" before the first completed turn and # (.data is {text,timestamp}; text is also "" before the first completed turn and
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use # always "" for shell/opencode/gemini/antigravity/pi/grok/omp/jcode, which have no transcript, use
# the terminal tail there, and here only to diagnose an unsubmitted prompt.) # the terminal tail there, and here only to diagnose an unsubmitted prompt.)
# 6. clean up: exact id, own list only, through the fail-closed preamble helper # 6. clean up: exact id, own list only, through the fail-closed preamble helper
+3 -3
View File
@@ -376,7 +376,7 @@ recovered by submitting it with `{"input":"\r"}`.
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a `deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
real end-of-turn signal rather than a guess. On real end-of-turn signal rather than a guess. On
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a `shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`jcode`, requesting them explicitly is a
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval, 400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
question or trust dialog is up; a wait for it answers at once if one already is), and question or trust dialog is up; a wait for it answers at once if one already is), and
lifecycle transitions there are coarse (a short shell command may emit **no** lifecycle transitions there are coarse (a short shell command may emit **no**
@@ -430,7 +430,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
single read taken the instant send-and-wait returns comes back `""` even though the single read taken the instant send-and-wait returns comes back `""` even though the
turn finished (verified live: empty on the first call, full text seconds later). `text` turn finished (verified live: empty on the first call, full text seconds later). `text`
is also `""` before the worker's first completed turn, and always `""` for modes with is also `""` before the worker's first completed turn, and always `""` for modes with
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`; the first four no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `jcode`; the first four
verified live, pi from the same source path), which is verified live, pi from the same source path), which is
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
@@ -516,7 +516,7 @@ turn), and both better than diffing terminal samples:
``` ```
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for ⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`** (those parsers are skipped wholesale) and `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`jcode`** (those parsers are skipped wholesale) and
in practice empty for `shell`. Source-verified, not measured live. in practice empty for `shell`. Source-verified, not measured live.
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
+2
View File
@@ -301,6 +301,8 @@ const capabilitiesSchema = z
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports). // the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
// Optional: absent means no paging, the safe default for an unmeasured CLI. // Optional: absent means no paging, the safe default for an unmeasured CLI.
transcriptPageKeys: z.boolean().optional(), transcriptPageKeys: z.boolean().optional(),
// Spare the CLI's own detached daemon (another process group) when a pane is killed.
sharedDaemon: z.boolean().optional(),
keyboardAccessory: z.enum(['agent', 'shell']), keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(), privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']), startMode: z.enum(['interactive', 'shell']),
+149 -1
View File
@@ -1652,5 +1652,153 @@ const OMP: CliEntry = {
}, },
}; };
// jcode (1jehuang/jcode, a Rust coding-agent TUI). Measured on a live v0.98.1 pane inside tmux
// (capture-pane every 200 ms through real turns, a recorded tmux client stream, PageUp/PageDown
// and SGR wheel reports, a bracketed-paste prompt; 2026-10-16). Three things about it shape
// this entry and are worth knowing before changing it:
//
// 1. Every jcode TUI is a CLIENT of one shared per-user daemon (`jcode serve`, started by the
// first client in its own session, so it outlives the pane that started it). The agent and
// its tools run in the DAEMON, with the daemon's environment: a second client's env never
// reaches its bash tool (measured, `probe=[]`). A session's `CODEMAN_SESSION_ID` and
// `CODEMAN_API_URL` are therefore whatever the session that started the daemon had, or
// nothing. That is why the agent skill is not injected (below) and why `JCODE_*` is not an
// `envOverrides` prefix: most of that namespace is read by the daemon, so a per-session
// value would do nothing, and where it did act (the session that starts the daemon) it
// would reach every jcode session, hook and command-path variables included.
// 2. It has no approval prompts and no bypass flag: bash runs ungated apart from jcode's own
// model-side reflection gate and a catastrophic-path deny. Nothing to clamp, nothing to
// read as blocked, same shape as pi.
// 3. A fresh install opens an onboarding wizard instead of the composer until its launch
// counter passes 5 (or 10 sessions exist). A prompt typed into it is lost, so a new user
// should run `jcode` once in a terminal (or press Esc in the pane) before driving it.
const JCODE: CliEntry = {
id: 'jcode' as CliEntry['id'],
label: 'jcode',
shortBadge: 'JC',
// No brand colour to borrow (the mark is a monochrome halftone donut, and its TUI's own blue
// is gemini's #8ab4f8), so an amber no other entry uses.
accent: '#f59e0b',
enabled: true,
stock: true,
order: 95,
kind: 'agent',
discovery: {
binaries: ['jcode'],
// The official installer links `~/.local/bin/jcode` (its `JCODE_INSTALL_DIR` default) to a
// launcher script under `~/.jcode/builds/`; the agent image puts it in `/usr/local/bin`.
searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.homeBin],
// `jcode --version` prints `jcode v0.98.1 (1c7426eb1)`. npm carries an unrelated `jcode`
// package, so the probe is anchored on the `jcode v` prefix and a mismatch counts as absent.
version: { arg: '--version', regex: '(?:^|\\s)jcode v(\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true },
install: {
command: {
linux: 'curl -fsSL https://jcode.sh/install | bash',
darwin: 'curl -fsSL https://jcode.sh/install | bash',
},
docsUrl: 'https://jcode.sh/docs',
agentImageLayer: {
kind: 'dedicated',
reason:
'Not on npm: jcode ships a standalone Rust binary through jcode.sh/install, whose launcher is a script that execs the binary beside it.',
},
},
},
launch: {
params: {
model: { type: 'token', pattern: 'model' },
// jcode ids look like `session_fox_1792160401272_f5b47ce47d631006` and `--resume` also
// takes the short name (`fox`). Its value is OPTIONAL (bare `--resume` opens the session
// picker), so the token must not be able to start with `-`, or it would be read as a
// flag of its own: `path-segment` requires a leading alphanumeric.
resumeId: { type: 'token', pattern: 'path-segment' },
},
variants: [
{
id: 'default',
args: [
{ lit: 'jcode' },
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
{ flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
],
},
],
legacyConfigAliases: { resumeId: 'resumeSessionId' },
legacyConfigField: 'jcodeConfig',
resumeAppend: { style: 'flag', flag: '--resume' },
},
env: {
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
unset: ['NO_COLOR'],
// Provider logins live in jcode's own files (`~/.jcode/auth.json`, `~/.config/jcode/*.env`)
// and are read by the daemon, so there is nothing for the server to forward into the pane.
tmuxSetenvKeys: [],
dockerExecEnvNames: [],
// Deliberately none: see note 1 above the entry.
allowedPrefixes: [],
allowedKeys: [],
},
capabilities: {
...agentDefaults(),
// A full-screen alternate-screen TUI that turns mouse tracking on itself (tmux
// `alternate_on=1`, `mouse_any_flag=1`, `mouse_sgr_flag=1`), the opencode shape: kept, a
// drag would be reported to jcode instead of selecting text, so Auto Copy and copy-on-select
// would silently do nothing.
altScreen: 'strip-mux-and-mouse',
// Pages its own transcript on PageUp/PageDown (measured: PageUp brought the welcome header
// back, PageDown returned to the composer). Over the hollow alt-screen buffer the wheel and a
// swipe become those keys instead of doing nothing.
transcriptPageKeys: true,
// Closing a jcode tab must not take the shared daemon (note 1) down with it: it is a
// descendant of whichever pane started it, in a process group of its own (setsid).
// Measured before this flag: deleting that tab left the next tab's client looping on
// `Connection lost - retrying` with its prompt queued.
sharedDaemon: true,
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// At rest the composer row is `<n>> ` (the prompt number, then `>`); while a turn runs the
// same row reads `<n>…` and a status row appears directly above it, a braille spinner (the
// gemini/omp frame set, ~12 fps) then the phase and the elapsed time:
// `⠋ sending… 0s`, `⠦ thinking… 2s · https`, `⠼ waiting for response… 3s · https`,
// `⠸ running bash · <detail> · 12s · Alt+B bg`, or, while streaming, the time first
// (`⠼ 12s · 45.1 tps · ↑16k ↓20`). Past a minute the time reads `1m 3s`, past an hour
// `1h 2m`. A dropped connection swaps the spinner for `↻ network disconnected, waiting to
// retry · … · 5s`, still mid-turn. At rest that row is empty or a dim tip, never a spinner.
// The spinner repaints the pane at least every 100 ms for the whole turn (recorded tmux
// client stream), so the pane is never quiet mid-turn, and at rest it sends nothing at all
// (12 s recorded: zero bytes), so the idle check settles.
// The glyph is the bare `>`: the turn-ending repaint redraws only the cells that changed,
// which on that row is the `…` turning into `>` (recorded: `\x1b[43;2H...> `), never the
// prompt number. It only ARMS the idle check. It also leaves the submit verifier inert,
// which is what jcode wants (no early-Enter defect): every composer row starts with its
// number, so no screen row starts with `>` the way the verifier requires.
workDetect: {
promptGlyph: '>',
workingLine: String.raw`(?:^|\n) *(?:[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] [^\n]*\d[smh]\b|↻ network disconnected)`,
},
// jcode reads `./.agents/skills` (and `./.jcode/skills`, `./.claude/skills`), but its tools
// run in the shared daemon, whose CODEMAN_* identity belongs to whichever session started
// it (note 1). An injected skill would act as THAT session, so it is not injected.
agentSkillInjection: false,
customModelInjection: { kind: 'unsupported' },
},
overlays: {
// No credStore: Docker seeding is CRED_STORES in docker-hosts.ts, which has no `.jcode` row.
// `~/.jcode` holds the OAuth tokens beside 160 MB of builds, sessions and SQLite caches, and
// API keys live in `~/.config/jcode`; sign in inside the container instead.
},
};
/** The full stock catalog, in the order the run menu shows by default. */ /** The full stock catalog, in the order the run menu shows by default. */
export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI, GROK, DEEPSEEK, OMP]; export const STOCK_CLIS: CliEntry[] = [
CLAUDE,
SHELL,
OPENCODE,
CODEX,
GEMINI,
ANTIGRAVITY,
PI,
GROK,
DEEPSEEK,
OMP,
JCODE,
];
+13
View File
@@ -555,6 +555,19 @@ export interface CliCapabilities {
* Absent means no paging, so a CLI nobody has measured keeps plain local scrolling. * Absent means no paging, so a CLI nobody has measured keeps plain local scrolling.
*/ */
transcriptPageKeys?: boolean; transcriptPageKeys?: boolean;
/**
* The CLI's TUI is a CLIENT of a per-user daemon that its first client starts and every
* later session of the CLI shares (jcode). The daemon detaches into its own session and
* process group, but stays a descendant of that first pane by parent pid, so a session
* kill signals only the descendants still in the pane's process group
* (`descendantsInPaneGroup()`, proc-tree.ts). Without it, closing the tab that happened
* to start the daemon took it down and cut every other session of the CLI off.
*
* The flip side is that processes the agent started through that daemon (its tools run
* there, not in the pane) outlive the tab; stopping them is the CLI's own business
* (`jcode server stop`). Absent = every descendant is killed, as for every other CLI.
*/
sharedDaemon?: boolean;
keyboardAccessory: 'agent' | 'shell'; keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */ /** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean; privilegedCommandGate: boolean;
+3
View File
@@ -20,6 +20,7 @@ import type {
AntigravityConfig, AntigravityConfig,
PiConfig, PiConfig,
GrokConfig, GrokConfig,
JcodeConfig,
DeepSeekConfig, DeepSeekConfig,
OmpConfig, OmpConfig,
SessionRemote, SessionRemote,
@@ -110,6 +111,7 @@ export interface CreateSessionOptions {
antigravityConfig?: AntigravityConfig; antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig; piConfig?: PiConfig;
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
jcodeConfig?: JcodeConfig;
deepSeekConfig?: DeepSeekConfig; deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig; ompConfig?: OmpConfig;
/** When restoring after reboot, resume a previous Claude conversation by its session ID */ /** When restoring after reboot, resume a previous Claude conversation by its session ID */
@@ -154,6 +156,7 @@ export interface RespawnPaneOptions {
antigravityConfig?: AntigravityConfig; antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig; piConfig?: PiConfig;
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
jcodeConfig?: JcodeConfig;
deepSeekConfig?: DeepSeekConfig; deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig; ompConfig?: OmpConfig;
/** Resume a previous Claude conversation when respawning */ /** Resume a previous Claude conversation when respawning */
+33
View File
@@ -85,3 +85,36 @@ export function collectDescendants(
} }
return out; return out;
} }
/**
* The descendants a session kill may signal when the pane's CLI runs a SHARED daemon
* (`capabilities.sharedDaemon`): only those still in the pane's own process group.
*
* Such a CLI's first client starts a per-user daemon that every other session of that
* CLI talks to, and the daemon detaches itself into its own session (setsid), which also
* makes it the leader of a new process group. It stays a descendant of that first pane
* by parent pid, though, so the plain descendant kill took it down with the pane and cut
* every other session off (measured with jcode: deleting the tab that happened to start
* the daemon left the next tab's client reconnecting forever). A process that left the
* pane's group did so on purpose, and its whole subtree inherits the new group, so the
* group is what draws the line.
*
* A pid whose group could not be read is KEPT in the kill list: it most likely exited
* between the walk and the lookup, and the kill path already checks liveness before it
* signals. Callers that could not read the groups at all keep the full list, which is
* the behaviour before this existed.
*
* @param descendants the walk's result for the pane pid
* @param groupOf pgid per pid, as read once by the caller
* @param paneGroup the pane process's own pgid
*/
export function descendantsInPaneGroup(
descendants: readonly number[],
groupOf: ReadonlyMap<number, number>,
paneGroup: number
): number[] {
return descendants.filter((pid) => {
const group = groupOf.get(pid);
return group === undefined || group === paneGroup;
});
}
+2
View File
@@ -33,6 +33,7 @@ import type {
EffortLevel, EffortLevel,
GeminiConfig, GeminiConfig,
GrokConfig, GrokConfig,
JcodeConfig,
OmpConfig, OmpConfig,
OpenCodeConfig, OpenCodeConfig,
PiConfig, PiConfig,
@@ -50,6 +51,7 @@ export interface SpawnBridgeOptions {
antigravityConfig?: AntigravityConfig; antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig; piConfig?: PiConfig;
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
jcodeConfig?: JcodeConfig;
deepSeekConfig?: DeepSeekConfig; deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig; ompConfig?: OmpConfig;
resumeSessionId?: string; resumeSessionId?: string;
+14
View File
@@ -56,6 +56,7 @@ import {
type AntigravityConfig, type AntigravityConfig,
type PiConfig, type PiConfig,
type GrokConfig, type GrokConfig,
type JcodeConfig,
type DeepSeekConfig, type DeepSeekConfig,
type OmpConfig, type OmpConfig,
type SessionRemote, type SessionRemote,
@@ -748,6 +749,8 @@ export class Session extends EventEmitter {
private _piConfig: PiConfig | undefined; private _piConfig: PiConfig | undefined;
// Grok configuration (only for mode === 'grok') // Grok configuration (only for mode === 'grok')
private _grokConfig: GrokConfig | undefined; private _grokConfig: GrokConfig | undefined;
// jcode configuration (only for mode === 'jcode')
private _jcodeConfig: JcodeConfig | undefined;
// DeepSeek Harness configuration (only for mode === 'deepseek') // DeepSeek Harness configuration (only for mode === 'deepseek')
private _deepSeekConfig: DeepSeekConfig | undefined; private _deepSeekConfig: DeepSeekConfig | undefined;
@@ -875,6 +878,8 @@ export class Session extends EventEmitter {
piConfig?: PiConfig; piConfig?: PiConfig;
/** Grok configuration (only for mode === 'grok') */ /** Grok configuration (only for mode === 'grok') */
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
/** jcode configuration (only for mode === 'jcode') */
jcodeConfig?: JcodeConfig;
/** DeepSeek Harness configuration (only for mode === 'deepseek') */ /** DeepSeek Harness configuration (only for mode === 'deepseek') */
deepSeekConfig?: DeepSeekConfig; deepSeekConfig?: DeepSeekConfig;
/** OMP configuration (only for mode === 'omp') */ /** OMP configuration (only for mode === 'omp') */
@@ -1037,6 +1042,11 @@ export class Session extends EventEmitter {
this._grokConfig = config.grokConfig; this._grokConfig = config.grokConfig;
} }
// Apply jcode configuration
if (config.jcodeConfig) {
this._jcodeConfig = config.jcodeConfig;
}
// Apply env overrides (exported at spawn, not persisted to disk). // Apply env overrides (exported at spawn, not persisted to disk).
// Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var, // Legacy migration: pre-0.7.2 carried effort as the CLAUDE_CODE_EFFORT_LEVEL env var,
// which hard-locks /effort switching. Extract it into _effort (--settings soft default) // which hard-locks /effort switching. Extract it into _effort (--settings soft default)
@@ -2009,6 +2019,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig, antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig, piConfig: this._piConfig,
grokConfig: this._grokConfig, grokConfig: this._grokConfig,
jcodeConfig: this._jcodeConfig,
deepSeekConfig: this._deepSeekConfig, deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig, ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
@@ -2381,6 +2392,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig, antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig, piConfig: this._piConfig,
grokConfig: this._grokConfig, grokConfig: this._grokConfig,
jcodeConfig: this._jcodeConfig,
deepSeekConfig: this._deepSeekConfig, deepSeekConfig: this._deepSeekConfig,
// OMP resolution/pinning does NOT happen here. This object is built // OMP resolution/pinning does NOT happen here. This object is built
// EAGERLY — including on every boot-recovery reattach, before anyone // EAGERLY — including on every boot-recovery reattach, before anyone
@@ -2866,6 +2878,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig, antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig, piConfig: this._piConfig,
grokConfig: this._grokConfig, grokConfig: this._grokConfig,
jcodeConfig: this._jcodeConfig,
deepSeekConfig: this._deepSeekConfig, deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig, ompConfig: this._ompConfig,
resumeSessionId: this._resumeSessionId, resumeSessionId: this._resumeSessionId,
@@ -3430,6 +3443,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig, antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig, piConfig: this._piConfig,
grokConfig: this._grokConfig, grokConfig: this._grokConfig,
jcodeConfig: this._jcodeConfig,
deepSeekConfig: this._deepSeekConfig, deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig, ompConfig: this._ompConfig,
}; };
+50 -4
View File
@@ -22,7 +22,7 @@
*/ */
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { collectDescendants } from './proc-tree.js'; import { collectDescendants, descendantsInPaneGroup } from './proc-tree.js';
import { execSync, exec, execFile } from 'node:child_process'; import { execSync, exec, execFile } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
@@ -53,6 +53,7 @@ import {
type AntigravityConfig, type AntigravityConfig,
type PiConfig, type PiConfig,
type GrokConfig, type GrokConfig,
type JcodeConfig,
type DeepSeekConfig, type DeepSeekConfig,
type OmpConfig, type OmpConfig,
type SessionRemote, type SessionRemote,
@@ -1081,6 +1082,7 @@ export function buildSpawnCommand(options: {
antigravityConfig?: AntigravityConfig; antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig; piConfig?: PiConfig;
grokConfig?: GrokConfig; grokConfig?: GrokConfig;
jcodeConfig?: JcodeConfig;
deepSeekConfig?: DeepSeekConfig; deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig; ompConfig?: OmpConfig;
resumeSessionId?: string; resumeSessionId?: string;
@@ -2317,6 +2319,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
jcodeConfig,
deepSeekConfig, deepSeekConfig,
ompConfig, ompConfig,
resumeSessionId, resumeSessionId,
@@ -2403,6 +2406,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
jcodeConfig,
deepSeekConfig, deepSeekConfig,
ompConfig, ompConfig,
resumeSessionId, resumeSessionId,
@@ -2652,6 +2656,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
jcodeConfig,
deepSeekConfig, deepSeekConfig,
ompConfig, ompConfig,
resumeSessionId, resumeSessionId,
@@ -2693,6 +2698,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
antigravityConfig, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
jcodeConfig,
deepSeekConfig, deepSeekConfig,
ompConfig, ompConfig,
resumeSessionId, resumeSessionId,
@@ -2865,6 +2871,45 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}); });
} }
/**
* Narrow a pane's descendants to the ones a kill may signal, for a CLI that runs a
* shared daemon (`capabilities.sharedDaemon`): those still in the pane's process group
* (see `descendantsInPaneGroup()`). One bounded `ps` for the pane and its descendants,
* as an argv (no shell). Any other CLI, or a lookup that failed, gets the list back
* unchanged, which is the kill as it always was.
*/
private async killableDescendants(mode: SessionMode, panePid: number, descendants: number[]): Promise<number[]> {
if (descendants.length === 0 || !getCli(mode)?.capabilities.sharedDaemon) return descendants;
try {
const { stdout } = await new Promise<{ stdout: string }>((resolve, reject) => {
execFile(
'ps',
['-o', 'pid=,pgid=', '-p', [panePid, ...descendants].join(',')],
{ timeout: EXEC_TIMEOUT_MS },
// `ps -p` exits non-zero when some pid is already gone; what it printed still counts.
(err, out) => (out ? resolve({ stdout: out }) : reject(err ?? new Error('ps printed nothing')))
);
});
const groupOf = new Map<number, number>();
for (const line of stdout.split('\n')) {
const [pid, pgid] = line.trim().split(/\s+/).map(Number);
if (Number.isInteger(pid) && Number.isInteger(pgid)) groupOf.set(pid, pgid);
}
const paneGroup = groupOf.get(panePid);
if (paneGroup === undefined) return descendants;
const killable = descendantsInPaneGroup(descendants, groupOf, paneGroup);
if (killable.length < descendants.length) {
console.log(
`[TmuxManager] Sparing ${descendants.length - killable.length} process(es) outside pane ${panePid}'s group (the CLI's shared daemon)`
);
}
return killable;
} catch (err) {
console.warn(`[TmuxManager] Could not read process groups for pane ${panePid}; killing every descendant:`, err);
return descendants;
}
}
// Check if a process is still alive. Signal decisions only: an unreaped zombie // Check if a process is still alive. Signal decisions only: an unreaped zombie
// counts here, so its process group still gets the SIGKILL below. The WAITS use // counts here, so its process group still gets the SIGKILL below. The WAITS use
// waitForProcessesExit(), which counts a zombie as exited. // waitForProcessesExit(), which counts a zombie as exited.
@@ -2960,8 +3005,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
const allPids: number[] = [currentPid]; const allPids: number[] = [currentPid];
// Strategy 1: Kill all child processes recursively // Strategy 1: Kill all child processes recursively (but never a shared daemon the
let childPids = await this.getChildPidsFresh(currentPid); // pane's CLI started for every session of that CLI: see killableDescendants)
let childPids = await this.killableDescendants(session.mode, currentPid, await this.getChildPidsFresh(currentPid));
if (childPids.length > 0) { if (childPids.length > 0) {
console.log(`[TmuxManager] Found ${childPids.length} child processes to kill`); console.log(`[TmuxManager] Found ${childPids.length} child processes to kill`);
allPids.push(...childPids); allPids.push(...childPids);
@@ -2980,7 +3026,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// catch anything spawned since the first one. // catch anything spawned since the first one.
await waitForProcessesExit(childPids, { timeoutMs: TMUX_KILL_WAIT_MS }); await waitForProcessesExit(childPids, { timeoutMs: TMUX_KILL_WAIT_MS });
childPids = await this.getChildPidsFresh(currentPid); childPids = await this.killableDescendants(session.mode, currentPid, await this.getChildPidsFresh(currentPid));
for (const childPid of childPids) { for (const childPid of childPids) {
if (this.isProcessAlive(childPid)) { if (this.isProcessAlive(childPid)) {
try { try {
+1
View File
@@ -1015,6 +1015,7 @@ const MODE_ITEMS: ReadonlyArray<{ id: TuiRunMode; label: string; detail: string
{ id: 'pi', label: 'pi', detail: 'pi.dev' }, { id: 'pi', label: 'pi', detail: 'pi.dev' },
{ id: 'grok', label: 'grok', detail: 'xAI Grok Build' }, { id: 'grok', label: 'grok', detail: 'xAI Grok Build' },
{ id: 'deepseek', label: 'deepseek', detail: 'DeepSeek Harness (dsh)' }, { id: 'deepseek', label: 'deepseek', detail: 'DeepSeek Harness (dsh)' },
{ id: 'jcode', label: 'jcode', detail: 'jcode' },
]; ];
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -155,7 +155,7 @@ export type TuiAnswerResult =
export interface TuiQuickStartOptions { export interface TuiQuickStartOptions {
caseName: string; caseName: string;
mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek'; mode?: 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'jcode';
sessionName?: string; sessionName?: string;
/** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */ /** The tab this spawn came from, for the lineage lines (cosmetic, dropped if unresolvable). */
parentSessionId?: string; parentSessionId?: string;
+26 -4
View File
@@ -8,7 +8,7 @@
* - SessionConfig — creation-time config (id, workingDir, createdAt) * - SessionConfig — creation-time config (id, workingDir, createdAt)
* - SessionOutput — captured stdout/stderr/exitCode * - SessionOutput — captured stdout/stderr/exitCode
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error' * - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' (which CLI backend) * - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'jcode' (which CLI backend)
* - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools') * - ClaudeMode — CLI permission mode ('dangerously-skip-permissions' | 'auto' | 'normal' | 'allowedTools')
* - SessionColor — visual differentiation color * - SessionColor — visual differentiation color
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession) * - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
@@ -18,6 +18,7 @@
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust) * - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue) * - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge) * - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
* - JcodeConfig: jcode settings (model, resume)
* *
* Cross-domain relationships: * Cross-domain relationships:
* - SessionState.respawnConfig embeds RespawnConfig (respawn domain) * - SessionState.respawnConfig embeds RespawnConfig (respawn domain)
@@ -56,7 +57,8 @@ export type SessionMode =
| 'pi' | 'pi'
| 'grok' | 'grok'
| 'deepseek' | 'deepseek'
| 'omp'; | 'omp'
| 'jcode';
/** /**
* Who owns a session's name. `placeholder`: Codeman's own `w<n>-<case>` (or no * Who owns a session's name. `placeholder`: Codeman's own `w<n>-<case>` (or no
@@ -78,7 +80,7 @@ export interface SessionWriteOptions {
export type RemoteCommandMode = Extract< export type RemoteCommandMode = Extract<
SessionMode, SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'jcode'
>; >;
/** /**
@@ -213,7 +215,7 @@ export interface RemoteSessionInfo {
/** Which CLI backends a Docker case can run (same set as remote). */ /** Which CLI backends a Docker case can run (same set as remote). */
export type DockerCommandMode = Extract< export type DockerCommandMode = Extract<
SessionMode, SessionMode,
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'jcode'
>; >;
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */ /** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
@@ -525,6 +527,24 @@ export interface GrokConfig {
resumeSessionId?: string; resumeSessionId?: string;
} }
/**
* jcode (1jehuang/jcode) session configuration.
*
* jcode has no approval prompts and no bypass flag, so there is nothing
* privileged to configure; the provider and its login come from jcode's own
* `~/.jcode/config.toml`. Verified against jcode 0.98.1.
*/
export interface JcodeConfig {
/** Model ID passed via --model (e.g. "claude-opus-4-8", "gpt-5.5"). */
model?: string;
/**
* Resume a jcode session (--resume): its id (`session_fox_<ms>_<hex>`) or its
* short name (`fox`), as ONE plain word starting with a letter or digit, since
* --resume's value is optional and a leading `-` would make it its own flag.
*/
resumeSessionId?: string;
}
/** /**
* DeepSeek Harness (`dsh`) session configuration. * DeepSeek Harness (`dsh`) session configuration.
* *
@@ -897,6 +917,8 @@ export interface SessionState {
deepSeekConfig?: DeepSeekConfig; deepSeekConfig?: DeepSeekConfig;
/** OMP-specific configuration (only for mode === 'omp') */ /** OMP-specific configuration (only for mode === 'omp') */
ompConfig?: OmpConfig; ompConfig?: OmpConfig;
/** jcode configuration (only for mode === 'jcode') */
jcodeConfig?: JcodeConfig;
/** Claude conversation session ID to resume after reboot (set by restore script) */ /** Claude conversation session ID to resume after reboot (set by restore script) */
resumeSessionId?: string; resumeSessionId?: string;
/** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */ /** Claude CLI effort level (soft default via --settings, switchable in-session via /effort) */
+8 -4
View File
@@ -3008,9 +3008,11 @@ class CodemanApp {
? 'DeepSeek' ? 'DeepSeek'
: mode === 'omp' : mode === 'omp'
? 'OMP' ? 'OMP'
: mode === 'opencode' : mode === 'jcode'
? 'OpenCode' ? 'jcode'
: 'Claude'; : mode === 'opencode'
? 'OpenCode'
: 'Claude';
} }
async toggleResponseViewer() { async toggleResponseViewer() {
@@ -10473,7 +10475,9 @@ class CodemanApp {
? 'Kill Tmux & DeepSeek' ? 'Kill Tmux & DeepSeek'
: session.mode === 'omp' : session.mode === 'omp'
? 'Kill Tmux & OMP' ? 'Kill Tmux & OMP'
: 'Kill Tmux & Claude Code'; : session.mode === 'jcode'
? 'Kill Tmux & jcode'
: 'Kill Tmux & Claude Code';
} }
document.getElementById('closeConfirmModal').classList.add('active'); document.getElementById('closeConfirmModal').classList.add('active');
+1
View File
@@ -254,6 +254,7 @@
'Run Grok': '运行 Grok', 'Run Grok': '运行 Grok',
'Run DeepSeek': '运行 DeepSeek', 'Run DeepSeek': '运行 DeepSeek',
'Run OMP': '运行 OMP', 'Run OMP': '运行 OMP',
'Run jcode': '运行 jcode',
'Run Shell': '运行 Shell', 'Run Shell': '运行 Shell',
'More tools': '更多工具', 'More tools': '更多工具',
'Select AI backend': '选择 AI 后端', 'Select AI backend': '选择 AI 后端',
+3 -1
View File
@@ -1078,6 +1078,7 @@
<option value="grok">Grok</option> <option value="grok">Grok</option>
<option value="deepseek">DeepSeek</option> <option value="deepseek">DeepSeek</option>
<option value="omp">OMP</option> <option value="omp">OMP</option>
<option value="jcode">jcode</option>
</select> </select>
</div> </div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div> <div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
@@ -3458,6 +3459,7 @@
<option value="grok" data-cli="grok">Grok</option> <option value="grok" data-cli="grok">Grok</option>
<option value="deepseek" data-cli="deepseek">DeepSeek</option> <option value="deepseek" data-cli="deepseek">DeepSeek</option>
<option value="omp" data-cli="omp">OMP</option> <option value="omp" data-cli="omp">OMP</option>
<option value="jcode" data-cli="jcode">jcode</option>
<option value="shell">Shell (no agent)</option> <option value="shell">Shell (no agent)</option>
</select> </select>
<span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span> <span class="form-hint">Which CLI to point the Run button at once the clone finishes. Changeable any time from the Run dropdown.</span>
@@ -3636,7 +3638,7 @@
<div class="form-row docker-create-only"> <div class="form-row docker-create-only">
<label>Image</label> <label>Image</label>
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false"> <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/opencode/codex/gemini/agy/pi/grok/dsh/omp + tmux.</span> <span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/opencode/codex/gemini/agy/pi/grok/dsh/omp/jcode + tmux.</span>
</div> </div>
<div class="form-row docker-create-only"> <div class="form-row docker-create-only">
<label>Network</label> <label>Network</label>
+1
View File
@@ -58,6 +58,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
{ mode: 'grok', label: 'Grok', short: 'Grok' }, { mode: 'grok', label: 'Grok', short: 'Grok' },
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' }, { mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
{ mode: 'omp', label: 'OMP', short: 'OMP' }, { mode: 'omp', label: 'OMP', short: 'OMP' },
{ mode: 'jcode', label: 'jcode', short: 'jcode' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' }, { mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
]; ];
+20
View File
@@ -1240,6 +1240,20 @@ html.mobile-init .file-browser-panel {
border-color: rgba(129, 140, 248, 0.5) !important; border-color: rgba(129, 140, 248, 0.5) !important;
} }
/* jcode mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek/omp blocks above. */
.btn-toolbar.btn-run.mode-jcode,
.btn-toolbar.btn-run-gear.mode-jcode {
background: #78350f !important;
border-color: rgba(245, 158, 11, 0.3) !important;
color: #fffbeb !important;
}
.btn-toolbar.btn-run.mode-jcode:active,
.btn-toolbar.btn-run-gear.mode-jcode:active {
background: #b45309 !important;
border-color: rgba(245, 158, 11, 0.5) !important;
}
/* Run mode dropdown menu — positioned above toolbar on mobile */ /* Run mode dropdown menu — positioned above toolbar on mobile */
.run-mode-menu { .run-mode-menu {
bottom: 100%; bottom: 100%;
@@ -3349,6 +3363,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
color: #ffffff; color: #ffffff;
} }
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-jcode, .btn-toolbar.btn-run-gear.mode-jcode) {
background: linear-gradient(135deg, #92400e, #d97706);
border-color: #78350f;
color: #ffffff;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear { html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
border-left-color: var(--control-border-hover) !important; border-left-color: var(--control-border-hover) !important;
} }
+1 -1
View File
@@ -533,7 +533,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') { _buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude'; const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP' }; const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP', jcode: 'jcode' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase'; const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return { return {
id: 'new-session', id: 'new-session',
+13
View File
@@ -79,6 +79,15 @@ const RUN_MODE_LAUNCH = {
supportsCustomModel: true, supportsCustomModel: true,
buildConfig: () => null, buildConfig: () => null,
}, },
jcode: {
label: 'jcode',
installHint: 'jcode not found. Install with: curl -fsSL https://jcode.sh/install | bash',
// No custom endpoint recipe yet: jcode's provider lives in its shared daemon, not the pane.
supportsCustomModel: false,
// Deliberately no jcodeConfig: jcode has no approval prompts, so there is no bypass
// switch to opt into (same as pi).
buildConfig: () => null,
},
grok: { grok: {
label: 'Grok', label: 'Grok',
installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash', installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash',
@@ -2493,6 +2502,10 @@ Object.assign(CodemanApp.prototype, {
return this._runCliMode('deepseek'); return this._runCliMode('deepseek');
}, },
async runJcode() {
return this._runCliMode('jcode');
},
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// Session Options Modal // Session Options Modal
+30 -1
View File
@@ -5823,6 +5823,24 @@ html[data-skin="og"] .welcome-primary:hover {
color: #f8faff; color: #f8faff;
} }
/* jcode mode colors. Same cascade note as pi/grok/deepseek above: this base-sheet
pair only renders on the `og` skin, so jcode also carries a rule inside the nested
`html:not([data-skin="og"])` block (search `.btn-toolbar.btn-run.mode-jcode`). */
.btn-toolbar.btn-run.mode-jcode,
.btn-toolbar.btn-run-gear.mode-jcode {
background: linear-gradient(135deg, #451a03 0%, #92400e 55%, #b45309 100%);
border-color: rgba(245, 158, 11, 0.5);
color: #fffbeb;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.btn-toolbar.btn-run.mode-jcode:hover,
.btn-toolbar.btn-run-gear.mode-jcode:hover {
background: linear-gradient(135deg, #5c2405 0%, #b45309 55%, #d97706 100%);
box-shadow: 0 0 12px rgba(245, 158, 11, 0.35), 0 2px 8px rgba(180, 83, 9, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.08);
border-color: rgba(252, 211, 77, 0.6);
color: #ffffff;
}
/* Dropdown menu */ /* Dropdown menu */
.run-mode-menu { .run-mode-menu {
display: none; display: none;
@@ -5934,7 +5952,8 @@ html[data-skin="og"] .welcome-primary:hover {
~/.codeman/clis.json, say) keeps the plain 8px dot. ~/.codeman/clis.json, say) keeps the plain 8px dot.
Logos: @lobehub/icons-static-svg 1.95.1 (MIT, (c) LobeHub); the OMP mark is Logos: @lobehub/icons-static-svg 1.95.1 (MIT, (c) LobeHub); the OMP mark is
the one omp.sh draws in its own header (oh-my-pi, MIT). The marks are their the one omp.sh draws in its own header (oh-my-pi, MIT). The marks are their
owners' trademarks, shown only to name the CLI a row launches. */ owners' trademarks, shown only to name the CLI a row launches. jcode's own mark
is a 60 KB halftone torus, so its slot draws a plain ring in its place. */
.run-mode-dot.claude, .run-mode-dot.claude,
.run-mode-dot.gemini, .run-mode-dot.gemini,
.run-mode-dot.antigravity, .run-mode-dot.antigravity,
@@ -5944,6 +5963,7 @@ html[data-skin="og"] .welcome-primary:hover {
.run-mode-dot.opencode, .run-mode-dot.opencode,
.run-mode-dot.pi, .run-mode-dot.pi,
.run-mode-dot.grok, .run-mode-dot.grok,
.run-mode-dot.jcode,
.run-mode-dot.shell, .run-mode-dot.shell,
.run-mode-dot.web { .run-mode-dot.web {
width: 1.15em; width: 1.15em;
@@ -5964,6 +5984,7 @@ html[data-skin="og"] .welcome-primary:hover {
.run-mode-dot.opencode, .run-mode-dot.opencode,
.run-mode-dot.pi, .run-mode-dot.pi,
.run-mode-dot.grok, .run-mode-dot.grok,
.run-mode-dot.jcode,
.run-mode-dot.shell, .run-mode-dot.shell,
.run-mode-dot.web { .run-mode-dot.web {
background-color: currentColor; background-color: currentColor;
@@ -5979,6 +6000,7 @@ html[data-skin="og"] .welcome-primary:hover {
.run-mode-dot.opencode { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16 6H8v12h8V6zm4 16H4V2h16v20z'/%3E%3C/svg%3E"); } .run-mode-dot.opencode { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16 6H8v12h8V6zm4 16H4V2h16v20z'/%3E%3C/svg%3E"); }
.run-mode-dot.pi { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath clip-rule='evenodd' d='M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z'/%3E%3Cpath d='M17.5 12H23v11h-5.5V12z'/%3E%3C/svg%3E"); } .run-mode-dot.pi { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath clip-rule='evenodd' d='M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z'/%3E%3Cpath d='M17.5 12H23v11h-5.5V12z'/%3E%3C/svg%3E"); }
.run-mode-dot.grok { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815'/%3E%3C/svg%3E"); } .run-mode-dot.grok { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815'/%3E%3C/svg%3E"); }
.run-mode-dot.jcode { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 2a10 10 0 110 20 10 10 0 010-20zm0 6a4 4 0 100 8 4 4 0 000-8z'/%3E%3C/svg%3E"); }
.run-mode-dot.shell { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.5' y='3.5' width='19' height='17' rx='3'/%3E%3Cpath d='M7 9.5l3 2.5-3 2.5M12.5 15h4.5'/%3E%3C/svg%3E"); } .run-mode-dot.shell { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.5' y='3.5' width='19' height='17' rx='3'/%3E%3Cpath d='M7 9.5l3 2.5-3 2.5M12.5 15h4.5'/%3E%3C/svg%3E"); }
.run-mode-dot.web { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20'/%3E%3C/svg%3E"); } .run-mode-dot.web { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20'/%3E%3C/svg%3E"); }
@@ -16400,6 +16422,13 @@ html:not([data-skin="og"]) {
color: #f8faff; color: #f8faff;
} }
.btn-toolbar.btn-run.mode-deepseek:hover { box-shadow: 0 0 14px -2px rgba(77, 107, 254, 0.55); } .btn-toolbar.btn-run.mode-deepseek:hover { box-shadow: 0 0 14px -2px rgba(77, 107, 254, 0.55); }
/* jcode keeps its amber on the non-og skins, same specificity trap as above. */
.btn-toolbar.btn-run.mode-jcode {
background: linear-gradient(135deg, #92400e, #d97706);
border-color: #78350f;
color: #fffbeb;
}
.btn-toolbar.btn-run.mode-jcode:hover { box-shadow: 0 0 14px -2px rgba(245, 158, 11, 0.5); }
.btn-toolbar.btn-run-gear { .btn-toolbar.btn-run-gear {
background: var(--accent-d); background: var(--accent-d);
border-color: var(--accent); border-color: var(--accent);
+26 -2
View File
@@ -1374,6 +1374,8 @@ export function registerSessionRoutes(
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined, antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? gatedPiConfig : undefined, piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
// No bypass switch to clamp (jcode has no approval prompts), so the body's own config.
jcodeConfig: mode === 'jcode' ? launchBody.jcodeConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined, deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig), ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig),
resumeSessionId: validatedResumeId, resumeSessionId: validatedResumeId,
@@ -2819,6 +2821,21 @@ export function registerSessionRoutes(
}; };
} }
// An external CLI with no transcript reader that the pane segmenter cannot read either
// (jcode: a repainting alternate-screen TUI, whose segmented stream served its welcome
// banner as "the answer", measured) has nothing honest to return. Empty, "nothing said
// yet", like the DeepSeek reader above: the Claude scan below would otherwise go looking
// in ~/.claude/projects and could adopt a Claude conversation from the same folder.
// `codeman agent read --source terminal` / MCP `read_output` still read the screen.
{
const caps = getCli(session.mode)?.capabilities;
if (caps?.external && caps.transcript === 'none') {
const emptyQuery = req.query as { context?: string };
const wantsEmptyMessages = emptyQuery.context === 'full' || emptyQuery.context === 'turn';
return wantsEmptyMessages ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
}
}
// Scan ~/.claude/projects/*/ for the transcript file // Scan ~/.claude/projects/*/ for the transcript file
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects'); const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
@@ -3725,6 +3742,7 @@ export function registerSessionRoutes(
antigravityConfig, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
jcodeConfig,
deepSeekConfig, deepSeekConfig,
ompConfig, ompConfig,
envOverrides, envOverrides,
@@ -3796,6 +3814,7 @@ export function registerSessionRoutes(
antigravityConfig || antigravityConfig ||
piConfig || piConfig ||
grokConfig || grokConfig ||
jcodeConfig ||
deepSeekConfig || deepSeekConfig ||
ompConfig || ompConfig ||
openCodeConfig || openCodeConfig ||
@@ -3866,6 +3885,7 @@ export function registerSessionRoutes(
antigravityConfig || antigravityConfig ||
piConfig || piConfig ||
grokConfig || grokConfig ||
jcodeConfig ||
deepSeekConfig || deepSeekConfig ||
ompConfig || ompConfig ||
openCodeConfig || openCodeConfig ||
@@ -3966,6 +3986,7 @@ export function registerSessionRoutes(
antigravityConfig, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
jcodeConfig,
deepSeekConfig, deepSeekConfig,
} as unknown as Record<string, unknown>) } as unknown as Record<string, unknown>)
); );
@@ -4053,7 +4074,7 @@ export function registerSessionRoutes(
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd); writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications // Write .claude/settings.local.json with hooks for desktop notifications
// (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP use their own systems) // (Claude-specific — OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP and jcode use their own systems)
if ( if (
mode !== 'opencode' && mode !== 'opencode' &&
mode !== 'codex' && mode !== 'codex' &&
@@ -4061,7 +4082,8 @@ export function registerSessionRoutes(
mode !== 'antigravity' && mode !== 'antigravity' &&
mode !== 'pi' && mode !== 'pi' &&
mode !== 'grok' && mode !== 'grok' &&
mode !== 'omp' mode !== 'omp' &&
mode !== 'jcode'
) { ) {
await writeHooksConfig(resolvedCasePath); await writeHooksConfig(resolvedCasePath);
} }
@@ -4168,6 +4190,7 @@ export function registerSessionRoutes(
antigravityConfig, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
jcodeConfig,
deepSeekConfig, deepSeekConfig,
ompConfig, ompConfig,
}; };
@@ -4371,6 +4394,7 @@ export function registerSessionRoutes(
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined, antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined, piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined, grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
jcodeConfig: mode === 'jcode' ? qsLaunchConfigs.jcodeConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined, deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
ompConfig: qsResolvedOmpConfig, ompConfig: qsResolvedOmpConfig,
envOverrides: qsCustomModelEnvOverrides, envOverrides: qsCustomModelEnvOverrides,
+14
View File
@@ -528,6 +528,20 @@ export function registerSystemRoutes(
}; };
}); });
// ========== jcode ==========
// The sibling shape (path + version), answered by the GENERIC registry resolver rather than a
// module of its own: it is what a jcode spawn resolves the binary with, and it already applies
// the entry's `jcode v<semver>` version anchor (npm carries an unrelated `jcode` package).
app.get('/api/jcode/status', async () => {
const { isCliAvailable, resolveCliBinDir, resolveCliVersion } = await import('../../utils/cli-resolver.js');
return {
available: isCliAvailable('jcode'),
path: resolveCliBinDir('jcode'),
version: resolveCliVersion('jcode'),
};
});
// ========== DeepSeek Harness ========== // ========== DeepSeek Harness ==========
// The widest of the per-CLI status shapes, because this mode has the widest // The widest of the per-CLI status shapes, because this mode has the widest
+27
View File
@@ -439,6 +439,30 @@ const GrokConfigSchema = z
}) })
.optional(); .optional();
/**
* Schema for jcode (1jehuang/jcode)-specific configuration.
*
* jcode has no approval prompts and no bypass flag, so there is nothing for the
* multi-user clamp to touch.
*/
const JcodeConfigSchema = z
.object({
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9._\-/]+$/)
.optional(),
// A session id (`session_fox_<ms>_<hex>`) or short name (`fox`). Leading
// alphanumeric: --resume's value is optional, so a value starting with `-`
// would be parsed as a flag of its own.
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
.optional(),
})
.optional();
/** /**
* Schema for OMP CLI-specific configuration. * Schema for OMP CLI-specific configuration.
*/ */
@@ -596,6 +620,7 @@ export const CreateSessionSchema = z.object({
antigravityConfig: AntigravityConfigSchema, antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema, piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema, grokConfig: GrokConfigSchema,
jcodeConfig: JcodeConfigSchema,
deepSeekConfig: DeepSeekConfigSchema, deepSeekConfig: DeepSeekConfigSchema,
ompConfig: OmpConfigSchema, ompConfig: OmpConfigSchema,
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */ /** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
@@ -807,6 +832,7 @@ const RemoteCommandOverridesSchema = z
pi: z.string().min(1).max(300).optional(), pi: z.string().min(1).max(300).optional(),
grok: z.string().min(1).max(300).optional(), grok: z.string().min(1).max(300).optional(),
deepseek: z.string().min(1).max(300).optional(), deepseek: z.string().min(1).max(300).optional(),
jcode: z.string().min(1).max(300).optional(),
}) })
.strict() .strict()
.optional(); .optional();
@@ -1195,6 +1221,7 @@ export const QuickStartSchema = z.object({
antigravityConfig: AntigravityConfigSchema, antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema, piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema, grokConfig: GrokConfigSchema,
jcodeConfig: JcodeConfigSchema,
deepSeekConfig: DeepSeekConfigSchema, deepSeekConfig: DeepSeekConfigSchema,
ompConfig: OmpConfigSchema, ompConfig: OmpConfigSchema,
envOverrides: safeEnvOverridesSchema, envOverrides: safeEnvOverridesSchema,
+1
View File
@@ -3590,6 +3590,7 @@ export class WebServer extends EventEmitter {
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined, antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined, piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined, grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
jcodeConfig: muxSession.mode === 'jcode' ? savedState?.jcodeConfig : undefined,
deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined, deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined,
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined, ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
envOverrides: savedEnvOverrides, envOverrides: savedEnvOverrides,
+2 -1
View File
@@ -36,6 +36,7 @@ describe('per-mode capability predicates', () => {
['grok', true, false, false], ['grok', true, false, false],
['deepseek', true, false, true], ['deepseek', true, false, true],
['omp', true, false, false], ['omp', true, false, false],
['jcode', true, false, false],
] as Array<[SessionMode, boolean, boolean, boolean]>)( ] as Array<[SessionMode, boolean, boolean, boolean]>)(
'%s: external=%s altScreenStrip=%s hooks=%s', '%s: external=%s altScreenStrip=%s hooks=%s',
(mode, external, altScreen, hooks) => { (mode, external, altScreen, hooks) => {
@@ -48,7 +49,7 @@ describe('per-mode capability predicates', () => {
it('covers every enabled mode (sanity)', () => { it('covers every enabled mode (sanity)', () => {
// If a CLI is added without a row above, this fails rather than the table silently // If a CLI is added without a row above, this fails rather than the table silently
// describing a subset of reality. // describing a subset of reality.
expect(MODES.length).toBe(10); expect(MODES.length).toBe(11);
}); });
it('keeps the three predicates genuinely distinct', () => { it('keeps the three predicates genuinely distinct', () => {
+4 -1
View File
@@ -96,6 +96,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'), "web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'), "web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'), "web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'jcode'": allow(2, 'legacy <Mode>Config plumbing'),
"web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'), "web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'), "web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'gemini'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'), "web/server.ts::mode === 'gemini'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
@@ -104,6 +105,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/server.ts::mode === 'grok'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'), "web/server.ts::mode === 'grok'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'), "web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'), "web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'jcode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
// --- Claude's remote/docker command construction --- // --- Claude's remote/docker command construction ---
"tmux-manager.ts::mode === 'claude'": allow( "tmux-manager.ts::mode === 'claude'": allow(
@@ -173,7 +175,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
'chain (see the opencode entry below)' 'chain (see the opencode entry below)'
), ),
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its // ⚠️ Scaffolded-case hooks. This chain excludes eight CLIs but NOT `deepseek`, while its
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a // own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this // Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
// change; expressing the chain as a capability would have to pick a side and would // change; expressing the chain as a capability would have to pick a side and would
@@ -188,6 +190,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/routes/session-routes.ts::mode !== 'antigravity'": allow(1, 'scaffolded-case hooks (see the opencode entry)'), "web/routes/session-routes.ts::mode !== 'antigravity'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'pi'": allow(1, 'scaffolded-case hooks (see the opencode entry)'), "web/routes/session-routes.ts::mode !== 'pi'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'grok'": allow(1, 'scaffolded-case hooks (see the opencode entry)'), "web/routes/session-routes.ts::mode !== 'grok'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'jcode'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
}; };
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */ /** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
+3 -3
View File
@@ -486,9 +486,9 @@ describe('capability shapes', () => {
}); });
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => { it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex and opencode (both on the alternate screen) were // claude (repaint mode), codex, opencode and jcode (all three on the alternate screen)
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been. // were measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string); const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']); expect(paging.sort()).toEqual(['claude', 'codex', 'jcode', 'opencode']);
}); });
}); });
+21
View File
@@ -254,6 +254,27 @@ describe('grok', () => {
}); });
}); });
describe('jcode', () => {
const jc = (jcodeConfig?: SpawnBridgeOptions['jcodeConfig']) => render({ mode: 'jcode', sessionId: SID, jcodeConfig });
it('spawns bare by default: no bypass flag exists to send', () => {
expect(jc()).toBe('jcode');
});
it('passes --model and --resume', () => {
expect(jc({ model: 'claude-opus-4-8' })).toBe('jcode --model claude-opus-4-8');
expect(jc({ resumeSessionId: 'session_fox_1792160401272_f5b47ce47d631006' })).toBe(
'jcode --resume session_fox_1792160401272_f5b47ce47d631006'
);
expect(jc({ model: 'gpt-5.5', resumeSessionId: 'fox' })).toBe('jcode --model gpt-5.5 --resume fox');
});
it('drops a resume value that would read as a flag of its own', () => {
// --resume's value is optional in jcode, so `--resume --no-update` is two flags.
expect(jc({ resumeSessionId: '--no-update' })).toBe('jcode');
});
});
describe('deepseek', () => { describe('deepseek', () => {
const ds = (deepSeekConfig?: SpawnBridgeOptions['deepSeekConfig']) => const ds = (deepSeekConfig?: SpawnBridgeOptions['deepSeekConfig']) =>
render({ mode: 'deepseek', sessionId: SID, deepSeekConfig }); render({ mode: 'deepseek', sessionId: SID, deepSeekConfig });
+4
View File
@@ -86,6 +86,9 @@ const LITERAL_SEARCH_PATHS: Record<string, string[]> = {
'$HOME/.npm-global/bin/omp', '$HOME/.npm-global/bin/omp',
'$HOME/bin/omp', '$HOME/bin/omp',
], ],
// Not from a hand-written array: jcode arrived after the generated block replaced them, so
// this is the registry's own list (the installer's ~/.local/bin target, then the image's).
JCODE: ['$HOME/.local/bin/jcode', '/usr/local/bin/jcode', '$HOME/bin/jcode'],
}; };
/** Array prefix in `install.sh` -> registry id, for the two that differ. */ /** Array prefix in `install.sh` -> registry id, for the two that differ. */
@@ -99,6 +102,7 @@ const ARRAY_PREFIX_TO_CLI_ID: Record<string, string> = {
GROK: 'grok', GROK: 'grok',
ANTIGRAVITY: 'antigravity', ANTIGRAVITY: 'antigravity',
OMP: 'omp', OMP: 'omp',
JCODE: 'jcode',
}; };
/** /**
+259
View File
@@ -0,0 +1,259 @@
/**
* @fileoverview jcode (1jehuang/jcode) as a run mode: its wire config, its spawn command,
* its capability flags, and the screen-reading patterns that tell a working jcode pane from
* one at rest.
*
* The pane fixtures are verbatim captures (`tmux capture-pane -p`, rows shortened to their
* tail) and verbatim tmux CLIENT stream chunks, both from a live jcode v0.98.1 pane driven
* through real turns on 2026-10-16: a plain reply, a bash tool call, and a streamed answer.
* The client stream matters because that is what Codeman's PTY reads, and tmux redraws only
* the cells that changed, so the composer glyph has to survive that diffing.
*
* Port: none (pure functions plus a Session with a scripted fake pane).
*/
import { describe, expect, it, afterEach, vi } from 'vitest';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
import { defaultRemoteCommandForMode } from '../src/remote-hosts.js';
import { Session, isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
import { hooksAvailableForMode } from '../src/web/session-wait-registry.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
import { promptStillInComposer } from '../src/session-submit-verifier.js';
import { isExternalCliTranscriptMode } from '../src/web/response-viewer-transcript.js';
const jcode = getCli('jcode');
const workDetect = jcode?.capabilities.workDetect;
const workingLine = compileVersionRegex(workDetect?.workingLine ?? '');
/** The composer and the status bar under it, as every capture ends (bar shortened). */
const FOOTER_REST = '\n5>\n …/scratchpad/jwork master ?1 17k/252k ▰▱▱▱▱▱▱▱▱▱ 7% qwen5090 qwen3.8 27b\n';
const FOOTER_BUSY = '\n5…\n …/scratchpad/jwork master ?1 17k/252k ▰▱▱▱▱▱▱▱▱▱ 7% qwen5090 qwen3.8 27b\n';
const PROMPT_ROW = ' 4› Use your bash tool to run exactly: sleep 8; echo slept. Then write a 40 word paragraph.\n\n';
/** One status row per phase a turn goes through, each verbatim from a capture. */
const WORKING_ROWS = [
'⠋ sending… 0s',
'⠦ thinking… 2s · https',
'⠼ waiting for response… 3s · https',
'⠧ running bash · Run user-requested sleep probe · https · 2s · Alt+B bg',
'⠙ 11s · 182.7 tps · ↑17k ↓156 · https',
];
const atRest =
`${PROMPT_ROW} The command ran and printed slept after 8 seconds.\n\n` +
` Donuts are ring-shaped treats, glazed with sugary sweetness.\n` +
// The turn's summary row: a time, but no spinner, so it must never read as work.
` 21s · 199.6 tps · ↑17k ↓2k\n${FOOTER_REST}`;
/**
* The tmux client stream (what Codeman's PTY reads). Mid-turn it is the spinner cell being
* repainted on its own; the turn's end repaints the composer row, and the only cell there
* that changes is `…` turning into `>` (the prompt number is not resent).
*/
const SPINNER_CHUNK = '\x1b[38;1H\x1b[38;5;114m⠏\x1b[39;4H\x1b(B\x1b[m';
const TURN_END_CHUNK =
'\x1b[40;2H\x1b[38;5;251mDONE\x1b[41;2H\x1b[39m \x1b[38;5;239m10s · 189.1 tps · ↑16k ↓15\r\n' +
'\x1b[39m \x1b[43;2H\x1b[38;5;111m> \x1b(B\x1b[m';
describe('jcode wire config', () => {
it('accepts a model and a resume id or short name on both create paths', () => {
const created = CreateSessionSchema.parse({
workingDir: '/tmp',
mode: 'jcode',
jcodeConfig: { model: 'claude-opus-4-8', resumeSessionId: 'session_fox_1792160401272_f5b47ce47d631006' },
});
expect(created.jcodeConfig?.resumeSessionId).toBe('session_fox_1792160401272_f5b47ce47d631006');
const quick = QuickStartSchema.parse({ caseName: 'jc', mode: 'jcode', jcodeConfig: { resumeSessionId: 'fox' } });
expect(quick.jcodeConfig?.resumeSessionId).toBe('fox');
});
it('refuses a resume value that would parse as a flag, and an unsafe model', () => {
// --resume takes an OPTIONAL value, so `--resume --no-update` would read as two flags.
for (const resumeSessionId of ['--no-update', '-x', 'fox; rm -rf ~', 'a b']) {
expect(
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'jcode', jcodeConfig: { resumeSessionId } }).success,
resumeSessionId
).toBe(false);
}
expect(
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'jcode', jcodeConfig: { model: 'a`b' } }).success
).toBe(false);
});
it('admits no JCODE_* env override', () => {
// Most of that namespace is read by jcode's shared daemon, hooks and command paths
// included, so a per-session value would either do nothing or reach every session.
expect(
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'jcode', envOverrides: { JCODE_HOME: '/tmp/x' } })
.success
).toBe(false);
expect(jcode?.env.allowedPrefixes).toEqual([]);
});
});
describe('jcode spawn command', () => {
const spawn = (jcodeConfig?: Parameters<typeof buildSpawnCommand>[0]['jcodeConfig']) =>
buildSpawnCommand({ mode: 'jcode', sessionId: 'abc12345', jcodeConfig });
it('spawns bare, then with --model and --resume when asked', () => {
expect(spawn()).toBe('jcode');
expect(spawn({ model: 'gpt-5.5' })).toBe('jcode --model gpt-5.5');
expect(spawn({ resumeSessionId: 'session_fox_1792160401272_f5b47ce47d631006' })).toBe(
'jcode --resume session_fox_1792160401272_f5b47ce47d631006'
);
expect(spawn({ model: 'gpt-5.5', resumeSessionId: 'fox' })).toBe('jcode --model gpt-5.5 --resume fox');
});
it('drops a value the schema would have refused rather than emit it', () => {
// Defence in depth behind the schema: the argv engine re-checks every token.
expect(spawn({ resumeSessionId: '--no-update' })).toBe('jcode');
expect(spawn({ model: 'a`b' })).toBe('jcode');
});
it('has remote and docker pane commands', () => {
expect(defaultDockerCommandForMode('jcode')).toBe('exec jcode');
expect(defaultRemoteCommandForMode('jcode')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'jcode\'');
});
});
describe('jcode capabilities', () => {
it('is an external CLI with no hooks, its own strip, and no agent skill', () => {
expect(isExternalCliMode('jcode')).toBe(true);
expect(isAltScreenStripMode('jcode')).toBe(false);
expect(hooksAvailableForMode('jcode')).toBe(false);
expect(jcode?.capabilities.altScreen).toBe('strip-mux-and-mouse');
expect(jcode?.capabilities.transcriptPageKeys).toBe(true);
// Its tools run in the shared daemon, under whichever session started it, so an
// injected skill would act as that session.
expect(jcode?.capabilities.agentSkillInjection).toBe(false);
expect(jcode?.capabilities.privilegedParams).toEqual([]);
});
it('anchors its version probe on the `jcode v` banner', () => {
const version = jcode?.discovery.version;
expect(version?.requireVersionMatch).toBe(true);
const re = compileVersionRegex(version?.regex ?? '');
expect(re?.exec('jcode v0.98.1 (1c7426eb1)')?.[1]).toBe('0.98.1');
// The unrelated npm package of the same name, or any other `jcode`, is absent.
expect(re?.exec('1.0.0')).toBeNull();
});
it('is never segmented off its pane by the response viewer', () => {
expect(isExternalCliTranscriptMode('jcode')).toBe(false);
});
});
describe("jcode's work-detection descriptor", () => {
it('compiles through the regex guard', () => {
expect(workingLine).not.toBeNull();
});
it('matches every status row a turn shows, in a full capture', () => {
for (const row of WORKING_ROWS) {
expect(workingLine!.test(`${PROMPT_ROW}${row}${FOOTER_BUSY}`), row).toBe(true);
}
});
it('reads past a minute and an hour, a dropped network, and a centred layout', () => {
expect(workingLine!.test(`${PROMPT_ROW}⠸ thinking… 1m 3s · https${FOOTER_BUSY}`)).toBe(true);
expect(workingLine!.test(`${PROMPT_ROW}⠸ running bash · build · 1h 2m · Alt+B bg${FOOTER_BUSY}`)).toBe(true);
expect(
workingLine!.test(`${PROMPT_ROW}↻ network disconnected, waiting to retry · sse · 5s${FOOTER_BUSY}`)
).toBe(true);
expect(workingLine!.test(`${PROMPT_ROW} ⠸ thinking… 4s${FOOTER_BUSY}`)).toBe(true);
});
it('does not match a pane at rest, its summary row, or a braille glyph in the transcript', () => {
expect(workingLine!.test(atRest)).toBe(false);
expect(workingLine!.test(`${PROMPT_ROW} the spinner looked like ⠋ for 3s\n${FOOTER_REST}`)).toBe(false);
});
it('arms the idle check from the turn-ending repaint, and nothing mid-turn carries the glyph', () => {
expect(TURN_END_CHUNK).toContain(workDetect!.promptGlyph);
expect(SPINNER_CHUNK).not.toContain(workDetect!.promptGlyph);
});
it('leaves the submit verifier inert: no screen row starts with the glyph', () => {
// The composer row is `<n>> …`, so the verifier finds no composer line and never presses
// Enter on its own (jcode has no early-Enter defect for it to work around).
const typed = `${PROMPT_ROW}${FOOTER_REST.replace('5>', '5> say ok')}`;
expect(promptStillInComposer(typed, 'say ok', workDetect!.promptGlyph)).toBeUndefined();
expect(promptStillInComposer(atRest, 'say ok', workDetect!.promptGlyph)).toBeUndefined();
});
});
describe('jcode interactive idle detection', () => {
afterEach(() => {
vi.useRealTimers();
});
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
function withFakePane(read: () => string): Session {
const mux = {
isAvailable: () => true,
capturePaneText: () => read(),
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode: 'jcode',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/** The spinner cell repainted every 80 ms, as jcode's ~12 fps indicator does. */
function spin(session: Session, ms: number): void {
for (let t = 0; t < ms; t += 80) {
feed(session, SPINNER_CHUNK);
vi.advanceTimersByTime(80);
}
}
it('marks a turn working off its spinner and ends it on the composer repaint', () => {
vi.useFakeTimers();
let screen = `${PROMPT_ROW}${WORKING_ROWS[3]}${FOOTER_BUSY}`;
const session = withFakePane(() => screen);
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
spin(session, 8000);
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
screen = atRest;
feed(session, TURN_END_CHUNK);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working', 'idle']);
expect(session.status).toBe('idle');
});
it('does not end a turn while the status row is still on screen', () => {
vi.useFakeTimers();
// A quiet pane whose screen still shows the spinner row (a stalled stream): the
// screen says working, so silence alone must not conclude idle.
const screen = `${PROMPT_ROW}${WORKING_ROWS[2]}${FOOTER_BUSY}`;
const session = withFakePane(() => screen);
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
spin(session, 4000);
feed(session, TURN_END_CHUNK);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
});
});
+2
View File
@@ -36,6 +36,7 @@ it('pins every remote pane command', () => {
grok: remoteLoginShellCommand('grok'), grok: remoteLoginShellCommand('grok'),
deepseek: remoteLoginShellCommand('dsh'), deepseek: remoteLoginShellCommand('dsh'),
omp: remoteLoginShellCommand('omp'), omp: remoteLoginShellCommand('omp'),
jcode: remoteLoginShellCommand('jcode'),
}; };
for (const [mode, want] of Object.entries(expected)) { for (const [mode, want] of Object.entries(expected)) {
expect(defaultRemoteCommandForMode(mode as SessionMode), mode).toBe(want); expect(defaultRemoteCommandForMode(mode as SessionMode), mode).toBe(want);
@@ -55,6 +56,7 @@ it('pins every in-container pane command', () => {
grok: 'exec grok', grok: 'exec grok',
deepseek: 'exec dsh', deepseek: 'exec dsh',
omp: 'exec omp', omp: 'exec omp',
jcode: 'exec jcode',
}; };
for (const [mode, want] of Object.entries(expected)) { for (const [mode, want] of Object.entries(expected)) {
expect(defaultDockerCommandForMode(mode as SessionMode), mode).toBe(want); expect(defaultDockerCommandForMode(mode as SessionMode), mode).toBe(want);
+1
View File
@@ -469,6 +469,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
'grok', 'grok',
'deepseek', 'deepseek',
'omp', 'omp',
'jcode',
'shell', 'shell',
]); ]);
}); });
+45 -1
View File
@@ -15,7 +15,13 @@
*/ */
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { collectDescendants, PROC_WALK_MAX_DEPTH, PROC_WALK_MAX_NODES } from '../src/proc-tree.js'; import {
collectDescendants,
descendantsInPaneGroup,
PROC_WALK_MAX_DEPTH,
PROC_WALK_MAX_NODES,
} from '../src/proc-tree.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
/** Build a parent→children map from `[parent, child]` pairs. */ /** Build a parent→children map from `[parent, child]` pairs. */
function tree(pairs: [number, number][]): Map<number, number[]> { function tree(pairs: [number, number][]): Map<number, number[]> {
@@ -198,3 +204,41 @@ describe('TmuxManager uses the bounded walk', () => {
expect(out.length).toBeLessThanOrEqual(PROC_WALK_MAX_NODES); expect(out.length).toBeLessThanOrEqual(PROC_WALK_MAX_NODES);
}); });
}); });
describe('descendantsInPaneGroup (a shared daemon outlives the pane that started it)', () => {
// The jcode shape, measured: pane 100 runs the client (group 100); the client spawned the
// daemon 200, which setsid()'d into group 200 and runs tools 300 and 301 for EVERY jcode
// session; 110 is an ordinary child of the client.
const descendants = collectDescendants(
100,
tree([
[100, 110],
[100, 200],
[200, 300],
[200, 301],
])
);
const groups = new Map([
[100, 100],
[110, 100],
[200, 200],
[300, 200],
[301, 200],
]);
it("keeps the pane's own group and spares the daemon with its whole subtree", () => {
expect(descendants.sort()).toEqual([110, 200, 300, 301]);
expect(descendantsInPaneGroup(descendants, groups, 100)).toEqual([110]);
});
it('kills a pid whose group could not be read (it most likely exited already)', () => {
const partial = new Map(groups);
partial.delete(110);
expect(descendantsInPaneGroup(descendants, partial, 100)).toEqual([110]);
});
it('is declared by jcode alone, so every other CLI keeps the full descendant kill', () => {
const declaring = STOCK_CLIS.filter((e) => e.capabilities.sharedDaemon === true).map((e) => e.id as string);
expect(declaring).toEqual(['jcode']);
});
});
+3
View File
@@ -225,6 +225,9 @@ describe('WebServer.renderIndexHtml', () => {
deepseek: false, deepseek: false,
deepseekBinary: false, deepseekBinary: false,
omp: true, omp: true,
// No per-CLI resolver module: the registry's generic one answers, and it resolves
// nothing under vitest (its probes would otherwise run whatever `jcode` is on PATH).
jcode: false,
cloudflared: true, cloudflared: true,
git: true, git: true,
shell: true, shell: true,
@@ -154,6 +154,24 @@ describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
expect(body.data.hasContext).toBe(false); expect(body.data.hasContext).toBe(false);
}); });
it('answers jcode with an honest empty, never a segmented pane', async () => {
// jcode is a repainting alternate-screen TUI: segmenting its stream served the
// welcome banner as "the answer" (measured on a live pane), and the Claude scan
// below the external branch could adopt a Claude conversation from the same folder.
// It has no transcript reader yet, so "nothing said yet" is the only honest reply.
session.mode = 'jcode';
session.terminalBuffer = PANE;
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(body.data.text).toBe('');
expect(body.data.messages).toBeUndefined();
const full = await lastResponse(true);
expect(full.body.data.text).toBe('');
expect(full.body.data.messages).toEqual([]);
});
it('leaves Claude mode on the Claude transcript path', async () => { it('leaves Claude mode on the Claude transcript path', async () => {
// Regression guard: a claude pane must NOT be segmented off its terminal // Regression guard: a claude pane must NOT be segmented off its terminal
// buffer, or a real transcript would be shadowed by scraped pane text. // buffer, or a real transcript would be shadowed by scraped pane text.