mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Merge pull request #592: feat(cli-registry): GitHub Copilot CLI as a run mode
GitHub Copilot CLI (`copilot`) becomes a run mode: Run menu, welcome screen, cron and CLI management once it is installed, tmux-backed like the other agent CLIs, working/idle and the model read off its footer, launch options through `copilotConfig` (model, resume, continue, the multi-user-clamped allowAll), Custom Model Endpoints via Copilot's BYOK variables, MCP sync of its mcp-config.json, a status probe, and the `codeman agent` verbs (spawn waits for its composer, read returns its last answer). The plan-usage quota row that earlier rounds carried was split out on review and is not part of this merge. Merge-time commits on top: the wider COPILOT_* clamp and the zh-CN README rows (maintainer), the Docker sign-in docs, divider rows inside an answer kept by `agent read`, route tests for the screen-read last-response branch, and doc drift (Past Sessions, wheelForward JSDoc, COPILOT_/OMP_ in the env prefix lists). Verified before merge on Copilot CLI 1.0.98: injected keystrokes, bracketed paste and Enter reach the TUI in tmux (github/copilot-cli#4180 does not reproduce), a shell tool call completes inside tmux (#4223 does not reproduce), and a session driven through this branch answered over send-and-wait. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
8e6abf4bfd
80 files changed
+1911
-132
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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 后端',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -58,6 +58,7 @@ const MOBILE_OVERVIEW_RUN_MODES = [
|
||||
{ mode: 'grok', label: 'Grok', short: 'Grok' },
|
||||
{ mode: 'deepseek', label: 'DeepSeek', short: 'DeepSeek' },
|
||||
{ mode: 'omp', label: 'OMP', short: 'OMP' },
|
||||
{ mode: 'copilot', label: 'GitHub Copilot', short: 'GitHub Copilot' },
|
||||
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1240,6 +1240,20 @@ html.mobile-init .file-browser-panel {
|
||||
border-color: rgba(129, 140, 248, 0.5) !important;
|
||||
}
|
||||
|
||||
/* GitHub Copilot mode colors on mobile. Same `!important` rationale as the pi/grok/deepseek/omp blocks above. */
|
||||
.btn-toolbar.btn-run.mode-copilot,
|
||||
.btn-toolbar.btn-run-gear.mode-copilot {
|
||||
background: #2e1065 !important;
|
||||
border-color: rgba(163, 113, 247, 0.3) !important;
|
||||
color: #ede9fe !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-run.mode-copilot:active,
|
||||
.btn-toolbar.btn-run-gear.mode-copilot:active {
|
||||
background: #6d28d9 !important;
|
||||
border-color: rgba(163, 113, 247, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Run mode dropdown menu — positioned above toolbar on mobile */
|
||||
.run-mode-menu {
|
||||
bottom: 100%;
|
||||
@@ -3337,6 +3351,12 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-copilot, .btn-toolbar.btn-run-gear.mode-copilot) {
|
||||
background: linear-gradient(135deg, #6d28d9, #8b5cf6);
|
||||
border-color: #5b21b6;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-grok, .btn-toolbar.btn-run-gear.mode-grok) {
|
||||
background: linear-gradient(135deg, #27272a, #52525b);
|
||||
border-color: #18181b;
|
||||
|
||||
@@ -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,7 +15,7 @@
|
||||
* PR B2: the single source for every non-Claude, non-Shell run mode's launch
|
||||
* shape, consumed by `_runCliMode()` below. Before this table existed, each of
|
||||
* `runOpenCode`/`runCodex`/`runGemini`/`runAntigravity`/`runPi`/`runOmp`/
|
||||
* `runGrok`/`runDeepSeek` was a ~45-line copy of the same probe/launch/select
|
||||
* `runGrok`/`runDeepSeek`/`runCopilot` was a ~45-line copy of the same probe/launch/select
|
||||
* skeleton with only the CLI-specific pieces below actually differing — eight
|
||||
* near-identical bodies guaranteed to drift, exactly what the CLI registry's
|
||||
* own no-id-branching rule exists to prevent server-side.
|
||||
@@ -79,6 +79,16 @@ const RUN_MODE_LAUNCH = {
|
||||
supportsCustomModel: true,
|
||||
buildConfig: () => null,
|
||||
},
|
||||
copilot: {
|
||||
label: 'GitHub Copilot',
|
||||
installHint: 'GitHub Copilot CLI not found. Install with: npm install -g @github/copilot',
|
||||
// Custom endpoints go in through Copilot's BYOK env vars (COPILOT_PROVIDER_*).
|
||||
supportsCustomModel: true,
|
||||
// Sends `copilotConfig: { allowAll: true }` the way grok sends `alwaysApprove: true`:
|
||||
// Codeman sessions exist for autonomous work, so the Run button opts into `--yolo`.
|
||||
// The multi-user clamp forces it back off for non-granted owners server-side.
|
||||
buildConfig: () => ({ copilotConfig: { allowAll: true } }),
|
||||
},
|
||||
grok: {
|
||||
label: 'Grok',
|
||||
installHint: 'Grok CLI not found. Install with: curl -fsSL https://x.ai/cli/install.sh | bash',
|
||||
@@ -2493,6 +2503,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this._runCliMode('deepseek');
|
||||
},
|
||||
|
||||
async runCopilot() {
|
||||
return this._runCliMode('copilot');
|
||||
},
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Session Options Modal
|
||||
|
||||
@@ -5785,6 +5785,22 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
color: #eef2ff;
|
||||
}
|
||||
|
||||
/* GitHub Copilot mode colors */
|
||||
.btn-toolbar.btn-run.mode-copilot,
|
||||
.btn-toolbar.btn-run-gear.mode-copilot {
|
||||
background: linear-gradient(135deg, #2e1065 0%, #6d28d9 55%, #7c3aed 100%);
|
||||
border-color: rgba(163, 113, 247, 0.5);
|
||||
color: #ede9fe;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-copilot:hover,
|
||||
.btn-toolbar.btn-run-gear.mode-copilot:hover {
|
||||
background: linear-gradient(135deg, #3b0f8c 0%, #7c3aed 55%, #8b5cf6 100%);
|
||||
box-shadow: 0 0 12px rgba(163, 113, 247, 0.35), 0 2px 8px rgba(109, 40, 217, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(196, 167, 252, 0.6);
|
||||
color: #f5f3ff;
|
||||
}
|
||||
|
||||
/* Grok mode colors. Same cascade note as pi above: this base-sheet pair only
|
||||
renders on the `og` skin — the nested `html:not([data-skin="og"])` block
|
||||
re-declares `.btn-toolbar.btn-run` at a HIGHER specificity, so grok also
|
||||
@@ -5944,6 +5960,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
.run-mode-dot.opencode,
|
||||
.run-mode-dot.pi,
|
||||
.run-mode-dot.grok,
|
||||
.run-mode-dot.copilot,
|
||||
.run-mode-dot.shell,
|
||||
.run-mode-dot.web {
|
||||
width: 1.15em;
|
||||
@@ -5964,6 +5981,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
.run-mode-dot.opencode,
|
||||
.run-mode-dot.pi,
|
||||
.run-mode-dot.grok,
|
||||
.run-mode-dot.copilot,
|
||||
.run-mode-dot.shell,
|
||||
.run-mode-dot.web {
|
||||
background-color: currentColor;
|
||||
@@ -5979,6 +5997,7 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
.run-mode-dot.opencode { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M16 6H8v12h8V6zm4 16H4V2h16v20z'/%3E%3C/svg%3E"); }
|
||||
.run-mode-dot.pi { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath clip-rule='evenodd' d='M1 1h16.5v11H12v5.5H6.5V23H1V1zm5.5 5.5V12H12V6.5H6.5z'/%3E%3Cpath d='M17.5 12H23v11h-5.5V12z'/%3E%3C/svg%3E"); }
|
||||
.run-mode-dot.grok { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815'/%3E%3C/svg%3E"); }
|
||||
.run-mode-dot.copilot { --run-mode-logo: url("data:image/svg+xml,%3Csvg fill-rule='evenodd' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 3C7 3 3 6.5 3 11v4a3 3 0 003 3h12a3 3 0 003-3v-4c0-4.5-4-8-9-8zM8.5 11.5a1.5 2 0 110 4 1.5 2 0 010-4zm7 0a1.5 2 0 110 4 1.5 2 0 010-4z'/%3E%3C/svg%3E"); }
|
||||
.run-mode-dot.shell { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2.5' y='3.5' width='19' height='17' rx='3'/%3E%3Cpath d='M7 9.5l3 2.5-3 2.5M12.5 15h4.5'/%3E%3C/svg%3E"); }
|
||||
.run-mode-dot.web { --run-mode-logo: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20'/%3E%3C/svg%3E"); }
|
||||
|
||||
@@ -16375,6 +16394,12 @@ html:not([data-skin="og"]) {
|
||||
color: #e0e7ff;
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-omp:hover { box-shadow: 0 0 14px -2px rgba(129, 140, 248, 0.45); }
|
||||
.btn-toolbar.btn-run.mode-copilot {
|
||||
background: linear-gradient(135deg, #6d28d9, #8b5cf6);
|
||||
border-color: #5b21b6;
|
||||
color: #ede9fe;
|
||||
}
|
||||
.btn-toolbar.btn-run.mode-copilot:hover { box-shadow: 0 0 14px -2px rgba(163, 113, 247, 0.45); }
|
||||
.btn-toolbar.btn-run.mode-pi {
|
||||
background: linear-gradient(135deg, #be185d, #f472b6);
|
||||
border-color: #be185d;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user