Merge pull request #592: feat(cli-registry): GitHub Copilot CLI as a run mode

GitHub Copilot CLI (`copilot`) becomes a run mode: Run menu, welcome
screen, cron and CLI management once it is installed, tmux-backed like
the other agent CLIs, working/idle and the model read off its footer,
launch options through `copilotConfig` (model, resume, continue, the
multi-user-clamped allowAll), Custom Model Endpoints via Copilot's BYOK
variables, MCP sync of its mcp-config.json, a status probe, and the
`codeman agent` verbs (spawn waits for its composer, read returns its
last answer).

The plan-usage quota row that earlier rounds carried was split out on
review and is not part of this merge.

Merge-time commits on top: the wider COPILOT_* clamp and the zh-CN
README rows (maintainer), the Docker sign-in docs, divider rows inside
an answer kept by `agent read`, route tests for the screen-read
last-response branch, and doc drift (Past Sessions, wheelForward JSDoc,
COPILOT_/OMP_ in the env prefix lists).

Verified before merge on Copilot CLI 1.0.98: injected keystrokes,
bracketed paste and Enter reach the TUI in tmux (github/copilot-cli#4180
does not reproduce), a shell tool call completes inside tmux (#4223 does
not reproduce), and a session driven through this branch answered over
send-and-wait.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 08:17:06 +02:00
commit 8e6abf4bfd
80 files changed
+1911 -132

No files matched your search

