mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
feat(cli-registry): GitHub Copilot CLI as a run mode
Copilot (`copilot`, npm @github/copilot) as a stock CLI entry with the same launch options as the other agent CLIs: model (--model), resume (--resume), continue (--continue) and allow-all (--yolo, clamped off for non-granted multi-user owners). Adds a resolver and GET /api/copilot/status probe, the agent-image package, Run-menu/mobile/logo entries, skill docs and tests.
This commit is contained in:
1 parent
de99a6d57a
commit
c5de8383e7
48 files changed
+847
-59
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`) and `allowAll` (`--yolo`, which the Run button sends and which is clamped off for non-granted multi-user owners), history resume, and a `GET /api/copilot/status` probe.
|
||||
@@ -277,5 +277,33 @@
|
||||
"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.
|
||||
# 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.
|
||||
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
|
||||
# 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.
|
||||
|
||||
+12
-6
@@ -1,11 +1,11 @@
|
||||
# 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
|
||||
|
||||
| File | What it holds |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `types.ts` | The `CliEntry` interface and everything under it. Read this first. |
|
||||
| `stock.ts` | The shipped catalog. **The only file allowed to name a CLI id.** |
|
||||
| `schema.ts` | Zod validation, including the cross-field checks that reject an incoherent entry at LOAD time. |
|
||||
@@ -28,7 +28,7 @@ App Settings → Agents & CLIs → **CLI management** (`cliManagementEnabled`, d
|
||||
- installs a missing **stock** CLI by running its shipped install command, after a confirm that names the exact command. Only one install per CLI runs at a time, and the command runs without any `CODEMAN_*` variable in its environment. An `npm install -g` command is pointed at `~/.local` (which every resolver searches) when the npm global prefix is not writable by the server user, for example a system node under `/usr`; a writable prefix, a prefix that does not exist yet but could be created, and an explicit `NPM_CONFIG_PREFIX` are left alone. A custom entry's install command is never executed.
|
||||
- adds, edits and deletes **custom** entries (id, label, badge, binaries, launch argv). The server re-validates the whole assembled entry through `CliEntrySchema`, so the form cannot bypass the load-time rules.
|
||||
|
||||
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under *CLI management*.
|
||||
These are the only writes to `clis.json`. They are serialized, and a file that does not parse or has unsafe permissions is refused rather than overwritten; fix it (or `chmod 600` it) and retry. The HTTP routes are listed in `docs/api-reference.md` under _CLI management_.
|
||||
|
||||
## The shape of an entry
|
||||
|
||||
@@ -228,7 +228,7 @@ The names live in `profiles.ts`, which is kept free of imports so `schema.ts` ca
|
||||
DeepSeek is worth reading before assuming an entry looks like its siblings — the schema carries four extensions because of it.
|
||||
|
||||
| What it breaks | How the registry expresses it |
|
||||
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `dsh` is a profile LAUNCHER, not the agent, so "installed" is not "runnable". | `discovery.launcherProfile` + `discovery.launcherTargetParam`. |
|
||||
| Its permission switch is the **`DSH_PERMISSION_MODE` env var**, not a flag — the harness has none. | `env.configSetenv` (so the ordinary `privilegedParams` clamp still reaches it) **and** `capabilities.privilegedEnvKeys`. |
|
||||
| It is the only non-claude mode with real hook signals, and for it that is a per-SESSION question. | `capabilities.hooks: 'supervised'` — a third state, not a boolean. |
|
||||
@@ -273,7 +273,7 @@ Two things need the catalogue but cannot import TypeScript, so `npm run generate
|
||||
and `test/cli-catalog-sync.test.ts` fails if either drifts from a fresh generation.
|
||||
|
||||
| Artifact | Consumer | Why it exists |
|
||||
| ------------------------------------ | ---------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| ---------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| `config/clis.stock.json` | `scripts/lib/cli-catalog.mjs` (Docker build args), tests | A `.mjs` cannot import the registry. |
|
||||
| a marked block inside `install.sh` | the installer itself | It runs via `curl \| bash` before any checkout exists, so it can read neither. |
|
||||
|
||||
@@ -336,7 +336,7 @@ A module-level const freezes at first import, and the failure is asymmetric: a C
|
||||
|
||||
`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`.
|
||||
|
||||
**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`.** GitHub Copilot CLI keeps an MCP list worth syncing and is a run mode, but its registry entry declares no `mcpConfig`; its sync target is the plain-data entry below. `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.
|
||||
|
||||
`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.
|
||||
|
||||
@@ -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.
|
||||
- `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.
|
||||
|
||||
## 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 or id prefix; names are refused by the id pattern) 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 goes through `copilotConfig.resumeSessionId` / `continueSession`. The tab names the model it reads from the footer (`modelDetect`). It declares no `mcpConfig`.
|
||||
|
||||
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`.
|
||||
+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
|
||||
# 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.
|
||||
CLI_IDS=('claude' 'shell' 'opencode' 'codex' 'gemini' 'antigravity' 'pi' 'grok' 'deepseek' 'omp')
|
||||
CLI_LABELS=('Claude Code' 'Shell' 'OpenCode' 'Codex' 'Gemini' 'Antigravity' 'Pi' 'Grok' 'DeepSeek' 'OMP')
|
||||
CLI_ENABLED=(1 1 1 1 1 1 1 1 1 1)
|
||||
CLI_LAUNCHER_ONLY=(0 0 0 0 0 0 0 0 1 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_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_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_ALL_BINS=('claude' 'opencode' 'codex' 'gemini' 'agy' 'pi' 'grok' 'dsh' 'omp')
|
||||
CLI_BIN_OFF=(0 1 1 2 3 4 5 6 7 8)
|
||||
CLI_BIN_LEN=(1 0 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_PATH_OFF=(0 5 5 12 18 24 28 33 37 41)
|
||||
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6)
|
||||
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' 'GitHub Copilot')
|
||||
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 0)
|
||||
CLI_DOCS=('https://docs.claude.com/claude-code' '' 'https://opencode.ai/docs' 'https://developers.openai.com/codex/cli' 'https://github.com/google-gemini/gemini-cli' 'https://antigravity.google/cli' 'https://pi.dev' 'https://github.com/xai-org/grok-build' 'https://github.com/deepseek-ai/deepseek-harness' 'https://omp.sh' 'https://github.com/github/copilot-cli')
|
||||
CLI_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' 'npm install -g @github/copilot')
|
||||
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 9)
|
||||
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" "$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 47)
|
||||
CLI_PATH_LEN=(5 0 7 6 6 4 5 4 4 6 5)
|
||||
# <<< 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
|
||||
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`,
|
||||
`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)).
|
||||
|
||||
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
|
||||
returns is too early (verified live: empty on the first call, full prose seconds later).
|
||||
It is also `""` before the worker's first completed turn, and permanently `""` for
|
||||
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `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
|
||||
for the same reason claude does (the harness finalizes the assistant message just after
|
||||
it reports `idle`), so poll it the same way.
|
||||
@@ -341,7 +341,7 @@ ESC=$(printf '\033')
|
||||
|
||||
`POST /api/v1/quick-start` body (all optional):
|
||||
`{"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
|
||||
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
|
||||
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/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed).
|
||||
Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name,
|
||||
`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated
|
||||
`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, 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, and `copilot` is a generic one, so an unrelated
|
||||
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
|
||||
not version-shaped, so `available:false` there can mean "a different program of the same
|
||||
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
|
||||
@@ -486,7 +486,7 @@ Quirks that will bite you:
|
||||
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
||||
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
||||
returns early for every external CLI mode (`session.ts:~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
|
||||
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:
|
||||
|
||||
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
|
||||
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
||||
| liveness / death check | HTTP `wait?until=exit` |
|
||||
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
||||
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`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 |
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
||||
# always "" for shell/opencode/gemini/antigravity/pi/grok/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.)
|
||||
|
||||
# 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
|
||||
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
||||
real end-of-turn signal rather than a guess. On
|
||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a
|
||||
`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,
|
||||
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**
|
||||
@@ -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
|
||||
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
||||
is also `""` before the worker's first completed turn, and always `""` for modes with
|
||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `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
|
||||
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
|
||||
@@ -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
|
||||
`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.
|
||||
|
||||
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
|
||||
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`,
|
||||
`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)).
|
||||
|
||||
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
|
||||
returns is too early (verified live: empty on the first call, full prose seconds later).
|
||||
It is also `""` before the worker's first completed turn, and permanently `""` for
|
||||
`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `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
|
||||
for the same reason claude does (the harness finalizes the assistant message just after
|
||||
it reports `idle`), so poll it the same way.
|
||||
@@ -341,7 +341,7 @@ ESC=$(printf '\033')
|
||||
|
||||
`POST /api/v1/quick-start` body (all optional):
|
||||
`{"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
|
||||
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
|
||||
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/pi/status` and `GET /api/v1/omp/status` each return `.data.{available, path}` (no session needed).
|
||||
Pi's, grok's and OMP's also carry `.data.version`, because `pi` is a short generic name,
|
||||
`grok` is a name with npm squatters, and `omp` is a similarly short name, so an unrelated
|
||||
`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, 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, and `copilot` is a generic one, so an unrelated
|
||||
binary on `$PATH` can shadow any of them: the resolver rejects one whose `--version` is
|
||||
not version-shaped, so `available:false` there can mean "a different program of the same
|
||||
name is in front" rather than "nothing is installed". `shell` has no CLI to probe.
|
||||
@@ -486,7 +486,7 @@ Quirks that will bite you:
|
||||
- ⚠️ **`active-tools` proves presence, never absence.** It is fed by the BashToolParser,
|
||||
which reads Claude's rendered `● Bash(…)` lines, and `_processExpensiveParsers`
|
||||
returns early for every external CLI mode (`session.ts:~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
|
||||
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:
|
||||
|
||||
@@ -56,7 +56,7 @@ own head: the worker enforcing the cap is the one who has to be told about it.
|
||||
| synchronize on end of turn | HTTP `wait until=stop` (fires for message-initiated turns too, verified live) |
|
||||
| liveness / death check | HTTP `wait?until=exit` |
|
||||
| interrupt a running turn (break-glass) | HTTP input, a bare `\x1b` with no `\r` |
|
||||
| non-claude modes (`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`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 |
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -188,7 +188,7 @@ for _ in $(seq 1 10); do
|
||||
done
|
||||
printf '%s\n' "$TXT"
|
||||
# (.data is {text,timestamp}; text is also "" before the first completed turn and
|
||||
# always "" for shell/opencode/gemini/antigravity/pi/grok/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.)
|
||||
|
||||
# 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
|
||||
`deepseek`** — the one external CLI that reports its own lifecycle, so its `stop` is a
|
||||
real end-of-turn signal rather than a guess. On
|
||||
`shell`/`opencode`/`codex`/`gemini`/`antigravity`/`pi`/`grok`/`omp`, requesting them explicitly is a
|
||||
`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,
|
||||
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**
|
||||
@@ -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
|
||||
turn finished (verified live: empty on the first call, full text seconds later). `text`
|
||||
is also `""` before the worker's first completed turn, and always `""` for modes with
|
||||
no transcript (`shell`, `opencode`, `gemini`, `antigravity`, `pi`, `grok`, `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
|
||||
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
|
||||
@@ -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
|
||||
`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.
|
||||
|
||||
Only if neither helps: sample `terminal?tail=` twice a few seconds apart. A changing
|
||||
|
||||
@@ -1652,5 +1652,163 @@ 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 it joins grok in the tmux-attach-time strip only.
|
||||
// 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, so the id
|
||||
// pattern below is what keeps a name or a path out of it.
|
||||
launch: {
|
||||
params: {
|
||||
allowAll: { type: 'bool' },
|
||||
model: { type: 'token', pattern: 'model' },
|
||||
resumeId: { type: 'token', pattern: 'id-dotted' },
|
||||
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 token in the
|
||||
// environment (COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN) is what a headless host uses.
|
||||
tmuxSetenvKeys: [],
|
||||
dockerExecEnvNames: [],
|
||||
allowedPrefixes: ['COPILOT_'],
|
||||
allowedKeys: ['GH_TOKEN', 'GITHUB_TOKEN'],
|
||||
},
|
||||
capabilities: {
|
||||
...agentDefaults(),
|
||||
altScreen: 'strip-mux-only',
|
||||
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
|
||||
// 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_HOME can restate permissions and COPILOT_ALLOW_ALL turns every one on; both
|
||||
// already match the COPILOT_ allowedPrefix, so a non-granted multi-user owner must not
|
||||
// be able to set them through envOverrides.
|
||||
privilegedEnvKeys: [
|
||||
'COPILOT_HOME',
|
||||
'COPILOT_ALLOW_ALL',
|
||||
'COPILOT_PROVIDER_BASE_URL',
|
||||
'COPILOT_PROVIDER_API_KEY',
|
||||
'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: {
|
||||
credStore: { rel: '.copilot', seedFiles: ['config.json', 'mcp-config.json'] },
|
||||
},
|
||||
};
|
||||
|
||||
/** 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,
|
||||
];
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
AntigravityConfig,
|
||||
PiConfig,
|
||||
GrokConfig,
|
||||
CopilotConfig,
|
||||
DeepSeekConfig,
|
||||
OmpConfig,
|
||||
SessionRemote,
|
||||
@@ -110,6 +111,7 @@ export interface CreateSessionOptions {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
/** When restoring after reboot, resume a previous Claude conversation by its session ID */
|
||||
@@ -154,6 +156,7 @@ export interface RespawnPaneOptions {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
/** Resume a previous Claude conversation when respawning */
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
EffortLevel,
|
||||
GeminiConfig,
|
||||
GrokConfig,
|
||||
CopilotConfig,
|
||||
OmpConfig,
|
||||
OpenCodeConfig,
|
||||
PiConfig,
|
||||
@@ -50,6 +51,7 @@ export interface SpawnBridgeOptions {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
type AntigravityConfig,
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type CopilotConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type SessionRemote,
|
||||
@@ -748,6 +749,8 @@ export class Session extends EventEmitter {
|
||||
private _piConfig: PiConfig | undefined;
|
||||
// Grok configuration (only for mode === 'grok')
|
||||
private _grokConfig: GrokConfig | undefined;
|
||||
// GitHub Copilot configuration (only for mode === 'copilot')
|
||||
private _copilotConfig: CopilotConfig | undefined;
|
||||
|
||||
// DeepSeek Harness configuration (only for mode === 'deepseek')
|
||||
private _deepSeekConfig: DeepSeekConfig | undefined;
|
||||
@@ -875,6 +878,7 @@ export class Session extends EventEmitter {
|
||||
piConfig?: PiConfig;
|
||||
/** Grok configuration (only for mode === 'grok') */
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** OMP configuration (only for mode === 'omp') */
|
||||
@@ -1037,6 +1041,11 @@ export class Session extends EventEmitter {
|
||||
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).
|
||||
// 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)
|
||||
@@ -2009,6 +2018,7 @@ export class Session extends EventEmitter {
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
copilotConfig: this._copilotConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
@@ -2381,6 +2391,7 @@ export class Session extends EventEmitter {
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
copilotConfig: this._copilotConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
// OMP resolution/pinning does NOT happen here. This object is built
|
||||
// EAGERLY — including on every boot-recovery reattach, before anyone
|
||||
@@ -2866,6 +2877,7 @@ export class Session extends EventEmitter {
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
copilotConfig: this._copilotConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
resumeSessionId: this._resumeSessionId,
|
||||
@@ -3430,6 +3442,7 @@ export class Session extends EventEmitter {
|
||||
antigravityConfig: this._antigravityConfig,
|
||||
piConfig: this._piConfig,
|
||||
grokConfig: this._grokConfig,
|
||||
copilotConfig: this._copilotConfig,
|
||||
deepSeekConfig: this._deepSeekConfig,
|
||||
ompConfig: this._ompConfig,
|
||||
};
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
type AntigravityConfig,
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type CopilotConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type SessionRemote,
|
||||
@@ -1081,6 +1082,7 @@ export function buildSpawnCommand(options: {
|
||||
antigravityConfig?: AntigravityConfig;
|
||||
piConfig?: PiConfig;
|
||||
grokConfig?: GrokConfig;
|
||||
copilotConfig?: CopilotConfig;
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
ompConfig?: OmpConfig;
|
||||
resumeSessionId?: string;
|
||||
@@ -2317,6 +2319,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
@@ -2403,6 +2406,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
@@ -2652,6 +2656,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
@@ -2693,6 +2698,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
resumeSessionId,
|
||||
|
||||
+31
-4
@@ -8,7 +8,7 @@
|
||||
* - SessionConfig — creation-time config (id, workingDir, createdAt)
|
||||
* - SessionOutput — captured stdout/stderr/exitCode
|
||||
* - SessionStatus — 'idle' | 'busy' | 'stopped' | 'error'
|
||||
* - SessionMode — 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini' | 'antigravity' | 'pi' | 'grok' | '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')
|
||||
* - SessionColor — visual differentiation color
|
||||
* - OpenCodeConfig — OpenCode-specific settings (model, autoAllowTools, continueSession)
|
||||
@@ -17,6 +17,7 @@
|
||||
* - AntigravityConfig — Antigravity CLI (agy) settings (model, dangerouslySkipPermissions, resumeConversationId)
|
||||
* - PiConfig — Pi CLI (pi.dev) settings (model, provider, thinking, resume/continue, project trust)
|
||||
* - GrokConfig — Grok Build CLI (xAI `grok`) settings (model, alwaysApprove, resume/continue)
|
||||
* - CopilotConfig — GitHub Copilot CLI settings (model, allowAll, resume/continue)
|
||||
* - DeepSeekConfig — DeepSeek Harness (`dsh`) settings (profile, permissionMode, resume, status bridge)
|
||||
*
|
||||
* Cross-domain relationships:
|
||||
@@ -56,7 +57,8 @@ export type SessionMode =
|
||||
| 'pi'
|
||||
| 'grok'
|
||||
| 'deepseek'
|
||||
| 'omp';
|
||||
| 'omp'
|
||||
| 'copilot';
|
||||
|
||||
/**
|
||||
* 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<
|
||||
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). */
|
||||
export type DockerCommandMode = Extract<
|
||||
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. */
|
||||
@@ -525,6 +527,29 @@ export interface GrokConfig {
|
||||
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 by ID (--resume=<id>). Ids only, never names. */
|
||||
resumeSessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek Harness (`dsh`) session configuration.
|
||||
*
|
||||
@@ -893,6 +918,8 @@ export interface SessionState {
|
||||
piConfig?: PiConfig;
|
||||
/** Grok-specific configuration (only for mode === 'grok') */
|
||||
grokConfig?: GrokConfig;
|
||||
/** GitHub Copilot CLI configuration (only for mode === 'copilot') */
|
||||
copilotConfig?: CopilotConfig;
|
||||
/** DeepSeek Harness configuration (only for mode === 'deepseek') */
|
||||
deepSeekConfig?: DeepSeekConfig;
|
||||
/** OMP-specific configuration (only for mode === 'omp') */
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 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
|
||||
* 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';
|
||||
@@ -30,6 +30,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
|
||||
{ isGrokAvailable },
|
||||
{ isDeepSeekRunnable },
|
||||
{ isOmpAvailable },
|
||||
{ isCopilotAvailable },
|
||||
] = await Promise.all([
|
||||
import('./claude-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('./deepseek-cli-resolver.js'),
|
||||
import('./omp-cli-resolver.js'),
|
||||
import('./copilot-cli-resolver.js'),
|
||||
]);
|
||||
return {
|
||||
claude: isClaudeAvailable(),
|
||||
@@ -51,6 +53,7 @@ export async function probeStockCliAvailability(): Promise<Record<string, boolea
|
||||
grok: isGrokAvailable(),
|
||||
deepseek: isDeepSeekRunnable(),
|
||||
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 type { FileQueryMatcher } from './file-query.js';
|
||||
export { resolveOmpDir, isOmpAvailable, getOmpNotFoundMessage, getOmpCliVersion } from './omp-cli-resolver.js';
|
||||
export {
|
||||
resolveCopilotDir,
|
||||
isCopilotAvailable,
|
||||
getCopilotNotFoundMessage,
|
||||
getCopilotCliVersion,
|
||||
} from './copilot-cli-resolver.js';
|
||||
export {
|
||||
boundedPathExists,
|
||||
describeUnknownPath,
|
||||
|
||||
@@ -3008,6 +3008,8 @@ class CodemanApp {
|
||||
? 'DeepSeek'
|
||||
: mode === 'omp'
|
||||
? 'OMP'
|
||||
: mode === 'copilot'
|
||||
? 'GitHub Copilot'
|
||||
: mode === 'opencode'
|
||||
? 'OpenCode'
|
||||
: 'Claude';
|
||||
@@ -10473,6 +10475,8 @@ class CodemanApp {
|
||||
? 'Kill Tmux & DeepSeek'
|
||||
: session.mode === 'omp'
|
||||
? 'Kill Tmux & OMP'
|
||||
: session.mode === 'copilot'
|
||||
? 'Kill Tmux & GitHub Copilot'
|
||||
: 'Kill Tmux & Claude Code';
|
||||
}
|
||||
|
||||
|
||||
@@ -1078,6 +1078,7 @@
|
||||
<option value="grok">Grok</option>
|
||||
<option value="deepseek">DeepSeek</option>
|
||||
<option value="omp">OMP</option>
|
||||
<option value="copilot">GitHub Copilot</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
|
||||
@@ -3458,6 +3459,7 @@
|
||||
<option value="grok" data-cli="grok">Grok</option>
|
||||
<option value="deepseek" data-cli="deepseek">DeepSeek</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>
|
||||
</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>
|
||||
@@ -3636,7 +3638,7 @@
|
||||
<div class="form-row docker-create-only">
|
||||
<label>Image</label>
|
||||
<input type="text" id="dockerImage" placeholder="codeman/agent:base" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Build it once with <code>node scripts/build-agent-image.mjs</code>. Contains node + claude/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 class="form-row docker-create-only">
|
||||
<label>Network</label>
|
||||
|
||||
@@ -58,6 +58,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'grok', label: 'Grok', short: 'Grok' },
|
||||
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
|
||||
{ mode: 'omp', label: 'OMP', short: 'OMP' },
|
||||
{ mode: 'copilot', label: 'GitHub Copilot', short: 'GitHub Copilot' },
|
||||
{ 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;
|
||||
}
|
||||
|
||||
/* 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-menu {
|
||||
bottom: 100%;
|
||||
@@ -3337,6 +3351,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-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) {
|
||||
background: linear-gradient(135deg, #27272a, #52525b);
|
||||
border-color: #18181b;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* 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
|
||||
* `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
|
||||
* near-identical bodies guaranteed to drift, exactly what the CLI registry's
|
||||
* own no-id-branching rule exists to prevent server-side.
|
||||
@@ -79,6 +79,16 @@ const RUN_MODE_LAUNCH = {
|
||||
supportsCustomModel: true,
|
||||
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: {
|
||||
label: 'Grok',
|
||||
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');
|
||||
},
|
||||
|
||||
async runCopilot() {
|
||||
return this._runCliMode('copilot');
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Options Modal
|
||||
|
||||
@@ -5785,6 +5785,22 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
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
|
||||
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
|
||||
@@ -5944,6 +5960,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
.run-mode-dot.opencode,
|
||||
.run-mode-dot.pi,
|
||||
.run-mode-dot.grok,
|
||||
.run-mode-dot.copilot,
|
||||
.run-mode-dot.shell,
|
||||
.run-mode-dot.web {
|
||||
width: 1.15em;
|
||||
@@ -5964,6 +5981,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
.run-mode-dot.opencode,
|
||||
.run-mode-dot.pi,
|
||||
.run-mode-dot.grok,
|
||||
.run-mode-dot.copilot,
|
||||
.run-mode-dot.shell,
|
||||
.run-mode-dot.web {
|
||||
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.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.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.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;
|
||||
}
|
||||
.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 {
|
||||
background: linear-gradient(135deg, #be185d, #f472b6);
|
||||
border-color: #be185d;
|
||||
|
||||
@@ -3670,6 +3670,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
opencode: 'openCodeConfig',
|
||||
pi: 'piConfig',
|
||||
grok: 'grokConfig',
|
||||
copilot: 'copilotConfig',
|
||||
omp: 'ompConfig',
|
||||
}[effectiveMode];
|
||||
// codex names a thread by an id of its own, not by Codeman's session id,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type AntigravityConfig,
|
||||
type PiConfig,
|
||||
type GrokConfig,
|
||||
type CopilotConfig,
|
||||
type DeepSeekConfig,
|
||||
type OmpConfig,
|
||||
type RemoteHost,
|
||||
@@ -611,7 +612,8 @@ export async function _clampExternalCliBypassForOwner(
|
||||
antigravityConfig?: AntigravityConfig,
|
||||
piConfig?: PiConfig,
|
||||
grokConfig?: GrokConfig,
|
||||
deepSeekConfig?: DeepSeekConfig
|
||||
deepSeekConfig?: DeepSeekConfig,
|
||||
copilotConfig?: CopilotConfig
|
||||
): Promise<{
|
||||
codexConfig: CodexConfig | undefined;
|
||||
geminiConfig: GeminiConfig | undefined;
|
||||
@@ -619,6 +621,7 @@ export async function _clampExternalCliBypassForOwner(
|
||||
piConfig: PiConfig | undefined;
|
||||
grokConfig: GrokConfig | undefined;
|
||||
deepSeekConfig: DeepSeekConfig | undefined;
|
||||
copilotConfig: CopilotConfig | undefined;
|
||||
}> {
|
||||
const out = await clampExternalCliBypassForOwner(owner, {
|
||||
codexConfig,
|
||||
@@ -627,6 +630,7 @@ export async function _clampExternalCliBypassForOwner(
|
||||
piConfig,
|
||||
grokConfig,
|
||||
deepSeekConfig,
|
||||
copilotConfig,
|
||||
});
|
||||
return out as {
|
||||
codexConfig: CodexConfig | undefined;
|
||||
@@ -635,6 +639,7 @@ export async function _clampExternalCliBypassForOwner(
|
||||
piConfig: PiConfig | undefined;
|
||||
grokConfig: GrokConfig | undefined;
|
||||
deepSeekConfig: DeepSeekConfig | undefined;
|
||||
copilotConfig: CopilotConfig | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1348,6 +1353,7 @@ export function registerSessionRoutes(
|
||||
piConfig: gatedPiConfig,
|
||||
grokConfig: gatedGrokConfig,
|
||||
deepSeekConfig: gatedDeepSeekConfig,
|
||||
copilotConfig: gatedCopilotConfig,
|
||||
} = await _clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
launchBody.codexConfig,
|
||||
@@ -1355,7 +1361,8 @@ export function registerSessionRoutes(
|
||||
launchBody.antigravityConfig,
|
||||
launchBody.piConfig,
|
||||
launchBody.grokConfig,
|
||||
launchBody.deepSeekConfig
|
||||
launchBody.deepSeekConfig,
|
||||
launchBody.copilotConfig
|
||||
);
|
||||
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
@@ -1374,6 +1381,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
|
||||
piConfig: mode === 'pi' ? gatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
|
||||
copilotConfig: mode === 'copilot' ? gatedCopilotConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
|
||||
ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig),
|
||||
resumeSessionId: validatedResumeId,
|
||||
@@ -3725,6 +3733,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
envOverrides,
|
||||
@@ -3796,6 +3805,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig ||
|
||||
piConfig ||
|
||||
grokConfig ||
|
||||
copilotConfig ||
|
||||
deepSeekConfig ||
|
||||
ompConfig ||
|
||||
openCodeConfig ||
|
||||
@@ -3866,6 +3876,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig ||
|
||||
piConfig ||
|
||||
grokConfig ||
|
||||
copilotConfig ||
|
||||
deepSeekConfig ||
|
||||
ompConfig ||
|
||||
openCodeConfig ||
|
||||
@@ -3966,6 +3977,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
} as unknown as Record<string, unknown>)
|
||||
);
|
||||
@@ -4168,6 +4180,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig,
|
||||
piConfig,
|
||||
grokConfig,
|
||||
copilotConfig,
|
||||
deepSeekConfig,
|
||||
ompConfig,
|
||||
};
|
||||
@@ -4197,6 +4210,7 @@ export function registerSessionRoutes(
|
||||
piConfig: qsGatedPiConfig,
|
||||
grokConfig: qsGatedGrokConfig,
|
||||
deepSeekConfig: qsGatedDeepSeekConfig,
|
||||
copilotConfig: qsGatedCopilotConfig,
|
||||
} = await _clampExternalCliBypassForOwner(
|
||||
owner,
|
||||
qsLaunchConfigs.codexConfig,
|
||||
@@ -4204,7 +4218,8 @@ export function registerSessionRoutes(
|
||||
qsLaunchConfigs.antigravityConfig,
|
||||
qsLaunchConfigs.piConfig,
|
||||
qsLaunchConfigs.grokConfig,
|
||||
qsLaunchConfigs.deepSeekConfig
|
||||
qsLaunchConfigs.deepSeekConfig,
|
||||
qsLaunchConfigs.copilotConfig
|
||||
);
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
|
||||
@@ -4371,6 +4386,7 @@ export function registerSessionRoutes(
|
||||
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
|
||||
piConfig: mode === 'pi' ? qsGatedPiConfig : undefined,
|
||||
grokConfig: mode === 'grok' ? qsGatedGrokConfig : undefined,
|
||||
copilotConfig: mode === 'copilot' ? qsGatedCopilotConfig : undefined,
|
||||
deepSeekConfig: mode === 'deepseek' ? qsGatedDeepSeekConfig : undefined,
|
||||
ompConfig: qsResolvedOmpConfig,
|
||||
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)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -439,6 +439,25 @@ const GrokConfigSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const CopilotConfigSchema = z
|
||||
.object({
|
||||
model: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._\-/]+$/)
|
||||
.optional(),
|
||||
allowAll: z.boolean().optional(),
|
||||
continueSession: z.boolean().optional(),
|
||||
// Ids only: --resume also matches session NAMES (arbitrary user strings), which this
|
||||
// regex deliberately cannot express.
|
||||
resumeSessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/)
|
||||
.optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
/**
|
||||
* Schema for OMP CLI-specific configuration.
|
||||
*/
|
||||
@@ -596,6 +615,7 @@ export const CreateSessionSchema = z.object({
|
||||
antigravityConfig: AntigravityConfigSchema,
|
||||
piConfig: PiConfigSchema,
|
||||
grokConfig: GrokConfigSchema,
|
||||
copilotConfig: CopilotConfigSchema,
|
||||
deepSeekConfig: DeepSeekConfigSchema,
|
||||
ompConfig: OmpConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
@@ -1195,6 +1215,7 @@ export const QuickStartSchema = z.object({
|
||||
antigravityConfig: AntigravityConfigSchema,
|
||||
piConfig: PiConfigSchema,
|
||||
grokConfig: GrokConfigSchema,
|
||||
copilotConfig: CopilotConfigSchema,
|
||||
deepSeekConfig: DeepSeekConfigSchema,
|
||||
ompConfig: OmpConfigSchema,
|
||||
envOverrides: safeEnvOverridesSchema,
|
||||
|
||||
@@ -3590,6 +3590,7 @@ export class WebServer extends EventEmitter {
|
||||
antigravityConfig: muxSession.mode === 'antigravity' ? savedState?.antigravityConfig : undefined,
|
||||
piConfig: muxSession.mode === 'pi' ? savedState?.piConfig : undefined,
|
||||
grokConfig: muxSession.mode === 'grok' ? savedState?.grokConfig : undefined,
|
||||
copilotConfig: muxSession.mode === 'copilot' ? savedState?.copilotConfig : undefined,
|
||||
deepSeekConfig: muxSession.mode === 'deepseek' ? savedState?.deepSeekConfig : undefined,
|
||||
ompConfig: muxSession.mode === 'omp' ? savedState?.ompConfig : undefined,
|
||||
envOverrides: savedEnvOverrides,
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('per-mode capability predicates', () => {
|
||||
['grok', true, false, false],
|
||||
['deepseek', true, false, true],
|
||||
['omp', true, false, false],
|
||||
['copilot', true, false, false],
|
||||
] as Array<[SessionMode, boolean, boolean, boolean]>)(
|
||||
'%s: external=%s altScreenStrip=%s hooks=%s',
|
||||
(mode, external, altScreen, hooks) => {
|
||||
@@ -48,7 +49,7 @@ describe('per-mode capability predicates', () => {
|
||||
it('covers every enabled mode (sanity)', () => {
|
||||
// If a CLI is added without a row above, this fails rather than the table silently
|
||||
// describing a subset of reality.
|
||||
expect(MODES.length).toBe(10);
|
||||
expect(MODES.length).toBe(11);
|
||||
});
|
||||
|
||||
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 === '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 === 'copilot'": 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 === '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 === '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 === '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 === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
|
||||
|
||||
|
||||
@@ -320,3 +320,50 @@ describe('unsafe values are DROPPED, never escaped into the command', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
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 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { STOCK_CLIS } from '../src/config/cli-registry/stock.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
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
// 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.
|
||||
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', () => {
|
||||
for (const entry of enabledAgents) {
|
||||
|
||||
@@ -86,6 +86,13 @@ const LITERAL_SEARCH_PATHS: Record<string, string[]> = {
|
||||
'$HOME/.npm-global/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. */
|
||||
@@ -99,6 +106,7 @@ const ARRAY_PREFIX_TO_CLI_ID: Record<string, string> = {
|
||||
GROK: 'grok',
|
||||
ANTIGRAVITY: 'antigravity',
|
||||
OMP: 'omp',
|
||||
COPILOT: 'copilot',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ it('pins every remote pane command', () => {
|
||||
grok: remoteLoginShellCommand('grok'),
|
||||
deepseek: remoteLoginShellCommand('dsh'),
|
||||
omp: remoteLoginShellCommand('omp'),
|
||||
copilot: remoteLoginShellCommand('copilot'),
|
||||
};
|
||||
for (const [mode, want] of Object.entries(expected)) {
|
||||
expect(defaultRemoteCommandForMode(mode as SessionMode), mode).toBe(want);
|
||||
@@ -55,6 +56,7 @@ it('pins every in-container pane command', () => {
|
||||
grok: 'exec grok',
|
||||
deepseek: 'exec dsh',
|
||||
omp: 'exec omp',
|
||||
copilot: 'exec copilot',
|
||||
};
|
||||
for (const [mode, want] of Object.entries(expected)) {
|
||||
expect(defaultDockerCommandForMode(mode as SessionMode), mode).toBe(want);
|
||||
|
||||
@@ -469,6 +469,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
|
||||
'grok',
|
||||
'deepseek',
|
||||
'omp',
|
||||
'copilot',
|
||||
'shell',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
|
||||
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
|
||||
import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js';
|
||||
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
|
||||
import { isCopilotAvailable } from '../src/utils/copilot-cli-resolver.js';
|
||||
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
|
||||
import { isGitAvailable } from '../src/git-clone.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),
|
||||
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', () => ({
|
||||
isCloudflaredAvailable: vi.fn(() => false),
|
||||
resolveCloudflaredPath: vi.fn(() => null),
|
||||
@@ -207,6 +212,7 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
vi.mocked(isDeepSeekAvailable).mockReturnValue(false);
|
||||
vi.mocked(isDeepSeekRunnable).mockReturnValue(false);
|
||||
vi.mocked(isOmpAvailable).mockReturnValue(true);
|
||||
vi.mocked(isCopilotAvailable).mockReturnValue(true);
|
||||
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
|
||||
vi.mocked(isGitAvailable).mockReturnValue(true);
|
||||
const { server } = makeServer({});
|
||||
@@ -225,6 +231,7 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
deepseek: false,
|
||||
deepseekBinary: false,
|
||||
omp: true,
|
||||
copilot: true,
|
||||
cloudflared: true,
|
||||
git: true,
|
||||
shell: true,
|
||||
@@ -365,6 +372,7 @@ describe('WebServer.renderIndexHtml', () => {
|
||||
isDeepSeekAvailable,
|
||||
isDeepSeekRunnable,
|
||||
isOmpAvailable,
|
||||
isCopilotAvailable,
|
||||
isCloudflaredAvailable,
|
||||
isGitAvailable,
|
||||
]) {
|
||||
|
||||
@@ -147,6 +147,7 @@ describe('resumeHistorySession: row retirement is gated on actual continuation',
|
||||
['opencode', 'openCodeConfig'],
|
||||
['pi', 'piConfig'],
|
||||
['grok', 'grokConfig'],
|
||||
['copilot', 'copilotConfig'],
|
||||
['omp', 'ompConfig'],
|
||||
])('retires the old row for %s (continueSession is wired via %s)', async (mode, configKey) => {
|
||||
const app = makeApp();
|
||||
|
||||
@@ -104,6 +104,25 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
|
||||
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 () => {
|
||||
// The clamp rewrites one field and must carry the rest; a clamp rebuilt from named
|
||||
// fields would drop the effort here without a word.
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user