mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a876731f6 | ||
|
|
86502b6904 | ||
|
|
9a1bed449d | ||
|
|
43a86eee39 | ||
|
|
404930d55e | ||
|
|
4bde072355 | ||
|
|
ad5455044a | ||
|
|
db6fb53f5e | ||
|
|
f3087a12c0 | ||
|
|
0c3a70676e | ||
|
|
ccb76c3325 | ||
|
|
01b4150672 |
No files matched your search
@@ -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`.
|
||||||
@@ -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`), OMP (`omp`) and GitHub Copilot (`copilot`, npm `@github/copilot`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'`).
|
**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`), GitHub Copilot (`copilot`, npm `@github/copilot`) and jcode (`jcode`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot' | '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`.
|
||||||
|
|
||||||
@@ -182,7 +182,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.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `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` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), 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.workDetect.composerFooterLine`, `capabilities.workDetect.composerPopupLine`, `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` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), 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, Copilot)**: `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. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [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, Copilot, 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 ten **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. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). ⚠️ **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](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
|
||||||
|
|
||||||
**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)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,13 @@
|
|||||||
<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 • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • Terminal - One Dashboard • Any Device</em>
|
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • jcode • Terminal - One Dashboard • Any Device</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg"><img src="https://img.shields.io/badge/Download_for_Mac-Codeman.dmg-2f6fe0?style=for-the-badge&logo=apple&logoColor=white&labelColor=000000" alt="Download Codeman for Mac (Codeman.dmg)" width="354"></a>
|
||||||
|
<br>
|
||||||
|
<sub>The native Mac app: signed and notarized, Apple Silicon and Intel, macOS 11.3 or later. <a href="#on-a-mac-the-codeman-app">What it does</a></sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -17,7 +23,6 @@
|
|||||||
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
||||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
||||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||||
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -28,7 +33,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, OMP, or GitHub Copilot CLI 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, GitHub Copilot CLI, 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):
|
||||||
|
|
||||||
@@ -45,7 +50,7 @@ The installer asks before every system change, and re-running the same line upda
|
|||||||
|
|
||||||
On a Mac you can skip the terminal: [download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg), and the app installs Codeman for you, opens the one already running, or connects to one on another machine. More in [On a Mac: the Codeman app](#on-a-mac-the-codeman-app).
|
On a Mac you can skip the terminal: [download Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg), and the app installs Codeman for you, opens the one already running, or connects to one on another machine. More in [On a Mac: the Codeman app](#on-a-mac-the-codeman-app).
|
||||||
|
|
||||||
- **One dashboard, ten CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, or GitHub Copilot](#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, eleven CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, GitHub Copilot, 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
|
||||||
@@ -74,7 +79,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
|
|||||||
- **No administrator rights needed.** On an account without `sudo`, one whose `sudo` rights the installer cannot confirm (outside the `sudo`, `wheel` and `admin` groups, with a password still required) or a Mac account that is not an administrator and has no Homebrew it can write to, the installer downloads Node.js from nodejs.org and the tmux project's own build into `~/.codeman/runtime`, each checked against its SHA-256, and takes the source as an archive when there is no working git. `--no-admin` forces that route and `CODEMAN_NO_ADMIN=0` turns it off. Two limits: Linux needs make, a C++ compiler and python3 to be there already, and the tmux download needs macOS 15 or newer. Details in the [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights).
|
- **No administrator rights needed.** On an account without `sudo`, one whose `sudo` rights the installer cannot confirm (outside the `sudo`, `wheel` and `admin` groups, with a password still required) or a Mac account that is not an administrator and has no Homebrew it can write to, the installer downloads Node.js from nodejs.org and the tmux project's own build into `~/.codeman/runtime`, each checked against its SHA-256, and takes the source as an archive when there is no working git. `--no-admin` forces that route and `CODEMAN_NO_ADMIN=0` turns it off. Two limits: Linux needs make, a C++ compiler and python3 to be there already, and the tmux download needs macOS 15 or newer. Details in the [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights).
|
||||||
- **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), [OMP](https://github.com/can1357/oh-my-pi), or [GitHub Copilot CLI](https://github.com/github/copilot-cli) (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:
|
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), [GitHub Copilot CLI](https://github.com/github/copilot-cli), 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 eleven 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
|
||||||
@@ -285,7 +290,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`, `Copilot`, or `Terminal` (plain shell). |
|
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, `Copilot`, `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`. |
|
||||||
|
|
||||||
@@ -490,7 +495,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
|||||||
- **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.
|
||||||
- **Open a tmux session you started by hand**: a tmux session started outside Codeman shows up on the home screen, on this machine automatically and in your Docker cases' containers or on your saved SSH hosts when you scan for them. One click opens it as a tab. Closing the tab only detaches, and Codeman will not respawn or relaunch an agent it did not start. See [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman).
|
- **Open a tmux session you started by hand**: a tmux session started outside Codeman shows up on the home screen, on this machine automatically and in your Docker cases' containers or on your saved SSH hosts when you scan for them. One click opens it as a tab. Closing the tab only detaches, and Codeman will not respawn or relaunch an agent it did not start. See [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman).
|
||||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, or **GitHub Copilot CLI** 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_*` vs `COPILOT_*`). 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/copilot-integration.md`](docs/copilot-integration.md)
|
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, **GitHub Copilot CLI**, 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_*` vs `COPILOT_*`). 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), [`docs/copilot-integration.md`](docs/copilot-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)
|
||||||
- **A model on this Mac** _(experimental, new in 1.50.0)_: on a Mac with Apple silicon, **Run a local model** on the welcome screen shows which coding model fits, installs it with its runtime and a coding agent, and starts a session on it: no account, no API key, and the prompts and files of that session go to a model on this Mac, not to a cloud provider. Then mix cloud and local: through the [codeman skill](#the-agent-skill-start-here) a Claude Code session hands work to that model (`spawn_worker <name> local`, `sendmark`) and reads the answer back, so Claude plans and reviews in the cloud while the Mac does the bulk: summaries, first drafts, files that should stay here. See [`docs/local-models.md`](docs/local-models.md)
|
- **A model on this Mac** _(experimental, new in 1.50.0)_: on a Mac with Apple silicon, **Run a local model** on the welcome screen shows which coding model fits, installs it with its runtime and a coding agent, and starts a session on it: no account, no API key, and the prompts and files of that session go to a model on this Mac, not to a cloud provider. Then mix cloud and local: through the [codeman skill](#the-agent-skill-start-here) a Claude Code session hands work to that model (`spawn_worker <name> local`, `sendmark`) and reads the answer back, so Claude plans and reviews in the cloud while the Mac does the bulk: summaries, first drafts, files that should stay here. See [`docs/local-models.md`](docs/local-models.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)
|
||||||
@@ -972,7 +977,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/copilot) have no transcript:
|
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp/copilot/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.
|
||||||
@@ -1213,7 +1218,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 / GitHub Copilot</small>"]
|
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot / jcode</small>"]
|
||||||
BG["Background Agents<br/><small>(Task tool)</small>"]
|
BG["Background Agents<br/><small>(Task tool)</small>"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+14
-9
@@ -5,7 +5,13 @@
|
|||||||
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
<h2 align="center">AI 编程智能体的任务控制中心</h2>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • 终端 —— 统一仪表盘 • 任意设备</em>
|
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • jcode • 终端 —— 统一仪表盘 • 任意设备</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg"><img src="https://img.shields.io/badge/%E4%B8%8B%E8%BD%BD_Mac_%E7%89%88-Codeman.dmg-2f6fe0?style=for-the-badge&logo=apple&logoColor=white&labelColor=000000" alt="下载 Mac 版 Codeman(Codeman.dmg)" width="281"></a>
|
||||||
|
<br>
|
||||||
|
<sub>原生 Mac 应用:已签名并经过 Apple 公证,支持 Apple 芯片和 Intel,macOS 11.3 或更高版本。<a href="#在-mac-上codeman-应用">它能做什么</a></sub>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -21,7 +27,6 @@
|
|||||||
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
<a href="https://github.com/Ark0N/Codeman/stargazers"><img src="https://img.shields.io/github/stars/Ark0N/Codeman?style=flat-square&color=eab308" alt="GitHub stars"></a>
|
||||||
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
<a href="https://github.com/Ark0N/Codeman/graphs/contributors"><img src="https://img.shields.io/github/contributors/Ark0N/Codeman?style=flat-square&color=3b82f6" alt="Contributors"></a>
|
||||||
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
<a href="https://github.com/Ark0N/Codeman/commits/master"><img src="https://img.shields.io/github/commit-activity/t/Ark0N/Codeman?style=flat-square&color=1e3a5f" alt="Total commits"></a>
|
||||||
<a href="https://github.com/NipunaRanasinghe/awesome-ai-agents"><img src="https://img.shields.io/badge/Featured_in-Awesome_AI_Agents-2ea44f?style=flat-square" alt="Featured in Awesome AI Agents"></a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -30,7 +35,7 @@
|
|||||||
|
|
||||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||||
|
|
||||||
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness、OMP 或 GitHub Copilot CLI,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
|
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness、OMP、GitHub Copilot CLI 或 jcode,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
|
||||||
|
|
||||||
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
|
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
|
||||||
|
|
||||||
@@ -47,7 +52,7 @@ codeman web
|
|||||||
|
|
||||||
在 Mac 上可以不用终端:[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg),这个应用会替你安装 Codeman、打开已在运行的 Codeman,或者连接另一台机器上的 Codeman。详见[在 Mac 上:Codeman 应用](#在-mac-上codeman-应用)。
|
在 Mac 上可以不用终端:[下载 Codeman.dmg](https://github.com/Ark0N/Codeman/releases/latest/download/Codeman.dmg),这个应用会替你安装 Codeman、打开已在运行的 Codeman,或者连接另一台机器上的 Codeman。详见[在 Mac 上:Codeman 应用](#在-mac-上codeman-应用)。
|
||||||
|
|
||||||
- **一个仪表盘,十个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP 或 GitHub Copilot](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
|
- **一个仪表盘,十一个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP、GitHub Copilot 或 jcode](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
|
||||||
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
|
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
|
||||||
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
|
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
|
||||||
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
|
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
|
||||||
@@ -76,7 +81,7 @@ curl -fsSL https://getcodeman.com/install | bash
|
|||||||
- **无需管理员权限。** 在没有 `sudo` 的账户、安装器无法确认 `sudo` 权限的账户(不在 `sudo`、`wheel`、`admin` 组内,且 `sudo` 需要密码)或不是管理员、也没有可写入的 Homebrew 的 Mac 账户上,安装器会把 nodejs.org 的 Node.js 和 tmux 项目自己的构建下载到 `~/.codeman/runtime`,两者都会校验 SHA-256;没有可用的 git 时,则以归档包的方式获取源码。`--no-admin` 强制走这条路线,`CODEMAN_NO_ADMIN=0` 则将其关闭。两个限制:Linux 上必须已有 make、C++ 编译器和 python3,tmux 下载需要 macOS 15 或更新版本。详见 [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights)。
|
- **无需管理员权限。** 在没有 `sudo` 的账户、安装器无法确认 `sudo` 权限的账户(不在 `sudo`、`wheel`、`admin` 组内,且 `sudo` 需要密码)或不是管理员、也没有可写入的 Homebrew 的 Mac 账户上,安装器会把 nodejs.org 的 Node.js 和 tmux 项目自己的构建下载到 `~/.codeman/runtime`,两者都会校验 SHA-256;没有可用的 git 时,则以归档包的方式获取源码。`--no-admin` 强制走这条路线,`CODEMAN_NO_ADMIN=0` 则将其关闭。两个限制:Linux 上必须已有 make、C++ 编译器和 python3,tmux 下载需要 macOS 15 或更新版本。详见 [wiki](https://github.com/Ark0N/Codeman/wiki/Installation#without-administrator-rights)。
|
||||||
- **给急性子的参数。** `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) 或 [GitHub Copilot CLI](https://github.com/github/copilot-cli)(任意组合均可;自 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)、[GitHub Copilot CLI](https://github.com/github/copilot-cli) 或 [jcode](https://jcode.sh)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这十一个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codeman web
|
codeman web
|
||||||
@@ -287,7 +292,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`、`Copilot` 或 `Terminal`(普通 shell)。 |
|
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP`、`Copilot`、`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` 切换。 |
|
||||||
|
|
||||||
@@ -492,7 +497,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
|||||||
- **把 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 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
|
||||||
- **打开你手动启动的 tmux 会话**:在 Codeman 之外启动的 tmux 会话会出现在主屏幕上:本机的会自动列出,Docker case 容器内和已保存 SSH 主机上的会在你扫描时列出。点一下即可作为标签页打开。关闭标签页只会分离,Codeman 不会重生或重启并非由它启动的智能体。详见 [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman)。
|
- **打开你手动启动的 tmux 会话**:在 Codeman 之外启动的 tmux 会话会出现在主屏幕上:本机的会自动列出,Docker case 容器内和已保存 SSH 主机上的会在你扫描时列出。点一下即可作为标签页打开。关闭标签页只会分离,Codeman 不会重生或重启并非由它启动的智能体。详见 [Opened outside Codeman](docs/wiki/The-Dashboard.md#opened-outside-codeman)。
|
||||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness**、**OMP** 或 **GitHub Copilot CLI**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*`、`OMP_*` 与 `COPILOT_*`)。详见 [`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/copilot-integration.md`](docs/copilot-integration.md)
|
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness**、**OMP**、**GitHub Copilot CLI** 或 **jcode**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*`、`OMP_*` 与 `COPILOT_*`)。详见 [`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/copilot-integration.md`](docs/copilot-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)
|
||||||
@@ -966,7 +971,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/copilot)没有 transcript,读终端。
|
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp/copilot/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'
|
||||||
@@ -1187,7 +1192,7 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph External["外部"]
|
subgraph External["外部"]
|
||||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot</small>"]
|
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot / jcode</small>"]
|
||||||
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -305,5 +305,34 @@
|
|||||||
"docsUrl": "https://github.com/github/copilot-cli"
|
"docsUrl": "https://github.com/github/copilot-cli"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "jcode",
|
||||||
|
"label": "jcode",
|
||||||
|
"shortBadge": "JC",
|
||||||
|
"enabled": true,
|
||||||
|
"order": 110,
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1637,6 +1637,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`, `copilot` 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`.
|
||||||
|
|||||||
@@ -37,11 +37,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)
|
||||||
|
|
||||||
**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()` (`--no-daemon` behind the `noDaemonFlag` gate at 0.162.0, measured against codex's own version, local launches only; `--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config sandbox_workspace_write.network_access=true` from `networkAccess` (the **Allow network access** app setting `codexNetworkAccess`, a launch default and a privileged param; ⚠️ with it on, a command in codex's sandbox can call Codeman's API and start a session outside that sandbox, password or not), `--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()` (fuLine truncated
|
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot, 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()` (`--no-daemon` behind the `noDaemonFlag` gate at 0.162.0, measured against codex's own version, local launches only; `--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config sandbox_workspace_write.network_access=true` from `networkAccess` (the **Allow network access** app setting `codexNetworkAccess`, a launch default and a privileged param; ⚠️ with it on, a command in codex's sandbox can call Codeman's API and start a session outside that sandbox, password or not), `--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 `isAltScreenLine 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.
|
||||||
|
|
||||||
@@ -65,6 +65,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.
|
||||||
@@ -249,7 +251,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, copilot and pi) 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`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/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, copilot, pi and 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`. Copilot is the same shape (measured on 1.0.95: alt screen, mouse reporting on): left on the narrow strip, Auto Copy and copy-on-select did nothing and only Shift+drag selected. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/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.
|
||||||
|
|
||||||
@@ -308,7 +310,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, antigravity declares the head of its footer hint `? for shortcuts` (`? for sh`, drawn only at rest, so nothing arms the idle check during a turn and a pending shell approval reads as working until it is answered; the composer's `>` is also the echoed prompt's) plus its 8-dot braille spinner row `⣯ Generating...`, 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, antigravity declares the head of its footer hint `? for shortcuts` (`? for sh`, drawn only at rest, so nothing arms the idle check during a turn and a pending shell approval reads as working until it is answered; the composer's `>` is also the echoed prompt's) plus its 8-dot braille spinner row `⣯ Generating...`, 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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, OMP and GitHub Copilot CLI — 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, GitHub Copilot CLI 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
|
||||||
|
|
||||||
@@ -227,7 +227,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.
|
||||||
|
|
||||||
@@ -235,6 +237,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:
|
||||||
@@ -368,7 +374,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 (and GitHub Copilot CLI, 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 (and GitHub Copilot CLI, 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, or that declare no `mcpConfig`.** `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`. GitHub Copilot CLI is both: its registry entry declares `mcpConfig`, so while the run mode is enabled the sync-only row is skipped, and it applies only if the run mode is disabled. 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, or that declare no `mcpConfig`.** `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`. GitHub Copilot CLI is both: its registry entry declares `mcpConfig`, so while the run mode is enabled the sync-only row is skipped, and it applies only if the run mode is disabled. 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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, OMP and GitHub Copilot. `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.
|
||||||
@@ -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
@@ -1,9 +1,9 @@
|
|||||||
# Agent CLIs
|
# Agent CLIs
|
||||||
|
|
||||||
Codeman drives eleven run modes: ten agent CLIs plus a plain shell. This page covers picking
|
Codeman drives twelve run modes: eleven 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 eleven modes
|
## The twelve modes
|
||||||
|
|
||||||
| Mode | CLI | Get it |
|
| Mode | CLI | Get it |
|
||||||
| -------------------- | ---------------------------- | ---------------------------------------------------------------------- |
|
| -------------------- | ---------------------------- | ---------------------------------------------------------------------- |
|
||||||
@@ -17,6 +17,7 @@ one, setting it up, and the differences that actually change how you work.
|
|||||||
| **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) |
|
||||||
| **GitHub Copilot** | `copilot` | [github.com/github/copilot-cli](https://github.com/github/copilot-cli) |
|
| **GitHub Copilot** | `copilot` | [github.com/github/copilot-cli](https://github.com/github/copilot-cli) |
|
||||||
|
| **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
|
||||||
@@ -51,10 +52,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`, `copilot` and `dsh` are additionally identity-probed rather than trusted by name:
|
`pi`, `grok`, `omp`, `copilot`, `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`, `/api/copilot/status`) that reports
|
unrelated `dsh` (dancer's shell). Each has a status endpoint (`/api/grok/status`,
|
||||||
|
`/api/deepseek/status`, `/api/omp/status`, `/api/copilot/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".
|
||||||
|
|
||||||
@@ -70,7 +72,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, Copilot, Gemini and Antigravity: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable |
|
| Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Copilot, Gemini, Antigravity and jcode: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable |
|
||||||
| Auto-resume when a usage limit resets | Yes | No |
|
| Auto-resume when a usage limit resets | Yes | No |
|
||||||
| Plan usage chip | Yes | Codex (limits) and Copilot (monthly premium requests); the others no |
|
| Plan usage chip | Yes | Codex (limits) and Copilot (monthly premium requests); the others 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 |
|
||||||
@@ -352,6 +354,33 @@ Ralph tracker, as for the other non-Claude CLIs.
|
|||||||
|
|
||||||
Guide: [`docs/copilot-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/copilot-integration.md).
|
Guide: [`docs/copilot-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/copilot-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.
|
||||||
@@ -379,6 +408,7 @@ respawns. Which variables are accepted depends on the mode:
|
|||||||
| DeepSeek | `DSH_*`, `DEEPSEEK_*` |
|
| DeepSeek | `DSH_*`, `DEEPSEEK_*` |
|
||||||
| OMP | `OMP_*` |
|
| OMP | `OMP_*` |
|
||||||
| Copilot | `COPILOT_*` (a token goes in `COPILOT_GITHUB_TOKEN`; `GH_TOKEN` and `GITHUB_TOKEN` are not accepted as per-session overrides) |
|
| Copilot | `COPILOT_*` (a token goes in `COPILOT_GITHUB_TOKEN`; `GH_TOKEN` and `GITHUB_TOKEN` are not accepted as per-session overrides) |
|
||||||
|
| 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
|
||||||
@@ -394,7 +424,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, GitHub Copilot** when you prefer that agent or that
|
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP, GitHub Copilot, 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 (Codex dialogs still raise the red alert and an alert-only inbox card,
|
hook-driven features (Codex dialogs still raise the red alert and an alert-only inbox card,
|
||||||
see [Codex](#codex) above).
|
see [Codex](#codex) above).
|
||||||
|
|||||||
@@ -122,7 +122,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. Ten of the eleven run modes are not
|
- Label Claude-only behaviour every time it appears. Eleven of the twelve run modes are not
|
||||||
Claude.
|
Claude.
|
||||||
|
|
||||||
## Conduct
|
## Conduct
|
||||||
|
|||||||
+16
-16
@@ -92,10 +92,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`, `copilot`, or `shell`. It is chosen at start and does not change afterwards; to
|
`antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, `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. Eleven of the twelve 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.
|
||||||
@@ -110,8 +110,8 @@ Where a case runs is **separate from** which CLI it runs. There are three locati
|
|||||||
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
|
| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). |
|
||||||
| **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** a thirteenth run
|
||||||
mode. All eleven run modes work in all three locations. A case is docker-backed or
|
mode. All twelve 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
|
||||||
@@ -213,21 +213,21 @@ See [Hooks And Integrations](Hooks-And-Integrations).
|
|||||||
|
|
||||||
## Vocabulary
|
## Vocabulary
|
||||||
|
|
||||||
| Term | Means |
|
| Term | Means |
|
||||||
| ---------------- | ------------------------------------------------------------------------------------------------- |
|
| ---------------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
| **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, copilot, shell. |
|
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, 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. |
|
||||||
| **Subagent** | An agent the CLI spawned itself, shown live in its own window. |
|
| **Subagent** | An agent the CLI spawned itself, shown live in its own window. |
|
||||||
| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. |
|
| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. |
|
||||||
| **Instance** | One Codeman server with its own data directory and tmux socket. |
|
| **Instance** | One Codeman server with its own data directory and tmux socket. |
|
||||||
|
|
||||||
## 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 eleven run modes in detail.
|
- [Agent CLIs](Agent-CLIs) - the twelve 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.
|
||||||
@@ -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 eleven run modes work inside a
|
A docker case is a **location overlay**, not a run mode. All twelve 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
|
||||||
@@ -120,7 +120,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.
|
||||||
|
|
||||||
GitHub Copilot CLI is not seeded: on a host without a keyring its sign-in token sits in plain
|
GitHub Copilot CLI is not seeded: on a host without a keyring its sign-in token sits in plain
|
||||||
text in `~/.copilot/config.json`, and there is no Docker credential row for it. Sign in inside
|
text in `~/.copilot/config.json`, and there is no Docker credential row for it. Sign in inside
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ 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,
|
Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness,
|
||||||
OMP and GitHub Copilot CLI, plus a plain shell, chosen per session. Claude is the reference mode and a few features
|
OMP, GitHub Copilot CLI 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
@@ -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, OMP, or GitHub Copilot CLI inside persistent tmux sessions, streams the real terminal to the browser, and keeps
|
Grok, DeepSeek Harness, OMP, GitHub Copilot CLI, 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 eleven run modes, their setup, and which features are Claude-only.
|
- [Agent CLIs](Agent-CLIs) - the twelve 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.
|
||||||
@@ -129,7 +129,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, GitHub Copilot CLI. Plain shell sessions need none. |
|
| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP, GitHub Copilot CLI, 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
|
||||||
|
|||||||
@@ -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, plus the
|
browser to your server, and whatever the agent CLI you chose does on its own, plus the
|
||||||
@@ -309,6 +309,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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 eleven run modes
|
Like Docker, this is a **location overlay** on a case, not a run mode. All twelve run modes
|
||||||
work remotely. See [Core Concepts](Core-Concepts).
|
work remotely. See [Core Concepts](Core-Concepts).
|
||||||
|
|
||||||
## Why bother
|
## Why bother
|
||||||
|
|||||||
+13
-13
@@ -224,19 +224,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' 'copilot')
|
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp' 'copilot' 'jcode')
|
||||||
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP' 'GitHub Copilot')
|
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP' 'GitHub Copilot' 'jcode')
|
||||||
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1 1)
|
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1 1 1)
|
||||||
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0 0)
|
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0 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' 'https://github.com/github/copilot-cli')
|
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://github.com/github/copilot-cli' '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' 'npm install -g @github/copilot')
|
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' 'npm install -g @github/copilot' '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' 'npm install -g @github/copilot')
|
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' 'npm install -g @github/copilot' 'curl -fsSL https://jcode.sh/install | bash')
|
||||||
CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp' 'copilot')
|
CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp' 'copilot' 'jcode')
|
||||||
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8 9)
|
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8 9 10)
|
||||||
CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1 1)
|
CLI_BIN_LEN=(1 0 1 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" "$HOME/.local/bin/copilot" "/usr/local/bin/copilot" "$HOME/.bun/bin/copilot" "$HOME/.npm-global/bin/copilot" "$HOME/bin/copilot")
|
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/copilot" "/usr/local/bin/copilot" "$HOME/.bun/bin/copilot" "$HOME/.npm-global/bin/copilot" "$HOME/bin/copilot" "$HOME/.local/bin/jcode" "/usr/local/bin/jcode" "$HOME/bin/jcode")
|
||||||
CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41 47)
|
CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41 47 52)
|
||||||
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 5)
|
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 5 3)
|
||||||
# <<< END GENERATED CLI CATALOGUE
|
# <<< END GENERATED CLI CATALOGUE
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
Generated
+6
-21
@@ -37,12 +37,6 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.22.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.23.1"
|
version = "0.23.1"
|
||||||
@@ -161,9 +155,9 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
|||||||
name = "codeman-desktop"
|
name = "codeman-desktop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64",
|
||||||
"block2",
|
"block2",
|
||||||
"dirs 6.0.0",
|
"dirs",
|
||||||
"libc",
|
"libc",
|
||||||
"muda",
|
"muda",
|
||||||
"objc2",
|
"objc2",
|
||||||
@@ -353,15 +347,6 @@ dependencies = [
|
|||||||
"crypto-common",
|
"crypto-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dirs"
|
|
||||||
version = "6.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
|
||||||
dependencies = [
|
|
||||||
"dirs-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "7.0.0"
|
version = "7.0.0"
|
||||||
@@ -2460,7 +2445,7 @@ version = "3.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
|
checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64",
|
||||||
"flate2",
|
"flate2",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
@@ -2478,7 +2463,7 @@ version = "0.6.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
|
checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64",
|
||||||
"http",
|
"http",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
@@ -2981,11 +2966,11 @@ version = "0.57.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9"
|
checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64",
|
||||||
"block2",
|
"block2",
|
||||||
"cookie",
|
"cookie",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dirs 7.0.0",
|
"dirs",
|
||||||
"dom_query",
|
"dom_query",
|
||||||
"dpi",
|
"dpi",
|
||||||
"dunce",
|
"dunce",
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ muda = "0.21"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
url = "2"
|
url = "2"
|
||||||
base64 = "0.22"
|
# The versions ureq and wry already pull in, so neither crate is built twice
|
||||||
dirs = "6"
|
# (ureq builds base64 without its default SIMD engines, and so do we).
|
||||||
|
base64 = { version = "0.23", default-features = false, features = ["std"] }
|
||||||
|
dirs = "7"
|
||||||
# The reachability probe. platform-verifier = the OS trust store, the same one
|
# The reachability probe. platform-verifier = the OS trust store, the same one
|
||||||
# WKWebView uses, so the probe and the window agree on which certificates pass.
|
# WKWebView uses, so the probe and the window agree on which certificates pass.
|
||||||
ureq = { version = "3", features = ["platform-verifier"] }
|
ureq = { version = "3", features = ["platform-verifier"] }
|
||||||
|
|||||||
+21
-19
@@ -9,7 +9,7 @@ macOS first: the app runs on 11.3 or later, and Codeman's own page needs the
|
|||||||
WebKit of Safari 16.4 (see Known limits). The same code is meant to build for
|
WebKit of Safari 16.4 (see Known limits). The same code is meant to build for
|
||||||
Windows and Linux later.
|
Windows and Linux later.
|
||||||
|
|
||||||
- **Size:** 4.6 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3 MB as a `.dmg` (measured on the first build).
|
- **Size:** 5.3 MB for the universal `Codeman.app` (Apple Silicon + Intel), 3.5 MB as a `.dmg` (measured 2026-11-09).
|
||||||
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
|
- **Stack:** Rust with [tao](https://crates.io/crates/tao) (window and event loop),
|
||||||
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
|
[wry](https://crates.io/crates/wry) (the system web view: WKWebView on macOS) and
|
||||||
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
|
[muda](https://crates.io/crates/muda) (native menus), plus [objc2](https://crates.io/crates/objc2)
|
||||||
@@ -24,7 +24,7 @@ operating system's own web view. An Electron build of the same thing would ship
|
|||||||
a whole Chromium (around 150 MB). The native side only adds what a browser tab
|
a whole Chromium (around 150 MB). The native side only adds what a browser tab
|
||||||
cannot do.
|
cannot do.
|
||||||
|
|
||||||
## What it does (v0.1)
|
## What it does
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -109,8 +109,9 @@ classes of wry's own delegates, so wry stays an unmodified crates.io dependency.
|
|||||||
base path, checked after decoding the path once, and which ones depends on
|
base path, checked after decoding the path once, and which ones depends on
|
||||||
who asks. `window.CodemanHost` (the app shell only) may open sessions,
|
who asks. `window.CodemanHost` (the app shell only) may open sessions,
|
||||||
rendered HTML views, the raw, preview, attachment and folder file routes, the
|
rendered HTML views, the raw, preview, attachment and folder file routes, the
|
||||||
path picker's preview and `api/download`. A `window.open` or `target=_blank` link, which WebKit passes
|
path picker's preview and `api/download`. A `window.open` or `target=_blank`
|
||||||
on from any frame without saying which, gets only the file routes and
|
link, which WebKit passes on from any frame without saying which, gets only
|
||||||
|
the file routes and
|
||||||
`api/download`, the pages Codeman itself opens that way; otherwise a rendered
|
`api/download`, the pages Codeman itself opens that way; otherwise a rendered
|
||||||
HTML file could open copies of itself as top-level windows that pass for the
|
HTML file could open copies of itself as top-level windows that pass for the
|
||||||
app's own. Such a window does not take keyboard focus, and these windows
|
app's own. Such a window does not take keyboard focus, and these windows
|
||||||
@@ -218,11 +219,12 @@ Codeman counts a request without valid credentials against its login rate
|
|||||||
limit (10 per IP, 15 minutes; a successful login resets the count), so the app
|
limit (10 per IP, 15 minutes; a successful login resets the count), so the app
|
||||||
never retries in a loop. A look at this Mac asks each Codeman server found
|
never retries in a loop. A look at this Mac asks each Codeman server found
|
||||||
here once; the app has a login only for the one at the usual address, so the
|
here once; the app has a login only for the one at the usual address, so the
|
||||||
others are asked without one. Once the server refuses the login in a challenge, the
|
others are asked without one. Once the server refuses the login in a challenge,
|
||||||
app sends it only once more, in the single probe that checks the refusal. If
|
the app sends it only once more, in the single probe that checks the refusal.
|
||||||
that probe is refused or rate limited, the connect screen comes back; the
|
If that probe is refused or rate limited, the connect screen comes back; the
|
||||||
app does not send the refused login again. Keeping no copy of the login in WebKit has a cost here: after a server
|
app does not send the refused login again. Keeping no copy of the login in
|
||||||
restart, each request that arrives without a cookie gets a 401 before the
|
WebKit has a cost here: after a server restart, each request that arrives
|
||||||
|
without a cookie gets a 401 before the
|
||||||
login goes out, and those count until one succeeds. Many windows reconnecting
|
login goes out, and those count until one succeeds. Many windows reconnecting
|
||||||
at once can push a request past 10, and that request then fails with a 429
|
at once can push a request past 10, and that request then fails with a 429
|
||||||
instead of being answered. A refusal that lands while you are in another app
|
instead of being answered. A refusal that lands while you are in another app
|
||||||
@@ -305,15 +307,15 @@ crosses, so the login is never on a command line.
|
|||||||
answers at the usual address). A password-protected one counts the
|
answers at the usual address). A password-protected one counts the
|
||||||
look as a failed login, like any look at a server the app has no login for.
|
look as a failed login, like any look at a server the app has no login for.
|
||||||
*Check again* asks again.
|
*Check again* asks again.
|
||||||
|
|
||||||
- **The logo blink rests on a private macOS interface.** Apple documents the
|
- **The logo blink rests on a private macOS interface.** Apple documents the
|
||||||
setting, not the way the app reaches it, so a macOS update can take it away;
|
setting, not the way the app reaches it, so a macOS update can take it away;
|
||||||
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
|
the menu then disappears. Every change of the logo writes `LEBacklightMode`,
|
||||||
from about once every two seconds (*Slow*, *Wink*) to about three times a
|
from about once every two seconds (*Slow*, *Wink*) to about three times a
|
||||||
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
|
second (*Fast*), which `corebrightnessd` keeps in root's preferences. The
|
||||||
setting is on or off, so these styles are rhythms of hard changes. After a blink
|
setting is on or off, so these styles are rhythms of hard changes. After a
|
||||||
the key stays there rather than being absent as on a fresh Mac. That reads the same to macOS; `sudo defaults delete
|
blink the key stays there rather than being absent as on a fresh Mac. That
|
||||||
com.apple.CoreBrightness LEBacklightMode` removes it. After a power cut in a
|
reads the same to macOS; `sudo defaults delete com.apple.CoreBrightness
|
||||||
|
LEBacklightMode` removes it. After a power cut in a
|
||||||
dark phase the logo stays dark until the app next starts. `api/sessions` does
|
dark phase the logo stays dark until the app next starts. `api/sessions` does
|
||||||
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
|
not say which turns are Codeman's own, so with prompt-cache keep-warm on, a
|
||||||
keep-alive turn blinks too.
|
keep-alive turn blinks too.
|
||||||
@@ -335,16 +337,16 @@ crosses, so the login is never on a command line.
|
|||||||
`SIGN_IDENTITY` (a self-made code-signing certificate from Keychain Access
|
`SIGN_IDENTITY` (a self-made code-signing certificate from Keychain Access
|
||||||
is enough). Microphone and camera permissions are asked again for the same
|
is enough). Microphone and camera permissions are asked again for the same
|
||||||
reason.
|
reason.
|
||||||
- **Gatekeeper.** Without a Developer ID the app is signed ad hoc. A build you
|
- **Gatekeeper, for a build of your own.** The `Codeman.dmg` on each GitHub
|
||||||
made yourself opens normally; a copy that arrived by download or AirDrop is
|
release is signed with a Developer ID and notarized by CI
|
||||||
|
(`.github/workflows/desktop-dmg.yml`), so it opens like any downloaded app.
|
||||||
|
A build without a Developer ID is signed ad hoc: it opens normally on the Mac
|
||||||
|
that built it, while a copy that arrived by download or AirDrop is
|
||||||
quarantined. On macOS 15 and later, try to open it once, then click *Open
|
quarantined. On macOS 15 and later, try to open it once, then click *Open
|
||||||
Anyway* in System Settings > Privacy & Security (it asks for an administrator
|
Anyway* in System Settings > Privacy & Security (it asks for an administrator
|
||||||
password). On macOS 14 and earlier, right-click it and choose *Open* once.
|
password). On macOS 14 and earlier, right-click it and choose *Open* once.
|
||||||
Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the
|
Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the
|
||||||
copy in Applications works too. The `Codeman.dmg` on each GitHub release is
|
copy in Applications works too. To sign and notarize by hand:
|
||||||
signed with a Developer ID and notarized by CI
|
|
||||||
(`.github/workflows/desktop-dmg.yml`), so it skips all of this. To do the
|
|
||||||
same by hand:
|
|
||||||
`SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> ./scripts/bundle-macos.sh --universal --notarize`,
|
`SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=<profile> ./scripts/bundle-macos.sh --universal --notarize`,
|
||||||
where `scripts/sign-macos.sh` lists what it needs.
|
where `scripts/sign-macos.sh` lists what it needs.
|
||||||
- **Local Network permission (macOS 15 and later).** The first connection to an
|
- **Local Network permission (macOS 15 and later).** The first connection to an
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
padding: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.45);
|
padding: 20px; box-shadow: 0 20px 60px rgba(0,0,0,0.45);
|
||||||
}
|
}
|
||||||
label.field { display: block; font-size: 12px; color: var(--muted); margin: 0 0 6px; }
|
label.field { display: block; font-size: 12px; color: var(--muted); margin: 0 0 6px; }
|
||||||
input[type=text], input[type=password], input[type=url] {
|
input[type=text], input[type=password] {
|
||||||
width: 100%; height: 38px; padding: 0 12px; border-radius: 9px;
|
width: 100%; height: 38px; padding: 0 12px; border-radius: 9px;
|
||||||
border: 1px solid var(--line-strong); background: #0c0d10; color: var(--text);
|
border: 1px solid var(--line-strong); background: #0c0d10; color: var(--text);
|
||||||
font: inherit; outline: none; -webkit-user-select: text; user-select: text;
|
font: inherit; outline: none; -webkit-user-select: text; user-select: text;
|
||||||
@@ -61,7 +61,6 @@
|
|||||||
input:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(59,130,246,0.25); }
|
input:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(59,130,246,0.25); }
|
||||||
.row { display: flex; gap: 10px; }
|
.row { display: flex; gap: 10px; }
|
||||||
.row > * { flex: 1; }
|
.row > * { flex: 1; }
|
||||||
.gap { height: 14px; }
|
|
||||||
#recent { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
#recent { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||||
#recent:empty { display: none; }
|
#recent:empty { display: none; }
|
||||||
.chip {
|
.chip {
|
||||||
@@ -128,7 +127,7 @@
|
|||||||
/* The Codeman servers found on this Mac: a row each, in the look of a choice
|
/* The Codeman servers found on this Mac: a row each, in the look of a choice
|
||||||
in the install, with the row's own Connect button. */
|
in the install, with the row's own Connect button. */
|
||||||
#local-found-note { margin: 8px 0 0; }
|
#local-found-note { margin: 8px 0 0; }
|
||||||
#local-also { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
|
#local-also, #elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||||
#local-also-head { color: var(--muted); font-size: 12px; margin: 0 0 6px; }
|
#local-also-head { color: var(--muted); font-size: 12px; margin: 0 0 6px; }
|
||||||
.found {
|
.found {
|
||||||
display: flex; gap: 10px; align-items: center; padding: 9px 11px; border-radius: 9px;
|
display: flex; gap: 10px; align-items: center; padding: 9px 11px; border-radius: 9px;
|
||||||
@@ -139,7 +138,6 @@
|
|||||||
.found b { display: block; font-weight: 600; }
|
.found b { display: block; font-weight: 600; }
|
||||||
.found small { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; }
|
.found small { display: block; margin-top: 1px; color: var(--muted); font-size: 12px; }
|
||||||
.found button { flex: none; }
|
.found button { flex: none; }
|
||||||
#elsewhere { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
|
|
||||||
#elsewhere[hidden] { display: none; }
|
#elsewhere[hidden] { display: none; }
|
||||||
#elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; }
|
#elsewhere summary { color: var(--muted); font-size: 13px; cursor: default; list-style: none; }
|
||||||
#elsewhere summary::-webkit-details-marker { display: none; }
|
#elsewhere summary::-webkit-details-marker { display: none; }
|
||||||
@@ -231,7 +229,6 @@
|
|||||||
border: 1px solid var(--line); color: #aab1bb; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
|
border: 1px solid var(--line); color: #aab1bb; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
white-space: pre-wrap; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text;
|
white-space: pre-wrap; overflow-wrap: anywhere; -webkit-user-select: text; user-select: text;
|
||||||
}
|
}
|
||||||
#wizard .actions .wiz-busy { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; }
|
|
||||||
footer { margin-top: 18px; text-align: center; color: var(--muted); font-size: 12px; }
|
footer { margin-top: 18px; text-align: center; color: var(--muted); font-size: 12px; }
|
||||||
footer a { color: #aab1bb; text-decoration: none; cursor: default; }
|
footer a { color: #aab1bb; text-decoration: none; cursor: default; }
|
||||||
footer a:hover { color: var(--accent); }
|
footer a:hover { color: var(--accent); }
|
||||||
@@ -416,9 +413,11 @@
|
|||||||
// suggestion: the address the suggest button connects to.
|
// suggestion: the address the suggest button connects to.
|
||||||
// tab: 'local' (This Mac) or 'remote' (Another machine). askedFrom: the tab
|
// tab: 'local' (This Mac) or 'remote' (Another machine). askedFrom: the tab
|
||||||
// the connect under way was asked from, where its answer belongs. local: the
|
// the connect under way was asked from, where its answer belongs. local: the
|
||||||
// last This Mac status from the app. wait: set while an install runs in
|
// last This Mac status from the app. wait: set while this page polls for the
|
||||||
// Terminal and this page polls for the server it brings up. gui: the
|
// server an install brings up (one in Terminal, or a graphical one that
|
||||||
// graphical install while its screens are up (newWizard), null otherwise.
|
// finished; newWait). gui: the graphical install or the agent chooser while
|
||||||
|
// its screens are up (newWizard), null otherwise. clock: the timer of the
|
||||||
|
// running phase's clock.
|
||||||
// reopen: a connect to this Mac was refused, so open it if it turns out to
|
// reopen: a connect to this Mac was refused, so open it if it turns out to
|
||||||
// be starting. startingLooks: how often the page has looked again at a
|
// be starting. startingLooks: how often the page has looked again at a
|
||||||
// starting server. askedFound: the connect under way was asked from the row
|
// starting server. askedFound: the connect under way was asked from the row
|
||||||
@@ -427,7 +426,7 @@
|
|||||||
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
|
servers: [], busy: false, lastUrl: '', startUrl: '', suggestion: '',
|
||||||
tab: 'local', askedFrom: 'local', askedFound: false, local: null, localChecking: false, opening: false,
|
tab: 'local', askedFrom: 'local', askedFound: false, local: null, localChecking: false, opening: false,
|
||||||
wait: null, waitTimer: 0, stoppedWaiting: false, gui: null, wantAgents: false,
|
wait: null, waitTimer: 0, stoppedWaiting: false, gui: null, wantAgents: false,
|
||||||
reopen: false, startingLooks: 0, startingTimer: 0
|
reopen: false, startingLooks: 0, startingTimer: 0, clock: 0
|
||||||
};
|
};
|
||||||
// While an install runs: how often to look, how long a finished install may
|
// While an install runs: how often to look, how long a finished install may
|
||||||
// take to answer before Start is offered, and when to stop looking.
|
// take to answer before Start is offered, and when to stop looking.
|
||||||
@@ -511,14 +510,15 @@
|
|||||||
els.suggest.hidden = true;
|
els.suggest.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function host(url) {
|
function displayAddress(url) {
|
||||||
try { var u = new URL(url); return u.host + (u.pathname === '/' ? '' : u.pathname.replace(/\/$/, '')); } catch (e) { return url; }
|
try { var u = new URL(url); return u.host + (u.pathname === '/' ? '' : u.pathname.replace(/\/$/, '')); } catch (e) { return url; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// The address as the app normalizes it (policy::normalize_server_url):
|
// The address as the app normalizes it (policy::normalize_server_url):
|
||||||
// http:// by default, https:// for a .ts.net name without a port, no query
|
// http:// by default, https:// for a .ts.net name without a port, no query
|
||||||
// or fragment, a trailing slash. Only used to tell whether two addresses
|
// or fragment, a trailing slash. Used to tell whether two addresses are one
|
||||||
// are one server.
|
// server, and which scheme and host a typed address ends up with
|
||||||
|
// (isLocalUrl, the http:// warning).
|
||||||
function normalize(raw) {
|
function normalize(raw) {
|
||||||
var s = String(raw || '').trim();
|
var s = String(raw || '').trim();
|
||||||
if (!s) return '';
|
if (!s) return '';
|
||||||
@@ -553,7 +553,7 @@
|
|||||||
chip.className = 'chip';
|
chip.className = 'chip';
|
||||||
var pick = document.createElement('button');
|
var pick = document.createElement('button');
|
||||||
pick.type = 'button';
|
pick.type = 'button';
|
||||||
pick.textContent = host(s.url) + (s.username ? ' (' + s.username + ')' : '');
|
pick.textContent = displayAddress(s.url) + (s.username ? ' (' + s.username + ')' : '');
|
||||||
pick.title = 'Connect to ' + s.url;
|
pick.title = 'Connect to ' + s.url;
|
||||||
pick.addEventListener('click', function () {
|
pick.addEventListener('click', function () {
|
||||||
if (state.busy) return;
|
if (state.busy) return;
|
||||||
@@ -565,7 +565,7 @@
|
|||||||
x.className = 'x';
|
x.className = 'x';
|
||||||
x.textContent = '×';
|
x.textContent = '×';
|
||||||
x.title = 'Forget this server' + (s.hasSavedPassword ? ' and its saved password' : '');
|
x.title = 'Forget this server' + (s.hasSavedPassword ? ' and its saved password' : '');
|
||||||
x.setAttribute('aria-label', 'Forget ' + host(s.url));
|
x.setAttribute('aria-label', 'Forget ' + displayAddress(s.url));
|
||||||
x.addEventListener('click', function () { if (!state.busy) post({ cmd: 'forget', url: s.url }); });
|
x.addEventListener('click', function () { if (!state.busy) post({ cmd: 'forget', url: s.url }); });
|
||||||
chip.appendChild(pick);
|
chip.appendChild(pick);
|
||||||
chip.appendChild(x);
|
chip.appendChild(x);
|
||||||
@@ -782,8 +782,11 @@
|
|||||||
return (s.found || []).filter(function (f) { return f && f.url !== s.url; });
|
return (s.found || []).filter(function (f) { return f && f.url !== s.url; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function canConnect(f) {
|
// Codeman answers there, with or without a login, so the app can connect
|
||||||
return f.state === 'running' || f.state === 'needsLogin';
|
// to it. Read the same way for a found server's row and for the This Mac
|
||||||
|
// status.
|
||||||
|
function answers(s) {
|
||||||
|
return s.state === 'running' || s.state === 'needsLogin';
|
||||||
}
|
}
|
||||||
|
|
||||||
// What a found server's row says under its address: its version, or why the
|
// What a found server's row says under its address: its version, or why the
|
||||||
@@ -815,7 +818,7 @@
|
|||||||
text.appendChild(small);
|
text.appendChild(small);
|
||||||
}
|
}
|
||||||
row.appendChild(text);
|
row.appendChild(text);
|
||||||
if (canConnect(f)) {
|
if (answers(f)) {
|
||||||
var button = document.createElement('button');
|
var button = document.createElement('button');
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.className = primary ? 'primary' : 'secondary';
|
button.className = primary ? 'primary' : 'secondary';
|
||||||
@@ -848,7 +851,7 @@
|
|||||||
} else if (!s) {
|
} else if (!s) {
|
||||||
dot = 'busy';
|
dot = 'busy';
|
||||||
title = 'Looking for Codeman on this Mac…';
|
title = 'Looking for Codeman on this Mac…';
|
||||||
} else if (s.installRunning && s.state !== 'running' && s.state !== 'needsLogin') {
|
} else if (s.installRunning && !answers(s)) {
|
||||||
dot = 'busy';
|
dot = 'busy';
|
||||||
title = 'Codeman is being installed in Terminal.';
|
title = 'Codeman is being installed in Terminal.';
|
||||||
detail = 'Finish the installer there, then Check again.';
|
detail = 'Finish the installer there, then Check again.';
|
||||||
@@ -861,7 +864,7 @@
|
|||||||
} else if (s.state === 'runningElsewhere') {
|
} else if (s.state === 'runningElsewhere') {
|
||||||
// Not at the address the app opens by default: each one gets a row.
|
// Not at the address the app opens by default: each one gets a row.
|
||||||
main = (s.found || []).filter(Boolean);
|
main = (s.found || []).filter(Boolean);
|
||||||
dot = main.some(canConnect) ? 'ok' : 'warn';
|
dot = main.some(answers) ? 'ok' : 'warn';
|
||||||
title = 'Codeman is already running on this Mac.';
|
title = 'Codeman is already running on this Mac.';
|
||||||
// No row: the app found the server's process, but no address to try.
|
// No row: the app found the server's process, but no address to try.
|
||||||
if (!main.length) {
|
if (!main.length) {
|
||||||
@@ -869,16 +872,12 @@
|
|||||||
'Starting another server here would take over its sessions.';
|
'Starting another server here would take over its sessions.';
|
||||||
}
|
}
|
||||||
show.check = true;
|
show.check = true;
|
||||||
} else if (s.state === 'running') {
|
} else if (answers(s)) {
|
||||||
dot = 'ok';
|
dot = 'ok';
|
||||||
title = 'Codeman is running on this Mac.';
|
title = 'Codeman is running on this Mac.';
|
||||||
detail = s.url + (s.version ? ' · version ' + s.version : '');
|
detail = s.state === 'needsLogin'
|
||||||
show.open = true;
|
? s.url + ' asks for a login: Open asks for the password.'
|
||||||
also = otherServers(s);
|
: s.url + (s.version ? ' · version ' + s.version : '');
|
||||||
} else if (s.state === 'needsLogin') {
|
|
||||||
dot = 'ok';
|
|
||||||
title = 'Codeman is running on this Mac.';
|
|
||||||
detail = s.url + ' asks for a login: Open asks for the password.';
|
|
||||||
show.open = true;
|
show.open = true;
|
||||||
also = otherServers(s);
|
also = otherServers(s);
|
||||||
} else if (s.state === 'stopped') {
|
} else if (s.state === 'stopped') {
|
||||||
@@ -915,7 +914,7 @@
|
|||||||
if (o.kind === 'notCodeman') {
|
if (o.kind === 'notCodeman') {
|
||||||
title = 'Something else answers at ' + s.url;
|
title = 'Something else answers at ' + s.url;
|
||||||
detail = 'It is not Codeman (HTTP ' + o.status + '). Stop what runs on that port, or connect to ' +
|
detail = 'It is not Codeman (HTTP ' + o.status + '). Stop what runs on that port, or connect to ' +
|
||||||
(also.some(canConnect) ? 'the Codeman listed below.' : 'Codeman’s real address under Another machine.');
|
(also.some(answers) ? 'the Codeman listed below.' : 'Codeman’s real address under Another machine.');
|
||||||
} else {
|
} else {
|
||||||
var e = explain[o.kind];
|
var e = explain[o.kind];
|
||||||
title = e ? e[1] : 'Could not reach Codeman on this Mac.';
|
title = e ? e[1] : 'Could not reach Codeman on this Mac.';
|
||||||
@@ -959,8 +958,8 @@
|
|||||||
}
|
}
|
||||||
// An install from this app is still running (this page was shown again
|
// An install from this app is still running (this page was shown again
|
||||||
// while it ran): wait for it, unless the user stopped waiting.
|
// while it ran): wait for it, unless the user stopped waiting.
|
||||||
if (!state.wait && s && s.installRunning && !s.installInApp && !state.stoppedWaiting && s.state !== 'running' && s.state !== 'needsLogin') {
|
if (!state.wait && s && s.installRunning && !s.installInApp && !state.stoppedWaiting && !answers(s)) {
|
||||||
state.wait = { since: Date.now(), finishedAt: 0, port: s.installPort || 3000 };
|
state.wait = newWait(s.installPort || 3000);
|
||||||
renderLocal();
|
renderLocal();
|
||||||
scheduleWait();
|
scheduleWait();
|
||||||
}
|
}
|
||||||
@@ -989,7 +988,7 @@
|
|||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
var up = !!s && (s.state === 'running' || s.state === 'needsLogin');
|
var up = !!s && answers(s);
|
||||||
var open = up && state.reopen && state.startingLooks > 0 && state.tab === 'local' && !state.busy;
|
var open = up && state.reopen && state.startingLooks > 0 && state.tab === 'local' && !state.busy;
|
||||||
state.startingLooks = 0;
|
state.startingLooks = 0;
|
||||||
state.reopen = false;
|
state.reopen = false;
|
||||||
@@ -1041,10 +1040,15 @@
|
|||||||
var network = !!state.gui && state.gui.reach === 'network';
|
var network = !!state.gui && state.gui.reach === 'network';
|
||||||
closeWizard();
|
closeWizard();
|
||||||
selectTab('local');
|
selectTab('local');
|
||||||
|
openTerminal({ cmd: 'installLocal', network: network });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both Terminal installs: no second one while Terminal opens.
|
||||||
|
function openTerminal(msg) {
|
||||||
state.opening = true;
|
state.opening = true;
|
||||||
syncLocalButtons();
|
syncLocalButtons();
|
||||||
setStatus('busy', 'Opening Terminal…');
|
setStatus('busy', 'Opening Terminal…');
|
||||||
post({ cmd: 'installLocal', network: network });
|
post(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
// mode 'install': Codeman itself. mode 'agent': one coding agent for a Mac
|
// mode 'install': Codeman itself. mode 'agent': one coding agent for a Mac
|
||||||
@@ -1056,17 +1060,22 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The first screen of the graphical install ('install') or of adding an
|
||||||
|
// agent ('agent'), which asks the installer for its plan.
|
||||||
|
function prepareWizard(mode) {
|
||||||
|
state.gui = newWizard('preparing', mode);
|
||||||
|
post({ cmd: mode === 'agent' ? 'agentPrepare' : 'installPrepare' });
|
||||||
|
}
|
||||||
|
|
||||||
function openWizard() {
|
function openWizard() {
|
||||||
if (state.busy || tooOld || state.opening) return;
|
if (state.busy || tooOld || state.opening) return;
|
||||||
state.gui = newWizard('preparing');
|
prepareWizard('install');
|
||||||
post({ cmd: 'installPrepare' });
|
|
||||||
renderWizard();
|
renderWizard();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAgentChooser() {
|
function openAgentChooser() {
|
||||||
if (state.busy || tooOld || state.opening) return;
|
if (state.busy || tooOld || state.opening) return;
|
||||||
state.gui = newWizard('preparing', 'agent');
|
prepareWizard('agent');
|
||||||
post({ cmd: 'agentPrepare' });
|
|
||||||
renderWizard();
|
renderWizard();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1086,14 +1095,14 @@
|
|||||||
renderLocal();
|
renderLocal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try again keeps what was chosen.
|
||||||
function retryWizard() {
|
function retryWizard() {
|
||||||
var reach = state.gui ? state.gui.reach : 'local';
|
var old = state.gui;
|
||||||
var agent = state.gui ? state.gui.agent : '';
|
prepareWizard(old ? old.mode : 'install');
|
||||||
var mode = state.gui ? state.gui.mode : 'install';
|
if (old) {
|
||||||
state.gui = newWizard('preparing', mode);
|
state.gui.reach = old.reach;
|
||||||
state.gui.reach = reach;
|
state.gui.agent = old.agent;
|
||||||
state.gui.agent = agent;
|
}
|
||||||
post({ cmd: mode === 'agent' ? 'agentPrepare' : 'installPrepare' });
|
|
||||||
renderWizard();
|
renderWizard();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,6 +1120,12 @@
|
|||||||
return offeredAgents().filter(function (c) { return c.id === id; })[0] || null;
|
return offeredAgents().filter(function (c) { return c.id === id; })[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This Mac has no coding agent and the installer offers one, so an install
|
||||||
|
// asks which one.
|
||||||
|
function needsAgent() {
|
||||||
|
return !installedAgents().length && offeredAgents().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// A run this page did not start has no plan, so no name for the agent.
|
// A run this page did not start has no plan, so no name for the agent.
|
||||||
function agentName() {
|
function agentName() {
|
||||||
var c = agentById(state.gui.agent);
|
var c = agentById(state.gui.agent);
|
||||||
@@ -1129,7 +1144,7 @@
|
|||||||
g.step = 'agent';
|
g.step = 'agent';
|
||||||
} else if (plan.blocker) {
|
} else if (plan.blocker) {
|
||||||
g.step = 'blocked';
|
g.step = 'blocked';
|
||||||
} else if (!installedAgents().length && offeredAgents().length) {
|
} else if (needsAgent()) {
|
||||||
// Kept across Try again; otherwise the first of the catalogue's order.
|
// Kept across Try again; otherwise the first of the catalogue's order.
|
||||||
if (!agentById(g.agent)) g.agent = offeredAgents()[0].id;
|
if (!agentById(g.agent)) g.agent = offeredAgents()[0].id;
|
||||||
g.step = 'agent';
|
g.step = 'agent';
|
||||||
@@ -1229,7 +1244,7 @@
|
|||||||
PHASES.forEach(function (id) {
|
PHASES.forEach(function (id) {
|
||||||
var label = {
|
var label = {
|
||||||
runtime: 'Getting Node.js and tmux',
|
runtime: 'Getting Node.js and tmux',
|
||||||
agent: 'Installing ' + (chosen ? chosen.label : 'a coding agent'),
|
agent: 'Installing ' + agentName(),
|
||||||
source: 'Downloading Codeman',
|
source: 'Downloading Codeman',
|
||||||
packages: 'Installing packages',
|
packages: 'Installing packages',
|
||||||
build: 'Building Codeman',
|
build: 'Building Codeman',
|
||||||
@@ -1313,21 +1328,14 @@
|
|||||||
if (!g) return;
|
if (!g) return;
|
||||||
appendLog(msg.log);
|
appendLog(msg.log);
|
||||||
clearInterval(state.clock);
|
clearInterval(state.clock);
|
||||||
if (msg.ok && g.mode === 'agent') {
|
|
||||||
g.ok = true;
|
|
||||||
g.step = 'added';
|
|
||||||
renderWizard();
|
|
||||||
// Whether there is a Codeman to open from here.
|
|
||||||
checkLocal('quick');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.ok) {
|
if (msg.ok) {
|
||||||
g.ok = true;
|
g.ok = true;
|
||||||
g.step = 'finishing';
|
g.step = g.mode === 'agent' ? 'added' : 'finishing';
|
||||||
renderWizard();
|
renderWizard();
|
||||||
// The same watch as a Terminal install: it opens Codeman at the first
|
// An install gets the same watch as a Terminal install: it opens Codeman
|
||||||
// answer, and offers Start when none comes.
|
// at the first answer, and offers Start when none comes. An added agent
|
||||||
state.wait = { since: Date.now(), finishedAt: 0, port: g.port };
|
// only asks whether there is a Codeman to open from here.
|
||||||
|
if (g.mode !== 'agent') state.wait = newWait(g.port);
|
||||||
checkLocal('quick');
|
checkLocal('quick');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1346,7 +1354,8 @@
|
|||||||
renderLog();
|
renderLog();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each screen of the graphical install, drawn from state.gui alone.
|
// Each screen of the graphical install and of adding an agent, drawn from
|
||||||
|
// state.gui (and state.local for Open Codeman).
|
||||||
function renderWizard() {
|
function renderWizard() {
|
||||||
var g = state.gui;
|
var g = state.gui;
|
||||||
els.card.classList.toggle('wizard-on', !!g);
|
els.card.classList.toggle('wizard-on', !!g);
|
||||||
@@ -1418,10 +1427,6 @@
|
|||||||
else els.wizTitle.focus();
|
else els.wizTitle.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function localAnswers() {
|
|
||||||
return !!state.local && (state.local.state === 'running' || state.local.state === 'needsLogin');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderWizardButtons() {
|
function renderWizardButtons() {
|
||||||
var g = state.gui;
|
var g = state.gui;
|
||||||
var step = g.step;
|
var step = g.step;
|
||||||
@@ -1430,7 +1435,7 @@
|
|||||||
var next = {
|
var next = {
|
||||||
agent: agentMode ? (chosen ? 'Install ' + chosen.label : '') : 'Continue',
|
agent: agentMode ? (chosen ? 'Install ' + chosen.label : '') : 'Continue',
|
||||||
reach: 'Install Codeman', failed: 'Try again', prepareFailed: 'Try again', blocked: 'Install in Terminal',
|
reach: 'Install Codeman', failed: 'Try again', prepareFailed: 'Try again', blocked: 'Install in Terminal',
|
||||||
ready: 'Open Codeman', added: localAnswers() ? 'Open Codeman' : ''
|
ready: 'Open Codeman', added: state.local && answers(state.local) ? 'Open Codeman' : ''
|
||||||
}[step];
|
}[step];
|
||||||
els.wizNext.hidden = !next;
|
els.wizNext.hidden = !next;
|
||||||
els.wizNext.textContent = next || '';
|
els.wizNext.textContent = next || '';
|
||||||
@@ -1478,7 +1483,7 @@
|
|||||||
els.wizPw.value = '';
|
els.wizPw.value = '';
|
||||||
els.wizPw2.value = '';
|
els.wizPw2.value = '';
|
||||||
// Skipped: the first thing to say once Codeman answers.
|
// Skipped: the first thing to say once Codeman answers.
|
||||||
g.noAgent = !installedAgents().length && !msg.aiCli && offeredAgents().length > 0;
|
g.noAgent = needsAgent() && !msg.aiCli;
|
||||||
beginRun();
|
beginRun();
|
||||||
post(msg);
|
post(msg);
|
||||||
}
|
}
|
||||||
@@ -1524,7 +1529,7 @@
|
|||||||
function wizardBack() {
|
function wizardBack() {
|
||||||
var g = state.gui;
|
var g = state.gui;
|
||||||
if (!g) return;
|
if (!g) return;
|
||||||
if (g.step === 'reach' && !installedAgents().length && offeredAgents().length && !g.plan.blocker) {
|
if (g.step === 'reach' && needsAgent() && !g.plan.blocker) {
|
||||||
g.step = 'agent';
|
g.step = 'agent';
|
||||||
renderWizard();
|
renderWizard();
|
||||||
} else {
|
} else {
|
||||||
@@ -1532,6 +1537,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A wait for the server an install brings up, on `port` until the
|
||||||
|
// installer says otherwise.
|
||||||
|
function newWait(port) {
|
||||||
|
return { since: Date.now(), finishedAt: 0, port: port };
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleWait() {
|
function scheduleWait() {
|
||||||
clearTimeout(state.waitTimer);
|
clearTimeout(state.waitTimer);
|
||||||
state.waitTimer = setTimeout(function () { if (state.wait) checkLocal('quick'); }, WAIT_POLL_MS);
|
state.waitTimer = setTimeout(function () { if (state.wait) checkLocal('quick'); }, WAIT_POLL_MS);
|
||||||
@@ -1560,7 +1571,7 @@
|
|||||||
// Until the installer writes its LaunchAgent, the app looks at port 3000,
|
// Until the installer writes its LaunchAgent, the app looks at port 3000,
|
||||||
// where something else may answer when the install took another port.
|
// where something else may answer when the install took another port.
|
||||||
var ours = portOf(s.url) === w.port;
|
var ours = portOf(s.url) === w.port;
|
||||||
if (ours && (s.state === 'running' || s.state === 'needsLogin')) {
|
if (ours && answers(s)) {
|
||||||
var g = state.gui;
|
var g = state.gui;
|
||||||
if (g && g.step === 'finishing' && g.noAgent) {
|
if (g && g.step === 'finishing' && g.noAgent) {
|
||||||
clearTimeout(state.waitTimer);
|
clearTimeout(state.waitTimer);
|
||||||
@@ -1608,10 +1619,7 @@
|
|||||||
els.ssh.focus();
|
els.ssh.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.opening = true;
|
openTerminal({ cmd: 'installRemote', target: target });
|
||||||
syncLocalButtons();
|
|
||||||
setStatus('busy', 'Opening Terminal…');
|
|
||||||
post({ cmd: 'installRemote', target: target });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.__codemanDesktop = {
|
window.__codemanDesktop = {
|
||||||
@@ -1690,7 +1698,7 @@
|
|||||||
case 'installStarted':
|
case 'installStarted':
|
||||||
state.opening = false;
|
state.opening = false;
|
||||||
setStatus('', '');
|
setStatus('', '');
|
||||||
state.wait = { since: Date.now(), finishedAt: 0, port: Number(msg.port) || 3000 };
|
state.wait = newWait(Number(msg.port) || 3000);
|
||||||
selectTab('local');
|
selectTab('local');
|
||||||
renderLocal();
|
renderLocal();
|
||||||
scheduleWait();
|
scheduleWait();
|
||||||
|
|||||||
@@ -115,19 +115,22 @@ fn session_cookie(header: &str) -> Option<&str> {
|
|||||||
(name == COOKIE_NAME && !value.is_empty()).then_some(pair)
|
(name == COOKIE_NAME && !value.is_empty()).then_some(pair)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A refused login: Codeman counts each against the login rate limit, so the
|
||||||
|
/// polling ends.
|
||||||
|
fn refused(status: u16) -> bool {
|
||||||
|
matches!(status, 401 | 403 | 429)
|
||||||
|
}
|
||||||
|
|
||||||
/// What a status code says about asking again.
|
/// What a status code says about asking again.
|
||||||
fn answer(status: u16, body: impl FnOnce() -> Option<String>) -> Poll {
|
fn answer(status: u16, body: Option<&str>) -> Poll {
|
||||||
match status {
|
match status {
|
||||||
200 => body()
|
200 => body.and_then(count_working).map_or(Poll::Failed, |working| {
|
||||||
.as_deref()
|
Poll::Seen(Activity {
|
||||||
.and_then(count_working)
|
working: working > 0,
|
||||||
.map_or(Poll::Failed, |working| {
|
..Activity::default()
|
||||||
Poll::Seen(Activity {
|
})
|
||||||
working: working > 0,
|
}),
|
||||||
..Activity::default()
|
s if refused(s) => Poll::Refused,
|
||||||
})
|
|
||||||
}),
|
|
||||||
401 | 403 | 429 => Poll::Refused,
|
|
||||||
_ => Poll::Failed,
|
_ => Poll::Failed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,7 +195,7 @@ impl Poller {
|
|||||||
let Some((status, body)) = self.ask(&url) else {
|
let Some((status, body)) = self.ask(&url) else {
|
||||||
return Poll::Failed;
|
return Poll::Failed;
|
||||||
};
|
};
|
||||||
let mut activity = match answer(status, || body) {
|
let mut activity = match answer(status, body.as_deref()) {
|
||||||
Poll::Seen(activity) => activity,
|
Poll::Seen(activity) => activity,
|
||||||
other => return other,
|
other => return other,
|
||||||
};
|
};
|
||||||
@@ -203,7 +206,7 @@ impl Poller {
|
|||||||
self.alerts = alerts;
|
self.alerts = alerts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some((401 | 403 | 429, _)) => return Poll::Refused,
|
Some((s, _)) if refused(s) => return Poll::Refused,
|
||||||
// A server from before the list has no alerts to report.
|
// A server from before the list has no alerts to report.
|
||||||
Some((404, _)) => self.alerts = (false, false),
|
Some((404, _)) => self.alerts = (false, false),
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -264,17 +267,17 @@ mod tests {
|
|||||||
fn a_body_that_is_not_the_list_is_no_answer() {
|
fn a_body_that_is_not_the_list_is_no_answer() {
|
||||||
assert_eq!(count_working("<html>"), None);
|
assert_eq!(count_working("<html>"), None);
|
||||||
assert_eq!(count_working(r#"{"success":false,"error":"nope"}"#), None);
|
assert_eq!(count_working(r#"{"success":false,"error":"nope"}"#), None);
|
||||||
assert_eq!(answer(200, || Some("<html>".into())), Poll::Failed);
|
assert_eq!(answer(200, Some("<html>")), Poll::Failed);
|
||||||
assert_eq!(answer(200, || None), Poll::Failed);
|
assert_eq!(answer(200, None), Poll::Failed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_a_login_refusal_ends_the_polling() {
|
fn only_a_login_refusal_ends_the_polling() {
|
||||||
for status in [401, 403, 429] {
|
for status in [401, 403, 429] {
|
||||||
assert_eq!(answer(status, || None), Poll::Refused, "{status}");
|
assert_eq!(answer(status, None), Poll::Refused, "{status}");
|
||||||
}
|
}
|
||||||
for status in [301, 404, 500, 502, 503] {
|
for status in [301, 404, 500, 502, 503] {
|
||||||
assert_eq!(answer(status, || None), Poll::Failed, "{status}");
|
assert_eq!(answer(status, None), Poll::Failed, "{status}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ const MAX_SERVERS: usize = 8;
|
|||||||
/// How many recovery copies of an unreadable config can sit side by side:
|
/// How many recovery copies of an unreadable config can sit side by side:
|
||||||
/// `desktop.json.corrupt`, then `desktop.json.corrupt.1` to `.9`.
|
/// `desktop.json.corrupt`, then `desktop.json.corrupt.1` to `.9`.
|
||||||
const ASIDE_SLOTS: usize = 10;
|
const ASIDE_SLOTS: usize = 10;
|
||||||
const MIN_WIDTH: f64 = 480.0;
|
pub const MIN_WIDTH: f64 = 480.0;
|
||||||
const MIN_HEIGHT: f64 = 360.0;
|
pub const MIN_HEIGHT: f64 = 360.0;
|
||||||
const MAX_EDGE: f64 = 20_000.0;
|
const MAX_EDGE: f64 = 20_000.0;
|
||||||
|
|
||||||
/// Height of a standard title bar, in points.
|
/// Height of a standard title bar, in points.
|
||||||
|
|||||||
@@ -88,9 +88,7 @@ impl Keychain {
|
|||||||
password: password.to_owned(),
|
password: password.to_owned(),
|
||||||
reply,
|
reply,
|
||||||
};
|
};
|
||||||
if self.jobs.send(job).is_err() {
|
self.jobs.send(job).map_err(|_| WORKER_GONE.to_string())?;
|
||||||
return Err(WORKER_GONE.into());
|
|
||||||
}
|
|
||||||
answer.recv().unwrap_or_else(|_| Err(WORKER_GONE.into()))
|
answer.recv().unwrap_or_else(|_| Err(WORKER_GONE.into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
//! tmux in the home folder, so nothing in it asks for a password, and the two
|
//! tmux in the home folder, so nothing in it asks for a password, and the two
|
||||||
//! questions only a person can answer (which coding agent, who may reach
|
//! questions only a person can answer (which coding agent, who may reach
|
||||||
//! Codeman) are asked by the page and passed as flags. The Terminal route in
|
//! Codeman) are asked by the page and passed as flags. The Terminal route in
|
||||||
//! `setup.rs` stays for what this one cannot do, a Mac older than macOS 15 with
|
//! `terminal_install.rs` stays for what this one cannot do, a Mac older than
|
||||||
//! no tmux first of all.
|
//! macOS 15 with no tmux first of all.
|
||||||
//!
|
//!
|
||||||
//! `prepare` downloads the installer once into a private folder and asks it
|
//! `prepare` downloads the installer once into a private folder and asks it
|
||||||
//! what is on this Mac (`install.sh preflight`); `start` runs the same file with
|
//! what is on this Mac (`install.sh preflight`); `start` runs the same file with
|
||||||
@@ -100,8 +100,8 @@ impl Run {
|
|||||||
/// not one: it has no `--progress`.
|
/// not one: it has no `--progress`.
|
||||||
pub fn matches(self, command: &str) -> bool {
|
pub fn matches(self, command: &str) -> bool {
|
||||||
let suffix = format!("/{}/{SCRIPT_FILE}", self.folder());
|
let suffix = format!("/{}/{SCRIPT_FILE}", self.folder());
|
||||||
let mut words = command.split_whitespace();
|
command.split_whitespace().any(|w| w.ends_with(&suffix))
|
||||||
words.any(|w| w.ends_with(&suffix)) && command.split_whitespace().any(|w| w == "--progress")
|
&& command.split_whitespace().any(|w| w == "--progress")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A process of this run, if it is running.
|
/// A process of this run, if it is running.
|
||||||
@@ -269,11 +269,12 @@ fn download_installer(script: &Path) -> Result<(), String> {
|
|||||||
if out.code == Some(0) && script.is_file() {
|
if out.code == Some(0) && script.is_file() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
const FAILED: &str = "Could not download the installer. Check the internet connection, then try again.";
|
||||||
let said = out.stderr.trim();
|
let said = out.stderr.trim();
|
||||||
Err(if said.is_empty() {
|
Err(if said.is_empty() {
|
||||||
"Could not download the installer. Check the internet connection, then try again.".to_string()
|
FAILED.to_string()
|
||||||
} else {
|
} else {
|
||||||
format!("Could not download the installer. Check the internet connection, then try again.\n{said}")
|
format!("{FAILED}\n{said}")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,9 +572,8 @@ pub fn follow(run: Run, stale: impl Fn() -> bool, send: impl Fn(Value)) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let running = run.process().is_some();
|
let running = run.process().is_some();
|
||||||
let log = progress.feed(&read_more(&mut file, &path, &mut offset));
|
let mut log = progress.feed(&read_more(&mut file, &path, &mut offset));
|
||||||
if !running || progress.done {
|
if !running || progress.done {
|
||||||
let mut log = log;
|
|
||||||
log.extend(progress.finish());
|
log.extend(progress.finish());
|
||||||
let cancelled = CANCELLED.load(Ordering::SeqCst);
|
let cancelled = CANCELLED.load(Ordering::SeqCst);
|
||||||
if progress.done {
|
if progress.done {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
//! "Start Codeman on this Mac": `codeman web -d` through the user's login shell.
|
//! "Start Codeman on this Mac": `codeman web -d` through the user's login shell.
|
||||||
|
//! The same login-shell machinery answers whether Codeman is installed
|
||||||
|
//! (`installed`) and hands its environment to the graphical install
|
||||||
|
//! (`login_environment`); `exec_bounded` is the crate's bounded process runner.
|
||||||
//!
|
//!
|
||||||
//! A double-clicked app gets launchd's bare PATH (`/usr/bin:/bin:...`), which
|
//! A double-clicked app gets launchd's bare PATH (`/usr/bin:/bin:...`), which
|
||||||
//! finds neither `codeman` nor `node`, and the server inherits whatever
|
//! finds neither `codeman` nor `node`, and the server inherits whatever
|
||||||
@@ -143,7 +146,8 @@ fn pump<R: Read + Send + 'static>(pipe: Option<R>, stream: usize, tx: mpsc::Send
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `exec_bounded` for a start.
|
/// `exec_bounded` with the start's timeout message. The installed check and
|
||||||
|
/// `login_environment` run through it too, and drop its error.
|
||||||
fn exec(cmd: Command, deadline: Instant) -> Result<Output, String> {
|
fn exec(cmd: Command, deadline: Instant) -> Result<Output, String> {
|
||||||
exec_bounded(
|
exec_bounded(
|
||||||
cmd,
|
cmd,
|
||||||
@@ -248,13 +252,16 @@ fn parse_env(output: &str) -> Option<Vec<(String, String)>> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A portable variable name: a letter or `_`, then letters, digits or `_`.
|
||||||
|
pub fn is_env_name(name: &str) -> bool {
|
||||||
|
let mut chars = name.chars();
|
||||||
|
chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||||
|
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||||
|
}
|
||||||
|
|
||||||
fn env_entry(line: &str) -> Option<(&str, &str)> {
|
fn env_entry(line: &str) -> Option<(&str, &str)> {
|
||||||
let (name, value) = line.split_once('=')?;
|
let (name, value) = line.split_once('=')?;
|
||||||
let mut chars = name.chars();
|
is_env_name(name).then_some((name, value))
|
||||||
let first = chars.next()?;
|
|
||||||
let portable =
|
|
||||||
(first.is_ascii_alphabetic() || first == '_') && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
|
||||||
portable.then_some((name, value))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask `shell` for its environment with `flags`. The outer error ends the
|
/// Ask `shell` for its environment with `flags`. The outer error ends the
|
||||||
@@ -339,16 +346,13 @@ fn run_in_shell(
|
|||||||
/// 127 means "no codeman here" and moves on to the next way of asking; the
|
/// 127 means "no codeman here" and moves on to the next way of asking; the
|
||||||
/// interactive profile is asked only when `interactive` allows it.
|
/// interactive profile is asked only when `interactive` allows it.
|
||||||
fn launch(script: &str, shell: &Path, args: &[String], deadline: Instant, interactive: bool) -> Result<Run, String> {
|
fn launch(script: &str, shell: &Path, args: &[String], deadline: Instant, interactive: bool) -> Result<Run, String> {
|
||||||
let attempts = match flavor(shell) {
|
let Flavor::Capture(attempts) = flavor(shell) else {
|
||||||
Flavor::Posix => {
|
let result = run_in_shell(script, shell, false, args, deadline)?;
|
||||||
let result = run_in_shell(script, shell, false, args, deadline)?;
|
if result.code != Some(127) || !interactive {
|
||||||
if result.code != Some(127) || !interactive {
|
return Ok(result);
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
// Not on the login PATH; try the interactive profile too.
|
|
||||||
return run_in_shell(script, shell, true, args, deadline);
|
|
||||||
}
|
}
|
||||||
Flavor::Capture(attempts) => attempts,
|
// Not on the login PATH; try the interactive profile too.
|
||||||
|
return run_in_shell(script, shell, true, args, deadline);
|
||||||
};
|
};
|
||||||
let mut last_run = None;
|
let mut last_run = None;
|
||||||
let mut capture_error = None;
|
let mut capture_error = None;
|
||||||
@@ -386,8 +390,8 @@ const FIND_SCRIPT: &str = r#"if command -v codeman >/dev/null 2>&1; then exit 0;
|
|||||||
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then exit 0; fi
|
if [ -f "$HOME/.codeman/app/dist/index.js" ] && command -v node >/dev/null 2>&1; then exit 0; fi
|
||||||
exit 127"#;
|
exit 127"#;
|
||||||
|
|
||||||
/// How long the This Mac tab waits for the login shell to answer whether
|
/// How long one look at the login shell may take: whether Codeman is
|
||||||
/// Codeman is installed.
|
/// installed (the This Mac tab), or its environment for the graphical install.
|
||||||
const FIND_TIMEOUT: Duration = Duration::from_secs(15);
|
const FIND_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// `installed` with the shell given, so tests need not touch `$SHELL`.
|
/// `installed` with the shell given, so tests need not touch `$SHELL`.
|
||||||
@@ -453,7 +457,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.output
|
.output
|
||||||
};
|
};
|
||||||
// stdout that ended without a newline used to be glued to stderr.
|
// stdout that ends without a newline: stderr still starts a line of its own.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
said("Starting Codeman", "Error: port 3000 is in use\n"),
|
said("Starting Codeman", "Error: port 3000 is in use\n"),
|
||||||
"Starting Codeman\nError: port 3000 is in use"
|
"Starting Codeman\nError: port 3000 is in use"
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
//! The login a connect goes out with, and what the Keychain keeps once the
|
||||||
|
//! server has answered it.
|
||||||
|
|
||||||
|
use crate::credentials::{self, Keychain};
|
||||||
|
use crate::probe::ProbeOutcome;
|
||||||
|
|
||||||
|
/// The login a connect goes out with, and where its password came from. A
|
||||||
|
/// typed password wins, then the one the Keychain holds for this user. Last
|
||||||
|
/// comes the login of the Codeman on this Mac, which keeps it where its own
|
||||||
|
/// clients find it (setup::local_login: its launchd job, then
|
||||||
|
/// ~/.codeman/.env) and only answers it at its own loopback address. That one
|
||||||
|
/// is used only when the config expected no password in the Keychain
|
||||||
|
/// (`expect_saved`: one that should be there and is not is reported, not
|
||||||
|
/// papered over), only for the username in use when there is one, and it is
|
||||||
|
/// never saved anywhere. `local` is asked only then: it runs processes.
|
||||||
|
pub fn choose_login(
|
||||||
|
user: Option<String>,
|
||||||
|
typed_password: Option<String>,
|
||||||
|
saved: Option<String>,
|
||||||
|
expect_saved: bool,
|
||||||
|
local: impl FnOnce() -> Option<(String, String)>,
|
||||||
|
) -> (Option<String>, Option<String>, PasswordSource) {
|
||||||
|
if let Some(password) = typed_password {
|
||||||
|
return (user, Some(password), PasswordSource::Typed);
|
||||||
|
}
|
||||||
|
if let Some(password) = saved {
|
||||||
|
return (user, Some(password), PasswordSource::Keychain);
|
||||||
|
}
|
||||||
|
if !expect_saved {
|
||||||
|
if let Some((local_user, password)) = local() {
|
||||||
|
if user.as_deref().is_none_or(|current| current == local_user) {
|
||||||
|
return (Some(local_user), Some(password), PasswordSource::LocalServer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(user, None, PasswordSource::Absent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A login the probe just tried, and what the user asked for it.
|
||||||
|
pub struct ProbedLogin<'a> {
|
||||||
|
/// The server, as the config and the Keychain accounts key it.
|
||||||
|
pub server: &'a str,
|
||||||
|
/// The username the config named for this server before the connect.
|
||||||
|
pub previous_user: Option<&'a str>,
|
||||||
|
pub username: Option<&'a str>,
|
||||||
|
pub password: Option<&'a str>,
|
||||||
|
/// Where the password came from.
|
||||||
|
pub source: PasswordSource,
|
||||||
|
pub remember: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a connect's password came from, which decides what the Keychain
|
||||||
|
/// does with it (update_keychain).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PasswordSource {
|
||||||
|
/// Typed on the connect page this time: saved if Remember is on.
|
||||||
|
Typed,
|
||||||
|
/// From the Keychain: there already.
|
||||||
|
Keychain,
|
||||||
|
/// The local Codeman's own login (setup::local_login): it stays where it
|
||||||
|
/// is, and the Keychain never gets a copy.
|
||||||
|
LocalServer,
|
||||||
|
/// No password.
|
||||||
|
Absent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What update_keychain did: whether the Keychain now holds the login's
|
||||||
|
/// password, and whose items it deleted.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct KeychainUpdate {
|
||||||
|
pub saved: bool,
|
||||||
|
pub deleted: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring the Keychain in line with a probe the server answered, on the probe
|
||||||
|
/// worker. Only an accepted login changes anything (Ok, or a password the
|
||||||
|
/// server wants changed), since only then does the config get the new entry.
|
||||||
|
///
|
||||||
|
/// When that entry names another user than the config did (compared exactly,
|
||||||
|
/// as the account string is), the old user's item is deleted FIRST, before any
|
||||||
|
/// save: the Keychain worker runs jobs in order, so a Keychain that treats two
|
||||||
|
/// spellings of a name as one item cannot lose the new password to that
|
||||||
|
/// delete. Then the typed password is saved when Remember is on and deleted
|
||||||
|
/// when it is off, and a password the server wants changed is never kept.
|
||||||
|
pub fn update_keychain(keychain: &Keychain, outcome: &ProbeOutcome, login: &ProbedLogin<'_>) -> KeychainUpdate {
|
||||||
|
let mut update = KeychainUpdate::default();
|
||||||
|
if !matches!(outcome, ProbeOutcome::Ok { .. } | ProbeOutcome::PasswordChangeRequired) {
|
||||||
|
return update;
|
||||||
|
}
|
||||||
|
let mut delete = |user: &str| {
|
||||||
|
keychain.delete(&credentials::account(login.server, user));
|
||||||
|
update.deleted.push(user.to_string());
|
||||||
|
};
|
||||||
|
if let Some(old) = login.previous_user.filter(|old| login.username != Some(*old)) {
|
||||||
|
delete(old);
|
||||||
|
}
|
||||||
|
let saved = match (outcome, login.username, login.password) {
|
||||||
|
(ProbeOutcome::Ok { .. }, Some(user), Some(password)) if login.source == PasswordSource::Typed => {
|
||||||
|
if login.remember {
|
||||||
|
keychain
|
||||||
|
.save_blocking(&credentials::account(login.server, user), password)
|
||||||
|
.is_ok()
|
||||||
|
} else {
|
||||||
|
delete(user);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A password that came from the Keychain is saved there already.
|
||||||
|
(ProbeOutcome::Ok { .. }, Some(_), Some(_)) => login.source == PasswordSource::Keychain,
|
||||||
|
(ProbeOutcome::PasswordChangeRequired, Some(user), _) => {
|
||||||
|
delete(user);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
update.saved = saved;
|
||||||
|
update
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::credentials::memory;
|
||||||
|
|
||||||
|
fn none() -> Option<(String, String)> {
|
||||||
|
panic!("the local login must not be looked up here");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_login_a_connect_uses() {
|
||||||
|
let s = |v: &str| Some(v.to_string());
|
||||||
|
let local = || Some(("admin".to_string(), "from-this-mac".to_string()));
|
||||||
|
|
||||||
|
// A typed password wins over everything, and nothing else is asked.
|
||||||
|
assert_eq!(
|
||||||
|
choose_login(s("kay"), s("typed"), s("kept"), true, none),
|
||||||
|
(s("kay"), s("typed"), PasswordSource::Typed)
|
||||||
|
);
|
||||||
|
// Then the Keychain's.
|
||||||
|
assert_eq!(
|
||||||
|
choose_login(s("kay"), None, s("kept"), true, none),
|
||||||
|
(s("kay"), s("kept"), PasswordSource::Keychain)
|
||||||
|
);
|
||||||
|
// The Keychain should hold one and does not: reported as absent, the
|
||||||
|
// local login is not tried in its place.
|
||||||
|
assert_eq!(
|
||||||
|
choose_login(s("kay"), None, None, true, none),
|
||||||
|
(s("kay"), None, PasswordSource::Absent)
|
||||||
|
);
|
||||||
|
// Nothing typed, nothing expected: the Codeman on this Mac's own
|
||||||
|
// login, for no username or the same one.
|
||||||
|
assert_eq!(
|
||||||
|
choose_login(None, None, None, false, local),
|
||||||
|
(s("admin"), s("from-this-mac"), PasswordSource::LocalServer)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
choose_login(s("admin"), None, None, false, local),
|
||||||
|
(s("admin"), s("from-this-mac"), PasswordSource::LocalServer)
|
||||||
|
);
|
||||||
|
// Never for another user: the names are compared exactly.
|
||||||
|
assert_eq!(
|
||||||
|
choose_login(s("kay"), None, None, false, local),
|
||||||
|
(s("kay"), None, PasswordSource::Absent)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
choose_login(s("Admin"), None, None, false, local),
|
||||||
|
(s("Admin"), None, PasswordSource::Absent)
|
||||||
|
);
|
||||||
|
// No local login either.
|
||||||
|
assert_eq!(
|
||||||
|
choose_login(None, None, None, false, || None),
|
||||||
|
(None, None, PasswordSource::Absent)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// update_keychain for one probe answer; the log is what the Keychain did,
|
||||||
|
/// in order (the last load only waits for the queue to drain).
|
||||||
|
fn keychain_after(outcome: ProbeOutcome, login: ProbedLogin<'_>) -> (KeychainUpdate, Vec<String>) {
|
||||||
|
let (keychain, store) = memory::keychain();
|
||||||
|
let update = update_keychain(&keychain, &outcome, &login);
|
||||||
|
keychain.load_blocking("done");
|
||||||
|
let mut log = store.log();
|
||||||
|
assert_eq!(log.pop().as_deref(), Some("load done"));
|
||||||
|
(update, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login<'a>(
|
||||||
|
previous_user: Option<&'a str>,
|
||||||
|
username: Option<&'a str>,
|
||||||
|
source: PasswordSource,
|
||||||
|
remember: bool,
|
||||||
|
) -> ProbedLogin<'a> {
|
||||||
|
ProbedLogin {
|
||||||
|
server: "http://a/",
|
||||||
|
previous_user,
|
||||||
|
username,
|
||||||
|
password: (source != PasswordSource::Absent).then_some("secret"),
|
||||||
|
source,
|
||||||
|
remember,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_the_keychain_keeps_after_a_connect() {
|
||||||
|
let ok = || ProbeOutcome::Ok {
|
||||||
|
version: "1.48.0".into(),
|
||||||
|
};
|
||||||
|
let item = |user: &str| credentials::account("http://a/", user);
|
||||||
|
|
||||||
|
// A typed password is saved when Remember is on, deleted when it is off.
|
||||||
|
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, true));
|
||||||
|
assert!(update.saved && update.deleted.is_empty());
|
||||||
|
assert_eq!(log, [format!("save {}", item("kay"))]);
|
||||||
|
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Typed, false));
|
||||||
|
assert!(!update.saved);
|
||||||
|
assert_eq!(update.deleted, ["kay"]);
|
||||||
|
assert_eq!(log, [format!("delete {}", item("kay"))]);
|
||||||
|
|
||||||
|
// One that came from the Keychain is there already: nothing is written.
|
||||||
|
let (update, log) = keychain_after(ok(), login(Some("kay"), Some("kay"), PasswordSource::Keychain, true));
|
||||||
|
assert!(update.saved && log.is_empty());
|
||||||
|
|
||||||
|
// The local Codeman's own login never gets a copy, Remember or not.
|
||||||
|
let (update, log) = keychain_after(ok(), login(None, Some("admin"), PasswordSource::LocalServer, true));
|
||||||
|
assert!(!update.saved && update.deleted.is_empty() && log.is_empty());
|
||||||
|
|
||||||
|
// Another user than before: the old user's item goes FIRST, then the save.
|
||||||
|
let (update, log) = keychain_after(ok(), login(Some("old"), Some("new"), PasswordSource::Typed, true));
|
||||||
|
assert!(update.saved);
|
||||||
|
assert_eq!(update.deleted, ["old"]);
|
||||||
|
assert_eq!(
|
||||||
|
log,
|
||||||
|
[format!("delete {}", item("old")), format!("save {}", item("new"))]
|
||||||
|
);
|
||||||
|
|
||||||
|
// A password the server wants changed is never kept.
|
||||||
|
let (update, log) = keychain_after(
|
||||||
|
ProbeOutcome::PasswordChangeRequired,
|
||||||
|
login(Some("kay"), Some("kay"), PasswordSource::Typed, true),
|
||||||
|
);
|
||||||
|
assert!(!update.saved);
|
||||||
|
assert_eq!(update.deleted, ["kay"]);
|
||||||
|
assert_eq!(log, [format!("delete {}", item("kay"))]);
|
||||||
|
|
||||||
|
// A login the server did not accept changes nothing.
|
||||||
|
for refused in [
|
||||||
|
ProbeOutcome::BadCredentials,
|
||||||
|
ProbeOutcome::NeedsLogin,
|
||||||
|
ProbeOutcome::Refused,
|
||||||
|
ProbeOutcome::Timeout,
|
||||||
|
] {
|
||||||
|
let (update, log) = keychain_after(refused, login(Some("old"), Some("new"), PasswordSource::Typed, true));
|
||||||
|
assert!(!update.saved && update.deleted.is_empty() && log.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -270,7 +270,7 @@ const SETTLE: Duration = Duration::from_secs(3);
|
|||||||
const LOOK_EVERY: Duration = Duration::from_millis(250);
|
const LOOK_EVERY: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
/// What a blink needs from the machine, so its decisions can be tested.
|
/// What a blink needs from the machine, so its decisions can be tested.
|
||||||
pub trait Logo {
|
trait Logo {
|
||||||
/// The current mode, None if it could not be read.
|
/// The current mode, None if it could not be read.
|
||||||
fn mode(&self) -> Option<i64>;
|
fn mode(&self) -> Option<i64>;
|
||||||
/// Set the mode; false if the daemon did not take it.
|
/// Set the mode; false if the daemon did not take it.
|
||||||
@@ -281,7 +281,7 @@ pub trait Logo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The file that says a blink may have left the logo dark.
|
/// The file that says a blink may have left the logo dark.
|
||||||
pub struct Marker(Option<PathBuf>);
|
struct Marker(Option<PathBuf>);
|
||||||
|
|
||||||
impl Marker {
|
impl Marker {
|
||||||
fn default_location() -> Self {
|
fn default_location() -> Self {
|
||||||
@@ -363,7 +363,7 @@ fn blink(logo: &impl Logo, marker: &Marker, pattern: &[Beat], mut wait: impl FnM
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Put back a logo that a blink could not finish with.
|
/// Put back a logo that a blink could not finish with.
|
||||||
pub fn recover(logo: &impl Logo, marker: &Marker) {
|
fn recover(logo: &impl Logo, marker: &Marker) {
|
||||||
if !marker.is_set() {
|
if !marker.is_set() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -507,25 +507,24 @@ fn spawn_child(connection: Connection) -> Option<(Connection, ChildStdin)> {
|
|||||||
Some((connection, stdin))
|
Some((connection, stdin))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub use system::{available, blink_main, recover_at_launch, switched_off};
|
pub use system::{available, blink_main, recover_at_launch, switched_off};
|
||||||
|
|
||||||
|
/// No lit logo to blink outside macOS.
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
pub fn available() -> bool {
|
mod system {
|
||||||
false
|
pub fn available() -> bool {
|
||||||
}
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
pub fn blink_main() -> ! {
|
||||||
pub fn blink_main() -> ! {
|
std::process::exit(0)
|
||||||
std::process::exit(0)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
pub fn recover_at_launch() {}
|
||||||
pub fn recover_at_launch() {}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
pub fn switched_off() -> bool {
|
||||||
pub fn switched_off() -> bool {
|
false
|
||||||
false
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
|||||||
+196
-636
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,161 @@
|
|||||||
|
//! The menu bar.
|
||||||
|
|
||||||
|
use muda::accelerator::{Accelerator, Code, Modifiers};
|
||||||
|
use muda::{AboutMetadata, CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::logo::{self, BlinkStyle, Signal};
|
||||||
|
use crate::{APP_NAME, VERSION};
|
||||||
|
|
||||||
|
/// Codeman's repository and its documentation (the Help menu, About).
|
||||||
|
pub const REPO_URL: &str = "https://github.com/Ark0N/Codeman";
|
||||||
|
pub const DOCS_URL: &str = "https://github.com/Ark0N/Codeman/wiki";
|
||||||
|
|
||||||
|
pub mod menu_id {
|
||||||
|
pub const SWITCH: &str = "switch-server";
|
||||||
|
pub const ADD_AGENT: &str = "add-agent";
|
||||||
|
pub const RELOAD: &str = "reload";
|
||||||
|
pub const START_FULLSCREEN: &str = "start-fullscreen";
|
||||||
|
pub const DEVTOOLS: &str = "devtools";
|
||||||
|
pub const HELP_DOCS: &str = "help-docs";
|
||||||
|
pub const HELP_GITHUB: &str = "help-github";
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AppMenu {
|
||||||
|
pub bar: Menu,
|
||||||
|
pub window_menu: Submenu,
|
||||||
|
pub help_menu: Submenu,
|
||||||
|
pub start_fullscreen: CheckMenuItem,
|
||||||
|
/// One item per state and style. Empty on a Mac without a lit logo, which has no
|
||||||
|
/// such menu.
|
||||||
|
pub logo_styles: Vec<(Signal, BlinkStyle, CheckMenuItem)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_menu(config: &Config) -> muda::Result<AppMenu> {
|
||||||
|
let bar = Menu::new();
|
||||||
|
let about = PredefinedMenuItem::about(
|
||||||
|
Some("About Codeman"),
|
||||||
|
Some(AboutMetadata {
|
||||||
|
name: Some(APP_NAME.into()),
|
||||||
|
version: Some(VERSION.into()),
|
||||||
|
comments: Some("A native window for your Codeman server.".into()),
|
||||||
|
website: Some(REPO_URL.into()),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let switch = MenuItem::with_id(
|
||||||
|
menu_id::SWITCH,
|
||||||
|
"Switch Server\u{2026}",
|
||||||
|
true,
|
||||||
|
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyO)),
|
||||||
|
);
|
||||||
|
let app_menu = Submenu::with_items(
|
||||||
|
APP_NAME,
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&about,
|
||||||
|
&PredefinedMenuItem::separator(),
|
||||||
|
&switch,
|
||||||
|
&MenuItem::with_id(menu_id::ADD_AGENT, "Add a Coding Agent to This Mac\u{2026}", true, None),
|
||||||
|
&PredefinedMenuItem::separator(),
|
||||||
|
&PredefinedMenuItem::services(None),
|
||||||
|
&PredefinedMenuItem::separator(),
|
||||||
|
&PredefinedMenuItem::hide(None),
|
||||||
|
&PredefinedMenuItem::hide_others(None),
|
||||||
|
&PredefinedMenuItem::show_all(None),
|
||||||
|
&PredefinedMenuItem::separator(),
|
||||||
|
&PredefinedMenuItem::quit(None),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
// Without these items Cmd+C / Cmd+V do nothing inside a WKWebView.
|
||||||
|
let edit = Submenu::with_items(
|
||||||
|
"Edit",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&PredefinedMenuItem::undo(None),
|
||||||
|
&PredefinedMenuItem::redo(None),
|
||||||
|
&PredefinedMenuItem::separator(),
|
||||||
|
&PredefinedMenuItem::cut(None),
|
||||||
|
&PredefinedMenuItem::copy(None),
|
||||||
|
&PredefinedMenuItem::paste(None),
|
||||||
|
&PredefinedMenuItem::select_all(None),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let start_fullscreen = CheckMenuItem::with_id(
|
||||||
|
menu_id::START_FULLSCREEN,
|
||||||
|
"Always Open in Full Screen",
|
||||||
|
true,
|
||||||
|
config.start_fullscreen,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let view = Submenu::with_items(
|
||||||
|
"View",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&MenuItem::with_id(
|
||||||
|
menu_id::RELOAD,
|
||||||
|
"Reload",
|
||||||
|
true,
|
||||||
|
Some(Accelerator::new(Modifiers::META, Code::KeyR)),
|
||||||
|
),
|
||||||
|
&PredefinedMenuItem::separator(),
|
||||||
|
&PredefinedMenuItem::fullscreen(None),
|
||||||
|
&start_fullscreen,
|
||||||
|
&PredefinedMenuItem::separator(),
|
||||||
|
&MenuItem::with_id(
|
||||||
|
menu_id::DEVTOOLS,
|
||||||
|
"Developer Tools",
|
||||||
|
true,
|
||||||
|
Some(Accelerator::new(Modifiers::META | Modifiers::ALT, Code::KeyI)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
// Only a Mac with a lit Apple logo has this menu at all.
|
||||||
|
let mut logo_styles = Vec::new();
|
||||||
|
if logo::available() {
|
||||||
|
let submenu = Submenu::new("Blink Apple Logo", true);
|
||||||
|
for signal in Signal::ALL {
|
||||||
|
let group = Submenu::new(signal.label(), true);
|
||||||
|
for style in BlinkStyle::ALL {
|
||||||
|
let chosen = style == config.logo_blink.get(signal);
|
||||||
|
let item = CheckMenuItem::with_id(style.menu_id(signal), style.label(), true, chosen, None);
|
||||||
|
group.append(&item)?;
|
||||||
|
if style == BlinkStyle::Off {
|
||||||
|
group.append(&PredefinedMenuItem::separator())?;
|
||||||
|
}
|
||||||
|
logo_styles.push((signal, style, item));
|
||||||
|
}
|
||||||
|
submenu.append(&group)?;
|
||||||
|
}
|
||||||
|
// Right under "Always Open in Full Screen", before the separator.
|
||||||
|
view.insert(&submenu, 4)?;
|
||||||
|
}
|
||||||
|
let window_menu = Submenu::with_items(
|
||||||
|
"Window",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&PredefinedMenuItem::minimize(None),
|
||||||
|
&PredefinedMenuItem::maximize(None),
|
||||||
|
&PredefinedMenuItem::separator(),
|
||||||
|
&PredefinedMenuItem::close_window(None),
|
||||||
|
&PredefinedMenuItem::separator(),
|
||||||
|
&PredefinedMenuItem::bring_all_to_front(None),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let help_menu = Submenu::with_items(
|
||||||
|
"Help",
|
||||||
|
true,
|
||||||
|
&[
|
||||||
|
&MenuItem::with_id(menu_id::HELP_DOCS, "Codeman Documentation", true, None),
|
||||||
|
&MenuItem::with_id(menu_id::HELP_GITHUB, "Codeman on GitHub", true, None),
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
bar.append_items(&[&app_menu, &edit, &view, &window_menu, &help_menu])?;
|
||||||
|
Ok(AppMenu {
|
||||||
|
bar,
|
||||||
|
window_menu,
|
||||||
|
help_menu,
|
||||||
|
start_fullscreen,
|
||||||
|
logo_styles,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ pub const CONNECT_PAGE: &str = "codemanapp://localhost/index.html";
|
|||||||
/// Caps on what a page may hand the native side.
|
/// Caps on what a page may hand the native side.
|
||||||
pub const MAX_URL_LEN: usize = 4096;
|
pub const MAX_URL_LEN: usize = 4096;
|
||||||
pub const MAX_FIELD_LEN: usize = 1024;
|
pub const MAX_FIELD_LEN: usize = 1024;
|
||||||
|
const MAX_MESSAGE_LEN: usize = 16 * 1024;
|
||||||
|
|
||||||
/// Where the main window is in reaching a server. The gates below take it
|
/// Where the main window is in reaching a server. The gates below take it
|
||||||
/// instead of "is a server set", because what may talk to the native side
|
/// instead of "is a server set", because what may talk to the native side
|
||||||
@@ -61,6 +62,16 @@ impl Phase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The phase a commit of `committed` in the main window moves to: Loaded once
|
||||||
|
/// a page from the server that is loading is the document.
|
||||||
|
pub fn loaded_on_commit(phase: &Phase, committed: &str) -> Option<Phase> {
|
||||||
|
let Phase::Loading(server) = phase else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let url = Url::parse(committed).ok()?;
|
||||||
|
same_origin(&url, server).then(|| Phase::Loaded(server.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Turn what the user typed into a server URL: scheme added when missing,
|
/// Turn what the user typed into a server URL: scheme added when missing,
|
||||||
/// query and fragment dropped, and the path ending in `/` so `api/status`
|
/// query and fragment dropped, and the path ending in `/` so `api/status`
|
||||||
/// joins below a base path. The scheme is `https` for a tailnet `.ts.net`
|
/// joins below a base path. The scheme is `https` for a tailnet `.ts.net`
|
||||||
@@ -542,12 +553,9 @@ pub fn new_window(target: &str, phase: &Phase, via: Via) -> NewWindow {
|
|||||||
if target.len() > MAX_URL_LEN {
|
if target.len() > MAX_URL_LEN {
|
||||||
return NewWindow::Deny;
|
return NewWindow::Deny;
|
||||||
}
|
}
|
||||||
let Ok(url) = Url::parse(target) else {
|
let Some(url) = external_url(target) else {
|
||||||
return NewWindow::Deny;
|
return NewWindow::Deny;
|
||||||
};
|
};
|
||||||
if !matches!(url.scheme(), "http" | "https") {
|
|
||||||
return NewWindow::Deny;
|
|
||||||
}
|
|
||||||
let Some(srv) = phase.loaded() else {
|
let Some(srv) = phase.loaded() else {
|
||||||
return NewWindow::Deny;
|
return NewWindow::Deny;
|
||||||
};
|
};
|
||||||
@@ -631,7 +639,7 @@ pub enum ConnectCmd {
|
|||||||
/// Show the agent install that is running already.
|
/// Show the agent install that is running already.
|
||||||
AgentAttach,
|
AgentAttach,
|
||||||
/// Run the installer on another machine over SSH, in Terminal. `target`
|
/// Run the installer on another machine over SSH, in Terminal. `target`
|
||||||
/// is what the user typed; setup::SshTarget::parse checks it.
|
/// is what the user typed; terminal_install::SshTarget::parse checks it.
|
||||||
InstallRemote {
|
InstallRemote {
|
||||||
target: String,
|
target: String,
|
||||||
},
|
},
|
||||||
@@ -646,7 +654,12 @@ pub enum Ipc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(tag = "cmd", rename_all = "camelCase", deny_unknown_fields)]
|
#[serde(
|
||||||
|
tag = "cmd",
|
||||||
|
rename_all = "camelCase",
|
||||||
|
rename_all_fields = "camelCase",
|
||||||
|
deny_unknown_fields
|
||||||
|
)]
|
||||||
enum RawConnect {
|
enum RawConnect {
|
||||||
Ready,
|
Ready,
|
||||||
Connect {
|
Connect {
|
||||||
@@ -686,18 +699,15 @@ enum RawConnect {
|
|||||||
},
|
},
|
||||||
InstallPrepare,
|
InstallPrepare,
|
||||||
InstallStart {
|
InstallStart {
|
||||||
// rename_all above reaches variant names only, and deny_unknown_fields
|
#[serde(default)]
|
||||||
// would refuse the page's spelling of these.
|
|
||||||
#[serde(default, rename = "networkPassword")]
|
|
||||||
network_password: Option<String>,
|
network_password: Option<String>,
|
||||||
#[serde(default, rename = "aiCli")]
|
#[serde(default)]
|
||||||
ai_cli: Option<String>,
|
ai_cli: Option<String>,
|
||||||
},
|
},
|
||||||
InstallAttach,
|
InstallAttach,
|
||||||
InstallCancel,
|
InstallCancel,
|
||||||
AgentPrepare,
|
AgentPrepare,
|
||||||
AgentStart {
|
AgentStart {
|
||||||
#[serde(rename = "aiCli")]
|
|
||||||
ai_cli: String,
|
ai_cli: String,
|
||||||
},
|
},
|
||||||
AgentAttach,
|
AgentAttach,
|
||||||
@@ -751,7 +761,7 @@ fn is_connect_page(frame: &Url) -> bool {
|
|||||||
/// - A server page is listened to only once it committed, and only from a
|
/// - A server page is listened to only once it committed, and only from a
|
||||||
/// frame running Codeman's own app shell (trusted_frame).
|
/// frame running Codeman's own app shell (trusted_frame).
|
||||||
pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: bool) -> Option<Ipc> {
|
pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: bool) -> Option<Ipc> {
|
||||||
if body.len() > 16 * 1024 {
|
if body.len() > MAX_MESSAGE_LEN {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let frame = Url::parse(frame_url).ok()?;
|
let frame = Url::parse(frame_url).ok()?;
|
||||||
@@ -766,17 +776,12 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo
|
|||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
remember,
|
remember,
|
||||||
} => {
|
} if url.len() <= MAX_URL_LEN => ConnectCmd::Connect {
|
||||||
if url.len() > MAX_URL_LEN {
|
url,
|
||||||
return None;
|
username: bounded(username, MAX_FIELD_LEN)?,
|
||||||
}
|
password: bounded(password, MAX_FIELD_LEN)?,
|
||||||
ConnectCmd::Connect {
|
remember,
|
||||||
url,
|
},
|
||||||
username: bounded(username, MAX_FIELD_LEN)?,
|
|
||||||
password: bounded(password, MAX_FIELD_LEN)?,
|
|
||||||
remember,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RawConnect::Cancel => ConnectCmd::Cancel,
|
RawConnect::Cancel => ConnectCmd::Cancel,
|
||||||
RawConnect::StartServer { url } if url.len() <= MAX_URL_LEN => ConnectCmd::StartServer { url },
|
RawConnect::StartServer { url } if url.len() <= MAX_URL_LEN => ConnectCmd::StartServer { url },
|
||||||
RawConnect::Forget { url } if url.len() <= MAX_URL_LEN => ConnectCmd::Forget { url },
|
RawConnect::Forget { url } if url.len() <= MAX_URL_LEN => ConnectCmd::Forget { url },
|
||||||
@@ -789,15 +794,10 @@ pub fn parse_ipc(frame_url: &str, body: &str, phase: &Phase, is_main_window: boo
|
|||||||
RawConnect::InstallStart {
|
RawConnect::InstallStart {
|
||||||
network_password,
|
network_password,
|
||||||
ai_cli,
|
ai_cli,
|
||||||
} => {
|
} if ai_cli.as_deref().is_none_or(is_cli_id) => ConnectCmd::InstallStart {
|
||||||
if ai_cli.as_deref().is_some_and(|id| !is_cli_id(id)) {
|
network_password: bounded(network_password, MAX_FIELD_LEN)?.filter(|p| !p.is_empty()),
|
||||||
return None;
|
ai_cli,
|
||||||
}
|
},
|
||||||
ConnectCmd::InstallStart {
|
|
||||||
network_password: bounded(network_password, MAX_FIELD_LEN)?.filter(|p| !p.is_empty()),
|
|
||||||
ai_cli,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RawConnect::InstallAttach => ConnectCmd::InstallAttach,
|
RawConnect::InstallAttach => ConnectCmd::InstallAttach,
|
||||||
RawConnect::InstallCancel => ConnectCmd::InstallCancel,
|
RawConnect::InstallCancel => ConnectCmd::InstallCancel,
|
||||||
RawConnect::AgentPrepare => ConnectCmd::AgentPrepare,
|
RawConnect::AgentPrepare => ConnectCmd::AgentPrepare,
|
||||||
@@ -959,6 +959,32 @@ mod tests {
|
|||||||
assert!(!Phase::Loaded(srv).connect_page_live());
|
assert!(!Phase::Loaded(srv).connect_page_live());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_commit_from_the_loading_server_is_the_loaded_page() {
|
||||||
|
let server = Url::parse("https://box.ts.net/codeman/").unwrap();
|
||||||
|
let loading = Phase::Loading(server.clone());
|
||||||
|
assert_eq!(
|
||||||
|
loaded_on_commit(&loading, "https://box.ts.net/codeman/"),
|
||||||
|
Some(Phase::Loaded(server.clone()))
|
||||||
|
);
|
||||||
|
// Any page of that origin counts: the server may redirect within it.
|
||||||
|
assert_eq!(
|
||||||
|
loaded_on_commit(&loading, "https://box.ts.net/codeman/session/abc"),
|
||||||
|
Some(Phase::Loaded(server.clone()))
|
||||||
|
);
|
||||||
|
// Another origin, the connect page, or junk: still loading.
|
||||||
|
assert_eq!(loaded_on_commit(&loading, "https://other.ts.net/codeman/"), None);
|
||||||
|
assert_eq!(loaded_on_commit(&loading, "http://box.ts.net/codeman/"), None);
|
||||||
|
assert_eq!(loaded_on_commit(&loading, CONNECT_PAGE), None);
|
||||||
|
assert_eq!(loaded_on_commit(&loading, "not a url"), None);
|
||||||
|
// Only a load under way can finish.
|
||||||
|
assert_eq!(loaded_on_commit(&Phase::Connect, "https://box.ts.net/codeman/"), None);
|
||||||
|
assert_eq!(
|
||||||
|
loaded_on_commit(&Phase::Loaded(server), "https://box.ts.net/codeman/"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn navigation_policy() {
|
fn navigation_policy() {
|
||||||
let srv = u("http://127.0.0.1:3000/");
|
let srv = u("http://127.0.0.1:3000/");
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! One `GET <server>/api/status`, five seconds at most, run off the UI thread.
|
//! One `GET <server>/api/status`, five seconds at most, run off the UI thread.
|
||||||
//! The answer is classified into something the connect page can explain. It is
|
//! The answer is classified into something the connect page can explain. It is
|
||||||
//! asked once per user action, never in a loop: Codeman counts a request
|
//! asked on a user action, or by one of the This Mac tab's waits (for an
|
||||||
//! without valid credentials against the login rate limit (10 per IP).
|
//! install to bring Codeman up, for a server that is starting), which end at
|
||||||
|
//! the first answer, never as a standing poll: Codeman counts a request without
|
||||||
|
//! valid credentials against the login rate limit (10 per IP).
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -82,6 +84,16 @@ pub enum ProbeOutcome {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ProbeOutcome {
|
||||||
|
/// The server's version, when it answered as Codeman.
|
||||||
|
pub fn version(&self) -> Option<String> {
|
||||||
|
match self {
|
||||||
|
ProbeOutcome::Ok { version } => Some(version.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Classify an HTTP answer to `<server>/api/status`. `request` is the URL that
|
/// Classify an HTTP answer to `<server>/api/status`. `request` is the URL that
|
||||||
/// was asked, `location` and `retry_after` are those response headers.
|
/// was asked, `location` and `retry_after` are those response headers.
|
||||||
pub fn classify_response(
|
pub fn classify_response(
|
||||||
@@ -94,12 +106,10 @@ pub fn classify_response(
|
|||||||
) -> ProbeOutcome {
|
) -> ProbeOutcome {
|
||||||
match status {
|
match status {
|
||||||
200 => {
|
200 => {
|
||||||
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
|
let version = serde_json::from_str::<serde_json::Value>(body)
|
||||||
let version = parsed.as_ref().and_then(|v| {
|
.ok()
|
||||||
(v.get("success")? == &serde_json::Value::Bool(true))
|
.filter(|v| v["success"] == true)
|
||||||
.then(|| v.get("data")?.get("version")?.as_str().map(str::to_string))
|
.and_then(|v| v["data"]["version"].as_str().map(str::to_string));
|
||||||
.flatten()
|
|
||||||
});
|
|
||||||
match version {
|
match version {
|
||||||
Some(version) => ProbeOutcome::Ok { version },
|
Some(version) => ProbeOutcome::Ok { version },
|
||||||
None => ProbeOutcome::NotCodeman { status },
|
None => ProbeOutcome::NotCodeman { status },
|
||||||
@@ -742,8 +752,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A Codeman that sends its status headers and then stalls (a blocked
|
/// A Codeman that sends its status headers and then stalls (a blocked
|
||||||
/// event loop, a lossy link) did not answer in time. It is not "something
|
/// event loop, a lossy link) did not answer in time: a Timeout, not
|
||||||
/// else on this port", which is what an empty body read as before.
|
/// NotCodeman ("something else on this port").
|
||||||
#[test]
|
#[test]
|
||||||
fn a_body_that_stalls_is_a_timeout() {
|
fn a_body_that_stalls_is_a_timeout() {
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
|
|||||||
+29
-566
@@ -1,25 +1,12 @@
|
|||||||
//! The connect screen's "This Mac" tab and its Terminal installs: where the
|
//! The connect screen's "This Mac" tab: where the Codeman on this Mac listens,
|
||||||
//! Codeman on this Mac listens, whether it is installed, its login, which
|
//! whether it is installed, its login, and which other Codeman servers of this
|
||||||
//! other Codeman servers of this user run here, and the installer runs in
|
//! user run here. The install most people get runs in the app
|
||||||
//! Terminal for this Mac and for another machine over SSH.
|
//! (`gui_install.rs`); the Terminal installs are in `terminal_install.rs`.
|
||||||
//! The install most people get runs in the app, in `gui_install.rs`.
|
|
||||||
//!
|
|
||||||
//! The Terminal route is for what that one cannot do. `install.sh` asks for
|
|
||||||
//! the administrator password on a terminal (Homebrew, its sudo keep-alive),
|
|
||||||
//! the Xcode tools arrive as a system dialog, and ssh asks about host keys and
|
|
||||||
//! passwords on a terminal too. So the app writes a small script that deletes itself and
|
|
||||||
//! hands it to Terminal (`open -b com.apple.Terminal`), which needs no
|
|
||||||
//! permission, unlike scripting Terminal through osascript (an Automation
|
|
||||||
//! prompt). Everything the scripts contain is built here and unit-tested; the
|
|
||||||
//! only text from the user is the SSH login, which `SshTarget::parse` limits to
|
|
||||||
//! the characters of a user and host name before it is quoted.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
|
||||||
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
|
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -29,6 +16,7 @@ use crate::gui_install;
|
|||||||
use crate::local_server;
|
use crate::local_server;
|
||||||
use crate::policy;
|
use crate::policy;
|
||||||
use crate::probe::{self, ProbeOutcome};
|
use crate::probe::{self, ProbeOutcome};
|
||||||
|
use crate::terminal_install::LOCAL_SCRIPT_NAME;
|
||||||
|
|
||||||
/// Codeman's published installer (a redirect to `install.sh` on master). A test
|
/// Codeman's published installer (a redirect to `install.sh` on master). A test
|
||||||
/// build of an installer change sets `CODEMAN_DESKTOP_INSTALLER_URL` when it is
|
/// build of an installer change sets `CODEMAN_DESKTOP_INSTALLER_URL` when it is
|
||||||
@@ -49,10 +37,9 @@ pub const INSTALL_ONE_LINER: &str = "curl -fsSL https://getcodeman.com/install |
|
|||||||
/// Codeman's default port, and the first one a fresh install tries.
|
/// Codeman's default port, and the first one a fresh install tries.
|
||||||
pub const DEFAULT_PORT: u16 = 3000;
|
pub const DEFAULT_PORT: u16 = 3000;
|
||||||
|
|
||||||
/// The Terminal scripts' file names (`<name>-<pid>-<n>.command`). The local
|
/// The username Codeman logs in with when only a password is set
|
||||||
/// one is also how `inspect` recognizes an install that is still running.
|
/// (`CODEMAN_USERNAME` unset).
|
||||||
pub const LOCAL_SCRIPT_NAME: &str = "install-codeman-local";
|
pub const DEFAULT_USERNAME: &str = "admin";
|
||||||
pub const REMOTE_SCRIPT_NAME: &str = "install-codeman-remote";
|
|
||||||
|
|
||||||
/// How many ports above 3000 a fresh install tries when 3000 is taken.
|
/// How many ports above 3000 a fresh install tries when 3000 is taken.
|
||||||
const PORT_SEARCH: u16 = 100;
|
const PORT_SEARCH: u16 = 100;
|
||||||
@@ -195,10 +182,7 @@ pub fn parse_env_file(text: &str) -> HashMap<String, String> {
|
|||||||
let Some((name, value)) = line.split_once('=') else {
|
let Some((name, value)) = line.split_once('=') else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let mut chars = name.chars();
|
if !local_server::is_env_name(name) {
|
||||||
let portable = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
|
||||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
|
||||||
if !portable {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut value = value.trim();
|
let mut value = value.trim();
|
||||||
@@ -246,13 +230,13 @@ pub fn login_for(
|
|||||||
}
|
}
|
||||||
if let Some(s) = service {
|
if let Some(s) = service {
|
||||||
if let Some(password) = &s.password {
|
if let Some(password) = &s.password {
|
||||||
let username = s.username.clone().unwrap_or_else(|| "admin".into());
|
let username = s.username.clone().unwrap_or_else(|| DEFAULT_USERNAME.into());
|
||||||
return Some((username, password.clone()));
|
return Some((username, password.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let from_file = |key: &str| env_file.get(key).filter(|v| !v.is_empty()).cloned();
|
let from_file = |key: &str| env_file.get(key).filter(|v| !v.is_empty()).cloned();
|
||||||
let password = from_file("CODEMAN_PASSWORD")?;
|
let password = from_file("CODEMAN_PASSWORD")?;
|
||||||
let username = from_file("CODEMAN_USERNAME").unwrap_or_else(|| "admin".into());
|
let username = from_file("CODEMAN_USERNAME").unwrap_or_else(|| DEFAULT_USERNAME.into());
|
||||||
Some((username, password))
|
Some((username, password))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,12 +332,17 @@ pub fn is_codeman_server(command: &str) -> bool {
|
|||||||
server_args(command).is_some()
|
server_args(command).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The last path component of a command-line word.
|
||||||
|
fn basename(word: &str) -> &str {
|
||||||
|
word.rsplit('/').next().unwrap_or(word)
|
||||||
|
}
|
||||||
|
|
||||||
/// What follows `web` on a Codeman server's command line, or None for any
|
/// What follows `web` on a Codeman server's command line, or None for any
|
||||||
/// other command.
|
/// other command.
|
||||||
fn server_args(command: &str) -> Option<Vec<&str>> {
|
fn server_args(command: &str) -> Option<Vec<&str>> {
|
||||||
let words: Vec<&str> = command.split_whitespace().collect();
|
let words: Vec<&str> = command.split_whitespace().collect();
|
||||||
let is_entry = |w: &str| {
|
let is_entry = |w: &str| {
|
||||||
let name = w.rsplit('/').next().unwrap_or(w);
|
let name = basename(w);
|
||||||
w.ends_with("dist/index.js") || w.ends_with("src/index.ts") || name == "codeman" || name == "aicodeman"
|
w.ends_with("dist/index.js") || w.ends_with("src/index.ts") || name == "codeman" || name == "aicodeman"
|
||||||
};
|
};
|
||||||
// Any entry word that `web` follows: an earlier word may only look like
|
// Any entry word that `web` follows: an earlier word may only look like
|
||||||
@@ -372,14 +361,14 @@ fn server_args(command: &str) -> Option<Vec<&str>> {
|
|||||||
/// the port it asked for once the installer runs.
|
/// the port it asked for once the installer runs.
|
||||||
pub fn install_in_progress(command: &str) -> Option<Option<u16>> {
|
pub fn install_in_progress(command: &str) -> Option<Option<u16>> {
|
||||||
let words: Vec<&str> = command.split_whitespace().collect();
|
let words: Vec<&str> = command.split_whitespace().collect();
|
||||||
let name = |w: &&str| w.rsplit('/').next().unwrap_or(w).to_string();
|
let script = format!("{LOCAL_SCRIPT_NAME}-");
|
||||||
if words.iter().any(|w| {
|
if words.iter().any(|w| {
|
||||||
let n = name(w);
|
let n = basename(w);
|
||||||
n.starts_with(&format!("{LOCAL_SCRIPT_NAME}-")) && n.ends_with(".command")
|
n.starts_with(&script) && n.ends_with(".command")
|
||||||
}) {
|
}) {
|
||||||
return Some(None);
|
return Some(None);
|
||||||
}
|
}
|
||||||
if words.iter().any(|w| name(w).starts_with("codeman-install.")) || gui_install::Run::Codeman.matches(command) {
|
if words.iter().any(|w| basename(w).starts_with("codeman-install.")) || gui_install::Run::Codeman.matches(command) {
|
||||||
let port = words
|
let port = words
|
||||||
.iter()
|
.iter()
|
||||||
.position(|w| *w == "--port")
|
.position(|w| *w == "--port")
|
||||||
@@ -552,10 +541,7 @@ fn found_server(url: &Url, answer: &ProbeOutcome, default: &Url) -> FoundServer
|
|||||||
};
|
};
|
||||||
FoundServer {
|
FoundServer {
|
||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
version: match answer {
|
version: answer.version(),
|
||||||
ProbeOutcome::Ok { version } => Some(version.clone()),
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
state,
|
state,
|
||||||
is_default: url == default,
|
is_default: url == default,
|
||||||
}
|
}
|
||||||
@@ -715,11 +701,10 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
|||||||
};
|
};
|
||||||
let answer = probe::probe(&default, auth.as_deref());
|
let answer = probe::probe(&default, auth.as_deref());
|
||||||
|
|
||||||
// Every address one of this user's Codeman servers listens at. The one at
|
// The other addresses this user's Codeman servers listen at, asked once
|
||||||
// the default address has just answered; the others are asked now, once
|
// each. With nothing at the default address, the first that answers is
|
||||||
// each. With nothing at the default address, the first of them that
|
// what the tab opens (`shown`), so each gets the probe's usual time;
|
||||||
// answers is what the tab opens, as it always was, so each gets the
|
// otherwise they are only listed, and get less.
|
||||||
// probe's usual time; otherwise they are only listed, and get less.
|
|
||||||
let patience = if answer == ProbeOutcome::Refused {
|
let patience = if answer == ProbeOutcome::Refused {
|
||||||
probe::TIMEOUT
|
probe::TIMEOUT
|
||||||
} else {
|
} else {
|
||||||
@@ -755,10 +740,7 @@ pub fn inspect(depth: Depth) -> LocalStatus {
|
|||||||
Depth::Interactive => local_server::installed(true),
|
Depth::Interactive => local_server::installed(true),
|
||||||
};
|
};
|
||||||
let state = local_state(&outcome, !servers.is_empty(), !listening.is_empty(), installed);
|
let state = local_state(&outcome, !servers.is_empty(), !listening.is_empty(), installed);
|
||||||
let version = match &outcome {
|
let version = outcome.version();
|
||||||
ProbeOutcome::Ok { version } => Some(version.clone()),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
LocalStatus {
|
LocalStatus {
|
||||||
url: url.to_string(),
|
url: url.to_string(),
|
||||||
state,
|
state,
|
||||||
@@ -896,290 +878,6 @@ fn pick_port(free: impl Fn(u16) -> bool) -> u16 {
|
|||||||
.unwrap_or(DEFAULT_PORT)
|
.unwrap_or(DEFAULT_PORT)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `s` as one bash word.
|
|
||||||
pub fn sh_quote(s: &str) -> String {
|
|
||||||
format!("'{}'", s.replace('\'', r"'\''"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the This Mac install asks `install.sh` for.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct LocalInstall {
|
|
||||||
pub port: u16,
|
|
||||||
/// Let the installer ask how other devices reach Codeman (Tailscale, the
|
|
||||||
/// local network with a password). Off: this Mac only (`--local`).
|
|
||||||
pub network: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `install.sh`'s flags for a This Mac install. Always `--service`: a
|
|
||||||
/// LaunchAgent runs Codeman, so it keeps running when the app quits and starts
|
|
||||||
/// again at login, and the app finds it there (read_service). No `--yes`: the
|
|
||||||
/// user answers the installer's questions (Homebrew, AI coding tools).
|
|
||||||
pub fn local_install_args(opts: LocalInstall) -> Vec<String> {
|
|
||||||
let mut args = Vec::new();
|
|
||||||
if !opts.network {
|
|
||||||
args.push("--local".to_string());
|
|
||||||
}
|
|
||||||
args.push("--service".to_string());
|
|
||||||
args.push("--port".to_string());
|
|
||||||
args.push(opts.port.to_string());
|
|
||||||
args
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The Terminal script for a This Mac install. It downloads the installer to a
|
|
||||||
/// temporary file and runs it from there, so the installer's input is the
|
|
||||||
/// terminal itself.
|
|
||||||
pub fn local_install_script(opts: LocalInstall) -> String {
|
|
||||||
let args = local_install_args(opts);
|
|
||||||
let shown = format!("bash install.sh {}", args.join(" "));
|
|
||||||
let quoted: Vec<String> = args.iter().map(|a| sh_quote(a)).collect();
|
|
||||||
format!(
|
|
||||||
r#"#!/bin/bash
|
|
||||||
rm -f -- "$0"
|
|
||||||
printf '\033]0;%s\007' 'Install Codeman'
|
|
||||||
cat <<'CODEMAN_MSG'
|
|
||||||
Installing Codeman on this Mac.
|
|
||||||
|
|
||||||
The installer may ask for your Mac password (it installs Homebrew, Node.js and
|
|
||||||
tmux when they are missing) and which AI coding tools to set up. Codeman then
|
|
||||||
runs in the background and starts when you log in, so your sessions keep
|
|
||||||
running when the app is closed. The Codeman app opens it by itself when the
|
|
||||||
install is done.
|
|
||||||
|
|
||||||
CODEMAN_MSG
|
|
||||||
tmp=$(mktemp "${{TMPDIR:-/tmp}}/codeman-install.XXXXXX") || exit 1
|
|
||||||
trap 'rm -f "$tmp"' EXIT
|
|
||||||
echo {from}
|
|
||||||
if ! curl -fsSL {url} -o "$tmp"; then
|
|
||||||
echo
|
|
||||||
echo 'Could not download the installer. Check the internet connection, then try again from the Codeman app.'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo {shown}
|
|
||||||
echo
|
|
||||||
bash "$tmp" {args}
|
|
||||||
status=$?
|
|
||||||
echo
|
|
||||||
if [ "$status" -eq 0 ]; then
|
|
||||||
echo 'Done. You can close this window.'
|
|
||||||
else
|
|
||||||
echo "The installer stopped (exit $status). Its log is ~/.codeman/install.log"
|
|
||||||
fi
|
|
||||||
exit "$status"
|
|
||||||
"#,
|
|
||||||
from = sh_quote(&format!("Downloading the installer from {INSTALLER_URL}")),
|
|
||||||
url = sh_quote(INSTALLER_URL),
|
|
||||||
shown = sh_quote(&format!("$ {shown}")),
|
|
||||||
args = quoted.join(" "),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A machine to install Codeman on, as ssh takes it: `[user@]host`, plus a
|
|
||||||
/// `:port` the app passes as `-p`. The host may be a name from `~/.ssh/config`.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct SshTarget {
|
|
||||||
pub user: Option<String>,
|
|
||||||
pub host: String,
|
|
||||||
pub port: Option<u16>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const SSH_FORMAT: &str =
|
|
||||||
"Enter the machine the way ssh takes it: user@host (for example kay@build-box), or a name from your ssh config.";
|
|
||||||
|
|
||||||
impl SshTarget {
|
|
||||||
/// Only the characters of a user and host name, never a leading `-` (ssh
|
|
||||||
/// would read it as an option) and never an IPv6 literal (ambiguous with
|
|
||||||
/// the port; a host name or an IPv4 address works).
|
|
||||||
pub fn parse(input: &str) -> Result<Self, String> {
|
|
||||||
let s = input.trim();
|
|
||||||
if s.is_empty() || s.len() > 300 {
|
|
||||||
return Err(SSH_FORMAT.into());
|
|
||||||
}
|
|
||||||
let (user, rest) = match s.split_once('@') {
|
|
||||||
Some((u, r)) => (Some(u), r),
|
|
||||||
None => (None, s),
|
|
||||||
};
|
|
||||||
let (host, port) = match rest.split_once(':') {
|
|
||||||
Some((h, p)) => {
|
|
||||||
let port = p.parse::<u16>().ok().filter(|p| *p > 0).ok_or_else(|| {
|
|
||||||
"The part after the colon must be the SSH port, for example kay@build-box:2222.".to_string()
|
|
||||||
})?;
|
|
||||||
(h, Some(port))
|
|
||||||
}
|
|
||||||
None => (rest, None),
|
|
||||||
};
|
|
||||||
let name_ok = |v: &str, max: usize| {
|
|
||||||
!v.is_empty()
|
|
||||||
&& v.len() <= max
|
|
||||||
&& !v.starts_with(['-', '.'])
|
|
||||||
&& v.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
|
|
||||||
};
|
|
||||||
if let Some(u) = user {
|
|
||||||
if !name_ok(u, 64) {
|
|
||||||
return Err(SSH_FORMAT.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !name_ok(host, 253) {
|
|
||||||
return Err(SSH_FORMAT.into());
|
|
||||||
}
|
|
||||||
Ok(SshTarget {
|
|
||||||
user: user.map(str::to_string),
|
|
||||||
host: host.to_string(),
|
|
||||||
port,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `user@host`, or `host`.
|
|
||||||
pub fn destination(&self) -> String {
|
|
||||||
match &self.user {
|
|
||||||
Some(u) => format!("{u}@{}", self.host),
|
|
||||||
None => self.host.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The address the connect screen suggests once the install is done: the
|
|
||||||
/// installer's default port on that host. Tailscale and a sub-path give
|
|
||||||
/// another address, which the installer prints at the end.
|
|
||||||
pub fn suggested_url(&self) -> String {
|
|
||||||
format!("http://{}:{DEFAULT_PORT}", self.host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the remote login shell runs. It may be fish or tcsh, so the work is
|
|
||||||
/// one single-quoted `bash -c` word with nothing in it those shells treat
|
|
||||||
/// specially (no `!`, `$`, backslash or single quote). `curl | bash` is the
|
|
||||||
/// documented install and the installer asks its questions on ssh's terminal
|
|
||||||
/// (`-t`); `pipefail` makes a failed download fail the whole command, where a
|
|
||||||
/// plain pipe would report bash's success on an empty script. The installer
|
|
||||||
/// needs bash anyway.
|
|
||||||
pub fn remote_command() -> String {
|
|
||||||
format!(
|
|
||||||
"bash -o pipefail -c 'if command -v curl >/dev/null 2>&1; then curl -fsSL {u} | bash; \
|
|
||||||
elif command -v wget >/dev/null 2>&1; then wget -qO- {u} | bash; \
|
|
||||||
else echo \"Codeman needs curl or wget on this machine.\" >&2; exit 1; fi'",
|
|
||||||
u = INSTALLER_URL
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `ssh`'s arguments for an install on `target`: a terminal (`-t`) for the
|
|
||||||
/// installer's questions, the port, and `--` before the destination.
|
|
||||||
pub fn ssh_args(target: &SshTarget) -> Vec<String> {
|
|
||||||
let mut args = vec!["-t".to_string()];
|
|
||||||
if let Some(port) = target.port {
|
|
||||||
args.push("-p".into());
|
|
||||||
args.push(port.to_string());
|
|
||||||
}
|
|
||||||
args.push("--".into());
|
|
||||||
args.push(target.destination());
|
|
||||||
args.push(remote_command());
|
|
||||||
args
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The Terminal script for an install on another machine over SSH.
|
|
||||||
pub fn remote_install_script(target: &SshTarget) -> String {
|
|
||||||
let args: Vec<String> = ssh_args(target).iter().map(|a| sh_quote(a)).collect();
|
|
||||||
let dest = target.destination();
|
|
||||||
format!(
|
|
||||||
r#"#!/bin/bash
|
|
||||||
rm -f -- "$0"
|
|
||||||
printf '\033]0;%s\007' {title}
|
|
||||||
echo {what}
|
|
||||||
cat <<'CODEMAN_MSG'
|
|
||||||
|
|
||||||
ssh may ask you to confirm the machine and for its password. Then the installer
|
|
||||||
asks how Codeman should be reached (Tailscale, the local network with a password,
|
|
||||||
or that machine only) and whether to run it in the background. At the end it
|
|
||||||
prints the address of Codeman there: connect to it in the Codeman app, under
|
|
||||||
Another machine.
|
|
||||||
|
|
||||||
CODEMAN_MSG
|
|
||||||
ssh {args}
|
|
||||||
status=$?
|
|
||||||
echo
|
|
||||||
if [ "$status" -eq 0 ]; then
|
|
||||||
echo 'Done. You can close this window.'
|
|
||||||
else
|
|
||||||
echo "ssh or the installer stopped (exit $status)."
|
|
||||||
fi
|
|
||||||
exit "$status"
|
|
||||||
"#,
|
|
||||||
title = sh_quote(&format!("Install Codeman on {}", target.host)),
|
|
||||||
what = sh_quote(&format!("Installing Codeman on {dest} over SSH.")),
|
|
||||||
args = args.join(" "),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
/// Run `script` in a new Terminal window. The script file is private to this
|
|
||||||
/// user (the per-user temporary folder, mode 0700, created fresh) and deletes
|
|
||||||
/// itself as it starts.
|
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
pub fn open_in_terminal(name: &str, script: &str) -> Result<(), String> {
|
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
|
||||||
|
|
||||||
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let path = std::env::temp_dir().join(format!("{name}-{}-{n}.command", std::process::id()));
|
|
||||||
let written = std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.mode(0o700)
|
|
||||||
.open(&path)
|
|
||||||
.and_then(|mut f| f.write_all(script.as_bytes()));
|
|
||||||
if let Err(e) = written {
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
return Err(format!("Could not write the install script: {e}"));
|
|
||||||
}
|
|
||||||
let out = Command::new("/usr/bin/open")
|
|
||||||
.args(["-b", "com.apple.Terminal"])
|
|
||||||
.arg(&path)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.output();
|
|
||||||
match out {
|
|
||||||
Ok(o) if o.status.success() => Ok(()),
|
|
||||||
Ok(o) => {
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
let said = String::from_utf8_lossy(&o.stderr).trim().to_string();
|
|
||||||
Err(if said.is_empty() {
|
|
||||||
"Could not open Terminal.".into()
|
|
||||||
} else {
|
|
||||||
format!("Could not open Terminal: {said}")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
Err(format!("Could not open Terminal: {e}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
|
||||||
pub fn open_in_terminal(_name: &str, _script: &str) -> Result<(), String> {
|
|
||||||
let _ = &SCRIPT_COUNTER;
|
|
||||||
Err("Installing from the app is only available on macOS so far.".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Put `text` on the clipboard (`pbcopy`). The connect page is not a secure
|
|
||||||
/// context, so it cannot use the web clipboard API itself.
|
|
||||||
pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
|
|
||||||
let mut child = Command::new("/usr/bin/pbcopy")
|
|
||||||
.stdin(Stdio::piped())
|
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null())
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| format!("Could not copy: {e}"))?;
|
|
||||||
if let Some(mut stdin) = child.stdin.take() {
|
|
||||||
stdin
|
|
||||||
.write_all(text.as_bytes())
|
|
||||||
.map_err(|e| format!("Could not copy: {e}"))?;
|
|
||||||
}
|
|
||||||
match child.wait() {
|
|
||||||
Ok(s) if s.success() => Ok(()),
|
|
||||||
_ => Err("Could not copy the command.".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1217,8 +915,7 @@ mod tests {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
#[test]
|
#[test]
|
||||||
fn plutil_reads_both_launch_agent_shapes() {
|
fn plutil_reads_both_launch_agent_shapes() {
|
||||||
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
let dir = std::env::temp_dir().join(format!("codeman-plist-test-{}", std::process::id()));
|
||||||
let dir = std::env::temp_dir().join(format!("codeman-plist-test-{}-{n}", std::process::id()));
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
let install_sh = r#"<?xml version="1.0" encoding="UTF-8"?>
|
let install_sh = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
@@ -1850,7 +1547,7 @@ mod tests {
|
|||||||
/// the answers come back in the order of the addresses.
|
/// the answers come back in the order of the addresses.
|
||||||
#[test]
|
#[test]
|
||||||
fn found_servers_are_asked_together() {
|
fn found_servers_are_asked_together() {
|
||||||
use std::io::Read;
|
use std::io::{Read, Write};
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
|
||||||
@@ -1923,238 +1620,4 @@ mod tests {
|
|||||||
assert_eq!(pick_port(|p| p >= 3002), 3002);
|
assert_eq!(pick_port(|p| p >= 3002), 3002);
|
||||||
assert_eq!(pick_port(|_| false), 3000);
|
assert_eq!(pick_port(|_| false), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn quoting() {
|
|
||||||
assert_eq!(sh_quote("plain"), "'plain'");
|
|
||||||
assert_eq!(sh_quote("it's"), r"'it'\''s'");
|
|
||||||
assert_eq!(sh_quote(""), "''");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn local_install_flags() {
|
|
||||||
assert_eq!(
|
|
||||||
local_install_args(LocalInstall {
|
|
||||||
port: 3000,
|
|
||||||
network: false
|
|
||||||
}),
|
|
||||||
["--local", "--service", "--port", "3000"]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
local_install_args(LocalInstall {
|
|
||||||
port: 3007,
|
|
||||||
network: true
|
|
||||||
}),
|
|
||||||
["--service", "--port", "3007"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a script under bash with `curl` and `bash`... replaced, and report
|
|
||||||
/// what it did. The real installer never runs in a test.
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn run_script(script: &str, fakes: &[(&str, &str)]) -> (String, i32, bool) {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let dir = std::env::temp_dir().join(format!("codeman-setup-test-{}-{n}", std::process::id()));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
|
||||||
for (name, body) in fakes {
|
|
||||||
let p = dir.join("bin").join(name);
|
|
||||||
std::fs::write(&p, body.replace("DIR", &dir.display().to_string())).unwrap();
|
|
||||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
||||||
}
|
|
||||||
let file = dir.join("run.command");
|
|
||||||
std::fs::write(&file, script).unwrap();
|
|
||||||
let out = Command::new("/bin/bash")
|
|
||||||
.arg(&file)
|
|
||||||
.env("PATH", format!("{}:/usr/bin:/bin", dir.join("bin").display()))
|
|
||||||
.env("TMPDIR", &dir)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
let log = std::fs::read_to_string(dir.join("log")).unwrap_or_default();
|
|
||||||
let self_deleted = !file.exists();
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
(log, out.status.code().unwrap_or(-1), self_deleted)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn local_script_downloads_then_runs_the_installer_with_its_flags() {
|
|
||||||
let script = local_install_script(LocalInstall {
|
|
||||||
port: 3001,
|
|
||||||
network: false,
|
|
||||||
});
|
|
||||||
let (log, code, self_deleted) = run_script(
|
|
||||||
&script,
|
|
||||||
&[(
|
|
||||||
"curl",
|
|
||||||
r#"#!/bin/sh
|
|
||||||
echo "curl $*" >> DIR/log
|
|
||||||
out=""
|
|
||||||
while [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done
|
|
||||||
cat > "$out" <<'INSTALLER'
|
|
||||||
for a in "$@"; do printf '[%s]' "$a" >> DIR/log; done
|
|
||||||
echo >> DIR/log
|
|
||||||
exit 7
|
|
||||||
INSTALLER
|
|
||||||
"#,
|
|
||||||
)],
|
|
||||||
);
|
|
||||||
assert!(self_deleted, "the script must delete itself");
|
|
||||||
assert!(
|
|
||||||
log.starts_with("curl -fsSL https://getcodeman.com/install -o "),
|
|
||||||
"{log}"
|
|
||||||
);
|
|
||||||
assert!(log.contains("\n[--local][--service][--port][3001]\n"), "{log}");
|
|
||||||
assert_eq!(code, 7, "the installer's exit status is the script's");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn local_script_stops_when_the_download_fails() {
|
|
||||||
let script = local_install_script(LocalInstall {
|
|
||||||
port: 3000,
|
|
||||||
network: true,
|
|
||||||
});
|
|
||||||
let (log, code, _) = run_script(&script, &[("curl", "#!/bin/sh\necho curl >> DIR/log\nexit 22\n")]);
|
|
||||||
assert_eq!(log, "curl\n");
|
|
||||||
assert_eq!(code, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ssh_targets() {
|
|
||||||
assert_eq!(
|
|
||||||
SshTarget::parse(" kay@build-box ").unwrap(),
|
|
||||||
SshTarget {
|
|
||||||
user: Some("kay".into()),
|
|
||||||
host: "build-box".into(),
|
|
||||||
port: None
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
SshTarget::parse("pi@192.168.1.20:2222").unwrap(),
|
|
||||||
SshTarget {
|
|
||||||
user: Some("pi".into()),
|
|
||||||
host: "192.168.1.20".into(),
|
|
||||||
port: Some(2222)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
assert_eq!(SshTarget::parse("macmini").unwrap().destination(), "macmini");
|
|
||||||
assert_eq!(
|
|
||||||
SshTarget::parse("a.b@tnode.tailnet.ts.net").unwrap().destination(),
|
|
||||||
"a.b@tnode.tailnet.ts.net"
|
|
||||||
);
|
|
||||||
for bad in [
|
|
||||||
"",
|
|
||||||
"-oProxyCommand=touch /tmp/x",
|
|
||||||
"kay@-oProxyCommand=x",
|
|
||||||
"-l@host",
|
|
||||||
"kay@host name",
|
|
||||||
"kay@host;rm",
|
|
||||||
"kay@host$(x)",
|
|
||||||
"k'y@host",
|
|
||||||
"kay@",
|
|
||||||
"@host",
|
|
||||||
"kay@host:",
|
|
||||||
"kay@host:0",
|
|
||||||
"kay@host:x",
|
|
||||||
"kay@fe80::1",
|
|
||||||
"kay@[::1]",
|
|
||||||
"a@b@c",
|
|
||||||
".hidden",
|
|
||||||
] {
|
|
||||||
assert!(SshTarget::parse(bad).is_err(), "accepted {bad:?}");
|
|
||||||
}
|
|
||||||
assert_eq!(SshTarget::parse("kay@box").unwrap().suggested_url(), "http://box:3000");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ssh_arguments() {
|
|
||||||
let t = SshTarget::parse("kay@box:2222").unwrap();
|
|
||||||
let args = ssh_args(&t);
|
|
||||||
assert_eq!(&args[..5], ["-t", "-p", "2222", "--", "kay@box"]);
|
|
||||||
assert_eq!(args.len(), 6);
|
|
||||||
let remote = &args[5];
|
|
||||||
let head = "bash -o pipefail -c '";
|
|
||||||
assert!(remote.starts_with(head) && remote.ends_with('\''));
|
|
||||||
// One word for fish and tcsh: no quote inside, nothing they expand.
|
|
||||||
let inner = &remote[head.len()..remote.len() - 1];
|
|
||||||
assert!(!inner.contains(['\'', '!', '$', '\\', '`']), "{inner}");
|
|
||||||
assert!(inner.contains("curl -fsSL https://getcodeman.com/install | bash"));
|
|
||||||
assert_eq!(ssh_args(&SshTarget::parse("box").unwrap())[..3], ["-t", "--", "box"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn remote_script_runs_ssh_with_exactly_those_arguments() {
|
|
||||||
let t = SshTarget::parse("kay@box:2222").unwrap();
|
|
||||||
let (log, code, self_deleted) = run_script(
|
|
||||||
&remote_install_script(&t),
|
|
||||||
&[(
|
|
||||||
"ssh",
|
|
||||||
"#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\" >> DIR/log; done\necho >> DIR/log\nexit 3\n",
|
|
||||||
)],
|
|
||||||
);
|
|
||||||
assert!(self_deleted);
|
|
||||||
let expected: String = ssh_args(&t).iter().map(|a| format!("[{a}]")).collect();
|
|
||||||
assert_eq!(log, format!("{expected}\n"));
|
|
||||||
assert_eq!(code, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The remote command does what it says under a POSIX shell: curl first,
|
|
||||||
/// wget without curl, and a clear error with neither.
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn remote_command_picks_a_downloader() {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
|
||||||
let dir = std::env::temp_dir().join(format!("codeman-remote-cmd-{}-{n}", std::process::id()));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
|
||||||
let exe = |name: &str, body: &str| {
|
|
||||||
let p = dir.join("bin").join(name);
|
|
||||||
std::fs::write(&p, body).unwrap();
|
|
||||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
||||||
};
|
|
||||||
// The outer `bash -o pipefail` is the real one; the `bash` the
|
|
||||||
// download is piped into only reports what it was fed.
|
|
||||||
exe(
|
|
||||||
"bash",
|
|
||||||
"#!/bin/sh\nif [ \"$1\" = -o ]; then exec /bin/bash \"$@\"; fi\nprintf 'bash got: '; /bin/cat\n",
|
|
||||||
);
|
|
||||||
exe("wget", "#!/bin/sh\necho \"wget $*\"\n");
|
|
||||||
let run = || {
|
|
||||||
let out = Command::new("/bin/sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(remote_command())
|
|
||||||
.env("PATH", dir.join("bin"))
|
|
||||||
.output()
|
|
||||||
.unwrap();
|
|
||||||
(
|
|
||||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
|
||||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
|
||||||
out.status.code(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let (out, _, _) = run();
|
|
||||||
assert_eq!(out, "bash got: wget -qO- https://getcodeman.com/install\n");
|
|
||||||
exe("curl", "#!/bin/sh\necho \"curl $*\"\n");
|
|
||||||
let (out, _, _) = run();
|
|
||||||
assert_eq!(out, "bash got: curl -fsSL https://getcodeman.com/install\n");
|
|
||||||
// A failed download fails the command (pipefail), not "Done".
|
|
||||||
exe(
|
|
||||||
"curl",
|
|
||||||
"#!/bin/sh\necho 'curl: (6) Could not resolve host' >&2\nexit 6\n",
|
|
||||||
);
|
|
||||||
let (_, _, code) = run();
|
|
||||||
assert_ne!(code, Some(0));
|
|
||||||
std::fs::remove_file(dir.join("bin/curl")).unwrap();
|
|
||||||
std::fs::remove_file(dir.join("bin/wget")).unwrap();
|
|
||||||
let (out, err, code) = run();
|
|
||||||
assert_eq!(out, "");
|
|
||||||
assert!(err.contains("needs curl or wget"), "{err}");
|
|
||||||
assert_eq!(code, Some(1));
|
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
//! The installs that run in Terminal: on this Mac, and on another machine over
|
||||||
|
//! SSH.
|
||||||
|
//!
|
||||||
|
//! The Terminal route is for what the in-app install (`gui_install.rs`) cannot
|
||||||
|
//! do. `install.sh` asks for the administrator password on a terminal
|
||||||
|
//! (Homebrew, its sudo keep-alive), the Xcode tools arrive as a system dialog,
|
||||||
|
//! and ssh asks about host keys and passwords on a terminal too. So the app
|
||||||
|
//! writes a small script that deletes itself and hands it to Terminal
|
||||||
|
//! (`open -b com.apple.Terminal`), which needs no permission, unlike scripting
|
||||||
|
//! Terminal through osascript (an Automation prompt). Everything the scripts
|
||||||
|
//! contain is built here and unit-tested; the only text from the user is the
|
||||||
|
//! SSH login, which `SshTarget::parse` limits to the characters of a user and
|
||||||
|
//! host name before it is quoted.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use crate::setup::{DEFAULT_PORT, INSTALLER_URL};
|
||||||
|
|
||||||
|
/// The Terminal scripts' file names (`<name>-<pid>-<n>.command`). The local
|
||||||
|
/// one is also how `inspect` recognizes an install that is still running.
|
||||||
|
pub const LOCAL_SCRIPT_NAME: &str = "install-codeman-local";
|
||||||
|
pub const REMOTE_SCRIPT_NAME: &str = "install-codeman-remote";
|
||||||
|
|
||||||
|
/// `s` as one bash word.
|
||||||
|
pub fn sh_quote(s: &str) -> String {
|
||||||
|
format!("'{}'", s.replace('\'', r"'\''"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the This Mac install asks `install.sh` for.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct LocalInstall {
|
||||||
|
pub port: u16,
|
||||||
|
/// Let the installer ask how other devices reach Codeman (Tailscale, the
|
||||||
|
/// local network with a password). Off: this Mac only (`--local`).
|
||||||
|
pub network: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `install.sh`'s flags for a This Mac install. Always `--service`: a
|
||||||
|
/// LaunchAgent runs Codeman, so it keeps running when the app quits and starts
|
||||||
|
/// again at login, and the app finds it there (read_service). No `--yes`: the
|
||||||
|
/// user answers the installer's questions (Homebrew, AI coding tools).
|
||||||
|
pub fn local_install_args(opts: LocalInstall) -> Vec<String> {
|
||||||
|
let mut args = Vec::new();
|
||||||
|
if !opts.network {
|
||||||
|
args.push("--local".to_string());
|
||||||
|
}
|
||||||
|
args.push("--service".to_string());
|
||||||
|
args.push("--port".to_string());
|
||||||
|
args.push(opts.port.to_string());
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Terminal script for a This Mac install. It downloads the installer to a
|
||||||
|
/// temporary file and runs it from there, so the installer's input is the
|
||||||
|
/// terminal itself.
|
||||||
|
pub fn local_install_script(opts: LocalInstall) -> String {
|
||||||
|
let args = local_install_args(opts);
|
||||||
|
let shown = format!("bash install.sh {}", args.join(" "));
|
||||||
|
let quoted: Vec<String> = args.iter().map(|a| sh_quote(a)).collect();
|
||||||
|
format!(
|
||||||
|
r#"#!/bin/bash
|
||||||
|
rm -f -- "$0"
|
||||||
|
printf '\033]0;%s\007' 'Install Codeman'
|
||||||
|
cat <<'CODEMAN_MSG'
|
||||||
|
Installing Codeman on this Mac.
|
||||||
|
|
||||||
|
The installer may ask for your Mac password (it installs Homebrew, Node.js and
|
||||||
|
tmux when they are missing) and which AI coding tools to set up. Codeman then
|
||||||
|
runs in the background and starts when you log in, so your sessions keep
|
||||||
|
running when the app is closed. The Codeman app opens it by itself when the
|
||||||
|
install is done.
|
||||||
|
|
||||||
|
CODEMAN_MSG
|
||||||
|
tmp=$(mktemp "${{TMPDIR:-/tmp}}/codeman-install.XXXXXX") || exit 1
|
||||||
|
trap 'rm -f "$tmp"' EXIT
|
||||||
|
echo {from}
|
||||||
|
if ! curl -fsSL {url} -o "$tmp"; then
|
||||||
|
echo
|
||||||
|
echo 'Could not download the installer. Check the internet connection, then try again from the Codeman app.'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo {shown}
|
||||||
|
echo
|
||||||
|
bash "$tmp" {args}
|
||||||
|
status=$?
|
||||||
|
echo
|
||||||
|
if [ "$status" -eq 0 ]; then
|
||||||
|
echo 'Done. You can close this window.'
|
||||||
|
else
|
||||||
|
echo "The installer stopped (exit $status). Its log is ~/.codeman/install.log"
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
|
"#,
|
||||||
|
from = sh_quote(&format!("Downloading the installer from {INSTALLER_URL}")),
|
||||||
|
url = sh_quote(INSTALLER_URL),
|
||||||
|
shown = sh_quote(&format!("$ {shown}")),
|
||||||
|
args = quoted.join(" "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A machine to install Codeman on, as ssh takes it: `[user@]host`, plus a
|
||||||
|
/// `:port` the app passes as `-p`. The host may be a name from `~/.ssh/config`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SshTarget {
|
||||||
|
pub user: Option<String>,
|
||||||
|
pub host: String,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const SSH_FORMAT: &str =
|
||||||
|
"Enter the machine the way ssh takes it: user@host (for example kay@build-box), or a name from your ssh config.";
|
||||||
|
|
||||||
|
impl SshTarget {
|
||||||
|
/// Only the characters of a user and host name, never a leading `-` (ssh
|
||||||
|
/// would read it as an option) and never an IPv6 literal (ambiguous with
|
||||||
|
/// the port; a host name or an IPv4 address works).
|
||||||
|
pub fn parse(input: &str) -> Result<Self, String> {
|
||||||
|
let s = input.trim();
|
||||||
|
if s.is_empty() || s.len() > 300 {
|
||||||
|
return Err(SSH_FORMAT.into());
|
||||||
|
}
|
||||||
|
let (user, rest) = match s.split_once('@') {
|
||||||
|
Some((u, r)) => (Some(u), r),
|
||||||
|
None => (None, s),
|
||||||
|
};
|
||||||
|
let (host, port) = match rest.split_once(':') {
|
||||||
|
Some((h, p)) => {
|
||||||
|
let port = p.parse::<u16>().ok().filter(|p| *p > 0).ok_or_else(|| {
|
||||||
|
"The part after the colon must be the SSH port, for example kay@build-box:2222.".to_string()
|
||||||
|
})?;
|
||||||
|
(h, Some(port))
|
||||||
|
}
|
||||||
|
None => (rest, None),
|
||||||
|
};
|
||||||
|
let name_ok = |v: &str, max: usize| {
|
||||||
|
!v.is_empty()
|
||||||
|
&& v.len() <= max
|
||||||
|
&& !v.starts_with(['-', '.'])
|
||||||
|
&& v.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
|
||||||
|
};
|
||||||
|
if let Some(u) = user {
|
||||||
|
if !name_ok(u, 64) {
|
||||||
|
return Err(SSH_FORMAT.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !name_ok(host, 253) {
|
||||||
|
return Err(SSH_FORMAT.into());
|
||||||
|
}
|
||||||
|
Ok(SshTarget {
|
||||||
|
user: user.map(str::to_string),
|
||||||
|
host: host.to_string(),
|
||||||
|
port,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `user@host`, or `host`.
|
||||||
|
pub fn destination(&self) -> String {
|
||||||
|
match &self.user {
|
||||||
|
Some(u) => format!("{u}@{}", self.host),
|
||||||
|
None => self.host.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address the connect screen suggests once the install is done: the
|
||||||
|
/// installer's default port on that host. Tailscale and a sub-path give
|
||||||
|
/// another address, which the installer prints at the end.
|
||||||
|
pub fn suggested_url(&self) -> String {
|
||||||
|
format!("http://{}:{DEFAULT_PORT}", self.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the remote login shell runs. It may be fish or tcsh, so the work is
|
||||||
|
/// one single-quoted `bash -c` word with nothing in it those shells treat
|
||||||
|
/// specially (no `!`, `$`, backslash or single quote). `curl | bash` is the
|
||||||
|
/// documented install and the installer asks its questions on ssh's terminal
|
||||||
|
/// (`-t`); `pipefail` makes a failed download fail the whole command, where a
|
||||||
|
/// plain pipe would report bash's success on an empty script. The installer
|
||||||
|
/// needs bash anyway.
|
||||||
|
pub fn remote_command() -> String {
|
||||||
|
format!(
|
||||||
|
"bash -o pipefail -c 'if command -v curl >/dev/null 2>&1; then curl -fsSL {u} | bash; \
|
||||||
|
elif command -v wget >/dev/null 2>&1; then wget -qO- {u} | bash; \
|
||||||
|
else echo \"Codeman needs curl or wget on this machine.\" >&2; exit 1; fi'",
|
||||||
|
u = INSTALLER_URL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `ssh`'s arguments for an install on `target`: a terminal (`-t`) for the
|
||||||
|
/// installer's questions, the port, and `--` before the destination.
|
||||||
|
pub fn ssh_args(target: &SshTarget) -> Vec<String> {
|
||||||
|
let mut args = vec!["-t".to_string()];
|
||||||
|
if let Some(port) = target.port {
|
||||||
|
args.push("-p".into());
|
||||||
|
args.push(port.to_string());
|
||||||
|
}
|
||||||
|
args.push("--".into());
|
||||||
|
args.push(target.destination());
|
||||||
|
args.push(remote_command());
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Terminal script for an install on another machine over SSH.
|
||||||
|
pub fn remote_install_script(target: &SshTarget) -> String {
|
||||||
|
let args: Vec<String> = ssh_args(target).iter().map(|a| sh_quote(a)).collect();
|
||||||
|
let dest = target.destination();
|
||||||
|
format!(
|
||||||
|
r#"#!/bin/bash
|
||||||
|
rm -f -- "$0"
|
||||||
|
printf '\033]0;%s\007' {title}
|
||||||
|
echo {what}
|
||||||
|
cat <<'CODEMAN_MSG'
|
||||||
|
|
||||||
|
ssh may ask you to confirm the machine and for its password. Then the installer
|
||||||
|
asks how Codeman should be reached (Tailscale, the local network with a password,
|
||||||
|
or that machine only) and whether to run it in the background. At the end it
|
||||||
|
prints the address of Codeman there: connect to it in the Codeman app, under
|
||||||
|
Another machine.
|
||||||
|
|
||||||
|
CODEMAN_MSG
|
||||||
|
ssh {args}
|
||||||
|
status=$?
|
||||||
|
echo
|
||||||
|
if [ "$status" -eq 0 ]; then
|
||||||
|
echo 'Done. You can close this window.'
|
||||||
|
else
|
||||||
|
echo "ssh or the installer stopped (exit $status)."
|
||||||
|
fi
|
||||||
|
exit "$status"
|
||||||
|
"#,
|
||||||
|
title = sh_quote(&format!("Install Codeman on {}", target.host)),
|
||||||
|
what = sh_quote(&format!("Installing Codeman on {dest} over SSH.")),
|
||||||
|
args = args.join(" "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
/// Run `script` in a new Terminal window. The script file is private to this
|
||||||
|
/// user (the per-user temporary folder, mode 0700, created fresh) and deletes
|
||||||
|
/// itself as it starts.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn open_in_terminal(name: &str, script: &str) -> Result<(), String> {
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
|
||||||
|
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let path = std::env::temp_dir().join(format!("{name}-{}-{n}.command", std::process::id()));
|
||||||
|
let written = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.mode(0o700)
|
||||||
|
.open(&path)
|
||||||
|
.and_then(|mut f| f.write_all(script.as_bytes()));
|
||||||
|
if let Err(e) = written {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
return Err(format!("Could not write the install script: {e}"));
|
||||||
|
}
|
||||||
|
let out = Command::new("/usr/bin/open")
|
||||||
|
.args(["-b", "com.apple.Terminal"])
|
||||||
|
.arg(&path)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output();
|
||||||
|
match out {
|
||||||
|
Ok(o) if o.status.success() => Ok(()),
|
||||||
|
Ok(o) => {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
let said = String::from_utf8_lossy(&o.stderr).trim().to_string();
|
||||||
|
Err(if said.is_empty() {
|
||||||
|
"Could not open Terminal.".into()
|
||||||
|
} else {
|
||||||
|
format!("Could not open Terminal: {said}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
Err(format!("Could not open Terminal: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub fn open_in_terminal(_name: &str, _script: &str) -> Result<(), String> {
|
||||||
|
let _ = &SCRIPT_COUNTER;
|
||||||
|
Err("Installing from the app is only available on macOS so far.".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put `text` on the clipboard (`pbcopy`). The connect page is not a secure
|
||||||
|
/// context, so it cannot use the web clipboard API itself.
|
||||||
|
pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
|
||||||
|
let mut child = Command::new("/usr/bin/pbcopy")
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|e| format!("Could not copy: {e}"))?;
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
stdin
|
||||||
|
.write_all(text.as_bytes())
|
||||||
|
.map_err(|e| format!("Could not copy: {e}"))?;
|
||||||
|
}
|
||||||
|
match child.wait() {
|
||||||
|
Ok(s) if s.success() => Ok(()),
|
||||||
|
_ => Err("Could not copy the command.".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quoting() {
|
||||||
|
assert_eq!(sh_quote("plain"), "'plain'");
|
||||||
|
assert_eq!(sh_quote("it's"), r"'it'\''s'");
|
||||||
|
assert_eq!(sh_quote(""), "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_install_flags() {
|
||||||
|
assert_eq!(
|
||||||
|
local_install_args(LocalInstall {
|
||||||
|
port: 3000,
|
||||||
|
network: false
|
||||||
|
}),
|
||||||
|
["--local", "--service", "--port", "3000"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
local_install_args(LocalInstall {
|
||||||
|
port: 3007,
|
||||||
|
network: true
|
||||||
|
}),
|
||||||
|
["--service", "--port", "3007"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a script under bash with `curl` and `bash`... replaced, and report
|
||||||
|
/// what it did. The real installer never runs in a test.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn run_script(script: &str, fakes: &[(&str, &str)]) -> (String, i32, bool) {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let dir = std::env::temp_dir().join(format!("codeman-setup-test-{}-{n}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
||||||
|
for (name, body) in fakes {
|
||||||
|
let p = dir.join("bin").join(name);
|
||||||
|
std::fs::write(&p, body.replace("DIR", &dir.display().to_string())).unwrap();
|
||||||
|
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
}
|
||||||
|
let file = dir.join("run.command");
|
||||||
|
std::fs::write(&file, script).unwrap();
|
||||||
|
let out = Command::new("/bin/bash")
|
||||||
|
.arg(&file)
|
||||||
|
.env("PATH", format!("{}:/usr/bin:/bin", dir.join("bin").display()))
|
||||||
|
.env("TMPDIR", &dir)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let log = std::fs::read_to_string(dir.join("log")).unwrap_or_default();
|
||||||
|
let self_deleted = !file.exists();
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
(log, out.status.code().unwrap_or(-1), self_deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn local_script_downloads_then_runs_the_installer_with_its_flags() {
|
||||||
|
let script = local_install_script(LocalInstall {
|
||||||
|
port: 3001,
|
||||||
|
network: false,
|
||||||
|
});
|
||||||
|
let (log, code, self_deleted) = run_script(
|
||||||
|
&script,
|
||||||
|
&[(
|
||||||
|
"curl",
|
||||||
|
r#"#!/bin/sh
|
||||||
|
echo "curl $*" >> DIR/log
|
||||||
|
out=""
|
||||||
|
while [ $# -gt 0 ]; do [ "$1" = -o ] && out="$2"; shift; done
|
||||||
|
cat > "$out" <<'INSTALLER'
|
||||||
|
for a in "$@"; do printf '[%s]' "$a" >> DIR/log; done
|
||||||
|
echo >> DIR/log
|
||||||
|
exit 7
|
||||||
|
INSTALLER
|
||||||
|
"#,
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
assert!(self_deleted, "the script must delete itself");
|
||||||
|
assert!(
|
||||||
|
log.starts_with("curl -fsSL https://getcodeman.com/install -o "),
|
||||||
|
"{log}"
|
||||||
|
);
|
||||||
|
assert!(log.contains("\n[--local][--service][--port][3001]\n"), "{log}");
|
||||||
|
assert_eq!(code, 7, "the installer's exit status is the script's");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn local_script_stops_when_the_download_fails() {
|
||||||
|
let script = local_install_script(LocalInstall {
|
||||||
|
port: 3000,
|
||||||
|
network: true,
|
||||||
|
});
|
||||||
|
let (log, code, _) = run_script(&script, &[("curl", "#!/bin/sh\necho curl >> DIR/log\nexit 22\n")]);
|
||||||
|
assert_eq!(log, "curl\n");
|
||||||
|
assert_eq!(code, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssh_targets() {
|
||||||
|
assert_eq!(
|
||||||
|
SshTarget::parse(" kay@build-box ").unwrap(),
|
||||||
|
SshTarget {
|
||||||
|
user: Some("kay".into()),
|
||||||
|
host: "build-box".into(),
|
||||||
|
port: None
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SshTarget::parse("pi@192.168.1.20:2222").unwrap(),
|
||||||
|
SshTarget {
|
||||||
|
user: Some("pi".into()),
|
||||||
|
host: "192.168.1.20".into(),
|
||||||
|
port: Some(2222)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(SshTarget::parse("macmini").unwrap().destination(), "macmini");
|
||||||
|
assert_eq!(
|
||||||
|
SshTarget::parse("a.b@tnode.tailnet.ts.net").unwrap().destination(),
|
||||||
|
"a.b@tnode.tailnet.ts.net"
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
"",
|
||||||
|
"-oProxyCommand=touch /tmp/x",
|
||||||
|
"kay@-oProxyCommand=x",
|
||||||
|
"-l@host",
|
||||||
|
"kay@host name",
|
||||||
|
"kay@host;rm",
|
||||||
|
"kay@host$(x)",
|
||||||
|
"k'y@host",
|
||||||
|
"kay@",
|
||||||
|
"@host",
|
||||||
|
"kay@host:",
|
||||||
|
"kay@host:0",
|
||||||
|
"kay@host:x",
|
||||||
|
"kay@fe80::1",
|
||||||
|
"kay@[::1]",
|
||||||
|
"a@b@c",
|
||||||
|
".hidden",
|
||||||
|
] {
|
||||||
|
assert!(SshTarget::parse(bad).is_err(), "accepted {bad:?}");
|
||||||
|
}
|
||||||
|
assert_eq!(SshTarget::parse("kay@box").unwrap().suggested_url(), "http://box:3000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ssh_arguments() {
|
||||||
|
let t = SshTarget::parse("kay@box:2222").unwrap();
|
||||||
|
let args = ssh_args(&t);
|
||||||
|
assert_eq!(&args[..5], ["-t", "-p", "2222", "--", "kay@box"]);
|
||||||
|
assert_eq!(args.len(), 6);
|
||||||
|
let remote = &args[5];
|
||||||
|
let head = "bash -o pipefail -c '";
|
||||||
|
assert!(remote.starts_with(head) && remote.ends_with('\''));
|
||||||
|
// One word for fish and tcsh: no quote inside, nothing they expand.
|
||||||
|
let inner = &remote[head.len()..remote.len() - 1];
|
||||||
|
assert!(!inner.contains(['\'', '!', '$', '\\', '`']), "{inner}");
|
||||||
|
assert!(inner.contains("curl -fsSL https://getcodeman.com/install | bash"));
|
||||||
|
assert_eq!(ssh_args(&SshTarget::parse("box").unwrap())[..3], ["-t", "--", "box"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn remote_script_runs_ssh_with_exactly_those_arguments() {
|
||||||
|
let t = SshTarget::parse("kay@box:2222").unwrap();
|
||||||
|
let (log, code, self_deleted) = run_script(
|
||||||
|
&remote_install_script(&t),
|
||||||
|
&[(
|
||||||
|
"ssh",
|
||||||
|
"#!/bin/sh\nfor a in \"$@\"; do printf '[%s]' \"$a\" >> DIR/log; done\necho >> DIR/log\nexit 3\n",
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
assert!(self_deleted);
|
||||||
|
let expected: String = ssh_args(&t).iter().map(|a| format!("[{a}]")).collect();
|
||||||
|
assert_eq!(log, format!("{expected}\n"));
|
||||||
|
assert_eq!(code, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The remote command does what it says under a POSIX shell: curl first,
|
||||||
|
/// wget without curl, and a clear error with neither.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn remote_command_picks_a_downloader() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let n = SCRIPT_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let dir = std::env::temp_dir().join(format!("codeman-remote-cmd-{}-{n}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
||||||
|
let exe = |name: &str, body: &str| {
|
||||||
|
let p = dir.join("bin").join(name);
|
||||||
|
std::fs::write(&p, body).unwrap();
|
||||||
|
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
};
|
||||||
|
// The outer `bash -o pipefail` is the real one; the `bash` the
|
||||||
|
// download is piped into only reports what it was fed.
|
||||||
|
exe(
|
||||||
|
"bash",
|
||||||
|
"#!/bin/sh\nif [ \"$1\" = -o ]; then exec /bin/bash \"$@\"; fi\nprintf 'bash got: '; /bin/cat\n",
|
||||||
|
);
|
||||||
|
exe("wget", "#!/bin/sh\necho \"wget $*\"\n");
|
||||||
|
let run = || {
|
||||||
|
let out = Command::new("/bin/sh")
|
||||||
|
.arg("-c")
|
||||||
|
.arg(remote_command())
|
||||||
|
.env("PATH", dir.join("bin"))
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
(
|
||||||
|
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||||
|
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||||
|
out.status.code(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let (out, _, _) = run();
|
||||||
|
assert_eq!(out, "bash got: wget -qO- https://getcodeman.com/install\n");
|
||||||
|
exe("curl", "#!/bin/sh\necho \"curl $*\"\n");
|
||||||
|
let (out, _, _) = run();
|
||||||
|
assert_eq!(out, "bash got: curl -fsSL https://getcodeman.com/install\n");
|
||||||
|
// A failed download fails the command (pipefail), not "Done".
|
||||||
|
exe(
|
||||||
|
"curl",
|
||||||
|
"#!/bin/sh\necho 'curl: (6) Could not resolve host' >&2\nexit 6\n",
|
||||||
|
);
|
||||||
|
let (_, _, code) = run();
|
||||||
|
assert_ne!(code, Some(0));
|
||||||
|
std::fs::remove_file(dir.join("bin/curl")).unwrap();
|
||||||
|
std::fs::remove_file(dir.join("bin/wget")).unwrap();
|
||||||
|
let (out, err, code) = run();
|
||||||
|
assert_eq!(out, "");
|
||||||
|
assert!(err.contains("needs curl or wget"), "{err}");
|
||||||
|
assert_eq!(code, Some(1));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -826,7 +826,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`, `gemini`, `antigravity`, `pi`,
|
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
|
||||||
`grok`, `omp` and `copilot` have neither, and `codex` has a transcript and a screen-read `blocked` but
|
`grok`, `omp`, `copilot` and `jcode` have neither, and `codex` has a transcript and a screen-read `blocked` but
|
||||||
no `stop`, so all of them still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
|
no `stop`, so all of them 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:
|
||||||
|
|||||||
@@ -242,7 +242,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`, `omp` and `copilot`, which write no transcript at
|
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot` 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.
|
||||||
@@ -346,7 +346,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|copilot`; response is
|
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot|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.
|
||||||
|
|
||||||
@@ -361,9 +361,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`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/status` each return `.data.{available, path}` (no session needed).
|
`GET /api/v1/pi/status`, `GET /api/v1/omp/status`, `GET /api/v1/copilot/status` and `GET /api/v1/jcode/status` each return `.data.{available, path}` (no session needed).
|
||||||
Pi's, grok's, OMP's and Copilot's also carry `.data.version`, because `pi` is a short generic name,
|
Pi's, grok's, OMP's, Copilot'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, and `copilot` is a generic one, so an unrelated
|
`grok` is a name with npm squatters, `omp` is a similarly short name, `copilot` is a generic one, and an
|
||||||
|
unrelated npm package is also called `jcode`, 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.
|
||||||
@@ -492,8 +493,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`/`copilot`. ⚠️ **`shell` is NOT one of those**
|
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`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`/`copilot`) | HTTP only (no other CLI has messaging) |
|
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`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`, `copilot`) cannot be peers
|
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, `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/copilot, which have no transcript, use
|
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot/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
|
||||||
|
|||||||
@@ -383,7 +383,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`/`copilot`, requesting them explicitly is a
|
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`/`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**
|
||||||
@@ -437,7 +437,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`, `copilot`; the first four
|
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`, `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
|
||||||
@@ -526,7 +526,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`/`copilot`** (those parsers are skipped wholesale) and
|
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`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
|
||||||
|
|||||||
@@ -826,7 +826,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`, `gemini`, `antigravity`, `pi`,
|
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
|
||||||
`grok`, `omp` and `copilot` have neither, and `codex` has a transcript and a screen-read `blocked` but
|
`grok`, `omp`, `copilot` and `jcode` have neither, and `codex` has a transcript and a screen-read `blocked` but
|
||||||
no `stop`, so all of them still need markers ([§5.5](reference/verbs.md#55-markers-for-hook-less-workers)).
|
no `stop`, so all of them 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:
|
||||||
|
|||||||
@@ -242,7 +242,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`, `omp` and `copilot`, which write no transcript at
|
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot` 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.
|
||||||
@@ -346,7 +346,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|copilot`; response is
|
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot|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.
|
||||||
|
|
||||||
@@ -361,9 +361,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`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/status` each return `.data.{available, path}` (no session needed).
|
`GET /api/v1/pi/status`, `GET /api/v1/omp/status`, `GET /api/v1/copilot/status` and `GET /api/v1/jcode/status` each return `.data.{available, path}` (no session needed).
|
||||||
Pi's, grok's, OMP's and Copilot's also carry `.data.version`, because `pi` is a short generic name,
|
Pi's, grok's, OMP's, Copilot'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, and `copilot` is a generic one, so an unrelated
|
`grok` is a name with npm squatters, `omp` is a similarly short name, `copilot` is a generic one, and an
|
||||||
|
unrelated npm package is also called `jcode`, 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.
|
||||||
@@ -492,8 +493,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`/`copilot`. ⚠️ **`shell` is NOT one of those**
|
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`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`/`copilot`) | HTTP only (no other CLI has messaging) |
|
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`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`, `copilot`) cannot be peers
|
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, `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/copilot, which have no transcript, use
|
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot/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
|
||||||
|
|||||||
@@ -383,7 +383,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`/`copilot`, requesting them explicitly is a
|
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`/`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**
|
||||||
@@ -437,7 +437,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`, `copilot`; the first four
|
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`, `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
|
||||||
@@ -526,7 +526,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`/`copilot`** (those parsers are skipped wholesale) and
|
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`/`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
|
||||||
|
|||||||
@@ -314,6 +314,8 @@ const capabilitiesSchema = z
|
|||||||
// Keep the PTY's rows while a phone keyboard is up (inline renderers only).
|
// Keep the PTY's rows while a phone keyboard is up (inline renderers only).
|
||||||
// Optional: absent means the keyboard resizes, the safe default.
|
// Optional: absent means the keyboard resizes, the safe default.
|
||||||
keyboardHold: z.literal('inline').optional(),
|
keyboardHold: z.literal('inline').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']),
|
||||||
|
|||||||
@@ -2023,6 +2023,142 @@ const COPILOT: 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: 110,
|
||||||
|
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[] = [
|
export const STOCK_CLIS: CliEntry[] = [
|
||||||
CLAUDE,
|
CLAUDE,
|
||||||
@@ -2036,4 +2172,5 @@ export const STOCK_CLIS: CliEntry[] = [
|
|||||||
DEEPSEEK,
|
DEEPSEEK,
|
||||||
OMP,
|
OMP,
|
||||||
COPILOT,
|
COPILOT,
|
||||||
|
JCODE,
|
||||||
];
|
];
|
||||||
@@ -622,6 +622,19 @@ export interface CliCapabilities {
|
|||||||
* losing lines, so it resizes as before), and for remote and docker sessions.
|
* losing lines, so it resizes as before), and for remote and docker sessions.
|
||||||
*/
|
*/
|
||||||
keyboardHold?: 'inline';
|
keyboardHold?: 'inline';
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
PiConfig,
|
PiConfig,
|
||||||
GrokConfig,
|
GrokConfig,
|
||||||
CopilotConfig,
|
CopilotConfig,
|
||||||
|
JcodeConfig,
|
||||||
DeepSeekConfig,
|
DeepSeekConfig,
|
||||||
OmpConfig,
|
OmpConfig,
|
||||||
SessionRemote,
|
SessionRemote,
|
||||||
@@ -127,6 +128,7 @@ export interface CreateSessionOptions {
|
|||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
copilotConfig?: CopilotConfig;
|
copilotConfig?: CopilotConfig;
|
||||||
|
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 */
|
||||||
@@ -179,6 +181,7 @@ export interface RespawnPaneOptions {
|
|||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
copilotConfig?: CopilotConfig;
|
copilotConfig?: CopilotConfig;
|
||||||
|
jcodeConfig?: JcodeConfig;
|
||||||
deepSeekConfig?: DeepSeekConfig;
|
deepSeekConfig?: DeepSeekConfig;
|
||||||
ompConfig?: OmpConfig;
|
ompConfig?: OmpConfig;
|
||||||
/** Resume a previous Claude conversation when respawning */
|
/** Resume a previous Claude conversation when respawning */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import type {
|
|||||||
GeminiConfig,
|
GeminiConfig,
|
||||||
GrokConfig,
|
GrokConfig,
|
||||||
CopilotConfig,
|
CopilotConfig,
|
||||||
|
JcodeConfig,
|
||||||
OmpConfig,
|
OmpConfig,
|
||||||
OpenCodeConfig,
|
OpenCodeConfig,
|
||||||
PiConfig,
|
PiConfig,
|
||||||
@@ -53,6 +54,7 @@ export interface SpawnBridgeOptions {
|
|||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
copilotConfig?: CopilotConfig;
|
copilotConfig?: CopilotConfig;
|
||||||
|
jcodeConfig?: JcodeConfig;
|
||||||
deepSeekConfig?: DeepSeekConfig;
|
deepSeekConfig?: DeepSeekConfig;
|
||||||
ompConfig?: OmpConfig;
|
ompConfig?: OmpConfig;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import {
|
|||||||
type PiConfig,
|
type PiConfig,
|
||||||
type GrokConfig,
|
type GrokConfig,
|
||||||
type CopilotConfig,
|
type CopilotConfig,
|
||||||
|
type JcodeConfig,
|
||||||
type DeepSeekConfig,
|
type DeepSeekConfig,
|
||||||
type OmpConfig,
|
type OmpConfig,
|
||||||
type SessionRemote,
|
type SessionRemote,
|
||||||
@@ -864,6 +865,8 @@ export class Session extends EventEmitter {
|
|||||||
private _grokConfig: GrokConfig | undefined;
|
private _grokConfig: GrokConfig | undefined;
|
||||||
// GitHub Copilot configuration (only for mode === 'copilot')
|
// GitHub Copilot configuration (only for mode === 'copilot')
|
||||||
private _copilotConfig: CopilotConfig | undefined;
|
private _copilotConfig: CopilotConfig | 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;
|
||||||
@@ -1003,6 +1006,8 @@ export class Session extends EventEmitter {
|
|||||||
/** Grok configuration (only for mode === 'grok') */
|
/** Grok configuration (only for mode === 'grok') */
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
copilotConfig?: CopilotConfig;
|
copilotConfig?: CopilotConfig;
|
||||||
|
/** 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') */
|
||||||
@@ -1197,6 +1202,11 @@ export class Session extends EventEmitter {
|
|||||||
this._copilotConfig = config.copilotConfig;
|
this._copilotConfig = config.copilotConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
@@ -2512,6 +2522,7 @@ export class Session extends EventEmitter {
|
|||||||
piConfig: this._piConfig,
|
piConfig: this._piConfig,
|
||||||
grokConfig: this._grokConfig,
|
grokConfig: this._grokConfig,
|
||||||
copilotConfig: this._copilotConfig,
|
copilotConfig: this._copilotConfig,
|
||||||
|
jcodeConfig: this._jcodeConfig,
|
||||||
deepSeekConfig: this._deepSeekConfig,
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
ompConfig: this._ompConfig,
|
ompConfig: this._ompConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
@@ -2893,6 +2904,7 @@ export class Session extends EventEmitter {
|
|||||||
piConfig: this._piConfig,
|
piConfig: this._piConfig,
|
||||||
grokConfig: this._grokConfig,
|
grokConfig: this._grokConfig,
|
||||||
copilotConfig: this._copilotConfig,
|
copilotConfig: this._copilotConfig,
|
||||||
|
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
|
||||||
@@ -3411,6 +3423,7 @@ export class Session extends EventEmitter {
|
|||||||
piConfig: this._piConfig,
|
piConfig: this._piConfig,
|
||||||
grokConfig: this._grokConfig,
|
grokConfig: this._grokConfig,
|
||||||
copilotConfig: this._copilotConfig,
|
copilotConfig: this._copilotConfig,
|
||||||
|
jcodeConfig: this._jcodeConfig,
|
||||||
deepSeekConfig: this._deepSeekConfig,
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
ompConfig: this._ompConfig,
|
ompConfig: this._ompConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
@@ -3994,6 +4007,7 @@ export class Session extends EventEmitter {
|
|||||||
piConfig: this._piConfig,
|
piConfig: this._piConfig,
|
||||||
grokConfig: this._grokConfig,
|
grokConfig: this._grokConfig,
|
||||||
copilotConfig: this._copilotConfig,
|
copilotConfig: this._copilotConfig,
|
||||||
|
jcodeConfig: this._jcodeConfig,
|
||||||
deepSeekConfig: this._deepSeekConfig,
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
ompConfig: this._ompConfig,
|
ompConfig: this._ompConfig,
|
||||||
};
|
};
|
||||||
|
|||||||
+50
-4
@@ -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 {
|
import {
|
||||||
foreignViewSessionName,
|
foreignViewSessionName,
|
||||||
buildForeignDockerViewKillCommand,
|
buildForeignDockerViewKillCommand,
|
||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
type PiConfig,
|
type PiConfig,
|
||||||
type GrokConfig,
|
type GrokConfig,
|
||||||
type CopilotConfig,
|
type CopilotConfig,
|
||||||
|
type JcodeConfig,
|
||||||
type DeepSeekConfig,
|
type DeepSeekConfig,
|
||||||
type OmpConfig,
|
type OmpConfig,
|
||||||
type SessionRemote,
|
type SessionRemote,
|
||||||
@@ -1213,6 +1214,7 @@ export function buildSpawnCommand(options: {
|
|||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
copilotConfig?: CopilotConfig;
|
copilotConfig?: CopilotConfig;
|
||||||
|
jcodeConfig?: JcodeConfig;
|
||||||
deepSeekConfig?: DeepSeekConfig;
|
deepSeekConfig?: DeepSeekConfig;
|
||||||
ompConfig?: OmpConfig;
|
ompConfig?: OmpConfig;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
@@ -2563,6 +2565,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
copilotConfig,
|
copilotConfig,
|
||||||
|
jcodeConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
ompConfig,
|
ompConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
@@ -2662,6 +2665,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
copilotConfig,
|
copilotConfig,
|
||||||
|
jcodeConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
ompConfig,
|
ompConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
@@ -2931,6 +2935,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
copilotConfig,
|
copilotConfig,
|
||||||
|
jcodeConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
ompConfig,
|
ompConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
@@ -2982,6 +2987,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
copilotConfig,
|
copilotConfig,
|
||||||
|
jcodeConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
ompConfig,
|
ompConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
@@ -3160,6 +3166,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.
|
||||||
@@ -3308,8 +3353,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);
|
||||||
@@ -3328,7 +3374,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 {
|
||||||
|
|||||||
@@ -1062,6 +1062,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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+48
-4
@@ -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' | 'copilot' (which CLI backend)
|
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot' | '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)
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
|
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
|
||||||
* - CopilotConfig — GitHub Copilot CLI settings (model, allowAll, resume/continue)
|
* - CopilotConfig — GitHub Copilot CLI settings (model, allowAll, 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)
|
||||||
@@ -60,7 +61,8 @@ export type SessionMode =
|
|||||||
| 'grok'
|
| 'grok'
|
||||||
| 'deepseek'
|
| 'deepseek'
|
||||||
| 'omp'
|
| 'omp'
|
||||||
| 'copilot';
|
| 'copilot'
|
||||||
|
| '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
|
||||||
@@ -82,7 +84,18 @@ export interface SessionWriteOptions {
|
|||||||
|
|
||||||
export type RemoteCommandMode = Extract<
|
export type RemoteCommandMode = Extract<
|
||||||
SessionMode,
|
SessionMode,
|
||||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
|
| 'shell'
|
||||||
|
| 'claude'
|
||||||
|
| 'opencode'
|
||||||
|
| 'codex'
|
||||||
|
| 'gemini'
|
||||||
|
| 'antigravity'
|
||||||
|
| 'pi'
|
||||||
|
| 'grok'
|
||||||
|
| 'deepseek'
|
||||||
|
| 'omp'
|
||||||
|
| 'copilot'
|
||||||
|
| 'jcode'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,7 +230,18 @@ 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' | 'copilot'
|
| 'shell'
|
||||||
|
| 'claude'
|
||||||
|
| 'opencode'
|
||||||
|
| 'codex'
|
||||||
|
| 'gemini'
|
||||||
|
| 'antigravity'
|
||||||
|
| 'pi'
|
||||||
|
| 'grok'
|
||||||
|
| 'deepseek'
|
||||||
|
| 'omp'
|
||||||
|
| 'copilot'
|
||||||
|
| '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. */
|
||||||
@@ -631,6 +655,24 @@ export interface CopilotConfig {
|
|||||||
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.
|
||||||
*
|
*
|
||||||
@@ -1106,6 +1148,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) */
|
||||||
|
|||||||
@@ -3181,9 +3181,11 @@ class CodemanApp {
|
|||||||
? 'OMP'
|
? 'OMP'
|
||||||
: mode === 'copilot'
|
: mode === 'copilot'
|
||||||
? 'GitHub Copilot'
|
? 'GitHub Copilot'
|
||||||
: mode === 'opencode'
|
: mode === 'jcode'
|
||||||
? 'OpenCode'
|
? 'jcode'
|
||||||
: 'Claude';
|
: mode === 'opencode'
|
||||||
|
? 'OpenCode'
|
||||||
|
: 'Claude';
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleResponseViewer() {
|
async toggleResponseViewer() {
|
||||||
@@ -11018,7 +11020,9 @@ class CodemanApp {
|
|||||||
? 'Kill Tmux & OMP'
|
? 'Kill Tmux & OMP'
|
||||||
: session.mode === 'copilot'
|
: session.mode === 'copilot'
|
||||||
? 'Kill Tmux & GitHub Copilot'
|
? 'Kill Tmux & GitHub Copilot'
|
||||||
: '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');
|
||||||
|
|||||||
@@ -276,6 +276,7 @@
|
|||||||
'Run DeepSeek': '运行 DeepSeek',
|
'Run DeepSeek': '运行 DeepSeek',
|
||||||
'Run OMP': '运行 OMP',
|
'Run OMP': '运行 OMP',
|
||||||
'Run GitHub Copilot': '运行 GitHub Copilot',
|
'Run GitHub Copilot': '运行 GitHub Copilot',
|
||||||
|
'Run jcode': '运行 jcode',
|
||||||
'Run Shell': '运行 Shell',
|
'Run Shell': '运行 Shell',
|
||||||
'More tools': '更多工具',
|
'More tools': '更多工具',
|
||||||
'Select AI backend': '选择 AI 后端',
|
'Select AI backend': '选择 AI 后端',
|
||||||
|
|||||||
@@ -1177,6 +1177,7 @@
|
|||||||
<option value="deepseek">DeepSeek</option>
|
<option value="deepseek">DeepSeek</option>
|
||||||
<option value="omp">OMP</option>
|
<option value="omp">OMP</option>
|
||||||
<option value="copilot">GitHub Copilot</option>
|
<option value="copilot">GitHub Copilot</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>
|
||||||
@@ -3892,6 +3893,7 @@
|
|||||||
<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="copilot" data-cli="copilot">GitHub Copilot</option>
|
<option value="copilot" data-cli="copilot">GitHub Copilot</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>
|
||||||
@@ -4070,7 +4072,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/copilot + 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/copilot/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>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
|||||||
{ 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: 'copilot', label: 'GitHub Copilot', short: 'GitHub Copilot' },
|
{ mode: 'copilot', label: 'GitHub Copilot', short: 'GitHub Copilot' },
|
||||||
|
{ mode: 'jcode', label: 'jcode', short: 'jcode' },
|
||||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1279,6 +1279,20 @@ html.mobile-init .file-browser-panel {
|
|||||||
border-color: rgba(163, 113, 247, 0.5) !important;
|
border-color: rgba(163, 113, 247, 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%;
|
||||||
@@ -3437,6 +3451,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -559,7 +559,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const caseName = this._commandPaletteCaseName(query);
|
const caseName = this._commandPaletteCaseName(query);
|
||||||
const agent = this._commandPaletteAgent(caseName);
|
const agent = this._commandPaletteAgent(caseName);
|
||||||
const mode = agent?.id || this.runMode || this._runMode || 'claude';
|
const mode = agent?.id || 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', copilot: 'Copilot' };
|
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP', copilot: 'Copilot', jcode: 'jcode' };
|
||||||
return {
|
return {
|
||||||
id: 'new-session',
|
id: 'new-session',
|
||||||
type: 'new-session',
|
type: 'new-session',
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ const RUN_MODE_LAUNCH = {
|
|||||||
// The multi-user clamp forces it back off for non-granted owners server-side.
|
// The multi-user clamp forces it back off for non-granted owners server-side.
|
||||||
buildConfig: () => ({ copilotConfig: { allowAll: true } }),
|
buildConfig: () => ({ copilotConfig: { allowAll: true } }),
|
||||||
},
|
},
|
||||||
|
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',
|
||||||
@@ -2700,6 +2709,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return this._runCliMode('copilot');
|
return this._runCliMode('copilot');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async runJcode() {
|
||||||
|
return this._runCliMode('jcode');
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Session Options Modal
|
// Session Options Modal
|
||||||
|
|||||||
@@ -6023,6 +6023,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;
|
||||||
@@ -6134,7 +6152,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,
|
||||||
@@ -6145,6 +6164,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
|||||||
.run-mode-dot.pi,
|
.run-mode-dot.pi,
|
||||||
.run-mode-dot.grok,
|
.run-mode-dot.grok,
|
||||||
.run-mode-dot.copilot,
|
.run-mode-dot.copilot,
|
||||||
|
.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;
|
||||||
@@ -6166,6 +6186,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
|||||||
.run-mode-dot.pi,
|
.run-mode-dot.pi,
|
||||||
.run-mode-dot.grok,
|
.run-mode-dot.grok,
|
||||||
.run-mode-dot.copilot,
|
.run-mode-dot.copilot,
|
||||||
|
.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;
|
||||||
@@ -6182,6 +6203,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
|||||||
.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.copilot { --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 3C7 3 3 6.5 3 11v4a3 3 0 003 3h12a3 3 0 003-3v-4c0-4.5-4-8-9-8zM8.5 11.5a1.5 2 0 110 4 1.5 2 0 010-4zm7 0a1.5 2 0 110 4 1.5 2 0 010-4z'/%3E%3C/svg%3E"); }
|
.run-mode-dot.copilot { --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 3C7 3 3 6.5 3 11v4a3 3 0 003 3h12a3 3 0 003-3v-4c0-4.5-4-8-9-8zM8.5 11.5a1.5 2 0 110 4 1.5 2 0 010-4zm7 0a1.5 2 0 110 4 1.5 2 0 010-4z'/%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"); }
|
||||||
|
|
||||||
@@ -17139,6 +17161,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);
|
||||||
|
|||||||
@@ -1462,6 +1462,8 @@ export function registerSessionRoutes(
|
|||||||
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
||||||
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
||||||
copilotConfig: mode === 'copilot' ? gatedCopilotConfig : undefined,
|
copilotConfig: mode === 'copilot' ? gatedCopilotConfig : 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,
|
||||||
@@ -3226,6 +3228,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') {
|
||||||
|
return context === 'full' || context === 'turn'
|
||||||
|
? { 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');
|
||||||
|
|
||||||
@@ -4248,6 +4265,7 @@ export function registerSessionRoutes(
|
|||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
copilotConfig,
|
copilotConfig,
|
||||||
|
jcodeConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
ompConfig,
|
ompConfig,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
@@ -4320,6 +4338,7 @@ export function registerSessionRoutes(
|
|||||||
piConfig ||
|
piConfig ||
|
||||||
grokConfig ||
|
grokConfig ||
|
||||||
copilotConfig ||
|
copilotConfig ||
|
||||||
|
jcodeConfig ||
|
||||||
deepSeekConfig ||
|
deepSeekConfig ||
|
||||||
ompConfig ||
|
ompConfig ||
|
||||||
openCodeConfig ||
|
openCodeConfig ||
|
||||||
@@ -4391,6 +4410,7 @@ export function registerSessionRoutes(
|
|||||||
piConfig ||
|
piConfig ||
|
||||||
grokConfig ||
|
grokConfig ||
|
||||||
copilotConfig ||
|
copilotConfig ||
|
||||||
|
jcodeConfig ||
|
||||||
deepSeekConfig ||
|
deepSeekConfig ||
|
||||||
ompConfig ||
|
ompConfig ||
|
||||||
openCodeConfig ||
|
openCodeConfig ||
|
||||||
@@ -4492,6 +4512,7 @@ export function registerSessionRoutes(
|
|||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
copilotConfig,
|
copilotConfig,
|
||||||
|
jcodeConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
} as unknown as Record<string, unknown>)
|
} as unknown as Record<string, unknown>)
|
||||||
);
|
);
|
||||||
@@ -4579,7 +4600,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' &&
|
||||||
@@ -4587,7 +4608,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);
|
||||||
}
|
}
|
||||||
@@ -4695,6 +4717,7 @@ export function registerSessionRoutes(
|
|||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
copilotConfig,
|
copilotConfig,
|
||||||
|
jcodeConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
ompConfig,
|
ompConfig,
|
||||||
};
|
};
|
||||||
@@ -4914,6 +4937,7 @@ export function registerSessionRoutes(
|
|||||||
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
||||||
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
||||||
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
|
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
|
||||||
|
jcodeConfig: mode === 'jcode' ? qsLaunchConfigs.jcodeConfig : undefined,
|
||||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||||
ompConfig: qsResolvedOmpConfig,
|
ompConfig: qsResolvedOmpConfig,
|
||||||
envOverrides: qsGatedEnvOverrides,
|
envOverrides: qsGatedEnvOverrides,
|
||||||
|
|||||||
@@ -584,6 +584,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
|
||||||
|
|||||||
@@ -493,6 +493,30 @@ const CopilotConfigSchema = 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.
|
||||||
*/
|
*/
|
||||||
@@ -651,6 +675,7 @@ export const CreateSessionSchema = z.object({
|
|||||||
piConfig: PiConfigSchema,
|
piConfig: PiConfigSchema,
|
||||||
grokConfig: GrokConfigSchema,
|
grokConfig: GrokConfigSchema,
|
||||||
copilotConfig: CopilotConfigSchema,
|
copilotConfig: CopilotConfigSchema,
|
||||||
|
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) */
|
||||||
@@ -870,6 +895,7 @@ const RemoteCommandOverridesSchema = z
|
|||||||
deepseek: z.string().min(1).max(300).optional(),
|
deepseek: z.string().min(1).max(300).optional(),
|
||||||
omp: z.string().min(1).max(300).optional(),
|
omp: z.string().min(1).max(300).optional(),
|
||||||
copilot: z.string().min(1).max(300).optional(),
|
copilot: z.string().min(1).max(300).optional(),
|
||||||
|
jcode: z.string().min(1).max(300).optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
@@ -1259,6 +1285,7 @@ export const QuickStartSchema = z.object({
|
|||||||
piConfig: PiConfigSchema,
|
piConfig: PiConfigSchema,
|
||||||
grokConfig: GrokConfigSchema,
|
grokConfig: GrokConfigSchema,
|
||||||
copilotConfig: CopilotConfigSchema,
|
copilotConfig: CopilotConfigSchema,
|
||||||
|
jcodeConfig: JcodeConfigSchema,
|
||||||
deepSeekConfig: DeepSeekConfigSchema,
|
deepSeekConfig: DeepSeekConfigSchema,
|
||||||
ompConfig: OmpConfigSchema,
|
ompConfig: OmpConfigSchema,
|
||||||
envOverrides: safeEnvOverridesSchema,
|
envOverrides: safeEnvOverridesSchema,
|
||||||
|
|||||||
@@ -4171,6 +4171,7 @@ export class WebServer extends EventEmitter {
|
|||||||
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,
|
||||||
copilotConfig: muxSession.mode === 'copilot' ? savedState?.copilotConfig : undefined,
|
copilotConfig: muxSession.mode === 'copilot' ? savedState?.copilotConfig : 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,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe('per-mode capability predicates', () => {
|
|||||||
['deepseek', true, false, true],
|
['deepseek', true, false, true],
|
||||||
['omp', true, false, false],
|
['omp', true, false, false],
|
||||||
['copilot', true, false, false],
|
['copilot', 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) => {
|
||||||
@@ -49,7 +50,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(11);
|
expect(MODES.length).toBe(12);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the three predicates genuinely distinct', () => {
|
it('keeps the three predicates genuinely distinct', () => {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
|
|||||||
"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 === 'copilot'": allow(2, 'legacy <Mode>Config plumbing'),
|
"web/routes/session-routes.ts::mode === 'copilot'": 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)'),
|
||||||
@@ -106,6 +107,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
|
|||||||
"web/server.ts::mode === 'copilot'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
"web/server.ts::mode === 'copilot'": 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(
|
||||||
@@ -174,7 +176,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
|
||||||
@@ -189,6 +191,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. */
|
||||||
|
|||||||
@@ -645,12 +645,12 @@ 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, opencode and copilot (all on the alternate screen) were
|
// claude (repaint mode), codex, opencode, copilot and jcode (all 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.
|
||||||
// pi pages too when fullscreen, and stays out on purpose: in `tuiMode: regular` the same keys
|
// pi pages too when fullscreen, and stays out on purpose: in `tuiMode: regular` the same keys
|
||||||
// move the caret of a multi-line draft.
|
// move the caret of a multi-line draft.
|
||||||
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', 'copilot', 'opencode']);
|
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'jcode', 'opencode']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -326,6 +326,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 });
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ const LITERAL_SEARCH_PATHS: Record<string, string[]> = {
|
|||||||
'$HOME/.npm-global/bin/copilot',
|
'$HOME/.npm-global/bin/copilot',
|
||||||
'$HOME/bin/copilot',
|
'$HOME/bin/copilot',
|
||||||
],
|
],
|
||||||
|
// 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. */
|
||||||
@@ -107,6 +110,7 @@ const ARRAY_PREFIX_TO_CLI_ID: Record<string, string> = {
|
|||||||
ANTIGRAVITY: 'antigravity',
|
ANTIGRAVITY: 'antigravity',
|
||||||
OMP: 'omp',
|
OMP: 'omp',
|
||||||
COPILOT: 'copilot',
|
COPILOT: 'copilot',
|
||||||
|
JCODE: 'jcode',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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, remoteLoginShellCommand } 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(remoteLoginShellCommand('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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ it('pins every remote pane command', () => {
|
|||||||
deepseek: remoteLoginShellCommand('dsh'),
|
deepseek: remoteLoginShellCommand('dsh'),
|
||||||
omp: remoteLoginShellCommand('omp'),
|
omp: remoteLoginShellCommand('omp'),
|
||||||
copilot: remoteLoginShellCommand('copilot'),
|
copilot: remoteLoginShellCommand('copilot'),
|
||||||
|
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', () => {
|
|||||||
deepseek: 'exec dsh',
|
deepseek: 'exec dsh',
|
||||||
omp: 'exec omp',
|
omp: 'exec omp',
|
||||||
copilot: 'exec copilot',
|
copilot: 'exec copilot',
|
||||||
|
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);
|
||||||
|
|||||||
@@ -470,6 +470,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
|
|||||||
'deepseek',
|
'deepseek',
|
||||||
'omp',
|
'omp',
|
||||||
'copilot',
|
'copilot',
|
||||||
|
'jcode',
|
||||||
'shell',
|
'shell',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -242,6 +242,9 @@ describe('WebServer.renderIndexHtml', () => {
|
|||||||
deepseekBinary: false,
|
deepseekBinary: false,
|
||||||
omp: true,
|
omp: true,
|
||||||
copilot: true,
|
copilot: 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,
|
||||||
|
|||||||
@@ -215,6 +215,24 @@ describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
|||||||
Reference in new issue
Block a user