+4
View File
@@ -20,6 +20,7 @@
export type TokenPattern =
| 'model'
| 'model-claude'
| 'model-plain'
| 'model-pi'
| 'id'
| 'id-dotted'
@@ -43,6 +44,9 @@ const PATTERNS: Record<TokenPattern, RegExp> = {
model: /^[a-zA-Z0-9._\-/]+$/,
// buildSpawnCommand's claude branch — `[` and `]` for bracketed model aliases
'model-claude': /^[a-zA-Z0-9._\-[\]]+$/,
// copilot --model. Same characters as `model`, but a leading alphanumeric: the value follows a
// flag, so `--yolo` must not pass as a model id and become its own flag (the allowAll clamp bypass).
'model-plain': /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/,
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
// opencode --session, codex resume
+5 -3
View File
@@ -295,7 +295,7 @@ const capabilitiesSchema = z
altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']),
echo: echoSchema,
wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
.object({ mode: z.enum(['never', 'version-gated', 'always']), minVersion: z.string().max(20).optional() })
.strict(),
// Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for
// the CLI to page its own transcript. Distinct from wheelForward (SGR reports).
@@ -464,8 +464,10 @@ const capabilitiesSchema = z
)
.max(8),
// Exact env var NAMES, not prefixes: this list is a targeted deny, and a prefix here
// would let one entry silently strip a whole namespace off every owner's overrides.
privilegedEnvKeys: z.array(envName).max(8),
// would let one entry silently strip a whole namespace off every owner's overrides. The
// bound is generous because a longer list only takes more away from non-granted owners:
// copilot's COPILOT_ prefix covers dozens of variables that run code or redirect its token.
privilegedEnvKeys: z.array(envName).max(48),
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
maxFrameBytes: z.number().int().positive().optional(),
newline: z.enum(['line-feed', 'esc-enter']).optional(),
+232 -1
View File
@@ -1676,5 +1676,236 @@ const OMP: CliEntry = {
},
};
// GitHub Copilot CLI (`copilot`, github/copilot-cli, npm @github/copilot). Measured on a live
// 1.0.94 pane inside tmux (capture-pane through real turns, a shell tool call and /model,
// 2026-10-09). It is an ALTERNATE-SCREEN TUI with mouse tracking on (tmux `alternate_on=1`,
// `mouse_any_flag=1`, `history_size=0`), so like opencode its mouse DECSETs are stripped
// (`altScreen: 'strip-mux-and-mouse'` below): a drag selects locally and the wheel is forwarded.
// Keystrokes sent with `tmux send-keys` reach its composer and a shell tool call completes
// inside tmux, which github/copilot-cli#4180 (a driven PTY ignoring input) and #4223 (a shell
// command never reported done under tmux) say they do not on 1.0.70 to 1.0.74: neither
// reproduces on 1.0.94, and the register of `version` below is what would show a regression.
const COPILOT: CliEntry = {
id: 'copilot' as CliEntry['id'],
label: 'GitHub Copilot',
shortBadge: 'CP',
accent: '#a371f7',
enabled: true,
stock: true,
order: 100,
kind: 'agent',
discovery: {
binaries: ['copilot'],
searchDirs: [HOME_DIRS.local, HOME_DIRS.usrLocal, HOME_DIRS.bunBin, HOME_DIRS.npmGlobal, HOME_DIRS.homeBin],
// `copilot --version` prints `GitHub Copilot CLI 1.0.94.`; anchoring on the product name
// keeps an unrelated `copilot` binary on PATH from passing the probe.
version: { arg: '--version', regex: 'GitHub Copilot CLI (\\d+\\.\\d+\\.\\d+)', requireVersionMatch: true },
install: {
command: { linux: 'npm install -g @github/copilot', darwin: 'npm install -g @github/copilot' },
npmPackage: '@github/copilot',
docsUrl: 'https://github.com/github/copilot-cli',
},
},
// `--yolo` (allow every tool, path and URL) is opt-in through `allowAll`, which the Run button
// sends like the other agent CLIs' bypass switches; an absent config spawns a bare `copilot`
// in its own Manual Approval mode. `--model` and `--resume=<id>` are Copilot's own flags
// (`copilot --help`, 1.0.94). `--resume` takes an id, id prefix or session NAME and its value
// is optional, so the token must be a single plain word that cannot start with `-`: a value
// like `--yolo` would otherwise be parsed as its own flag and get around the `allowAll` clamp.
launch: {
params: {
allowAll: { type: 'bool' },
// `model-plain`, not `model`: the leading alphanumeric keeps a flag-shaped value out.
model: { type: 'token', pattern: 'model-plain' },
// `path-segment`, not `id-dotted`: the leading alphanumeric keeps a flag-shaped value out.
resumeId: { type: 'token', pattern: 'path-segment' },
continueSession: { type: 'bool' },
// The tab's name, so the CLI's own session list reads the same as the tab.
sessionName: { type: 'engine', source: 'sessionName' },
},
variants: [
{
id: 'default',
args: [
{ lit: 'copilot' },
{ flag: '--yolo', when: { param: 'allowAll', is: true } },
// `--name` is refused next to `--resume` and `--continue` (copilot 1.0.95: "cannot be used
// with"), and a resumed session keeps the name it was created with.
{
flag: '--name',
valueFrom: 'sessionName',
quote: 'double',
when: {
allOf: [
{ param: 'sessionName', state: 'set' },
{ param: 'resumeId', state: 'unset' },
{ not: { param: 'continueSession', is: true } },
],
},
},
{ flag: '--model', valueFrom: 'model', when: { param: 'model', state: 'set' } },
{ flag: '--resume', valueFrom: 'resumeId', when: { param: 'resumeId', state: 'set' } },
{
lit: '--continue',
when: {
allOf: [
{ param: 'continueSession', is: true },
{ param: 'resumeId', state: 'unset' },
],
},
},
],
},
],
legacyConfigAliases: { resumeId: 'resumeSessionId' },
legacyConfigField: 'copilotConfig',
resumeAppend: { style: 'flag', flag: '--resume' },
},
env: {
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
unset: ['NO_COLOR'],
// Sign-in is the CLI's own (`copilot login`, kept under ~/.copilot). A headless host sets
// COPILOT_GITHUB_TOKEN, which the COPILOT_ prefix already admits. GH_TOKEN / GITHUB_TOKEN are
// deliberately NOT allowlisted: the env allowlist is one global list with no mode context, so
// admitting them would make them settable on every session (see allowedEnvPrefixes()).
tmuxSetenvKeys: [],
dockerExecEnvNames: [],
allowedPrefixes: ['COPILOT_'],
allowedKeys: [],
},
capabilities: {
...agentDefaults(),
// A full-screen TUI that turns mouse tracking on itself (measured on 1.0.94: alt screen,
// mouse reporting on). Kept, xterm reports a plain drag to the TUI instead of selecting, so
// Auto Copy and "mark text, copy on select" silently do nothing; stripped, a drag is a local
// selection and clicks still reach the CLI through the browser's hand-encoded tap
// (`cliMouseTracking`). Same shape and reason as opencode. The wheel keeps working because
// `_shouldForwardWheelToApp` forwards it as SGR reports while the CLI is tracking.
altScreen: 'strip-mux-and-mouse',
// Forwards the wheel as SGR reports while the CLI is tracking the mouse, at any version
// (`_shouldForwardWheelToApp` in terminal-ui.js has no version check for copilot).
wheelForward: { mode: 'always' },
// Pages its own transcript on PageUp/PageDown (1.0.95, measured in tmux: three PageUp moved
// lines 109-140 to 010-042 and PageDown returned). This is the fallback when the
// wheel-forward flag is stale after a server restart, so the wheel and a swipe never go dead.
transcriptPageKeys: true,
echo: { policy: 'buffer', anchor: { kind: 'cursor' } },
// What `codeman agent spawn` waits for. The composer's footer hint, NOT the `❯` glyph: the
// folder-trust dialog draws `❯ 1. Yes` for its selected row, so the glyph would call a worker
// ready while it is still asking for trust (measured on 1.0.95: the dialog's footer reads
// `↑/↓ to navigate`, the composer's `/ commands · ? help`).
composerReadyMark: '/ commands · ? help',
// The composer row is a `❯` between two rules, and the submitted prompt is echoed as
// ` ❯ <text> <time>` above it. While a turn runs the footer's left end reads
// `◉ Working esc edit prompt` (the dot alternates `◉` and `◎`); at rest it reads
// `← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab`.
// A tool call that is waiting for approval replaces the composer and the footer's
// `Working` goes with it, so it reads as idle (waiting on the user), like gemini.
workDetect: {
promptGlyph: '❯',
workingLine: String.raw`[◉◎] Working\b`,
},
// The model is the right-hand field of the last row, both at rest and mid-turn
// (`... tab next tab Claude Sonnet 5.5`, `◎ Working esc edit prompt GPT-6 Luna`).
// Anchored on the footer's own words so a model-looking string in the transcript cannot match.
modelDetect: {
screenLine: String.raw`(?:tab next tab|\? help|esc edit prompt|github-mcp-server) {3,}([A-Za-z0-9][\w.:/@ -]{1,60}?) *(?:\n|$)`,
screenLines: 2,
},
// `copilot mcp add` writes `~/.copilot/mcp-config.json`; COPILOT_HOME replaces ~/.copilot
// (checked: `COPILOT_HOME=<dir> copilot mcp list` reads <dir>).
mcpConfig: {
path: '.copilot/mcp-config.json',
format: 'copilot-json',
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
},
// Every one of these matches the COPILOT_ allowedPrefix, so a non-granted multi-user owner
// must not be able to set it through envOverrides. Read from the 1.0.98 bundle (copilot reads
// far more COPILOT_* variables than `copilot help environment` lists), by what each hands back:
// - approval: COPILOT_ALLOW_ALL turns every tool on; COPILOT_PLAN_THEN_AUTOPILOT switches a
// plan into autopilot; COPILOT_ASSISTED_APPROVAL; COPILOT_HOME restates the permission config.
// - code at boot or without an approval row: COPILOT_CLI_DIST_DIR runs `<dir>/index.js` in place
// of the CLI, the package caches pick which CLI build runs, COPILOT_EDITOR,
// COPILOT_PROVIDER_API_KEY_COMMAND and COPILOT_AHP_HOST_BIN are run as commands, skills and
// provider configs are loaded from the named paths, and the hook switches widen what hooks run.
// - credential redirects: the Copilot and GitHub API bases receive the CLI's sign-in token, which
// in multi-user mode is the operator's (every user shares the OS account and ~/.copilot), and
// the BYOK provider family aims a Codeman custom endpoint elsewhere.
// The prefix stays because COPILOT_GITHUB_TOKEN and the harmless knobs live in it; a variable a
// later release adds is NOT covered until it is listed here.
privilegedEnvKeys: [
'COPILOT_HOME',
'COPILOT_ALLOW_ALL',
'COPILOT_PLAN_THEN_AUTOPILOT',
'COPILOT_ASSISTED_APPROVAL',
'COPILOT_CLI_DIST_DIR',
'COPILOT_CLI_RESOLVED_DIST_DIR',
'COPILOT_PKG_CACHE_HOME',
'COPILOT_CACHE_HOME',
'COPILOT_EDITOR',
'COPILOT_AHP_HOST_BIN',
'COPILOT_DEBUG_VOICE_ENGINE_SCRIPT',
'COPILOT_SKILLS_DIRS',
'COPILOT_PROVIDERS_CONFIG',
'COPILOT_HOOK_ALLOW_HTTP_AUTH_HOOKS',
'COPILOT_HOOK_ALLOW_LOCALHOST',
'COPILOT_E2E_TEST_HOOKS',
'COPILOT_API_URL',
'COPILOT_DEBUG_GITHUB_API_URL',
'COPILOT_GH_HOST',
'COPILOT_MC_BASE_URL',
'COPILOT_AHP_URL',
'COPILOT_DEBUG_ENTRA_BROKER_URL',
'COPILOT_CAFE_METRICS_URL',
'COPILOT_PROVIDER_BASE_URL',
'COPILOT_PROVIDER_API_KEY',
'COPILOT_PROVIDER_API_KEY_COMMAND',
'COPILOT_PROVIDER_BEARER_TOKEN',
'COPILOT_PROVIDER_HEADERS',
'COPILOT_PROVIDER_TYPE',
'COPILOT_PROVIDER_WIRE_MODEL',
'COPILOT_PROVIDER_MODEL_ID',
'COPILOT_PROVIDER_GHES_HOST',
'COPILOT_PROVIDER_GHES_TOKEN',
'COPILOT_MODEL',
],
// Grok/codex-shaped clamp: a bare spawn is already Manual Approval, so the multi-user clamp
// only forces an EXPLICITLY-SENT `--yolo` back off; nothing is materialized when absent.
privilegedParams: [{ param: 'allowAll', clampTo: false }],
// BYOK (`copilot help environment`, 1.0.95): COPILOT_PROVIDER_BASE_URL switches the CLI to a
// custom provider and drops the GitHub sign-in requirement; COPILOT_PROVIDER_TYPE defaults to
// "openai", which is what a Codeman custom endpoint is (llama.cpp, vLLM, Ollama).
// COPILOT_MODEL names the model and COPILOT_PROVIDER_MODEL_ID / _WIRE_MODEL default to it.
// The OpenAI-style route is `<base>/v1/chat/completions`, so the base gets the `/v1` suffix
// the same way deepseek's does. All four already match the COPILOT_ prefix allowlist, so
// they are privileged env keys below: a non-granted multi-user owner must not be able to
// redirect the session's endpoint or credentials through plain envOverrides.
customModelInjection: {
kind: 'env',
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
modelVars: ['COPILOT_MODEL'],
appendV1Suffix: true,
},
},
overlays: {
// No credStore: Docker seeding is CRED_STORES in docker-hosts.ts, which has no `.copilot` row,
// and the sign-in token sits in plain text in config.json on a host without a keyring, so it
// is not copied into a container by default. Sign in inside the container instead.
},
};
/** The full stock catalog, in the order the run menu shows by default. */
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,
];
+11 -3
View File
@@ -525,7 +525,7 @@ export interface CliCapabilities {
* own mouse support must work in the pane (htop/vim in a shell).
*
* Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` =
* opencode (a full-screen TUI that enables tracking itself); `strip-mux-only` =
* opencode and copilot (full-screen TUIs that enable tracking themselves); `strip-mux-only` =
* antigravity, grok, deepseek, omp; `preserve` = shell, pi.
*
* A fourth combination is the point to split this into flags; three is still cheaper
@@ -539,8 +539,16 @@ export interface CliCapabilities {
/** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */
predictProfile?: string;
};
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
/**
* Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback;
* 'version-gated' forwards from `minVersion` on; 'always' forwards at any version. Either
* forwarding mode applies only while the CLI has mouse tracking on (`cliMouseTracking`).
*
* ⚠️ Declared only: nothing reads this field yet (see DECLARED-FOR-LATER below). The browser's
* gate is `_shouldForwardWheelToApp` in terminal-ui.js, which keeps its own hand-written
* per-CLI rules, so changing a value here changes nothing until that gate is changed too.
*/
wheelForward: { mode: 'never' | 'version-gated' | 'always'; minVersion?: string };
/**
* The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over
* a pane with no local history can be turned into those keys instead of doing nothing
+6 -3
View File
@@ -4,9 +4,12 @@
* `mcpSyncTargets()` (routes/mcp-sync-routes.ts) takes the registry's enabled CLIs that declare an
* `mcpConfig`. Some tools read an MCP server list worth keeping in step with the others but are not
* something Codeman launches, so they have no registry entry (and no id to branch on): GitHub
* Copilot CLI is the first. They are plain data here, take part only when installed or when their
* config file already exists (an absent tool is reported `absent`, never created), and sort after
* the registry CLIs, so when two definitions of a name differ the registry CLI's is the one copied.
* Copilot CLI is the first. It is also a run mode that declares its own `mcpConfig`, so while that
* entry is enabled this row is skipped (`mcpSyncOnlyTargets` drops ids the registry already
* supplies); it applies when the run mode is disabled but the tool is still on the machine. They are
* plain data here, take part only when installed or when their config file already exists (an
* absent tool is reported `absent`, never created), and sort after the registry CLIs, so when two
* definitions of a name differ the registry CLI's is the one copied.
*
* @module mcp-sync-targets
*/
+3
View File
@@ -20,6 +20,7 @@ import type {
AntigravityConfig,
PiConfig,
GrokConfig,
CopilotConfig,
DeepSeekConfig,
OmpConfig,
SessionRemote,
@@ -111,6 +112,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 */
@@ -156,6 +158,7 @@ export interface RespawnPaneOptions {
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
copilotConfig?: CopilotConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
/** Resume a previous Claude conversation when respawning */
+2
View File
@@ -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;
+13
View File
@@ -56,6 +56,7 @@ import {
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type CopilotConfig,
type DeepSeekConfig,
type OmpConfig,
type SessionRemote,
@@ -750,6 +751,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;
@@ -877,6 +880,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') */
@@ -1039,6 +1043,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)
@@ -2026,6 +2035,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,
@@ -2398,6 +2408,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
@@ -2886,6 +2897,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,
@@ -3450,6 +3462,7 @@ export class Session extends EventEmitter {
antigravityConfig: this._antigravityConfig,
piConfig: this._piConfig,
grokConfig: this._grokConfig,
copilotConfig: this._copilotConfig,
deepSeekConfig: this._deepSeekConfig,
ompConfig: this._ompConfig,
};
+6
View File
@@ -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,
+34 -4
View File
@@ -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,32 @@ 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 (--resume <value>): an id, id prefix or session name, as ONE plain word
* starting with a letter or digit (a leading `-` would make the value its own flag).
*/
resumeSessionId?: string;
}
/**
* DeepSeek Harness (`dsh`) session configuration.
*
@@ -893,6 +921,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') */
+4 -1
View File
@@ -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(),
};
}
+139
View File
@@ -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;
}
+6
View File
@@ -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,
+8 -4
View File
@@ -3014,9 +3014,11 @@ class CodemanApp {
? 'DeepSeek'
: mode === 'omp'
? 'OMP'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
: mode === 'copilot'
? 'GitHub Copilot'
: mode === 'opencode'
? 'OpenCode'
: 'Claude';
}
async toggleResponseViewer() {
@@ -10511,7 +10513,9 @@ class CodemanApp {
? 'Kill Tmux & DeepSeek'
: session.mode === 'omp'
? 'Kill Tmux & OMP'
: 'Kill Tmux & Claude Code';
: session.mode === 'copilot'
? 'Kill Tmux & GitHub Copilot'
: 'Kill Tmux & Claude Code';
}
document.getElementById('closeConfirmModal').classList.add('active');
+1
View File
@@ -254,6 +254,7 @@
'Run Grok': '运行 Grok',
'Run DeepSeek': '运行 DeepSeek',
'Run OMP': '运行 OMP',
'Run GitHub Copilot': '运行 GitHub Copilot',
'Run Shell': '运行 Shell',
'More tools': '更多工具',
'Select AI backend': '选择 AI 后端',
+3 -1
View File
@@ -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>
@@ -3465,6 +3466,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>
@@ -3643,7 +3645,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>
+1
View File
@@ -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' },
];
+20
View File
@@ -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;
+1 -1
View File
@@ -533,7 +533,7 @@ Object.assign(CodemanApp.prototype, {
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP' };
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini', antigravity: 'Antigravity', pi: 'Pi', grok: 'Grok', deepseek: 'DeepSeek', omp: 'OMP', copilot: 'Copilot' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
+15 -1
View File
@@ -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
+25
View File
@@ -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;
+7
View File
@@ -3672,6 +3672,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,
@@ -5853,6 +5854,12 @@ Object.assign(CodemanApp.prototype, {
if (mode && mode !== 'none') return false;
const session = this.sessions?.get(target.sessionId || this.activeSessionId);
const sessionMode = session?.mode || 'claude';
// GitHub Copilot CLI scrolls its own transcript on SGR wheel reports (checked with xterm
// encoding them natively). Its mouse DECSETs are stripped server-side so a drag selects
// locally (strip-mux-and-mouse), which also means xterm no longer encodes the wheel: send the
// same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a
// version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's.
if (sessionMode === 'copilot') return session?.cliMouseTracking === true;
if (sessionMode !== 'claude') return false;
if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
// Only while Claude is actually listening for the mouse. In its default
+167 -1
View File
@@ -11,7 +11,65 @@ export interface ResponseViewerTranscriptBlock {
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
// would drag node-pty and the whole session layer into this pure module, so the
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek']);
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek', 'copilot']);
/**
* A pane that is not Codex-shaped (`›` prompt, `•` bullets) describes its own dialect as data, so
* a new CLI adds a row here instead of another branch in the Codex-oriented loop below.
*/
interface PaneDialect {
/** Group 1 is the prompt text. A bare composer glyph with nothing after it must NOT match. */
prompt: RegExp;
/** A right-aligned timestamp the TUI appends to a submitted prompt. */
promptTrailer?: RegExp;
/** The glyph that opens an answer. */
responseBullet: RegExp;
/** Chrome that can sit between turns: banner notes, the cwd/credits row, the footer, spinners. */
status: RegExp[];
/** Rows that carry nothing (the empty composer), dropped and treated as a block boundary. */
ignore: RegExp[];
/**
* The composer row, the one between two rules at the bottom of the screen. Only the LAST rule,
* composer row, rule triple counts: an answer can hold a rule-delimited paragraph or a quoted
* prompt of its own, and that is answer text.
*/
composer: RegExp;
/** Furniture drawn at the end of a row (a scrollbar gutter), removed before anything else reads it. */
lineTrailer?: RegExp;
/**
* Text that is on screen only before the first prompt has scrolled the screen: the banner, a
* trust dialog. Its presence is how "nothing has been asked yet" is told apart from "the prompt
* row has scrolled off the top".
*/
startup: RegExp[];
}
const PANE_DIALECTS: Record<string, PaneDialect> = {
// GitHub Copilot CLI, measured on 1.0.95 inside tmux: ` ❯ <prompt> HH:MM`, then ` ● <answer>`
// with 3-space continuation lines, a cwd + `Session: N AIC used` row, the composer between two
// rules, and a `← open sidebar · … · / commands · ? help` footer. Tool-call rows are not
// modelled: they open with the same bullet and read as part of the answer.
copilot: {
// Copilot draws a submitted prompt at column 0 or 1; the continuation rows of an answer are
// indented three spaces, so a shell prompt inside a code block (` ❯ npm test`) is not a prompt.
prompt: /^ ?❯\s+(\S.*)$/,
promptTrailer: /\s{2,}\d{1,2}:\d{2}\s*$/,
responseBullet: /^\s*●\s+/,
status: [
/^\s*(?:!|●)\s+(?:Tip:|MCP Servers?\b|Model\b)/,
/^\s*!\s+/,
/\bSession: .*(?:AIC used|credits unavailable)/,
/^\s*←\s+open sidebar/,
/\/ commands · \? help/,
/^\s*[◉◎]\s+Working\b/,
],
ignore: [/^ ?❯\s*$/],
composer: /^ ?❯/,
// The right-hand scrollbar gutter, `┃` on a row (or alone on a blank one).
lineTrailer: /\s*[┃▐]\s*$/,
startup: [/Copilot v\d+\.\d+\.\d+ uses AI/, /Confirm folder trust/, /Do you trust the files in this folder/],
},
};
function isPromptLine(line: string): boolean {
return /^\s*›\s*/.test(line);
@@ -265,6 +323,111 @@ export function isExternalCliTranscriptMode(mode: string | null | undefined): bo
return EXTERNAL_CLI_MODES.has(String(mode || ''));
}
/**
* Whether this mode's pane is a repainting full-screen TUI whose output stream is absolute cursor
* moves rather than lines. The segmenter needs the SCREEN for those (tmux capture-pane), because
* stripping the escapes from the stream concatenates every row into one run of text.
*/
export function transcriptNeedsScreenCapture(mode: string | null | undefined): boolean {
return Object.prototype.hasOwnProperty.call(PANE_DIALECTS, String(mode || ''));
}
/**
* Segment a pane that has its own dialect. Everything before the first prompt is the banner (or a
* trust dialog) and is dropped, so a session that has not been asked anything answers "nothing
* yet" rather than serving its logo as a reply.
*
* The screen is all there is to read, so a long answer scrolls the prompt row off the top. When no
* prompt row is on screen AND no startup text is either, the session has already been asked
* something: the rows above the composer are the tail of the last answer, not "nothing yet".
*/
function parseDialectTranscript(cleaned: string, dialect: PaneDialect): ResponseViewerTranscriptBlock[] {
const blocks: ResponseViewerTranscriptBlock[] = [];
let kind: ResponseViewerTranscriptKind | null = null;
let current: string[] = [];
const rows = cleaned.split('\n').map((row) => (dialect.lineTrailer ? row.replace(dialect.lineTrailer, '') : row));
// The composer sits between two rules and holds whatever is typed but not sent. That is not a
// prompt row: otherwise typing under a tall answer would make the answer's tail read as banner.
let composerIndex = -1;
for (let i = rows.length - 2; i > 0; i -= 1) {
if (
dialect.composer.test(rows[i] ?? '') &&
isDividerOnlyLine(rows[i - 1] ?? '') &&
isDividerOnlyLine(rows[i + 1] ?? '')
) {
composerIndex = i;
break;
}
}
const isComposerRow = (i: number) => i === composerIndex;
const isComposerRule = (i: number) => composerIndex !== -1 && (i === composerIndex - 1 || i === composerIndex + 1);
const hasPrompt = rows.some((row, i) => dialect.prompt.test(row) && !isComposerRow(i));
const startupVisible = rows.some((row) => dialect.startup.some((re) => re.test(row)));
let sawPrompt = !hasPrompt && !startupVisible;
// A rule drawn INSIDE an answer (a markdown `---`, a quoted passage between two rules) is part
// of the answer, so it must not end the block, or `agent read` returns only the text after the
// last rule. It is held until more of the same answer follows, so a rule that only separates
// the answer from the footer never trails it. The composer's own two rules stay boundaries.
let pendingRule = false;
const flush = () => {
if (sawPrompt) pushBlock(blocks, kind, current);
kind = null;
current = [];
pendingRule = false;
};
for (const [index, line] of rows.entries()) {
if (kind === 'response' && isDividerOnlyLine(line) && !isComposerRule(index)) {
pendingRule = true;
continue;
}
if (isDividerOnlyLine(line) || isComposerRow(index) || dialect.ignore.some((re) => re.test(line))) {
flush();
continue;
}
const prompt = dialect.prompt.exec(line);
if (prompt) {
flush();
sawPrompt = true;
kind = 'prompt';
current = [dialect.promptTrailer ? prompt[1].replace(dialect.promptTrailer, '').trim() : prompt[1].trim()];
continue;
}
if (!sawPrompt) continue;
if (dialect.status.some((re) => re.test(line))) {
if (kind !== 'status') flush();
kind = 'status';
current.push(line.trim());
continue;
}
// A wrapped warning or tip: indented under its status row.
if (kind === 'status' && /^\s{3,}\S/.test(line)) {
current.push(line.trim());
continue;
}
if (!line.trim()) {
if (kind) current.push('');
continue;
}
const bullet = dialect.responseBullet.exec(line);
if (bullet) {
flush();
kind = 'response';
current = [line.slice(bullet[0].length)];
continue;
}
if (!kind) kind = 'response';
if (pendingRule) {
current.push('', '---', '');
pendingRule = false;
}
current.push(line);
}
flush();
return blocks.filter((block) => block.text.trim().length > 0);
}
export function parseExternalCliTranscript(
buffer: string,
mode: string | null | undefined
@@ -275,6 +438,9 @@ export function parseExternalCliTranscript(
const cleaned = cleanTerminalTranscript(buffer);
if (!cleaned) return [];
const dialect = PANE_DIALECTS[resolvedMode];
if (dialect) return parseDialectTranscript(cleaned, dialect);
const lines = trimLeadingStartup(cleaned.split('\n'));
const blocks: ResponseViewerTranscriptBlock[] = [];
let currentKind: ResponseViewerTranscriptKind | null = null;
+4 -4
View File
@@ -10,8 +10,8 @@
* only, never env values, headers or file content (a parse failure is reported by position).
*
* A CLI takes part when it is ENABLED in the registry, declares an `mcpConfig`, and is installed
* or already has its config file (Copilot CLI, which is not a registry CLI, takes part when installed
* or when its config file exists); one that is enabled but absent from the machine is reported
* or already has its config file (Copilot CLI declares its own `mcpConfig`; with its run mode disabled it still
* takes part as a sync-only tool when installed or when its config file exists); one that is enabled but absent from the machine is reported
* `absent` and never created. Its file is located with this process's env (the env the CLIs
* Codeman spawns inherit), so a relocation var such as `CODEX_HOME` is followed.
*/
@@ -39,8 +39,8 @@ export async function readMcpSyncEnabled(): Promise<boolean> {
/**
* Enabled CLIs that declare an MCP config file, in registry order (first definition wins), then the
* sync-only tools (src/mcp-sync-targets.ts: Copilot CLI), which come last so a registry CLI's
* definition wins a same-name difference.
* sync-only tools (src/mcp-sync-targets.ts: Copilot CLI while its run mode is disabled), which come
* last so a registry CLI's definition wins a same-name difference.
*/
export function mcpSyncTargets(availability: Record<string, boolean>): McpSyncTarget[] {
const registry = enabledClis()
+28 -4
View File
@@ -28,6 +28,7 @@ import {
type AntigravityConfig,
type PiConfig,
type GrokConfig,
type CopilotConfig,
type DeepSeekConfig,
type OmpConfig,
type RemoteHost,
@@ -189,6 +190,7 @@ import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
import {
transcriptNeedsScreenCapture,
getLastTranscriptResponse,
isExternalCliTranscriptMode,
parseExternalCliTranscript,
@@ -628,7 +630,8 @@ export async function _clampExternalCliBypassForOwner(
antigravityConfig?: AntigravityConfig,
piConfig?: PiConfig,
grokConfig?: GrokConfig,
deepSeekConfig?: DeepSeekConfig
deepSeekConfig?: DeepSeekConfig,
copilotConfig?: CopilotConfig
): Promise<{
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
@@ -636,6 +639,7 @@ export async function _clampExternalCliBypassForOwner(
piConfig: PiConfig | undefined;
grokConfig: GrokConfig | undefined;
deepSeekConfig: DeepSeekConfig | undefined;
copilotConfig: CopilotConfig | undefined;
}> {
const out = await clampExternalCliBypassForOwner(owner, {
codexConfig,
@@ -644,6 +648,7 @@ export async function _clampExternalCliBypassForOwner(
piConfig,
grokConfig,
deepSeekConfig,
copilotConfig,
});
return out as {
codexConfig: CodexConfig | undefined;
@@ -652,6 +657,7 @@ export async function _clampExternalCliBypassForOwner(
piConfig: PiConfig | undefined;
grokConfig: GrokConfig | undefined;
deepSeekConfig: DeepSeekConfig | undefined;
copilotConfig: CopilotConfig | undefined;
};
}
@@ -1365,6 +1371,7 @@ export function registerSessionRoutes(
piConfig: gatedPiConfig,
grokConfig: gatedGrokConfig,
deepSeekConfig: gatedDeepSeekConfig,
copilotConfig: gatedCopilotConfig,
} = await _clampExternalCliBypassForOwner(
owner,
launchBody.codexConfig,
@@ -1372,7 +1379,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({
@@ -1391,6 +1399,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,
@@ -2827,7 +2836,14 @@ export function registerSessionRoutes(
// where a real rollout file is the better source.
if (isExternalCliTranscriptMode(session.mode)) {
const externalQuery = req.query as { context?: string };
const blocks = parseExternalCliTranscript(session.terminalBuffer, session.mode);
// A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the
// buffer cannot be segmented; read the pane's visible screen instead, and fall back to the
// buffer when there is no tmux pane to ask (direct PTY, tests).
const screen =
transcriptNeedsScreenCapture(session.mode) && session.muxName
? (ctx.mux.capturePaneText?.(session.muxName) ?? null)
: null;
const blocks = parseExternalCliTranscript(screen ?? session.terminalBuffer, session.mode);
return {
text: getLastTranscriptResponse(blocks),
timestamp: '',
@@ -3742,6 +3758,7 @@ export function registerSessionRoutes(
antigravityConfig,
piConfig,
grokConfig,
copilotConfig,
deepSeekConfig,
ompConfig,
envOverrides,
@@ -3813,6 +3830,7 @@ export function registerSessionRoutes(
antigravityConfig ||
piConfig ||
grokConfig ||
copilotConfig ||
deepSeekConfig ||
ompConfig ||
openCodeConfig ||
@@ -3883,6 +3901,7 @@ export function registerSessionRoutes(
antigravityConfig ||
piConfig ||
grokConfig ||
copilotConfig ||
deepSeekConfig ||
ompConfig ||
openCodeConfig ||
@@ -3983,6 +4002,7 @@ export function registerSessionRoutes(
antigravityConfig,
piConfig,
grokConfig,
copilotConfig,
deepSeekConfig,
} as unknown as Record<string, unknown>)
);
@@ -4185,6 +4205,7 @@ export function registerSessionRoutes(
antigravityConfig,
piConfig,
grokConfig,
copilotConfig,
deepSeekConfig,
ompConfig,
};
@@ -4214,6 +4235,7 @@ export function registerSessionRoutes(
piConfig: qsGatedPiConfig,
grokConfig: qsGatedGrokConfig,
deepSeekConfig: qsGatedDeepSeekConfig,
copilotConfig: qsGatedCopilotConfig,
} = await _clampExternalCliBypassForOwner(
owner,
qsLaunchConfigs.codexConfig,
@@ -4221,7 +4243,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);
@@ -4388,6 +4411,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,
+14
View File
@@ -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)
// ═══════════════════════════════════════════════════════════════
+26 -2
View File
@@ -194,7 +194,7 @@ function sessionModeSchema(): z.ZodType<SessionMode> {
/**
* Allowlisted env var key prefixes, contributed by the ENABLED CLIs in the registry
* (`env.allowedPrefixes`) — `CLAUDE_CODE_`, `OPENCODE_`, `CODEX_`, `GEMINI_`, `GOOGLE_`,
* `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_` as shipped.
* `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_`, `OMP_`, `COPILOT_` as shipped.
*
* ⚠️ Resolved AT PARSE TIME, not at module load. This used to be a frozen array computed
* once when the module was imported, which meant a CLI enabled while the server was running
@@ -259,7 +259,7 @@ const safeEnvOverridesSchema = z
},
{
message:
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_* keys and CLAUDE_CONFIG_DIR are allowed.',
'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_*, OPENCODE_*, CODEX_*, GEMINI_*, GOOGLE_*, ANTIGRAVITY_*, PI_*, GROK_*, XAI_*, DSH_*, DEEPSEEK_*, OMP_*, COPILOT_* keys and CLAUDE_CONFIG_DIR are allowed.',
}
);
@@ -439,6 +439,28 @@ const GrokConfigSchema = z
})
.optional();
const CopilotConfigSchema = z
.object({
// A leading alphanumeric, like resumeSessionId below: a value like `--yolo` would otherwise be read
// as its own flag after `--model` (bypassing the allowAll clamp). `/` stays for provider/id.
model: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/)
.optional(),
allowAll: z.boolean().optional(),
continueSession: z.boolean().optional(),
// A single plain word with a leading alphanumeric: --resume also matches session NAMES, so
// `my-feature` is accepted, but a value like `--yolo` must not be, because --resume's value is
// optional and the next token would be read as its own flag (bypassing the allowAll clamp).
resumeSessionId: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/)
.optional(),
})
.optional();
/**
* Schema for OMP CLI-specific configuration.
*/
@@ -596,6 +618,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 +1218,7 @@ export const QuickStartSchema = z.object({
antigravityConfig: AntigravityConfigSchema,
piConfig: PiConfigSchema,
grokConfig: GrokConfigSchema,
copilotConfig: CopilotConfigSchema,
deepSeekConfig: DeepSeekConfigSchema,
ompConfig: OmpConfigSchema,
envOverrides: safeEnvOverridesSchema,
+1
View File
@@ -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,