mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Merge pull request #592: feat(cli-registry): GitHub Copilot CLI as a run mode
GitHub Copilot CLI (`copilot`) becomes a run mode: Run menu, welcome screen, cron and CLI management once it is installed, tmux-backed like the other agent CLIs, working/idle and the model read off its footer, launch options through `copilotConfig` (model, resume, continue, the multi-user-clamped allowAll), Custom Model Endpoints via Copilot's BYOK variables, MCP sync of its mcp-config.json, a status probe, and the `codeman agent` verbs (spawn waits for its composer, read returns its last answer). The plan-usage quota row that earlier rounds carried was split out on review and is not part of this merge. Merge-time commits on top: the wider COPILOT_* clamp and the zh-CN README rows (maintainer), the Docker sign-in docs, divider rows inside an answer kept by `agent read`, route tests for the screen-read last-response branch, and doc drift (Past Sessions, wheelForward JSDoc, COPILOT_/OMP_ in the env prefix lists). Verified before merge on Copilot CLI 1.0.98: injected keystrokes, bracketed paste and Enter reach the TUI in tmux (github/copilot-cli#4180 does not reproduce), a shell tool call completes inside tmux (#4223 does not reproduce), and a session driven through this branch answered over send-and-wait. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
8e6abf4bfd
80 files changed
+1907
-128
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
'aicodeman': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
GitHub Copilot CLI is a run mode. It appears in the Run menu, the welcome screen and the cron agent list once `copilot` is installed, runs in tmux like the other agent CLIs, reports working and idle from its `Working` footer, shows the model it reads off its footer on the tab, and is listed in CLI management with an install command (`npm install -g @github/copilot`). It takes the same launch options as the other agent CLIs: a `copilotConfig` on the session create and quick-start bodies with `model` (`--model`), `resumeSessionId` (`--resume`), `continueSession` (`--continue`, which Past Sessions sends) and `allowAll` (`--yolo`, which the Run button sends and which is clamped off for non-granted multi-user owners), a tab name you set yourself as `--name`, Custom Model Endpoints through Copilot's BYOK variables, MCP sync of its `mcp-config.json`, and a `GET /api/copilot/status` probe. A plain drag selects text (Auto Copy works) while the wheel and phone swipes scroll its transcript, `codeman agent spawn` waits for its composer, and `codeman agent read` returns its last answer.
|
||||||
@@ -58,7 +58,7 @@ When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`d
|
|||||||
|
|
||||||
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
|
Codeman is a Claude Code session manager with web interface and autonomous Ralph Loop. Spawns Claude CLI via PTY, streams via SSE, supports respawn cycling for 24+ hour autonomous runs.
|
||||||
|
|
||||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`) and OMP (`omp`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'`).
|
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, node-pty, xterm.js. Supports Claude Code, OpenCode, Codex (OpenAI), Gemini (Google, enterprise-only since Google's June 2026 consumer cutover), Antigravity (`agy`, Google), Pi (pi.dev), Grok Build (`grok`, xAI), DeepSeek Harness (`dsh`), OMP (`omp`) and GitHub Copilot (`copilot`, npm `@github/copilot`) CLIs via pluggable CLI resolvers (`SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'`).
|
||||||
|
|
||||||
**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`.
|
||||||
|
|
||||||
@@ -92,11 +92,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
- **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks
|
||||||
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
- **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`)
|
||||||
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
- **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically)
|
||||||
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
|
- **`envOverrides` flow `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `GEMINI_*` / `GOOGLE_*` / `ANTIGRAVITY_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `COPILOT_*` env vars, plus exact-key `CLAUDE_CONFIG_DIR`** — Set via `POST /api/sessions { envOverrides }`, stored on `Session._envOverrides`, exported by `tmux-manager.buildEnvExports()` at spawn time, persisted in `SessionState.envOverrides`. **Do NOT** write these to `<case>/.claude/settings.local.json` — that's the old path and creates UI/disk drift. (`GOOGLE_*` is the deliberately-broad Vertex-AI namespace for Gemini — see Multi-CLI prefix discipline.) `CLAUDE_CONFIG_DIR` (#255, exact match via `ALLOWED_ENV_KEYS` in `schemas.ts`) points a session at a separate Claude account/config dir for per-client subscriptions; it persists to state.json (a path, not a secret; losing it on restart would silently switch accounts). ⚠️ A relocated config dir writes transcripts outside `~/.claude/projects`, so the response viewer, subagent windows, ultracode panel and Read My Mind capture go blind for that session unless the user symlinks `projects` back into the shared tree (`ln -s ~/.claude/projects <configDir>/projects`). ⚠️ It is also one of claude's `privilegedEnvKeys` (Custom Model Endpoint Profiles, since it can redirect a session's traffic same as any other injected var), so in multi-user mode setting it via `envOverrides` is admin-only, and a non-granted owner's already-persisted `CLAUDE_CONFIG_DIR` is stripped on reboot-restore — silently returning that session to the default Claude account rather than the one it was pointed at (see `session-env-clamp.ts`). → [architecture-invariants#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir](docs/architecture-invariants.md#per-session-env-overrides-exact-key-allowlist-and-claude_config_dir)
|
||||||
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
- **Effort is NOT an env var** — never carry effort as `CLAUDE_CODE_EFFORT_LEVEL`: the env var hard-locks effort and blocks in-session `/effort` switching (incl. ultracode). It flows as the dedicated `effort` payload field → `Session._effort` → `claude --effort <level>` for regular levels incl. `max` (the settings `effortLevel` key is `enum(["low","medium","high","xhigh"]).catch(undefined)` — `max` gets SILENTLY dropped there), or `claude --settings '{"ultracode":true}'` for ultracode (rejected by `--effort`). Both are soft defaults the user can override anytime. Legacy env-var entries are auto-migrated by the Session constructor and unset from tmux sessions in `applyEnvOverrides()`. See `buildEffortCliArgs()` in `session-cli-builder.ts`, tests in `test/effort-injection.test.ts`
|
||||||
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts`
|
- **The advisor rides `--settings`, NEVER the `--advisor` flag**: Claude Code's advisor tool (a stronger model consulted at decision points, code.claude.com/docs/en/advisor) flows as the `advisorModel` payload field → `Session._advisorModel` (persisted, so respawn and reboot restore keep it) → the `advisorModel` key in the launch's ONE `--settings` JSON, merged with ultracode and the statusLine exporter by `buildAdvisorSettings()` (`session-cli-builder.ts`). ⚠️ The flag EXITS at launch on any pairing the CLI refuses (`claude --advisor haiku` exits 1, so does Fable before its usage-credit consent), which would leave a dead pane on every respawn; the settings key degrades to "no advisor" instead. ⚠️ `isAdvisorModel()` (fable/opus/sonnet aliases or full ids, no haiku) is also the injection guard for the single-quoted argument. Soft default: `/advisor` still switches it in-session. App Settings key `claudeAdvisorModel` (SYNCED, `''` = leave it to the CLI). Remote/docker quick-start refuses it, like `effort`, and so does a remote attach on `POST /api/sessions`. Tests: `test/advisor-model.test.ts`
|
||||||
- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model).
|
- **Model choice: a persistent default in `settings.local.json`, a per-session `--model`, never env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not. A caller that wants one session on a model without touching the case sends `model` on `POST /api/sessions` instead: it goes out as `claude --model <id>`, writes nothing, wins over the app-wide default, and is persisted as `SessionState.model` so both recovery paths relaunch on it (`test/routes/session-routes-claude-model.test.ts`, `test/session-model-recovery.test.ts`). ⚠️ Claude only, via the `model.source` capability (`cliTakesSessionModel()`), never a mode check: refused for other CLIs and on a remote attach, and published by `toState()` for claude alone (cron hands its Claude default to every CLI with a model).
|
||||||
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`
|
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*` vs `GROK_*` vs `DSH_*` vs `OMP_*` vs `COPILOT_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI), and Grok allowlists **`XAI_*`** for the same vendor-namespace reason (`XAI_API_KEY` is grok's documented auth var). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. ⚠️ DeepSeek repeats pi's lesson exactly: a dsh `settings.yaml` can nominate ANY env var as a provider credential (`apiKeyEnv`), so only the vendor namespaces `DSH_*` (launcher inputs incl. `DSH_PERMISSION_MODE`) and `DEEPSEEK_*` (`DEEPSEEK_API_KEY`/`DEEPSEEK_BASE_URL`) are admitted; foreign provider keys authenticate from dsh's own files or the server env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`, `docs/grok-integration.md`, `docs/deepseek-integration.md`, `docs/omp-integration.md`, `docs/copilot-integration.md`
|
||||||
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
|
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
|
||||||
- **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`.
|
- **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`.
|
||||||
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
|
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
|
||||||
@@ -120,7 +120,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Config**: `src/config/` — flat files plus the `cli-registry/` subdir, no barrel (`index.ts`) exists; import from the specific file. ⚠️ There are TWO `config/` directories: the repo-root `config/` holds tooling only (ESLint, knip, the vitest configs, `test-suites.ts`), while runtime config lives in `src/config/`. Throughout this file a bare `config/<name>.ts` in a code context means `src/config/<name>.ts`.
|
**Config**: `src/config/` — flat files plus the `cli-registry/` subdir, no barrel (`index.ts`) exists; import from the specific file. ⚠️ There are TWO `config/` directories: the repo-root `config/` holds tooling only (ESLint, knip, the vitest configs, `test-suites.ts`), while runtime config lives in `src/config/`. Throughout this file a bare `config/<name>.ts` in a code context means `src/config/<name>.ts`.
|
||||||
|
|
||||||
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver`/`grok-cli-resolver`/`deepseek-cli-resolver`/`omp-cli-resolver` (CLI path resolution, one per `SessionMode`, all nine sharing the lookup chain in `cli-executable-resolver`: server PATH, then that CLI's install dirs, then an interactive login shell LAST, since it is the only step that spawns anything and it is what finds nvm/Homebrew installs under a service manager's minimal PATH; ⚠ `pi-`, `grok-` and `deepseek-cli-resolver` additionally probe the binary's identity, since `pi` is a generic name, `grok` has npm squatters, and Debian ships an unrelated `dsh`), `file-query` (⚠ Files-panel search matcher, glob-by-two-pointer, never RegExp), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority), `shell-resolver` (⚠ resolves a real login shell for `mode: 'shell'`; the literal string `$SHELL` used to be expanded by the SERVER's shell, which is empty in a container), `event-loop-monitor` (a sync `execSync` freezes the port while the process stays alive, leaving no trace), `dependency-checker` + `dependency-report` (the `codeman doctor` probe engine, registry in `config/dependency-registry.ts`).
|
**Utilities**: `src/utils/` — re-exported via index. Key: `CleanupManager`, `LRUMap` (⚠ NOT in the barrel — import from `./utils/lru-map.js` directly), `StaleExpirationMap`, `BufferAccumulator`, `stripAnsi`, `Debouncer`, `KeyedDebouncer`. Also: `claude-cli-resolver`/`opencode-cli-resolver`/`codex-cli-resolver`/`gemini-cli-resolver`/`antigravity-cli-resolver`/`pi-cli-resolver`/`grok-cli-resolver`/`deepseek-cli-resolver`/`omp-cli-resolver`/`copilot-cli-resolver` (CLI path resolution, one per `SessionMode`, all ten sharing the lookup chain in `cli-executable-resolver`: server PATH, then that CLI's install dirs, then an interactive login shell LAST, since it is the only step that spawns anything and it is what finds nvm/Homebrew installs under a service manager's minimal PATH; ⚠ `pi-`, `grok-` and `deepseek-cli-resolver` additionally probe the binary's identity, since `pi` is a generic name, `grok` has npm squatters, and Debian ships an unrelated `dsh`), `file-query` (⚠ Files-panel search matcher, glob-by-two-pointer, never RegExp), `string-similarity` (fuzzy matching), `regex-patterns` (ANSI/token/spinner patterns), `assertNever` (exhaustive checks), `token-validation` (auth tokens), `nice-wrapper` (process priority), `shell-resolver` (⚠ resolves a real login shell for `mode: 'shell'`; the literal string `$SHELL` used to be expanded by the SERVER's shell, which is empty in a container), `event-loop-monitor` (a sync `execSync` freezes the port while the process stays alive, leaving no trace), `dependency-checker` + `dependency-report` (the `codeman doctor` probe engine, registry in `config/dependency-registry.ts`).
|
||||||
|
|
||||||
### Key Patterns
|
### Key Patterns
|
||||||
|
|
||||||
@@ -172,7 +172,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md`
|
||||||
|
|
||||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All eight **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp)
|
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, 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)
|
||||||
|
|
||||||
**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)
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||||
|
|
||||||
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 and copilot ONLY, and only while they have mouse tracking on** (copilot has no version gate; with a stale flag it falls back to PageUp/PageDown through its `transcriptPageKeys` capability) (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||||
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
**Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||||
|
|
||||||
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
**Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update)
|
||||||
@@ -273,7 +273,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
| **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) |
|
| **Hook bypass** | `/api/hook-event` + `/api/status-telemetry` skip Basic auth (localhost-only, schema-validated), but when auth is active the loopback bypass requires `X-Codeman-Hook-Secret` **unconditionally** (Codeman cannot detect a user's own loopback reverse proxy) |
|
||||||
| **Lost-frame page** | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a `GET`/`HEAD` carrying `Sec-Fetch-Dest: iframe\|frame`, `Accept: text/html` and mode `navigate` (or none), for a path that is NOT a registered route (never `/api/`, `/ws/`, `/q/`), is answered BEFORE the credential checks with the static web-tab recovery page (`lostWebviewFramePage`: no reflected input, `default-src 'none'` plus its own script hash, `no-store`). `/` is the one registered route also admitted, only when the request carries neither `codeman_session` nor `Authorization` (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly `/` and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in `docs/api-reference.md`. Pinned by `test/webview-auth-exemption.test.ts` + `test/webview-lost-root-frame.test.ts` |
|
| **Lost-frame page** | The THIRD unauthenticated 200, beside the two hook routes, and the only one decided by request headers alone: a `GET`/`HEAD` carrying `Sec-Fetch-Dest: iframe\|frame`, `Accept: text/html` and mode `navigate` (or none), for a path that is NOT a registered route (never `/api/`, `/ws/`, `/q/`), is answered BEFORE the credential checks with the static web-tab recovery page (`lostWebviewFramePage`: no reflected input, `default-src 'none'` plus its own script hash, `no-store`). `/` is the one registered route also admitted, only when the request carries neither `codeman_session` nor `Authorization` (nothing in Codeman frames its own root; a sandboxed frame has neither), since the landing page masks to exactly `/` and its reload otherwise rendered Codeman inside the web tab. ⚠️ A non-browser client can set those headers, so an unauthenticated caller can tell a registered route (401) from a non-route (200) and enumerate the route table; accepted, the routes are public in `docs/api-reference.md`. Pinned by `test/webview-auth-exemption.test.ts` + `test/webview-lost-root-frame.test.ts` |
|
||||||
| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) |
|
| **Tunnel** | Enabling a tunnel **refuses** without `CODEMAN_PASSWORD` unless exposure is acknowledged via `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` or the per-request `acknowledgeUnauthTunnel:true` action field (never persisted) |
|
||||||
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`) |
|
| **Validation** | Zod schemas, Unicode-aware path allowlist regex, env prefix allowlist (`CLAUDE_CODE_*`/`OPENCODE_*`/`CODEX_*`/`GEMINI_*`/`GOOGLE_*`/`ANTIGRAVITY_*`/`PI_*`/`GROK_*`/`XAI_*`/`DSH_*`/`DEEPSEEK_*`/`OMP_*`/`COPILOT_*`) |
|
||||||
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
| **Headers** | CORS localhost-only, CSP, X-Frame-Options, HSTS if HTTPS |
|
||||||
|
|
||||||
**Security-relevant env vars**: `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS=1` (opt-in hooks-only listener on the docker bridge gateway).
|
**Security-relevant env vars**: `CODEMAN_MUX` (managed session), `CODEMAN_API_URL` (auto-set for hooks), `CODEMAN_ALLOWED_HOSTS` (extra Host/Origin allowlist entries for reverse proxies; bare `.suffix` matches subdomains), `CODEMAN_DOCKER_BRIDGE_HOOKS=1` (opt-in hooks-only listener on the docker bridge gateway).
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<h2 align="center">Mission control for AI coding agents</h2>
|
<h2 align="center">Mission control for AI coding agents</h2>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Terminal - One Dashboard • Any Device</em>
|
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • Terminal - One Dashboard • Any Device</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
|
<img src="docs/images/tiles-crt-stats-1600-20261010.gif" alt="Codeman tile grid: six live agents (DeepSeek Harness, Claude Code, Pi, Codex, OpenCode and a shell) powering on and off with the CRT animation, with the live header strip showing CPU, memory and Claude plan usage" width="800">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, or OMP inside persistent tmux sessions, streams the real terminal to any browser, and keeps agents productive after you walk away: it re-prompts on idle, resumes when a usage limit resets, runs scheduled jobs, and shows every background agent working in real time.
|
**Codeman** is a self-hosted mission control for AI coding agents. It spawns Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek Harness, OMP, or 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.
|
||||||
|
|
||||||
Get started in one line (macOS & Linux, Windows via WSL):
|
Get started in one line (macOS & Linux, Windows via WSL):
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ codeman web
|
|||||||
|
|
||||||
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
|
The installer asks before every system change, and re-running the same line updates in place. Full details: [Quick Start - Installation](#quick-start---installation).
|
||||||
|
|
||||||
- **One dashboard, nine CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, or OMP](#more-features) per session (plus plain shell), locally, [in Docker](#isolated-docker-sessions), or [over SSH](#remote-ssh-sessions), with your own dashboards open as [web tabs](#more-features) beside them
|
- **One dashboard, ten CLIs** - run [Claude Code, OpenCode, Codex, Antigravity, Gemini, Pi, Grok, DeepSeek, OMP, or 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
|
||||||
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
|
- **Truly phone-friendly** - a [touch-optimized terminal](#mobile-optimized-web-ui) with instant local echo, QR login, swipe navigation, and push notifications
|
||||||
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
|
- **Runs while you sleep** - [idle detection + respawn cycling](#respawn-controller) and auto-resume when a subscription limit resets, for 24+ hour unattended runs
|
||||||
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
|
- **See your agents think** - [live floating windows](#live-agent-visualization) for every subagent and teammate, with real-time transcripts
|
||||||
@@ -70,7 +70,7 @@ This installs Node.js, tmux and a build toolchain if missing (node-pty ships no
|
|||||||
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist.
|
- **Re-run to update.** The same one-liner updates a finished install in place: local changes in `~/.codeman/app` are stashed (never discarded), and a running service is restarted and verified. If a first install was interrupted, re-running resumes the full setup instead. `install.sh status` prints the URLs and the QR code again; `install.sh update`, `install.sh tailscale` and `install.sh uninstall` also exist.
|
||||||
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
- **Flags for the impatient.** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` answers the questions from the command line (`--lan`, `--local`, `--run`, `--no-start`, `--name <n>`, `--port <n>`, `--yes` too). **CI / headless:** without a terminal attached, steps that would change your system abort with instructions instead of running silently; set `CODEMAN_NONINTERACTIVE=1` to approve them for automation.
|
||||||
|
|
||||||
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), or [OMP](https://github.com/can1357/oh-my-pi) (any combination works; Gemini CLI is enterprise-only since Google's consumer cutover, and Antigravity is its successor). The installer detects whichever of the nine is present; if none is found, it offers to install any of them from a menu (DeepSeek excepted, since its npm package installs only a launcher with no runnable profile), or you can skip and install one yourself later. After install:
|
You'll need at least one AI coding CLI installed — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi), or [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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codeman web
|
codeman web
|
||||||
@@ -259,7 +259,7 @@ Click **+ New Session** (or **Quick Start**). A session is one AI CLI running in
|
|||||||
| Field | What it does |
|
| Field | What it does |
|
||||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
|
| **Working directory / case** | The folder the agent operates in. A "case" is just a named working dir Codeman remembers. **Add Case** creates one from scratch, links an existing folder, or clones a GitHub repo straight into one (**Clone Repo**). |
|
||||||
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, or `Terminal` (plain shell). |
|
| **CLI / run mode** | `Claude` (default), `OpenCode`, `Codex`, `Antigravity`, `Gemini`, `Pi`, `Grok`, `DeepSeek`, `OMP`, `Copilot`, or `Terminal` (plain shell). |
|
||||||
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
|
| **Model** | Per-session model (App Settings → Models → New Claude sessions). A soft default — `/model` still works in-session. |
|
||||||
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
|
| **Effort / Ultracode** | Reasoning effort (`low`–`max`) or `ultracode` for dynamic multi-agent workflows. Switchable anytime with `/effort`. |
|
||||||
|
|
||||||
@@ -463,7 +463,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt
|
|||||||
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions.
|
- **Git status in the bottom bar** — off by default (**App Settings → Header & Panels → Bottom bar → Git status**, per device). A small indicator at the right of the bottom bar shows the active session's repository at a glance: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge conflicts, `✓` all committed and pushed. Click it for a draggable window listing the staged, not-staged, untracked and conflicted files (grouped under collapsed folders, or as a flat list if you turn that setting off) and the unpushed commits; **click a file to see its diff** (new files as all additions, deleted files as all removals), with **Open file** to jump to the viewer. A folder that holds several projects gets one collapsible section per repository found up to two levels down, all collapsed until you open them. Read-only and offline (Codeman never fetches or changes the repo); not shown for Docker or remote sessions.
|
||||||
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
- **Clone a GitHub repo as a case** — paste a repository URL into **Add Case → Clone Repo** and Codeman clones it into `~/codeman-cases/<name>` and registers it as a normal case, ready to run an agent in. It preflights the URL while you type (tells you whether it can be cloned anonymously and offers the repo's real branches and tags for the optional branch/tag field), fills the case name in from the URL, and lets you pick which CLI the Run button should use. Public repositories over `https://`; Codeman never collects or stores credentials
|
||||||
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
|
- **Create a case in a custom folder** — tick **Create in a custom folder** in **Add Case → Create New**, pick a parent folder (Browse included) and a name, and Codeman scaffolds the new case there instead of `~/codeman-cases`. The target must be a new or empty folder; system directories, your home folder itself, credential trees such as `~/.ssh`, and Codeman's own data folder are refused. Admin only in multi-user mode.
|
||||||
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, or **OMP** per session; env-var prefixes auto-gate (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `ANTIGRAVITY_*` vs `GEMINI_*`/`GOOGLE_*` vs `PI_*` vs `GROK_*`/`XAI_*` vs `DSH_*`/`DEEPSEEK_*` vs `OMP_*`). See [`docs/opencode-integration.md`](docs/opencode-integration.md), [`docs/pi-integration.md`](docs/pi-integration.md), [`docs/grok-integration.md`](docs/grok-integration.md), [`docs/deepseek-integration.md`](docs/deepseek-integration.md) and [`docs/omp-integration.md`](docs/omp-integration.md)
|
- **Multi-CLI** — run **Claude Code**, **OpenCode**, **Codex**, **Antigravity**, **Gemini**, **Pi**, **Grok**, **DeepSeek Harness**, **OMP**, or **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)
|
||||||
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
- **Custom model endpoints** _(new in 1.29.0, HTTP API for now)_ — point a session's CLI at any OpenAI-compatible endpoint instead of its native backend: a local llama.cpp, llama-swap, Ollama or vLLM box, or a cloud gateway such as Azure AI Foundry or OpenRouter. Save an endpoint once (`POST /api/model-endpoints`; its models are discovered from `/v1/models`), apply it to a session (`POST /api/sessions/:id/custom-model`), and the CLI restarts in place on that endpoint. Verified live for Claude, OpenCode, Pi, Grok and OMP; Codex, Gemini and DeepSeek have documented gaps, Antigravity has no mechanism. A toolbar picker is the follow-up. See [`docs/custom-model-endpoints.md`](docs/custom-model-endpoints.md)
|
||||||
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
- **Web tabs** — open Grafana, Uptime Kuma, a Vite dev server or any dashboard URL as a tab beside your sessions (Run dropdown → **Web / URL** → **Add URL**). Dashboards are proxied through Codeman's own origin, so an `http://` target works from a phone over HTTPS and through the tunnel, single-page apps route on their own paths, and a frame that reloads recovers itself. A `localhost` link an agent prints opens as a web tab automatically. See [`docs/web-tabs.md`](docs/web-tabs.md)
|
||||||
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
- **Docker sessions** — run a case inside an isolated, hardened container. One checkbox on **Create New** spins up a container with sensible defaults and starts the agent inside it; multiple sessions share one per-case container, or attach a case to a container you already run; export a container + its workspace to a portable `.tar.gz` to move it to another machine. See [`docs/docker-cases.md`](docs/docker-cases.md)
|
||||||
@@ -684,7 +684,7 @@ These run for **every** request — before auth, even on the default no-password
|
|||||||
|
|
||||||
### Input, files & headers
|
### Input, files & headers
|
||||||
|
|
||||||
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` env-prefix allowlist gates which settings each CLI can receive, and the keys that could redirect a CLI's traffic (base URLs, config homes) are clamped for non-admin users
|
- **Schema-validated inputs** — every API body is checked with Zod v4 schemas; a `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` / `COPILOT_*` env-prefix allowlist gates which settings each CLI can receive, and the keys that could redirect a CLI's traffic (base URLs, config homes) are clamped for non-admin users
|
||||||
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 2 GB raw & download (`CODEMAN_MAX_DOWNLOAD_BYTES`; bodies stream and answer `Range` requests, so the cap is a sanity bound rather than memory protection); `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes. The one place HTML renders is the File Viewer's page view: a separate `/html-view/<capability>/` address scoped to the file's own folder, served under `Content-Security-Policy: sandbox` without `allow-same-origin`, so the page has an opaque origin and can never read Codeman's cookies or drive its API; hidden folders and broad roots (`/`, home, `/tmp`) are refused, and the capabilities are revoked on logout
|
- **Path containment** — file routes `realpath` before boundary checks (no TOCTOU); `..`, absolute paths, and symlinks resolving outside the working dir are rejected. Caps: 10 MB text preview / 2 GB raw & download (`CODEMAN_MAX_DOWNLOAD_BYTES`; bodies stream and answer `Range` requests, so the cap is a sanity bound rather than memory protection); `/api/download` blocklists sensitive paths (`.env`, `*credentials*`, `~/.ssh/`, `.aws/credentials`). SVG/HTML is served `octet-stream` + `nosniff` + attachment so it downloads rather than executes. The one place HTML renders is the File Viewer's page view: a separate `/html-view/<capability>/` address scoped to the file's own folder, served under `Content-Security-Policy: sandbox` without `allow-same-origin`, so the page has an opaque origin and can never read Codeman's cookies or drive its API; hidden folders and broad roots (`/`, home, `/tmp`) are refused, and the capabilities are revoked on logout
|
||||||
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
|
- **Security headers** — `Content-Security-Policy` (`default-src 'self'`, every exception enumerated), `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN`, HSTS over HTTPS, and CORS reflected **only** for `localhost` / `127.0.0.1` / `::1`
|
||||||
|
|
||||||
@@ -935,7 +935,7 @@ for _ in $(seq 1 10); do
|
|||||||
done
|
done
|
||||||
printf '%s\n' "$TXT"
|
printf '%s\n' "$TXT"
|
||||||
|
|
||||||
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp) have no transcript:
|
# 5b. Other modes (shell/opencode/gemini/antigravity/pi/grok/omp/copilot) have no transcript:
|
||||||
# read the terminal. ⚠️ Use terminal?tail=, NOT /output: the latter's textOutput
|
# read the terminal. ⚠️ Use terminal?tail=, NOT /output: the latter's textOutput
|
||||||
# is empty for every tmux-backed (i.e. every interactive) session. tail counts
|
# is empty for every tmux-backed (i.e. every interactive) session. tail counts
|
||||||
# BYTES, and what comes back is terminal data, ANSI included.
|
# BYTES, and what comes back is terminal data, ANSI included.
|
||||||
@@ -1155,7 +1155,7 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph External["External"]
|
subgraph External["External"]
|
||||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP</small>"]
|
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot</small>"]
|
||||||
BG["Background Agents<br/><small>(Task tool)</small>"]
|
BG["Background Agents<br/><small>(Task tool)</small>"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+9
-9
@@ -5,7 +5,7 @@
|
|||||||
<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 • 终端 —— 统一仪表盘 • 任意设备</em>
|
<em>Claude Code • OpenCode • Codex • Antigravity • Gemini • Pi • Grok • DeepSeek • OMP • Copilot • 终端 —— 统一仪表盘 • 任意设备</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
> 本文档由英文版 [`README.md`](README.md) 翻译而来。如有出入,以英文版为准。
|
||||||
|
|
||||||
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness 或 OMP,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
|
**Codeman** 是一个自托管的 AI 编程智能体任务控制中心。它在持久化的 tmux 会话里拉起 Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek Harness、OMP 或 GitHub Copilot CLI,把真实的终端流式传到任意浏览器,并在你离开之后让智能体继续干活:空闲时重新提示、用量限额重置后自动续跑、按计划执行任务,还能实时展示每一个后台智能体的工作。
|
||||||
|
|
||||||
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
|
一行命令即可安装(macOS 和 Linux,Windows 通过 WSL):
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ codeman web
|
|||||||
|
|
||||||
安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。
|
安装器在每次系统改动前都会先询问;重跑同一条命令即可原地更新。详见[快速开始 — 安装](#快速开始--安装)。
|
||||||
|
|
||||||
- **一个仪表盘,九个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek 或 OMP](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
|
- **一个仪表盘,十个 CLI**:每个会话可选 [Claude Code、OpenCode、Codex、Antigravity、Gemini、Pi、Grok、DeepSeek、OMP 或 GitHub Copilot](#更多特性)(外加普通 shell),在本机、[Docker 容器](#隔离的-docker-会话)或 [SSH 远程主机](#远程-ssh-会话)上运行,你自己的仪表盘也能作为 [Web 标签页](#更多特性)并排打开
|
||||||
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
|
- **真正的手机友好**:[触控优化的终端](#移动端优化的-web-ui),即时本地回显、二维码登录、滑动导航与推送通知
|
||||||
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
|
- **睡觉时也在跑**:[空闲检测 + 重生循环](#重生控制器respawn-controller),订阅限额重置后自动续跑,支持 24 小时以上的无人值守运行
|
||||||
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
|
- **看见智能体在想什么**:每个子智能体和团队成员都有[实时浮动窗口](#实时智能体可视化),附带实时活动记录
|
||||||
@@ -72,7 +72,7 @@ curl -fsSL https://getcodeman.com/install | bash
|
|||||||
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。`install.sh status` 会再次打印 URL 和二维码;另外还有 `install.sh update`、`install.sh tailscale` 和 `install.sh uninstall`。
|
- **重跑即更新。** 再次运行同一条命令即可原地更新已完成的安装:`~/.codeman/app` 中的本地改动会被 stash(绝不丢弃),运行中的服务会自动重启并校验。若首次安装中途失败,重跑会继续完成完整的安装流程。`install.sh status` 会再次打印 URL 和二维码;另外还有 `install.sh update`、`install.sh tailscale` 和 `install.sh uninstall`。
|
||||||
- **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
- **给急性子的参数。** `curl -fsSL https://getcodeman.com/install | bash -s -- --tailscale --service` 直接在命令行上回答这些问题(还有 `--lan`、`--local`、`--run`、`--no-start`、`--name <n>`、`--port <n>`、`--yes`)。**CI / 无终端环境:** 没有终端时,涉及系统改动的步骤会带着说明中止,而不是静默执行;在自动化场景设置 `CODEMAN_NONINTERACTIVE=1` 即可批准这些步骤。
|
||||||
|
|
||||||
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 或 [OMP](https://github.com/can1357/oh-my-pi)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这九个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
|
你至少需要安装一个 AI 编程 CLI —— [Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[OpenCode](https://opencode.ai)、[Codex](https://developers.openai.com/codex/cli)、[Antigravity](https://antigravity.google)、[Gemini CLI](https://github.com/google-gemini/gemini-cli)、[Pi](https://pi.dev)、[Grok Build](https://github.com/xai-org/grok-build)、[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)、[OMP](https://github.com/can1357/oh-my-pi) 或 [GitHub Copilot CLI](https://github.com/github/copilot-cli)(任意组合均可;自 Google 面向消费者停售后,Gemini CLI 仅限企业版,Antigravity 是其继任者)。安装器会自动检测这十个中已安装的任意一个;若一个都没有,会给出一个菜单让你安装其中任意一个(DeepSeek 除外,它的 npm 包只装一个启动器,没有可运行的 profile),也可以选择跳过、稍后自行安装。安装完成后:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
codeman web
|
codeman web
|
||||||
@@ -261,7 +261,7 @@ codeman service install # systemd/launchd 服务:重启后自动回
|
|||||||
| 字段 | 作用 |
|
| 字段 | 作用 |
|
||||||
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。**Add Case** 可以从零创建、链接一个已有文件夹,或把一个 GitHub 仓库直接克隆成 case(**Clone Repo**)。 |
|
| **工作目录 / case** | 智能体操作的文件夹。「case」就是一个 Codeman 记住的命名工作目录。**Add Case** 可以从零创建、链接一个已有文件夹,或把一个 GitHub 仓库直接克隆成 case(**Clone Repo**)。 |
|
||||||
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP` 或 `Terminal`(普通 shell)。 |
|
| **CLI / 运行模式** | `Claude`(默认)、`OpenCode`、`Codex`、`Antigravity`、`Gemini`、`Pi`、`Grok`、`DeepSeek`、`OMP`、`Copilot` 或 `Terminal`(普通 shell)。 |
|
||||||
| **模型** | 每会话模型(App Settings → Models → New Claude sessions)。软默认值 —— 会话内 `/model` 依然有效。 |
|
| **模型** | 每会话模型(App Settings → Models → New Claude sessions)。软默认值 —— 会话内 `/model` 依然有效。 |
|
||||||
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
|
| **Effort / Ultracode** | 推理力度(`low`–`max`),或用 `ultracode` 开启动态多智能体工作流。随时可用 `/effort` 切换。 |
|
||||||
|
|
||||||
@@ -465,7 +465,7 @@ PTY 输出 → 16ms 服务端批处理 → DEC 2026 包裹 → SSE → 客户端
|
|||||||
- **底栏里的 Git 状态**:默认关闭(**App Settings → Header & Panels → Bottom bar → Git status**,按设备生效)。底栏右侧的一个小指示器让你一眼看清当前会话所在仓库的状态:`● 3` 表示未提交的文件,`↑ 2` 表示未推送的提交,`⚠` 表示合并冲突,`✓` 表示全部已提交并推送。点击它会打开一个可拖动的窗口,列出已暂存、未暂存、未跟踪和有冲突的文件(按折叠的文件夹分组,关掉该设置则显示为平铺列表)以及未推送的提交;**点击文件即可查看它的 diff**(新文件全部显示为新增,已删除的文件全部显示为删除),**Open file** 可直接跳到查看器。一个包含多个项目的文件夹,会为向下两层内找到的每个仓库各给一个可折叠分区,打开之前全部折叠。只读且离线(Codeman 绝不 fetch,也绝不改动仓库);Docker 和远程会话不显示
|
- **底栏里的 Git 状态**:默认关闭(**App Settings → Header & Panels → Bottom bar → Git status**,按设备生效)。底栏右侧的一个小指示器让你一眼看清当前会话所在仓库的状态:`● 3` 表示未提交的文件,`↑ 2` 表示未推送的提交,`⚠` 表示合并冲突,`✓` 表示全部已提交并推送。点击它会打开一个可拖动的窗口,列出已暂存、未暂存、未跟踪和有冲突的文件(按折叠的文件夹分组,关掉该设置则显示为平铺列表)以及未推送的提交;**点击文件即可查看它的 diff**(新文件全部显示为新增,已删除的文件全部显示为删除),**Open file** 可直接跳到查看器。一个包含多个项目的文件夹,会为向下两层内找到的每个仓库各给一个可折叠分区,打开之前全部折叠。只读且离线(Codeman 绝不 fetch,也绝不改动仓库);Docker 和远程会话不显示
|
||||||
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
|
- **把 GitHub 仓库克隆成 case** —— 在 **Add Case → Clone Repo** 里粘贴一个仓库 URL,Codeman 会把它克隆到 `~/codeman-cases/<name>` 并注册为普通 case,随时可以跑智能体。输入时它会预检 URL(告诉你能否匿名克隆,并为可选的分支/标签字段提供仓库真实的分支与标签),从 URL 里填好 case 名,还让你选 Run 按钮该用哪个 CLI。支持 `https://` 的公开仓库;Codeman 绝不收集或保存凭据
|
||||||
- **在自定义文件夹里创建 case**:在 **Add Case → Create New** 里勾选 **Create in a custom folder**,选一个父文件夹(可用 Browse 浏览)和一个名字,Codeman 就会在那里而不是 `~/codeman-cases` 里搭建新 case。目标必须是一个新的或空的文件夹;系统目录、你的主目录本身、`~/.ssh` 这类凭据目录,以及 Codeman 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
|
- **在自定义文件夹里创建 case**:在 **Add Case → Create New** 里勾选 **Create in a custom folder**,选一个父文件夹(可用 Browse 浏览)和一个名字,Codeman 就会在那里而不是 `~/codeman-cases` 里搭建新 case。目标必须是一个新的或空的文件夹;系统目录、你的主目录本身、`~/.ssh` 这类凭据目录,以及 Codeman 自己的数据目录都会被拒绝。多用户模式下仅管理员可用
|
||||||
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness** 或 **OMP**;环境变量前缀自动隔离(`CLAUDE_CODE_*`、`OPENCODE_*`、`CODEX_*`、`ANTIGRAVITY_*`、`GEMINI_*`/`GOOGLE_*`、`PI_*`、`GROK_*`/`XAI_*`、`DSH_*`/`DEEPSEEK_*` 与 `OMP_*`)。详见 [`docs/opencode-integration.md`](docs/opencode-integration.md)、[`docs/pi-integration.md`](docs/pi-integration.md)、[`docs/grok-integration.md`](docs/grok-integration.md)、[`docs/deepseek-integration.md`](docs/deepseek-integration.md) 与 [`docs/omp-integration.md`](docs/omp-integration.md)
|
- **多 CLI** —— 每个会话可选 **Claude Code**、**OpenCode**、**Codex**、**Antigravity**、**Gemini**、**Pi**、**Grok**、**DeepSeek Harness**、**OMP** 或 **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)
|
||||||
- **自定义模型端点**(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)
|
||||||
@@ -686,7 +686,7 @@ Codeman 默认用 `--dangerously-skip-permissions` 启动会话,因此 Web UI
|
|||||||
|
|
||||||
### 输入、文件与响应头
|
### 输入、文件与响应头
|
||||||
|
|
||||||
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置,而那些能把 CLI 流量改道的键(base URL、配置目录)对非管理员用户会被钳制
|
- **模式校验的输入** —— 每个 API 请求体都用 Zod v4 模式检查;一个 `CLAUDE_CODE_*` / `OPENCODE_*` / `CODEX_*` / `ANTIGRAVITY_*` / `GEMINI_*` / `GOOGLE_*` / `PI_*` / `GROK_*` / `XAI_*` / `DSH_*` / `DEEPSEEK_*` / `OMP_*` / `COPILOT_*` 环境变量前缀允许列表把控每个 CLI 能接收哪些设置,而那些能把 CLI 流量改道的键(base URL、配置目录)对非管理员用户会被钳制
|
||||||
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 2 GB 原始与下载(`CODEMAN_MAX_DOWNLOAD_BYTES`;响应体是流式的并支持 `Range` 请求,所以这个上限只是合理性边界,不是内存保护);`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行。HTML 唯一会被渲染的地方是文件查看器的网页视图:一个独立的 `/html-view/<capability>/` 地址,只覆盖该文件所在的文件夹,响应带 `Content-Security-Policy: sandbox` 且不含 `allow-same-origin`,因此页面是不透明源,永远读不到 Codeman 的 cookie,也驱动不了它的 API;隐藏文件夹和范围过大的根目录(`/`、主目录、`/tmp`)会被拒绝,退出登录时这些凭证也会被吊销
|
- **路径限定** —— 文件路由在边界检查前先 `realpath`(无 TOCTOU);`..`、绝对路径、以及解析到工作目录之外的符号链接都会被拒绝。上限:10 MB 文本预览 / 2 GB 原始与下载(`CODEMAN_MAX_DOWNLOAD_BYTES`;响应体是流式的并支持 `Range` 请求,所以这个上限只是合理性边界,不是内存保护);`/api/download` 对敏感路径(`.env`、`*credentials*`、`~/.ssh/`、`.aws/credentials`)做黑名单。SVG/HTML 以 `octet-stream` + `nosniff` + attachment 提供,因此会被下载而非执行。HTML 唯一会被渲染的地方是文件查看器的网页视图:一个独立的 `/html-view/<capability>/` 地址,只覆盖该文件所在的文件夹,响应带 `Content-Security-Policy: sandbox` 且不含 `allow-same-origin`,因此页面是不透明源,永远读不到 Codeman 的 cookie,也驱动不了它的 API;隐藏文件夹和范围过大的根目录(`/`、主目录、`/tmp`)会被拒绝,退出登录时这些凭证也会被吊销
|
||||||
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
|
- **安全响应头** —— `Content-Security-Policy`(`default-src 'self'`,每个例外都逐条列举)、`X-Content-Type-Options: nosniff`、`X-Frame-Options: SAMEORIGIN`、HTTPS 下的 HSTS,以及**仅**对 `localhost` / `127.0.0.1` / `::1` 反射的 CORS
|
||||||
|
|
||||||
@@ -933,7 +933,7 @@ for _ in $(seq 1 10); do
|
|||||||
done
|
done
|
||||||
printf '%s\n' "$TXT"
|
printf '%s\n' "$TXT"
|
||||||
|
|
||||||
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp)没有 transcript,读终端。
|
# 5b. 其他模式(shell/opencode/gemini/antigravity/pi/grok/omp/copilot)没有 transcript,读终端。
|
||||||
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
|
# ⚠️ 用 terminal?tail=,不要用 /output:后者的 textOutput 对每个由 tmux 承载的
|
||||||
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
|
# (也就是每个交互式)会话都是空的。tail 按字节计,返回的是含 ANSI 的终端数据。
|
||||||
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
curl -s "$API/api/sessions/$SID/terminal?tail=8000" | jq -r '.data.terminalBuffer'
|
||||||
@@ -1154,7 +1154,7 @@ flowchart TB
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph External["外部"]
|
subgraph External["外部"]
|
||||||
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP</small>"]
|
CLI["AI CLI<br/><small>Claude Code / OpenCode / Codex / Antigravity / Gemini / Pi / Grok / DeepSeek / OMP / GitHub Copilot</small>"]
|
||||||
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
BG["后台智能体<br/><small>(Task 工具)</small>"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -277,5 +277,33 @@
|
|||||||
"docsUrl": "https://omp.sh"
|
"docsUrl": "https://omp.sh"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "copilot",
|
||||||
|
"label": "GitHub Copilot",
|
||||||
|
"shortBadge": "CP",
|
||||||
|
"enabled": true,
|
||||||
|
"order": 100,
|
||||||
|
"kind": "agent",
|
||||||
|
"discovery": {
|
||||||
|
"binaries": [
|
||||||
|
"copilot"
|
||||||
|
],
|
||||||
|
"searchDirs": [
|
||||||
|
"~/.local/bin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
"~/.bun/bin",
|
||||||
|
"~/.npm-global/bin",
|
||||||
|
"~/bin"
|
||||||
|
],
|
||||||
|
"install": {
|
||||||
|
"command": {
|
||||||
|
"linux": "npm install -g @github/copilot",
|
||||||
|
"darwin": "npm install -g @github/copilot"
|
||||||
|
},
|
||||||
|
"npmPackage": "@github/copilot",
|
||||||
|
"docsUrl": "https://github.com/github/copilot-cli"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -126,7 +126,7 @@ RUN set -eux; \
|
|||||||
# ⚠️ The default is in REGISTRY order, byte-identical to what the generator emits.
|
# ⚠️ The default is in REGISTRY order, byte-identical to what the generator emits.
|
||||||
# A different order is a different RUN string, which is a different layer hash and
|
# A different order is a different RUN string, which is a different layer hash and
|
||||||
# so a needless cache miss between a bare `docker build` and a scripted one.
|
# so a needless cache miss between a bare `docker build` and a scripted one.
|
||||||
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli"
|
ARG CLI_NPM_PACKAGES="@anthropic-ai/claude-code opencode-ai @openai/codex @google/gemini-cli @github/copilot"
|
||||||
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
# uv/uvx: MCP servers are commonly launched with `uvx <package>` (e.g. the Nginx
|
||||||
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
# Proxy Manager MCP), and Codex failed to enable them with "uvx not found". Copied
|
||||||
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
# from the pinned upstream image into root-owned /usr/local/bin, never pip-installed.
|
||||||
|
|||||||
@@ -1141,7 +1141,7 @@ Copies MCP servers between the agent CLIs' own user-level config files (`docs/cl
|
|||||||
Result (`data`):
|
Result (`data`):
|
||||||
|
|
||||||
- `applied` — `false` for the dry run.
|
- `applied` — `false` for the dry run.
|
||||||
- `targets[]` — one per enabled CLI that declares an MCP config, plus GitHub Copilot CLI (`id: "copilot"`, a sync-only target that is not a run mode): `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
|
- `targets[]` — one per enabled CLI that declares an MCP config, including GitHub Copilot CLI (`id: "copilot"`; it is a run mode that declares its own `mcpConfig`, and a sync-only target takes its place only while that run mode is disabled): `id`, `label`, `file`, `status`, `error?`, `servers` (names it already has), `added` (names added, or that would be), `skipped` (names its dialect cannot express, e.g. SSE for Codex and Antigravity).
|
||||||
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
|
- `status`: `ok`; `absent` (not installed and no config file, so not read or created); `skipped` (the CLI's relocation env var, e.g. `CODEX_HOME`, is set to a relative path in the server's environment, so its file cannot be located safely and is neither read nor written); `unreadable` (the file exists but cannot be parsed safely, so it is not written); `failed` (a read or write error, the file may be unchanged).
|
||||||
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
|
- `error` says why a target is not `ok`. A parse failure is reported by position only (`not valid TOML (line 3, column 21)`, `not valid JSON`), never with text from the file.
|
||||||
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for the sync-only Copilot CLI); see `docs/cli-registry.md`.
|
- `file` honours each CLI's own relocation env var as the server process sees it (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for the sync-only Copilot CLI); see `docs/cli-registry.md`.
|
||||||
|
|||||||
@@ -221,11 +221,11 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect`
|
|||||||
|
|
||||||
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
||||||
|
|
||||||
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
|
**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode and copilot) 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/pi/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`.
|
||||||
|
|
||||||
⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
||||||
|
|
||||||
**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk.
|
**Only claude ≥ 2.1.187 and copilot, each with mouse tracking on, forward the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. **Copilot is the second forwarder** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk.
|
||||||
|
|
||||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -1,6 +1,6 @@
|
|||||||
# The CLI registry
|
# The CLI registry
|
||||||
|
|
||||||
Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness and OMP — is a `CliEntry`: a data record describing how to find the binary, how to build its command line, what environment it needs, and what it can do. Code that used to ask "which CLI is this?" asks the entry instead.
|
Every run mode Codeman can launch — Claude Code, Terminal/Shell, OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek Harness, OMP and 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.
|
||||||
|
|
||||||
## Where it lives
|
## Where it lives
|
||||||
|
|
||||||
@@ -334,11 +334,11 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
|
|||||||
|
|
||||||
## MCP server sync
|
## MCP server sync
|
||||||
|
|
||||||
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (plus GitHub Copilot CLI as a sync-only target, below); every format was checked against what the CLI's own `mcp add` writes, except opencode's (documented, not installed to check). A CLI with no entry (pi, grok, omp, deepseek) is not guessed at: it is listed as `unsupported` in the result when enabled. Adding one is a registry entry plus a small adapter in `mcp-sync.ts`, and a verified fixture in `test/mcp-sync.test.ts`.
|
`capabilities.mcpConfig` (`{ path, format, relocation? }`, `path` relative to the home directory) names the file a CLI keeps its user-level MCP server list in and the dialect it is written in. `src/mcp-sync.ts` reads that list from every ENABLED CLI that declares one, and that is installed or already has the file (a CLI that is neither is reported `absent`, never created), and adds any server a CLI is missing from the others. It writes other tools' own config, so it is **opt-in**: `mcpSyncEnabled` (synced, default OFF) gates `GET`/`POST /api/mcp-sync` (403 while off) and the Settings → Agents & CLIs → MCP servers controls. Declared today for claude, gemini, codex, opencode and antigravity (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`.
|
||||||
|
|
||||||
**Tools that are not run modes.** GitHub Copilot CLI keeps an MCP list worth syncing but Codeman does not launch it, so it has no registry entry. `src/mcp-sync-targets.ts` declares such tools as plain data (`MCP_SYNC_ONLY_TOOLS`: id, label, config path, dialect, relocation var, the binary whose presence means "installed"). They join the registry CLIs as sync targets (listed after them, so a registry CLI's definition wins a same-name difference), under the same rules: installed or already configured, otherwise `absent`. Copilot's dialect is `copilot-json` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`; checked against `copilot mcp add` 1.0.94): `mcpServers`, each entry with `tools` (`["*"]` = all), `type` `local` | `http` | `sse`, `command`/`args`/`env` or `url`/`headers`. `copilot mcp disable` does not mark the entry: it lists the name under `disabledMcpServers` in `settings.json` beside the config. Sync reads that list (never writes it) so a disabled server is not copied, and reports the target `unreadable` if `settings.json` is not valid JSON rather than guessing.
|
**Tools that are not run modes, 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.
|
||||||
|
|
||||||
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`), gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`) and, for the sync-only Copilot CLI, `COPILOT_HOME` (`mcp-config.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
|
`relocation` (`{ envVar, path }`) names the env var the CLI itself reads to move that file: claude `CLAUDE_CONFIG_DIR` (`.claude.json` under it), codex `CODEX_HOME` (`config.toml`), opencode `XDG_CONFIG_HOME` (`opencode/opencode.json`), gemini `GEMINI_CLI_HOME` (`.gemini/settings.json`) copilot `COPILOT_HOME` (`mcp-config.json`); antigravity follows `$HOME` only, so it declares none. The var is read from the SERVER process env at call time, which is the env the CLIs Codeman spawns inherit. An absolute value moves the file to `<value>/<relocation.path>`, an empty one counts as unset (as it does for each CLI), and anything else reports the target `skipped` with the reason instead of writing a file the CLI never reads. A per-session relocation (a session's own `CLAUDE_CONFIG_DIR` in `envOverrides`) is not followed: the sync only knows the server's environment.
|
||||||
|
|
||||||
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
|
The rules the module keeps and the tests pin: it only ADDS (a name already defined, in any shape, is never edited or removed; a same-name difference is reported as a conflict); a server switched off in its own CLI is not copied; it never writes a file it could not parse (opencode JSONC with comments, a TOML file with a duplicate table) and re-parses the new text before writing; codex TOML is read with a real parser (`smol-toml`), so CRLF files and inline tables are handled; names such as `__proto__` are ignored and every table keyed by an untrusted name has no prototype; a symlinked config is written through, not replaced; a file that receives `env`/`headers` is left `0600`; only one apply runs at a time; and its result carries server names only, never env values or headers, and never file text: a parse failure is reported by line and column, not by the parser's message (smol-toml prints a code frame of the offending lines and V8's JSON errors quote source, either of which can hold a secret). The schema restricts `path` and `relocation.path` to a relative path without `..`, since sync writes to it.
|
||||||
|
|
||||||
@@ -347,3 +347,9 @@ The rules the module keeps and the tests pin: it only ADDS (a name already defin
|
|||||||
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
|
- [Agent CLIs](wiki/Agent-CLIs.md) — the user-facing per-CLI guide.
|
||||||
- `docs/architecture-invariants.md` — the mechanics and the history behind the rules above.
|
- `docs/architecture-invariants.md` — the mechanics and the history behind the rules above.
|
||||||
- `docs/deepseek-integration.md` — why DeepSeek is shaped the way it is.
|
- `docs/deepseek-integration.md` — why DeepSeek is shaped the way it is.
|
||||||
|
|
||||||
|
## GitHub Copilot CLI
|
||||||
|
|
||||||
|
`copilot` (npm `@github/copilot`) is a stock entry. A bare launch is `copilot`, in its own Manual Approval mode. The launch parameters follow the grok/omp shape: `model` (`--model`), `resumeId` (`--resume <id>`, an id, id prefix or session name: a single word that starts with a letter or digit, so a flag-shaped value such as `--yolo` is dropped rather than read as its own flag) and `continueSession` (`--continue`, skipped when a resume id is set), plus `allowAll` (`--yolo`: allow every tool, path and URL). The legacy config object is `copilotConfig` (`{ model, allowAll, resumeSessionId, continueSession }`). The Run button sends `allowAll: true` like the other agent CLIs' bypass switches, and `allowAll` is declared in `privilegedParams`, so a non-granted multi-user owner has it forced back to `false` (an absent config is never materialized: a bare spawn is already safe). Session history resume (Past Sessions) sends only `copilotConfig.continueSession`, which continues the most recent conversation Copilot has for that directory; `resumeSessionId` is for API callers. The tab names the model it reads from the footer (`modelDetect`). Its `altScreen` is `strip-mux-and-mouse`, like opencode: Copilot turns mouse tracking on itself, and left in, a plain drag is reported to the TUI instead of selecting (Auto Copy and copy-on-select did nothing); stripped, a drag is a local selection, clicks go through the hand-encoded tap, and the wheel is forwarded as SGR reports while `cliMouseTracking` is on. It declares `mcpConfig` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`) and no `credStore`: its token sits in `config.json` in plain text on a host without a keyring, so a Docker case is not seeded and you sign in inside the container. `env.allowedKeys` is empty on purpose: a headless host sets `COPILOT_GITHUB_TOKEN`, which the `COPILOT_` prefix admits, and `GH_TOKEN`/`GITHUB_TOKEN` stay out of the one global allowlist.
|
||||||
|
|
||||||
|
Measured on 1.0.94 inside tmux: an alternate-screen TUI with mouse tracking on, whose composer is the `❯` between two rules. While a turn runs the footer reads `◉ Working esc edit prompt` (the dot alternates `◉`/`◎`), which is the `workDetect` working line. Keystrokes sent with `tmux send-keys` reach the composer, Shift+Enter inserts a newline, and a shell tool call completes inside tmux; github/copilot-cli#4180 (a driven PTY ignores input) and #4223 (a shell command never completes under tmux) report both failing on 1.0.70 to 1.0.74 and neither reproduces on 1.0.94. `COPILOT_HOME` and `COPILOT_ALLOW_ALL` are privileged env keys, so a non-granted multi-user owner cannot set them through `envOverrides`, and so are the other `COPILOT_*` variables (read from the 1.0.98 bundle, far more than `copilot help environment` lists) that switch approvals, run code with no approval row or send the sign-in token elsewhere. The `COPILOT_` prefix admits whatever a later release adds, so the list needs a look whenever the measured version moves.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# GitHub Copilot CLI sessions
|
||||||
|
|
||||||
|
Codeman can drive [GitHub Copilot CLI](https://github.com/github/copilot-cli) (`copilot`,
|
||||||
|
npm `@github/copilot`) as a session backend, alongside Claude Code, OpenCode, Codex, Gemini,
|
||||||
|
Antigravity, Pi, Grok, DeepSeek Harness and OMP. `copilot` is a run mode: its own PTY, its
|
||||||
|
own tmux session, its own tab identity (purple, a drawn mark rather than GitHub's logo). It is
|
||||||
|
not a location overlay like Docker or remote-SSH cases, and it is not a web tab.
|
||||||
|
|
||||||
|
Everything here was measured against Copilot CLI 1.0.94 and 1.0.95 inside tmux. The entry
|
||||||
|
itself is registry data (`src/config/cli-registry/stock.ts`); see
|
||||||
|
[`cli-registry.md`](./cli-registry.md#github-copilot-cli) for the field-by-field reference.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @github/copilot
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI management (Settings → Agents & CLIs) can run exactly that for you. Codeman resolves the
|
||||||
|
binary from the server's PATH and the usual npm/nvm prefixes, then runs `copilot --version`
|
||||||
|
and accepts it only if the output says `GitHub Copilot CLI <x.y.z>`. A bare `copilot` on PATH
|
||||||
|
that answers anything else is ignored. Check what it resolved:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s localhost:3000/api/copilot/status | jq
|
||||||
|
# { "available": true, "path": "/home/you/.local/bin", "version": "1.0.95" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authenticate
|
||||||
|
|
||||||
|
Codeman never logs Copilot in or stores its credentials.
|
||||||
|
|
||||||
|
- **Interactive.** Run `copilot` once and use `/login` (or `copilot login`), or do it inside a
|
||||||
|
Codeman tab. The token goes to the OS keychain when one is available.
|
||||||
|
- **Headless server.** There is no keychain on a service, so Copilot offers to keep the token
|
||||||
|
as **plain text in `~/.copilot/config.json`** (`authTokens`). That is where it ends up when you
|
||||||
|
accept. To keep a token out of a file, set `COPILOT_GITHUB_TOKEN` in the service environment;
|
||||||
|
Copilot reads it first, then `GH_TOKEN`, then `GITHUB_TOKEN`.
|
||||||
|
- **Per-session override.** `COPILOT_*` is the only prefix the env allowlist admits for
|
||||||
|
Copilot. `GH_TOKEN` and `GITHUB_TOKEN` are deliberately **not** accepted as per-session
|
||||||
|
overrides: the allowlist is one global list with no mode context, so admitting them would
|
||||||
|
make them settable on every session. Use `COPILOT_GITHUB_TOKEN`.
|
||||||
|
- **No GitHub sign-in is needed** when a session points at your own endpoint (below).
|
||||||
|
|
||||||
|
## What Codeman wires up
|
||||||
|
|
||||||
|
| Thing | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| Launch | `copilot`, in Copilot's own Manual Approval mode. |
|
||||||
|
| Run button | `copilot --yolo` (allow every tool, path and URL), like the other CLIs' bypass switches, so an unattended session never stops on an approval. |
|
||||||
|
| Permission clamp | `allowAll` is a `privilegedParam`. A multi-user owner without the bypass grant has it forced back to `false`; an absent config is never materialized (a bare `copilot` is already safe). `COPILOT_HOME`, `COPILOT_ALLOW_ALL`, `COPILOT_PROVIDER_*` and `COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals (`COPILOT_PLAN_THEN_AUTOPILOT`), runs code with no approval row (`COPILOT_CLI_DIST_DIR`, the package caches, `COPILOT_EDITOR`, `COPILOT_PROVIDER_API_KEY_COMMAND`, skills, hooks) or sends the sign-in token to another host (`COPILOT_API_URL`, `COPILOT_GH_HOST`, ...): the list is in `stock.ts`, read from the 1.0.98 bundle. A variable a later Copilot release adds is not covered until it is listed there. |
|
||||||
|
| Model | `--model <id>`, sent only by an API caller that sets `copilotConfig.model` (the value must start with a letter or digit, like `resumeSessionId`). Nothing in the UI sets it: a saved custom endpoint sets `COPILOT_MODEL` instead (see below). |
|
||||||
|
| Resume | `--resume <value>`, sent only by an API caller that sets `copilotConfig.resumeSessionId` (Past Sessions does not). The value (an id, an id prefix or a session name) must be one plain word starting with a letter or digit: `--resume`'s value is optional, so `--resume --yolo` would otherwise make `--yolo` its own flag and bypass the clamp. |
|
||||||
|
| Continue | `--continue`, from **Past Sessions**: it resumes the most recent conversation Copilot has for that directory. Nothing sets it on a respawn. Skipped when a resume value is set. |
|
||||||
|
| Name | A tab name **you set yourself** is passed as `--name <tab name>`, so Copilot's own session list reads like that tab. An automatic name (`w1-myapp`, or an auto-named title) is not pinned. Copilot refuses `--name` with `--resume`/`--continue`, so it is dropped there and a continued session keeps the name it had. |
|
||||||
|
| Working / idle | The footer. A turn shows `◉ Working esc edit prompt` (the dot alternates `◉`/`◎`); the composer is the `❯` between two rules. A tool waiting for approval replaces the composer and reads as idle. |
|
||||||
|
| Model detection | The right-hand field of the footer's last row, at rest and mid-turn. |
|
||||||
|
| Status probe | `GET /api/copilot/status`, plus an entry in the CLI-installed probes and `codeman doctor`. |
|
||||||
|
| MCP | The entry declares `mcpConfig` (`~/.copilot/mcp-config.json`, dialect `copilot-json`, relocated by `COPILOT_HOME`), so [MCP server sync](./cli-registry.md) treats it as a target and a source. `copilot mcp disable` is honoured (read from `settings.json`, never written). |
|
||||||
|
| Docker | The agent image installs `@github/copilot`. No credential directory is seeded (see below). |
|
||||||
|
|
||||||
|
### Your own endpoint (BYOK)
|
||||||
|
|
||||||
|
With custom model endpoints on, a saved endpoint is offered in the Run menu for Copilot and
|
||||||
|
launches straight onto it (no restart; the env is applied before the process starts):
|
||||||
|
|
||||||
|
| Codeman field | Variable |
|
||||||
|
| --- | --- |
|
||||||
|
| base URL (+ `/v1`) | `COPILOT_PROVIDER_BASE_URL` |
|
||||||
|
| key | `COPILOT_PROVIDER_API_KEY` |
|
||||||
|
| model | `COPILOT_MODEL` |
|
||||||
|
|
||||||
|
`COPILOT_PROVIDER_TYPE` is not set: it defaults to `openai`, which is what a Codeman custom
|
||||||
|
endpoint is (llama.cpp, vLLM, Ollama). All of these are privileged env keys.
|
||||||
|
|
||||||
|
Checked at the CLI level against a local OpenAI-compatible endpoint (1.0.95): it posted to
|
||||||
|
`/v1/chat/completions` with the key and the model name, needed no GitHub sign-in, and
|
||||||
|
`--resume <id> --model <m>` / `--continue --model <m>` resumed earlier sessions. Tool calls
|
||||||
|
through a custom endpoint were not exercised. Only keys are persisted (never values).
|
||||||
|
|
||||||
|
## Terminal behavior
|
||||||
|
|
||||||
|
Copilot is an alternate-screen TUI with mouse tracking on, so it needed one non-default
|
||||||
|
decision, the same one OpenCode needed:
|
||||||
|
|
||||||
|
- **Selection.** Left in, xterm reports a plain drag to the TUI instead of selecting, so
|
||||||
|
Auto Copy and copy-on-select silently did nothing (only Shift+drag selected). The entry is
|
||||||
|
`altScreen: 'strip-mux-and-mouse'`: the tracking DECSETs are stripped under tmux, a drag is
|
||||||
|
a local selection, and clicks still reach Copilot through the browser's hand-encoded tap
|
||||||
|
(`cliMouseTracking` is published as the server strips).
|
||||||
|
- **Wheel and touch.** Stripping the DECSETs also stops xterm encoding the wheel, so
|
||||||
|
`_shouldForwardWheelToApp` forwards it by hand as SGR reports while Copilot is tracking the
|
||||||
|
mouse; a phone swipe goes through the same gate. Shift+wheel scrolls Codeman's local
|
||||||
|
history. If the tracking flag is stale after a server restart, the gesture falls back to
|
||||||
|
PageUp/PageDown rather than going dead (Copilot pages its own transcript on those keys: three
|
||||||
|
PageUp moved a 140-line answer from lines 109-140 to 010-042).
|
||||||
|
- **Keys.** Keystrokes from `tmux send-keys` and `POST /api/sessions/:id/input` reach the
|
||||||
|
composer; Shift+Enter inserts a newline; shell tool calls complete inside tmux
|
||||||
|
(github/copilot-cli#4180 and #4223 report both failing on 1.0.70 to 1.0.74; neither
|
||||||
|
reproduces on 1.0.94).
|
||||||
|
- **Scrollbar.** Copilot draws a `┃` gutter at the right edge of every row; the response
|
||||||
|
reader strips it.
|
||||||
|
|
||||||
|
## `codeman agent` verbs
|
||||||
|
|
||||||
|
The three `CODEMAN_*` variables are exported into every pane, so the
|
||||||
|
[session-to-session verbs](../README.md#codeman-agent--session-to-session-verbs-in-every-cli-mode)
|
||||||
|
work from inside a Copilot tab and against Copilot workers:
|
||||||
|
|
||||||
|
- `spawn` waits for the composer. The ready mark is the footer hint `/ commands · ? help`, **not**
|
||||||
|
`❯`: the folder-trust dialog draws `❯ 1. Yes`, so the glyph would call a worker ready while it
|
||||||
|
is still asking whether to trust the folder. An untrusted folder therefore makes `spawn`
|
||||||
|
warn that the composer was not seen instead of claiming ready.
|
||||||
|
- `send --wait` resolves on `idle`. `--until stop` is refused (`400`, "no Claude Code hooks")
|
||||||
|
like every hook-less mode; use `idle`, `exit` or a `--match` marker.
|
||||||
|
- `read` returns the last answer. Copilot's output stream is absolute cursor moves rather
|
||||||
|
than lines, so the route reads the pane's **visible screen** (tmux `capture-pane`) and cuts it
|
||||||
|
with a per-CLI pane dialect (`src/web/response-viewer-transcript.ts`): `❯ <prompt> HH:MM`,
|
||||||
|
`● <answer>` with indented continuation, and the cwd / `Session: N AIC used` row, composer,
|
||||||
|
footer and scrollbar as chrome. Everything before the first prompt (banner, trust dialog) is
|
||||||
|
dropped, so a session that has not been asked anything answers nothing.
|
||||||
|
- `interrupt` sends a bare ESC; `rm` refuses itself.
|
||||||
|
|
||||||
|
## Docker cases
|
||||||
|
|
||||||
|
The agent image installs `@github/copilot`, and a docker case runs Copilot like any mode. **No
|
||||||
|
credential directory is seeded**: without a keyring the sign-in token is plain text in
|
||||||
|
`config.json`, and `CRED_STORES` (`src/docker-hosts.ts`) deliberately has no `.copilot` row.
|
||||||
|
Sign in inside the container (`copilot login`). Setting `COPILOT_GITHUB_TOKEN` for the session does
|
||||||
|
not reach it: a docker quick-start refuses `envOverrides`, and Copilot's `dockerExecEnvNames` is
|
||||||
|
empty, so a token in the server environment is not forwarded either.
|
||||||
|
|
||||||
|
## Remote SSH cases
|
||||||
|
|
||||||
|
A remote or Docker pane runs a bare `copilot` (`defaultRemoteCommandForMode`, pinned in
|
||||||
|
`test/location-overlay-commands.test.ts`), in Copilot's own Manual Approval mode. The
|
||||||
|
`copilotConfig` flags (`--yolo`, `--model`, `--resume`, `--continue`) are a local-session feature
|
||||||
|
and are not sent there, so there is nothing for the privileged-param clamp to do. A remote host
|
||||||
|
needs its own `copilot` on its own PATH and its own sign-in. This path has unit coverage for the
|
||||||
|
built command but was not exercised against a real remote host.
|
||||||
|
|
||||||
|
## What Codeman deliberately does NOT wire up
|
||||||
|
|
||||||
|
- **No login or token storage.** See Authenticate.
|
||||||
|
- **No bypass inferred.** `--yolo` is sent only by the Run button's `allowAll`, never
|
||||||
|
guessed from anything else, and is clamped for non-granted owners.
|
||||||
|
- **No `--api-key`-style flag on the command line.** Secrets travel as environment variables
|
||||||
|
through `tmux setenv`.
|
||||||
|
- **No Claude-only features.** Auto-resume on a usage limit, the Approvals Inbox, Read My Mind,
|
||||||
|
the Ralph tracker, subagent windows and the bundled agent skill are Claude-shaped.
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
|
||||||
|
- `agent read` does not model tool-call rows: they open with the same `●` bullet and read as
|
||||||
|
part of the answer.
|
||||||
|
- `agent read` sees the visible screen, so an answer longer than one screen is its tail.
|
||||||
|
- Custom endpoints: tool calls through a BYOK endpoint were not exercised.
|
||||||
|
- Remote SSH was not exercised against a real remote host.
|
||||||
+61
-6
@@ -1,9 +1,9 @@
|
|||||||
# Agent CLIs
|
# Agent CLIs
|
||||||
|
|
||||||
Codeman drives ten run modes: nine agent CLIs plus a plain shell. This page covers picking
|
Codeman drives eleven run modes: ten agent CLIs plus a plain shell. This page covers picking
|
||||||
one, setting it up, and the differences that actually change how you work.
|
one, setting it up, and the differences that actually change how you work.
|
||||||
|
|
||||||
## The ten modes
|
## The eleven modes
|
||||||
|
|
||||||
| Mode | CLI | Get it |
|
| Mode | CLI | Get it |
|
||||||
| -------------------- | ---------------------------- | ---------------------------------------------------------------------- |
|
| -------------------- | ---------------------------- | ---------------------------------------------------------------------- |
|
||||||
@@ -16,6 +16,7 @@ one, setting it up, and the differences that actually change how you work.
|
|||||||
| **Grok Build** | `grok` | [github.com/xai-org/grok-build](https://github.com/xai-org/grok-build) |
|
| **Grok Build** | `grok` | [github.com/xai-org/grok-build](https://github.com/xai-org/grok-build) |
|
||||||
| **DeepSeek Harness** | `dsh` | [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) |
|
| **DeepSeek Harness** | `dsh` | [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) |
|
||||||
| **OMP** | `omp` | [github.com/can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) |
|
| **OMP** | `omp` | [github.com/can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) |
|
||||||
|
| **GitHub Copilot** | `copilot` | [github.com/github/copilot-cli](https://github.com/github/copilot-cli) |
|
||||||
| **Terminal / Shell** | your `$SHELL` | Already installed. |
|
| **Terminal / Shell** | your `$SHELL` | Already installed. |
|
||||||
|
|
||||||
Any combination works, including all of them. The run mode is chosen per session from the
|
Any combination works, including all of them. The run mode is chosen per session from the
|
||||||
@@ -50,10 +51,10 @@ If a CLI is installed but a Run button for it never appears:
|
|||||||
precisely to avoid this; a hand-written plist or unit will not.
|
precisely to avoid this; a hand-written plist or unit will not.
|
||||||
3. Restart the server after installing a new CLI.
|
3. Restart the server after installing a new CLI.
|
||||||
|
|
||||||
`pi`, `grok`, `omp` and `dsh` are additionally identity-probed rather than trusted by name:
|
`pi`, `grok`, `omp`, `copilot` and `dsh` 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, and Debian ships an unrelated `dsh` (dancer's shell). Each has a
|
||||||
status endpoint (`/api/grok/status`, `/api/deepseek/status`, `/api/omp/status`) that reports
|
status endpoint (`/api/grok/status`, `/api/deepseek/status`, `/api/omp/status`, `/api/copilot/status`) that reports
|
||||||
the path and version that actually resolved, so a misresolution is visible rather than
|
the path and version that actually resolved, so a misresolution is visible rather than
|
||||||
presenting as "the mode just does not work".
|
presenting as "the mode just does not work".
|
||||||
|
|
||||||
@@ -69,7 +70,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, 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 and Antigravity: 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 | No |
|
| Plan usage chip | Yes | No |
|
||||||
| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no |
|
| Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no |
|
||||||
@@ -273,6 +274,59 @@ an OMP session that had started a turn showed as working for good.
|
|||||||
|
|
||||||
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
|
Guide: [`docs/omp-integration.md`](https://github.com/Ark0N/Codeman/blob/master/docs/omp-integration.md).
|
||||||
|
|
||||||
|
### GitHub Copilot CLI
|
||||||
|
|
||||||
|
GitHub's `copilot`, installed with `npm install -g @github/copilot` (CLI management can run
|
||||||
|
that for you). Codeman recognises it only when `--version` answers `GitHub Copilot CLI`, and
|
||||||
|
`GET /api/copilot/status` reports the path and version that resolved.
|
||||||
|
|
||||||
|
**Signing in.** Codeman does not log it in: run `copilot` once and use `/login`, or sign in
|
||||||
|
inside a Codeman tab. Copilot keeps the token in the OS keychain when there is one; a headless
|
||||||
|
server has none, so it offers to save it as **plain text in `~/.copilot/config.json`**, which is
|
||||||
|
what a service ends up with. To keep a token out of a file, give the service
|
||||||
|
`COPILOT_GITHUB_TOKEN` (Copilot reads it first, then `GH_TOKEN`, `GITHUB_TOKEN`). The token is
|
||||||
|
never copied into a [Docker case](Docker-Cases): sign in inside the container.
|
||||||
|
|
||||||
|
**Run button and permissions.** Run starts `copilot --yolo` (allow every tool, path and URL),
|
||||||
|
the same bypass switch the other agent CLIs get, so an unattended session never stops on an
|
||||||
|
approval. A multi-user user without the bypass grant gets a plain `copilot`, which is
|
||||||
|
Copilot's own Manual Approval mode. Model, permission mode and the rest are also changeable
|
||||||
|
inside its TUI.
|
||||||
|
|
||||||
|
**Options and resume.** `--model` is sent only by an API caller that sets `copilotConfig.model`; the Run menu has no Copilot model picker (a saved custom endpoint sets `COPILOT_MODEL` instead). A session from
|
||||||
|
Past Sessions continues with `--continue`, which resumes the most recent Copilot conversation
|
||||||
|
in that directory. A tab name you set yourself is passed as `--name`, so Copilot's own session
|
||||||
|
list reads like that tab (an automatic `w1-myapp` name is not pinned); Copilot refuses `--name`
|
||||||
|
together with `--resume`/`--continue`, so a continued session keeps the name it had.
|
||||||
|
|
||||||
|
**Your own endpoint.** With [custom model endpoints](Custom-Model-Endpoints) on, a saved
|
||||||
|
endpoint appears in the Run menu for Copilot. It is launched with Copilot's BYOK variables
|
||||||
|
(`COPILOT_PROVIDER_BASE_URL` with the `/v1` an OpenAI route needs, `COPILOT_PROVIDER_API_KEY`,
|
||||||
|
`COPILOT_MODEL`), needs no GitHub sign-in, and applies at launch with no restart.
|
||||||
|
|
||||||
|
**Working and idle.** A turn shows `◉ Working` in the footer, which is what Codeman reads;
|
||||||
|
a tool waiting for your approval replaces the composer and reads as idle. The model name is
|
||||||
|
read from the footer too. Copilot has no hooks, so the `stop` and `blocked` wait signals are
|
||||||
|
not available: wait on `idle`, `exit` or an output marker.
|
||||||
|
|
||||||
|
**Selecting and scrolling.** Copilot turns mouse tracking on itself. Codeman strips that, so a
|
||||||
|
plain drag selects text (and Auto Copy works) and clicks still reach Copilot. The wheel, and a
|
||||||
|
swipe on a phone, scroll Copilot's own transcript; Shift+wheel scrolls Codeman's local history.
|
||||||
|
|
||||||
|
**MCP servers.** [MCP server sync](Settings-Reference) includes Copilot's
|
||||||
|
`~/.copilot/mcp-config.json` (relocated by `COPILOT_HOME`), as a target and a source.
|
||||||
|
|
||||||
|
**From another agent.** The `codeman agent` verbs work from inside a Copilot tab and against
|
||||||
|
Copilot workers: `spawn` waits for the composer (not the folder-trust dialog), `send --wait`
|
||||||
|
resolves on `idle`, and `read` returns the last answer from the pane's visible screen. Tool-call
|
||||||
|
rows are not modelled and read as part of the answer, and `--until stop` is refused as for
|
||||||
|
every hook-less mode.
|
||||||
|
|
||||||
|
**Not available.** Auto-resume on a usage limit, the Approvals Inbox, Read My Mind and the
|
||||||
|
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).
|
||||||
|
|
||||||
### 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.
|
||||||
@@ -299,6 +353,7 @@ respawns. Which variables are accepted depends on the mode:
|
|||||||
| Grok | `GROK_*`, `XAI_*` |
|
| Grok | `GROK_*`, `XAI_*` |
|
||||||
| DeepSeek | `DSH_*`, `DEEPSEEK_*` |
|
| DeepSeek | `DSH_*`, `DEEPSEEK_*` |
|
||||||
| OMP | `OMP_*` |
|
| OMP | `OMP_*` |
|
||||||
|
| Copilot | `COPILOT_*` (a token goes in `COPILOT_GITHUB_TOKEN`; `GH_TOKEN` and `GITHUB_TOKEN` are not accepted as per-session overrides) |
|
||||||
|
|
||||||
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
|
||||||
@@ -314,7 +369,7 @@ into the case's `.claude/settings.local.json` so that `/model` keeps working.
|
|||||||
|
|
||||||
- **Claude Code** if you want every Codeman feature. Unattended overnight runs, usage-limit
|
- **Claude Code** if you want every Codeman feature. Unattended overnight runs, usage-limit
|
||||||
auto-resume, the Approvals Inbox, and subagent visualization all assume it.
|
auto-resume, the Approvals Inbox, and subagent visualization all assume it.
|
||||||
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP** when you prefer that agent or that
|
- **Codex, OpenCode, Gemini, Antigravity, Grok, OMP, GitHub Copilot** 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).
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ Conventions for wiki pages:
|
|||||||
- Images are referenced from the main repository over raw URLs rather than being copied into
|
- Images are referenced from the main repository over raw URLs rather than being copied into
|
||||||
the wiki.
|
the wiki.
|
||||||
- Say what the default is, especially when it is off. Most of Codeman is opt-in.
|
- Say what the default is, especially when it is off. Most of Codeman is opt-in.
|
||||||
- Label Claude-only behaviour every time it appears. Nine of the ten run modes are not
|
- Label Claude-only behaviour every time it appears. Ten of the eleven run modes are not
|
||||||
Claude.
|
Claude.
|
||||||
|
|
||||||
## Conduct
|
## Conduct
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ A session carries state the case does not:
|
|||||||
## Run mode
|
## Run mode
|
||||||
|
|
||||||
The **run mode** is which CLI the session runs: `claude`, `opencode`, `codex`, `gemini`,
|
The **run mode** is which CLI the session runs: `claude`, `opencode`, `codex`, `gemini`,
|
||||||
`antigravity`, `pi`, `grok`, `deepseek`, `omp`, or `shell`. It is chosen at start and does not change afterwards; to
|
`antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`, 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. Nine of the ten are not Claude, and a number of Codeman
|
||||||
@@ -78,7 +78,7 @@ Where a case runs is **separate from** which CLI it runs. There are three locati
|
|||||||
| **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). |
|
| **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). |
|
||||||
|
|
||||||
This matters because it is a common source of confusion: Docker is **not** an eleventh run
|
This matters because it is a common source of confusion: Docker is **not** an eleventh run
|
||||||
mode. All ten run modes work in all three locations. A case is docker-backed or
|
mode. All eleven run modes work in all three locations. A case is docker-backed or
|
||||||
ssh-backed; a session is claude or codex or shell.
|
ssh-backed; a session is claude or codex or shell.
|
||||||
|
|
||||||
**Web tabs** are the other thing that is not a session. A saved dashboard URL renders as a
|
**Web tabs** are the other thing that is not a session. A saved dashboard URL renders as a
|
||||||
@@ -180,7 +180,7 @@ See [Hooks And Integrations](Hooks-And-Integrations).
|
|||||||
| --------------- | ---------------------------------------------------------------------------- |
|
| --------------- | ---------------------------------------------------------------------------- |
|
||||||
| **Case** | Named working directory. |
|
| **Case** | Named working directory. |
|
||||||
| **Session** | One CLI in one tmux session. |
|
| **Session** | One CLI in one tmux session. |
|
||||||
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, shell. |
|
| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, 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. |
|
||||||
@@ -191,6 +191,6 @@ See [Hooks And Integrations](Hooks-And-Integrations).
|
|||||||
## Read next
|
## Read next
|
||||||
|
|
||||||
- [The Dashboard](The-Dashboard) - what the UI is showing you.
|
- [The Dashboard](The-Dashboard) - what the UI is showing you.
|
||||||
- [Agent CLIs](Agent-CLIs) - the ten run modes in detail.
|
- [Agent CLIs](Agent-CLIs) - the eleven run modes in detail.
|
||||||
- [Keeping Agents Running](Keeping-Agents-Running) - respawn, idle detection, usage limits.
|
- [Keeping Agents Running](Keeping-Agents-Running) - respawn, idle detection, usage limits.
|
||||||
- [`docs/architecture-invariants.md`](https://github.com/Ark0N/Codeman/blob/master/docs/architecture-invariants.md) - the mechanisms behind all of this, for contributors.
|
- [`docs/architecture-invariants.md`](https://github.com/Ark0N/Codeman/blob/master/docs/architecture-invariants.md) - the mechanisms behind all of this, for contributors.
|
||||||
@@ -58,11 +58,11 @@ another device starts from its own history). The default model keeps its own **D
|
|||||||
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
|
in both cases, and nothing is ever auto-chosen: the promoted row is simply the one under your
|
||||||
thumb.
|
thumb.
|
||||||
|
|
||||||
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP, picking an entry launches
|
**For opencode, Codex, Gemini, Pi, Grok, DeepSeek, OMP and GitHub Copilot, picking an entry launches
|
||||||
straight onto the endpoint** — no restart, because the endpoint is applied before the
|
straight onto the endpoint** — no restart, because the endpoint is applied before the
|
||||||
session's process ever starts. **Claude still restarts the harness's process in place** —
|
session's process ever starts. **Claude still restarts the harness's process in place** —
|
||||||
same tab, same conversation (`--resume`) — after a normal native launch, since that restart
|
same tab, same conversation (`--resume`) — after a normal native launch, since that restart
|
||||||
is far less jarring for Claude than for the other seven, whose own TUI can fully
|
is far less jarring for Claude than for the others, whose own TUI can fully
|
||||||
reinitialize on a restart. Either way, every supported harness reads its endpoint config at
|
reinitialize on a restart. Either way, every supported harness reads its endpoint config at
|
||||||
process start, never per turn, so there is no live hot-swap while a turn is running.
|
process start, never per turn, so there is no live hot-swap while a turn is running.
|
||||||
|
|
||||||
@@ -154,6 +154,7 @@ entry. "Launch anyway" is still there if you want to try regardless.
|
|||||||
|
|
||||||
| Harness | Status |
|
| Harness | Status |
|
||||||
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **GitHub Copilot CLI** | Verified at the CLI level (1.0.95) against a local OpenAI-compatible endpoint: it posts to `/v1/chat/completions` with your key and model name and needs no GitHub sign-in. Tool calls through a custom endpoint were not exercised. |
|
||||||
| **Claude Code, opencode, Pi, Grok, OMP** | Verified end-to-end against a real local server. |
|
| **Claude Code, opencode, Pi, Grok, OMP** | Verified end-to-end against a real local server. |
|
||||||
| **Codex** | Config is correct, and plain chat can work against a server that speaks the Responses API — but a real tool-call attempt comes back as inert text instead of running, so it's still not usable for real coding work. |
|
| **Codex** | Config is correct, and plain chat can work against a server that speaks the Responses API — but a real tool-call attempt comes back as inert text instead of running, so it's still not usable for real coding work. |
|
||||||
| **Gemini** | Fails with an auth error gemini-cli raises once redirected. Unresolved; don't rely on it yet. |
|
| **Gemini** | Fails with an auth error gemini-cli raises once redirected. Unresolved; don't rely on it yet. |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Run a case inside its own container instead of directly on your host: for isolat
|
|||||||
reproducible toolchain, and for the ability to pick the whole environment up and move it to
|
reproducible toolchain, and for the ability to pick the whole environment up and move it to
|
||||||
another machine.
|
another machine.
|
||||||
|
|
||||||
A docker case is a **location overlay**, not a run mode. All ten run modes work inside a
|
A docker case is a **location overlay**, not a run mode. All eleven run modes work inside a
|
||||||
container. See [Core Concepts](Core-Concepts).
|
container. See [Core Concepts](Core-Concepts).
|
||||||
|
|
||||||
## One-time setup: the base image
|
## One-time setup: the base image
|
||||||
@@ -118,6 +118,12 @@ invisible from the host (`pi -c` and `grok -c` inside a docker case see only tha
|
|||||||
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
the container (`copilot login`). A `COPILOT_GITHUB_TOKEN` cannot be handed in instead: a docker
|
||||||
|
case refuses per-session environment overrides, and Copilot forwards no server variable into
|
||||||
|
the container.
|
||||||
|
|
||||||
**Git hosts.** The agent image can also include the GitHub CLI (`gh`) and the Azure CLI (`az`,
|
**Git hosts.** The agent image can also include the GitHub CLI (`gh`) and the Azure CLI (`az`,
|
||||||
with the `azure-devops` extension), off by default, and its git then uses them as credential
|
with the `azure-devops` extension), off by default, and its git then uses them as credential
|
||||||
helpers for github.com and Azure DevOps. When the matching switch is on, their sign-ins are
|
helpers for github.com and Azure DevOps. When the matching switch is on, their sign-ins are
|
||||||
|
|||||||
+2
-2
@@ -23,8 +23,8 @@ stores, or refreshes your credentials.
|
|||||||
|
|
||||||
### Which agent CLIs does it support?
|
### Which agent CLIs does it support?
|
||||||
|
|
||||||
Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness and
|
Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness,
|
||||||
OMP, plus a plain shell, chosen per session. Claude is the reference mode and a few features
|
OMP and GitHub Copilot CLI, 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, or OMP inside persistent tmux sessions, streams the real terminal to the browser, and keeps
|
Grok, DeepSeek Harness, OMP, or GitHub Copilot CLI inside persistent tmux sessions, streams the real terminal to the browser, and keeps
|
||||||
working while you are away from the keyboard: it re-prompts idle agents, resumes when a
|
working while you are away from the keyboard: it re-prompts idle agents, resumes when a
|
||||||
subscription limit resets, runs jobs on a schedule, and shows every background subagent
|
subscription limit resets, runs jobs on a schedule, and shows every background subagent
|
||||||
live.
|
live.
|
||||||
@@ -33,7 +33,7 @@ codeman web # then open http://localhost:3000
|
|||||||
|
|
||||||
**Already running it**
|
**Already running it**
|
||||||
|
|
||||||
- [Agent CLIs](Agent-CLIs) - the ten run modes, their setup, and which features are Claude-only.
|
- [Agent CLIs](Agent-CLIs) - the eleven run modes, their setup, and which features are Claude-only.
|
||||||
- [Mobile Guide](Mobile-Guide) - phone and tablet use, QR login, the touch keyboard bar.
|
- [Mobile Guide](Mobile-Guide) - phone and tablet use, QR login, the touch keyboard bar.
|
||||||
- [Remote Access](Remote-Access) - Tailscale, Cloudflare tunnel, LAN plus password, QR login.
|
- [Remote Access](Remote-Access) - Tailscale, Cloudflare tunnel, LAN plus password, QR login.
|
||||||
- [Keeping Agents Running](Keeping-Agents-Running) - idle detection, respawn cycling, auto-resume on usage limits.
|
- [Keeping Agents Running](Keeping-Agents-Running) - idle detection, respawn cycling, auto-resume on usage limits.
|
||||||
@@ -127,7 +127,7 @@ codeman web # then open http://localhost:3000
|
|||||||
| OS | macOS or Linux. Windows works through WSL2. |
|
| OS | macOS or Linux. Windows works through WSL2. |
|
||||||
| Node.js | 22 or newer. |
|
| Node.js | 22 or newer. |
|
||||||
| tmux | Required. Sessions live in tmux, which is what makes them survive restarts. |
|
| tmux | Required. Sessions live in tmux, which is what makes them survive restarts. |
|
||||||
| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP. Plain shell sessions need none. |
|
| An agent CLI | At least one of Claude Code, OpenCode, Codex, Gemini, Antigravity, Pi, Grok Build, DeepSeek Harness, OMP, GitHub Copilot CLI. 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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Point a case at another machine and the agent runs **there**, with the same dash
|
|||||||
mobile UI, and autonomy features. Your laptop becomes a window onto a session living on the
|
mobile UI, and autonomy features. Your laptop becomes a window onto a session living on the
|
||||||
remote host.
|
remote host.
|
||||||
|
|
||||||
Like Docker, this is a **location overlay** on a case, not a run mode. All ten run modes
|
Like Docker, this is a **location overlay** on a case, not a run mode. All eleven run modes
|
||||||
work remotely. See [Core Concepts](Core-Concepts).
|
work remotely. See [Core Concepts](Core-Concepts).
|
||||||
|
|
||||||
## Why bother
|
## Why bother
|
||||||
|
|||||||
+13
-13
@@ -168,19 +168,19 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
|
|||||||
# script will ever execute, and they arrive embedded in this file — same TLS fetch, same
|
# script will ever execute, and they arrive embedded in this file — same TLS fetch, same
|
||||||
# commit as the script itself. Nothing fetched at install time is ever executed; there is
|
# commit as the script itself. Nothing fetched at install time is ever executed; there is
|
||||||
# no network refresh of these arrays. See cli_catalog_select_platform below.
|
# no network refresh of these arrays. See cli_catalog_select_platform below.
|
||||||
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp')
|
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp' 'copilot')
|
||||||
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP')
|
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP' 'GitHub Copilot')
|
||||||
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1)
|
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1 1)
|
||||||
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0)
|
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 0 0)
|
||||||
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh')
|
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh' 'https://github.com/github/copilot-cli')
|
||||||
CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'curl -fsSL https://omp.sh/install | sh')
|
CLI_CMD_LINUX=('curl -fsSL https://claude.ai/install.sh | bash' '' 'curl -fsSL https://opencode.ai/install | bash' 'npm install -g @openai/codex' 'npm install -g @google/gemini-cli' 'curl -fsSL https://antigravity.google/cli/install.sh | bash' 'npm install -g --ignore-scripts @earendil-works/pi-coding-agent' 'curl -fsSL https://x.ai/cli/install.sh | bash' '' 'curl -fsSL https://omp.sh/install | sh' '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')
|
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_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp')
|
CLI_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp' 'copilot')
|
||||||
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8)
|
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8 9)
|
||||||
CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1)
|
CLI_BIN_LEN=(1 0 1 1 1 1 1 1 1 1 1)
|
||||||
CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/go/bin/opencode" "$HOME/.bun/bin/opencode" "$HOME/.npm-global/bin/opencode" "$HOME/bin/opencode" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp")
|
CLI_ALL_PATHS=("$HOME/.local/bin/claude" "$HOME/.claude/local/claude" "/usr/local/bin/claude" "$HOME/.npm-global/bin/claude" "$HOME/bin/claude" "$HOME/.opencode/bin/opencode" "$HOME/.local/bin/opencode" "/usr/local/bin/opencode" "$HOME/go/bin/opencode" "$HOME/.bun/bin/opencode" "$HOME/.npm-global/bin/opencode" "$HOME/bin/opencode" "$HOME/.codex/bin/codex" "$HOME/.local/bin/codex" "/usr/local/bin/codex" "$HOME/.bun/bin/codex" "$HOME/.npm-global/bin/codex" "$HOME/bin/codex" "$HOME/.gemini/bin/gemini" "$HOME/.local/bin/gemini" "/usr/local/bin/gemini" "$HOME/.bun/bin/gemini" "$HOME/.npm-global/bin/gemini" "$HOME/bin/gemini" "$HOME/.local/bin/agy" "$HOME/.antigravity/bin/agy" "/usr/local/bin/agy" "$HOME/bin/agy" "$HOME/.local/bin/pi" "/usr/local/bin/pi" "$HOME/.bun/bin/pi" "$HOME/.npm-global/bin/pi" "$HOME/bin/pi" "$HOME/.grok/bin/grok" "$HOME/.local/bin/grok" "/usr/local/bin/grok" "$HOME/bin/grok" "$HOME/.local/bin/dsh" "/usr/local/bin/dsh" "$HOME/.npm-global/bin/dsh" "$HOME/bin/dsh" "$HOME/.local/bin/omp" "$HOME/.omp/bin/omp" "/usr/local/bin/omp" "$HOME/.bun/bin/omp" "$HOME/.npm-global/bin/omp" "$HOME/bin/omp" "$HOME/.local/bin/copilot" "/usr/local/bin/copilot" "$HOME/.bun/bin/copilot" "$HOME/.npm-global/bin/copilot" "$HOME/bin/copilot")
|
||||||
CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41)
|
CLI_PATH_OFF=(0 5 5 12 18 24 28 33 37 41 47)
|
||||||
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6)
|
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 5)
|
||||||
# <<< END GENERATED CLI CATALOGUE
|
# <<< END GENERATED CLI CATALOGUE
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -553,7 +553,7 @@ Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor co
|
|||||||
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
|
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
|
||||||
instead of guessed-from-silence ones — and it writes a structured transcript, which is
|
instead of guessed-from-silence ones — and it writes a structured transcript, which is
|
||||||
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
|
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
|
||||||
`grok` and `omp` have neither, and `codex` has a transcript and a screen-read `blocked` but
|
`grok`, `omp` and `copilot` 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:
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ minutes, never retry the credential.
|
|||||||
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
|
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
|
||||||
returns is too early (verified live: empty on the first call, full prose seconds later).
|
returns is too early (verified live: empty on the first call, full prose seconds later).
|
||||||
It is also `""` before the worker's first completed turn, and permanently `""` for
|
It is also `""` before the worker's first completed turn, and permanently `""` for
|
||||||
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `omp`, which write no transcript at
|
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp` and `copilot`, which write no transcript at
|
||||||
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
|
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
|
||||||
for the same reason claude does (the harness finalizes the assistant message just after
|
for the same reason claude does (the harness finalizes the assistant message just after
|
||||||
it reports `idle`), so poll it the same way.
|
it reports `idle`), so poll it the same way.
|
||||||
@@ -341,7 +341,7 @@ ESC=$(printf '\033')
|
|||||||
|
|
||||||
`POST /api/v1/quick-start` body (all optional):
|
`POST /api/v1/quick-start` body (all optional):
|
||||||
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
||||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
|
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot`; response is
|
||||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||||
|
|
||||||
@@ -356,9 +356,9 @@ a soft default the worker can change with `/advisor`. Remote and docker cases re
|
|||||||
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
||||||
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
||||||
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
|
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
|
||||||
`GET /api/v1/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed).
|
`GET /api/v1/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/status` each return `.data.{available, path}` (no session needed).
|
||||||
Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name,
|
Pi's, grok's, OMP's and Copilot's also carry `.data.version`, because `pi` is a short generic name,
|
||||||
`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated
|
`grok` is a name with npm squatters, and `omp` is a similarly short name, and `copilot` is a generic one, so an unrelated
|
||||||
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
|
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
|
||||||
not version-shaped, so `available:false` there can mean "a different program of the same
|
not version-shaped, so `available:false` there can mean "a different program of the same
|
||||||
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
|
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
|
||||||
@@ -486,7 +486,7 @@ Quirks that will bite you:
|
|||||||
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
||||||
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
||||||
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
|
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
|
||||||
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`. ⚠️ **`shell` is NOT one of those**
|
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`. ⚠️ **`shell` is NOT one of those**
|
||||||
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
|
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
|
||||||
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
|
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:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
|
|||||||
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
||||||
| liveness / death check | HTTP `wait?until=exit` |
|
| liveness / death check | HTTP `wait?until=exit` |
|
||||||
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
||||||
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`) | HTTP only (no other CLI has messaging) |
|
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`) | HTTP only (no other CLI has messaging) |
|
||||||
| delete | HTTP, via SKILL.md's `delete_session` guard |
|
| delete | HTTP, via SKILL.md's `delete_session` guard |
|
||||||
|
|
||||||
## Availability: probe, never assume
|
## Availability: probe, never assume
|
||||||
@@ -352,7 +352,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
|
|||||||
|
|
||||||
### Mixed fleets: the pairing matrix
|
### Mixed fleets: the pairing matrix
|
||||||
|
|
||||||
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`) cannot be peers
|
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`) cannot be peers
|
||||||
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
|
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
|
||||||
messaging in their briefs. The claude half of the fleet can use messaging among itself,
|
messaging in their briefs. The claude half of the fleet can use messaging among itself,
|
||||||
subject to the namespace rule: **messaging works between two sessions that share one
|
subject to the namespace rule: **messaging works between two sessions that share one
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
|
|||||||
done
|
done
|
||||||
printf '%s\n' "$TXT"
|
printf '%s\n' "$TXT"
|
||||||
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
||||||
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use
|
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot, which have no transcript, use
|
||||||
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
||||||
|
|
||||||
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
|
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ recovered by submitting it with `{"input":"\r"}`.
|
|||||||
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
|
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
|
||||||
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
||||||
real end-of-turn signal rather than a guess. On
|
real end-of-turn signal rather than a guess. On
|
||||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a
|
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`, requesting them explicitly is a
|
||||||
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
|
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
|
||||||
question or trust dialog is up; a wait for it answers at once if one already is), and
|
question or trust dialog is up; a wait for it answers at once if one already is), and
|
||||||
lifecycle transitions there are coarse (a short shell command may emit **no**
|
lifecycle transitions there are coarse (a short shell command may emit **no**
|
||||||
@@ -430,7 +430,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
|
|||||||
single read taken the instant send-and-wait returns comes back `""` even though the
|
single read taken the instant send-and-wait returns comes back `""` even though the
|
||||||
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
||||||
is also `""` before the worker's first completed turn, and always `""` for modes with
|
is also `""` before the worker's first completed turn, and always `""` for modes with
|
||||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`; the first four
|
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`; the first four
|
||||||
verified live, pi from the same source path), which is
|
verified live, pi from the same source path), which is
|
||||||
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
|
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
|
||||||
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
|
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
|
||||||
@@ -516,7 +516,7 @@ turn), and both better than diffing terminal samples:
|
|||||||
```
|
```
|
||||||
|
|
||||||
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
|
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
|
||||||
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`** (those parsers are skipped wholesale) and
|
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`** (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
|
||||||
|
|||||||
@@ -553,7 +553,7 @@ Harness TUI reports `idle`/`working`/`blocked` to Codeman over the supervisor co
|
|||||||
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
|
implements, so dsh is the one external CLI with definitive `stop`/`blocked` signals
|
||||||
instead of guessed-from-silence ones — and it writes a structured transcript, which is
|
instead of guessed-from-silence ones — and it writes a structured transcript, which is
|
||||||
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
|
what `last-response` reads for it. `shell`, `opencode`, `gemini`, `antigravity`, `pi`,
|
||||||
`grok` and `omp` have neither, and `codex` has a transcript and a screen-read `blocked` but
|
`grok`, `omp` and `copilot` 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:
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ minutes, never retry the credential.
|
|||||||
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
|
flushed slightly *after* the `stop` hook fires, so a read taken the instant the wait
|
||||||
returns is too early (verified live: empty on the first call, full prose seconds later).
|
returns is too early (verified live: empty on the first call, full prose seconds later).
|
||||||
It is also `""` before the worker's first completed turn, and permanently `""` for
|
It is also `""` before the worker's first completed turn, and permanently `""` for
|
||||||
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok` and `omp`, which write no transcript at
|
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp` and `copilot`, which write no transcript at
|
||||||
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
|
all. `deepseek` is NOT one of those — it is read from `$DSH_HOME/sessions/**` and lags
|
||||||
for the same reason claude does (the harness finalizes the assistant message just after
|
for the same reason claude does (the harness finalizes the assistant message just after
|
||||||
it reports `idle`), so poll it the same way.
|
it reports `idle`), so poll it the same way.
|
||||||
@@ -341,7 +341,7 @@ ESC=$(printf '\033')
|
|||||||
|
|
||||||
`POST /api/v1/quick-start` body (all optional):
|
`POST /api/v1/quick-start` body (all optional):
|
||||||
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
`{"caseName":"worker-1","mode":"claude","sessionName":"auth-worker","effort":"high"}`
|
||||||
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp`; response is
|
, `mode` ∈ `claude|shell|opencode|codex|gemini|antigravity|pi|grok|deepseek|omp|copilot`; response is
|
||||||
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
`.data.{sessionId, caseName, casePath}`. Creates the case directory (a real directory
|
||||||
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
on the user's disk) if missing, do not retry it in a loop, and remember the name.
|
||||||
|
|
||||||
@@ -356,9 +356,9 @@ a soft default the worker can change with `/advisor`. Remote and docker cases re
|
|||||||
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
`OPERATION_FAILED`; it never falls back to claude. Probe first whenever you did not pick
|
||||||
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
the mode yourself: `GET /api/v1/claude/status`, `GET /api/v1/opencode/status`,
|
||||||
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
|
`GET /api/v1/codex/status`, `GET /api/v1/gemini/status`, `GET /api/v1/antigravity/status`, `GET /api/v1/grok/status`, `GET /api/v1/deepseek/status`,
|
||||||
`GET /api/v1/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed).
|
`GET /api/v1/pi/status`, `GET /api/v1/omp/status` and `GET /api/v1/copilot/status` each return `.data.{available, path}` (no session needed).
|
||||||
Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name,
|
Pi's, grok's, OMP's and Copilot's also carry `.data.version`, because `pi` is a short generic name,
|
||||||
`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated
|
`grok` is a name with npm squatters, and `omp` is a similarly short name, and `copilot` is a generic one, so an unrelated
|
||||||
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
|
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
|
||||||
not version-shaped, so `available:false` there can mean "a different program of the same
|
not version-shaped, so `available:false` there can mean "a different program of the same
|
||||||
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
|
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
|
||||||
@@ -486,7 +486,7 @@ Quirks that will bite you:
|
|||||||
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
||||||
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
||||||
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
|
returns early for every external CLI mode (`session.ts:~2225`), so it is permanently
|
||||||
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`. ⚠️ **`shell` is NOT one of those**
|
`[]` on `opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`. ⚠️ **`shell` is NOT one of those**
|
||||||
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
|
(`isExternalCliMode`, `session.ts:176-187`, lists only those seven), so the parser does
|
||||||
run on a shell worker, and `TEXT_COMMAND_PATTERN` (`bash-tool-parser.ts:89`) matches
|
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:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
|
|||||||
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
||||||
| liveness / death check | HTTP `wait?until=exit` |
|
| liveness / death check | HTTP `wait?until=exit` |
|
||||||
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
||||||
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`) | HTTP only (no other CLI has messaging) |
|
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`) | HTTP only (no other CLI has messaging) |
|
||||||
| delete | HTTP, via SKILL.md's `delete_session` guard |
|
| delete | HTTP, via SKILL.md's `delete_session` guard |
|
||||||
|
|
||||||
## Availability: probe, never assume
|
## Availability: probe, never assume
|
||||||
@@ -352,7 +352,7 @@ Without a break-glass, a pair with a bad brief is a token bonfire with no off sw
|
|||||||
|
|
||||||
### Mixed fleets: the pairing matrix
|
### Mixed fleets: the pairing matrix
|
||||||
|
|
||||||
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`) cannot be peers
|
Non-claude workers (`shell`, `opencode`, `codex`, `gemini`, `antigravity`, `pi`, `grok`, `deepseek`, `omp`, `copilot`) cannot be peers
|
||||||
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
|
at all; no other CLI has this feature. Their tasks route over HTTP, and you never mention
|
||||||
messaging in their briefs. The claude half of the fleet can use messaging among itself,
|
messaging in their briefs. The claude half of the fleet can use messaging among itself,
|
||||||
subject to the namespace rule: **messaging works between two sessions that share one
|
subject to the namespace rule: **messaging works between two sessions that share one
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
|
|||||||
done
|
done
|
||||||
printf '%s\n' "$TXT"
|
printf '%s\n' "$TXT"
|
||||||
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
||||||
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp, which have no transcript, use
|
# always "" for shell/opencode/gemini/antigravity/pi/grok/omp/copilot, which have no transcript, use
|
||||||
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
# the terminal tail there, and here only to diagnose an unsubmitted prompt.)
|
||||||
|
|
||||||
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
|
# 6. clean up: exact id, own list only, through the fail-closed preamble helper
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ recovered by submitting it with `{"input":"\r"}`.
|
|||||||
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
|
only when the workspace actually has them, see [§5.1](#51-where-to-spawn)) **and for
|
||||||
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
||||||
real end-of-turn signal rather than a guess. On
|
real end-of-turn signal rather than a guess. On
|
||||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a
|
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`/`copilot`, requesting them explicitly is a
|
||||||
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
|
400 (except `blocked` on `codex`, which Codeman reads off its screen when an approval,
|
||||||
question or trust dialog is up; a wait for it answers at once if one already is), and
|
question or trust dialog is up; a wait for it answers at once if one already is), and
|
||||||
lifecycle transitions there are coarse (a short shell command may emit **no**
|
lifecycle transitions there are coarse (a short shell command may emit **no**
|
||||||
@@ -430,7 +430,7 @@ from the transcript file, which is flushed slightly *after* the `stop` hook fire
|
|||||||
single read taken the instant send-and-wait returns comes back `""` even though the
|
single read taken the instant send-and-wait returns comes back `""` even though the
|
||||||
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
||||||
is also `""` before the worker's first completed turn, and always `""` for modes with
|
is also `""` before the worker's first completed turn, and always `""` for modes with
|
||||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`; the first four
|
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `omp`, `copilot`; the first four
|
||||||
verified live, pi from the same source path), which is
|
verified live, pi from the same source path), which is
|
||||||
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
|
why the loop above is bounded rather than open-ended. A dsh worker lags too, for its own
|
||||||
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
|
reason: the harness finalizes the assistant message just after it reports `idle`. Fall back to the terminal buffer
|
||||||
@@ -516,7 +516,7 @@ turn), and both better than diffing terminal samples:
|
|||||||
```
|
```
|
||||||
|
|
||||||
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
|
⚠️ `active-tools` is parsed out of Claude's own output format, so it is **empty for
|
||||||
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`** (those parsers are skipped wholesale) and
|
`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`deepseek`/`omp`/`copilot`** (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
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
export type TokenPattern =
|
export type TokenPattern =
|
||||||
| 'model'
|
| 'model'
|
||||||
| 'model-claude'
|
| 'model-claude'
|
||||||
|
| 'model-plain'
|
||||||
| 'model-pi'
|
| 'model-pi'
|
||||||
| 'id'
|
| 'id'
|
||||||
| 'id-dotted'
|
| 'id-dotted'
|
||||||
@@ -43,6 +44,9 @@ const PATTERNS: Record<TokenPattern, RegExp> = {
|
|||||||
model: /^[a-zA-Z0-9._\-/]+$/,
|
model: /^[a-zA-Z0-9._\-/]+$/,
|
||||||
// buildSpawnCommand's claude branch — `[` and `]` for bracketed model aliases
|
// buildSpawnCommand's claude branch — `[` and `]` for bracketed model aliases
|
||||||
'model-claude': /^[a-zA-Z0-9._\-[\]]+$/,
|
'model-claude': /^[a-zA-Z0-9._\-[\]]+$/,
|
||||||
|
// copilot --model. Same characters as `model`, but a leading alphanumeric: the value follows a
|
||||||
|
// flag, so `--yolo` must not pass as a model id and become its own flag (the allowAll clamp bypass).
|
||||||
|
'model-plain': /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/,
|
||||||
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
|
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
|
||||||
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
|
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
|
||||||
// opencode --session, codex resume
|
// opencode --session, codex resume
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ const capabilitiesSchema = z
|
|||||||
altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']),
|
altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']),
|
||||||
echo: echoSchema,
|
echo: echoSchema,
|
||||||
wheelForward: z
|
wheelForward: z
|
||||||
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
|
.object({ mode: z.enum(['never', 'version-gated', 'always']), minVersion: z.string().max(20).optional() })
|
||||||
.strict(),
|
.strict(),
|
||||||
// Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for
|
// Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for
|
||||||
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
|
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
|
||||||
@@ -464,8 +464,10 @@ const capabilitiesSchema = z
|
|||||||
)
|
)
|
||||||
.max(8),
|
.max(8),
|
||||||
// Exact env var NAMES, not prefixes: this list is a targeted deny, and a prefix here
|
// Exact env var NAMES, not prefixes: this list is a targeted deny, and a prefix here
|
||||||
// would let one entry silently strip a whole namespace off every owner's overrides.
|
// would let one entry silently strip a whole namespace off every owner's overrides. The
|
||||||
privilegedEnvKeys: z.array(envName).max(8),
|
// bound is generous because a longer list only takes more away from non-granted owners:
|
||||||
|
// copilot's COPILOT_ prefix covers dozens of variables that run code or redirect its token.
|
||||||
|
privilegedEnvKeys: z.array(envName).max(48),
|
||||||
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
|
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
|
||||||
maxFrameBytes: z.number().int().positive().optional(),
|
maxFrameBytes: z.number().int().positive().optional(),
|
||||||
newline: z.enum(['line-feed', 'esc-enter']).optional(),
|
newline: z.enum(['line-feed', 'esc-enter']).optional(),
|
||||||
|
|||||||
@@ -1676,5 +1676,236 @@ const OMP: CliEntry = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// GitHub Copilot CLI (`copilot`, github/copilot-cli, npm @github/copilot). Measured on a live
|
||||||
|
// 1.0.94 pane inside tmux (capture-pane through real turns, a shell tool call and /model,
|
||||||
|
// 2026-10-09). It is an ALTERNATE-SCREEN TUI with mouse tracking on (tmux `alternate_on=1`,
|
||||||
|
// `mouse_any_flag=1`, `history_size=0`), so like opencode its mouse DECSETs are stripped
|
||||||
|
// (`altScreen: 'strip-mux-and-mouse'` below): a drag selects locally and the wheel is forwarded.
|
||||||
|
// Keystrokes sent with `tmux send-keys` reach its composer and a shell tool call completes
|
||||||
|
// inside tmux, which github/copilot-cli#4180 (a driven PTY ignoring input) and #4223 (a shell
|
||||||
|
// command never reported done under tmux) say they do not on 1.0.70 to 1.0.74: neither
|
||||||
|
// reproduces on 1.0.94, and the register of `version` below is what would show a regression.
|
||||||
|
const COPILOT: CliEntry = {
|
||||||
|
id: 'copilot' as CliEntry['id'],
|
||||||
|
label: 'GitHub Copilot',
|
||||||
|
shortBadge: 'CP',
|
||||||
|
accent: '#a371f7',
|
||||||
|
enabled: true,
|
||||||
|
stock: true,
|
||||||
|
order: 100,
|
||||||
|
kind: 'agent',
|
||||||
|
discovery: {
|
||||||
|
binaries: ['copilot'],
|
||||||
|
searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin],
|
||||||
|
// `copilot --version` prints `GitHub Copilot CLI 1.0.94.`; anchoring on the product name
|
||||||
|
// keeps an unrelated `copilot` binary on PATH from passing the probe.
|
||||||
|
version: { arg: '--version', regex: 'GitHub Copilot CLI (\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true },
|
||||||
|
install: {
|
||||||
|
command: { linux: 'npm install -g @github/copilot', darwin: 'npm install -g @github/copilot' },
|
||||||
|
npmPackage: '@github/copilot',
|
||||||
|
docsUrl: 'https://github.com/github/copilot-cli',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// `--yolo` (allow every tool, path and URL) is opt-in through `allowAll`, which the Run button
|
||||||
|
// sends like the other agent CLIs' bypass switches; an absent config spawns a bare `copilot`
|
||||||
|
// in its own Manual Approval mode. `--model` and `--resume=<id>` are Copilot's own flags
|
||||||
|
// (`copilot --help`, 1.0.94). `--resume` takes an id, id prefix or session NAME and its value
|
||||||
|
// is optional, so the token must be a single plain word that cannot start with `-`: a value
|
||||||
|
// like `--yolo` would otherwise be parsed as its own flag and get around the `allowAll` clamp.
|
||||||
|
launch: {
|
||||||
|
params: {
|
||||||
|
allowAll: { type: 'bool' },
|
||||||
|
// `model-plain`, not `model`: the leading alphanumeric keeps a flag-shaped value out.
|
||||||
|
model: { type: 'token', pattern: 'model-plain' },
|
||||||
|
// `path-segment`, not `id-dotted`: the leading alphanumeric keeps a flag-shaped value out.
|
||||||
|
resumeId: { type: 'token', pattern: 'path-segment' },
|
||||||
|
continueSession: { type: 'bool' },
|
||||||
|
// The tab's name, so the CLI's own session list reads the same as the tab.
|
||||||
|
sessionName: { type: 'engine', source: 'sessionName' },
|
||||||
|
},
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: 'default',
|
||||||
|
args: [
|
||||||
|
{ lit: 'copilot' },
|
||||||
|
{ flag: '--yolo', when: { param: 'allowAll', is: true } },
|
||||||
|
// `--name` is refused next to `--resume` and `--continue` (copilot 1.0.95: "cannot be used
|
||||||
|
// with"), and a resumed session keeps the name it was created with.
|
||||||
|
{
|
||||||
|
flag: '--name',
|
||||||
|
valueFrom: 'sessionName',
|
||||||
|
quote: 'double',
|
||||||
|
when: {
|
||||||
|
allOf: [
|
||||||
|
{ param: 'sessionName', state: 'set' },
|
||||||
|
{ param: 'resumeId', state: 'unset' },
|
||||||
|
{ not: { param: 'continueSession', is: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
|
||||||
|
{ flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
|
||||||
|
{
|
||||||
|
lit: '--continue',
|
||||||
|
when: {
|
||||||
|
allOf: [
|
||||||
|
{ param: 'continueSession', is: true },
|
||||||
|
{ param: 'resumeId', state: 'unset' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
legacyConfigAliases: { resumeId: 'resumeSessionId' },
|
||||||
|
legacyConfigField: 'copilotConfig',
|
||||||
|
resumeAppend: { style: 'flag', flag: '--resume' },
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
|
||||||
|
unset: ['NO_COLOR'],
|
||||||
|
// Sign-in is the CLI's own (`copilot login`, kept under ~/.copilot). A headless host sets
|
||||||
|
// COPILOT_GITHUB_TOKEN, which the COPILOT_ prefix already admits. GH_TOKEN / GITHUB_TOKEN are
|
||||||
|
// deliberately NOT allowlisted: the env allowlist is one global list with no mode context, so
|
||||||
|
// admitting them would make them settable on every session (see allowedEnvPrefixes()).
|
||||||
|
tmuxSetenvKeys: [],
|
||||||
|
dockerExecEnvNames: [],
|
||||||
|
allowedPrefixes: ['COPILOT_'],
|
||||||
|
allowedKeys: [],
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
...agentDefaults(),
|
||||||
|
// A full-screen TUI that turns mouse tracking on itself (measured on 1.0.94: alt screen,
|
||||||
|
// mouse reporting on). Kept, xterm reports a plain drag to the TUI instead of selecting, so
|
||||||
|
// Auto Copy and "mark text, copy on select" silently do nothing; stripped, a drag is a local
|
||||||
|
// selection and clicks still reach the CLI through the browser's hand-encoded tap
|
||||||
|
// (`cliMouseTracking`). Same shape and reason as opencode. The wheel keeps working because
|
||||||
|
// `_shouldForwardWheelToApp` forwards it as SGR reports while the CLI is tracking.
|
||||||
|
altScreen: 'strip-mux-and-mouse',
|
||||||
|
// Forwards the wheel as SGR reports while the CLI is tracking the mouse, at any version
|
||||||
|
// (`_shouldForwardWheelToApp` in terminal-ui.js has no version check for copilot).
|
||||||
|
wheelForward: { mode: 'always' },
|
||||||
|
// Pages its own transcript on PageUp/PageDown (1.0.95, measured in tmux: three PageUp moved
|
||||||
|
// lines 109-140 to 010-042 and PageDown returned). This is the fallback when the
|
||||||
|
// wheel-forward flag is stale after a server restart, so the wheel and a swipe never go dead.
|
||||||
|
transcriptPageKeys: true,
|
||||||
|
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||||
|
// What `codeman agent spawn` waits for. The composer's footer hint, NOT the `❯` glyph: the
|
||||||
|
// folder-trust dialog draws `❯ 1. Yes` for its selected row, so the glyph would call a worker
|
||||||
|
// ready while it is still asking for trust (measured on 1.0.95: the dialog's footer reads
|
||||||
|
// `↑/↓ to navigate`, the composer's `/ commands · ? help`).
|
||||||
|
composerReadyMark: '/ commands · ? help',
|
||||||
|
// The composer row is a `❯` between two rules, and the submitted prompt is echoed as
|
||||||
|
// ` ❯ <text> <time>` above it. While a turn runs the footer's left end reads
|
||||||
|
// `◉ Working esc edit prompt` (the dot alternates `◉` and `◎`); at rest it reads
|
||||||
|
// `← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab`.
|
||||||
|
// A tool call that is waiting for approval replaces the composer and the footer's
|
||||||
|
// `Working` goes with it, so it reads as idle (waiting on the user), like gemini.
|
||||||
|
workDetect: {
|
||||||
|
promptGlyph: '❯',
|
||||||
|
workingLine: String.raw`[◉◎] Working\b`,
|
||||||
|
},
|
||||||
|
// The model is the right-hand field of the last row, both at rest and mid-turn
|
||||||
|
// (`... tab next tab Claude Sonnet 5.5`, `◎ Working esc edit prompt GPT-6 Luna`).
|
||||||
|
// Anchored on the footer's own words so a model-looking string in the transcript cannot match.
|
||||||
|
modelDetect: {
|
||||||
|
screenLine: String.raw`(?:tab next tab|\? help|esc edit prompt|github-mcp-server) {3,}([A-Za-z0-9][\w.:/@ -]{1,60}?) *(?:\n|$)`,
|
||||||
|
screenLines: 2,
|
||||||
|
},
|
||||||
|
// `copilot mcp add` writes `~/.copilot/mcp-config.json`; COPILOT_HOME replaces ~/.copilot
|
||||||
|
// (checked: `COPILOT_HOME=<dir> copilot mcp list` reads <dir>).
|
||||||
|
mcpConfig: {
|
||||||
|
path: '.copilot/mcp-config.json',
|
||||||
|
format: 'copilot-json',
|
||||||
|
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
|
||||||
|
},
|
||||||
|
// Every one of these matches the COPILOT_ allowedPrefix, so a non-granted multi-user owner
|
||||||
|
// must not be able to set it through envOverrides. Read from the 1.0.98 bundle (copilot reads
|
||||||
|
// far more COPILOT_* variables than `copilot help environment` lists), by what each hands back:
|
||||||
|
// - approval: COPILOT_ALLOW_ALL turns every tool on; COPILOT_PLAN_THEN_AUTOPILOT switches a
|
||||||
|
// plan into autopilot; COPILOT_ASSISTED_APPROVAL; COPILOT_HOME restates the permission config.
|
||||||
|
// - code at boot or without an approval row: COPILOT_CLI_DIST_DIR runs `<dir>/index.js` in place
|
||||||
|
// of the CLI, the package caches pick which CLI build runs, COPILOT_EDITOR,
|
||||||
|
// COPILOT_PROVIDER_API_KEY_COMMAND and COPILOT_AHP_HOST_BIN are run as commands, skills and
|
||||||
|
// provider configs are loaded from the named paths, and the hook switches widen what hooks run.
|
||||||
|
// - credential redirects: the Copilot and GitHub API bases receive the CLI's sign-in token, which
|
||||||
|
// in multi-user mode is the operator's (every user shares the OS account and ~/.copilot), and
|
||||||
|
// the BYOK provider family aims a Codeman custom endpoint elsewhere.
|
||||||
|
// The prefix stays because COPILOT_GITHUB_TOKEN and the harmless knobs live in it; a variable a
|
||||||
|
// later release adds is NOT covered until it is listed here.
|
||||||
|
privilegedEnvKeys: [
|
||||||
|
'COPILOT_HOME',
|
||||||
|
'COPILOT_ALLOW_ALL',
|
||||||
|
'COPILOT_PLAN_THEN_AUTOPILOT',
|
||||||
|
'COPILOT_ASSISTED_APPROVAL',
|
||||||
|
'COPILOT_CLI_DIST_DIR',
|
||||||
|
'COPILOT_CLI_RESOLVED_DIST_DIR',
|
||||||
|
'COPILOT_PKG_CACHE_HOME',
|
||||||
|
'COPILOT_CACHE_HOME',
|
||||||
|
'COPILOT_EDITOR',
|
||||||
|
'COPILOT_AHP_HOST_BIN',
|
||||||
|
'COPILOT_DEBUG_VOICE_ENGINE_SCRIPT',
|
||||||
|
'COPILOT_SKILLS_DIRS',
|
||||||
|
'COPILOT_PROVIDERS_CONFIG',
|
||||||
|
'COPILOT_HOOK_ALLOW_HTTP_AUTH_HOOKS',
|
||||||
|
'COPILOT_HOOK_ALLOW_LOCALHOST',
|
||||||
|
'COPILOT_E2E_TEST_HOOKS',
|
||||||
|
'COPILOT_API_URL',
|
||||||
|
'COPILOT_DEBUG_GITHUB_API_URL',
|
||||||
|
'COPILOT_GH_HOST',
|
||||||
|
'COPILOT_MC_BASE_URL',
|
||||||
|
'COPILOT_AHP_URL',
|
||||||
|
'COPILOT_DEBUG_ENTRA_BROKER_URL',
|
||||||
|
'COPILOT_CAFE_METRICS_URL',
|
||||||
|
'COPILOT_PROVIDER_BASE_URL',
|
||||||
|
'COPILOT_PROVIDER_API_KEY',
|
||||||
|
'COPILOT_PROVIDER_API_KEY_COMMAND',
|
||||||
|
'COPILOT_PROVIDER_BEARER_TOKEN',
|
||||||
|
'COPILOT_PROVIDER_HEADERS',
|
||||||
|
'COPILOT_PROVIDER_TYPE',
|
||||||
|
'COPILOT_PROVIDER_WIRE_MODEL',
|
||||||
|
'COPILOT_PROVIDER_MODEL_ID',
|
||||||
|
'COPILOT_PROVIDER_GHES_HOST',
|
||||||
|
'COPILOT_PROVIDER_GHES_TOKEN',
|
||||||
|
'COPILOT_MODEL',
|
||||||
|
],
|
||||||
|
// Grok/codex-shaped clamp: a bare spawn is already Manual Approval, so the multi-user clamp
|
||||||
|
// only forces an EXPLICITLY-SENT `--yolo` back off; nothing is materialized when absent.
|
||||||
|
privilegedParams: [{ param: 'allowAll', clampTo: false }],
|
||||||
|
// BYOK (`copilot help environment`, 1.0.95): COPILOT_PROVIDER_BASE_URL switches the CLI to a
|
||||||
|
// custom provider and drops the GitHub sign-in requirement; COPILOT_PROVIDER_TYPE defaults to
|
||||||
|
// "openai", which is what a Codeman custom endpoint is (llama.cpp, vLLM, Ollama).
|
||||||
|
// COPILOT_MODEL names the model and COPILOT_PROVIDER_MODEL_ID / _WIRE_MODEL default to it.
|
||||||
|
// The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1` suffix
|
||||||
|
// the same way deepseek's does. All four already match the COPILOT_ prefix allowlist, so
|
||||||
|
// they are privileged env keys below: a non-granted multi-user owner must not be able to
|
||||||
|
// redirect the session's endpoint or credentials through plain envOverrides.
|
||||||
|
customModelInjection: {
|
||||||
|
kind: 'env',
|
||||||
|
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
|
||||||
|
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
|
||||||
|
modelVars: ['COPILOT_MODEL'],
|
||||||
|
appendV1Suffix: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overlays: {
|
||||||
|
// No credStore: Docker seeding is CRED_STORES in docker-hosts.ts, which has no `.copilot` row,
|
||||||
|
// and the sign-in token sits in plain text in config.json on a host without a keyring, so it
|
||||||
|
// is not copied into a container by default. Sign in inside the container instead.
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/** The full stock catalog, in the order the run menu shows by default. */
|
/** The full stock catalog, in the order the run menu shows by default. */
|
||||||
export const STOCK_CLIS: CliEntry[] = [CLAUDE, SHELL, OPENCODE, CODEX, GEMINI, ANTIGRAVITY, PI, GROK, DEEPSEEK, OMP];
|
export const STOCK_CLIS: CliEntry[] = [
|
||||||
|
CLAUDE,
|
||||||
|
SHELL,
|
||||||
|
OPENCODE,
|
||||||
|
CODEX,
|
||||||
|
GEMINI,
|
||||||
|
ANTIGRAVITY,
|
||||||
|
PI,
|
||||||
|
GROK,
|
||||||
|
DEEPSEEK,
|
||||||
|
OMP,
|
||||||
|
COPILOT,
|
||||||
|
];
|
||||||
@@ -525,7 +525,7 @@ export interface CliCapabilities {
|
|||||||
* own mouse support must work in the pane (htop/vim in a shell).
|
* own mouse support must work in the pane (htop/vim in a shell).
|
||||||
*
|
*
|
||||||
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
|
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
|
||||||
* opencode (a full-screen TUI that enables tracking itself); `strip-mux-only` =
|
* opencode and copilot (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
|
||||||
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
|
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
|
||||||
*
|
*
|
||||||
* A fourth combination is the point to split this into flags; three is still cheaper
|
* A fourth combination is the point to split this into flags; three is still cheaper
|
||||||
@@ -539,8 +539,16 @@ export interface CliCapabilities {
|
|||||||
/** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */
|
/** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */
|
||||||
predictProfile?: string;
|
predictProfile?: string;
|
||||||
};
|
};
|
||||||
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
|
/**
|
||||||
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
|
* Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback;
|
||||||
|
* 'version-gated' forwards from `minVersion` on; 'always' forwards at any version. Either
|
||||||
|
* forwarding mode applies only while the CLI has mouse tracking on (`cliMouseTracking`).
|
||||||
|
*
|
||||||
|
* ⚠️ Declared only: nothing reads this field yet (see DECLARED-FOR-LATER below). The browser's
|
||||||
|
* gate is `_shouldForwardWheelToApp` in terminal-ui.js, which keeps its own hand-written
|
||||||
|
* per-CLI rules, so changing a value here changes nothing until that gate is changed too.
|
||||||
|
*/
|
||||||
|
wheelForward: { mode: 'never' | 'version-gated' | 'always'; minVersion?: string };
|
||||||
/**
|
/**
|
||||||
* The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over
|
* The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over
|
||||||
* a pane with no local history can be turned into those keys instead of doing nothing
|
* a pane with no local history can be turned into those keys instead of doing nothing
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
* `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an
|
* `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an
|
||||||
* `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not
|
* `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not
|
||||||
* something Codeman launches, so they have no registry entry (and no id to branch on): GitHub
|
* something Codeman launches, so they have no registry entry (and no id to branch on): GitHub
|
||||||
* Copilot CLI is the first. They are plain data here, take part only when installed or when their
|
* Copilot CLI is the first. It is also a run mode that declares its own `mcpConfig`, so while that
|
||||||
* config file already exists (an absent tool is reported `absent`, never created), and sort after
|
* entry is enabled this row is skipped (`mcpSyncOnlyTargets` drops ids the registry already
|
||||||
* the registry CLIs, so when two definitions of a name differ the registry CLI's is the one copied.
|
* supplies); it applies when the run mode is disabled but the tool is still on the machine. They are
|
||||||
|
* plain data here, take part only when installed or when their config file already exists (an
|
||||||
|
* absent tool is reported `absent`, never created), and sort after the registry CLIs, so when two
|
||||||
|
* definitions of a name differ the registry CLI's is the one copied.
|
||||||
*
|
*
|
||||||
* @module mcp-sync-targets
|
* @module mcp-sync-targets
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
AntigravityConfig,
|
AntigravityConfig,
|
||||||
PiConfig,
|
PiConfig,
|
||||||
GrokConfig,
|
GrokConfig,
|
||||||
|
CopilotConfig,
|
||||||
DeepSeekConfig,
|
DeepSeekConfig,
|
||||||
OmpConfig,
|
OmpConfig,
|
||||||
SessionRemote,
|
SessionRemote,
|
||||||
@@ -111,6 +112,7 @@ export interface CreateSessionOptions {
|
|||||||
antigravityConfig?: AntigravityConfig;
|
antigravityConfig?: AntigravityConfig;
|
||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
|
copilotConfig?: CopilotConfig;
|
||||||
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 */
|
||||||
@@ -156,6 +158,7 @@ export interface RespawnPaneOptions {
|
|||||||
antigravityConfig?: AntigravityConfig;
|
antigravityConfig?: AntigravityConfig;
|
||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
|
copilotConfig?: CopilotConfig;
|
||||||
deepSeekConfig?: DeepSeekConfig;
|
deepSeekConfig?: DeepSeekConfig;
|
||||||
ompConfig?: OmpConfig;
|
ompConfig?: OmpConfig;
|
||||||
/** Resume a previous Claude conversation when respawning */
|
/** Resume a previous Claude conversation when respawning */
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
EffortLevel,
|
EffortLevel,
|
||||||
GeminiConfig,
|
GeminiConfig,
|
||||||
GrokConfig,
|
GrokConfig,
|
||||||
|
CopilotConfig,
|
||||||
OmpConfig,
|
OmpConfig,
|
||||||
OpenCodeConfig,
|
OpenCodeConfig,
|
||||||
PiConfig,
|
PiConfig,
|
||||||
@@ -50,6 +51,7 @@ export interface SpawnBridgeOptions {
|
|||||||
antigravityConfig?: AntigravityConfig;
|
antigravityConfig?: AntigravityConfig;
|
||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
|
copilotConfig?: CopilotConfig;
|
||||||
deepSeekConfig?: DeepSeekConfig;
|
deepSeekConfig?: DeepSeekConfig;
|
||||||
ompConfig?: OmpConfig;
|
ompConfig?: OmpConfig;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
type AntigravityConfig,
|
type AntigravityConfig,
|
||||||
type PiConfig,
|
type PiConfig,
|
||||||
type GrokConfig,
|
type GrokConfig,
|
||||||
|
type CopilotConfig,
|
||||||
type DeepSeekConfig,
|
type DeepSeekConfig,
|
||||||
type OmpConfig,
|
type OmpConfig,
|
||||||
type SessionRemote,
|
type SessionRemote,
|
||||||
@@ -750,6 +751,8 @@ export class Session extends EventEmitter {
|
|||||||
private _piConfig: PiConfig | undefined;
|
private _piConfig: PiConfig | undefined;
|
||||||
// Grok configuration (only for mode === 'grok')
|
// Grok configuration (only for mode === 'grok')
|
||||||
private _grokConfig: GrokConfig | undefined;
|
private _grokConfig: GrokConfig | undefined;
|
||||||
|
// GitHub Copilot configuration (only for mode === 'copilot')
|
||||||
|
private _copilotConfig: CopilotConfig | undefined;
|
||||||
|
|
||||||
// DeepSeek Harness configuration (only for mode === 'deepseek')
|
// DeepSeek Harness configuration (only for mode === 'deepseek')
|
||||||
private _deepSeekConfig: DeepSeekConfig | undefined;
|
private _deepSeekConfig: DeepSeekConfig | undefined;
|
||||||
@@ -877,6 +880,7 @@ export class Session extends EventEmitter {
|
|||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
/** Grok configuration (only for mode === 'grok') */
|
/** Grok configuration (only for mode === 'grok') */
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
|
copilotConfig?: CopilotConfig;
|
||||||
/** 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') */
|
||||||
@@ -1039,6 +1043,11 @@ export class Session extends EventEmitter {
|
|||||||
this._grokConfig = config.grokConfig;
|
this._grokConfig = config.grokConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply GitHub Copilot configuration
|
||||||
|
if (config.copilotConfig) {
|
||||||
|
this._copilotConfig = config.copilotConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -2026,6 +2035,7 @@ export class Session extends EventEmitter {
|
|||||||
antigravityConfig: this._antigravityConfig,
|
antigravityConfig: this._antigravityConfig,
|
||||||
piConfig: this._piConfig,
|
piConfig: this._piConfig,
|
||||||
grokConfig: this._grokConfig,
|
grokConfig: this._grokConfig,
|
||||||
|
copilotConfig: this._copilotConfig,
|
||||||
deepSeekConfig: this._deepSeekConfig,
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
ompConfig: this._ompConfig,
|
ompConfig: this._ompConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
@@ -2398,6 +2408,7 @@ export class Session extends EventEmitter {
|
|||||||
antigravityConfig: this._antigravityConfig,
|
antigravityConfig: this._antigravityConfig,
|
||||||
piConfig: this._piConfig,
|
piConfig: this._piConfig,
|
||||||
grokConfig: this._grokConfig,
|
grokConfig: this._grokConfig,
|
||||||
|
copilotConfig: this._copilotConfig,
|
||||||
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
|
||||||
@@ -2886,6 +2897,7 @@ export class Session extends EventEmitter {
|
|||||||
antigravityConfig: this._antigravityConfig,
|
antigravityConfig: this._antigravityConfig,
|
||||||
piConfig: this._piConfig,
|
piConfig: this._piConfig,
|
||||||
grokConfig: this._grokConfig,
|
grokConfig: this._grokConfig,
|
||||||
|
copilotConfig: this._copilotConfig,
|
||||||
deepSeekConfig: this._deepSeekConfig,
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
ompConfig: this._ompConfig,
|
ompConfig: this._ompConfig,
|
||||||
resumeSessionId: this._resumeSessionId,
|
resumeSessionId: this._resumeSessionId,
|
||||||
@@ -3450,6 +3462,7 @@ export class Session extends EventEmitter {
|
|||||||
antigravityConfig: this._antigravityConfig,
|
antigravityConfig: this._antigravityConfig,
|
||||||
piConfig: this._piConfig,
|
piConfig: this._piConfig,
|
||||||
grokConfig: this._grokConfig,
|
grokConfig: this._grokConfig,
|
||||||
|
copilotConfig: this._copilotConfig,
|
||||||
deepSeekConfig: this._deepSeekConfig,
|
deepSeekConfig: this._deepSeekConfig,
|
||||||
ompConfig: this._ompConfig,
|
ompConfig: this._ompConfig,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
type AntigravityConfig,
|
type AntigravityConfig,
|
||||||
type PiConfig,
|
type PiConfig,
|
||||||
type GrokConfig,
|
type GrokConfig,
|
||||||
|
type CopilotConfig,
|
||||||
type DeepSeekConfig,
|
type DeepSeekConfig,
|
||||||
type OmpConfig,
|
type OmpConfig,
|
||||||
type SessionRemote,
|
type SessionRemote,
|
||||||
@@ -1081,6 +1082,7 @@ export function buildSpawnCommand(options: {
|
|||||||
antigravityConfig?: AntigravityConfig;
|
antigravityConfig?: AntigravityConfig;
|
||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
|
copilotConfig?: CopilotConfig;
|
||||||
deepSeekConfig?: DeepSeekConfig;
|
deepSeekConfig?: DeepSeekConfig;
|
||||||
ompConfig?: OmpConfig;
|
ompConfig?: OmpConfig;
|
||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
@@ -2317,6 +2319,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
|
copilotConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
ompConfig,
|
ompConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
@@ -2403,6 +2406,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
|
copilotConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
ompConfig,
|
ompConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
@@ -2652,6 +2656,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
|
copilotConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
ompConfig,
|
ompConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
@@ -2693,6 +2698,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
|||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
|
copilotConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
ompConfig,
|
ompConfig,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
|
|||||||
+34
-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' (which CLI backend)
|
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot' (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)
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
||||||
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
|
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
|
||||||
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
|
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
|
||||||
|
* - 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)
|
||||||
*
|
*
|
||||||
* Cross-domain relationships:
|
* Cross-domain relationships:
|
||||||
@@ -56,7 +57,8 @@ export type SessionMode =
|
|||||||
| 'pi'
|
| 'pi'
|
||||||
| 'grok'
|
| 'grok'
|
||||||
| 'deepseek'
|
| 'deepseek'
|
||||||
| 'omp';
|
| 'omp'
|
||||||
|
| 'copilot';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Who owns a session's name. `placeholder`: Codeman's own `w<n>-<case>` (or no
|
* Who owns a session's name. `placeholder`: Codeman's own `w<n>-<case>` (or no
|
||||||
@@ -78,7 +80,7 @@ export interface SessionWriteOptions {
|
|||||||
|
|
||||||
export type RemoteCommandMode = Extract<
|
export type RemoteCommandMode = Extract<
|
||||||
SessionMode,
|
SessionMode,
|
||||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,7 +215,7 @@ export interface RemoteSessionInfo {
|
|||||||
/** Which CLI backends a Docker case can run (same set as remote). */
|
/** Which CLI backends a Docker case can run (same set as remote). */
|
||||||
export type DockerCommandMode = Extract<
|
export type DockerCommandMode = Extract<
|
||||||
SessionMode,
|
SessionMode,
|
||||||
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp'
|
'shell' | 'claude' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | 'deepseek' | 'omp' | 'copilot'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
/** Container engine. Docker and Podman differ in the uid/userns + host-gateway alias. */
|
||||||
@@ -525,6 +527,32 @@ export interface GrokConfig {
|
|||||||
resumeSessionId?: string;
|
resumeSessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub Copilot CLI (`copilot`) session configuration.
|
||||||
|
*
|
||||||
|
* Copilot asks before running tools ("Manual Approval") and before touching paths
|
||||||
|
* outside the trusted folder. `--yolo` is its allow-all switch (tools, paths and URLs),
|
||||||
|
* the one the Run button opts into like the other agent CLIs' bypass switches. Verified
|
||||||
|
* against copilot 1.0.94.
|
||||||
|
*/
|
||||||
|
export interface CopilotConfig {
|
||||||
|
/** Model ID passed via --model (e.g. "claude-sonnet-5.5", or "auto"). */
|
||||||
|
model?: string;
|
||||||
|
/**
|
||||||
|
* Allow every tool, path and URL without asking (passes --yolo). Absent = Copilot's own
|
||||||
|
* Manual Approval mode. Multi-user: forced off for non-granted owners by the only-if-sent
|
||||||
|
* clamp branch, like codex/antigravity/grok.
|
||||||
|
*/
|
||||||
|
allowAll?: boolean;
|
||||||
|
/** Resume the most recent session (--continue). Skipped when resumeSessionId is set. */
|
||||||
|
continueSession?: boolean;
|
||||||
|
/**
|
||||||
|
* Resume a specific session (--resume <value>): an id, id prefix or session name, as ONE plain word
|
||||||
|
* starting with a letter or digit (a leading `-` would make the value its own flag).
|
||||||
|
*/
|
||||||
|
resumeSessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DeepSeek Harness (`dsh`) session configuration.
|
* DeepSeek Harness (`dsh`) session configuration.
|
||||||
*
|
*
|
||||||
@@ -893,6 +921,8 @@ export interface SessionState {
|
|||||||
piConfig?: PiConfig;
|
piConfig?: PiConfig;
|
||||||
/** Grok-specific configuration (only for mode === 'grok') */
|
/** Grok-specific configuration (only for mode === 'grok') */
|
||||||
grokConfig?: GrokConfig;
|
grokConfig?: GrokConfig;
|
||||||
|
/** GitHub Copilot CLI configuration (only for mode === 'copilot') */
|
||||||
|
copilotConfig?: CopilotConfig;
|
||||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||||
deepSeekConfig?: DeepSeekConfig;
|
deepSeekConfig?: DeepSeekConfig;
|
||||||
/** OMP-specific configuration (only for mode === 'omp') */
|
/** OMP-specific configuration (only for mode === 'omp') */
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* and the badge could disagree with the Run menu.
|
* and the badge could disagree with the Run menu.
|
||||||
*
|
*
|
||||||
* Every probe is a memoized resolver, so this is cheap to call per request. Dynamic imports
|
* Every probe is a memoized resolver, so this is cheap to call per request. Dynamic imports
|
||||||
* keep the nine resolvers out of any module that never asks.
|
* keep the ten resolvers out of any module that never asks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CliEntry } from '../config/cli-registry/types.js';
|
import type { CliEntry } from '../config/cli-registry/types.js';
|
||||||
@@ -30,6 +30,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
|
|||||||
{ isGrokAvailable },
|
{ isGrokAvailable },
|
||||||
{ isDeepSeekRunnable },
|
{ isDeepSeekRunnable },
|
||||||
{ isOmpAvailable },
|
{ isOmpAvailable },
|
||||||
|
{ isCopilotAvailable },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
import('./claude-cli-resolver.js'),
|
import('./claude-cli-resolver.js'),
|
||||||
import('./opencode-cli-resolver.js'),
|
import('./opencode-cli-resolver.js'),
|
||||||
@@ -40,6 +41,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
|
|||||||
import('./grok-cli-resolver.js'),
|
import('./grok-cli-resolver.js'),
|
||||||
import('./deepseek-cli-resolver.js'),
|
import('./deepseek-cli-resolver.js'),
|
||||||
import('./omp-cli-resolver.js'),
|
import('./omp-cli-resolver.js'),
|
||||||
|
import('./copilot-cli-resolver.js'),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
claude: isClaudeAvailable(),
|
claude: isClaudeAvailable(),
|
||||||
@@ -51,6 +53,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
|
|||||||
grok: isGrokAvailable(),
|
grok: isGrokAvailable(),
|
||||||
deepseek: isDeepSeekRunnable(),
|
deepseek: isDeepSeekRunnable(),
|
||||||
omp: isOmpAvailable(),
|
omp: isOmpAvailable(),
|
||||||
|
copilot: isCopilotAvailable(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Resolve the GitHub Copilot CLI binary across common install paths.
|
||||||
|
*
|
||||||
|
* Uses the shared `createCliExecutableResolver` (cli-executable-resolver.ts),
|
||||||
|
* same as the sibling claude/opencode/codex/gemini/antigravity/pi resolvers:
|
||||||
|
* server process PATH first, then common install directories, then — last,
|
||||||
|
* because it is the only step that spawns anything — an interactive login
|
||||||
|
* shell, which is what finds nvm/Homebrew/user-npm installs when Codeman runs
|
||||||
|
* as a systemd/launchd service with a minimal PATH.
|
||||||
|
*
|
||||||
|
* Provides an augmented PATH directory for tmux sessions.
|
||||||
|
*
|
||||||
|
* @module utils/copilot-cli-resolver
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
|
import { getCli } from '../config/cli-registry/registry.js';
|
||||||
|
import { expandHome } from './cli-resolver.js';
|
||||||
|
import {
|
||||||
|
createCliExecutableResolver,
|
||||||
|
formatCliNotFoundMessage,
|
||||||
|
type CliResolverHost,
|
||||||
|
} from './cli-executable-resolver.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directories probed after `which`, read from this CLI's registry entry so the spawn
|
||||||
|
* path, `codeman doctor` and this resolver cannot disagree about where to look.
|
||||||
|
* `~` is expanded by `expandHome`; nothing else is interpreted.
|
||||||
|
*/
|
||||||
|
const COPILOT_SEARCH_DIRS = (): string[] => (getCli('copilot')?.discovery.searchDirs ?? []).map(expandHome);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A real `copilot --version` prints `GitHub Copilot CLI 1.0.94.`. Anchoring on the product
|
||||||
|
* name keeps an unrelated `copilot` binary on PATH from being accepted.
|
||||||
|
*/
|
||||||
|
export const COPILOT_VERSION_REGEX = /GitHub Copilot CLI (\d+\.\d+\.\d+)/;
|
||||||
|
|
||||||
|
const COPILOT_NOT_FOUND = 'GitHub Copilot CLI not found. Install with: npm install -g @github/copilot';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `copilot --version` on a candidate path and return the trimmed version when
|
||||||
|
* it is the GitHub Copilot CLI. Returns null for anything else — a missing
|
||||||
|
* binary, a non-zero exit, a hang (timeout), or output that is not
|
||||||
|
* `GitHub Copilot CLI <semver>`-shaped (which is how an unrelated `copilot` on PATH gets rejected).
|
||||||
|
*
|
||||||
|
* Never runs under vitest: the suites must stay hermetic and must not depend on
|
||||||
|
* whether the dev box happens to have copilot installed. The shared resolver host
|
||||||
|
* is already inert under vitest, so this gate is defense in depth for any
|
||||||
|
* opted-in host that still carries the default probe.
|
||||||
|
*/
|
||||||
|
function probeCopilotVersion(binPath: string): string | null {
|
||||||
|
if (process.env.VITEST) return null;
|
||||||
|
try {
|
||||||
|
const out = execFileSync(binPath, ['--version'], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
timeout: EXEC_TIMEOUT_MS,
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
// A stuck or hostile `copilot` that ignores SIGTERM would survive the timeout
|
||||||
|
// and block the server (execFileSync keeps waiting after the signal).
|
||||||
|
killSignal: 'SIGKILL',
|
||||||
|
}).trim();
|
||||||
|
const candidate = COPILOT_VERSION_REGEX.exec(out)?.[1];
|
||||||
|
if (candidate) return candidate;
|
||||||
|
console.warn(
|
||||||
|
`[CopilotResolver] Ignoring ${binPath}: "copilot --version" printed ${JSON.stringify(out.slice(0, 80))}`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[CopilotResolver] Ignoring ${binPath}: "copilot --version" failed (${(err as Error).message})`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CopilotVersionProbe = (binPath: string) => string | null;
|
||||||
|
|
||||||
|
function createCopilotResolver(
|
||||||
|
host?: CliResolverHost,
|
||||||
|
versionProbe: CopilotVersionProbe = probeCopilotVersion,
|
||||||
|
now?: () => number
|
||||||
|
) {
|
||||||
|
return createCliExecutableResolver<string>(
|
||||||
|
{
|
||||||
|
binary: 'copilot',
|
||||||
|
searchDirs: COPILOT_SEARCH_DIRS,
|
||||||
|
validateCandidate: (binPath) => {
|
||||||
|
const version = versionProbe(binPath);
|
||||||
|
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
},
|
||||||
|
host
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an isolated Copilot wrapper around an injected host, version probe and
|
||||||
|
* clock. Omitting `versionProbe` keeps the ambient (VITEST-gated) probe, which
|
||||||
|
* is exactly what the hermeticity test exercises.
|
||||||
|
*/
|
||||||
|
export function createCopilotResolverForTest(
|
||||||
|
host: CliResolverHost,
|
||||||
|
versionProbe?: CopilotVersionProbe,
|
||||||
|
now?: () => number
|
||||||
|
) {
|
||||||
|
return createCopilotResolver(host, versionProbe ?? probeCopilotVersion, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const copilotResolver = createCopilotResolver();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the directory containing a verified `copilot` binary.
|
||||||
|
* Checks `which copilot` first, then falls back to common install locations. Every
|
||||||
|
* candidate must pass the `copilot --version` sanity probe before it is accepted.
|
||||||
|
*
|
||||||
|
* @returns Directory path, or null if not found
|
||||||
|
*/
|
||||||
|
export function resolveCopilotDir(): string | null {
|
||||||
|
return copilotResolver.resolve()?.directory ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the GitHub Copilot CLI is available on the system.
|
||||||
|
*/
|
||||||
|
export function isCopilotAvailable(): boolean {
|
||||||
|
return resolveCopilotDir() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCopilotNotFoundMessage(): string {
|
||||||
|
return formatCliNotFoundMessage(COPILOT_NOT_FOUND, copilotResolver.diagnostics());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version reported by the resolved `copilot` binary, or null when copilot is
|
||||||
|
* unavailable. Surfaced through `GET /api/copilot/status` so a misresolution is
|
||||||
|
* diagnosable from the UI.
|
||||||
|
*/
|
||||||
|
export function getCopilotCliVersion(): string | null {
|
||||||
|
return copilotResolver.resolve()?.metadata ?? null;
|
||||||
|
}
|
||||||
@@ -69,6 +69,12 @@ export type { DeepSeekProfile, DeepSeekProfileKind } from './deepseek-cli-resolv
|
|||||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||||
export type { FileQueryMatcher } from './file-query.js';
|
export type { FileQueryMatcher } from './file-query.js';
|
||||||
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
|
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
|
||||||
|
export {
|
||||||
|
resolveCopilotDir,
|
||||||
|
isCopilotAvailable,
|
||||||
|
getCopilotNotFoundMessage,
|
||||||
|
getCopilotCliVersion,
|
||||||
|
} from './copilot-cli-resolver.js';
|
||||||
export {
|
export {
|
||||||
boundedPathExists,
|
boundedPathExists,
|
||||||
describeUnknownPath,
|
describeUnknownPath,
|
||||||
|
|||||||
@@ -3014,6 +3014,8 @@ class CodemanApp {
|
|||||||
? 'DeepSeek'
|
? 'DeepSeek'
|
||||||
: mode === 'omp'
|
: mode === 'omp'
|
||||||
? 'OMP'
|
? 'OMP'
|
||||||
|
: mode === 'copilot'
|
||||||
|
? 'GitHub Copilot'
|
||||||
: mode === 'opencode'
|
: mode === 'opencode'
|
||||||
? 'OpenCode'
|
? 'OpenCode'
|
||||||
: 'Claude';
|
: 'Claude';
|
||||||
@@ -10511,6 +10513,8 @@ class CodemanApp {
|
|||||||
? 'Kill Tmux & DeepSeek'
|
? 'Kill Tmux & DeepSeek'
|
||||||
: session.mode === 'omp'
|
: session.mode === 'omp'
|
||||||
? 'Kill Tmux & OMP'
|
? 'Kill Tmux & OMP'
|
||||||
|
: session.mode === 'copilot'
|
||||||
|
? 'Kill Tmux & GitHub Copilot'
|
||||||
: 'Kill Tmux & Claude Code';
|
: 'Kill Tmux & Claude Code';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,7 @@
|
|||||||
'Run Grok': '运行 Grok',
|
'Run Grok': '运行 Grok',
|
||||||
'Run DeepSeek': '运行 DeepSeek',
|
'Run DeepSeek': '运行 DeepSeek',
|
||||||
'Run OMP': '运行 OMP',
|
'Run OMP': '运行 OMP',
|
||||||
|
'Run GitHub Copilot': '运行 GitHub Copilot',
|
||||||
'Run Shell': '运行 Shell',
|
'Run Shell': '运行 Shell',
|
||||||
'More tools': '更多工具',
|
'More tools': '更多工具',
|
||||||
'Select AI backend': '选择 AI 后端',
|
'Select AI backend': '选择 AI 后端',
|
||||||
|
|||||||
@@ -1078,6 +1078,7 @@
|
|||||||
<option value="grok">Grok</option>
|
<option value="grok">Grok</option>
|
||||||
<option value="deepseek">DeepSeek</option>
|
<option value="deepseek">DeepSeek</option>
|
||||||
<option value="omp">OMP</option>
|
<option value="omp">OMP</option>
|
||||||
|
<option value="copilot">GitHub Copilot</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>
|
||||||
@@ -3465,6 +3466,7 @@
|
|||||||
<option value="grok" data-cli="grok">Grok</option>
|
<option value="grok" data-cli="grok">Grok</option>
|
||||||
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
|
<option value="deepseek" data-cli="deepseek">DeepSeek</option>
|
||||||
<option value="omp" data-cli="omp">OMP</option>
|
<option value="omp" data-cli="omp">OMP</option>
|
||||||
|
<option value="copilot" data-cli="copilot">GitHub Copilot</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>
|
||||||
@@ -3643,7 +3645,7 @@
|
|||||||
<div class="form-row docker-create-only">
|
<div class="form-row docker-create-only">
|
||||||
<label>Image</label>
|
<label>Image</label>
|
||||||
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/opencode/codex/gemini/agy/pi/grok/dsh/omp + tmux.</span>
|
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/opencode/codex/gemini/agy/pi/grok/dsh/omp/copilot + tmux.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row docker-create-only">
|
<div class="form-row docker-create-only">
|
||||||
<label>Network</label>
|
<label>Network</label>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
|||||||
{ mode: 'grok', label: 'Grok', short: 'Grok' },
|
{ mode: 'grok', label: 'Grok', short: 'Grok' },
|
||||||
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
|
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
|
||||||
{ mode: 'omp', label: 'OMP', short: 'OMP' },
|
{ mode: 'omp', label: 'OMP', short: 'OMP' },
|
||||||
|
{ mode: 'copilot', label: 'GitHub Copilot', short: 'GitHub Copilot' },
|
||||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1240,6 +1240,20 @@ html.mobile-init .file-browser-panel {
|
|||||||
border-color: rgba(129, 140, 248, 0.5) !important;
|
border-color: rgba(129, 140, 248, 0.5) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GitHub Copilot mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek/omp blocks above. */
|
||||||
|
.btn-toolbar.btn-run.mode-copilot,
|
||||||
|
.btn-toolbar.btn-run-gear.mode-copilot {
|
||||||
|
background: #2e1065 !important;
|
||||||
|
border-color: rgba(163, 113, 247, 0.3) !important;
|
||||||
|
color: #ede9fe !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toolbar.btn-run.mode-copilot:active,
|
||||||
|
.btn-toolbar.btn-run-gear.mode-copilot:active {
|
||||||
|
background: #6d28d9 !important;
|
||||||
|
border-color: rgba(163, 113, 247, 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%;
|
||||||
@@ -3337,6 +3351,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-copilot, .btn-toolbar.btn-run-gear.mode-copilot) {
|
||||||
|
background: linear-gradient(135deg, #6d28d9, #8b5cf6);
|
||||||
|
border-color: #5b21b6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
|
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
|
||||||
background: linear-gradient(135deg, #27272a, #52525b);
|
background: linear-gradient(135deg, #27272a, #52525b);
|
||||||
border-color: #18181b;
|
border-color: #18181b;
|
||||||
|
|||||||
@@ -533,7 +533,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
_buildCommandPaletteNewSessionItem(query = '') {
|
_buildCommandPaletteNewSessionItem(query = '') {
|
||||||
const mode = this.runMode || this._runMode || 'claude';
|
const mode = this.runMode || this._runMode || 'claude';
|
||||||
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP' };
|
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP', copilot: 'Copilot' };
|
||||||
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
|
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
|
||||||
return {
|
return {
|
||||||
id: 'new-session',
|
id: 'new-session',
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
* PR B2: the single source for every non-Claude, non-Shell run mode's launch
|
* PR B2: the single source for every non-Claude, non-Shell run mode's launch
|
||||||
* shape, consumed by `_runCliMode()` below. Before this table existed, each of
|
* shape, consumed by `_runCliMode()` below. Before this table existed, each of
|
||||||
* `runOpenCode`/`runCodex`/`runGemini`/`runAntigravity`/`runPi`/`runOmp`/
|
* `runOpenCode`/`runCodex`/`runGemini`/`runAntigravity`/`runPi`/`runOmp`/
|
||||||
* `runGrok`/`runDeepSeek` was a ~45-line copy of the same probe/launch/select
|
* `runGrok`/`runDeepSeek`/`runCopilot` was a ~45-line copy of the same probe/launch/select
|
||||||
* skeleton with only the CLI-specific pieces below actually differing — eight
|
* skeleton with only the CLI-specific pieces below actually differing — eight
|
||||||
* near-identical bodies guaranteed to drift, exactly what the CLI registry's
|
* near-identical bodies guaranteed to drift, exactly what the CLI registry's
|
||||||
* own no-id-branching rule exists to prevent server-side.
|
* own no-id-branching rule exists to prevent server-side.
|
||||||
@@ -79,6 +79,16 @@ const RUN_MODE_LAUNCH = {
|
|||||||
supportsCustomModel: true,
|
supportsCustomModel: true,
|
||||||
buildConfig: () => null,
|
buildConfig: () => null,
|
||||||
},
|
},
|
||||||
|
copilot: {
|
||||||
|
label: 'GitHub Copilot',
|
||||||
|
installHint: 'GitHub Copilot CLI not found. Install with: npm install -g @github/copilot',
|
||||||
|
// Custom endpoints go in through Copilot's BYOK env vars (COPILOT_PROVIDER_*).
|
||||||
|
supportsCustomModel: true,
|
||||||
|
// Sends `copilotConfig: { allowAll: true }` the way grok sends `alwaysApprove: true`:
|
||||||
|
// Codeman sessions exist for autonomous work, so the Run button opts into `--yolo`.
|
||||||
|
// The multi-user clamp forces it back off for non-granted owners server-side.
|
||||||
|
buildConfig: () => ({ copilotConfig: { allowAll: true } }),
|
||||||
|
},
|
||||||
grok: {
|
grok: {
|
||||||
label: 'Grok',
|
label: 'Grok',
|
||||||
installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash',
|
installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash',
|
||||||
@@ -2493,6 +2503,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return this._runCliMode('deepseek');
|
return this._runCliMode('deepseek');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async runCopilot() {
|
||||||
|
return this._runCliMode('copilot');
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// Session Options Modal
|
// Session Options Modal
|
||||||
|
|||||||
@@ -5785,6 +5785,22 @@ html[data-skin="og"] .welcome-primary:hover {
|
|||||||
color: #eef2ff;
|
color: #eef2ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* GitHub Copilot mode colors */
|
||||||
|
.btn-toolbar.btn-run.mode-copilot,
|
||||||
|
.btn-toolbar.btn-run-gear.mode-copilot {
|
||||||
|
background: linear-gradient(135deg, #2e1065 0%, #6d28d9 55%, #7c3aed 100%);
|
||||||
|
border-color: rgba(163, 113, 247, 0.5);
|
||||||
|
color: #ede9fe;
|
||||||
|
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-copilot:hover,
|
||||||
|
.btn-toolbar.btn-run-gear.mode-copilot:hover {
|
||||||
|
background: linear-gradient(135deg, #3b0f8c 0%, #7c3aed 55%, #8b5cf6 100%);
|
||||||
|
box-shadow: 0 0 12px rgba(163, 113, 247, 0.35), 0 2px 8px rgba(109, 40, 217, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(196, 167, 252, 0.6);
|
||||||
|
color: #f5f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
/* Grok mode colors. Same cascade note as pi above: this base-sheet pair only
|
/* Grok mode colors. Same cascade note as pi above: this base-sheet pair only
|
||||||
renders on the `og` skin — the nested `html:not([data-skin="og"])` block
|
renders on the `og` skin — the nested `html:not([data-skin="og"])` block
|
||||||
re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so grok also
|
re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so grok also
|
||||||
@@ -5944,6 +5960,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
|||||||
.run-mode-dot.opencode,
|
.run-mode-dot.opencode,
|
||||||
.run-mode-dot.pi,
|
.run-mode-dot.pi,
|
||||||
.run-mode-dot.grok,
|
.run-mode-dot.grok,
|
||||||
|
.run-mode-dot.copilot,
|
||||||
.run-mode-dot.shell,
|
.run-mode-dot.shell,
|
||||||
.run-mode-dot.web {
|
.run-mode-dot.web {
|
||||||
width: 1.15em;
|
width: 1.15em;
|
||||||
@@ -5964,6 +5981,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
|||||||
.run-mode-dot.opencode,
|
.run-mode-dot.opencode,
|
||||||
.run-mode-dot.pi,
|
.run-mode-dot.pi,
|
||||||
.run-mode-dot.grok,
|
.run-mode-dot.grok,
|
||||||
|
.run-mode-dot.copilot,
|
||||||
.run-mode-dot.shell,
|
.run-mode-dot.shell,
|
||||||
.run-mode-dot.web {
|
.run-mode-dot.web {
|
||||||
background-color: currentColor;
|
background-color: currentColor;
|
||||||
@@ -5979,6 +5997,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
|||||||
.run-mode-dot.opencode { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16 6H8v12h8V6zm4 16H4V2h16v20z'/%3E%3C/svg%3E"); }
|
.run-mode-dot.opencode { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16 6H8v12h8V6zm4 16H4V2h16v20z'/%3E%3C/svg%3E"); }
|
||||||
.run-mode-dot.pi { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath clip-rule='evenodd' d='M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z'/%3E%3Cpath d='M17.5 12H23v11h-5.5V12z'/%3E%3C/svg%3E"); }
|
.run-mode-dot.pi { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath clip-rule='evenodd' d='M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z'/%3E%3Cpath d='M17.5 12H23v11h-5.5V12z'/%3E%3C/svg%3E"); }
|
||||||
.run-mode-dot.grok { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815'/%3E%3C/svg%3E"); }
|
.run-mode-dot.grok { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815'/%3E%3C/svg%3E"); }
|
||||||
|
.run-mode-dot.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.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"); }
|
||||||
|
|
||||||
@@ -16375,6 +16394,12 @@ html:not([data-skin="og"]) {
|
|||||||
color: #e0e7ff;
|
color: #e0e7ff;
|
||||||
}
|
}
|
||||||
.btn-toolbar.btn-run.mode-omp:hover { box-shadow: 0 0 14px -2px rgba(129, 140, 248, 0.45); }
|
.btn-toolbar.btn-run.mode-omp:hover { box-shadow: 0 0 14px -2px rgba(129, 140, 248, 0.45); }
|
||||||
|
.btn-toolbar.btn-run.mode-copilot {
|
||||||
|
background: linear-gradient(135deg, #6d28d9, #8b5cf6);
|
||||||
|
border-color: #5b21b6;
|
||||||
|
color: #ede9fe;
|
||||||
|
}
|
||||||
|
.btn-toolbar.btn-run.mode-copilot:hover { box-shadow: 0 0 14px -2px rgba(163, 113, 247, 0.45); }
|
||||||
.btn-toolbar.btn-run.mode-pi {
|
.btn-toolbar.btn-run.mode-pi {
|
||||||
background: linear-gradient(135deg, #be185d, #f472b6);
|
background: linear-gradient(135deg, #be185d, #f472b6);
|
||||||
border-color: #be185d;
|
border-color: #be185d;
|
||||||
|
|||||||
@@ -3672,6 +3672,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
opencode: 'openCodeConfig',
|
opencode: 'openCodeConfig',
|
||||||
pi: 'piConfig',
|
pi: 'piConfig',
|
||||||
grok: 'grokConfig',
|
grok: 'grokConfig',
|
||||||
|
copilot: 'copilotConfig',
|
||||||
omp: 'ompConfig',
|
omp: 'ompConfig',
|
||||||
}[effectiveMode];
|
}[effectiveMode];
|
||||||
// codex names a thread by an id of its own, not by Codeman's session id,
|
// codex names a thread by an id of its own, not by Codeman's session id,
|
||||||
@@ -5853,6 +5854,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (mode && mode !== 'none') return false;
|
if (mode && mode !== 'none') return false;
|
||||||
const session = this.sessions?.get(target.sessionId || this.activeSessionId);
|
const session = this.sessions?.get(target.sessionId || this.activeSessionId);
|
||||||
const sessionMode = session?.mode || 'claude';
|
const sessionMode = session?.mode || 'claude';
|
||||||
|
// GitHub Copilot CLI scrolls its own transcript on SGR wheel reports (checked with xterm
|
||||||
|
// encoding them natively). Its mouse DECSETs are stripped server-side so a drag selects
|
||||||
|
// locally (strip-mux-and-mouse), which also means xterm no longer encodes the wheel: send the
|
||||||
|
// same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a
|
||||||
|
// version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's.
|
||||||
|
if (sessionMode === 'copilot') return session?.cliMouseTracking === true;
|
||||||
if (sessionMode !== 'claude') return false;
|
if (sessionMode !== 'claude') return false;
|
||||||
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
|
||||||
// Only while Claude is actually listening for the mouse. In its default
|
// Only while Claude is actually listening for the mouse. In its default
|
||||||
|
|||||||
@@ -11,7 +11,65 @@ export interface ResponseViewerTranscriptBlock {
|
|||||||
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
|
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
|
||||||
// would drag node-pty and the whole session layer into this pure module, so the
|
// would drag node-pty and the whole session layer into this pure module, so the
|
||||||
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
|
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
|
||||||
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek']);
|
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek', 'copilot']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pane that is not Codex-shaped (`›` prompt, `•` bullets) describes its own dialect as data, so
|
||||||
|
* a new CLI adds a row here instead of another branch in the Codex-oriented loop below.
|
||||||
|
*/
|
||||||
|
interface PaneDialect {
|
||||||
|
/** Group 1 is the prompt text. A bare composer glyph with nothing after it must NOT match. */
|
||||||
|
prompt: RegExp;
|
||||||
|
/** A right-aligned timestamp the TUI appends to a submitted prompt. */
|
||||||
|
promptTrailer?: RegExp;
|
||||||
|
/** The glyph that opens an answer. */
|
||||||
|
responseBullet: RegExp;
|
||||||
|
/** Chrome that can sit between turns: banner notes, the cwd/credits row, the footer, spinners. */
|
||||||
|
status: RegExp[];
|
||||||
|
/** Rows that carry nothing (the empty composer), dropped and treated as a block boundary. */
|
||||||
|
ignore: RegExp[];
|
||||||
|
/**
|
||||||
|
* The composer row, the one between two rules at the bottom of the screen. Only the LAST rule,
|
||||||
|
* composer row, rule triple counts: an answer can hold a rule-delimited paragraph or a quoted
|
||||||
|
* prompt of its own, and that is answer text.
|
||||||
|
*/
|
||||||
|
composer: RegExp;
|
||||||
|
/** Furniture drawn at the end of a row (a scrollbar gutter), removed before anything else reads it. */
|
||||||
|
lineTrailer?: RegExp;
|
||||||
|
/**
|
||||||
|
* Text that is on screen only before the first prompt has scrolled the screen: the banner, a
|
||||||
|
* trust dialog. Its presence is how "nothing has been asked yet" is told apart from "the prompt
|
||||||
|
* row has scrolled off the top".
|
||||||
|
*/
|
||||||
|
startup: RegExp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANE_DIALECTS: Record<string, PaneDialect> = {
|
||||||
|
// GitHub Copilot CLI, measured on 1.0.95 inside tmux: ` ❯ <prompt> HH:MM`, then ` ● <answer>`
|
||||||
|
// with 3-space continuation lines, a cwd + `Session: N AIC used` row, the composer between two
|
||||||
|
// rules, and a `← open sidebar · … · / commands · ? help` footer. Tool-call rows are not
|
||||||
|
// modelled: they open with the same bullet and read as part of the answer.
|
||||||
|
copilot: {
|
||||||
|
// Copilot draws a submitted prompt at column 0 or 1; the continuation rows of an answer are
|
||||||
|
// indented three spaces, so a shell prompt inside a code block (` ❯ npm test`) is not a prompt.
|
||||||
|
prompt: /^ ?❯\s+(\S.*)$/,
|
||||||
|
promptTrailer: /\s{2,}\d{1,2}:\d{2}\s*$/,
|
||||||
|
responseBullet: /^\s*●\s+/,
|
||||||
|
status: [
|
||||||
|
/^\s*(?:!|●)\s+(?:Tip:|MCP Servers?\b|Model\b)/,
|
||||||
|
/^\s*!\s+/,
|
||||||
|
/\bSession: .*(?:AIC used|credits unavailable)/,
|
||||||
|
/^\s*←\s+open sidebar/,
|
||||||
|
/\/ commands · \? help/,
|
||||||
|
/^\s*[◉◎]\s+Working\b/,
|
||||||
|
],
|
||||||
|
ignore: [/^ ?❯\s*$/],
|
||||||
|
composer: /^ ?❯/,
|
||||||
|
// The right-hand scrollbar gutter, `┃` on a row (or alone on a blank one).
|
||||||
|
lineTrailer: /\s*[┃▐]\s*$/,
|
||||||
|
startup: [/Copilot v\d+\.\d+\.\d+ uses AI/, /Confirm folder trust/, /Do you trust the files in this folder/],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function isPromptLine(line: string): boolean {
|
function isPromptLine(line: string): boolean {
|
||||||
return /^\s*›\s*/.test(line);
|
return /^\s*›\s*/.test(line);
|
||||||
@@ -265,6 +323,111 @@ export function isExternalCliTranscriptMode(mode: string | null | undefined): bo
|
|||||||
return EXTERNAL_CLI_MODES.has(String(mode || ''));
|
return EXTERNAL_CLI_MODES.has(String(mode || ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this mode's pane is a repainting full-screen TUI whose output stream is absolute cursor
|
||||||
|
* moves rather than lines. The segmenter needs the SCREEN for those (tmux capture-pane), because
|
||||||
|
* stripping the escapes from the stream concatenates every row into one run of text.
|
||||||
|
*/
|
||||||
|
export function transcriptNeedsScreenCapture(mode: string | null | undefined): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(PANE_DIALECTS, String(mode || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Segment a pane that has its own dialect. Everything before the first prompt is the banner (or a
|
||||||
|
* trust dialog) and is dropped, so a session that has not been asked anything answers "nothing
|
||||||
|
* yet" rather than serving its logo as a reply.
|
||||||
|
*
|
||||||
|
* The screen is all there is to read, so a long answer scrolls the prompt row off the top. When no
|
||||||
|
* prompt row is on screen AND no startup text is either, the session has already been asked
|
||||||
|
* something: the rows above the composer are the tail of the last answer, not "nothing yet".
|
||||||
|
*/
|
||||||
|
function parseDialectTranscript(cleaned: string, dialect: PaneDialect): ResponseViewerTranscriptBlock[] {
|
||||||
|
const blocks: ResponseViewerTranscriptBlock[] = [];
|
||||||
|
let kind: ResponseViewerTranscriptKind | null = null;
|
||||||
|
let current: string[] = [];
|
||||||
|
const rows = cleaned.split('\n').map((row) => (dialect.lineTrailer ? row.replace(dialect.lineTrailer, '') : row));
|
||||||
|
// The composer sits between two rules and holds whatever is typed but not sent. That is not a
|
||||||
|
// prompt row: otherwise typing under a tall answer would make the answer's tail read as banner.
|
||||||
|
let composerIndex = -1;
|
||||||
|
for (let i = rows.length - 2; i > 0; i -= 1) {
|
||||||
|
if (
|
||||||
|
dialect.composer.test(rows[i] ?? '') &&
|
||||||
|
isDividerOnlyLine(rows[i - 1] ?? '') &&
|
||||||
|
isDividerOnlyLine(rows[i + 1] ?? '')
|
||||||
|
) {
|
||||||
|
composerIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isComposerRow = (i: number) => i === composerIndex;
|
||||||
|
const isComposerRule = (i: number) => composerIndex !== -1 && (i === composerIndex - 1 || i === composerIndex + 1);
|
||||||
|
const hasPrompt = rows.some((row, i) => dialect.prompt.test(row) && !isComposerRow(i));
|
||||||
|
const startupVisible = rows.some((row) => dialect.startup.some((re) => re.test(row)));
|
||||||
|
let sawPrompt = !hasPrompt && !startupVisible;
|
||||||
|
// A rule drawn INSIDE an answer (a markdown `---`, a quoted passage between two rules) is part
|
||||||
|
// of the answer, so it must not end the block, or `agent read` returns only the text after the
|
||||||
|
// last rule. It is held until more of the same answer follows, so a rule that only separates
|
||||||
|
// the answer from the footer never trails it. The composer's own two rules stay boundaries.
|
||||||
|
let pendingRule = false;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (sawPrompt) pushBlock(blocks, kind, current);
|
||||||
|
kind = null;
|
||||||
|
current = [];
|
||||||
|
pendingRule = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [index, line] of rows.entries()) {
|
||||||
|
if (kind === 'response' && isDividerOnlyLine(line) && !isComposerRule(index)) {
|
||||||
|
pendingRule = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isDividerOnlyLine(line) || isComposerRow(index) || dialect.ignore.some((re) => re.test(line))) {
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const prompt = dialect.prompt.exec(line);
|
||||||
|
if (prompt) {
|
||||||
|
flush();
|
||||||
|
sawPrompt = true;
|
||||||
|
kind = 'prompt';
|
||||||
|
current = [dialect.promptTrailer ? prompt[1].replace(dialect.promptTrailer, '').trim() : prompt[1].trim()];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!sawPrompt) continue;
|
||||||
|
if (dialect.status.some((re) => re.test(line))) {
|
||||||
|
if (kind !== 'status') flush();
|
||||||
|
kind = 'status';
|
||||||
|
current.push(line.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// A wrapped warning or tip: indented under its status row.
|
||||||
|
if (kind === 'status' && /^\s{3,}\S/.test(line)) {
|
||||||
|
current.push(line.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!line.trim()) {
|
||||||
|
if (kind) current.push('');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bullet = dialect.responseBullet.exec(line);
|
||||||
|
if (bullet) {
|
||||||
|
flush();
|
||||||
|
kind = 'response';
|
||||||
|
current = [line.slice(bullet[0].length)];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!kind) kind = 'response';
|
||||||
|
if (pendingRule) {
|
||||||
|
current.push('', '---', '');
|
||||||
|
pendingRule = false;
|
||||||
|
}
|
||||||
|
current.push(line);
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return blocks.filter((block) => block.text.trim().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function parseExternalCliTranscript(
|
export function parseExternalCliTranscript(
|
||||||
buffer: string,
|
buffer: string,
|
||||||
mode: string | null | undefined
|
mode: string | null | undefined
|
||||||
@@ -275,6 +438,9 @@ export function parseExternalCliTranscript(
|
|||||||
const cleaned = cleanTerminalTranscript(buffer);
|
const cleaned = cleanTerminalTranscript(buffer);
|
||||||
if (!cleaned) return [];
|
if (!cleaned) return [];
|
||||||
|
|
||||||
|
const dialect = PANE_DIALECTS[resolvedMode];
|
||||||
|
if (dialect) return parseDialectTranscript(cleaned, dialect);
|
||||||
|
|
||||||
const lines = trimLeadingStartup(cleaned.split('\n'));
|
const lines = trimLeadingStartup(cleaned.split('\n'));
|
||||||
const blocks: ResponseViewerTranscriptBlock[] = [];
|
const blocks: ResponseViewerTranscriptBlock[] = [];
|
||||||
let currentKind: ResponseViewerTranscriptKind | null = null;
|
let currentKind: ResponseViewerTranscriptKind | null = null;
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
* only, never env values, headers or file content (a parse failure is reported by position).
|
* only, never env values, headers or file content (a parse failure is reported by position).
|
||||||
*
|
*
|
||||||
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
|
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
|
||||||
* or already has its config file (Copilot CLI, which is not a registry CLI, takes part when installed
|
* or already has its config file (Copilot CLI declares its own `mcpConfig`; with its run mode disabled it still
|
||||||
* or when its config file exists); one that is enabled but absent from the machine is reported
|
* takes part as a sync-only tool when installed or when its config file exists); one that is enabled but absent from the machine is reported
|
||||||
* `absent` and never created. Its file is located with this process's env (the env the CLIs
|
* `absent` and never created. Its file is located with this process's env (the env the CLIs
|
||||||
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
|
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
|
||||||
*/
|
*/
|
||||||
@@ -39,8 +39,8 @@ export async function readMcpSyncEnabled(): Promise<boolean> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enabled CLIs that declare an MCP config file, in registry order (first definition wins), then the
|
* Enabled CLIs that declare an MCP config file, in registry order (first definition wins), then the
|
||||||
* sync-only tools (src/mcp-sync-targets.ts: Copilot CLI), which come last so a registry CLI's
|
* sync-only tools (src/mcp-sync-targets.ts: Copilot CLI while its run mode is disabled), which come
|
||||||
* definition wins a same-name difference.
|
* last so a registry CLI's definition wins a same-name difference.
|
||||||
*/
|
*/
|
||||||
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
|
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
|
||||||
const registry = enabledClis()
|
const registry = enabledClis()
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
type AntigravityConfig,
|
type AntigravityConfig,
|
||||||
type PiConfig,
|
type PiConfig,
|
||||||
type GrokConfig,
|
type GrokConfig,
|
||||||
|
type CopilotConfig,
|
||||||
type DeepSeekConfig,
|
type DeepSeekConfig,
|
||||||
type OmpConfig,
|
type OmpConfig,
|
||||||
type RemoteHost,
|
type RemoteHost,
|
||||||
@@ -189,6 +190,7 @@ import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
|
|||||||
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
|
||||||
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
|
||||||
import {
|
import {
|
||||||
|
transcriptNeedsScreenCapture,
|
||||||
getLastTranscriptResponse,
|
getLastTranscriptResponse,
|
||||||
isExternalCliTranscriptMode,
|
isExternalCliTranscriptMode,
|
||||||
parseExternalCliTranscript,
|
parseExternalCliTranscript,
|
||||||
@@ -628,7 +630,8 @@ export async function _clampExternalCliBypassForOwner(
|
|||||||
antigravityConfig?: AntigravityConfig,
|
antigravityConfig?: AntigravityConfig,
|
||||||
piConfig?: PiConfig,
|
piConfig?: PiConfig,
|
||||||
grokConfig?: GrokConfig,
|
grokConfig?: GrokConfig,
|
||||||
deepSeekConfig?: DeepSeekConfig
|
deepSeekConfig?: DeepSeekConfig,
|
||||||
|
copilotConfig?: CopilotConfig
|
||||||
): Promise<{
|
): Promise<{
|
||||||
codexConfig: CodexConfig | undefined;
|
codexConfig: CodexConfig | undefined;
|
||||||
geminiConfig: GeminiConfig | undefined;
|
geminiConfig: GeminiConfig | undefined;
|
||||||
@@ -636,6 +639,7 @@ export async function _clampExternalCliBypassForOwner(
|
|||||||
piConfig: PiConfig | undefined;
|
piConfig: PiConfig | undefined;
|
||||||
grokConfig: GrokConfig | undefined;
|
grokConfig: GrokConfig | undefined;
|
||||||
deepSeekConfig: DeepSeekConfig | undefined;
|
deepSeekConfig: DeepSeekConfig | undefined;
|
||||||
|
copilotConfig: CopilotConfig | undefined;
|
||||||
}> {
|
}> {
|
||||||
const out = await clampExternalCliBypassForOwner(owner, {
|
const out = await clampExternalCliBypassForOwner(owner, {
|
||||||
codexConfig,
|
codexConfig,
|
||||||
@@ -644,6 +648,7 @@ export async function _clampExternalCliBypassForOwner(
|
|||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
|
copilotConfig,
|
||||||
});
|
});
|
||||||
return out as {
|
return out as {
|
||||||
codexConfig: CodexConfig | undefined;
|
codexConfig: CodexConfig | undefined;
|
||||||
@@ -652,6 +657,7 @@ export async function _clampExternalCliBypassForOwner(
|
|||||||
piConfig: PiConfig | undefined;
|
piConfig: PiConfig | undefined;
|
||||||
grokConfig: GrokConfig | undefined;
|
grokConfig: GrokConfig | undefined;
|
||||||
deepSeekConfig: DeepSeekConfig | undefined;
|
deepSeekConfig: DeepSeekConfig | undefined;
|
||||||
|
copilotConfig: CopilotConfig | undefined;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1365,6 +1371,7 @@ export function registerSessionRoutes(
|
|||||||
piConfig: gatedPiConfig,
|
piConfig: gatedPiConfig,
|
||||||
grokConfig: gatedGrokConfig,
|
grokConfig: gatedGrokConfig,
|
||||||
deepSeekConfig: gatedDeepSeekConfig,
|
deepSeekConfig: gatedDeepSeekConfig,
|
||||||
|
copilotConfig: gatedCopilotConfig,
|
||||||
} = await _clampExternalCliBypassForOwner(
|
} = await _clampExternalCliBypassForOwner(
|
||||||
owner,
|
owner,
|
||||||
launchBody.codexConfig,
|
launchBody.codexConfig,
|
||||||
@@ -1372,7 +1379,8 @@ export function registerSessionRoutes(
|
|||||||
launchBody.antigravityConfig,
|
launchBody.antigravityConfig,
|
||||||
launchBody.piConfig,
|
launchBody.piConfig,
|
||||||
launchBody.grokConfig,
|
launchBody.grokConfig,
|
||||||
launchBody.deepSeekConfig
|
launchBody.deepSeekConfig,
|
||||||
|
launchBody.copilotConfig
|
||||||
);
|
);
|
||||||
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
@@ -1391,6 +1399,7 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
|
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
|
||||||
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
||||||
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
||||||
|
copilotConfig: mode === 'copilot' ? gatedCopilotConfig : 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,
|
||||||
@@ -2827,7 +2836,14 @@ export function registerSessionRoutes(
|
|||||||
// where a real rollout file is the better source.
|
// where a real rollout file is the better source.
|
||||||
if (isExternalCliTranscriptMode(session.mode)) {
|
if (isExternalCliTranscriptMode(session.mode)) {
|
||||||
const externalQuery = req.query as { context?: string };
|
const externalQuery = req.query as { context?: string };
|
||||||
const blocks = parseExternalCliTranscript(session.terminalBuffer, session.mode);
|
// A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the
|
||||||
|
// buffer cannot be segmented; read the pane's visible screen instead, and fall back to the
|
||||||
|
// buffer when there is no tmux pane to ask (direct PTY, tests).
|
||||||
|
const screen =
|
||||||
|
transcriptNeedsScreenCapture(session.mode) && session.muxName
|
||||||
|
? (ctx.mux.capturePaneText?.(session.muxName) ?? null)
|
||||||
|
: null;
|
||||||
|
const blocks = parseExternalCliTranscript(screen ?? session.terminalBuffer, session.mode);
|
||||||
return {
|
return {
|
||||||
text: getLastTranscriptResponse(blocks),
|
text: getLastTranscriptResponse(blocks),
|
||||||
timestamp: '',
|
timestamp: '',
|
||||||
@@ -3742,6 +3758,7 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
|
copilotConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
ompConfig,
|
ompConfig,
|
||||||
envOverrides,
|
envOverrides,
|
||||||
@@ -3813,6 +3830,7 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig ||
|
antigravityConfig ||
|
||||||
piConfig ||
|
piConfig ||
|
||||||
grokConfig ||
|
grokConfig ||
|
||||||
|
copilotConfig ||
|
||||||
deepSeekConfig ||
|
deepSeekConfig ||
|
||||||
ompConfig ||
|
ompConfig ||
|
||||||
openCodeConfig ||
|
openCodeConfig ||
|
||||||
@@ -3883,6 +3901,7 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig ||
|
antigravityConfig ||
|
||||||
piConfig ||
|
piConfig ||
|
||||||
grokConfig ||
|
grokConfig ||
|
||||||
|
copilotConfig ||
|
||||||
deepSeekConfig ||
|
deepSeekConfig ||
|
||||||
ompConfig ||
|
ompConfig ||
|
||||||
openCodeConfig ||
|
openCodeConfig ||
|
||||||
@@ -3983,6 +4002,7 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
|
copilotConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
} as unknown as Record<string, unknown>)
|
} as unknown as Record<string, unknown>)
|
||||||
);
|
);
|
||||||
@@ -4185,6 +4205,7 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig,
|
antigravityConfig,
|
||||||
piConfig,
|
piConfig,
|
||||||
grokConfig,
|
grokConfig,
|
||||||
|
copilotConfig,
|
||||||
deepSeekConfig,
|
deepSeekConfig,
|
||||||
ompConfig,
|
ompConfig,
|
||||||
};
|
};
|
||||||
@@ -4214,6 +4235,7 @@ export function registerSessionRoutes(
|
|||||||
piConfig: qsGatedPiConfig,
|
piConfig: qsGatedPiConfig,
|
||||||
grokConfig: qsGatedGrokConfig,
|
grokConfig: qsGatedGrokConfig,
|
||||||
deepSeekConfig: qsGatedDeepSeekConfig,
|
deepSeekConfig: qsGatedDeepSeekConfig,
|
||||||
|
copilotConfig: qsGatedCopilotConfig,
|
||||||
} = await _clampExternalCliBypassForOwner(
|
} = await _clampExternalCliBypassForOwner(
|
||||||
owner,
|
owner,
|
||||||
qsLaunchConfigs.codexConfig,
|
qsLaunchConfigs.codexConfig,
|
||||||
@@ -4221,7 +4243,8 @@ export function registerSessionRoutes(
|
|||||||
qsLaunchConfigs.antigravityConfig,
|
qsLaunchConfigs.antigravityConfig,
|
||||||
qsLaunchConfigs.piConfig,
|
qsLaunchConfigs.piConfig,
|
||||||
qsLaunchConfigs.grokConfig,
|
qsLaunchConfigs.grokConfig,
|
||||||
qsLaunchConfigs.deepSeekConfig
|
qsLaunchConfigs.deepSeekConfig,
|
||||||
|
qsLaunchConfigs.copilotConfig
|
||||||
);
|
);
|
||||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||||
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
|
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
|
||||||
@@ -4388,6 +4411,7 @@ export function registerSessionRoutes(
|
|||||||
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
|
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
|
||||||
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
||||||
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
||||||
|
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
|
||||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||||
ompConfig: qsResolvedOmpConfig,
|
ompConfig: qsResolvedOmpConfig,
|
||||||
envOverrides: qsCustomModelEnvOverrides,
|
envOverrides: qsCustomModelEnvOverrides,
|
||||||
|
|||||||
@@ -762,6 +762,20 @@ export function registerSystemRoutes(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========== GitHub Copilot ==========
|
||||||
|
|
||||||
|
// Carries `version` like pi/grok/omp: `copilot` is a generic binary name, so the resolver
|
||||||
|
// only accepts one whose `--version` says "GitHub Copilot CLI".
|
||||||
|
app.get('/api/copilot/status', async () => {
|
||||||
|
const { isCopilotAvailable, resolveCopilotDir, getCopilotCliVersion } =
|
||||||
|
await import('../../utils/copilot-cli-resolver.js');
|
||||||
|
return {
|
||||||
|
available: isCopilotAvailable(),
|
||||||
|
path: resolveCopilotDir(),
|
||||||
|
version: getCopilotCliVersion(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// State & Lifecycle (cleanup, lifecycle log, stats)
|
// State & Lifecycle (cleanup, lifecycle log, stats)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
+26
-2
@@ -194,7 +194,7 @@ function sessionModeSchema(): z.ZodType<SessionMode> {
|
|||||||
/**
|
/**
|
||||||
* Allowlisted env var key prefixes, contributed by the ENABLED CLIs in the registry
|
* Allowlisted env var key prefixes, contributed by the ENABLED CLIs in the registry
|
||||||
* (`env.allowedPrefixes`) — `CLAUDE_CODE_`, `OPENCODE_`, `CODEX_`, `GEMINI_`, `GOOGLE_`,
|
* (`env.allowedPrefixes`) — `CLAUDE_CODE_`, `OPENCODE_`, `CODEX_`, `GEMINI_`, `GOOGLE_`,
|
||||||
* `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_` as shipped.
|
* `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_`, `OMP_`, `COPILOT_` as shipped.
|
||||||
*
|
*
|
||||||
* ⚠️ Resolved AT PARSE TIME, not at module load. This used to be a frozen array computed
|
* ⚠️ Resolved AT PARSE TIME, not at module load. This used to be a frozen array computed
|
||||||
* once when the module was imported, which meant a CLI enabled while the server was running
|
* once when the module was imported, which meant a CLI enabled while the server was running
|
||||||
@@ -259,7 +259,7 @@ const safeEnvOverridesSchema = z
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_* keys and CLAUDE_CONFIG_DIR are allowed.',
|
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_*, COPILOT_* keys and CLAUDE_CONFIG_DIR are allowed.',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -439,6 +439,28 @@ const GrokConfigSchema = z
|
|||||||
})
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
const CopilotConfigSchema = z
|
||||||
|
.object({
|
||||||
|
// A leading alphanumeric, like resumeSessionId below: a value like `--yolo` would otherwise be read
|
||||||
|
// as its own flag after `--model` (bypassing the allowAll clamp). `/` stays for provider/id.
|
||||||
|
model: z
|
||||||
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/)
|
||||||
|
.optional(),
|
||||||
|
allowAll: z.boolean().optional(),
|
||||||
|
continueSession: z.boolean().optional(),
|
||||||
|
// A single plain word with a leading alphanumeric: --resume also matches session NAMES, so
|
||||||
|
// `my-feature` is accepted, but a value like `--yolo` must not be, because --resume's value is
|
||||||
|
// optional and the next token would be read as its own flag (bypassing the allowAll clamp).
|
||||||
|
resumeSessionId: z
|
||||||
|
.string()
|
||||||
|
.max(100)
|
||||||
|
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schema for OMP CLI-specific configuration.
|
* Schema for OMP CLI-specific configuration.
|
||||||
*/
|
*/
|
||||||
@@ -596,6 +618,7 @@ export const CreateSessionSchema = z.object({
|
|||||||
antigravityConfig: AntigravityConfigSchema,
|
antigravityConfig: AntigravityConfigSchema,
|
||||||
piConfig: PiConfigSchema,
|
piConfig: PiConfigSchema,
|
||||||
grokConfig: GrokConfigSchema,
|
grokConfig: GrokConfigSchema,
|
||||||
|
copilotConfig: CopilotConfigSchema,
|
||||||
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) */
|
||||||
@@ -1195,6 +1218,7 @@ export const QuickStartSchema = z.object({
|
|||||||
antigravityConfig: AntigravityConfigSchema,
|
antigravityConfig: AntigravityConfigSchema,
|
||||||
piConfig: PiConfigSchema,
|
piConfig: PiConfigSchema,
|
||||||
grokConfig: GrokConfigSchema,
|
grokConfig: GrokConfigSchema,
|
||||||
|
copilotConfig: CopilotConfigSchema,
|
||||||
deepSeekConfig: DeepSeekConfigSchema,
|
deepSeekConfig: DeepSeekConfigSchema,
|
||||||
ompConfig: OmpConfigSchema,
|
ompConfig: OmpConfigSchema,
|
||||||
envOverrides: safeEnvOverridesSchema,
|
envOverrides: safeEnvOverridesSchema,
|
||||||
|
|||||||
@@ -3590,6 +3590,7 @@ export class WebServer extends EventEmitter {
|
|||||||
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
|
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
|
||||||
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
|
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
|
||||||
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
|
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
|
||||||
|
copilotConfig: muxSession.mode === 'copilot' ? savedState?.copilotConfig : 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,
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ describe('isMuxAltScreenOnlyStripMode', () => {
|
|||||||
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
|
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
|
||||||
}
|
}
|
||||||
// The full strip and the mouse strip already own their modes; never double-gate.
|
// The full strip and the mouse strip already own their modes; never double-gate.
|
||||||
for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) {
|
for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot'] as const) {
|
||||||
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
|
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -366,3 +366,35 @@ describe('strip decision table: live stream = replay, for every stock CLI', () =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GitHub Copilot CLI is the same shape as opencode: a full-screen TUI that turns mouse tracking
|
||||||
|
* on itself. Left in, xterm reports a plain drag to the TUI instead of selecting, so Auto Copy and
|
||||||
|
* copy-on-select did nothing (only Shift+drag selected). Same strip, same reason.
|
||||||
|
*/
|
||||||
|
describe('copilot: alt-screen + mouse DECSETs stripped, 3J kept', () => {
|
||||||
|
it('is a mouse-strip mode under tmux, and only there', () => {
|
||||||
|
expect(isMuxMouseStripMode('copilot', true)).toBe(true);
|
||||||
|
expect(isMuxMouseStripMode('copilot', false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops mouse tracking so xterm keeps local text selection, and keeps 3J', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
|
||||||
|
handleOutput(session, '\x1b[?1003h\x1b[?1006h\x1b[3JTUI\x1b[?1006l\x1b[?1003l');
|
||||||
|
expect(session.terminalBuffer).toBe('\x1b[3JTUI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('publishes cliMouseTracking so the browser can forward the wheel and hand-encode clicks', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
|
||||||
|
handleOutput(session, '\x1b[?1002h\x1b[?1006hTUI');
|
||||||
|
expect(session.toState().cliMouseTracking).toBe(true);
|
||||||
|
handleOutput(session, '\x1b[?1002l\x1b[?1006l');
|
||||||
|
expect(session.toState().cliMouseTracking).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a direct-PTY copilot pane untouched', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: false });
|
||||||
|
handleOutput(session, '\x1b[?1049h\x1b[?1002hTUI');
|
||||||
|
expect(session.terminalBuffer).toBe('\x1b[?1049h\x1b[?1002hTUI');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -506,6 +506,8 @@ describe('agent spawn', () => {
|
|||||||
it('takes the readiness mark from the CLI registry, not from a mode list', () => {
|
it('takes the readiness mark from the CLI registry, not from a mode list', () => {
|
||||||
expect(composerReadyMark('claude')).toBe('shift+tab');
|
expect(composerReadyMark('claude')).toBe('shift+tab');
|
||||||
expect(composerReadyMark('deepseek')).toBe('❯');
|
expect(composerReadyMark('deepseek')).toBe('❯');
|
||||||
|
// Copilot's trust dialog draws `❯ 1. Yes`, so its mark is the composer's footer hint instead.
|
||||||
|
expect(composerReadyMark('copilot')).toBe('/ commands · ? help');
|
||||||
expect(composerReadyMark('pi')).toBeUndefined();
|
expect(composerReadyMark('pi')).toBeUndefined();
|
||||||
expect(composerReadyMark('no-such-cli')).toBeUndefined();
|
expect(composerReadyMark('no-such-cli')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ describe('per-mode capability predicates', () => {
|
|||||||
['grok', true, false, false],
|
['grok', true, false, false],
|
||||||
['deepseek', true, false, true],
|
['deepseek', true, false, true],
|
||||||
['omp', true, false, false],
|
['omp', true, false, false],
|
||||||
|
['copilot', true, false, false],
|
||||||
] as Array<[SessionMode, boolean, boolean, boolean]>)(
|
] as Array<[SessionMode, boolean, boolean, boolean]>)(
|
||||||
'%s: external=%s altScreenStrip=%s hooks=%s',
|
'%s: external=%s altScreenStrip=%s hooks=%s',
|
||||||
(mode, external, altScreen, hooks) => {
|
(mode, external, altScreen, hooks) => {
|
||||||
@@ -48,7 +49,7 @@ describe('per-mode capability predicates', () => {
|
|||||||
it('covers every enabled mode (sanity)', () => {
|
it('covers every enabled mode (sanity)', () => {
|
||||||
// If a CLI is added without a row above, this fails rather than the table silently
|
// If a CLI is added without a row above, this fails rather than the table silently
|
||||||
// describing a subset of reality.
|
// describing a subset of reality.
|
||||||
expect(MODES.length).toBe(10);
|
expect(MODES.length).toBe(11);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the three predicates genuinely distinct', () => {
|
it('keeps the three predicates genuinely distinct', () => {
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
|
|||||||
"web/routes/session-routes.ts::mode === 'antigravity'": allow(2, 'legacy <Mode>Config plumbing'),
|
"web/routes/session-routes.ts::mode === 'antigravity'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||||
"web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'),
|
"web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||||
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
|
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
|
||||||
|
"web/routes/session-routes.ts::mode === '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/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)'),
|
||||||
@@ -102,6 +103,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
|
|||||||
"web/server.ts::mode === 'antigravity'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
"web/server.ts::mode === 'antigravity'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||||
"web/server.ts::mode === 'pi'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
"web/server.ts::mode === 'pi'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||||
"web/server.ts::mode === 'grok'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
"web/server.ts::mode === 'grok'": 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)'),
|
||||||
|
|
||||||
|
|||||||
@@ -485,10 +485,23 @@ describe('capability shapes', () => {
|
|||||||
}, 'transcriptPageKeys is a boolean');
|
}, 'transcriptPageKeys is a boolean');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
|
||||||
|
// claude forwards from 2.1.187 (fullscreen renderer only); copilot forwards at any version while
|
||||||
|
// it tracks the mouse. codex ignores SGR wheel reports, so every other entry stays 'never'.
|
||||||
|
const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward;
|
||||||
|
expect(mode('copilot')).toEqual({ mode: 'always' });
|
||||||
|
expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' });
|
||||||
|
const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map(
|
||||||
|
(e) => e.id as string
|
||||||
|
);
|
||||||
|
expect(forwarding.sort()).toEqual(['claude', 'copilot']);
|
||||||
|
expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
|
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
|
||||||
// claude (repaint mode), codex and opencode (both on the alternate screen) were
|
// claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were
|
||||||
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
|
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
|
||||||
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
|
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
|
||||||
expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']);
|
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -320,3 +320,72 @@ describe('unsafe values are DROPPED, never escaped into the command', () => {
|
|||||||
expect(cmd).not.toContain('..');
|
expect(cmd).not.toContain('..');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('copilot', () => {
|
||||||
|
const cp = (copilotConfig?: SpawnBridgeOptions['copilotConfig']) =>
|
||||||
|
render({ mode: 'copilot' as never, sessionId: SID, copilotConfig });
|
||||||
|
|
||||||
|
it("spawns bare by default, in Copilot's own Manual Approval mode", () => {
|
||||||
|
expect(cp()).toBe('copilot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits --yolo only when asked, with the model', () => {
|
||||||
|
expect(cp({ allowAll: true, model: 'claude-sonnet-5.5' })).toBe('copilot --yolo --model claude-sonnet-5.5');
|
||||||
|
expect(cp({ allowAll: false })).toBe('copilot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pins the tab's name as --name", () => {
|
||||||
|
expect(render({ mode: 'copilot' as never, sessionId: SID, sessionName: 'w4-Codeman' } as never)).toBe(
|
||||||
|
'copilot --name "w4-Codeman"'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops --name next to --resume and --continue, which copilot refuses to combine', () => {
|
||||||
|
const named = (copilotConfig: SpawnBridgeOptions['copilotConfig']) =>
|
||||||
|
render({ mode: 'copilot' as never, sessionId: SID, sessionName: 'w4-Codeman', copilotConfig } as never);
|
||||||
|
expect(named({ resumeSessionId: '0c02c30f' })).toBe('copilot --resume 0c02c30f');
|
||||||
|
expect(named({ continueSession: true })).toBe('copilot --continue');
|
||||||
|
expect(named({ allowAll: true, model: 'auto' })).toBe('copilot --yolo --name "w4-Codeman" --model auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers an explicit resume id over --continue', () => {
|
||||||
|
expect(cp({ resumeSessionId: '0c02c30f-3383-4139-8b52-b28abad2ad43' })).toBe(
|
||||||
|
'copilot --resume 0c02c30f-3383-4139-8b52-b28abad2ad43'
|
||||||
|
);
|
||||||
|
expect(cp({ continueSession: true })).toBe('copilot --continue');
|
||||||
|
expect(cp({ continueSession: true, resumeSessionId: '0c02c30f' })).toBe('copilot --resume 0c02c30f');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops unsafe values rather than escaping them', () => {
|
||||||
|
expect(cp({ model: 'a`b' })).toBe('copilot');
|
||||||
|
expect(cp({ resumeSessionId: 'my session; rm -rf /' })).toBe('copilot');
|
||||||
|
});
|
||||||
|
|
||||||
|
// `--resume`'s value is optional, so `--resume --yolo` would make `--yolo` its own flag and get
|
||||||
|
// around the allowAll clamp. A resume value must start with an alphanumeric.
|
||||||
|
it('drops a resume value that would be read as a flag', () => {
|
||||||
|
for (const resumeSessionId of ['--yolo', '-n', '--allow-all-tools', '.', '..', '.hidden']) {
|
||||||
|
expect(cp({ resumeSessionId })).toBe('copilot');
|
||||||
|
}
|
||||||
|
expect(cp({ allowAll: false, resumeSessionId: '--yolo' })).toBe('copilot');
|
||||||
|
// A plain word is still passed through (a session NAME also resumes).
|
||||||
|
expect(cp({ resumeSessionId: 'my-feature' })).toBe('copilot --resume my-feature');
|
||||||
|
});
|
||||||
|
|
||||||
|
// `--model --yolo` would make `--yolo` its own flag and get around the allowAll clamp, so a model id
|
||||||
|
// starts with an alphanumeric too. A provider/id value (the `/`) is still accepted.
|
||||||
|
it('drops a model value that would be read as a flag', () => {
|
||||||
|
for (const model of ['--yolo', '-n', '--allow-all-tools', '.', '/etc/passwd']) {
|
||||||
|
expect(cp({ model })).toBe('copilot');
|
||||||
|
expect(cp({ allowAll: false, model })).toBe('copilot');
|
||||||
|
}
|
||||||
|
expect(cp({ model: 'claude-sonnet-5.5' })).toBe('copilot --model claude-sonnet-5.5');
|
||||||
|
expect(cp({ model: 'openai/gpt-5' })).toBe('copilot --model openai/gpt-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never puts a credential on the command line', () => {
|
||||||
|
const cmd = cp({ allowAll: true, model: 'auto' }) ?? '';
|
||||||
|
expect(cmd).not.toContain('token');
|
||||||
|
expect(cmd).not.toContain('key');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for the GitHub Copilot CLI resolver wrapper.
|
||||||
|
*
|
||||||
|
* Copilot is a resolver with a version probe: `copilot` is a generic binary name, so a
|
||||||
|
* resolved path is only accepted once `copilot --version` prints a
|
||||||
|
* `GitHub Copilot CLI <semver>` string (e.g. `GitHub Copilot CLI 1.0.94.`). The probe EXECUTES the candidate, which is
|
||||||
|
* exactly why it must never run under vitest — the hermeticity test below pins
|
||||||
|
* that gate with a real executable fixture that would make the test fail
|
||||||
|
* loudly if the gate were deleted again.
|
||||||
|
*/
|
||||||
|
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createCopilotResolverForTest } from '../src/utils/copilot-cli-resolver.js';
|
||||||
|
import {
|
||||||
|
cliResolveRetryDelayMs,
|
||||||
|
createProductionCliResolverHost,
|
||||||
|
type CliResolverHost,
|
||||||
|
} from '../src/utils/cli-executable-resolver.js';
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const directory of temporaryDirectories.splice(0)) {
|
||||||
|
rmSync(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function createHost(
|
||||||
|
options: {
|
||||||
|
processPathResult?: string | null;
|
||||||
|
loginShellResults?: Array<string | null>;
|
||||||
|
existingPaths?: string[];
|
||||||
|
} = {}
|
||||||
|
): CliResolverHost {
|
||||||
|
const loginShellResults = [...(options.loginShellResults ?? [])];
|
||||||
|
const existingPaths = new Set(options.existingPaths ?? []);
|
||||||
|
return {
|
||||||
|
processPath: '/service/bin',
|
||||||
|
shellPath: '/bin/zsh',
|
||||||
|
shellArgs: ['-l'],
|
||||||
|
findOnProcessPath: () => options.processPathResult ?? null,
|
||||||
|
findInLoginShell: () => loginShellResults.shift() ?? null,
|
||||||
|
exists: (path) => existingPaths.has(path),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GitHub Copilot CLI resolver', () => {
|
||||||
|
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
|
||||||
|
const binaryPath = '/service/bin/copilot';
|
||||||
|
const probe = vi.fn(() => '1.0.94');
|
||||||
|
const resolver = createCopilotResolverForTest(
|
||||||
|
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
|
||||||
|
probe
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({
|
||||||
|
binaryPath,
|
||||||
|
directory: '/service/bin',
|
||||||
|
source: 'process-path',
|
||||||
|
metadata: '1.0.94',
|
||||||
|
});
|
||||||
|
expect(probe).toHaveBeenCalledWith(binaryPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a candidate the probe refuses and falls through to a later one', () => {
|
||||||
|
// An unrelated `copilot` on the service PATH (probe returns null) must not mask
|
||||||
|
// the real coding agent found by the login shell.
|
||||||
|
const impostor = '/service/bin/copilot';
|
||||||
|
const genuine = '/login-shell/bin/copilot';
|
||||||
|
const probe = vi.fn((binPath: string) => (binPath === genuine ? '1.0.94' : null));
|
||||||
|
const resolver = createCopilotResolverForTest(
|
||||||
|
createHost({
|
||||||
|
processPathResult: impostor,
|
||||||
|
loginShellResults: [genuine],
|
||||||
|
existingPaths: [impostor, genuine],
|
||||||
|
}),
|
||||||
|
probe
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '1.0.94' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('negative-caches a miss and retries only after the backoff elapses', () => {
|
||||||
|
const binaryPath = '/late/bin/copilot';
|
||||||
|
let now = 0;
|
||||||
|
const probe = vi.fn(() => '1.0.94');
|
||||||
|
const resolver = createCopilotResolverForTest(
|
||||||
|
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||||
|
probe,
|
||||||
|
() => now
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolver.resolve()).toBeNull();
|
||||||
|
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
|
||||||
|
expect(probe).not.toHaveBeenCalled();
|
||||||
|
now = cliResolveRetryDelayMs(1);
|
||||||
|
expect(resolver.resolve()?.metadata).toBe('1.0.94');
|
||||||
|
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never executes a copilot candidate under vitest (the ambient probe is VITEST-gated)', () => {
|
||||||
|
// A REAL executable fixture that prints a valid version. If the guard in
|
||||||
|
// probeCopilotVersion is ever removed again, the probe runs this script, the
|
||||||
|
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
|
||||||
|
// behavior rather than by source text. (The suites must never execute
|
||||||
|
// whatever `copilot` binary the machine running them happens to carry.)
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'codeman-copilot-vitest-gate-'));
|
||||||
|
temporaryDirectories.push(root);
|
||||||
|
const binaryPath = join(root, 'copilot');
|
||||||
|
writeFileSync(binaryPath, '#!/bin/sh\necho GitHub Copilot CLI 0.99.0.\n');
|
||||||
|
chmodSync(binaryPath, 0o755);
|
||||||
|
const hostOptions = {
|
||||||
|
processPath: root,
|
||||||
|
shellPath: '/bin/bash',
|
||||||
|
shellArgs: ['-i', '-l'] as string[],
|
||||||
|
runCommand: () => '',
|
||||||
|
isExecutableFile: (path: string) => path === binaryPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default (ambient) probe: the candidate is found but never executed, so
|
||||||
|
// the VITEST gate reports it unusable and resolution misses.
|
||||||
|
const gated = createCopilotResolverForTest(createProductionCliResolverHost(hostOptions));
|
||||||
|
expect(gated.resolve()).toBeNull();
|
||||||
|
|
||||||
|
// Control: identical setup with an injected probe resolves, proving the
|
||||||
|
// null above comes from the gate, not from the fixture or the host.
|
||||||
|
const control = createCopilotResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
|
||||||
|
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview copilotConfig on the session create body: `model` and `resumeSessionId` follow a flag
|
||||||
|
* (`--model`, `--resume`), so a value that starts with `-` would be read as its own flag and get
|
||||||
|
* around the allowAll clamp. Both need a leading letter or digit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { CreateSessionSchema } from '../src/web/schemas.js';
|
||||||
|
|
||||||
|
const parse = (copilotConfig: unknown) =>
|
||||||
|
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'copilot', copilotConfig });
|
||||||
|
|
||||||
|
describe('copilotConfig schema', () => {
|
||||||
|
it.each(['--yolo', '-n', '--allow-all-tools', '.hidden', '/etc/passwd'])('rejects model %s', (model) => {
|
||||||
|
expect(parse({ model }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['--yolo', '-n', '--allow-all-tools'])('rejects resumeSessionId %s', (resumeSessionId) => {
|
||||||
|
expect(parse({ resumeSessionId }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['auto', 'claude-sonnet-5.5', 'openai/gpt-5'])('accepts model %s', (model) => {
|
||||||
|
expect(parse({ model }).success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
|
||||||
|
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||||
|
import { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
|
||||||
|
import { CreateSessionSchema } from '../src/web/schemas.js';
|
||||||
|
|
||||||
|
const copilot = STOCK_CLIS.find((c) => (c.id as string) === 'copilot')!;
|
||||||
|
|
||||||
|
describe('GitHub Copilot custom endpoint (BYOK)', () => {
|
||||||
|
it('injects its COPILOT_PROVIDER_* variables, with the /v1 suffix an OpenAI route needs', () => {
|
||||||
|
expect(copilot.capabilities.customModelInjection).toEqual({
|
||||||
|
kind: 'env',
|
||||||
|
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
|
||||||
|
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
|
||||||
|
modelVars: ['COPILOT_MODEL'],
|
||||||
|
appendV1Suffix: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every injected variable privileged, so a non-granted owner cannot set them by hand', () => {
|
||||||
|
const injection = copilot.capabilities.customModelInjection;
|
||||||
|
if (injection.kind !== 'env') throw new Error('expected env injection');
|
||||||
|
for (const name of [injection.baseUrlVar, injection.apiKeyVar, ...injection.modelVars]) {
|
||||||
|
expect(copilot.capabilities.privilegedEnvKeys).toContain(name);
|
||||||
|
expect(name.startsWith('COPILOT_')).toBe(true); // reachable through the prefix allowlist
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GitHub Copilot environment and sync declarations', () => {
|
||||||
|
it('admits only its own COPILOT_ namespace, never the shared GitHub token names', () => {
|
||||||
|
// The env allowlist is one global list with no mode context, so a foreign key here would be
|
||||||
|
// settable on every session. COPILOT_GITHUB_TOKEN is already reachable through the prefix.
|
||||||
|
expect(copilot.env.allowedPrefixes).toEqual(['COPILOT_']);
|
||||||
|
expect(copilot.env.allowedKeys).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares its own MCP config so Settings does not list it as unsupported', () => {
|
||||||
|
expect(copilot.capabilities.mcpConfig).toEqual({
|
||||||
|
path: '.copilot/mcp-config.json',
|
||||||
|
format: 'copilot-json',
|
||||||
|
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds no credentials into a Docker case', () => {
|
||||||
|
expect(copilot.overlays?.credStore).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GitHub Copilot multi-user clamp: the env-var half', () => {
|
||||||
|
// COPILOT_ is an allowlisted prefix, so every variable below would otherwise pass the schema.
|
||||||
|
// Each one hands back what the allowAll clamp removed: an approval switch, code that runs with no
|
||||||
|
// approval row (a CLI build, an editor, a key command, skills, hooks), or the sign-in token sent to
|
||||||
|
// a host of the caller's choosing.
|
||||||
|
const DANGEROUS: Record<string, string> = {
|
||||||
|
COPILOT_ALLOW_ALL: 'true',
|
||||||
|
COPILOT_PLAN_THEN_AUTOPILOT: '1',
|
||||||
|
COPILOT_HOME: '/tmp/planted-copilot-home',
|
||||||
|
COPILOT_CLI_DIST_DIR: '/tmp/planted-dist',
|
||||||
|
COPILOT_PKG_CACHE_HOME: '/tmp/planted-cache',
|
||||||
|
COPILOT_CACHE_HOME: '/tmp/planted-cache',
|
||||||
|
COPILOT_EDITOR: 'sh -c planted',
|
||||||
|
COPILOT_PROVIDER_API_KEY_COMMAND: 'sh -c planted',
|
||||||
|
COPILOT_SKILLS_DIRS: '/tmp/planted-skills',
|
||||||
|
COPILOT_PROVIDERS_CONFIG: '/tmp/planted-providers.json',
|
||||||
|
COPILOT_API_URL: 'https://attacker.example',
|
||||||
|
COPILOT_GH_HOST: 'attacker.example',
|
||||||
|
COPILOT_PROVIDER_BASE_URL: 'https://attacker.example/v1',
|
||||||
|
COPILOT_PROVIDER_HEADERS: 'X-Planted: 1',
|
||||||
|
};
|
||||||
|
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.CODEMAN_MULTIUSER = '1';
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||||
|
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the env allowlist, so only the clamp stands between these and the pane', () => {
|
||||||
|
expect(() =>
|
||||||
|
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: DANGEROUS })
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips every one of them from a non-granted owner's overrides and keeps the harmless knobs", async () => {
|
||||||
|
const out = await _clampEnvOverridesForOwner('nobody', {
|
||||||
|
...DANGEROUS,
|
||||||
|
COPILOT_GITHUB_TOKEN: 'own-token',
|
||||||
|
COPILOT_AUTO_UPDATE: 'false',
|
||||||
|
});
|
||||||
|
expect(out).toEqual({ COPILOT_GITHUB_TOKEN: 'own-token', COPILOT_AUTO_UPDATE: 'false' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -128,7 +128,7 @@ describe('docker server image divergence is declared, not accidental', () => {
|
|||||||
// untouched by this change because two other open PRs already modify it. Asserting the
|
// untouched by this change because two other open PRs already modify it. Asserting the
|
||||||
// omissions here makes the divergence reviewable without editing the file: if someone adds
|
// omissions here makes the divergence reviewable without editing the file: if someone adds
|
||||||
// a CLI there, or the intent changes, this fails and the list has to be restated.
|
// a CLI there, or the intent changes, this fails and the list has to be restated.
|
||||||
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp']);
|
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot']);
|
||||||
|
|
||||||
it('installs exactly the CLIs it declares, and no more', () => {
|
it('installs exactly the CLIs it declares, and no more', () => {
|
||||||
for (const entry of enabledAgents) {
|
for (const entry of enabledAgents) {
|
||||||
|
|||||||
@@ -51,6 +51,17 @@ describe('envOverrides exact-key allowlist', () => {
|
|||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects the shared GitHub token names (Copilot reads COPILOT_GITHUB_TOKEN, in its own prefix)', () => {
|
||||||
|
for (const key of ['GH_TOKEN', 'GITHUB_TOKEN']) {
|
||||||
|
for (const mode of ['claude', 'shell', 'codex', 'copilot'] as const) {
|
||||||
|
expect(() => CreateSessionSchema.parse({ workingDir: '/tmp', mode, envOverrides: { [key]: 'x' } })).toThrow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(() =>
|
||||||
|
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: { COPILOT_GITHUB_TOKEN: 'x' } })
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it('still blocks security-sensitive keys', () => {
|
it('still blocks security-sensitive keys', () => {
|
||||||
for (const key of ['PATH', 'LD_PRELOAD', 'NODE_OPTIONS', 'CODEMAN_MUX_NAME']) {
|
for (const key of ['PATH', 'LD_PRELOAD', 'NODE_OPTIONS', 'CODEMAN_MUX_NAME']) {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ const LITERAL_SEARCH_PATHS: Record<string, string[]> = {
|
|||||||
'$HOME/.npm-global/bin/omp',
|
'$HOME/.npm-global/bin/omp',
|
||||||
'$HOME/bin/omp',
|
'$HOME/bin/omp',
|
||||||
],
|
],
|
||||||
|
COPILOT: [
|
||||||
|
'$HOME/.local/bin/copilot',
|
||||||
|
'/usr/local/bin/copilot',
|
||||||
|
'$HOME/.bun/bin/copilot',
|
||||||
|
'$HOME/.npm-global/bin/copilot',
|
||||||
|
'$HOME/bin/copilot',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Array prefix in `install.sh` -> registry id, for the two that differ. */
|
/** Array prefix in `install.sh` -> registry id, for the two that differ. */
|
||||||
@@ -99,6 +106,7 @@ const ARRAY_PREFIX_TO_CLI_ID: Record<string, string> = {
|
|||||||
GROK: 'grok',
|
GROK: 'grok',
|
||||||
ANTIGRAVITY: 'antigravity',
|
ANTIGRAVITY: 'antigravity',
|
||||||
OMP: 'omp',
|
OMP: 'omp',
|
||||||
|
COPILOT: 'copilot',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ it('pins every remote pane command', () => {
|
|||||||
grok: remoteLoginShellCommand('grok'),
|
grok: remoteLoginShellCommand('grok'),
|
||||||
deepseek: remoteLoginShellCommand('dsh'),
|
deepseek: remoteLoginShellCommand('dsh'),
|
||||||
omp: remoteLoginShellCommand('omp'),
|
omp: remoteLoginShellCommand('omp'),
|
||||||
|
copilot: remoteLoginShellCommand('copilot'),
|
||||||
};
|
};
|
||||||
for (const [mode, want] of Object.entries(expected)) {
|
for (const [mode, want] of Object.entries(expected)) {
|
||||||
expect(defaultRemoteCommandForMode(mode as SessionMode), mode).toBe(want);
|
expect(defaultRemoteCommandForMode(mode as SessionMode), mode).toBe(want);
|
||||||
@@ -55,6 +56,7 @@ it('pins every in-container pane command', () => {
|
|||||||
grok: 'exec grok',
|
grok: 'exec grok',
|
||||||
deepseek: 'exec dsh',
|
deepseek: 'exec dsh',
|
||||||
omp: 'exec omp',
|
omp: 'exec omp',
|
||||||
|
copilot: 'exec copilot',
|
||||||
};
|
};
|
||||||
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);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function withMcp(mcpConfig: unknown) {
|
|||||||
describe('capabilities.mcpConfig', () => {
|
describe('capabilities.mcpConfig', () => {
|
||||||
it('is declared by exactly the CLIs whose format is verified', () => {
|
it('is declared by exactly the CLIs whose format is verified', () => {
|
||||||
const declared = STOCK_CLIS.filter((e) => e.capabilities.mcpConfig).map((e) => e.id as string);
|
const declared = STOCK_CLIS.filter((e) => e.capabilities.mcpConfig).map((e) => e.id as string);
|
||||||
expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'gemini', 'opencode']);
|
expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'copilot', 'gemini', 'opencode']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('every stock declaration passes the schema, with a distinct file per CLI', () => {
|
it('every stock declaration passes the schema, with a distinct file per CLI', () => {
|
||||||
@@ -41,6 +41,7 @@ describe('capabilities.mcpConfig', () => {
|
|||||||
claude: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
|
claude: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
|
||||||
opencode: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
|
opencode: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
|
||||||
codex: { envVar: 'CODEX_HOME', path: 'config.toml' },
|
codex: { envVar: 'CODEX_HOME', path: 'config.toml' },
|
||||||
|
copilot: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
|
||||||
gemini: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
|
gemini: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
|
||||||
antigravity: undefined,
|
antigravity: undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -469,6 +469,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
|
|||||||
'grok',
|
'grok',
|
||||||
'deepseek',
|
'deepseek',
|
||||||
'omp',
|
'omp',
|
||||||
|
'copilot',
|
||||||
'shell',
|
'shell',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
|
|||||||
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
|
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
|
||||||
import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js';
|
import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js';
|
||||||
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
|
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
|
||||||
|
import { isCopilotAvailable } from '../src/utils/copilot-cli-resolver.js';
|
||||||
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
||||||
import { isGitAvailable } from '../src/git-clone.js';
|
import { isGitAvailable } from '../src/git-clone.js';
|
||||||
import { enabledClis, reloadCliRegistry } from '../src/config/cli-registry/registry.js';
|
import { enabledClis, reloadCliRegistry } from '../src/config/cli-registry/registry.js';
|
||||||
@@ -76,6 +77,10 @@ vi.mock('../src/utils/omp-cli-resolver.js', () => ({
|
|||||||
isOmpAvailable: vi.fn(() => false),
|
isOmpAvailable: vi.fn(() => false),
|
||||||
resolveOmpDir: vi.fn(() => null),
|
resolveOmpDir: vi.fn(() => null),
|
||||||
}));
|
}));
|
||||||
|
vi.mock('../src/utils/copilot-cli-resolver.js', () => ({
|
||||||
|
isCopilotAvailable: vi.fn(() => false),
|
||||||
|
resolveCopilotDir: vi.fn(() => null),
|
||||||
|
}));
|
||||||
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
|
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
|
||||||
isCloudflaredAvailable: vi.fn(() => false),
|
isCloudflaredAvailable: vi.fn(() => false),
|
||||||
resolveCloudflaredPath: vi.fn(() => null),
|
resolveCloudflaredPath: vi.fn(() => null),
|
||||||
@@ -207,6 +212,7 @@ describe('WebServer.renderIndexHtml', () => {
|
|||||||
vi.mocked(isDeepSeekAvailable).mockReturnValue(false);
|
vi.mocked(isDeepSeekAvailable).mockReturnValue(false);
|
||||||
vi.mocked(isDeepSeekRunnable).mockReturnValue(false);
|
vi.mocked(isDeepSeekRunnable).mockReturnValue(false);
|
||||||
vi.mocked(isOmpAvailable).mockReturnValue(true);
|
vi.mocked(isOmpAvailable).mockReturnValue(true);
|
||||||
|
vi.mocked(isCopilotAvailable).mockReturnValue(true);
|
||||||
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
|
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
|
||||||
vi.mocked(isGitAvailable).mockReturnValue(true);
|
vi.mocked(isGitAvailable).mockReturnValue(true);
|
||||||
const { server } = makeServer({});
|
const { server } = makeServer({});
|
||||||
@@ -225,6 +231,7 @@ describe('WebServer.renderIndexHtml', () => {
|
|||||||
deepseek: false,
|
deepseek: false,
|
||||||
deepseekBinary: false,
|
deepseekBinary: false,
|
||||||
omp: true,
|
omp: true,
|
||||||
|
copilot: true,
|
||||||
cloudflared: true,
|
cloudflared: true,
|
||||||
git: true,
|
git: true,
|
||||||
shell: true,
|
shell: true,
|
||||||
@@ -365,6 +372,7 @@ describe('WebServer.renderIndexHtml', () => {
|
|||||||
isDeepSeekAvailable,
|
isDeepSeekAvailable,
|
||||||
isDeepSeekRunnable,
|
isDeepSeekRunnable,
|
||||||
isOmpAvailable,
|
isOmpAvailable,
|
||||||
|
isCopilotAvailable,
|
||||||
isCloudflaredAvailable,
|
isCloudflaredAvailable,
|
||||||
isGitAvailable,
|
isGitAvailable,
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -460,3 +460,204 @@ Here is the assistant answer at column zero.
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GitHub Copilot pane dialect', () => {
|
||||||
|
// Captured from a real Copilot 1.0.95 pane inside tmux (a throwaway COPILOT_HOME, a local
|
||||||
|
// OpenAI-compatible endpoint): banner, two turns, status rows, composer between rules, footer.
|
||||||
|
const rule = '─'.repeat(100);
|
||||||
|
const pane = [
|
||||||
|
' ╭─╮╭─╮',
|
||||||
|
' ╰─╯╰─╯ Copilot v1.0.95 uses AI.',
|
||||||
|
' █ ▘▝ █ Check for mistakes.',
|
||||||
|
' ▔▔▔▔',
|
||||||
|
' ! Model "mock" is not in the built-in catalog. Using defaults for: prompt tokens',
|
||||||
|
' (COPILOT_PROVIDER_MAX_PROMPT_TOKENS), output tokens (COPILOT_PROVIDER_MAX_OUTPUT_TOKENS). Run copilot help',
|
||||||
|
' providers for configuration details.',
|
||||||
|
' ● Tip: /theme',
|
||||||
|
' └ View or set color mode',
|
||||||
|
' ❯ multi please 11:16',
|
||||||
|
' ● First line of the answer. ┃',
|
||||||
|
' ┃',
|
||||||
|
' Second paragraph with bold text. ┃',
|
||||||
|
' ┃',
|
||||||
|
' • bullet one ┃',
|
||||||
|
' • bullet two ┃',
|
||||||
|
' ❯ WORKDONE followed by _4711 11:17',
|
||||||
|
' ● WORKDONE_4711',
|
||||||
|
' ● MCP Servers reloaded: 1 server connected',
|
||||||
|
' /tmp/work/.../scratch-2 Session: credits unavailable',
|
||||||
|
rule,
|
||||||
|
'❯',
|
||||||
|
rule,
|
||||||
|
' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
it('is a transcript mode', () => {
|
||||||
|
expect(isExternalCliTranscriptMode('copilot')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the last answer, with no banner, status rows, composer or footer in it', () => {
|
||||||
|
const blocks = parseExternalCliTranscript(pane, 'copilot');
|
||||||
|
expect(getLastTranscriptResponse(blocks)).toBe('WORKDONE_4711');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a multi-paragraph answer whole and strips the prompt timestamp', () => {
|
||||||
|
const blocks = parseExternalCliTranscript(pane, 'copilot');
|
||||||
|
const prompts = blocks.filter((b) => b.kind === 'prompt').map((b) => b.text);
|
||||||
|
expect(prompts).toEqual(['multi please', 'WORKDONE followed by _4711']);
|
||||||
|
const first = blocks.find((b) => b.kind === 'response')!;
|
||||||
|
expect(first.text).toContain('First line of the answer.');
|
||||||
|
expect(first.text).toContain('Second paragraph with bold text.');
|
||||||
|
expect(first.text).toContain('• bullet two');
|
||||||
|
expect(first.text).not.toMatch(/Session:|open sidebar|Tip:/);
|
||||||
|
// The scrollbar gutter Copilot draws at the right edge of every row never reaches the text.
|
||||||
|
expect(first.text).not.toContain('┃');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The footer and composer every screen below ends with.
|
||||||
|
const tail = [
|
||||||
|
' /tmp/work/.../scratch-2 Session: credits unavailable',
|
||||||
|
rule,
|
||||||
|
'❯',
|
||||||
|
rule,
|
||||||
|
' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock',
|
||||||
|
];
|
||||||
|
|
||||||
|
it("keeps a shell prompt inside an answer's code block as answer text, not a new prompt", () => {
|
||||||
|
const screen = [
|
||||||
|
' ❯ how do I run the tests 11:20',
|
||||||
|
' ● Run this from the repo root:',
|
||||||
|
'',
|
||||||
|
' ❯ npm test',
|
||||||
|
'',
|
||||||
|
' Then read the summary line.',
|
||||||
|
...tail,
|
||||||
|
].join('\n');
|
||||||
|
const blocks = parseExternalCliTranscript(screen, 'copilot');
|
||||||
|
expect(blocks.filter((b) => b.kind === 'prompt').map((b) => b.text)).toEqual(['how do I run the tests']);
|
||||||
|
const answer = getLastTranscriptResponse(blocks);
|
||||||
|
expect(answer).toContain('Run this from the repo root:');
|
||||||
|
expect(answer).toContain('❯ npm test');
|
||||||
|
expect(answer).toContain('Then read the summary line.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a paragraph between two rules inside an answer, and treats only the last triple as the composer', () => {
|
||||||
|
const screen = [
|
||||||
|
' ❯ quote the passage 11:21',
|
||||||
|
' ● Here it is:',
|
||||||
|
rule,
|
||||||
|
' Quoted passage between two rules.',
|
||||||
|
rule,
|
||||||
|
...tail,
|
||||||
|
].join('\n');
|
||||||
|
expect(getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'))).toBe(
|
||||||
|
'Here it is:\n\n---\n\nQuoted passage between two rules.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an answer whole across a rule inside it, so agent read never returns only its tail', () => {
|
||||||
|
const screen = [
|
||||||
|
' ❯ summarize the change 11:22',
|
||||||
|
' ● Summary',
|
||||||
|
'',
|
||||||
|
' One line of summary.',
|
||||||
|
rule,
|
||||||
|
' Details',
|
||||||
|
'',
|
||||||
|
' The longer explanation.',
|
||||||
|
...tail,
|
||||||
|
].join('\n');
|
||||||
|
const blocks = parseExternalCliTranscript(screen, 'copilot');
|
||||||
|
expect(blocks.filter((b) => b.kind === 'response')).toHaveLength(1);
|
||||||
|
const answer = getLastTranscriptResponse(blocks);
|
||||||
|
expect(answer).toContain('Summary');
|
||||||
|
expect(answer).toContain('One line of summary.');
|
||||||
|
expect(answer).toContain('Details');
|
||||||
|
expect(answer).toContain('The longer explanation.');
|
||||||
|
// The rule before the footer separates the answer from the chrome and never trails it.
|
||||||
|
expect(answer.endsWith('---')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers nothing for a session that has only drawn its banner or a trust dialog', () => {
|
||||||
|
const banner = pane.split('\n').slice(0, 9).join('\n');
|
||||||
|
expect(getLastTranscriptResponse(parseExternalCliTranscript(banner, 'copilot'))).toBe('');
|
||||||
|
const trust = [
|
||||||
|
'│ Do you trust the files in this folder?',
|
||||||
|
'│ ❯ 1. Yes',
|
||||||
|
'│ 2. Yes, and remember this folder for future sessions',
|
||||||
|
].join('\n');
|
||||||
|
expect(getLastTranscriptResponse(parseExternalCliTranscript(trust, 'copilot'))).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not take the empty composer glyph for a prompt', () => {
|
||||||
|
const blocks = parseExternalCliTranscript(pane, 'copilot');
|
||||||
|
expect(blocks.some((b) => b.kind === 'prompt' && b.text === '')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The pane is all there is to read, so a long answer scrolls the prompt row (and the banner) off
|
||||||
|
// the top. That must read as the tail of the answer, not as "nothing yet".
|
||||||
|
describe('an answer taller than the pane', () => {
|
||||||
|
const rule = '─'.repeat(100);
|
||||||
|
const tail = (n: number) =>
|
||||||
|
Array.from({ length: n }, (_, i) => ` line ${String(i + 1).padStart(3, '0')} of the long answer`);
|
||||||
|
const footer = [
|
||||||
|
' /tmp/work/.../scratch-2 Session: 0 AIC used',
|
||||||
|
rule,
|
||||||
|
'❯',
|
||||||
|
rule,
|
||||||
|
' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock',
|
||||||
|
];
|
||||||
|
|
||||||
|
it('returns the visible rows when the prompt row has scrolled off', () => {
|
||||||
|
const screen = [...tail(35), ...footer].join('\n');
|
||||||
|
const text = getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'));
|
||||||
|
expect(text).toContain('line 001 of the long answer');
|
||||||
|
expect(text).toContain('line 035 of the long answer');
|
||||||
|
expect(text).not.toMatch(/Session:|open sidebar|commands/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still reads the tail when the answer has a leading bullet row on screen', () => {
|
||||||
|
const screen = [' ● the answer begins here', ...tail(20), ...footer].join('\n');
|
||||||
|
const text = getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'));
|
||||||
|
expect(text).toContain('the answer begins here');
|
||||||
|
expect(text).toContain('line 020 of the long answer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not fooled by text typed in the composer under the answer', () => {
|
||||||
|
const typed = [
|
||||||
|
...tail(30),
|
||||||
|
' /tmp/work/.../scratch-2 Session: 0 AIC used',
|
||||||
|
rule,
|
||||||
|
'❯ half a thought',
|
||||||
|
rule,
|
||||||
|
footer[4],
|
||||||
|
];
|
||||||
|
const text = getLastTranscriptResponse(parseExternalCliTranscript(typed.join('\n'), 'copilot'));
|
||||||
|
expect(text).toContain('line 030 of the long answer');
|
||||||
|
expect(text).not.toContain('half a thought');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still answers nothing while the banner or a trust dialog is on screen', () => {
|
||||||
|
const banner = [
|
||||||
|
' ╭─╮╭─╮',
|
||||||
|
' ╰─╯╰─╯ Copilot v1.0.95 uses AI.',
|
||||||
|
' ! Model "mock" is not in the built-in catalog.',
|
||||||
|
' /tmp/work Session: 0 AIC used',
|
||||||
|
rule,
|
||||||
|
'❯',
|
||||||
|
rule,
|
||||||
|
footer[4],
|
||||||
|
].join('\n');
|
||||||
|
expect(getLastTranscriptResponse(parseExternalCliTranscript(banner, 'copilot'))).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the prompt row when it is on screen', () => {
|
||||||
|
const screen = [
|
||||||
|
' ❯ do the thing 11:16',
|
||||||
|
' ● short answer',
|
||||||
|
...footer,
|
||||||
|
].join('\n');
|
||||||
|
expect(getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'))).toBe('short answer');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,6 +147,7 @@ describe('resumeHistorySession: row retirement is gated on actual continuation',
|
|||||||
['opencode', 'openCodeConfig'],
|
['opencode', 'openCodeConfig'],
|
||||||
['pi', 'piConfig'],
|
['pi', 'piConfig'],
|
||||||
['grok', 'grokConfig'],
|
['grok', 'grokConfig'],
|
||||||
|
['copilot', 'copilotConfig'],
|
||||||
['omp', 'ompConfig'],
|
['omp', 'ompConfig'],
|
||||||
])('retires the old row for %s (continueSession is wired via %s)', async (mode, configKey) => {
|
])('retires the old row for %s (continueSession is wired via %s)', async (mode, configKey) => {
|
||||||
const app = makeApp();
|
const app = makeApp();
|
||||||
|
|||||||
@@ -104,6 +104,25 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
|
|||||||
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
|
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forces a non-granted owner's copilot --yolo off and keeps the model", async () => {
|
||||||
|
const out = await _clampExternalCliBypassForOwner(
|
||||||
|
'peon',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ allowAll: true, model: 'claude-sonnet-5.5' }
|
||||||
|
);
|
||||||
|
expect(out.copilotConfig).toEqual({ allowAll: false, model: 'claude-sonnet-5.5' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves copilot absent when nothing was sent (a bare copilot is already Manual Approval)', async () => {
|
||||||
|
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined);
|
||||||
|
expect(out.copilotConfig).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a non-granted owner's codex reasoning effort while forcing bypass off", async () => {
|
it("keeps a non-granted owner's codex reasoning effort while forcing bypass off", async () => {
|
||||||
// The clamp rewrites one field and must carry the rest; a clamp rebuilt from named
|
// The clamp rewrites one field and must carry the rest; a clamp rebuilt from named
|
||||||
// fields would drop the effort here without a word.
|
// fields would drop the effort here without a word.
|
||||||
|
|||||||
@@ -14,9 +14,11 @@
|
|||||||
* block without it lost the "You" badge on prompts
|
* block without it lost the "You" badge on prompts
|
||||||
* - a pane that has produced no output reports hasContext: false rather than 404ing
|
* - a pane that has produced no output reports hasContext: false rather than 404ing
|
||||||
* - Claude mode still takes the Claude path (regression guard)
|
* - Claude mode still takes the Claude path (regression guard)
|
||||||
|
* - copilot (a repainting full-screen TUI) is read off the pane SCREEN via
|
||||||
|
* capturePaneText, and falls back to the buffer only when there is no screen
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
|
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
|
||||||
@@ -154,6 +156,65 @@ describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
|
|||||||
expect(body.data.hasContext).toBe(false);
|
expect(body.data.hasContext).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('copilot reads the pane screen, not the stream', () => {
|
||||||
|
const rule = '─'.repeat(80);
|
||||||
|
const screenOf = (answer: string) =>
|
||||||
|
[
|
||||||
|
' ❯ what changed 11:30',
|
||||||
|
` ● ${answer}`,
|
||||||
|
' /tmp/work Session: 0 AIC used',
|
||||||
|
rule,
|
||||||
|
'❯',
|
||||||
|
rule,
|
||||||
|
' ← open sidebar · Interactive · Manual Approval · / commands · ? help',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
function stubCapture(result: string | null) {
|
||||||
|
const capturePaneText = vi.fn((_muxName: string) => result);
|
||||||
|
(harness.ctx.mux as unknown as { capturePaneText: typeof capturePaneText }).capturePaneText = capturePaneText;
|
||||||
|
return capturePaneText;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('segments the captured screen of the session pane', async () => {
|
||||||
|
session.mode = 'copilot';
|
||||||
|
// What the stream looks like for a repainting TUI once escapes are stripped: rows fused
|
||||||
|
// together, so a reader of the buffer would return this run of text.
|
||||||
|
session.terminalBuffer = 'STREAM-ONLY fused rows that are not the answer';
|
||||||
|
const capture = stubCapture(screenOf('The screen answer.'));
|
||||||
|
|
||||||
|
const { res, body } = await lastResponse();
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(capture).toHaveBeenCalledWith(session.muxName);
|
||||||
|
expect(body.data.text).toBe('The screen answer.');
|
||||||
|
expect(body.data.text).not.toContain('STREAM-ONLY');
|
||||||
|
expect(body.data.hasContext).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the buffer when the pane has no screen to capture', async () => {
|
||||||
|
session.mode = 'copilot';
|
||||||
|
session.terminalBuffer = screenOf('The buffer answer.');
|
||||||
|
const capture = stubCapture(null);
|
||||||
|
|
||||||
|
const { res, body } = await lastResponse();
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(capture).toHaveBeenCalledWith(session.muxName);
|
||||||
|
expect(body.data.text).toBe('The buffer answer.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never captures the screen for a mode that streams lines', async () => {
|
||||||
|
session.mode = 'opencode';
|
||||||
|
session.terminalBuffer = PANE;
|
||||||
|
const capture = stubCapture(screenOf('must not be read'));
|
||||||
|
|
||||||
|
const { body } = await lastResponse();
|
||||||
|
|
||||||
|
expect(capture).not.toHaveBeenCalled();
|
||||||
|
expect(body.data.text).toContain('Documented the helper in docs/retry.md');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
|
||||||
|
|
||||||
|
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
|
||||||
|
|
||||||
|
// An agent CLI whose Run button has no `mode-<id>` rule falls back to the default button
|
||||||
|
// colours, so it reads as a different button from every other CLI's. The Run button, its
|
||||||
|
// gear, the phone toolbar and the light skins each carry their own rule.
|
||||||
|
const AGENT_IDS = STOCK_CLIS.filter((c) => c.kind === 'agent').map((c) => c.id as string);
|
||||||
|
|
||||||
|
describe('Run button colours for every stock agent CLI', () => {
|
||||||
|
it('lists agent CLIs (anti-vacuity)', () => {
|
||||||
|
expect(AGENT_IDS).toContain('copilot');
|
||||||
|
expect(AGENT_IDS.length).toBeGreaterThan(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const id of AGENT_IDS) {
|
||||||
|
it(`${id} has a Run button rule`, () => {
|
||||||
|
expect(styles, `${id} has no .btn-run.mode-${id} rule in styles.css`).toContain(
|
||||||
|
`.btn-toolbar.btn-run.mode-${id}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -303,6 +303,31 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
|
|||||||
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]);
|
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pages a Copilot pane when the tracking flag is stale, so the wheel never goes dead', () => {
|
||||||
|
// Copilot pages its own transcript on PageUp/PageDown (three PageUp moved lines 109-140 to
|
||||||
|
// 010-042 against a 140-line answer in tmux, and PageDown returned). While it tracks the mouse
|
||||||
|
// the wheel is forwarded as SGR reports; with the flag stale (a server restart) this is the
|
||||||
|
// gesture's fallback, on an alternate-screen TUI whose local buffer is one screen.
|
||||||
|
const { app, sent } = hollowApp({ mode: 'copilot', cliMouseTracking: false });
|
||||||
|
|
||||||
|
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||||
|
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
|
||||||
|
app._flushWheelSgrQueue();
|
||||||
|
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
|
||||||
|
|
||||||
|
app._maybePageCliTranscript({ shiftKey: false }, 18);
|
||||||
|
app._flushWheelSgrQueue();
|
||||||
|
expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not page a Copilot pane that has real local scrollback', () => {
|
||||||
|
const { app, sent } = hollowApp({ mode: 'copilot' });
|
||||||
|
app.terminal.buffer.active.baseY = 200;
|
||||||
|
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
|
||||||
|
app._flushWheelSgrQueue();
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('pages an OpenCode pane too, whose TUI never fills the local buffer', () => {
|
it('pages an OpenCode pane too, whose TUI never fills the local buffer', () => {
|
||||||
// OpenCode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux
|
// OpenCode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux
|
||||||
// `alternate_on=1`, `history_size=0`), so the browser's normal buffer stays at
|
// `alternate_on=1`, `history_size=0`), so the browser's normal buffer stays at
|
||||||
@@ -707,6 +732,29 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GitHub Copilot wheel forwarding', () => {
|
||||||
|
// Copilot's mouse DECSETs are stripped so a drag selects locally, which stops xterm from
|
||||||
|
// encoding the wheel for it; the gate must therefore forward by hand, but only while tracking.
|
||||||
|
it('forwards the wheel while the CLI tracks the mouse, with no version gate', () => {
|
||||||
|
const { app } = hollowApp({ mode: 'copilot', cliMouseTracking: true });
|
||||||
|
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Shift+wheel on local scrollback and falls back to paging when not tracking', () => {
|
||||||
|
const tracking = hollowApp({ mode: 'copilot', cliMouseTracking: true });
|
||||||
|
expect(tracking.app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false);
|
||||||
|
const idle = hollowApp({ mode: 'copilot', cliMouseTracking: false });
|
||||||
|
expect(idle.app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not change what other non-claude modes do', () => {
|
||||||
|
for (const mode of ['codex', 'opencode', 'grok', 'gemini']) {
|
||||||
|
const { app } = hollowApp({ mode, cliMouseTracking: true });
|
||||||
|
expect(app._shouldForwardWheelToApp({ shiftKey: false }), mode).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('the paging gates asked for another pane (a TerminalTile)', () => {
|
describe('the paging gates asked for another pane (a TerminalTile)', () => {
|
||||||
it('exports the paging math, and the primary pane runs on it', () => {
|
it('exports the paging math, and the primary pane runs on it', () => {
|
||||||
const { app, sent, windowRef } = hollowApp();
|
const { app, sent, windowRef } = hollowApp();
|
||||||
|
|||||||
@@ -239,6 +239,15 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
|
|||||||
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: PAGE_DOWN });
|
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: PAGE_DOWN });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pages a copilot tile with an empty local buffer (the stale-tracking fallback)', async () => {
|
||||||
|
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'copilot', cliMouseTracking: false } }, 'other');
|
||||||
|
const { ws, mount } = await connectTile(app, { mode: 'copilot' });
|
||||||
|
|
||||||
|
mount.fire('wheel', wheelLines(-12));
|
||||||
|
|
||||||
|
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
|
||||||
|
});
|
||||||
|
|
||||||
it("reads the TILE's session, not the active one", async () => {
|
it("reads the TILE's session, not the active one", async () => {
|
||||||
// Active session is a shell; the tile shows opencode: the tile still pages.
|
// Active session is a shell; the tile shows opencode: the tile still pages.
|
||||||
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode' } }, 'other');
|
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode' } }, 'other');
|
||||||
|
|||||||
Reference in new issue
Block a user