mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79a0399552 | ||
|
|
61251c0b94 | ||
|
|
bb4ba79791 | ||
|
|
d7ad73bc9b | ||
|
|
96ee8b536d | ||
|
|
b7f3b07c79 | ||
|
|
2073a1b185 | ||
|
|
f9a8493823 | ||
|
|
d2711ef092 | ||
|
|
30a15adbd6 | ||
|
|
a35438ba34 | ||
|
|
fef903df98 | ||
|
|
02e7d3fcba | ||
|
|
63c5ba89da | ||
|
|
7fc4784d0f |
@@ -1,5 +1,25 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Response viewer for OpenCode, Gemini, Antigravity and Pi sessions (#326). External CLIs render their own TUIs, so the viewer used to come up empty for them; a new transcript parser (`response-viewer-transcript.ts`) reconstructs the conversation from the pane text instead, and the `?context=full` view now tags every block with a role so prompts render as "You" and agent output as the assistant. The divider normalizer was rewritten as a linear scan after review found catastrophic backtracking on agent-controlled input (minutes of stall on a long dash run), with an equivalence corpus pinning the old accept set.
|
||||
|
||||
CLIs installed via nvm or Homebrew are now found when Codeman runs as a service (#329). A shared resolver falls back to a login-shell probe when the direct PATH lookup misses, so systemd and LaunchAgent installs no longer report every CLI as missing. Review hardening on top: a failed resolution is negative-cached with doubling backoff instead of re-spawning a login shell on every request, all probes pass `killSignal: 'SIGKILL'` (interactive bash shrugs off SIGTERM, and a blocking `.bash_profile` could have hung the server indefinitely), the resolvers are inert under vitest again so test suites cannot execute binaries found on the dev box, and the improved not-found guidance is wired into both the session-create errors and the per-CLI status endpoints.
|
||||
|
||||
`GET /api/system/repo-status` reports branch, upstream, ahead/behind and remote reachability for git-clone installs (#328). Review hardening: the git network calls moved off the synchronous path onto a single-flight 45s cache (one slow remote could previously freeze the whole server for up to a minute per request), remote URLs and git stderr are credential-redacted before they leave the server, the spawns use the same non-interactive git env as the clone path, and a local-branch upstream no longer parses into garbage.
|
||||
|
||||
Auto Copy for the terminal (#325, opt-in, per-device): a finished selection (mouse drag, double or triple click, or a phone long-press) lands on the clipboard by itself, so select-then-copy becomes select. Alongside it, hand-encoded tap reports are now gated on the server-observed `cliMouseTracking` state, so a pane that has fallen back to a plain shell no longer receives `[<0;88;20M` junk on tap.
|
||||
|
||||
The Ralph loop no longer stops polling after two ticks (#330): the reschedule guard read a stale timer handle that the timer callback never cleared, so the loop silently died while its status stayed `running`. The handle is now nulled as the callback's first statement, and a regression test pins the bug.
|
||||
|
||||
The red "needs you" tab alert clears when a dialog is answered in the terminal instead of surviving until the end of the turn: the post-hook re-capture could erase the parsed dialog options that the staleness sweep relies on (`applyCapture` is now add-only for options), and a delayed staleness pass now runs while a page is open. The unreachable `copyTerminal()` was removed, closing out #322.
|
||||
|
||||
### Thanks
|
||||
- @aakhter contributed the external-CLI response viewer (#326), the repo-status endpoint (#328), the login-shell CLI resolution (#329) and the Ralph reschedule fix (#330)
|
||||
- @rounakdatta reported the mobile copy gap (#322) closed out in this release
|
||||
|
||||
## 1.19.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -100,7 +100,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously (so clicks keep being reported exactly as before), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
||||
|
||||
**Only claude ≥ 2.1.187 forwards the wheel; every other mode scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS stay enabled for codex (`_sessionUsesServerMouseStrip`); measured, they are no-ops that insert nothing, so click-to-position is simply unavailable there rather than harmful.
|
||||
**Only claude ≥ 2.1.187 forwards the wheel; every other mode scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (strip mode AND the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk.
|
||||
|
||||
**Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 — version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.19.7",
|
||||
"version": "1.20.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.19.7",
|
||||
"version": "1.20.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.19.7",
|
||||
"version": "1.20.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+11
-2
@@ -479,14 +479,23 @@ export function gitNonInteractiveEnv(base: NodeJS.ProcessEnv = process.env): Nod
|
||||
|
||||
// ─── Pure: output handling ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Redact any `scheme://user:secret@host` credential pair embedded in text — a
|
||||
* remote URL stored with an inline token, or git stderr echoing such a URL
|
||||
* back. Shared by the clone error path (`sanitizeGitOutput`) and the
|
||||
* repository-status card fields (`web/repo-status.ts`).
|
||||
*/
|
||||
export function redactGitCredentials(text: string): string {
|
||||
return text.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]*:[^/@\s]*@/g, '$1***:***@');
|
||||
}
|
||||
|
||||
/**
|
||||
* Make git's stderr safe to show in the browser: strip ANSI/control bytes,
|
||||
* redact any `scheme://user:secret@host` that a credential helper echoed back,
|
||||
* and keep only the tail (the last lines are the ones that say why it failed).
|
||||
*/
|
||||
export function sanitizeGitOutput(text: string, maxBytes = MAX_STDERR_BYTES): string {
|
||||
const redacted = text
|
||||
.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]*:[^/@\s]*@/g, '$1***:***@')
|
||||
const redacted = redactGitCredentials(text)
|
||||
// eslint-disable-next-line no-control-regex -- deliberate: strip C0/C1 and DEL.
|
||||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
|
||||
.trim();
|
||||
|
||||
+8
-1
@@ -281,7 +281,14 @@ export class RalphLoop extends EventEmitter {
|
||||
// Guard: only reschedule if still running AND no timer is pending
|
||||
// (prevents race where stop() clears timer between our check and setTimeout)
|
||||
if (this._status === 'running' && this.loopTimer === null) {
|
||||
this.loopTimer = setTimeout(() => this.runLoop(), this.pollIntervalMs);
|
||||
// Null the handle when the timer fires, BEFORE re-entering runLoop —
|
||||
// otherwise the `loopTimer === null` guard above stays false on the
|
||||
// next pass and the loop stops rescheduling after 2 ticks.
|
||||
// Mirrors the orchestrator-loop reschedule pattern.
|
||||
this.loopTimer = setTimeout(() => {
|
||||
this.loopTimer = null;
|
||||
this.runLoop();
|
||||
}, this.pollIntervalMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+16
-11
@@ -75,11 +75,17 @@ import {
|
||||
SAFE_PATH_PATTERN,
|
||||
findClaudeDir,
|
||||
getClaudeCliVersion,
|
||||
getClaudeNotFoundMessage,
|
||||
resolveOpenCodeDir,
|
||||
getOpenCodeNotFoundMessage,
|
||||
resolveCodexDir,
|
||||
getCodexNotFoundMessage,
|
||||
resolveGeminiDir,
|
||||
getGeminiNotFoundMessage,
|
||||
resolveAntigravityDir,
|
||||
getAntigravityNotFoundMessage,
|
||||
resolvePiDir,
|
||||
getPiNotFoundMessage,
|
||||
resolveLocalShell,
|
||||
loginShellArgs,
|
||||
} from './utils/index.js';
|
||||
@@ -1853,29 +1859,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
return session;
|
||||
}
|
||||
|
||||
// Resolve CLI binary directory based on mode
|
||||
// Resolve CLI binary directory based on mode. The not-found messages come
|
||||
// from the resolvers (formatCliNotFoundMessage) so the error names WHERE it
|
||||
// looked — server PATH, login shell, checked directories — instead of just
|
||||
// asserting the CLI is missing (the classic systemd/launchd PATH trap).
|
||||
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
|
||||
if (mode === 'claude' && !cliDir) {
|
||||
throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
|
||||
throw new Error(getClaudeNotFoundMessage());
|
||||
}
|
||||
if (mode === 'opencode' && !cliDir) {
|
||||
throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
|
||||
throw new Error(getOpenCodeNotFoundMessage());
|
||||
}
|
||||
if (mode === 'codex' && !cliDir) {
|
||||
throw new Error('Codex CLI not found. Install with: npm install -g @openai/codex');
|
||||
throw new Error(getCodexNotFoundMessage());
|
||||
}
|
||||
if (mode === 'gemini' && !cliDir) {
|
||||
throw new Error('Gemini CLI not found. Install with: npm install -g @google/gemini-cli');
|
||||
throw new Error(getGeminiNotFoundMessage());
|
||||
}
|
||||
if (mode === 'antigravity' && !cliDir) {
|
||||
throw new Error(
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
throw new Error(getAntigravityNotFoundMessage());
|
||||
}
|
||||
if (mode === 'pi' && !cliDir) {
|
||||
throw new Error(
|
||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
||||
);
|
||||
throw new Error(getPiNotFoundMessage());
|
||||
}
|
||||
|
||||
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
|
||||
|
||||
@@ -98,3 +98,52 @@ export interface UpdateCheckResult {
|
||||
source: 'github-api' | 'git-ls-remote' | 'none';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role of a remote in the repository-status view.
|
||||
* - `tracking`: the current branch's `@{upstream}` remote (where `git pull` goes).
|
||||
* - `upstream`: the canonical project (a remote named `origin`/`upstream` that is
|
||||
* not the tracking remote).
|
||||
* - `other`: anything else explicitly requested via `CODEMAN_UPDATE_REMOTES`.
|
||||
*/
|
||||
export type RepoRemoteRole = 'tracking' | 'upstream' | 'other';
|
||||
|
||||
/** A single incoming commit — present on the remote ref but not in local HEAD. */
|
||||
export interface RepoIncomingCommit {
|
||||
/** Abbreviated SHA. */
|
||||
sha: string;
|
||||
/** Commit subject (first line). */
|
||||
subject: string;
|
||||
}
|
||||
|
||||
/** Ahead/behind + incoming summary for local HEAD vs one remote's compare ref. */
|
||||
export interface RepoRemoteStatus {
|
||||
/** Remote name, e.g. `origin`, `bitbucket`. */
|
||||
name: string;
|
||||
/** Remote URL (best-effort; empty if unresolved). */
|
||||
url: string;
|
||||
role: RepoRemoteRole;
|
||||
/** Ref HEAD is compared against, e.g. `origin/master`, `bitbucket/local`. */
|
||||
compareRef: string;
|
||||
/** Commits in local HEAD not on the remote ref (local-only / unpushed). */
|
||||
ahead: number;
|
||||
/** Commits on the remote ref not in local HEAD (incoming). */
|
||||
behind: number;
|
||||
/** Up to N most recent incoming commits (newest first). */
|
||||
incoming: RepoIncomingCommit[];
|
||||
/** Set when this remote could not be fetched/compared. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Result of the repository-status check across the configured remotes. */
|
||||
export interface RepositoryStatusResult {
|
||||
/** epoch ms of the check. */
|
||||
checkedAt: number;
|
||||
/** False when this is not a git install (then `remotes` is empty + `error` set). */
|
||||
isGit: boolean;
|
||||
/** Current running version, for display. */
|
||||
currentVersion: string;
|
||||
remotes: RepoRemoteStatus[];
|
||||
/** Top-level error (e.g. not a git install, or no remotes resolved). */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -7,22 +7,37 @@
|
||||
* @module utils/antigravity-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import {
|
||||
createCliExecutableResolver,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the Antigravity CLI binary may be installed */
|
||||
const ANTIGRAVITY_SEARCH_DIRS = [
|
||||
join(homedir(), '.local', 'bin'),
|
||||
join(homedir(), '.antigravity', 'bin'),
|
||||
'/usr/local/bin',
|
||||
join(homedir(), '.bun', 'bin'),
|
||||
join(homedir(), '.npm-global', 'bin'),
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/** Cached directory containing the agy binary (empty string = searched but not found) */
|
||||
let _antigravityDir: string | null = null;
|
||||
const ANTIGRAVITY_NOT_FOUND =
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash';
|
||||
|
||||
function createAntigravityResolver(host?: CliResolverHost, now?: () => number) {
|
||||
return createCliExecutableResolver({ binary: 'agy', searchDirs: ANTIGRAVITY_SEARCH_DIRS, now }, host);
|
||||
}
|
||||
|
||||
/** Creates an isolated Antigravity wrapper around an injected resolver host and clock. */
|
||||
export function createAntigravityResolverForTest(host: CliResolverHost, now?: () => number) {
|
||||
return createAntigravityResolver(host, now);
|
||||
}
|
||||
|
||||
const antigravityResolver = createAntigravityResolver();
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `agy` binary.
|
||||
@@ -31,30 +46,7 @@ let _antigravityDir: string | null = null;
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveAntigravityDir(): string | null {
|
||||
if (_antigravityDir !== null) return _antigravityDir || null;
|
||||
|
||||
try {
|
||||
const result = execSync('which agy', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_antigravityDir = dirname(result);
|
||||
return _antigravityDir;
|
||||
}
|
||||
} catch {
|
||||
// agy not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of ANTIGRAVITY_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'agy'))) {
|
||||
_antigravityDir = dir;
|
||||
return _antigravityDir;
|
||||
}
|
||||
}
|
||||
|
||||
_antigravityDir = '';
|
||||
return null;
|
||||
return antigravityResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,3 +55,7 @@ export function resolveAntigravityDir(): string | null {
|
||||
export function isAntigravityAvailable(): boolean {
|
||||
return resolveAntigravityDir() !== null;
|
||||
}
|
||||
|
||||
export function getAntigravityNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(ANTIGRAVITY_NOT_FOUND, antigravityResolver.diagnostics());
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
* @module utils/claude-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync, execFileSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { delimiter, dirname, join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { delimiter, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the Claude CLI binary may be installed */
|
||||
const CLAUDE_SEARCH_DIRS = [
|
||||
@@ -23,8 +23,8 @@ const CLAUDE_SEARCH_DIRS = [
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/** Cached directory containing the claude binary (empty string = searched but not found) */
|
||||
let _claudeDir: string | null = null;
|
||||
const claudeResolver = createCliExecutableResolver({ binary: 'claude', searchDirs: CLAUDE_SEARCH_DIRS });
|
||||
const CLAUDE_NOT_FOUND = 'Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash';
|
||||
|
||||
/**
|
||||
* Returns true if the Claude CLI binary can be located (via `which` or one of
|
||||
@@ -43,29 +43,11 @@ export function isClaudeAvailable(): boolean {
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function findClaudeDir(): string | null {
|
||||
if (_claudeDir !== null) return _claudeDir || null;
|
||||
return claudeResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
// Try `which` first (respects current PATH)
|
||||
try {
|
||||
const result = execSync('which claude', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_claudeDir = dirname(result);
|
||||
return _claudeDir;
|
||||
}
|
||||
} catch {
|
||||
// Claude not in PATH, will check common locations
|
||||
}
|
||||
|
||||
// Fallback: check common installation directories
|
||||
for (const dir of CLAUDE_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'claude'))) {
|
||||
_claudeDir = dir;
|
||||
return _claudeDir;
|
||||
}
|
||||
}
|
||||
|
||||
_claudeDir = ''; // mark as searched, not found
|
||||
return null;
|
||||
export function getClaudeNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(CLAUDE_NOT_FOUND, claudeResolver.diagnostics());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +81,9 @@ export function getAugmentedPath(): string {
|
||||
const currentPath = process.env.PATH || '';
|
||||
const claudeDir = findClaudeDir();
|
||||
|
||||
if (claudeDir && !currentPath.split(delimiter).includes(claudeDir)) {
|
||||
if (!claudeDir) return currentPath;
|
||||
|
||||
if (!currentPath.split(delimiter).includes(claudeDir)) {
|
||||
_augmentedPath = `${claudeDir}${delimiter}${currentPath}`;
|
||||
return _augmentedPath;
|
||||
}
|
||||
@@ -193,6 +177,9 @@ function probeClaudeCliVersion(): string | null {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
env: { ...process.env, PATH: getAugmentedPath() },
|
||||
// execFileSync's timeout only SENDS the signal and then keeps waiting; a
|
||||
// child that ignores SIGTERM would block the server thread permanently.
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
const match = out.match(/(\d+\.\d+\.\d+)/);
|
||||
return match ? match[1] : null;
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @fileoverview Shared CLI executable resolution for the per-CLI resolvers.
|
||||
*
|
||||
* One lookup chain behind all six *-cli-resolver modules (claude, opencode,
|
||||
* codex, gemini, antigravity, pi): the server process PATH first, then the
|
||||
* CLI's common install directories in order, 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 (launchd hands a job `/usr/bin:/bin:/usr/sbin:/sbin`).
|
||||
*
|
||||
* Caching is asymmetric, same shape as `resolveClaudeCliVersion` in
|
||||
* claude-cli-resolver.ts: a successful resolution is cached for the process
|
||||
* lifetime, a MISS is negative-cached and retried only after a doubling backoff
|
||||
* (`cliResolveRetryDelayMs`). The callers are request-facing (the per-CLI
|
||||
* status endpoints in system-routes.ts, the availability gates in
|
||||
* session-routes.ts, and tmux-manager's spawn path), and the login-shell probe
|
||||
* is a SYNCHRONOUS spawn bounded by `EXEC_TIMEOUT_MS` — without the negative
|
||||
* cache, a missing CLI re-ran the whole chain and stalled the event loop for up
|
||||
* to 5s on every request, forever.
|
||||
*
|
||||
* Test hermeticity: under vitest (`process.env.VITEST`) the production host
|
||||
* short-circuits — IO primitives that were not injected become inert stubs, so
|
||||
* a suite can never scan the machine's PATH or spawn login shells (the same
|
||||
* rule as `IS_TEST_MODE` in tmux-manager and the VITEST gate in
|
||||
* `getClaudeCliVersion`). Tests opt back in through the injection hooks
|
||||
* (`runCommand`/`isExecutableFile` fakes do no real IO by construction) or, for
|
||||
* fixtures that need the real filesystem predicate against their own temp
|
||||
* files, via `allowRealIoUnderVitest`.
|
||||
*
|
||||
* @module utils/cli-executable-resolver
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { accessSync, constants, statSync } from 'node:fs';
|
||||
import { basename, delimiter, dirname, isAbsolute, join } from 'node:path';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { loginShellArgs, resolveLocalShell } from './shell-resolver.js';
|
||||
|
||||
const SAFE_BINARY_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
|
||||
const LOGIN_SHELL_BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__';
|
||||
const LOGIN_SHELL_END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
|
||||
/** Maximum rendered length of each bounded diagnostic field, excluding its label. */
|
||||
const DIAGNOSTIC_FIELD_MAX_LENGTH = 1024;
|
||||
|
||||
/** First retry window after a full-chain resolution miss. */
|
||||
const RESOLVE_RETRY_BASE_MS = 60_000;
|
||||
/**
|
||||
* Ceiling for the doubling backoff. Deliberately shorter than the 15min cap on
|
||||
* the claude version probe: that one is cosmetic, while this gates the Run
|
||||
* flow, and "installing a CLI while the server is running is picked up without
|
||||
* a restart" should stay true within minutes.
|
||||
*/
|
||||
const RESOLVE_RETRY_MAX_MS = 5 * 60_000;
|
||||
|
||||
/**
|
||||
* How long to wait before re-running the resolution chain after `failures`
|
||||
* consecutive misses: 1min, 2min, 4min… capped at 5min. Mirrors
|
||||
* `claudeVersionRetryDelayMs` in claude-cli-resolver.ts. Exported for tests.
|
||||
*/
|
||||
export function cliResolveRetryDelayMs(failures: number): number {
|
||||
if (failures <= 0) return 0;
|
||||
return Math.min(RESOLVE_RETRY_BASE_MS * 2 ** (failures - 1), RESOLVE_RETRY_MAX_MS);
|
||||
}
|
||||
|
||||
export type CliResolutionSource = 'process-path' | 'common-directory' | 'login-shell';
|
||||
|
||||
export interface CliResolutionDiagnostics {
|
||||
binary: string;
|
||||
processPath: string;
|
||||
shellPath: string;
|
||||
shellArgs: string[];
|
||||
searchDirs: string[];
|
||||
}
|
||||
|
||||
export interface CliResolverHost {
|
||||
processPath: string;
|
||||
shellPath: string;
|
||||
shellArgs: string[];
|
||||
findOnProcessPath(binary: string): string | null;
|
||||
findInLoginShell(binary: string): string | null;
|
||||
exists(path: string): boolean;
|
||||
}
|
||||
|
||||
export interface CandidateValidation<T> {
|
||||
accepted: boolean;
|
||||
metadata?: T;
|
||||
}
|
||||
|
||||
export interface CliResolution<T = undefined> {
|
||||
binaryPath: string;
|
||||
directory: string;
|
||||
source: CliResolutionSource;
|
||||
metadata?: T;
|
||||
}
|
||||
|
||||
export interface CliExecutableResolver<T = undefined> {
|
||||
resolve(): CliResolution<T> | null;
|
||||
diagnostics(): CliResolutionDiagnostics;
|
||||
}
|
||||
|
||||
export interface CliResolverCommandOptions {
|
||||
encoding: 'utf8';
|
||||
timeout: number;
|
||||
stdio: ['ignore', 'pipe', 'ignore'];
|
||||
killSignal: 'SIGKILL';
|
||||
}
|
||||
|
||||
export type CliResolverCommandRunner = (file: string, args: string[], options: CliResolverCommandOptions) => string;
|
||||
|
||||
export interface ProductionCliResolverHostOptions {
|
||||
processPath?: string;
|
||||
shellPath?: string;
|
||||
shellArgs?: string[];
|
||||
runCommand?: CliResolverCommandRunner;
|
||||
isExecutableFile?: (path: string) => boolean;
|
||||
/**
|
||||
* Test-only escape hatch: keep the REAL IO primitives even under vitest.
|
||||
* For tests that exercise `isExecutableRegularFile` against their own temp
|
||||
* fixtures. Such a test must still inject `runCommand` if it can reach the
|
||||
* login-shell step, or it would spawn a real interactive shell.
|
||||
*/
|
||||
allowRealIoUnderVitest?: boolean;
|
||||
}
|
||||
|
||||
function isExecutableRegularFile(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isFile()) return false;
|
||||
accessSync(path, constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLoginShellResult(output: string, binary: string): string | null {
|
||||
const lines = output.split(/\r?\n/).map((line) => line.trim());
|
||||
const begin = lines.indexOf(LOGIN_SHELL_BEGIN_MARKER);
|
||||
if (begin === -1) return null;
|
||||
const end = lines.indexOf(LOGIN_SHELL_END_MARKER, begin + 1);
|
||||
if (end === -1) return null;
|
||||
|
||||
for (const candidate of lines.slice(begin + 1, end)) {
|
||||
if (isAbsolute(candidate) && basename(candidate) === binary) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loginShellCommand(binary: string): string {
|
||||
return [
|
||||
`printf '%s\\n' '${LOGIN_SHELL_BEGIN_MARKER}'`,
|
||||
`command -v -- ${binary}`,
|
||||
`printf '%s\\n' '${LOGIN_SHELL_END_MARKER}'`,
|
||||
].join('; ');
|
||||
}
|
||||
|
||||
export function createProductionCliResolverHost(options: ProductionCliResolverHostOptions = {}): CliResolverHost {
|
||||
const shellPath = options.shellPath ?? resolveLocalShell();
|
||||
const shellArgs = options.shellArgs ?? loginShellArgs(shellPath).trim().split(/\s+/).filter(Boolean);
|
||||
const processPath = options.processPath ?? process.env.PATH ?? '';
|
||||
// Hermeticity gate (see @fileoverview): under vitest, any IO primitive the
|
||||
// caller did not inject is replaced by an inert stub. The suites must never
|
||||
// depend on — or execute — whatever happens to be installed on the machine
|
||||
// running them, and route tests hitting the per-CLI status endpoints would
|
||||
// otherwise scan the real PATH and spawn real login shells on CI.
|
||||
const inert = Boolean(process.env.VITEST) && options.allowRealIoUnderVitest !== true;
|
||||
const isExecutableFile = options.isExecutableFile ?? (inert ? () => false : isExecutableRegularFile);
|
||||
const runCommand: CliResolverCommandRunner =
|
||||
options.runCommand ?? (inert ? () => '' : (file, args, commandOptions) => execFileSync(file, args, commandOptions));
|
||||
const run = (file: string, args: string[]): string => {
|
||||
try {
|
||||
return runCommand(file, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// SIGKILL is load-bearing: execFileSync's `timeout` only SENDS the kill
|
||||
// signal and then keeps waiting for the child to exit. Interactive bash
|
||||
// ignores SIGTERM (the default), so a login shell stuck in a blocking
|
||||
// .bash_profile would survive the timeout and block the server forever.
|
||||
killSignal: 'SIGKILL',
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
processPath,
|
||||
shellPath,
|
||||
shellArgs: [...shellArgs],
|
||||
findOnProcessPath: (binary) => {
|
||||
if (!SAFE_BINARY_NAME.test(binary)) return null;
|
||||
for (const directory of processPath.split(delimiter).filter(Boolean)) {
|
||||
const candidate = join(directory, binary);
|
||||
if (isAbsolute(candidate) && isExecutableFile(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
findInLoginShell: (binary) => {
|
||||
if (!SAFE_BINARY_NAME.test(binary)) return null;
|
||||
const candidate = parseLoginShellResult(run(shellPath, [...shellArgs, '-c', loginShellCommand(binary)]), binary);
|
||||
return candidate && isExecutableFile(candidate) ? candidate : null;
|
||||
},
|
||||
exists: isExecutableFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCliExecutableResolver<T = undefined>(
|
||||
options: {
|
||||
binary: string;
|
||||
searchDirs: string[];
|
||||
validateCandidate?: (path: string) => CandidateValidation<T>;
|
||||
/** Clock injection for tests driving the failure backoff. Defaults to `Date.now`. */
|
||||
now?: () => number;
|
||||
},
|
||||
host: CliResolverHost = createProductionCliResolverHost()
|
||||
): CliExecutableResolver<T> {
|
||||
if (!SAFE_BINARY_NAME.test(options.binary)) {
|
||||
throw new Error(`Unsafe CLI binary name: ${options.binary}`);
|
||||
}
|
||||
|
||||
const now = options.now ?? Date.now;
|
||||
/** Successful resolution, cached for the process lifetime. */
|
||||
let cached: CliResolution<T> | null = null;
|
||||
/** Consecutive full-chain misses (drives the retry backoff). */
|
||||
let failures = 0;
|
||||
/** Timestamp of the most recent miss. */
|
||||
let lastFailureAt = 0;
|
||||
const accept = (path: string | null, source: CliResolutionSource): CliResolution<T> | null => {
|
||||
if (!path || !isAbsolute(path) || !host.exists(path)) return null;
|
||||
const validation = options.validateCandidate?.(path) ?? ({ accepted: true } as CandidateValidation<T>);
|
||||
if (!validation.accepted) return null;
|
||||
return {
|
||||
binaryPath: path,
|
||||
directory: dirname(path),
|
||||
source,
|
||||
metadata: validation.metadata,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
resolve() {
|
||||
if (cached) return cached;
|
||||
// Negative cache: a miss is remembered and the chain — whose login-shell
|
||||
// tail is a synchronous 5s-bounded spawn — is not re-run until the
|
||||
// backoff elapses. Without this, every status poll and Run click against
|
||||
// a missing CLI froze the event loop for the full probe, forever.
|
||||
if (failures > 0 && now() - lastFailureAt < cliResolveRetryDelayMs(failures)) return null;
|
||||
|
||||
cached = accept(host.findOnProcessPath(options.binary), 'process-path');
|
||||
if (!cached) {
|
||||
for (const dir of options.searchDirs) {
|
||||
cached = accept(join(dir, options.binary), 'common-directory');
|
||||
if (cached) break;
|
||||
}
|
||||
}
|
||||
if (!cached) {
|
||||
cached = accept(host.findInLoginShell(options.binary), 'login-shell');
|
||||
}
|
||||
|
||||
if (cached) {
|
||||
failures = 0;
|
||||
lastFailureAt = 0;
|
||||
return cached;
|
||||
}
|
||||
failures += 1;
|
||||
lastFailureAt = now();
|
||||
return null;
|
||||
},
|
||||
diagnostics: () => ({
|
||||
binary: options.binary,
|
||||
processPath: host.processPath,
|
||||
shellPath: host.shellPath,
|
||||
shellArgs: [...host.shellArgs],
|
||||
searchDirs: [...options.searchDirs],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeDiagnosticField(value: string, emptyMarker: string): string {
|
||||
const flattened = Array.from(value, (character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
const isControl = codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
||||
return isControl || codePoint === 0x2028 || codePoint === 0x2029 ? ' ' : character;
|
||||
})
|
||||
.join('')
|
||||
.replace(/ +/g, ' ')
|
||||
.trim();
|
||||
if (!flattened) return emptyMarker;
|
||||
if (flattened.length <= DIAGNOSTIC_FIELD_MAX_LENGTH) return flattened;
|
||||
return `${flattened.slice(0, DIAGNOSTIC_FIELD_MAX_LENGTH - 1)}…`;
|
||||
}
|
||||
|
||||
export function formatCliNotFoundMessage(base: string, diagnostics: CliResolutionDiagnostics): string {
|
||||
const processPath = sanitizeDiagnosticField(diagnostics.processPath, '(empty)');
|
||||
const shell = sanitizeDiagnosticField(
|
||||
[diagnostics.shellPath, ...diagnostics.shellArgs].filter(Boolean).join(' '),
|
||||
'(none)'
|
||||
);
|
||||
const dirs = sanitizeDiagnosticField(diagnostics.searchDirs.join(', '), '(none)');
|
||||
return `${base}\nServer PATH: ${processPath}\nLogin shell: ${shell}\nChecked directories: ${dirs}`;
|
||||
}
|
||||
@@ -7,11 +7,9 @@
|
||||
* @module utils/codex-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the Codex CLI binary may be installed */
|
||||
const CODEX_SEARCH_DIRS = [
|
||||
@@ -23,8 +21,8 @@ const CODEX_SEARCH_DIRS = [
|
||||
join(homedir(), 'bin'), // User bin
|
||||
];
|
||||
|
||||
/** Cached directory containing the codex binary (empty string = searched but not found) */
|
||||
let _codexDir: string | null = null;
|
||||
const codexResolver = createCliExecutableResolver({ binary: 'codex', searchDirs: CODEX_SEARCH_DIRS });
|
||||
const CODEX_NOT_FOUND = 'Codex CLI not found. Install with: npm install -g @openai/codex';
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `codex` binary.
|
||||
@@ -34,31 +32,7 @@ let _codexDir: string | null = null;
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveCodexDir(): string | null {
|
||||
if (_codexDir !== null) return _codexDir || null;
|
||||
|
||||
// Try `which` first (respects current PATH)
|
||||
try {
|
||||
const result = execSync('which codex', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_codexDir = dirname(result);
|
||||
return _codexDir;
|
||||
}
|
||||
} catch {
|
||||
// Codex not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of CODEX_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'codex'))) {
|
||||
_codexDir = dir;
|
||||
return _codexDir;
|
||||
}
|
||||
}
|
||||
|
||||
_codexDir = ''; // mark as searched, not found
|
||||
return null;
|
||||
return codexResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,3 +41,7 @@ export function resolveCodexDir(): string | null {
|
||||
export function isCodexAvailable(): boolean {
|
||||
return resolveCodexDir() !== null;
|
||||
}
|
||||
|
||||
export function getCodexNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(CODEX_NOT_FOUND, codexResolver.diagnostics());
|
||||
}
|
||||
|
||||
@@ -7,11 +7,9 @@
|
||||
* @module utils/gemini-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the Gemini CLI binary may be installed */
|
||||
const GEMINI_SEARCH_DIRS = [
|
||||
@@ -23,8 +21,8 @@ const GEMINI_SEARCH_DIRS = [
|
||||
join(homedir(), 'bin'),
|
||||
];
|
||||
|
||||
/** Cached directory containing the gemini binary (empty string = searched but not found) */
|
||||
let _geminiDir: string | null = null;
|
||||
const geminiResolver = createCliExecutableResolver({ binary: 'gemini', searchDirs: GEMINI_SEARCH_DIRS });
|
||||
const GEMINI_NOT_FOUND = 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli';
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `gemini` binary.
|
||||
@@ -33,30 +31,7 @@ let _geminiDir: string | null = null;
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveGeminiDir(): string | null {
|
||||
if (_geminiDir !== null) return _geminiDir || null;
|
||||
|
||||
try {
|
||||
const result = execSync('which gemini', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_geminiDir = dirname(result);
|
||||
return _geminiDir;
|
||||
}
|
||||
} catch {
|
||||
// Gemini not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of GEMINI_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'gemini'))) {
|
||||
_geminiDir = dir;
|
||||
return _geminiDir;
|
||||
}
|
||||
}
|
||||
|
||||
_geminiDir = '';
|
||||
return null;
|
||||
return geminiResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,3 +40,7 @@ export function resolveGeminiDir(): string | null {
|
||||
export function isGeminiAvailable(): boolean {
|
||||
return resolveGeminiDir() !== null;
|
||||
}
|
||||
|
||||
export function getGeminiNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(GEMINI_NOT_FOUND, geminiResolver.diagnostics());
|
||||
}
|
||||
|
||||
+16
-6
@@ -28,12 +28,22 @@ export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-si
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
export { resolveLocalShell, loginShellArgs } from './shell-resolver.js';
|
||||
export { findClaudeDir, getAugmentedPath, getClaudeCliVersion, getClaudeBinaryPath } from './claude-cli-resolver.js';
|
||||
export {
|
||||
findClaudeDir,
|
||||
getAugmentedPath,
|
||||
getClaudeCliVersion,
|
||||
getClaudeBinaryPath,
|
||||
getClaudeNotFoundMessage,
|
||||
} from './claude-cli-resolver.js';
|
||||
export { spawnPtyWithHelperRepair } from './node-pty-repair.js';
|
||||
export { resolveOpenCodeDir } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
||||
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
||||
export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js';
|
||||
export { resolvePiDir, isPiAvailable, getPiCliVersion } from './pi-cli-resolver.js';
|
||||
export { resolveOpenCodeDir, getOpenCodeNotFoundMessage } from './opencode-cli-resolver.js';
|
||||
export { resolveCodexDir, isCodexAvailable, getCodexNotFoundMessage } from './codex-cli-resolver.js';
|
||||
export { resolveGeminiDir, isGeminiAvailable, getGeminiNotFoundMessage } from './gemini-cli-resolver.js';
|
||||
export {
|
||||
resolveAntigravityDir,
|
||||
isAntigravityAvailable,
|
||||
getAntigravityNotFoundMessage,
|
||||
} from './antigravity-cli-resolver.js';
|
||||
export { resolvePiDir, isPiAvailable, getPiCliVersion, getPiNotFoundMessage } from './pi-cli-resolver.js';
|
||||
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||
export type { FileQueryMatcher } from './file-query.js';
|
||||
|
||||
@@ -7,11 +7,9 @@
|
||||
* @module utils/opencode-cli-resolver
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the OpenCode CLI binary may be installed */
|
||||
const OPENCODE_SEARCH_DIRS = [
|
||||
@@ -24,8 +22,8 @@ const OPENCODE_SEARCH_DIRS = [
|
||||
join(homedir(), 'bin'), // User bin
|
||||
];
|
||||
|
||||
/** Cached directory containing the opencode binary (empty string = searched but not found) */
|
||||
let _openCodeDir: string | null = null;
|
||||
const openCodeResolver = createCliExecutableResolver({ binary: 'opencode', searchDirs: OPENCODE_SEARCH_DIRS });
|
||||
const OPENCODE_NOT_FOUND = 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash';
|
||||
|
||||
/**
|
||||
* Finds the directory containing the `opencode` binary.
|
||||
@@ -35,32 +33,7 @@ let _openCodeDir: string | null = null;
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolveOpenCodeDir(): string | null {
|
||||
if (_openCodeDir !== null) return _openCodeDir || null;
|
||||
|
||||
// Try `which` first (respects current PATH)
|
||||
try {
|
||||
const result = execSync('which opencode', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
_openCodeDir = dirname(result);
|
||||
return _openCodeDir;
|
||||
}
|
||||
} catch {
|
||||
// OpenCode not in PATH, will check common locations
|
||||
}
|
||||
|
||||
// Fallback: check common installation directories
|
||||
for (const dir of OPENCODE_SEARCH_DIRS) {
|
||||
if (existsSync(join(dir, 'opencode'))) {
|
||||
_openCodeDir = dir;
|
||||
return _openCodeDir;
|
||||
}
|
||||
}
|
||||
|
||||
_openCodeDir = ''; // mark as searched, not found
|
||||
return null;
|
||||
return openCodeResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,3 +42,7 @@ export function resolveOpenCodeDir(): string | null {
|
||||
export function isOpenCodeAvailable(): boolean {
|
||||
return resolveOpenCodeDir() !== null;
|
||||
}
|
||||
|
||||
export function getOpenCodeNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(OPENCODE_NOT_FOUND, openCodeResolver.diagnostics());
|
||||
}
|
||||
|
||||
@@ -15,11 +15,15 @@
|
||||
* @module utils/pi-cli-resolver
|
||||
*/
|
||||
|
||||
import { execFileSync, execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import {
|
||||
createCliExecutableResolver,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from './cli-executable-resolver.js';
|
||||
|
||||
/** Common directories where the Pi CLI binary may be installed */
|
||||
const PI_SEARCH_DIRS = [
|
||||
@@ -45,10 +49,7 @@ const PI_SEARCH_DIRS = [
|
||||
*/
|
||||
export const PI_VERSION_REGEX = /(?:^|\s)(\d+\.\d+\.\d+)/;
|
||||
|
||||
/** Cached directory containing the pi binary (empty string = searched but not found) */
|
||||
let _piDir: string | null = null;
|
||||
/** Cached version string reported by the resolved binary (empty string = probed, unusable) */
|
||||
let _piVersion: string | null = null;
|
||||
const PI_NOT_FOUND = 'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent';
|
||||
|
||||
/**
|
||||
* Run `pi --version` on a candidate path and return the trimmed version when it
|
||||
@@ -57,7 +58,12 @@ let _piVersion: string | null = null;
|
||||
* (which is how an unrelated `pi` 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 pi installed.
|
||||
* whether the dev box happens to have pi installed — and since `pi` is a short
|
||||
* GENERIC name, this probe would EXECUTE whatever binary of that name the
|
||||
* machine carries. 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; tests drive resolution via `createPiResolverForTest`, whose
|
||||
* injected probe bypasses it. Pinned by test/pi-cli-resolver.test.ts.
|
||||
*/
|
||||
function probePiVersion(binPath: string): string | null {
|
||||
if (process.env.VITEST) return null;
|
||||
@@ -66,6 +72,9 @@ function probePiVersion(binPath: string): string | null {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// A stuck or hostile `pi` that ignores SIGTERM would survive the timeout
|
||||
// and block the server (execFileSync keeps waiting after the signal).
|
||||
killSignal: 'SIGKILL',
|
||||
}).trim();
|
||||
// Upstream prints a bare version today; tolerate a `pi 0.84.1` style prefix too.
|
||||
const candidate = PI_VERSION_REGEX.exec(out)?.[1];
|
||||
@@ -77,6 +86,34 @@ function probePiVersion(binPath: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
type PiVersionProbe = (binPath: string) => string | null;
|
||||
|
||||
function createPiResolver(host?: CliResolverHost, versionProbe: PiVersionProbe = probePiVersion, now?: () => number) {
|
||||
return createCliExecutableResolver<string>(
|
||||
{
|
||||
binary: 'pi',
|
||||
searchDirs: PI_SEARCH_DIRS,
|
||||
validateCandidate: (binPath) => {
|
||||
const version = versionProbe(binPath);
|
||||
return version ? { accepted: true, metadata: version } : { accepted: false };
|
||||
},
|
||||
now,
|
||||
},
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an isolated Pi 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 createPiResolverForTest(host: CliResolverHost, versionProbe?: PiVersionProbe, now?: () => number) {
|
||||
return createPiResolver(host, versionProbe ?? probePiVersion, now);
|
||||
}
|
||||
|
||||
const piResolver = createPiResolver();
|
||||
|
||||
/**
|
||||
* Finds the directory containing a verified `pi` binary.
|
||||
* Checks `which pi` first, then falls back to common install locations. Every
|
||||
@@ -86,46 +123,7 @@ function probePiVersion(binPath: string): string | null {
|
||||
* @returns Directory path, or null if not found
|
||||
*/
|
||||
export function resolvePiDir(): string | null {
|
||||
if (_piDir !== null) return _piDir || null;
|
||||
|
||||
const accept = (binPath: string): string | null => {
|
||||
// Under vitest the probe never runs, so existence alone decides (keeps the
|
||||
// suites hermetic and matches how the sibling resolvers behave there).
|
||||
if (process.env.VITEST) {
|
||||
_piDir = dirname(binPath);
|
||||
_piVersion = '';
|
||||
return _piDir;
|
||||
}
|
||||
const version = probePiVersion(binPath);
|
||||
if (!version) return null;
|
||||
_piDir = dirname(binPath);
|
||||
_piVersion = version;
|
||||
return _piDir;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = execSync('which pi', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
}).trim();
|
||||
if (result && existsSync(result)) {
|
||||
const dir = accept(result);
|
||||
if (dir) return dir;
|
||||
}
|
||||
} catch {
|
||||
// pi not in PATH, will check common locations
|
||||
}
|
||||
|
||||
for (const dir of PI_SEARCH_DIRS) {
|
||||
const binPath = join(dir, 'pi');
|
||||
if (!existsSync(binPath)) continue;
|
||||
const accepted = accept(binPath);
|
||||
if (accepted) return accepted;
|
||||
}
|
||||
|
||||
_piDir = '';
|
||||
_piVersion = '';
|
||||
return null;
|
||||
return piResolver.resolve()?.directory ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,12 +133,14 @@ export function isPiAvailable(): boolean {
|
||||
return resolvePiDir() !== null;
|
||||
}
|
||||
|
||||
export function getPiNotFoundMessage(): string {
|
||||
return formatCliNotFoundMessage(PI_NOT_FOUND, piResolver.diagnostics());
|
||||
}
|
||||
|
||||
/**
|
||||
* Version reported by the resolved `pi` binary, or null when pi is unavailable
|
||||
* (or when the probe was skipped, i.e. under vitest). Surfaced through
|
||||
* `GET /api/pi/status` so a misresolution is diagnosable from the UI.
|
||||
* Version reported by the resolved `pi` binary, or null when pi is unavailable.
|
||||
* Surfaced through `GET /api/pi/status` so a misresolution is diagnosable from the UI.
|
||||
*/
|
||||
export function getPiCliVersion(): string | null {
|
||||
resolvePiDir();
|
||||
return _piVersion || null;
|
||||
return piResolver.resolve()?.metadata ?? null;
|
||||
}
|
||||
|
||||
+110
-26
@@ -22,14 +22,14 @@
|
||||
* - Acknowledgement (`acknowledge()`, idle items only) is NOT resolution: the
|
||||
* item stays pending, it just stops arming the tab alert on every client.
|
||||
*
|
||||
* @dependencies utils (stripAnsi)
|
||||
* @dependencies utils (stripAnsi, CLAUDE_WORKING_LINE_PATTERN)
|
||||
* @consumedby web/routes/hook-event-routes (notePrompt/resolve), web/routes/approval-routes,
|
||||
* web/session-listener-wiring (working/exit resolution), web/server (emit callbacks + stop)
|
||||
*
|
||||
* @module web/approval-inbox
|
||||
*/
|
||||
|
||||
import { stripAnsi } from '../utils/index.js';
|
||||
import { stripAnsi, CLAUDE_WORKING_LINE_PATTERN } from '../utils/index.js';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -106,6 +106,12 @@ const ITEM_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
* single delayed re-capture picks up the frame the immediate capture missed.
|
||||
*/
|
||||
const RECAPTURE_DELAY_MS = 600;
|
||||
/**
|
||||
* Delayed staleness pass for the late-hook case (see notePrompt). Comfortably
|
||||
* clear of RECAPTURE_DELAY_MS so a dialog Ink has not painted yet is never
|
||||
* mistaken for one that is gone.
|
||||
*/
|
||||
const STALE_CHECK_DELAY_MS = 3000;
|
||||
/** Context kept per item: enough for a dialog plus a few lines above it. */
|
||||
const MAX_CONTEXT_CHARS = 4000;
|
||||
const MAX_CONTEXT_LINES = 30;
|
||||
@@ -195,7 +201,8 @@ export function parseDialogOptions(context: string | undefined): ApprovalOption[
|
||||
export class ApprovalInbox {
|
||||
/** Keyed by sessionId; the one-active-item-per-session invariant lives here. */
|
||||
private items = new Map<string, ApprovalItem>();
|
||||
private recaptureTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
/** Post-capture timers per item id (re-capture + the delayed staleness check). */
|
||||
private itemTimers = new Map<string, ReturnType<typeof setTimeout>[]>();
|
||||
/** Capture callbacks kept for answer-time re-verification; dropped on remove. */
|
||||
private captures = new Map<string, () => string | null>();
|
||||
private seq = 0;
|
||||
@@ -229,31 +236,55 @@ export class ApprovalInbox {
|
||||
if (args.capture) this.captures.set(args.sessionId, args.capture);
|
||||
this.onPending?.(item);
|
||||
if (args.capture && !this.stopped) {
|
||||
const timer = setTimeout(() => {
|
||||
this.recaptureTimers.delete(item.id);
|
||||
// Only update the item if it is still the live one for the session.
|
||||
if (this.items.get(args.sessionId)?.id !== item.id) return;
|
||||
// Pass 1 (600ms): enrich the card with the painted frame.
|
||||
this.scheduleForItem(item, RECAPTURE_DELAY_MS, () => {
|
||||
this.applyCapture(item, args.capture);
|
||||
this.onUpdated?.(item);
|
||||
}, RECAPTURE_DELAY_MS);
|
||||
this.recaptureTimers.set(item.id, timer);
|
||||
});
|
||||
// Pass 2: the late-hook staleness check. Claude Code fires the
|
||||
// Notification behind the dialog, so a prompt answered before the hook
|
||||
// lands creates an item for a dialog that is ALREADY gone: nothing ever
|
||||
// parsed, so the "options vanished" test can never fire, `stop` may have
|
||||
// gone by already, and the red alert then outlived reloads until the 12h
|
||||
// TTL. This pass re-reads the pane and resolves when the frame proves no
|
||||
// dialog is up. Deliberately LATER than the re-capture, whose whole
|
||||
// reason for existing is that Ink may not have painted the dialog yet:
|
||||
// resolving inside that window could clear the alert for a dialog that
|
||||
// was about to appear.
|
||||
this.scheduleForItem(item, STALE_CHECK_DELAY_MS, () => {
|
||||
this.verifyStillAnswerable(item.id);
|
||||
});
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer-time guard: re-capture the pane and check the dialog is still on
|
||||
* screen before keystrokes are sent at it. Only conclusive when the ORIGINAL
|
||||
* frame parsed options: if a fresh capture then parses none, the dialog is
|
||||
* gone (answered in the terminal moments ago), so the item resolves and the
|
||||
* answer must be refused, because the digit would land in whatever now has
|
||||
* focus. Unparseable-from-the-start items stay answerable (approve/deny
|
||||
* only), same risk the terminal user already carries.
|
||||
* screen before keystrokes are sent at it. If the dialog is gone (answered in
|
||||
* the terminal moments ago) the item resolves and the answer is refused,
|
||||
* because the digit would land in whatever now has focus.
|
||||
*
|
||||
* A fresh frame that parses NO options is conclusive in two cases, and only
|
||||
* those; anything else stays answerable, so an unreadable capture keeps the
|
||||
* alert rather than losing a live dialog:
|
||||
*
|
||||
* 1. The item HAD parsed options. They cannot vanish while the dialog is up.
|
||||
* 2. The frame shows Claude actively running a turn. A modal dialog BLOCKS
|
||||
* the turn, so a working line and a dialog cannot coexist — measured on
|
||||
* v2.1.237: a live-dialog frame carries neither the `… (13s` timer nor
|
||||
* even the `esc to interrupt` footer, which the dialog replaces with
|
||||
* `Enter to select · ↑/↓ to navigate · Esc to cancel`.
|
||||
*
|
||||
* Case 2 is what closes the late-hook hole. Claude Code fires the
|
||||
* Notification behind the dialog, so a prompt answered before the hook lands
|
||||
* produces an item whose FIRST capture already has no dialog in it — never
|
||||
* parsed, so case 1 can never fire, and the red alert then outlived even
|
||||
* `stop` (which had already fired) and survived reloads until the 12h TTL.
|
||||
*/
|
||||
verifyStillAnswerable(id: string): boolean {
|
||||
const item = this.getById(id);
|
||||
if (!item) return false;
|
||||
if (item.kind === 'idle' || !item.options) return true;
|
||||
if (item.kind === 'idle') return true;
|
||||
const capture = this.captures.get(item.sessionId);
|
||||
if (!capture) return true;
|
||||
let raw: string | null = null;
|
||||
@@ -266,14 +297,39 @@ export class ApprovalInbox {
|
||||
if (!context) return true;
|
||||
const options = parseDialogOptions(context);
|
||||
if (!options) {
|
||||
this.remove(item, 'resolved_in_terminal');
|
||||
return false;
|
||||
if (item.options || CLAUDE_WORKING_LINE_PATTERN.test(context)) {
|
||||
this.remove(item, 'resolved_in_terminal');
|
||||
return false;
|
||||
}
|
||||
return true; // never parsed and the pane is not visibly working: unreadable, not gone
|
||||
}
|
||||
item.context = context;
|
||||
item.options = options;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* "This session's pane started moving again": re-verify its pending DIALOG
|
||||
* item against the screen and resolve it if the dialog is gone.
|
||||
*
|
||||
* The staleness check itself lived only in `GET /api/approvals`, which
|
||||
* nothing calls while a page is open (`seedApprovals()` runs on init and
|
||||
* reconnect), so a dialog answered in the terminal kept its red tab alert for
|
||||
* the whole rest of the turn. The `working` signal is exactly the moment an
|
||||
* answer lands, and routing it through `verifyStillAnswerable` is what makes
|
||||
* it safe to act on for a permission/question item: `working` is heuristic
|
||||
* and can flap, but it only decides WHEN to look — the pane decides the
|
||||
* outcome, and an unreadable capture keeps the alert.
|
||||
*
|
||||
* Cheap by construction: a Map miss unless a dialog item is actually pending,
|
||||
* and the item is gone after the first successful resolve.
|
||||
*/
|
||||
resolveIfDialogGone(sessionId: string): void {
|
||||
const item = this.getForSession(sessionId);
|
||||
if (!item || item.kind === 'idle') return;
|
||||
this.verifyStillAnswerable(item.id);
|
||||
}
|
||||
|
||||
/** Pending item for a session, TTL-checked. */
|
||||
getForSession(sessionId: string): ApprovalItem | undefined {
|
||||
const item = this.items.get(sessionId);
|
||||
@@ -359,12 +415,27 @@ export class ApprovalInbox {
|
||||
/** Clear all timers (shutdown/tests). Items become inert; no events fire after this. */
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
for (const timer of this.recaptureTimers.values()) clearTimeout(timer);
|
||||
this.recaptureTimers.clear();
|
||||
for (const timers of this.itemTimers.values()) for (const timer of timers) clearTimeout(timer);
|
||||
this.itemTimers.clear();
|
||||
this.items.clear();
|
||||
this.captures.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `fn` after `delayMs` if the item is still the live one for its session,
|
||||
* tracking the timer so `remove()`/`stop()` can cancel it.
|
||||
*/
|
||||
private scheduleForItem(item: ApprovalItem, delayMs: number, fn: () => void): void {
|
||||
const timer = setTimeout(() => {
|
||||
const timers = this.itemTimers.get(item.id)?.filter((t) => t !== timer) ?? [];
|
||||
if (timers.length > 0) this.itemTimers.set(item.id, timers);
|
||||
else this.itemTimers.delete(item.id);
|
||||
if (this.items.get(item.sessionId)?.id !== item.id) return;
|
||||
fn();
|
||||
}, delayMs);
|
||||
this.itemTimers.set(item.id, [...(this.itemTimers.get(item.id) ?? []), timer]);
|
||||
}
|
||||
|
||||
private applyCapture(item: ApprovalItem, capture?: () => string | null): void {
|
||||
if (!capture) return;
|
||||
let raw: string | null = null;
|
||||
@@ -377,17 +448,30 @@ export class ApprovalInbox {
|
||||
if (!context) return;
|
||||
item.context = context;
|
||||
// Idle prompts are not dialogs; never offer digit answers for them.
|
||||
if (item.kind !== 'idle') item.options = parseDialogOptions(context);
|
||||
if (item.kind === 'idle') return;
|
||||
const options = parseDialogOptions(context);
|
||||
// ⚠️ ADD-ONLY: a re-capture that parses NOTHING must never erase options a
|
||||
// previous capture found. Claude Code delays the Notification hook behind
|
||||
// the dialog (measured 6s here, up to ~30s), so the 600ms re-capture very
|
||||
// often lands AFTER the user has already answered in the terminal, on a
|
||||
// frame with no dialog in it. Clearing the field there was the whole bug:
|
||||
// `verifyStillAnswerable` reads a MISSING `options` as "never parsed" and
|
||||
// keeps such an item answerable by design, so a cleared field made the item
|
||||
// permanently unsweepable — the red "needs you" alert then survived every
|
||||
// `GET /api/approvals` and every page reload and only went away on `stop`
|
||||
// (owner report 2026-08-20: a confirmed question left a tab flowing red for
|
||||
// ~8 minutes while the turn ran on), and the stale card still accepted an
|
||||
// answer, typing a bare `1` into a composer with no dialog under it.
|
||||
// Keeping the parse means a later capture is CONCLUSIVE: options present +
|
||||
// fresh frame without them == answered in the terminal.
|
||||
if (options) item.options = options;
|
||||
}
|
||||
|
||||
private remove(item: ApprovalItem, resolution: ApprovalResolution): void {
|
||||
this.items.delete(item.sessionId);
|
||||
this.captures.delete(item.sessionId);
|
||||
const timer = this.recaptureTimers.get(item.id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.recaptureTimers.delete(item.id);
|
||||
}
|
||||
for (const timer of this.itemTimers.get(item.id) ?? []) clearTimeout(timer);
|
||||
this.itemTimers.delete(item.id);
|
||||
if (!this.stopped) {
|
||||
this.onResolved?.({ id: item.id, sessionId: item.sessionId, kind: item.kind, resolution });
|
||||
}
|
||||
|
||||
@@ -41,8 +41,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
_onHookElicitationComplete(data) {
|
||||
// Question answered in the terminal: clear the action alert without
|
||||
// waiting for `stop` (the turn may keep running for a long time).
|
||||
// ⚠️ BOTH action kinds, matching the server's APPROVAL_RESOLVING_EVENTS,
|
||||
// which resolves a session's pending item whatever its kind. An
|
||||
// AskUserQuestion dialog arrives as `permission_prompt` (only MCP
|
||||
// elicitation is `elicitation_dialog`), so clearing just the elicitation
|
||||
// entry left the red alert armed on exactly the dialog these events are
|
||||
// most often about. Normally the server's `approval:resolved` broadcast
|
||||
// clears it too; this is the path that still works when the store holds no
|
||||
// item for the session (restart, superseded).
|
||||
if (data.sessionId) {
|
||||
this.clearPendingHooks(data.sessionId, 'elicitation_dialog');
|
||||
this.clearPendingHooks(data.sessionId, 'permission_prompt');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* @fileoverview Repository-status check (App Settings → Updates → "Repository
|
||||
* status").
|
||||
*
|
||||
* INFORMATIONAL companion to the release-tag self-updater (`self-update.ts`).
|
||||
* Where the updater answers "is there a newer published release tag, and do you
|
||||
* want to `git checkout` it", this module answers "where does my local checkout
|
||||
* sit relative to the upstream project AND my own fork" — by commit ahead/behind
|
||||
* count plus a short list of the incoming commits.
|
||||
*
|
||||
* This needs the actual commits locally, so it does a READ-ONLY `git fetch` of
|
||||
* the compared ref per remote (updates only remote-tracking refs under `.git`,
|
||||
* never the working tree), then `git rev-list`/`git log`. Works uniformly for
|
||||
* GitHub and non-GitHub remotes (e.g. Bitbucket) — no release tags required.
|
||||
*
|
||||
* Remote selection (generalizable): by default the union of `origin` and the
|
||||
* current branch's `@{upstream}` tracking remote, deduped. Override with the
|
||||
* `CODEMAN_UPDATE_REMOTES` env var (comma-separated remote names) for any other
|
||||
* layout (e.g. the common `origin`=fork / `upstream`=canonical convention).
|
||||
*
|
||||
* Split PURE helpers (parsing + remote-set/role/compare-ref decisions, unit
|
||||
* tested) from the IO wrapper `getRepositoryStatus()` (touches git).
|
||||
*
|
||||
* PERFORMANCE: every git invocation is ASYNC (`execFile`), never `execFileSync`
|
||||
* — the fetch path can spend `2 × FETCH_TIMEOUT_MS` per remote on the network,
|
||||
* and a synchronous version froze the whole event loop (SSE, PTY streaming) for
|
||||
* up to a minute per request. The computation is additionally single-flight
|
||||
* with a short TTL cache: concurrent requests share one in-flight promise, and
|
||||
* a fresh-enough result is served without spawning git at all.
|
||||
*
|
||||
* SECURITY: remote URLs (and git stderr echoing them) can embed
|
||||
* `scheme://user:token@host` credentials, so every `url`/`error` field is
|
||||
* passed through `redactGitCredentials()` (shared with `git-clone.ts`) before
|
||||
* it reaches a client.
|
||||
*
|
||||
* @module web/repo-status
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
import { promisify } from 'node:util';
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
import { gitNonInteractiveEnv, redactGitCredentials } from '../git-clone.js';
|
||||
import { getInstallInfo } from './self-update.js';
|
||||
import type { RepoIncomingCommit, RepoRemoteRole, RepoRemoteStatus, RepositoryStatusResult } from '../types/update.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { version: APP_VERSION } = require('../../package.json') as { version: string };
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Network/git timeout for the fetch path (longer than EXEC_TIMEOUT_MS — hits network). */
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
/** Max incoming commit subjects to list per remote. */
|
||||
const MAX_INCOMING = 10;
|
||||
/** Fresh-enough window for a cached status — repeated UI polls reuse it instead of re-running git. */
|
||||
const STATUS_CACHE_TTL_MS = 45_000;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PURE helpers (unit tested, no IO)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse `git rev-list --left-right --count HEAD...<ref>` output.
|
||||
* Git prints two tab-separated counts: LEFT (commits in HEAD not in ref → ahead)
|
||||
* and RIGHT (commits in ref not in HEAD → behind). Returns null on malformed input.
|
||||
*/
|
||||
export function parseAheadBehind(out: string): { ahead: number; behind: number } | null {
|
||||
const m = out.trim().match(/^(\d+)\s+(\d+)$/);
|
||||
if (!m) return null;
|
||||
return { ahead: parseInt(m[1], 10), behind: parseInt(m[2], 10) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git log --oneline` output into commits. Each non-empty line is
|
||||
* `<sha> <subject>`; the first whitespace-delimited token is the SHA.
|
||||
*/
|
||||
export function parseLogLines(out: string): RepoIncomingCommit[] {
|
||||
const commits: RepoIncomingCommit[] = [];
|
||||
for (const line of out.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const idx = trimmed.indexOf(' ');
|
||||
if (idx === -1) {
|
||||
commits.push({ sha: trimmed, subject: '' });
|
||||
} else {
|
||||
commits.push({ sha: trimmed.slice(0, idx), subject: trimmed.slice(idx + 1).trim() });
|
||||
}
|
||||
}
|
||||
return commits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the default branch name from `git ls-remote --symref <remote> HEAD`
|
||||
* output (a line like `ref: refs/heads/master\tHEAD`). Returns null if absent.
|
||||
*
|
||||
* SECURITY: this parses UNTRUSTED remote output, and the result flows into a
|
||||
* later `git fetch <remote> <branch>` positional. The capture is constrained to
|
||||
* start with an alphanumeric (no leading `-`) so a hostile remote can't return
|
||||
* `ref: refs/heads/--upload-pack=<cmd>\tHEAD` and smuggle an argv flag (RCE) —
|
||||
* see `isSafeGitPositional` for the defense-in-depth re-check at the call site.
|
||||
*/
|
||||
export function parseSymrefDefaultBranch(out: string): string | null {
|
||||
const m = out.match(/^ref:\s+refs\/heads\/([A-Za-z0-9_./][A-Za-z0-9_./+-]*)\s+HEAD$/m);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a value that would be unsafe as a git positional argument (remote name
|
||||
* or branch). A leading `-` lets untrusted ls-remote/symref output or a stray
|
||||
* `CODEMAN_UPDATE_REMOTES` entry inject an option (e.g. `--upload-pack=<cmd>`)
|
||||
* into a subsequent `git fetch`. Empty values are rejected too.
|
||||
*/
|
||||
export function isSafeGitPositional(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && value.length > 0 && !value.startsWith('-');
|
||||
}
|
||||
|
||||
/** Split a comma-separated env value into trimmed, non-empty names. */
|
||||
export function parseRemotesEnv(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which remote NAMES the status view covers, in display order.
|
||||
*
|
||||
* - If `envRemotes` is non-empty, use exactly those that actually exist (order
|
||||
* preserved). This is the full-control escape hatch.
|
||||
* - Otherwise the union of `origin` (if it exists) and the tracking remote (if
|
||||
* any), deduped. The tracking remote is listed first when it isn't `origin`,
|
||||
* so "your fork" leads and "upstream" follows.
|
||||
*/
|
||||
export function resolveRemoteSet(opts: {
|
||||
existingRemotes: string[];
|
||||
trackingRemote: string | null;
|
||||
envRemotes: string[];
|
||||
}): string[] {
|
||||
const exists = new Set(opts.existingRemotes);
|
||||
if (opts.envRemotes.length > 0) {
|
||||
return dedupe(opts.envRemotes.filter((n) => exists.has(n)));
|
||||
}
|
||||
const out: string[] = [];
|
||||
if (opts.trackingRemote && exists.has(opts.trackingRemote) && opts.trackingRemote !== 'origin') {
|
||||
out.push(opts.trackingRemote);
|
||||
}
|
||||
if (exists.has('origin')) out.push('origin');
|
||||
if (opts.trackingRemote && exists.has(opts.trackingRemote)) out.push(opts.trackingRemote);
|
||||
return dedupe(out);
|
||||
}
|
||||
|
||||
/** Classify a remote's role relative to the tracking remote. */
|
||||
export function roleForRemote(name: string, trackingRemote: string | null): RepoRemoteRole {
|
||||
if (trackingRemote && name === trackingRemote) return 'tracking';
|
||||
if (name === 'origin' || name === 'upstream') return 'upstream';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function dedupe(names: string[]): string[] {
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the remote name from an `@{upstream}` short ref like `origin/master`.
|
||||
*
|
||||
* A ref with NO slash is a LOCAL-branch upstream (`git branch -u otherbranch`):
|
||||
* `rev-parse --abbrev-ref @{upstream}` prints just the branch name, and there
|
||||
* is no remote in it — the old `slice(0, indexOf('/'))` became `slice(0, -1)`
|
||||
* there and yielded garbage like `maste`. Returns null for that case (treated
|
||||
* as "no tracking remote"), and for empty/degenerate refs.
|
||||
*/
|
||||
export function parseTrackingRemote(trackingRef: string | null | undefined): string | null {
|
||||
if (!trackingRef) return null;
|
||||
const idx = trackingRef.indexOf('/');
|
||||
if (idx <= 0) return null;
|
||||
return trackingRef.slice(0, idx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact embedded `scheme://user:secret@host` credentials from the fields of
|
||||
* one remote's status that can carry them: the remote URL itself, and the
|
||||
* error string (which may include git stderr echoing that URL back).
|
||||
*/
|
||||
export function redactRemoteStatus(status: RepoRemoteStatus): RepoRemoteStatus {
|
||||
const out: RepoRemoteStatus = { ...status, url: redactGitCredentials(status.url) };
|
||||
if (out.error !== undefined) out.error = redactGitCredentials(out.error);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-flight TTL cache around an async compute: concurrent callers await
|
||||
* the SAME in-flight promise, and a result younger than `ttlMs` is returned
|
||||
* without recomputing. A rejected compute is never cached, so the next call
|
||||
* retries. `now` is injectable for deterministic TTL tests.
|
||||
*/
|
||||
export function createSingleFlightCache<T>(
|
||||
ttlMs: number,
|
||||
compute: () => Promise<T>
|
||||
): { get(now?: number): Promise<T> } {
|
||||
let cachedAt = 0;
|
||||
let cachedValue: T | undefined;
|
||||
let hasValue = false;
|
||||
let inFlight: Promise<T> | null = null;
|
||||
return {
|
||||
get(now = Date.now()): Promise<T> {
|
||||
if (hasValue && now - cachedAt < ttlMs) return Promise.resolve(cachedValue as T);
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = compute().then(
|
||||
(value) => {
|
||||
cachedValue = value;
|
||||
hasValue = true;
|
||||
cachedAt = Date.now();
|
||||
inFlight = null;
|
||||
return value;
|
||||
},
|
||||
(err: unknown) => {
|
||||
inFlight = null;
|
||||
throw err;
|
||||
}
|
||||
);
|
||||
return inFlight;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// IO wrapper
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GitResult {
|
||||
ok: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run git non-interactively (no credential or SSH prompts — a missing key/cred
|
||||
* fails fast instead of hanging; env via the shared `gitNonInteractiveEnv()`,
|
||||
* which also closes the askpass/GCM/DISPLAY prompt paths). ASYNC on purpose:
|
||||
* the fetch path hits the network for up to `FETCH_TIMEOUT_MS`, and a sync
|
||||
* spawn would block the event loop for the whole wait.
|
||||
*/
|
||||
async function runGit(args: string[], cwd: string, timeout = EXEC_TIMEOUT_MS): Promise<GitResult> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
timeout,
|
||||
env: gitNonInteractiveEnv(),
|
||||
});
|
||||
return { ok: true, stdout: stdout.trim(), stderr: '' };
|
||||
} catch (err: unknown) {
|
||||
const e = err as { stdout?: Buffer | string; stderr?: Buffer | string };
|
||||
return {
|
||||
ok: false,
|
||||
stdout: e.stdout ? String(e.stdout).trim() : '',
|
||||
stderr: e.stderr ? String(e.stderr).trim() : '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** First line of stderr, trimmed — a compact human-readable failure reason. */
|
||||
function firstLine(s: string): string {
|
||||
return (
|
||||
s
|
||||
.split('\n')
|
||||
.find((l) => l.trim())
|
||||
?.trim() ?? 'git command failed'
|
||||
);
|
||||
}
|
||||
|
||||
/** Compute ahead/behind + incoming commits for one already-selected remote. */
|
||||
async function statusForRemote(
|
||||
dir: string,
|
||||
name: string,
|
||||
trackingRemote: string,
|
||||
trackingRef: string | null
|
||||
): Promise<RepoRemoteStatus> {
|
||||
const role = roleForRemote(name, trackingRemote);
|
||||
const url = (await runGit(['remote', 'get-url', name], dir)).stdout || '';
|
||||
const base: RepoRemoteStatus = { name, url, role, compareRef: '', ahead: 0, behind: 0, incoming: [] };
|
||||
|
||||
// SECURITY: the remote name reaches git as a positional; reject a `-` prefix
|
||||
// (e.g. a stray CODEMAN_UPDATE_REMOTES entry) before it can act as a flag.
|
||||
if (!isSafeGitPositional(name)) {
|
||||
return { ...base, error: `Refusing unsafe remote name "${name}".` };
|
||||
}
|
||||
|
||||
// Resolve the compare ref + the remote branch to fetch.
|
||||
let branch: string | null;
|
||||
if (role === 'tracking' && trackingRef) {
|
||||
// e.g. trackingRef = "bitbucket/local" → branch = "local"
|
||||
branch = trackingRef.slice(name.length + 1) || null;
|
||||
} else {
|
||||
const symref = await runGit(['ls-remote', '--symref', name, 'HEAD'], dir, FETCH_TIMEOUT_MS);
|
||||
branch = symref.ok ? parseSymrefDefaultBranch(symref.stdout) : null;
|
||||
if (!branch && !symref.ok) {
|
||||
return { ...base, error: `Could not reach ${name}: ${firstLine(symref.stderr)}` };
|
||||
}
|
||||
branch = branch ?? 'master';
|
||||
}
|
||||
if (!branch) return { ...base, error: `Could not resolve a branch on ${name}.` };
|
||||
// SECURITY: defense-in-depth — `branch` may come from untrusted symref output
|
||||
// or a tracking-ref slice; never let a `-`-prefixed value reach `git fetch`.
|
||||
if (!isSafeGitPositional(branch)) {
|
||||
return { ...base, error: `Refusing unsafe branch name "${branch}" from ${name}.` };
|
||||
}
|
||||
const compareRef = `${name}/${branch}`;
|
||||
|
||||
// Read-only fetch of just that ref so the local rev-list/log can see it.
|
||||
// `--` ends option parsing so neither `name` nor `branch` can be read as a flag.
|
||||
const fetched = await runGit(['fetch', '--no-tags', name, '--', branch], dir, FETCH_TIMEOUT_MS);
|
||||
if (!fetched.ok) {
|
||||
return { ...base, compareRef, error: `Could not fetch ${compareRef}: ${firstLine(fetched.stderr)}` };
|
||||
}
|
||||
|
||||
const counts = await runGit(['rev-list', '--left-right', '--count', `HEAD...${compareRef}`], dir);
|
||||
if (!counts.ok) {
|
||||
return { ...base, compareRef, error: `Could not compare against ${compareRef}: ${firstLine(counts.stderr)}` };
|
||||
}
|
||||
const ab = parseAheadBehind(counts.stdout);
|
||||
if (!ab) return { ...base, compareRef, error: `Unexpected git output comparing ${compareRef}.` };
|
||||
|
||||
const log = await runGit(['log', '--oneline', '-n', String(MAX_INCOMING), `HEAD..${compareRef}`], dir);
|
||||
const incoming = log.ok ? parseLogLines(log.stdout) : [];
|
||||
|
||||
return { ...base, compareRef, ahead: ab.ahead, behind: ab.behind, incoming };
|
||||
}
|
||||
|
||||
/** The uncached computation behind `getRepositoryStatus()`. */
|
||||
async function computeRepositoryStatus(): Promise<RepositoryStatusResult> {
|
||||
const checkedAt = Date.now();
|
||||
const info = getInstallInfo();
|
||||
const base: RepositoryStatusResult = {
|
||||
checkedAt,
|
||||
isGit: info.installKind === 'git',
|
||||
currentVersion: info.currentVersion || APP_VERSION,
|
||||
remotes: [],
|
||||
};
|
||||
if (info.installKind !== 'git') {
|
||||
return { ...base, error: 'Not a git install — repository status is unavailable.' };
|
||||
}
|
||||
|
||||
const dir = info.installDir;
|
||||
|
||||
// Tracking ref of the current branch, e.g. "bitbucket/local" (empty if none).
|
||||
const trackingRef =
|
||||
(await runGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], dir)).stdout || null;
|
||||
const trackingRemote = parseTrackingRemote(trackingRef);
|
||||
// A local-branch upstream (no slash) carries no remote — drop the ref too, so
|
||||
// nothing downstream can mistake a bare branch name for a remote-tracking ref.
|
||||
const remoteTrackingRef = trackingRemote ? trackingRef : null;
|
||||
|
||||
const remotesOut = await runGit(['remote'], dir);
|
||||
const existingRemotes = remotesOut.ok
|
||||
? remotesOut.stdout
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
const selected = resolveRemoteSet({
|
||||
existingRemotes,
|
||||
trackingRemote,
|
||||
envRemotes: parseRemotesEnv(process.env.CODEMAN_UPDATE_REMOTES),
|
||||
});
|
||||
|
||||
if (selected.length === 0) {
|
||||
return { ...base, error: 'No comparable remotes found (set CODEMAN_UPDATE_REMOTES to choose).' };
|
||||
}
|
||||
|
||||
// Sequential on purpose: concurrent `git fetch` in one repo can contend on
|
||||
// ref locks, and the event loop no longer cares how long this takes.
|
||||
const remotes: RepoRemoteStatus[] = [];
|
||||
for (const name of selected) {
|
||||
// SECURITY: redact `scheme://user:token@host` credentials from the URL and
|
||||
// any error string (git stderr echoes the URL back) before they leave the server.
|
||||
remotes.push(redactRemoteStatus(await statusForRemote(dir, name, trackingRemote ?? '', remoteTrackingRef)));
|
||||
}
|
||||
return { ...base, remotes };
|
||||
}
|
||||
|
||||
const statusCache = createSingleFlightCache(STATUS_CACHE_TTL_MS, computeRepositoryStatus);
|
||||
|
||||
/**
|
||||
* Inspect how the local checkout sits relative to the configured remotes.
|
||||
* Each remote is fetched + compared independently; a single unreachable remote
|
||||
* surfaces as that card's `error` and never fails the whole call.
|
||||
*
|
||||
* Single-flight + TTL-cached: concurrent requests share one in-flight
|
||||
* computation, and a result younger than `STATUS_CACHE_TTL_MS` is served
|
||||
* without spawning git.
|
||||
*/
|
||||
export function getRepositoryStatus(): Promise<RepositoryStatusResult> {
|
||||
return statusCache.get();
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
export type ResponseViewerTranscriptKind = 'prompt' | 'response' | 'status' | 'tool';
|
||||
|
||||
export interface ResponseViewerTranscriptBlock {
|
||||
kind: ResponseViewerTranscriptKind;
|
||||
label: 'Prompt' | 'Response' | 'Status' | 'Tool';
|
||||
/** What the frontend renders by: 'user' gets the "You" badge, everything else the agent badge. */
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
}
|
||||
|
||||
// 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']);
|
||||
|
||||
function isPromptLine(line: string): boolean {
|
||||
return /^\s*›\s*/.test(line);
|
||||
}
|
||||
|
||||
function isDividerDashChar(ch: string): boolean {
|
||||
return ch === '─' || ch === '-';
|
||||
}
|
||||
|
||||
function isWhitespaceChar(ch: string): boolean {
|
||||
return /\s/.test(ch);
|
||||
}
|
||||
|
||||
// Linear-time equivalent of the old /^[─-]+\s*(.+?)\s*[─-]{3,}$/. The lazy
|
||||
// middle of that pattern backtracked catastrophically on a long dash run that
|
||||
// does NOT end in 3+ dashes (measured >2min at 8,000 chars) — and pane text is
|
||||
// agent-controlled with buffers up to 32MB, so this ran on hostile input. Same
|
||||
// accept set and same captured content, computed with counters; equivalence is
|
||||
// pinned char-for-char against the old regex by the brute-force corpus test in
|
||||
// test/response-viewer-transcript.test.ts.
|
||||
function normalizeDividerStatusLine(line: string): string | null {
|
||||
const s = line.trim();
|
||||
const n = s.length;
|
||||
// Minimum match: 1 leading dash + 1 content char + 3 trailing dashes.
|
||||
if (n < 5) return null;
|
||||
|
||||
let lead = 0;
|
||||
while (lead < n && isDividerDashChar(s.charAt(lead))) lead += 1;
|
||||
if (lead === 0) return null;
|
||||
|
||||
let trail = 0;
|
||||
while (trail < n && isDividerDashChar(s.charAt(n - 1 - trail))) trail += 1;
|
||||
if (trail < 3) return null;
|
||||
|
||||
// The regex was greedy on the leading run but gave dashes back until at least
|
||||
// one content char plus the 3-dash tail fit (an all-dash line matched with a
|
||||
// single leftover dash as its "content"), so the content window starts at the
|
||||
// end of the leading run, clamped to leave 4 chars.
|
||||
const contentStart = Math.min(lead, n - 4);
|
||||
let ws = 0;
|
||||
while (contentStart + ws < n - 4 && isWhitespaceChar(s.charAt(contentStart + ws))) ws += 1;
|
||||
const from = contentStart + ws;
|
||||
|
||||
// The lazy middle stopped at the first position from which "optional
|
||||
// whitespace, then dashes to end-of-line" matches: the start of the
|
||||
// whitespace padding in front of the trailing dash run (never before the
|
||||
// first content char).
|
||||
const tailStart = n - trail;
|
||||
let padded = tailStart;
|
||||
while (padded > 0 && isWhitespaceChar(s.charAt(padded - 1))) padded -= 1;
|
||||
const end = Math.max(from + 1, padded);
|
||||
|
||||
return s.slice(from, end).trim() || null;
|
||||
}
|
||||
|
||||
function isDividerOnlyLine(line: string): boolean {
|
||||
return /^[\s─-]{8,}$/.test(line.trim());
|
||||
}
|
||||
|
||||
// COD-227: unambiguous Codex tool-call markers. These only ever appear as internal
|
||||
// activity, never as ordinary assistant prose, so they are always a Tool block.
|
||||
function isToolActivityMarker(line: string): boolean {
|
||||
return /^\s*[•*-]\s+(Calling|Called)\b/.test(line.trim());
|
||||
}
|
||||
|
||||
// COD-227: action verbs that ALSO occur in ordinary assistant prose (e.g.
|
||||
// "• Created COD-226: …"). These are a Tool header only when corroborated by a
|
||||
// box-drawing result tree on the next non-blank line (see the caller); the verb
|
||||
// alone is not sufficient.
|
||||
function isToolVerbBullet(line: string): boolean {
|
||||
return /^\s*[•*-]\s+(Explored|Viewed|Read|Edited|Updated|Created|Deleted|Ran|Searched|Opened|Listed|Found|Applied|Patched|Used|Wrote|Executed|Modified|Analyzed|Compared|Fetched|Installed)\b/.test(
|
||||
line.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// A genuine Codex tool block renders its result as a box-drawing tree (└ │ ├).
|
||||
function isBoxDrawingLine(line: string): boolean {
|
||||
return /^[│├└]/.test(line.trim());
|
||||
}
|
||||
|
||||
// Look past blank lines from `fromIndex + 1` for the next non-blank line and report
|
||||
// whether it is a box-drawing tool-result line — the signal that a verb bullet is a
|
||||
// real tool block rather than assistant prose that happens to start with a verb.
|
||||
function nextNonBlankIsBoxDrawing(lines: string[], fromIndex: number): boolean {
|
||||
for (let j = fromIndex + 1; j < lines.length; j += 1) {
|
||||
const trimmed = (lines[j] || '').trim();
|
||||
if (!trimmed) continue;
|
||||
return isBoxDrawingLine(trimmed);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isToolContinuationLine(line: string, currentKind: ResponseViewerTranscriptKind | null): boolean {
|
||||
if (currentKind !== 'tool') return false;
|
||||
const trimmed = line.trimEnd();
|
||||
if (!trimmed) return true;
|
||||
return /^\s*[│├└]/.test(trimmed) || /^\s{2,}\S/.test(line);
|
||||
}
|
||||
|
||||
function isStatusLine(line: string, mode: string): boolean {
|
||||
if (!EXTERNAL_CLI_MODES.has(mode)) return false;
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return false;
|
||||
if (normalizeDividerStatusLine(trimmed)) return true;
|
||||
if (/^(model|directory):\s+/i.test(trimmed)) return true;
|
||||
if (/\bContext\b.*\bleft\b/i.test(trimmed)) return true;
|
||||
if (/\b\/model to change\b/i.test(trimmed)) return true;
|
||||
if (/\bReady\b/i.test(trimmed) && /·/.test(trimmed)) return true;
|
||||
if (/^(gpt|o\d|claude|gemini)\b/i.test(trimmed) && /·/.test(trimmed)) return true;
|
||||
if (/^Tip:/i.test(trimmed)) return true;
|
||||
if (/^\s*[•*-]\s+(Working|Thinking|Loading|Starting\b|Waiting\b)/i.test(trimmed)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isStandaloneMarkdownLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^#{1,6}\s/.test(trimmed)) return true;
|
||||
if (/^>\s/.test(trimmed)) return true;
|
||||
if (/^(```|~~~)/.test(trimmed)) return true;
|
||||
if (/^[-*+]\s/.test(trimmed)) return true;
|
||||
if (/^\d+[.)]\s/.test(trimmed)) return true;
|
||||
if (/^\|/.test(trimmed)) return true;
|
||||
if (/^\s{4,}\S/.test(line)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldJoinWrappedLine(previous: string, next: string): boolean {
|
||||
const prev = previous.trimEnd();
|
||||
const curr = next.trim();
|
||||
if (!prev || !curr) return false;
|
||||
if (/[.!?]$/.test(prev)) return false;
|
||||
if (/[:;]$/.test(prev)) return false;
|
||||
if (isStandaloneMarkdownLine(curr)) return false;
|
||||
if (/^[a-z(]/.test(curr)) return true;
|
||||
if (prev.length >= 72 && /^[A-Za-z0-9"'(]/.test(curr)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeWrappedText(lines: string[]): string {
|
||||
const out: string[] = [];
|
||||
let paragraph = '';
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (!paragraph) return;
|
||||
out.push(paragraph);
|
||||
paragraph = '';
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushParagraph();
|
||||
if (out[out.length - 1] !== '') out.push('');
|
||||
continue;
|
||||
}
|
||||
if (isStandaloneMarkdownLine(line)) {
|
||||
flushParagraph();
|
||||
out.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
if (!paragraph) {
|
||||
paragraph = trimmed;
|
||||
continue;
|
||||
}
|
||||
if (shouldJoinWrappedLine(paragraph, trimmed)) {
|
||||
paragraph += ` ${trimmed}`;
|
||||
continue;
|
||||
}
|
||||
flushParagraph();
|
||||
paragraph = trimmed;
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
return out
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function cleanTerminalTranscript(buffer: string): string {
|
||||
// Stripping ANSI/OSC/DCS escape sequences and stray C0/C1 control bytes
|
||||
// legitimately requires control characters in these patterns.
|
||||
/* eslint-disable no-control-regex */
|
||||
return String(buffer || '')
|
||||
.replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '')
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
|
||||
.replace(/\x1b[NO()][A-Z0-9]?/g, '')
|
||||
.replace(/\x1b[>=<78cDEHM]/g, '')
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.trim();
|
||||
/* eslint-enable no-control-regex */
|
||||
}
|
||||
|
||||
function trimLeadingStartup(lines: string[]): string[] {
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] || '';
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^[╭╰│─].*[╮╯│]?$/.test(trimmed)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^>_\s*OpenAI Codex/i.test(trimmed)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^(model|directory):\s+/i.test(trimmed)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return lines.slice(index);
|
||||
}
|
||||
|
||||
function pushBlock(
|
||||
blocks: ResponseViewerTranscriptBlock[],
|
||||
kind: ResponseViewerTranscriptKind | null,
|
||||
lines: string[]
|
||||
): void {
|
||||
if (!kind || lines.length === 0) return;
|
||||
const normalizedLines =
|
||||
kind === 'status'
|
||||
? lines.map((line) => normalizeDividerStatusLine(line) || line.trim()).filter((line) => line.length > 0)
|
||||
: lines;
|
||||
const text =
|
||||
kind === 'tool' || kind === 'status'
|
||||
? normalizedLines
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
: normalizeWrappedText(normalizedLines);
|
||||
if (!text) return;
|
||||
const label = (kind.charAt(0).toUpperCase() + kind.slice(1)) as ResponseViewerTranscriptBlock['label'];
|
||||
// The frontend's loadFullContext() renders via msg.role — a block without it
|
||||
// lost the "You" badge on prompts and rendered every block as the agent.
|
||||
blocks.push({ kind, label, role: kind === 'prompt' ? 'user' : 'assistant', text });
|
||||
}
|
||||
|
||||
export function isExternalCliTranscriptMode(mode: string | null | undefined): boolean {
|
||||
return EXTERNAL_CLI_MODES.has(String(mode || ''));
|
||||
}
|
||||
|
||||
export function parseExternalCliTranscript(
|
||||
buffer: string,
|
||||
mode: string | null | undefined
|
||||
): ResponseViewerTranscriptBlock[] {
|
||||
const resolvedMode = String(mode || '');
|
||||
if (!isExternalCliTranscriptMode(resolvedMode)) return [];
|
||||
|
||||
const cleaned = cleanTerminalTranscript(buffer);
|
||||
if (!cleaned) return [];
|
||||
|
||||
const lines = trimLeadingStartup(cleaned.split('\n'));
|
||||
const blocks: ResponseViewerTranscriptBlock[] = [];
|
||||
let currentKind: ResponseViewerTranscriptKind | null = null;
|
||||
let currentLines: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
pushBlock(blocks, currentKind, currentLines);
|
||||
currentKind = null;
|
||||
currentLines = [];
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i] ?? '';
|
||||
// COD-226: within a prompt, the 2-space Codex gutter is authoritative. Blank
|
||||
// lines and gutter-indented (2+ leading spaces) continuation lines stay in the
|
||||
// Prompt block ahead of every structural detector below, so multiline prompts —
|
||||
// including bullets, indented dividers, and literal › lines — are not
|
||||
// misclassified as responses. Only a non-blank, non-gutter line ends the prompt
|
||||
// and falls through (a column-0 › then opens a NEW prompt).
|
||||
if (currentKind === 'prompt') {
|
||||
if (!line.trim()) {
|
||||
currentLines.push('');
|
||||
continue;
|
||||
}
|
||||
if (/^ {2,}\S/.test(line)) {
|
||||
currentLines.push(line);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDividerOnlyLine(line)) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPromptLine(line)) {
|
||||
flush();
|
||||
currentKind = 'prompt';
|
||||
currentLines = [line.replace(/^\s*›\s*/, '').trim()];
|
||||
continue;
|
||||
}
|
||||
|
||||
// COD-227: • Calling / • Called are always tool markers; the other action verbs
|
||||
// are a tool header only when a box-drawing result tree follows on the next
|
||||
// non-blank line — otherwise the verb bullet is ordinary assistant prose.
|
||||
if (isToolActivityMarker(line) || (isToolVerbBullet(line) && nextNonBlankIsBoxDrawing(lines, i))) {
|
||||
if (currentKind !== 'tool') flush();
|
||||
currentKind = 'tool';
|
||||
currentLines.push(line.trimEnd());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isToolContinuationLine(line, currentKind)) {
|
||||
currentLines.push(line.trimEnd());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isStatusLine(line, resolvedMode)) {
|
||||
if (currentKind !== 'status') flush();
|
||||
currentKind = 'status';
|
||||
currentLines.push(line.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
currentLines.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentKind !== 'response') flush();
|
||||
currentKind = 'response';
|
||||
currentLines.push(line);
|
||||
}
|
||||
|
||||
flush();
|
||||
return blocks.filter((block) => block.text.trim().length > 0);
|
||||
}
|
||||
|
||||
export function getLastTranscriptResponse(blocks: ResponseViewerTranscriptBlock[]): string {
|
||||
for (let i = blocks.length - 1; i >= 0; i -= 1) {
|
||||
if (blocks[i]?.kind === 'response') return blocks[i].text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -131,6 +131,11 @@ import {
|
||||
toSessionDocker,
|
||||
} from '../../docker-hosts.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import {
|
||||
getLastTranscriptResponse,
|
||||
isExternalCliTranscriptMode,
|
||||
parseExternalCliTranscript,
|
||||
} from '../response-viewer-transcript.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
@@ -797,54 +802,42 @@ export function registerSessionRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
// Check OpenCode availability if requested. The error text comes from the
|
||||
// resolver (formatCliNotFoundMessage) so it names where resolution looked —
|
||||
// server PATH, login shell, common directories — same for the modes below.
|
||||
if (body.mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (body.mode === 'codex') {
|
||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
||||
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gemini availability if requested
|
||||
if (body.mode === 'gemini') {
|
||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||||
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
if (body.mode === 'antigravity') {
|
||||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
||||
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
|
||||
await import('../../utils/antigravity-cli-resolver.js');
|
||||
if (!isAntigravityAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
|
||||
}
|
||||
}
|
||||
if (body.mode === 'pi') {
|
||||
const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js');
|
||||
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
|
||||
if (!isPiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1898,6 +1891,22 @@ export function registerSessionRoutes(
|
||||
return await readCodexLastResponse(session, codexQuery.context === 'full');
|
||||
}
|
||||
|
||||
// OpenCode / Gemini / Antigravity / Pi render their own TUIs and write no
|
||||
// Claude transcript, so the scan below finds nothing and the response viewer
|
||||
// renders permanently empty for them. Segment the terminal buffer instead —
|
||||
// the pane IS the transcript for these CLIs. Codex is already handled above,
|
||||
// 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);
|
||||
return {
|
||||
text: getLastTranscriptResponse(blocks),
|
||||
timestamp: '',
|
||||
hasContext: blocks.length > 0,
|
||||
messages: externalQuery.context === 'full' ? blocks : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Scan ~/.claude/projects/*/ for the transcript file
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
|
||||
@@ -2806,58 +2815,46 @@ export function registerSessionRoutes(
|
||||
dockerResumeId = dockerCase.lastClaudeSessionId;
|
||||
}
|
||||
} else {
|
||||
// Check OpenCode availability if requested
|
||||
// Check OpenCode availability if requested. Error text comes from the
|
||||
// resolver so it carries the resolution diagnostics; same for the modes below.
|
||||
if (mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } =
|
||||
await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (mode === 'codex') {
|
||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
||||
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gemini availability if requested
|
||||
if (mode === 'gemini') {
|
||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||||
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Antigravity availability if requested
|
||||
if (mode === 'antigravity') {
|
||||
const { isAntigravityAvailable } = await import('../../utils/antigravity-cli-resolver.js');
|
||||
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
|
||||
await import('../../utils/antigravity-cli-resolver.js');
|
||||
if (!isAntigravityAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Check Pi availability if requested
|
||||
if (mode === 'pi') {
|
||||
const { isPiAvailable } = await import('../../utils/pi-cli-resolver.js');
|
||||
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
|
||||
if (!isPiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Pi CLI not found. Install with: npm install -g --ignore-scripts @earendil-works/pi-coding-agent'
|
||||
);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
|
||||
import { getRepositoryStatus } from '../repo-status.js';
|
||||
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
|
||||
@@ -354,6 +355,11 @@ export function registerSystemRoutes(
|
||||
// Poll target for update progress — survives the restart the update triggers.
|
||||
app.get('/api/system/update/status', async () => getUpdateStatusForApi());
|
||||
|
||||
// Informational companion to the release-tag updater above: what this CHECKOUT
|
||||
// looks like against its own remotes (ahead/behind, incoming commits), which a
|
||||
// release tag cannot answer for a git install tracking a branch.
|
||||
app.get('/api/system/repo-status', async () => getRepositoryStatus());
|
||||
|
||||
// Kick off a detached update to the latest release. Returns immediately; the
|
||||
// browser then polls /api/system/update/status across the service restart.
|
||||
app.post('/api/system/update', async (_req, reply) => {
|
||||
|
||||
@@ -220,10 +220,18 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
||||
// An idle-prompt inbox item means "composer is waiting"; any working
|
||||
// transition means input arrived, so the item is moot. ONLY the idle
|
||||
// kind: `working` is heuristic and can flap mid-turn, so clearing a
|
||||
// pending permission/question dialog on it would false-clear real
|
||||
// approvals (those resolve via stop / elicitation hooks / answer-time
|
||||
// re-capture instead).
|
||||
// pending permission/question dialog on the signal ALONE would
|
||||
// false-clear real approvals.
|
||||
approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']);
|
||||
// A permission/question dialog gets the pane-VERIFIED variant instead:
|
||||
// the signal only decides when to look, `verifyStillAnswerable` re-reads
|
||||
// the screen and resolves only when the dialog is really gone. Without
|
||||
// this, answering a dialog in the terminal left its red "needs you" alert
|
||||
// armed for the rest of the turn, because the only other staleness check
|
||||
// lives in `GET /api/approvals` and nothing calls that while a page is
|
||||
// open. `stop` was the first thing to clear it, which on a long turn is
|
||||
// minutes away.
|
||||
approvalInbox.resolveIfDialogGone(session.id);
|
||||
deps.broadcast(SseEvent.SessionWorking, { id: session.id });
|
||||
// Full state ride-along: the home screens sort the running group on
|
||||
// lastSubmitAt, and without this the browser keeps the stamp it loaded
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @fileoverview Tests for the Antigravity CLI resolver wrapper.
|
||||
*/
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createAntigravityResolverForTest, isAntigravityAvailable } from '../src/utils/antigravity-cli-resolver.js';
|
||||
import {
|
||||
cliResolveRetryDelayMs,
|
||||
type CliResolution,
|
||||
type CliResolverHost,
|
||||
} from '../src/utils/cli-executable-resolver.js';
|
||||
|
||||
const availabilityResolution = vi.hoisted(() => ({ current: null as CliResolution | null }));
|
||||
|
||||
vi.mock('../src/utils/cli-executable-resolver.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../src/utils/cli-executable-resolver.js')>();
|
||||
return {
|
||||
...actual,
|
||||
createCliExecutableResolver: (options: { binary: string; searchDirs: string[] }, host?: CliResolverHost) =>
|
||||
host
|
||||
? actual.createCliExecutableResolver(options, host)
|
||||
: {
|
||||
resolve: () => availabilityResolution.current,
|
||||
diagnostics: () => ({
|
||||
binary: options.binary,
|
||||
processPath: '/service/bin',
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
searchDirs: [...options.searchDirs],
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function createHost(
|
||||
options: {
|
||||
processPathResult?: string | null;
|
||||
loginShellResults?: Array<string | null>;
|
||||
existingPaths?: string[];
|
||||
} = {}
|
||||
): CliResolverHost {
|
||||
const loginShellResults = [...(options.loginShellResults ?? [])];
|
||||
const existingPaths = new Set(options.existingPaths ?? []);
|
||||
return {
|
||||
processPath: '/service/bin',
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
findOnProcessPath: () => options.processPathResult ?? null,
|
||||
findInLoginShell: () => loginShellResults.shift() ?? null,
|
||||
exists: (path) => existingPaths.has(path),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Antigravity CLI resolver', () => {
|
||||
beforeEach(() => {
|
||||
availabilityResolution.current = null;
|
||||
});
|
||||
|
||||
it('resolves agy from the service PATH', () => {
|
||||
const binaryPath = '/service/bin/agy';
|
||||
const resolver = createAntigravityResolverForTest(
|
||||
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] })
|
||||
);
|
||||
|
||||
expect(resolver.resolve()?.directory).toBe('/service/bin');
|
||||
});
|
||||
|
||||
it('falls back to a common install directory', () => {
|
||||
const binaryPath = join(homedir(), '.local', 'bin', 'agy');
|
||||
const resolver = createAntigravityResolverForTest(createHost({ existingPaths: [binaryPath] }));
|
||||
|
||||
expect(resolver.resolve()?.directory).toBe(join(homedir(), '.local', 'bin'));
|
||||
});
|
||||
|
||||
it('resolves agy found only by the login shell', () => {
|
||||
const binaryPath = '/login-shell/bin/agy';
|
||||
const resolver = createAntigravityResolverForTest(
|
||||
createHost({ loginShellResults: [binaryPath], existingPaths: [binaryPath] })
|
||||
);
|
||||
|
||||
expect(resolver.resolve()?.directory).toBe('/login-shell/bin');
|
||||
});
|
||||
|
||||
it('returns null when agy is unavailable', () => {
|
||||
const resolver = createAntigravityResolverForTest(createHost());
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
});
|
||||
|
||||
it('retries a failed lookup after the backoff and caches the first successful login-shell discovery', () => {
|
||||
const binaryPath = '/late-login-shell/bin/agy';
|
||||
let now = 0;
|
||||
const resolver = createAntigravityResolverForTest(
|
||||
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||
() => now
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
// A miss is negative-cached: within the backoff window nothing re-runs the
|
||||
// chain (its login-shell tail is a synchronous bounded spawn in production).
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
now = cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||
});
|
||||
|
||||
it('reports the public wrapper as available when agy resolves', () => {
|
||||
availabilityResolution.current = {
|
||||
binaryPath: '/service/bin/agy',
|
||||
directory: '/service/bin',
|
||||
source: 'process-path',
|
||||
};
|
||||
|
||||
expect(isAntigravityAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the public wrapper as unavailable when agy does not resolve', () => {
|
||||
expect(isAntigravityAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,39 @@ const ASK_USER_QUESTION_FRAME = [
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
||||
].join('\n');
|
||||
|
||||
// Both frames captured off a live Claude Code v2.1.237 pane. They are the
|
||||
// discriminator behind the late-hook resolution: a modal dialog BLOCKS the
|
||||
// turn, so a working line and a dialog cannot coexist. Note the dialog frame
|
||||
// carries no `esc to interrupt` footer either — the dialog replaces it.
|
||||
const LIVE_DIALOG_FRAME = [
|
||||
'● Bash(sleep 12; echo "slept 12s")',
|
||||
' ⎿ slept 12s',
|
||||
'────────────────────────────────────────',
|
||||
' ☐ Proceed',
|
||||
'',
|
||||
'Proceed?',
|
||||
'',
|
||||
'❯ 1. Yes',
|
||||
' Go ahead and proceed.',
|
||||
' 2. No',
|
||||
' Do not proceed.',
|
||||
' 3. Type something.',
|
||||
'────────────────────────────────────────',
|
||||
' 4. Chat about this',
|
||||
'',
|
||||
'Enter to select · ↑/↓ to navigate · Esc to cancel',
|
||||
].join('\n');
|
||||
|
||||
const WORKING_FRAME = [
|
||||
'● Bash(sleep 25)',
|
||||
' ⎿ Tip: Use git worktrees to run multiple Claude sessions in parallel.',
|
||||
'✢ Clauding… (13s · ↓ 1.4k tokens)',
|
||||
'────────────────────────────────────────',
|
||||
'❯',
|
||||
'────────────────────────────────────────',
|
||||
' ⏵⏵ bypass permissions on (shift+tab to cycle) · esc to interrupt · ← for agents',
|
||||
].join('\n');
|
||||
|
||||
function collect(inbox: ApprovalInbox) {
|
||||
const pending: ApprovalItem[] = [];
|
||||
const updated: ApprovalItem[] = [];
|
||||
@@ -333,6 +366,186 @@ describe('ApprovalInbox', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('answered-in-the-terminal staleness', () => {
|
||||
// Claude Code delays the Notification hook behind the dialog (measured 6s
|
||||
// on v2.1.237, documented up to ~30s), so the 600ms re-capture routinely
|
||||
// lands on a frame the user has ALREADY answered. Erasing `options` there
|
||||
// made the item permanently unsweepable, because a missing `options` is how
|
||||
// "we never could read this dialog" is expressed, and such items stay
|
||||
// answerable on purpose. The red tab alert then survived every
|
||||
// `GET /api/approvals` and every reload, clearing only on `stop`.
|
||||
it('a re-capture taken after the answer does not erase parsed options', () => {
|
||||
const { updated } = collect(inbox);
|
||||
let frame = ASK_USER_QUESTION_FRAME;
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => frame,
|
||||
});
|
||||
expect(item.options).toHaveLength(5);
|
||||
|
||||
// Answered in the terminal before the re-capture fires.
|
||||
frame = "● User answered Claude's questions:\n ⎿ · Which color do you prefer? → Red\n\n✶ Cooking… (6s)";
|
||||
vi.advanceTimersByTime(600);
|
||||
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(inbox.getById(item.id)?.context).toContain('User answered');
|
||||
expect(inbox.getById(item.id)?.options).toHaveLength(5);
|
||||
// ...which keeps the staleness check conclusive instead of inconclusive.
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(false);
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone resolves a dialog answered in the terminal', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
let frame = PERMISSION_FRAME;
|
||||
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
|
||||
frame = 'the dialog is gone, claude is typing';
|
||||
|
||||
inbox.resolveIfDialogGone('s1');
|
||||
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone keeps a dialog that is still on screen', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => PERMISSION_FRAME,
|
||||
});
|
||||
inbox.resolveIfDialogGone('s1');
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone never touches an idle item (that is the working signal job)', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'idle',
|
||||
capture: () => 'composer is empty',
|
||||
});
|
||||
inbox.resolveIfDialogGone('s1');
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
// The late-hook hole: Claude Code fires the Notification behind the dialog,
|
||||
// so a prompt answered before the hook lands produces an item whose FIRST
|
||||
// capture already has no dialog in it. Nothing ever parsed, so "options
|
||||
// vanished" can never fire, and `stop` had already gone by too — the red
|
||||
// alert then survived reloads until the 12h TTL.
|
||||
it('resolves an item that never parsed options once the pane is visibly working', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => WORKING_FRAME,
|
||||
});
|
||||
expect(item.options).toBeUndefined();
|
||||
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(false);
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||
});
|
||||
|
||||
it('keeps an unreadable dialog answerable when the pane is NOT visibly working', () => {
|
||||
// The conservative rule this fix must not loosen: no options and no proof
|
||||
// the turn is running means "we cannot read it", not "it is gone".
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => 'some dialog shape we cannot parse',
|
||||
});
|
||||
expect(item.options).toBeUndefined();
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('a real live-dialog frame carries no working line, so it is never false-resolved', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => LIVE_DIALOG_FRAME,
|
||||
});
|
||||
expect(item.options).toHaveLength(4);
|
||||
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone clears a late-hook item on the next working signal', () => {
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => WORKING_FRAME,
|
||||
});
|
||||
inbox.resolveIfDialogGone('s1');
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('the delayed staleness pass resolves a late-hook item with no working signal needed', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => WORKING_FRAME,
|
||||
});
|
||||
expect(item.options).toBeUndefined();
|
||||
|
||||
vi.advanceTimersByTime(600); // re-capture: enrichment only, never resolves
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
|
||||
vi.advanceTimersByTime(2400); // the delayed staleness pass
|
||||
expect(inbox.getById(item.id)).toBeUndefined();
|
||||
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
|
||||
});
|
||||
|
||||
it('a dialog Ink paints late is NOT resolved by the delayed pass', () => {
|
||||
// The paint race the re-capture exists for: the hook can beat Ink to the
|
||||
// screen. Resolving inside that window would clear the alert for a dialog
|
||||
// that was about to appear, so the frame is what decides, every time.
|
||||
let frame = WORKING_FRAME;
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => frame,
|
||||
});
|
||||
frame = LIVE_DIALOG_FRAME; // Ink finishes painting
|
||||
|
||||
vi.advanceTimersByTime(600);
|
||||
expect(inbox.getById(item.id)?.options).toHaveLength(4);
|
||||
vi.advanceTimersByTime(2400);
|
||||
expect(inbox.getById(item.id)).toBeDefined();
|
||||
});
|
||||
|
||||
it('resolving cancels both pending timers', () => {
|
||||
const { updated } = collect(inbox);
|
||||
const item = inbox.notePrompt({
|
||||
sessionId: 's1',
|
||||
sessionName: 'w1',
|
||||
kind: 'permission',
|
||||
capture: () => PERMISSION_FRAME,
|
||||
});
|
||||
inbox.dismiss(item.id);
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(updated).toHaveLength(0);
|
||||
expect(inbox.listPending()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resolveIfDialogGone is a no-op for a session with nothing pending', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
expect(() => inbox.resolveIfDialogGone('nobody')).not.toThrow();
|
||||
expect(resolved).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('stop() clears items and silences events', () => {
|
||||
const { resolved } = collect(inbox);
|
||||
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js';
|
||||
import {
|
||||
cliResolveRetryDelayMs,
|
||||
createCliExecutableResolver,
|
||||
createProductionCliResolverHost,
|
||||
formatCliNotFoundMessage,
|
||||
type CliResolverHost,
|
||||
} from '../src/utils/cli-executable-resolver.js';
|
||||
|
||||
// Pass-through spy on execFileSync so the vitest-hermeticity test below can
|
||||
// PROVE the un-injected production host never spawns anything.
|
||||
const { execFileSyncSpy } = vi.hoisted(() => ({ execFileSyncSpy: vi.fn() }));
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||
execFileSyncSpy.mockImplementation(actual.execFileSync as (...args: unknown[]) => unknown);
|
||||
return { ...actual, execFileSync: execFileSyncSpy };
|
||||
});
|
||||
|
||||
const BEGIN_MARKER = '__CODEMAN_CLI_RESOLVE_BEGIN__';
|
||||
const END_MARKER = '__CODEMAN_CLI_RESOLVE_END__';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function host(overrides: Partial<CliResolverHost> = {}): CliResolverHost {
|
||||
return {
|
||||
processPath: '/usr/bin:/bin',
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
findOnProcessPath: vi.fn(() => null),
|
||||
findInLoginShell: vi.fn(() => null),
|
||||
exists: vi.fn(() => false),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createCliExecutableResolver', () => {
|
||||
it('prefers the server process PATH over common directories and the login shell', () => {
|
||||
const h = host({
|
||||
findOnProcessPath: vi.fn(() => '/process/bin/codex'),
|
||||
findInLoginShell: vi.fn(() => '/shell/bin/codex'),
|
||||
exists: vi.fn(() => true),
|
||||
});
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/known/bin'] }, h);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({ binaryPath: '/process/bin/codex', source: 'process-path' });
|
||||
expect(h.exists).toHaveBeenCalledTimes(1);
|
||||
expect(h.findInLoginShell).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers common directories in order over the login shell', () => {
|
||||
const h = host({
|
||||
findInLoginShell: vi.fn(() => '/shell/bin/codex'),
|
||||
exists: vi.fn((path) => path === '/second/bin/codex' || path === '/shell/bin/codex'),
|
||||
});
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/first/bin', '/second/bin'] }, h);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({ binaryPath: '/second/bin/codex', source: 'common-directory' });
|
||||
expect(h.exists).toHaveBeenNthCalledWith(1, '/first/bin/codex');
|
||||
expect(h.exists).toHaveBeenNthCalledWith(2, '/second/bin/codex');
|
||||
expect(h.findInLoginShell).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finds an executable exposed only by the interactive login shell', () => {
|
||||
const h = host({
|
||||
findInLoginShell: vi.fn(() => '/home/u/.nvm/versions/node/v22/bin/codex'),
|
||||
exists: vi.fn((path) => path === '/home/u/.nvm/versions/node/v22/bin/codex'),
|
||||
});
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: ['/known/bin'] }, h);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({
|
||||
binaryPath: '/home/u/.nvm/versions/node/v22/bin/codex',
|
||||
directory: '/home/u/.nvm/versions/node/v22/bin',
|
||||
source: 'login-shell',
|
||||
});
|
||||
});
|
||||
|
||||
it('continues after a validator rejects an earlier candidate', () => {
|
||||
const h = host({
|
||||
findOnProcessPath: vi.fn(() => '/usr/bin/pi'),
|
||||
findInLoginShell: vi.fn(() => '/home/u/.npm/bin/pi'),
|
||||
exists: vi.fn(() => true),
|
||||
});
|
||||
const resolver = createCliExecutableResolver(
|
||||
{
|
||||
binary: 'pi',
|
||||
searchDirs: [],
|
||||
validateCandidate: (path) =>
|
||||
path.includes('.npm') ? { accepted: true, metadata: '0.84.1' } : { accepted: false },
|
||||
},
|
||||
h
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({
|
||||
binaryPath: '/home/u/.npm/bin/pi',
|
||||
source: 'login-shell',
|
||||
metadata: '0.84.1',
|
||||
});
|
||||
});
|
||||
|
||||
it('caches success, and retries a miss only after the backoff elapses', () => {
|
||||
let now = 0;
|
||||
const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/codex');
|
||||
const h = host({ findInLoginShell, exists: vi.fn((path) => path === '/new/bin/codex') });
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => now }, h);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
// Within the backoff window the miss is answered from the negative cache:
|
||||
// the chain — whose login-shell tail is a synchronous 5s-bounded spawn —
|
||||
// must NOT re-run per call, or a missing CLI stalls every status request.
|
||||
now = cliResolveRetryDelayMs(1) - 1;
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Once the backoff elapses the retry runs, so installing a CLI while the
|
||||
// server is up is still picked up without a restart.
|
||||
now = cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
||||
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('doubles the retry delay per consecutive miss and caps it at five minutes', () => {
|
||||
let now = 0;
|
||||
const findInLoginShell = vi.fn(() => null);
|
||||
const resolver = createCliExecutableResolver(
|
||||
{ binary: 'codex', searchDirs: [], now: () => now },
|
||||
host({ findInLoginShell })
|
||||
);
|
||||
|
||||
expect(cliResolveRetryDelayMs(0)).toBe(0);
|
||||
expect(cliResolveRetryDelayMs(1)).toBe(60_000);
|
||||
expect(cliResolveRetryDelayMs(2)).toBe(120_000);
|
||||
expect(cliResolveRetryDelayMs(3)).toBe(240_000);
|
||||
expect(cliResolveRetryDelayMs(4)).toBe(300_000);
|
||||
expect(cliResolveRetryDelayMs(60)).toBe(300_000);
|
||||
|
||||
// Consecutive misses stack: after the second miss the SECOND delay applies.
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
now += cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
now += cliResolveRetryDelayMs(2) - 1;
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(2);
|
||||
now += 1;
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(findInLoginShell).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('rejects unsafe binary names', () => {
|
||||
const h = host();
|
||||
|
||||
expect(() => createCliExecutableResolver({ binary: 'codex;id', searchDirs: [] }, h)).toThrow(
|
||||
'Unsafe CLI binary name'
|
||||
);
|
||||
expect(() => createCliExecutableResolver({ binary: '../codex', searchDirs: [] }, h)).toThrow(
|
||||
'Unsafe CLI binary name'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects relative and nonexistent candidates', () => {
|
||||
let now = 0;
|
||||
const findInLoginShell = vi
|
||||
.fn<() => string | null>()
|
||||
.mockReturnValueOnce('relative/codex')
|
||||
.mockReturnValue('/missing/codex');
|
||||
const h = host({ findInLoginShell, exists: vi.fn(() => false) });
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => now }, h);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
now = cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(h.exists).toHaveBeenCalledTimes(1);
|
||||
expect(h.exists).toHaveBeenCalledWith('/missing/codex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCliNotFoundMessage', () => {
|
||||
it('includes only the base install hint and bounded resolution diagnostics', () => {
|
||||
const base = 'Codex CLI not found. Install with: npm install -g @openai/codex';
|
||||
const diagnostics = {
|
||||
binary: 'codex',
|
||||
processPath: '/usr/bin:/bin',
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
searchDirs: ['/home/u/.local/bin', '/usr/local/bin'],
|
||||
API_KEY: 'super-secret',
|
||||
};
|
||||
const message = formatCliNotFoundMessage(base, diagnostics);
|
||||
|
||||
expect(message).toContain(base);
|
||||
expect(message).toContain('Server PATH: /usr/bin:/bin');
|
||||
expect(message).toContain('Login shell: /bin/bash -i -l');
|
||||
expect(message).toContain('Checked directories: /home/u/.local/bin, /usr/local/bin');
|
||||
expect(message).not.toContain('API_KEY');
|
||||
expect(message).not.toContain('super-secret');
|
||||
});
|
||||
|
||||
it('marks empty diagnostic values without dumping arbitrary environment data', () => {
|
||||
const message = formatCliNotFoundMessage('Missing CLI', {
|
||||
binary: 'codex',
|
||||
processPath: '',
|
||||
shellPath: '',
|
||||
shellArgs: [],
|
||||
searchDirs: [],
|
||||
});
|
||||
|
||||
expect(message).toBe('Missing CLI\nServer PATH: (empty)\nLogin shell: (none)\nChecked directories: (none)');
|
||||
expect(message).not.toContain('HOME=');
|
||||
expect(message).not.toContain('TOKEN=');
|
||||
});
|
||||
|
||||
it('flattens control characters and bounds every diagnostic field', () => {
|
||||
const pathological = `first\r\nforged label: value\u0000${'x'.repeat(10_000)}`;
|
||||
const message = formatCliNotFoundMessage('Missing CLI', {
|
||||
binary: 'codex',
|
||||
processPath: pathological,
|
||||
shellPath: pathological,
|
||||
shellArgs: [pathological],
|
||||
searchDirs: [pathological, pathological],
|
||||
});
|
||||
const lines = message.split('\n');
|
||||
|
||||
expect(lines).toHaveLength(4);
|
||||
expect(lines[1]).toMatch(/^Server PATH: first forged label: value x+…$/);
|
||||
expect(lines[2]).toMatch(/^Login shell: first forged label: value x+…$/);
|
||||
expect(lines[3]).toMatch(/^Checked directories: first forged label: value x+…$/);
|
||||
expect(lines.slice(1).every((line) => line.length <= 1_050)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProductionCliResolverHost', () => {
|
||||
// Hermeticity gate (the guards PR #329 deleted, restored shared): under
|
||||
// vitest an un-injected host must neither scan the machine nor spawn a login
|
||||
// shell — route tests hitting the per-CLI status endpoints would otherwise
|
||||
// walk the real PATH and execute real binaries on whatever box runs the suite.
|
||||
it('never scans the machine or spawns a login shell under vitest without injected IO', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-cli-vitest-gate-'));
|
||||
temporaryDirectories.push(root);
|
||||
writeFileSync(join(root, 'codex'), '#!/bin/sh\n');
|
||||
chmodSync(join(root, 'codex'), 0o755);
|
||||
execFileSyncSpy.mockClear();
|
||||
|
||||
const gatedHost = createProductionCliResolverHost({
|
||||
processPath: root,
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
});
|
||||
|
||||
// The real, executable candidate is invisible: the filesystem predicate is inert.
|
||||
expect(gatedHost.findOnProcessPath('codex')).toBeNull();
|
||||
expect(gatedHost.exists(join(root, 'codex'))).toBe(false);
|
||||
// The login-shell step yields nothing and never reaches execFileSync.
|
||||
expect(gatedHost.findInLoginShell('codex')).toBeNull();
|
||||
expect(execFileSyncSpy).not.toHaveBeenCalled();
|
||||
|
||||
// The same fixture through the test-only real-IO opt-in IS found, proving
|
||||
// the nulls above come from the vitest gate rather than from the fixture.
|
||||
const optedInHost = createProductionCliResolverHost({
|
||||
processPath: root,
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
runCommand: () => '',
|
||||
allowRealIoUnderVitest: true,
|
||||
});
|
||||
expect(optedInHost.findOnProcessPath('codex')).toBe(join(root, 'codex'));
|
||||
});
|
||||
|
||||
it('resolves through injected IO hooks under vitest (injection is the opt-in)', () => {
|
||||
const runCommand = vi.fn(() => `${BEGIN_MARKER}\n/home/u/.nvm/bin/codex\n${END_MARKER}`);
|
||||
const productionHost = createProductionCliResolverHost({
|
||||
processPath: '',
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
runCommand,
|
||||
isExecutableFile: () => true,
|
||||
});
|
||||
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [] }, productionHost);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({
|
||||
binaryPath: '/home/u/.nvm/bin/codex',
|
||||
source: 'login-shell',
|
||||
});
|
||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('searches the captured process PATH directly in directory order without running a command', () => {
|
||||
const runCommand = vi.fn(() => '');
|
||||
const isExecutableFile = vi.fn((path: string) => path === '/second/bin/codex');
|
||||
const productionHost = createProductionCliResolverHost({
|
||||
processPath: '/first/bin:/second/bin:/third/bin',
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
runCommand,
|
||||
isExecutableFile,
|
||||
});
|
||||
|
||||
expect(productionHost.findOnProcessPath('codex')).toBe('/second/bin/codex');
|
||||
expect(isExecutableFile).toHaveBeenNthCalledWith(1, '/first/bin/codex');
|
||||
expect(isExecutableFile).toHaveBeenNthCalledWith(2, '/second/bin/codex');
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts only executable regular files with the production predicate', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-cli-resolver-'));
|
||||
temporaryDirectories.push(root);
|
||||
const executableDirectory = join(root, 'executable');
|
||||
const plainDirectory = join(root, 'plain');
|
||||
const directoryCandidate = join(root, 'directory');
|
||||
mkdirSync(executableDirectory);
|
||||
mkdirSync(plainDirectory);
|
||||
mkdirSync(directoryCandidate);
|
||||
writeFileSync(join(executableDirectory, 'codex'), '#!/bin/sh\n');
|
||||
chmodSync(join(executableDirectory, 'codex'), 0o755);
|
||||
writeFileSync(join(plainDirectory, 'codex'), '#!/bin/sh\n');
|
||||
mkdirSync(join(directoryCandidate, 'codex'));
|
||||
const productionHost = createProductionCliResolverHost({
|
||||
processPath: [directoryCandidate, plainDirectory, executableDirectory].join(':'),
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
// This test exists to exercise the REAL executable-regular-file predicate
|
||||
// against its own temp fixtures, so it opts out of the vitest inert-IO
|
||||
// gate; the stubbed runCommand keeps the login-shell path inert anyway.
|
||||
runCommand: () => '',
|
||||
allowRealIoUnderVitest: true,
|
||||
});
|
||||
|
||||
expect(productionHost.findOnProcessPath('codex')).toBe(join(executableDirectory, 'codex'));
|
||||
});
|
||||
|
||||
it('uses the resolved shell, allowlisted args, tagged command, and bounded timeout', () => {
|
||||
const runCommand = vi.fn(() =>
|
||||
['/profile/absolute-noise', BEGIN_MARKER, '/home/u/.nvm/bin/codex', END_MARKER, '/exit-trap/absolute-noise'].join(
|
||||
'\n'
|
||||
)
|
||||
);
|
||||
const productionHost = createProductionCliResolverHost({
|
||||
processPath: '',
|
||||
shellPath: '/usr/bin/fish',
|
||||
shellArgs: ['-i', '-l'],
|
||||
runCommand,
|
||||
isExecutableFile: () => true,
|
||||
});
|
||||
|
||||
expect(productionHost.findInLoginShell('codex')).toBe('/home/u/.nvm/bin/codex');
|
||||
expect(runCommand).toHaveBeenCalledWith(
|
||||
'/usr/bin/fish',
|
||||
['-i', '-l', '-c', `printf '%s\\n' '${BEGIN_MARKER}'; command -v -- codex; printf '%s\\n' '${END_MARKER}'`],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
// SIGKILL is load-bearing: interactive bash ignores SIGTERM, and
|
||||
// execFileSync's timeout only sends the signal, then keeps waiting.
|
||||
killSignal: 'SIGKILL',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['mismatched basename', `${BEGIN_MARKER}\n/opt/bin/not-codex\n${END_MARKER}`],
|
||||
['missing begin marker', `/opt/bin/codex\n${END_MARKER}`],
|
||||
['missing end marker', `${BEGIN_MARKER}\n/opt/bin/codex`],
|
||||
['absolute output outside markers', `/profile/codex\n${BEGIN_MARKER}\nrelative/codex\n${END_MARKER}\n/exit/codex`],
|
||||
])('rejects malformed tagged shell output: %s', (_name, output) => {
|
||||
const productionHost = createProductionCliResolverHost({
|
||||
processPath: '',
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
runCommand: () => output,
|
||||
isExecutableFile: () => true,
|
||||
});
|
||||
|
||||
expect(productionHost.findInLoginShell('codex')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the shell command throws', () => {
|
||||
const productionHost = createProductionCliResolverHost({
|
||||
processPath: '',
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'],
|
||||
runCommand: () => {
|
||||
throw new Error('exit 1');
|
||||
},
|
||||
isExecutableFile: () => true,
|
||||
});
|
||||
|
||||
expect(productionHost.findInLoginShell('codex')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @fileoverview Tests for the Pi CLI resolver wrapper.
|
||||
*
|
||||
* Pi is the resolver with a version probe: `pi` is a short, generic binary
|
||||
* name, so a resolved path is only accepted once `pi --version` prints a
|
||||
* semver-shaped string. The probe EXECUTES the candidate, which is exactly why
|
||||
* it must never run under vitest — the hermeticity test below pins that gate
|
||||
* with a real executable fixture that would make the test fail loudly if the
|
||||
* gate were deleted again (as PR #329 once did).
|
||||
*/
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPiResolverForTest } from '../src/utils/pi-cli-resolver.js';
|
||||
import {
|
||||
cliResolveRetryDelayMs,
|
||||
createProductionCliResolverHost,
|
||||
type CliResolverHost,
|
||||
} from '../src/utils/cli-executable-resolver.js';
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createHost(
|
||||
options: {
|
||||
processPathResult?: string | null;
|
||||
loginShellResults?: Array<string | null>;
|
||||
existingPaths?: string[];
|
||||
} = {}
|
||||
): CliResolverHost {
|
||||
const loginShellResults = [...(options.loginShellResults ?? [])];
|
||||
const existingPaths = new Set(options.existingPaths ?? []);
|
||||
return {
|
||||
processPath: '/service/bin',
|
||||
shellPath: '/bin/zsh',
|
||||
shellArgs: ['-l'],
|
||||
findOnProcessPath: () => options.processPathResult ?? null,
|
||||
findInLoginShell: () => loginShellResults.shift() ?? null,
|
||||
exists: (path) => existingPaths.has(path),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Pi CLI resolver', () => {
|
||||
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
|
||||
const binaryPath = '/service/bin/pi';
|
||||
const probe = vi.fn(() => '0.84.1');
|
||||
const resolver = createPiResolverForTest(
|
||||
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
|
||||
probe
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({
|
||||
binaryPath,
|
||||
directory: '/service/bin',
|
||||
source: 'process-path',
|
||||
metadata: '0.84.1',
|
||||
});
|
||||
expect(probe).toHaveBeenCalledWith(binaryPath);
|
||||
});
|
||||
|
||||
it('rejects a candidate the probe refuses and falls through to a later one', () => {
|
||||
// An unrelated `pi` on the service PATH (probe returns null) must not mask
|
||||
// the real coding agent found by the login shell.
|
||||
const impostor = '/service/bin/pi';
|
||||
const genuine = '/login-shell/bin/pi';
|
||||
const probe = vi.fn((binPath: string) => (binPath === genuine ? '0.84.1' : null));
|
||||
const resolver = createPiResolverForTest(
|
||||
createHost({
|
||||
processPathResult: impostor,
|
||||
loginShellResults: [genuine],
|
||||
existingPaths: [impostor, genuine],
|
||||
}),
|
||||
probe
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '0.84.1' });
|
||||
});
|
||||
|
||||
it('negative-caches a miss and retries only after the backoff elapses', () => {
|
||||
const binaryPath = '/late/bin/pi';
|
||||
let now = 0;
|
||||
const probe = vi.fn(() => '0.84.1');
|
||||
const resolver = createPiResolverForTest(
|
||||
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
|
||||
probe,
|
||||
() => now
|
||||
);
|
||||
|
||||
expect(resolver.resolve()).toBeNull();
|
||||
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
now = cliResolveRetryDelayMs(1);
|
||||
expect(resolver.resolve()?.metadata).toBe('0.84.1');
|
||||
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
|
||||
});
|
||||
|
||||
it('never executes a pi candidate under vitest (the ambient probe is VITEST-gated)', () => {
|
||||
// A REAL executable fixture that prints a valid version. If the guard in
|
||||
// probePiVersion is ever removed again, the probe runs this script, the
|
||||
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
|
||||
// behavior rather than by source text. (The suites must never execute
|
||||
// whatever `pi` binary the machine running them happens to carry.)
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeman-pi-vitest-gate-'));
|
||||
temporaryDirectories.push(root);
|
||||
const binaryPath = join(root, 'pi');
|
||||
writeFileSync(binaryPath, '#!/bin/sh\necho 0.99.0\n');
|
||||
chmodSync(binaryPath, 0o755);
|
||||
const hostOptions = {
|
||||
processPath: root,
|
||||
shellPath: '/bin/bash',
|
||||
shellArgs: ['-i', '-l'] as string[],
|
||||
runCommand: () => '',
|
||||
isExecutableFile: (path: string) => path === binaryPath,
|
||||
};
|
||||
|
||||
// Default (ambient) probe: the candidate is found but never executed, so
|
||||
// the VITEST gate reports it unusable and resolution misses.
|
||||
const gated = createPiResolverForTest(createProductionCliResolverHost(hostOptions));
|
||||
expect(gated.resolve()).toBeNull();
|
||||
|
||||
// Control: identical setup with an injected probe resolves, proving the
|
||||
// null above comes from the gate, not from the fixture or the host.
|
||||
const control = createPiResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
|
||||
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
|
||||
});
|
||||
});
|
||||
+31
-5
@@ -129,6 +129,36 @@ describe('RalphLoop', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('reschedule loop (regression)', () => {
|
||||
// tick() is the only path that calls setRalphLoopState with lastCheckAt and
|
||||
// no status field; start() carries status:'running', stop() status:'stopped'.
|
||||
const countTicks = () =>
|
||||
(mockState.store.setRalphLoopState as any).mock.calls.filter(
|
||||
(c: any[]) => c[0]?.lastCheckAt !== undefined && c[0]?.status === undefined
|
||||
).length;
|
||||
|
||||
it('keeps rescheduling past the second tick', async () => {
|
||||
// Real timers + a tiny poll interval: tick()'s async internals settle
|
||||
// naturally on the event loop, avoiding fake-timer microtask fragility.
|
||||
vi.useRealTimers();
|
||||
loop.destroy();
|
||||
// Keep the loop in the "running, should keep polling" state so it actually
|
||||
// exercises the reschedule path: min duration unreached + autoGenerate on
|
||||
// makes shouldStop() false (idle sessions are mocked empty, so nothing is
|
||||
// actually generated). Otherwise an empty queue self-stops after 1 tick.
|
||||
loop = new RalphLoop({ pollIntervalMs: 5, minDurationMs: 60_000, autoGenerateTasks: true });
|
||||
|
||||
await loop.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 80)); // ~16 poll intervals
|
||||
loop.stop();
|
||||
|
||||
// The bug: the loopTimer handle is never nulled in the setTimeout callback,
|
||||
// so the `loopTimer === null` reschedule guard is false after the first fire
|
||||
// and the loop dies at exactly 2 ticks. Fixed -> many ticks.
|
||||
expect(countTicks()).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should start in stopped status', () => {
|
||||
expect(loop.status).toBe('stopped');
|
||||
@@ -296,11 +326,7 @@ describe('RalphLoop', () => {
|
||||
describe('getStats', () => {
|
||||
it('should return complete stats object', async () => {
|
||||
const mockRunningTask = { id: '2', status: 'running', isTimedOut: () => false };
|
||||
mockState.taskQueue.tasks = [
|
||||
{ id: '1', status: 'pending' },
|
||||
mockRunningTask,
|
||||
{ id: '3', status: 'completed' },
|
||||
];
|
||||
mockState.taskQueue.tasks = [{ id: '1', status: 'pending' }, mockRunningTask, { id: '3', status: 'completed' }];
|
||||
mockState.taskQueue.getRunningTasks.mockReturnValue([mockRunningTask]);
|
||||
|
||||
await loop.start();
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* @fileoverview Unit tests for the repository-status pure helpers: ahead/behind
|
||||
* + log + symref parsing, env parsing, the remote-set / role / tracking-remote
|
||||
* decisions, credential redaction of the returned fields, and the single-flight
|
||||
* TTL cache that keeps the async status computation off the git hot path.
|
||||
* No IO, no git, no port — safe to run individually.
|
||||
*
|
||||
* npm test -- test/repo-status.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseAheadBehind,
|
||||
parseLogLines,
|
||||
parseSymrefDefaultBranch,
|
||||
parseRemotesEnv,
|
||||
parseTrackingRemote,
|
||||
resolveRemoteSet,
|
||||
roleForRemote,
|
||||
isSafeGitPositional,
|
||||
redactRemoteStatus,
|
||||
createSingleFlightCache,
|
||||
} from '../src/web/repo-status.js';
|
||||
import { redactGitCredentials } from '../src/git-clone.js';
|
||||
import type { RepoRemoteStatus } from '../src/types/update.js';
|
||||
|
||||
describe('parseAheadBehind', () => {
|
||||
it('parses tab-separated left/right counts as ahead/behind', () => {
|
||||
expect(parseAheadBehind('3\t14')).toEqual({ ahead: 3, behind: 14 });
|
||||
});
|
||||
it('parses space-separated counts', () => {
|
||||
expect(parseAheadBehind('0 0')).toEqual({ ahead: 0, behind: 0 });
|
||||
});
|
||||
it('tolerates surrounding whitespace/newline', () => {
|
||||
expect(parseAheadBehind(' 5 2 \n')).toEqual({ ahead: 5, behind: 2 });
|
||||
});
|
||||
it('returns null on malformed output', () => {
|
||||
expect(parseAheadBehind('')).toBeNull();
|
||||
expect(parseAheadBehind('abc')).toBeNull();
|
||||
expect(parseAheadBehind('1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLogLines', () => {
|
||||
it('splits each line into sha + subject', () => {
|
||||
const out = 'a1b2c3 fix: thing\nd4e5f6 COD-9 add digest';
|
||||
expect(parseLogLines(out)).toEqual([
|
||||
{ sha: 'a1b2c3', subject: 'fix: thing' },
|
||||
{ sha: 'd4e5f6', subject: 'COD-9 add digest' },
|
||||
]);
|
||||
});
|
||||
it('handles a subject with no space (sha only)', () => {
|
||||
expect(parseLogLines('deadbee')).toEqual([{ sha: 'deadbee', subject: '' }]);
|
||||
});
|
||||
it('ignores blank lines', () => {
|
||||
expect(parseLogLines('\n \nabc123 hi\n')).toEqual([{ sha: 'abc123', subject: 'hi' }]);
|
||||
});
|
||||
it('returns [] for empty input', () => {
|
||||
expect(parseLogLines('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSymrefDefaultBranch', () => {
|
||||
it('extracts the default branch from ls-remote --symref output', () => {
|
||||
const out = 'ref: refs/heads/master\tHEAD\n0123abc\tHEAD';
|
||||
expect(parseSymrefDefaultBranch(out)).toBe('master');
|
||||
});
|
||||
it('handles main', () => {
|
||||
expect(parseSymrefDefaultBranch('ref: refs/heads/main\tHEAD')).toBe('main');
|
||||
});
|
||||
it('returns null when no symref line present', () => {
|
||||
expect(parseSymrefDefaultBranch('0123abc\tHEAD')).toBeNull();
|
||||
});
|
||||
it('rejects a hostile branch that would smuggle a git flag (argv injection)', () => {
|
||||
// A malicious remote sets HEAD to a `-`-leading "branch"; the capture must
|
||||
// not start with `-`, so this yields null rather than `--upload-pack=...`.
|
||||
expect(parseSymrefDefaultBranch('ref: refs/heads/--upload-pack=touch\tHEAD')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSafeGitPositional', () => {
|
||||
it('accepts normal remote/branch names', () => {
|
||||
expect(isSafeGitPositional('origin')).toBe(true);
|
||||
expect(isSafeGitPositional('feature/foo')).toBe(true);
|
||||
});
|
||||
it('rejects flag-injecting and empty values', () => {
|
||||
expect(isSafeGitPositional('--upload-pack=touch /tmp/x')).toBe(false);
|
||||
expect(isSafeGitPositional('-x')).toBe(false);
|
||||
expect(isSafeGitPositional('')).toBe(false);
|
||||
expect(isSafeGitPositional(null)).toBe(false);
|
||||
expect(isSafeGitPositional(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRemotesEnv', () => {
|
||||
it('splits comma list, trims, drops empties', () => {
|
||||
expect(parseRemotesEnv('origin, bitbucket ,, fork')).toEqual(['origin', 'bitbucket', 'fork']);
|
||||
});
|
||||
it('returns [] for null/undefined/empty', () => {
|
||||
expect(parseRemotesEnv(null)).toEqual([]);
|
||||
expect(parseRemotesEnv(undefined)).toEqual([]);
|
||||
expect(parseRemotesEnv('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRemoteSet', () => {
|
||||
it('defaults to tracking-remote first, then origin (the maintainer-fork layout)', () => {
|
||||
// local branch tracks bitbucket; origin is the canonical upstream.
|
||||
const set = resolveRemoteSet({
|
||||
existingRemotes: ['bitbucket', 'fork', 'origin'],
|
||||
trackingRemote: 'bitbucket',
|
||||
envRemotes: [],
|
||||
});
|
||||
expect(set).toEqual(['bitbucket', 'origin']);
|
||||
});
|
||||
|
||||
it('collapses to a single entry when origin IS the tracking remote', () => {
|
||||
const set = resolveRemoteSet({
|
||||
existingRemotes: ['origin'],
|
||||
trackingRemote: 'origin',
|
||||
envRemotes: [],
|
||||
});
|
||||
expect(set).toEqual(['origin']);
|
||||
});
|
||||
|
||||
it('falls back to just origin when there is no tracking remote', () => {
|
||||
const set = resolveRemoteSet({
|
||||
existingRemotes: ['origin', 'fork'],
|
||||
trackingRemote: null,
|
||||
envRemotes: [],
|
||||
});
|
||||
expect(set).toEqual(['origin']);
|
||||
});
|
||||
|
||||
it('honors CODEMAN_UPDATE_REMOTES order and filters to existing remotes', () => {
|
||||
const set = resolveRemoteSet({
|
||||
existingRemotes: ['origin', 'bitbucket', 'fork'],
|
||||
trackingRemote: 'bitbucket',
|
||||
envRemotes: ['fork', 'origin', 'ghost'],
|
||||
});
|
||||
expect(set).toEqual(['fork', 'origin']); // 'ghost' dropped (does not exist)
|
||||
});
|
||||
|
||||
it('returns [] when env names none of the existing remotes', () => {
|
||||
const set = resolveRemoteSet({
|
||||
existingRemotes: ['origin'],
|
||||
trackingRemote: null,
|
||||
envRemotes: ['nope'],
|
||||
});
|
||||
expect(set).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleForRemote', () => {
|
||||
it('labels the tracking remote as tracking even if named origin', () => {
|
||||
expect(roleForRemote('origin', 'origin')).toBe('tracking');
|
||||
expect(roleForRemote('bitbucket', 'bitbucket')).toBe('tracking');
|
||||
});
|
||||
it('labels origin/upstream (when not tracking) as upstream', () => {
|
||||
expect(roleForRemote('origin', 'bitbucket')).toBe('upstream');
|
||||
expect(roleForRemote('upstream', 'origin')).toBe('upstream');
|
||||
});
|
||||
it('labels anything else as other', () => {
|
||||
expect(roleForRemote('fork', 'bitbucket')).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTrackingRemote', () => {
|
||||
it('extracts the remote name from a remote-tracking short ref', () => {
|
||||
expect(parseTrackingRemote('origin/master')).toBe('origin');
|
||||
expect(parseTrackingRemote('bitbucket/feature/nested')).toBe('bitbucket');
|
||||
});
|
||||
it('returns null for a LOCAL-branch upstream (ref with no slash)', () => {
|
||||
// `git branch -u otherbranch` makes @{upstream} a bare branch name; the old
|
||||
// slice(0, indexOf('/')) turned "master" into "maste" here.
|
||||
expect(parseTrackingRemote('master')).toBeNull();
|
||||
expect(parseTrackingRemote('main')).toBeNull();
|
||||
});
|
||||
it('returns null for null/empty/degenerate refs', () => {
|
||||
expect(parseTrackingRemote(null)).toBeNull();
|
||||
expect(parseTrackingRemote(undefined)).toBeNull();
|
||||
expect(parseTrackingRemote('')).toBeNull();
|
||||
expect(parseTrackingRemote('/leading-slash')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('credential redaction', () => {
|
||||
it('redactGitCredentials masks scheme://user:secret@host pairs', () => {
|
||||
expect(redactGitCredentials('https://user:ghp_token123@github.com/o/r.git')).toBe(
|
||||
'https://***:***@github.com/o/r.git'
|
||||
);
|
||||
expect(redactGitCredentials('plain text, no url')).toBe('plain text, no url');
|
||||
expect(redactGitCredentials('https://github.com/o/r.git')).toBe('https://github.com/o/r.git');
|
||||
});
|
||||
|
||||
it('redactRemoteStatus masks the url field', () => {
|
||||
const status: RepoRemoteStatus = {
|
||||
name: 'origin',
|
||||
url: 'https://alice:s3cret@example.com/repo.git',
|
||||
role: 'upstream',
|
||||
compareRef: 'origin/master',
|
||||
ahead: 1,
|
||||
behind: 2,
|
||||
incoming: [{ sha: 'abc', subject: 'hi' }],
|
||||
};
|
||||
const out = redactRemoteStatus(status);
|
||||
expect(out.url).toBe('https://***:***@example.com/repo.git');
|
||||
// Everything else passes through untouched.
|
||||
expect(out.name).toBe('origin');
|
||||
expect(out.compareRef).toBe('origin/master');
|
||||
expect(out.ahead).toBe(1);
|
||||
expect(out.behind).toBe(2);
|
||||
expect(out.incoming).toEqual([{ sha: 'abc', subject: 'hi' }]);
|
||||
expect(out.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redactRemoteStatus masks credentials echoed into the error string by git stderr', () => {
|
||||
const status: RepoRemoteStatus = {
|
||||
name: 'origin',
|
||||
url: 'https://alice:s3cret@example.com/repo.git',
|
||||
role: 'upstream',
|
||||
compareRef: '',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
incoming: [],
|
||||
error: "Could not reach origin: fatal: unable to access 'https://alice:s3cret@example.com/repo.git/'",
|
||||
};
|
||||
const out = redactRemoteStatus(status);
|
||||
expect(out.error).toBe("Could not reach origin: fatal: unable to access 'https://***:***@example.com/repo.git/'");
|
||||
expect(out.error).not.toContain('s3cret');
|
||||
expect(out.url).not.toContain('s3cret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSingleFlightCache', () => {
|
||||
it('shares one in-flight computation across concurrent callers', async () => {
|
||||
let calls = 0;
|
||||
let release!: (v: string) => void;
|
||||
const cache = createSingleFlightCache(60_000, () => {
|
||||
calls++;
|
||||
return new Promise<string>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
});
|
||||
const a = cache.get();
|
||||
const b = cache.get();
|
||||
release('result');
|
||||
expect(await a).toBe('result');
|
||||
expect(await b).toBe('result');
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it('serves a fresh-enough cached result without recomputing', async () => {
|
||||
let calls = 0;
|
||||
const cache = createSingleFlightCache(60_000, async () => ++calls);
|
||||
expect(await cache.get()).toBe(1);
|
||||
expect(await cache.get()).toBe(1);
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it('recomputes once the TTL has elapsed', async () => {
|
||||
let calls = 0;
|
||||
const cache = createSingleFlightCache(60_000, async () => ++calls);
|
||||
expect(await cache.get()).toBe(1);
|
||||
// Inject a "now" past the TTL instead of sleeping.
|
||||
expect(await cache.get(Date.now() + 60_001)).toBe(2);
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it('does not cache a rejected computation — the next call retries', async () => {
|
||||
let calls = 0;
|
||||
const cache = createSingleFlightCache(60_000, async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error('boom');
|
||||
return 'ok';
|
||||
});
|
||||
await expect(cache.get()).rejects.toThrow('boom');
|
||||
expect(await cache.get()).toBe('ok');
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,462 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getLastTranscriptResponse,
|
||||
isExternalCliTranscriptMode,
|
||||
parseExternalCliTranscript,
|
||||
} from '../src/web/response-viewer-transcript.js';
|
||||
import { isExternalCliMode } from '../src/session.js';
|
||||
import type { SessionMode } from '../src/types.js';
|
||||
|
||||
describe('response viewer transcript parser', () => {
|
||||
it('extracts structured COD transcript blocks and keeps labeled status/tool entries', () => {
|
||||
const transcript = `
|
||||
╭──────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.143.0) │
|
||||
│ model: gpt-5.4 medium /model to change │
|
||||
│ directory: /mnt/c/Users/aakhter/.../kb │
|
||||
╰──────────────────────────────────────────────────────╯
|
||||
|
||||
Tip: Use /side to start a side conversation in a temporary fork without polluting the main thread.
|
||||
|
||||
› i need to create a naming recommendation for project helix
|
||||
|
||||
gpt-5.4 medium · kb · main · Ready · Context 100% left
|
||||
|
||||
• Explored
|
||||
└ Read SKILL.md
|
||||
|
||||
Subject: Naming Recommendation for Project Helix
|
||||
|
||||
The product helps customers move virtual machine workloads between hypervisors while keeping operations
|
||||
stable. It improves visibility into application dependencies, supports pre-migration validation, and reduces
|
||||
the risk
|
||||
and effort involved in moving workloads.
|
||||
|
||||
› Improve documentation in @filename
|
||||
|
||||
gpt-5.4 medium · kb · main · Ready · Context 92% left
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(blocks.map((block) => block.kind)).toEqual([
|
||||
'status',
|
||||
'prompt',
|
||||
'status',
|
||||
'tool',
|
||||
'response',
|
||||
'prompt',
|
||||
'status',
|
||||
]);
|
||||
expect(blocks[0]?.label).toBe('Status');
|
||||
expect(blocks[3]?.label).toBe('Tool');
|
||||
expect(blocks[4]?.text).toContain('reduces the risk and effort involved');
|
||||
expect(blocks[4]?.text).not.toContain('reduces\nthe risk');
|
||||
|
||||
// The frontend's loadFullContext() renders via msg.role: 'user' gets the
|
||||
// "You" badge, everything else the agent badge. A block without role
|
||||
// rendered every prompt as the agent.
|
||||
expect(blocks[1]?.role).toBe('user');
|
||||
expect(blocks[5]?.role).toBe('user');
|
||||
expect(blocks[0]?.role).toBe('assistant');
|
||||
expect(blocks[3]?.role).toBe('assistant');
|
||||
expect(blocks[4]?.role).toBe('assistant');
|
||||
});
|
||||
|
||||
it('returns the most recent completed response when the last block is a new prompt', () => {
|
||||
const transcript = `
|
||||
› say again
|
||||
|
||||
Final polished answer
|
||||
With two lines
|
||||
|
||||
› Improve documentation in @filename
|
||||
|
||||
gpt-5.4 medium · kb · main · Ready · Context 92% left
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('Final polished answer\nWith two lines');
|
||||
});
|
||||
|
||||
it('drops box-drawing dividers from the last response and treats worked-for lines as status', () => {
|
||||
const transcript = `
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
|
||||
• The launcher supports CODEMAN_APP_DIR, so I can deploy this exact worktree without merging it back first.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
|
||||
• Deployed.
|
||||
|
||||
The local Codeman service is now running from the worktree at app/.worktrees/cod-215-response-viewer on
|
||||
commit 8bbcf77bafbdbf01a34653386c256b685898ad06.
|
||||
|
||||
Verified:
|
||||
|
||||
- process pid: 2708947
|
||||
- HTTPS health: https://127.0.0.1:3000/ returned 401 as expected
|
||||
|
||||
One detail: I had to restart it outside the sandbox because the sandboxed launch path could not see tmux.
|
||||
|
||||
─ Worked for 1m 51s ───────────────────────────────────────────────────
|
||||
|
||||
› what are these lines in the middle column?
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(getLastTranscriptResponse(blocks)).toContain('• Deployed.');
|
||||
expect(getLastTranscriptResponse(blocks)).not.toContain('────────────────');
|
||||
expect(getLastTranscriptResponse(blocks)).not.toContain('Worked for 1m 51s');
|
||||
expect(blocks.some((block) => block.kind === 'status' && block.text === 'Worked for 1m 51s')).toBe(true);
|
||||
});
|
||||
|
||||
// COD-226: multiline prompt continuations (2-space Codex gutter) must stay in the
|
||||
// Prompt block, not be misclassified as Response. The gutter is authoritative ahead
|
||||
// of every structural detector (divider, prompt-marker, tool, status).
|
||||
describe('COD-226 multiline prompt gutter is authoritative', () => {
|
||||
it('keeps bullet/prose continuations and internal blank lines in the Prompt block', () => {
|
||||
const transcript = `
|
||||
› i think we can improve this slide. or maybe a follow on slide. here is what i'm thinking:
|
||||
* left hand side: current AI stack (frontier model, cloud hosted, sovereign concerns)
|
||||
right hand -> future enterprise stack: frontier model (optional, cloud), on-prem model router, OSS models
|
||||
|
||||
make the point that the right hand side addresses the concerns.
|
||||
|
||||
gpt-5.4 medium · kb · main · Ready · Context 100% left
|
||||
|
||||
• Explored
|
||||
└ Read SKILL.md
|
||||
|
||||
Here is the actual assistant answer that starts at column zero and is a real response.
|
||||
|
||||
› next prompt
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
// Exactly two prompts, and the first holds the whole multiline prompt.
|
||||
expect(promptBlocks).toHaveLength(2);
|
||||
expect(promptBlocks[0]?.text).toContain('left hand side');
|
||||
expect(promptBlocks[0]?.text).toContain('right hand');
|
||||
expect(promptBlocks[0]?.text).toContain('make the point that the right hand side');
|
||||
|
||||
// The continuation must NOT have leaked into a Response block.
|
||||
const responseBlocks = blocks.filter((b) => b.kind === 'response');
|
||||
expect(responseBlocks.some((b) => b.text.includes('make the point'))).toBe(false);
|
||||
expect(getLastTranscriptResponse(blocks)).toContain('actual assistant answer');
|
||||
expect(getLastTranscriptResponse(blocks)).not.toContain('make the point');
|
||||
});
|
||||
|
||||
it('does not let an indented divider inside a prompt flush the Prompt block', () => {
|
||||
const transcript = `
|
||||
› compare these two layouts
|
||||
first layout uses a single column
|
||||
────────────────────────────
|
||||
second layout uses two columns
|
||||
|
||||
The response begins here at column zero.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
expect(promptBlocks).toHaveLength(1);
|
||||
expect(promptBlocks[0]?.text).toContain('first layout');
|
||||
expect(promptBlocks[0]?.text).toContain('second layout uses two columns');
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('The response begins here at column zero.');
|
||||
});
|
||||
|
||||
it('treats a gutter-indented literal › as prompt content, not a new prompt', () => {
|
||||
const transcript = `
|
||||
› here is my question about the ui
|
||||
› should this arrow start a new prompt?
|
||||
no it should not — it is part of my question
|
||||
|
||||
Answer at column zero.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
// The gutter-indented › must NOT open a second prompt.
|
||||
expect(promptBlocks).toHaveLength(1);
|
||||
expect(promptBlocks[0]?.text).toContain('here is my question');
|
||||
expect(promptBlocks[0]?.text).toContain('no it should not');
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('Answer at column zero.');
|
||||
});
|
||||
|
||||
// The two exact live roadmap-tab examples from the ticket (AC: use both verbatim).
|
||||
it('live example 1: two-line prompt keeps the gutter continuation in the Prompt block', () => {
|
||||
const transcript = `
|
||||
› create uid for each feature so it's easy to ref.
|
||||
for the p200 - why is diffentiation only 2/5?
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
expect(promptBlocks).toHaveLength(1);
|
||||
expect(promptBlocks[0]?.text).toContain('create uid for each feature');
|
||||
expect(promptBlocks[0]?.text).toContain('for the p200 - why is diffentiation only 2/5?');
|
||||
// The continuation must not have leaked into a Response block.
|
||||
expect(blocks.some((b) => b.kind === 'response')).toBe(false);
|
||||
});
|
||||
|
||||
it('live example 2: five-line prompt (bullet + prose + blank + prose) stays one Prompt block', () => {
|
||||
const transcript = `
|
||||
› i think we can improve this slide. or maybe a follow on slide. here is what i'm thinking:
|
||||
* left hand side... current AI stack: (frontier model), large component cloud hosted, soverign concerns etc.
|
||||
right hand -> future-enterprise-stack: frontier-model (optional, in cloud), on-prem: model router, OSS models, rest of stack (gpus, data, etc.)
|
||||
|
||||
make the point that the right hand side addresses the concerns.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
expect(promptBlocks).toHaveLength(1);
|
||||
expect(promptBlocks[0]?.text).toContain('left hand side');
|
||||
expect(promptBlocks[0]?.text).toContain('right hand -> future-enterprise-stack');
|
||||
expect(promptBlocks[0]?.text).toContain('make the point that the right hand side addresses the concerns');
|
||||
expect(blocks.some((b) => b.kind === 'response')).toBe(false);
|
||||
});
|
||||
|
||||
it('still separates a single-line prompt from a column-zero response (no regression)', () => {
|
||||
const transcript = `
|
||||
› say again
|
||||
|
||||
Final polished answer at column zero.
|
||||
|
||||
› next
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
expect(promptBlocks).toHaveLength(2);
|
||||
expect(promptBlocks[0]?.text).toBe('say again');
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('Final polished answer at column zero.');
|
||||
});
|
||||
});
|
||||
|
||||
// COD-227: Last Response must return the final assistant answer, not tool logs.
|
||||
// A response bullet beginning with a tool-like verb (• Created …) must not be
|
||||
// classified as Tool, and genuine • Calling / • Called blocks must be classified
|
||||
// as Tool. Disambiguator: a verb-bullet is a tool header only when followed by a
|
||||
// box-drawing result tree (└│├); Calling/Called are always tool markers.
|
||||
describe('COD-227 tool-header vs response disambiguation', () => {
|
||||
const MINIMAL_REPRO = `
|
||||
› new jira issue
|
||||
|
||||
• The fresh read shows a formatting problem.
|
||||
|
||||
• Calling
|
||||
└ atlassian.jira_update_issue({})
|
||||
|
||||
• Called atlassian.jira_get_issue({})
|
||||
└ { result: true }
|
||||
|
||||
• Created COD-226: View Response → More misclassifies multiline prompt continuations as responses.
|
||||
|
||||
It includes:
|
||||
|
||||
- Two concrete failures
|
||||
- Regression-test criteria
|
||||
`.trim();
|
||||
|
||||
it('returns the final • Created … answer, not the Jira Calling/Called tool log', () => {
|
||||
const blocks = parseExternalCliTranscript(MINIMAL_REPRO, 'codex');
|
||||
const last = getLastTranscriptResponse(blocks);
|
||||
|
||||
expect(last).toContain('Created COD-226');
|
||||
expect(last).toContain('Two concrete failures');
|
||||
// Must exclude tool invocations, raw results, and earlier commentary.
|
||||
expect(last).not.toContain('atlassian.jira');
|
||||
expect(last).not.toContain('Calling');
|
||||
expect(last).not.toContain('Called');
|
||||
expect(last).not.toContain('fresh read');
|
||||
});
|
||||
|
||||
it('classifies genuine • Calling / • Called (with box-drawing results) as Tool', () => {
|
||||
const blocks = parseExternalCliTranscript(MINIMAL_REPRO, 'codex');
|
||||
const toolText = blocks
|
||||
.filter((b) => b.kind === 'tool')
|
||||
.map((b) => b.text)
|
||||
.join('\n');
|
||||
|
||||
expect(toolText).toContain('Calling');
|
||||
expect(toolText).toContain('Called');
|
||||
expect(toolText).toContain('atlassian.jira_update_issue');
|
||||
// The final answer must not have been swallowed into the tool block.
|
||||
expect(toolText).not.toContain('Created COD-226');
|
||||
});
|
||||
|
||||
it('labels the repro chronologically: Prompt, Response (commentary), Tool, Response (final)', () => {
|
||||
const blocks = parseExternalCliTranscript(MINIMAL_REPRO, 'codex');
|
||||
|
||||
expect(blocks.map((b) => b.kind)).toEqual(['prompt', 'response', 'tool', 'response']);
|
||||
expect(blocks[1]?.text).toContain('fresh read');
|
||||
expect(blocks[3]?.text).toContain('Created COD-226');
|
||||
});
|
||||
|
||||
it('does not classify a verb-prefixed prose bullet as Tool when no result tree follows', () => {
|
||||
const transcript = `
|
||||
› do it
|
||||
|
||||
• Created COD-999: a brand new issue with a descriptive title.
|
||||
|
||||
Follow-up prose that belongs to the same answer.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(blocks.some((b) => b.kind === 'tool')).toBe(false);
|
||||
expect(getLastTranscriptResponse(blocks)).toContain('Created COD-999');
|
||||
expect(getLastTranscriptResponse(blocks)).toContain('Follow-up prose');
|
||||
});
|
||||
|
||||
it('does not regress a genuine verb tool block that has a box-drawing continuation', () => {
|
||||
const transcript = `
|
||||
› look around
|
||||
|
||||
• Explored
|
||||
└ Read SKILL.md
|
||||
|
||||
Here is the assistant answer at column zero.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(blocks.some((b) => b.kind === 'tool' && b.text.includes('Explored'))).toBe(true);
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('Here is the assistant answer at column zero.');
|
||||
});
|
||||
});
|
||||
|
||||
// The divider-status detector used to be /^[─-]+\s*(.+?)\s*[─-]{3,}$/, whose
|
||||
// lazy middle backtracked catastrophically on a long dash run that does not
|
||||
// end in 3+ dashes (measured >2min at 8,000 chars). Pane text is
|
||||
// agent-controlled and buffers reach 32MB, so the pattern was replaced by a
|
||||
// linear counter walk. Same approach as the glob-matcher ReDoS fix (68ae9a8):
|
||||
// the hostile input below fails by timeout with the RegExp version.
|
||||
describe('divider-status ReDoS hardening', () => {
|
||||
it('classifies a hostile 10k dash run in linear time', () => {
|
||||
const hostile = '-'.repeat(10_000) + '>';
|
||||
const started = Date.now();
|
||||
const blocks = parseExternalCliTranscript(`› q\n${hostile}`, 'opencode');
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
expect(elapsed).toBeLessThan(2_000);
|
||||
// No 3-dash tail, so the line is not a status divider — it stays prose.
|
||||
expect(blocks.some((b) => b.kind === 'response' && b.text.includes('>'))).toBe(true);
|
||||
expect(blocks.some((b) => b.kind === 'status')).toBe(false);
|
||||
});
|
||||
|
||||
// The counter walk must keep the EXACT accept set and captured content of
|
||||
// the old regex (including its backtracking quirks on all-dash lines), so
|
||||
// brute-force both against a corpus. The old regex is safe here: probes are
|
||||
// capped at 24 chars, far below the blowup threshold.
|
||||
it('matches the old regex char-for-char on a brute-force corpus', () => {
|
||||
const oldNormalize = (line: string): string | null => {
|
||||
const trimmed = line.trim();
|
||||
const matched = trimmed.match(/^[─-]+\s*(.+?)\s*[─-]{3,}$/);
|
||||
if (!matched) return null;
|
||||
return matched[1]?.trim() || null;
|
||||
};
|
||||
|
||||
// Observe the private normalizeDividerStatusLine through the parser: after
|
||||
// a prompt, a column-zero probe from this alphabet is a status block iff
|
||||
// the divider detector matched, and the status text is its return value.
|
||||
// (The alphabet triggers no other status arm; letters are limited to 'x'.)
|
||||
const observedStatusText = (probe: string): string | null => {
|
||||
const blocks = parseExternalCliTranscript(`› q\n${probe}`, 'opencode');
|
||||
const status = blocks.find((b) => b.kind === 'status');
|
||||
return status ? status.text : null;
|
||||
};
|
||||
|
||||
const directed = [
|
||||
'──── Status ────',
|
||||
'─ Worked for 1m 51s ───────',
|
||||
'---',
|
||||
'----',
|
||||
'-----',
|
||||
'------',
|
||||
'-------',
|
||||
'---- ---',
|
||||
'- ---',
|
||||
'--x',
|
||||
'x---',
|
||||
'----x',
|
||||
'----x--',
|
||||
'----x---',
|
||||
'─x───',
|
||||
'- x ---',
|
||||
'--- x -',
|
||||
'─── ──',
|
||||
'-\tx\t---',
|
||||
'--->----',
|
||||
'─-─- x x ─-─',
|
||||
'- x x x ----',
|
||||
];
|
||||
|
||||
// Deterministic LCG so a failure reproduces byte-identically.
|
||||
let seed = 0x2f6e2b1;
|
||||
const rand = () => {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return seed / 0x80000000;
|
||||
};
|
||||
const alphabet = ['-', '-', '─', '─', ' ', ' ', '\t', 'x', '>'];
|
||||
const probes = [...directed];
|
||||
for (let i = 0; i < 3000; i += 1) {
|
||||
const len = Math.floor(rand() * 25);
|
||||
let probe = '';
|
||||
for (let j = 0; j < len; j += 1) {
|
||||
probe += alphabet[Math.floor(rand() * alphabet.length)];
|
||||
}
|
||||
probes.push(probe);
|
||||
}
|
||||
|
||||
for (const raw of probes) {
|
||||
const probe = raw.trim();
|
||||
// Divider-only lines (8+ dash/whitespace chars) are consumed by
|
||||
// isDividerOnlyLine before the status detector ever runs, in both the
|
||||
// old and new worlds — the detector is unobservable there.
|
||||
if (!probe || /^[\s─-]{8,}$/.test(probe)) continue;
|
||||
expect(observedStatusText(probe), `probe: ${JSON.stringify(probe)}`).toBe(oldNormalize(probe));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// EXTERNAL_CLI_MODES is a local duplicate of isExternalCliMode() in
|
||||
// src/session.ts (importing session.ts would drag node-pty and the whole
|
||||
// session layer into the pure transcript module). This pin is what keeps the
|
||||
// two from drifting — 'pi' was missing here while isExternalCliMode() had it,
|
||||
// which left pi sessions with the exact empty-viewer symptom the transcript
|
||||
// branch exists to fix.
|
||||
describe('mode parity with isExternalCliMode()', () => {
|
||||
// Exhaustive by construction: adding a SessionMode without deciding its
|
||||
// transcript behavior fails to compile here.
|
||||
const ALL_SESSION_MODES: Record<SessionMode, true> = {
|
||||
claude: true,
|
||||
shell: true,
|
||||
opencode: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
antigravity: true,
|
||||
pi: true,
|
||||
};
|
||||
|
||||
it('agrees with isExternalCliMode() for every SessionMode', () => {
|
||||
for (const mode of Object.keys(ALL_SESSION_MODES) as SessionMode[]) {
|
||||
expect(isExternalCliTranscriptMode(mode), `mode: ${mode}`).toBe(isExternalCliMode(mode));
|
||||
}
|
||||
});
|
||||
|
||||
it('covers pi', () => {
|
||||
expect(isExternalCliTranscriptMode('pi')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* @fileoverview Tests for the external-CLI branch of GET /api/sessions/:id/last-response.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*
|
||||
* OpenCode / Gemini / Antigravity / Pi render their own TUIs and never write a
|
||||
* Claude transcript under ~/.claude/projects, so before this branch existed the
|
||||
* handler fell through to the Claude scan, found nothing, and the response viewer
|
||||
* was permanently empty for those modes. These tests pin:
|
||||
* - the pane buffer is segmented and the LAST response is returned
|
||||
* - ?context=full carries the parsed blocks, and the short form omits them
|
||||
* - ?context=full blocks carry role — the frontend renders via msg.role, so a
|
||||
* block without it lost the "You" badge on prompts
|
||||
* - a pane that has produced no output reports hasContext: false rather than 404ing
|
||||
* - Claude mode still takes the Claude path (regression guard)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
interface LocalHarness {
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
}
|
||||
|
||||
/** Mirror of the production uniform-envelope hook (server.ts), as in the sibling suites. */
|
||||
async function createEnvelopeHarness(
|
||||
registerFn: (app: FastifyInstance, ctx: MockRouteContext) => void
|
||||
): Promise<LocalHarness> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
const ctx = createMockRouteContext();
|
||||
registerFn(app, ctx);
|
||||
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||||
if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') {
|
||||
return done(null, payload);
|
||||
}
|
||||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||||
if (p.success === false) {
|
||||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
}
|
||||
if (p.success === true) return done(null, payload);
|
||||
return done(null, { success: true, data: payload });
|
||||
});
|
||||
|
||||
await app.ready();
|
||||
return { app, ctx };
|
||||
}
|
||||
|
||||
// A pane as one of these CLIs actually leaves it: banner, a `›` prompt line, a
|
||||
// status divider, a tool-activity marker, then the assistant's prose.
|
||||
const PANE = `
|
||||
╭──────────────────────────────────────────────────────╮
|
||||
│ >_ OpenCode │
|
||||
│ directory: /workspace/project │
|
||||
╰──────────────────────────────────────────────────────╯
|
||||
|
||||
› summarise the retry logic
|
||||
|
||||
model · project · main · Ready · Context 100% left
|
||||
|
||||
• Explored
|
||||
└ Read src/retry.ts
|
||||
|
||||
The retry helper backs off exponentially and gives up after five attempts.
|
||||
|
||||
› now document it
|
||||
|
||||
model · project · main · Ready · Context 92% left
|
||||
|
||||
• Called write_file
|
||||
|
||||
Documented the helper in docs/retry.md, including the five-attempt ceiling.
|
||||
`.trim();
|
||||
|
||||
describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
|
||||
let harness: LocalHarness;
|
||||
let session: ReturnType<typeof createMockSession>;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||||
session = harness.ctx._session;
|
||||
});
|
||||
|
||||
async function lastResponse(full = false) {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${session.id}/last-response${full ? '?context=full' : ''}`,
|
||||
});
|
||||
return { res, body: JSON.parse(res.body) };
|
||||
}
|
||||
|
||||
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
|
||||
it(`returns the last assistant response from the ${mode} pane buffer`, async () => {
|
||||
session.mode = mode;
|
||||
session.terminalBuffer = PANE;
|
||||
|
||||
const { res, body } = await lastResponse();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
// The LAST response, not the first — the viewer shows the current turn.
|
||||
expect(body.data.text).toContain('Documented the helper in docs/retry.md');
|
||||
expect(body.data.text).not.toContain('backs off exponentially');
|
||||
expect(body.data.hasContext).toBe(true);
|
||||
// Short form stays short: blocks only travel under ?context=full.
|
||||
expect(body.data.messages).toBeUndefined();
|
||||
});
|
||||
}
|
||||
|
||||
it('carries the parsed blocks under ?context=full', async () => {
|
||||
session.mode = 'opencode';
|
||||
session.terminalBuffer = PANE;
|
||||
|
||||
const { body } = await lastResponse(true);
|
||||
|
||||
const kinds = body.data.messages.map((block: { kind: string }) => block.kind);
|
||||
expect(kinds).toContain('prompt');
|
||||
expect(kinds).toContain('response');
|
||||
expect(kinds).toContain('tool');
|
||||
// Both user turns survive segmentation, so the viewer can show the exchange.
|
||||
const prompts = body.data.messages.filter((b: { kind: string }) => b.kind === 'prompt');
|
||||
expect(prompts).toHaveLength(2);
|
||||
expect(prompts[1].text).toContain('now document it');
|
||||
|
||||
// loadFullContext() renders via msg.role — without it every block got the
|
||||
// agent badge and the user's own prompts lost their "You" attribution.
|
||||
for (const block of body.data.messages as Array<{ kind: string; role: string }>) {
|
||||
expect(block.role).toBe(block.kind === 'prompt' ? 'user' : 'assistant');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports hasContext false for a pane that has produced no output', async () => {
|
||||
session.mode = 'gemini';
|
||||
// Session created but nothing rendered yet. Any non-prompt line counts as
|
||||
// prose to the parser, so the empty pane is the honest no-context case.
|
||||
session.terminalBuffer = '';
|
||||
|
||||
const { res, body } = await lastResponse();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(body.data.text).toBe('');
|
||||
expect(body.data.hasContext).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves Claude mode on the Claude transcript path', async () => {
|
||||
// Regression guard: a claude pane must NOT be segmented off its terminal
|
||||
// buffer, or a real transcript would be shadowed by scraped pane text.
|
||||
session.mode = 'claude';
|
||||
session.terminalBuffer = PANE;
|
||||
|
||||
const { res, body } = await lastResponse();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(body.data.text).not.toContain('Documented the helper');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user