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

+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,