From 8b20ff6fc11344748864cc5d2a03a3137f831904 Mon Sep 17 00:00:00 2001 From: theogravity Date: Sun, 27 Sep 2026 14:35:17 -0700 Subject: [PATCH 1/7] fix(panes): mirror the pane app's input modes into the attach replay Measured on the live instance (2026-09-27): a pane whose app had ALREADY enabled alt screen or mouse reporting before the attach replays a capture-pane that carries no DECSET, and the live join starts after the app announced them. The browser's xterm never learns the app owns the mouse, so every wheel notch fires xterm's passive-wheel fallback: one arrow key through the socket. That is the reported "slow scroll, no scrollbar" on mouse-holding panes; a terminal on the same machine scrolls at the app's speed because its PTY stream carried the modes. - pane-runtime: TmuxRunner.paneModePreamble reads the pane app's live mode flags (alternate_on, mouse_standard/button/all/sgr_flag, mapped against tmux 3.7c) and capturePane leads its result with the matching DECSET bytes, read before the capture so the preamble describes the basis the captured grid was drawn on. Any failure degrades to no preamble; no frame-shape change, so every plane/agent version mix keeps working, old agents simply ship today's behavior. - claude-code plugin: suggestedEnv offers CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN, the classic renderer (real scrollback, native scrollbar), as a per-preset choice. Deliberately not a product default: the classic renderer re-admits the flicker the no-flicker renderer replaced, and browser scrollback is bounded by what scrolled off while attached. tmux's own mouse option stays off; it hijacks native copy/paste (operator ruling). Co-Authored-By: Claude Code --- ...ane-mode-mirror-and-renderer-suggestion.md | 11 +++ packages/pane-runtime/src/tmux-runner.ts | 77 ++++++++++++++++++- packages/plugins/claude-code/src/index.ts | 4 + 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 .changeset/pane-mode-mirror-and-renderer-suggestion.md diff --git a/.changeset/pane-mode-mirror-and-renderer-suggestion.md b/.changeset/pane-mode-mirror-and-renderer-suggestion.md new file mode 100644 index 000000000..191e90df5 --- /dev/null +++ b/.changeset/pane-mode-mirror-and-renderer-suggestion.md @@ -0,0 +1,11 @@ +--- +"@subshell-ai/plugin-claude-code": minor +"@internal/server": minor +"@internal/node": minor +--- + +Browser panes now learn the input modes the app inside them already owns, and the Claude Code classic-renderer switch is discoverable. + +A pane whose application had already enabled alt screen or mouse reporting before you attached replayed a capture carrying none of that: the browser terminal could not know the app owned the mouse, so every wheel notch became a single arrow key through the socket ("slow scroll, no scrollbar"). The pane capture now leads with the escape sequences that announce the pane's live mode flags, measured against tmux 3.7c, so a fresh attach scrolls like a terminal on the machine: at the app's own speed. Vim, htop, and any mouse-reporting harness get this; apps that keep the plain screen keep the native scrollbar and scrollback untouched, and every server/agent version mix degrades to the old behavior rather than breaking. + +The Claude Code preset editor now suggests `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`, whose description names why you would set it: the classic renderer appends to scrollback, so the pane shows a real scrollbar. It is a per-preset choice, not a default; Claude Code's newer renderer keeps its no-flicker alt screen unless a preset says otherwise. diff --git a/packages/pane-runtime/src/tmux-runner.ts b/packages/pane-runtime/src/tmux-runner.ts index 011281216..0d4d83e1e 100644 --- a/packages/pane-runtime/src/tmux-runner.ts +++ b/packages/pane-runtime/src/tmux-runner.ts @@ -741,6 +741,71 @@ export class TmuxRunner { return result; } + /** + * The pane app's CURRENT input modes, as the DECSET bytes that announce + * them, for {@link capturePane} to lead its result with. + * + * **Why this exists** (measured 2026-09-27). The browser attach writes + * `term.reset()` and then a replay built from `capture-pane`, but + * capture-pane re-emits no DECSET, so an app that had ALREADY enabled alt + * screen or mouse reporting before the attach is invisible to the client's + * xterm. Claude Code 2.1.283 measures `alt=1, mouse_all=1, mouse_sgr=1` at + * attach time, and the client that never learned the app owns the mouse + * fires xterm's passive-wheel fallback on every notch: one arrow key typed + * into the pane through the WS. That is the reported "slow scroll, no + * scrollbar". A real terminal on the same machine scrolls fast because the + * PTY stream carried the DECSETs the app emitted at startup, and the + * browser never sees that stream; mirroring the pane's live flags into + * every capture ships the knowledge it would have carried. + * + * The five flags come from ONE `display-message` read, mapped live on tmux + * 3.7c: `alternate_on` → `?1049h`, `mouse_standard_flag` → `?1000h`, + * `mouse_button_flag` → `?1002h`, `mouse_all_flag` → `?1003h`, + * `mouse_sgr_flag` → `?1006h`. Only the modes that are ON are emitted, in + * that order, with no separator. + * + * ANY failure degrades to `""` and never throws: a gone socket, a tmux too + * old to resolve a variable (its token comes back empty or literal), or + * otherwise unparseable output all mean "modes unknown", which is simply + * today's behavior. Losing the decoration is no reason to lose the capture + * it decorates. + * + * A mode that flips between this read and the capture is cosmetic: the + * live byte stream re-announces DECSETs as the app's own bytes, so the + * next mode change the app makes corrects the client. + */ + async paneModePreamble(socket: string, subshellName: string): Promise { + try { + const out = ( + await this.runAsync( + [ + "-L", + socket, + "display-message", + "-t", + subshellName, + "-p", + "#{alternate_on}:#{mouse_standard_flag}:#{mouse_button_flag}:#{mouse_all_flag}:#{mouse_sgr_flag}", + ], + {}, + ) + ).stdout.trim(); + const [alternate, standard, button, all, sgr] = out.split(":"); + // `=== "1"` throughout: an empty token (a tmux too old for the + // variable), a literal one (a format that did not resolve) or anything + // else that is not `1` is not evidence a mode is ON. + let preamble = ""; + if (alternate === "1") preamble += "\x1b[?1049h"; + if (standard === "1") preamble += "\x1b[?1000h"; + if (button === "1") preamble += "\x1b[?1002h"; + if (all === "1") preamble += "\x1b[?1003h"; + if (sgr === "1") preamble += "\x1b[?1006h"; + return preamble; + } catch { + return ""; + } + } + /** * Captures the pane's current visible content with escape sequences. * @@ -749,12 +814,22 @@ export class TmuxRunner { * rows carry the same SGR-only shape as the visible grid and replay cleanly * at ANY geometry. Without it, only the visible grid is captured (preview/ * settle callers never wanted history bytes). + * + * The result LEADS with {@link paneModePreamble}. Those bytes carry no LF, + * so they ride the front of row 1 through the capture→terminal CRLF + * normalization untouched; a consumer that strips ANSI sees the plain grid, + * and a browser terminal learns the modes the pane's app already owns. */ async capturePane(socket: string, subshellName: string, scrollbackLines?: number): Promise { const args = ["-L", socket, "capture-pane", "-p", "-e", "-t", subshellName]; if (scrollbackLines && scrollbackLines > 0) args.push("-S", `-${scrollbackLines}`); + // The modes are read BEFORE the capture, not concurrently with it: the + // replay's preamble must describe the basis the captured grid was drawn + // on, and a flip inside the gap between the two reads is covered by the + // live join re-announcing DECSETs as app bytes ({@link paneModePreamble}). + const preamble = await this.paneModePreamble(socket, subshellName); const res = await this.runAsync(args, {}); - return res.stdout; + return preamble + res.stdout; } /** Terminates a subshell (also kills the harness process tree). */ diff --git a/packages/plugins/claude-code/src/index.ts b/packages/plugins/claude-code/src/index.ts index 6f5952b2a..50f067fca 100644 --- a/packages/plugins/claude-code/src/index.ts +++ b/packages/plugins/claude-code/src/index.ts @@ -65,6 +65,10 @@ const SUGGESTED_ENV: { key: string; description: string }[] = [ { key: "ANTHROPIC_DEFAULT_HAIKU_MODEL", description: "Model behind the 'haiku' alias" }, { key: "CLAUDE_CODE_MAX_OUTPUT_TOKENS", description: "Max output tokens" }, { key: "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", description: "Disable telemetry/update traffic (1)" }, + { + key: "CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN", + description: "Classic renderer: appends to scrollback so the pane shows a scrollbar (1)", + }, { key: "CLAUDE_CODE_SSE_URL", description: "Anthropic SSE URL override (e.g. http://localhost:8080/anthropic)", From 5c4da4d57864e05e5b0b660123d0da3fdc5ad79e Mon Sep 17 00:00:00 2001 From: theogravity Date: Sun, 27 Sep 2026 14:43:55 -0700 Subject: [PATCH 2/7] test(pane-runtime): retire the extraction parity guards per their exit condition The fixtures' own headers declared the exit: "frozen as a REFERENCE... Both this file and that test are deleted once the extraction has been reviewed and shipped" (operator decision 2026-09-27). All five extracted plugins have shipped (e.g. @subshell-ai/plugin-claude-code at 3.0.0), and the guard fired exactly when an extraction guard must stop: the plugin's first legitimate post-extraction change to its preset-editor reference data (the CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN suggestion). A frozen pre-extraction snapshot cannot stay green against an evolving product, and "never absorb a difference by editing the expectation" is the suite's own rule. Verified no imports outside the suite before removal; __tests__/fixtures/plugins (the loader's fixtures) is unaffected. Co-Authored-By: Claude Code --- .../__tests__/fixtures/claude-code-legacy.ts | 345 ------------------ .../src/__tests__/fixtures/codex-legacy.ts | 235 ------------ .../src/__tests__/fixtures/hermes-legacy.ts | 202 ---------- .../src/__tests__/fixtures/opencode-legacy.ts | 187 ---------- .../src/__tests__/fixtures/pi-legacy.ts | 184 ---------- .../src/__tests__/plugin-parity.test.ts | 322 ---------------- 6 files changed, 1475 deletions(-) delete mode 100644 packages/pane-runtime/src/__tests__/fixtures/claude-code-legacy.ts delete mode 100644 packages/pane-runtime/src/__tests__/fixtures/codex-legacy.ts delete mode 100644 packages/pane-runtime/src/__tests__/fixtures/hermes-legacy.ts delete mode 100644 packages/pane-runtime/src/__tests__/fixtures/opencode-legacy.ts delete mode 100644 packages/pane-runtime/src/__tests__/fixtures/pi-legacy.ts delete mode 100644 packages/pane-runtime/src/__tests__/plugin-parity.test.ts diff --git a/packages/pane-runtime/src/__tests__/fixtures/claude-code-legacy.ts b/packages/pane-runtime/src/__tests__/fixtures/claude-code-legacy.ts deleted file mode 100644 index e52958dc4..000000000 --- a/packages/pane-runtime/src/__tests__/fixtures/claude-code-legacy.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * The pre-extraction ClaudeCodePlugin, frozen as a REFERENCE. - * - * `plugin-parity.test.ts` compares the extracted package against this to prove - * the move changed nothing that gets executed. Both this file and that test - * are deleted once the extraction has been reviewed and shipped; nothing else - * may import it. - * @internal - */ -import { existsSync } from "node:fs"; -import { homedir } from "node:os"; -import { join, resolve } from "node:path"; -import { validateGenericPreset } from "@subshell-ai/plugin-api"; -import { type DetectionResult, detectBinary } from "../../binary-lookup.js"; -import type { - BuildCommandInput, - McpLaunchSpec, - McpRegistration, - McpSetupInfo, - PresetDefinition, - PresetValidationResult, - SettingsField, -} from "../../types.js"; -import { MCP_SERVER_NAME } from "../../types.js"; -import { probeVersion } from "../../version-probe.js"; - -/** - * The resume member AS THE PRE-EXTRACTION CONTRACT HAD IT. - * - * Frozen on purpose: the live `HarnessResume` moved to the pure `resumePath` - * contract (spec 2026-09-10 §5) and this class must keep answering the - * question the old way, or the parity test would compare the new contract - * against itself. Nothing outside the parity suite may use this shape. - */ -interface LegacyHarnessResume { - allocateHarnessSessionId(): string; - canResume(harnessSessionId: string, cwd: string): boolean; -} - -/** Known claude-code settings editor fields (top-level `--settings` keys). */ -const CLAUDE_SETTINGS_FIELDS: SettingsField[] = [ - { - key: "permissionMode", - label: "Permission mode", - description: "How permissions are handled for tool use", - type: "select", - choices: ["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk"], - }, - { - key: "model", - label: "Model", - description: "Model alias or full name (e.g. 'sonnet', 'opus', 'fable')", - type: "string", - }, - { - key: "maxTokens", - label: "Max output tokens", - type: "number", - }, - { - key: "outputStyle", - label: "Output style", - type: "select", - choices: ["default", "compact", "expanded"], - }, - { - key: "alwaysAllowReadOnly", - label: "Always allow read-only tools", - type: "boolean", - }, - { - key: "includeCoAuthoredBy", - label: "Add Co-Authored-By footer", - type: "boolean", - }, -]; - -const SUGGESTED_ENV: { key: string; description: string }[] = [ - { key: "ANTHROPIC_API_KEY", description: "API key (overrides logged-in account)" }, - { key: "ANTHROPIC_AUTH_TOKEN", description: "OAuth/bearer token instead of an API key" }, - { key: "ANTHROPIC_MODEL", description: "Model alias (deprecated in favor of settings)" }, - { key: "ANTHROPIC_BASE_URL", description: "API base URL (e.g. a gateway/proxy)" }, - { key: "ANTHROPIC_DEFAULT_SONNET_MODEL", description: "Model behind the 'sonnet' alias" }, - { key: "ANTHROPIC_DEFAULT_OPUS_MODEL", description: "Model behind the 'opus' alias" }, - { key: "ANTHROPIC_DEFAULT_HAIKU_MODEL", description: "Model behind the 'haiku' alias" }, - { key: "CLAUDE_CODE_MAX_OUTPUT_TOKENS", description: "Max output tokens" }, - { key: "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", description: "Disable telemetry/update traffic (1)" }, - { - key: "CLAUDE_CODE_SSE_URL", - description: "Anthropic SSE URL override (e.g. http://localhost:8080/anthropic)", - }, - { key: "CLAUDE_CODE_USE_BEDROCK", description: "Use Amazon Bedrock (1)" }, - { key: "CLAUDE_CODE_USE_VERTEX", description: "Use Google Vertex AI (1)" }, - { key: "BASH_DEFAULT_TIMEOUT_MS", description: "Default timeout for Bash tool commands" }, - { key: "MAX_THINKING_TOKENS", description: "Cap on extended-thinking tokens" }, -]; - -const SUGGESTED_FLAGS: { flag: string; description: string }[] = [ - { flag: "--dangerously-skip-permissions", description: "Skip all permission prompts" }, - { flag: "--permission-mode plan", description: "Start in plan mode" }, - { flag: "--model sonnet", description: "Use a specific model alias" }, - { flag: "--effort high", description: "Effort level for the subshell" }, - { flag: "--fallback-model opus", description: "Fall back when the primary model is overloaded" }, - { flag: "--agent reviewer", description: "Use a custom agent definition" }, - { flag: "--append-system-prompt ", description: "Append to the system prompt" }, - { flag: "--allowedTools Bash,Read,Edit", description: "Allow only these tools" }, - { flag: "--disallowedTools WebSearch", description: "Deny these tools" }, - { flag: "--mcp-config ", description: "Load MCP servers from a JSON file" }, - { flag: "--max-turns ", description: "Cap agentic turns (print mode)" }, - { flag: "--continue", description: "Continue the most recent conversation" }, - { flag: "--resume ", description: "Resume a specific past conversation" }, - { flag: "--fork-session", description: "Fork instead of resuming in place" }, - { flag: "--worktree", description: "Run in a fresh git worktree" }, -]; - -// `.npm-global/bin` for parity with codex: the npm install route is the -// documented one, and a plain `npm config set prefix ~/.npm-global` lands -// there. A version-manager prefix (nvm, fnm, volta) cannot be named statically -// and is covered by the login-PATH rung in `binary-lookup.ts` instead. -const PLUGIN_KNOWN_PATHS = [ - ".local/bin/claude", - ".local/share/claude/versions/claude", - ".claude/local/claude", - ".npm-global/bin/claude", -]; - -/** - * Fire-and-forget attention reporting. Each hook runs `bun -e` (bun is on the - * pane PATH — the image ships it, and no curl exists there) and POSTs the - * subshell's own bearer to the attention endpoint; the server gates delivery - * on the subshell's bell and derives the "waiting for you" state. The env - * vars are baked into the pane by the backend (`subshellMcpEnv`), and every - * failure path is swallowed: a missing hook event costs one notification, - * never a broken subshell turn. SUBSHELL_BASE_URL must be reachable from inside - * the pane's network — in the container that means the published port, which - * compose already passes via APP_BASE_URL. - */ -const attentionPing = (kind: string): string => - `bun -e '` + - `fetch(process.env.SUBSHELL_BASE_URL+"/api/subshells/"+process.env.SUBSHELL_ID+"/attention",` + - `{method:"POST",headers:{authorization:"Bearer "+process.env.SUBSHELL_API_KEY,"content-type":"application/json"},` + - `body:JSON.stringify({kind:${JSON.stringify(kind)}}),signal:AbortSignal.timeout(5000)})` + - `.catch(()=>{}).finally(()=>process.exit(0))'`; - -/** - * Conversation-identity reporting. The restart-resume pin (`--session-id` - * at launch) only survives while the pane keeps that ONE conversation — but - * /clear, /resume , and /fork start a DIFFERENT transcript id in-pane - * and nothing else tells the server, so the next restart would resurrect a - * stale conversation (observed 2026-09-03). The SessionStart hook fires on - * every such transition (source: startup|resume|clear|compact|fork). - * - * Unlike the attention pings, THIS command reads stdin — the deliberate, - * documented exception to the no-stdin rule (whose rationale is that - * Stop/Notification payloads carry conversation text): the SessionStart - * payload is pure metadata ({session_id, transcript_path, cwd, source,…}) - * and ONLY `session_id` is forwarded; the response is fire-and-forget like - * the pings — every failure path is swallowed. - * - * Both awaits are bounded: a self-kill timer caps the WHOLE hook (4 s — an - * unclosed stdin must never stall the pane start, and a SessionStart hook - * blocks the harness until it exits) and a 2 s fetch timeout caps the POST - * (a lost report is harmless — the next transition re-reports). - */ -const sessionReportPing = (): string => - `bun -e '` + - `setTimeout(()=>process.exit(0),4000);` + - `try{const j=JSON.parse(await Bun.stdin.text());` + - `if(j.session_id)await fetch(process.env.SUBSHELL_BASE_URL+"/api/subshells/"+process.env.SUBSHELL_ID+"/harness-session",` + - `{method:"POST",headers:{authorization:"Bearer "+process.env.SUBSHELL_API_KEY,"content-type":"application/json"},` + - `body:JSON.stringify({sessionId:j.session_id}),signal:AbortSignal.timeout(2000)})}catch{}` + - `process.exit(0)'`; - -/** The `--settings` hooks object injected into every Claude Code launch. - * - * `UserPromptSubmit`/`PreToolUse` (the `resumed` waiting-clear) joined on - * 2026-09-24 — an intentional behavior change, the one class of difference - * this fixture exists to MOVE rather than swallow: an agent-node pane stayed - * "waiting for you" forever because the plane's watcher cannot stat the - * node's log, and the fix is the pane clearing its own stamp from wherever - * it runs (`packages/plugins/claude-code/src/index.ts`). Every accidental - * divergence still fails here; this one was understood first. */ -export const ATTENTION_HOOKS = { - Stop: [{ hooks: [{ type: "command", command: attentionPing("turn_complete") }] }], - Notification: [{ hooks: [{ type: "command", command: attentionPing("needs_attention") }] }], - UserPromptSubmit: [{ hooks: [{ type: "command", command: attentionPing("resumed") }] }], - PreToolUse: [{ hooks: [{ type: "command", command: attentionPing("resumed") }] }], - SessionStart: [{ hooks: [{ type: "command", command: sessionReportPing() }] }], -} as const; - -/** Claude's state dir: the documented override, else `~/.claude`. */ -function claudeConfigDir(): string { - const override = process.env.CLAUDE_CONFIG_DIR?.trim(); - return override ? resolve(override) : join(homedir(), ".claude"); -} - -/** Claude's transcript folder name for a project dir: every non-alphanumeric → `-`. */ -function projectSlug(cwd: string): string { - return cwd.replace(/[^a-zA-Z0-9]/g, "-"); -} - -/** - * Built-in harness: Claude Code. - * - * Launch shape: - * claude --settings --name [preset flags] [extra flags] - * Settings are passed as a JSON string via `--settings` (per-invocation, so - * preset config never needs to write into the user's real ~/.claude). - */ -export class ClaudeCodePlugin { - readonly id = "claude-code"; - readonly name = "Claude Code"; - readonly binaryName = "claude"; - readonly description = "Anthropic's agentic coding assistant (interactive CLI)"; - readonly icon = "🤖"; - readonly ttyRequired = true; - readonly enabledByDefault = true; - readonly supportsAttentionHooks = true; - readonly installHint = { - command: "curl -fsSL https://claude.ai/install.sh | bash", - docsUrl: "https://code.claude.com/docs/en/setup", - }; - - /** Binary override; injectable for tests. */ - readonly #binaryOverride: string | null; - - constructor(binaryOverride: string | null = null) { - this.#binaryOverride = binaryOverride; - } - - async detect(): Promise { - if (this.#binaryOverride) { - // An injected override that does not exist is the same class of mistake - // as a bad CLAUDE_PATH: the caller said where it is and was wrong. - return (await Bun.file(this.#binaryOverride).exists()) - ? { path: this.#binaryOverride } - : { path: null, reason: "override-invalid" }; - } - return detectBinary(this.binaryName, "CLAUDE_PATH", PLUGIN_KNOWN_PATHS); - } - - async findBinary(): Promise { - return (await this.detect()).path; - } - - async isInstalled(): Promise { - return (await this.detect()).path !== null; - } - - async getVersion(): Promise { - const binary = await this.findBinary(); - return binary ? await probeVersion(binary) : null; - } - - /** - * Restart-resume: conversations are PINNED at start (`--session-id `) - * so subshell always knows the exact id to resume by, and resume mode adds - * `--resume ` (Claude appends to the same transcript, so one id - * survives repeated restarts). Pinning is what avoids both `--continue`'s - * "most recent in this directory" ambiguity — several subshells can share - * a cwd — and parsing the exit banner back out of the pane log. - */ - readonly resume: LegacyHarnessResume = { - allocateHarnessSessionId: () => crypto.randomUUID(), - canResume: (harnessSessionId, cwd) => - existsSync(join(claudeConfigDir(), "projects", projectSlug(cwd), `${harnessSessionId}.jsonl`)), - }; - - buildCommand(input: BuildCommandInput): string[] { - const { binary, preset, subshellName, extraFlags, mcp, harnessSession } = input; - - const args: string[] = [binary]; - - // Subshell channels + subshell orchestration: the registration's own argv - // (--mcp-config ) lands right after the binary. - if (mcp?.args) args.push(...mcp.args); - - // Conversation identity for restart-resume (see `resume` above). - if (harnessSession) { - args.push(harnessSession.mode === "resume" ? "--resume" : "--session-id", harnessSession.id); - } - - // Settings JSON is passed via --settings so presets never touch the - // user's real ~/.claude files. The attention hooks ride along on EVERY - // launch (subshell's signal wins if a preset set its own `hooks` key — - // documented limitation, the alternative is no notifications). - const settings = { ...(preset.settings ?? {}), hooks: ATTENTION_HOOKS }; - args.push("--settings", JSON.stringify(settings)); - - if (subshellName) { - args.push("--name", subshellName); - } - - // Each stored flag is one complete argv token (see opencode.ts note). - for (const flag of preset.flags) args.push(flag); - - if (extraFlags) { - args.push(...extraFlags); - } - - return args; - } - - /** Claude Code's `--mcp-config` document: `{ mcpServers: { subshell: {...} } }`. */ - mcpRegistration(launch: McpLaunchSpec, configPath: string): McpRegistration { - return { - fileContent: `${JSON.stringify({ mcpServers: { [MCP_SERVER_NAME]: { command: launch.command, args: launch.args } } }, null, 2)}\n`, - args: ["--mcp-config", configPath], - }; - } - - /** Auto: `buildCommand` wires `--mcp-config` into every subshell. */ - mcpSetup(_launch: McpLaunchSpec): McpSetupInfo { - return { - mode: "auto", - summary: "Subshell registers itself with every Claude Code subshell automatically (via --mcp-config).", - }; - } - - validatePreset(preset: PresetDefinition): PresetValidationResult { - return validateGenericPreset(preset); - } - - settingsFields(): SettingsField[] { - return CLAUDE_SETTINGS_FIELDS; - } - - suggestedEnv(): { key: string; description: string }[] { - return SUGGESTED_ENV; - } - - suggestedFlags(): { flag: string; description: string }[] { - return SUGGESTED_FLAGS; - } - - exitStatus(code: number): string | null { - const map: Record = { - 1: "error doing work", - 5: "permissions denied", - 10: "closed-loop complete", - 11: "gate closed", - }; - return map[code] ?? null; - } -} diff --git a/packages/pane-runtime/src/__tests__/fixtures/codex-legacy.ts b/packages/pane-runtime/src/__tests__/fixtures/codex-legacy.ts deleted file mode 100644 index 34e21b063..000000000 --- a/packages/pane-runtime/src/__tests__/fixtures/codex-legacy.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * The pre-extraction codex plugin, frozen as a REFERENCE. - * - * `plugin-parity.test.ts` compares the extracted package against this to prove - * the move changed nothing that gets executed. Deleted with that test once the - * extraction has been reviewed and shipped; nothing else may import it. - * @internal - */ -import { validateGenericPreset } from "@subshell-ai/plugin-api"; -import { type DetectionResult, detectBinary } from "../../binary-lookup.js"; -import type { - BuildCommandInput, - McpLaunchSpec, - McpRegistration, - McpSetupInfo, - PresetDefinition, - PresetValidationResult, - SettingsField, -} from "../../types.js"; -import { MCP_SERVER_NAME } from "../../types.js"; -import { probeVersion } from "../../version-probe.js"; - -/** Known Codex settings, applied as per-invocation CLI flags. */ -const CODEX_SETTINGS_FIELDS: SettingsField[] = [ - { - key: "model", - label: "Model", - description: "Model name passed as -m/--model (e.g. 'gpt-5-codex')", - type: "string", - }, - { - key: "sandbox", - label: "Sandbox", - description: "What the agent may touch without asking (-s/--sandbox)", - type: "select", - choices: ["read-only", "workspace-write", "danger-full-access"], - }, - { - key: "askForApproval", - label: "Ask for approval", - description: "When codex pauses for a human decision (-a/--ask-for-approval)", - type: "select", - choices: ["on-request", "never"], - }, -]; - -const SUGGESTED_ENV: { key: string; description: string }[] = [ - // CODEX_HOME is deliberately NOT suggested: it locates ~/.codex, which - // holds the user's auth.json — pointing it elsewhere logs the subshell out - // of ChatGPT and hides their config.toml. subshell never needs it: its MCP - // wiring rides per-invocation `-c` overrides (mcpRegistration), so the - // user's own dir is used as-is. - { key: "OPENAI_API_KEY", description: "API key for the api-key login mode (else ChatGPT sign-in)" }, -]; - -const SUGGESTED_FLAGS: { flag: string; description: string }[] = [ - { flag: "--search", description: "Enable live web search" }, - { flag: "-p work", description: "Apply a profile from ~/.codex/config.toml" }, - { - flag: '-c mcp_servers.foo.command="/usr/bin/foo"', - description: "One-off config override (dotted path, value parsed as TOML)", - }, -]; - -// The locations a real codex install might sit in, all under HOME. (An absolute -// entry is allowed too — `findBinary` uses one as-is — but every one of these -// IS a home directory, so they are spelled relative.) -const PLUGIN_KNOWN_PATHS = [".local/bin/codex", ".npm-global/bin/codex", ".bun/bin/codex"]; - -/** A TOML basic string from an ASCII-safe JS string. */ -function tomlString(value: string): string { - // JSON.stringify emits exactly TOML's basic-string syntax for these shapes - // (same `"` delimiter, same `\"`/`\\`/`\n` escapes) — command paths and argv - // tokens are filesystem-safe by construction, so the encodings coincide. - return JSON.stringify(value); -} - -/** A TOML inline array of basic strings — same JSON-compatibility argument. */ -function tomlStringArray(values: string[]): string { - return `[${values.map(tomlString).join(", ")}]`; -} - -/** - * Built-in harness: Codex (OpenAI's `@openai/codex` CLI). - * - * Launch shape: - * codex [-m ] [-s ] [-a ] [mcp -c pairs] - * [preset flags] [extra flags] - * A bare launch (no subcommand, no prompt) opens the interactive TUI. Codex - * has no create-time flag for naming its own session and no way to pin a - * conversation id, so neither the subshell name nor a resume capability is - * forwarded — restarts start a fresh conversation (the documented default). - * The pane's - * cwd is already the working directory, so `-C/--cd` is never passed, and - * `--dangerously-bypass-approvals-and-sandbox` is NEVER baked into a launch - * (a user may put it in their own preset flags — that is their call). - * - * subshell MCP is wired automatically WITHOUT touching user state: every - * launch carries `-c mcp_servers.subshell.command=… -c mcp_servers.subshell.args=…` - * config overrides (dotted path, value parsed as TOML — verified against the - * real binary), which codex merges over ~/.codex/config.toml for that run - * only. No config file the harness must read, no CODEX_HOME redirect (that - * dir holds the user's auth.json). The spawned `subshell mcp` child inherits - * the subshell's baked SUBSHELL_* pane env for its credential, like every - * harness. - * (Flags/values verified against @openai/codex 2026-09 help output.) - */ -export class CodexPlugin { - readonly id = "codex"; - readonly name = "Codex"; - readonly binaryName = "codex"; - readonly description = "OpenAI's agentic coding CLI (interactive TUI)"; - readonly icon = "📖"; - readonly ttyRequired = true; - readonly enabledByDefault = true; - readonly installHint = { - // Official standalone installer (also `npm install -g @openai/codex`). - command: "curl -fsSL https://chatgpt.com/codex/install.sh | sh", - docsUrl: "https://learn.chatgpt.com/docs/codex/cli", - }; - - /** Binary override; injectable for tests. */ - readonly #binaryOverride: string | null; - - constructor(binaryOverride: string | null = null) { - this.#binaryOverride = binaryOverride; - } - - async detect(): Promise { - if (this.#binaryOverride) { - // An injected override that does not exist is the same class of mistake - // as a bad CODEX_PATH: the caller said where it is and was wrong. - return (await Bun.file(this.#binaryOverride).exists()) - ? { path: this.#binaryOverride } - : { path: null, reason: "override-invalid" }; - } - return detectBinary(this.binaryName, "CODEX_PATH", PLUGIN_KNOWN_PATHS); - } - - async findBinary(): Promise { - return (await this.detect()).path; - } - - async isInstalled(): Promise { - return (await this.detect()).path !== null; - } - - async getVersion(): Promise { - const binary = await this.findBinary(); - return binary ? await probeVersion(binary) : null; - } - - buildCommand(input: BuildCommandInput): string[] { - const { binary, preset, extraFlags, mcp } = input; - const args: string[] = [binary]; - - const s = preset.settings ?? {}; - if (typeof s.model === "string" && s.model) args.push("-m", s.model); - if (typeof s.sandbox === "string" && s.sandbox) args.push("-s", s.sandbox); - if (typeof s.askForApproval === "string" && s.askForApproval) args.push("-a", s.askForApproval); - - // Subshell channels + subshell orchestration: the registration's own argv - // (-c mcp_servers.subshell.* overrides — see mcpRegistration) splices here. - if (mcp?.args) args.push(...mcp.args); - - // Each stored flag is one complete argv token (row editor guarantees it); - // never re-split on whitespace or multi-word values break. - for (const flag of preset.flags) args.push(flag); - - if (extraFlags) args.push(...extraFlags); - return args; - } - - /** - * Codex config fragment registering subshell as a stdio MCP server — the - * exact `[mcp_servers.subshell]` block `~/.codex/config.toml` expects. The - * block is NOT what activates the server on subshell launches: `buildCommand` - * splices live `-c` overrides instead (see the class doc), so this file is a - * manual-setup reference a user may append to their own config. - */ - mcpRegistration(launch: McpLaunchSpec, _configPath: string): McpRegistration { - const fragment = [ - "# Subshell: cross-subshell MCP server, in the shape ~/.codex/config.toml expects.", - "#", - "# This file is a MANUAL-SETUP REFERENCE ONLY: subshell's own launches pass the", - "# live values as `-c mcp_servers.subshell.*=…` per-invocation overrides, so codex", - "# never reads this file. To register subshell permanently (e.g. terminal codex),", - "# append the block below to ~/.codex/config.toml.", - "", - `[mcp_servers.${MCP_SERVER_NAME}]`, - `command = ${tomlString(launch.command)}`, - `args = ${tomlStringArray(launch.args)}`, - "", - ].join("\n"); - return { - fileContent: fragment, - args: [ - "-c", - `mcp_servers.${MCP_SERVER_NAME}.command=${tomlString(launch.command)}`, - "-c", - `mcp_servers.${MCP_SERVER_NAME}.args=${tomlStringArray(launch.args)}`, - ], - // No wiring env on purpose (contrast opencode's OPENCODE_CONFIG): the -c - // argv IS the wiring, and CODEX_HOME must stay on the user's dir (auth). - // The spawned `subshell mcp` child inherits the SUBSHELL_* credentials - // the backend bakes into the pane regardless of this registration. - env: undefined, - }; - } - - /** Auto: `buildCommand` wires `-c mcp_servers.subshell.*` into every subshell. */ - mcpSetup(_launch: McpLaunchSpec): McpSetupInfo { - return { - mode: "auto", - summary: - "Subshell registers itself with every Codex subshell automatically (per-invocation -c mcp_servers.subshell.* overrides; your ~/.codex config is never modified).", - }; - } - - validatePreset(preset: PresetDefinition): PresetValidationResult { - return validateGenericPreset(preset); - } - - settingsFields(): SettingsField[] { - return CODEX_SETTINGS_FIELDS; - } - - suggestedEnv(): { key: string; description: string }[] { - return SUGGESTED_ENV; - } - - suggestedFlags(): { flag: string; description: string }[] { - return SUGGESTED_FLAGS; - } -} diff --git a/packages/pane-runtime/src/__tests__/fixtures/hermes-legacy.ts b/packages/pane-runtime/src/__tests__/fixtures/hermes-legacy.ts deleted file mode 100644 index faa4aff16..000000000 --- a/packages/pane-runtime/src/__tests__/fixtures/hermes-legacy.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * The pre-extraction hermes plugin, frozen as a REFERENCE. - * - * `plugin-parity.test.ts` compares the extracted package against this to prove - * the move changed nothing that gets executed. Deleted with that test once the - * extraction has been reviewed and shipped; nothing else may import it. - * @internal - */ -import { validateGenericPreset } from "@subshell-ai/plugin-api"; -import { type DetectionResult, detectBinary } from "../../binary-lookup.js"; -import { shellQuote } from "../../shell.js"; -import type { - BuildCommandInput, - McpLaunchSpec, - McpSetupInfo, - PresetDefinition, - PresetValidationResult, - SettingsField, -} from "../../types.js"; -import { MCP_SERVER_NAME } from "../../types.js"; -import { probeVersion } from "../../version-probe.js"; - -/** Hermes per-invocation overrides, applied as CLI flags (no config file writes). */ -const HERMES_SETTINGS_FIELDS: SettingsField[] = [ - { - key: "model", - label: "Model", - description: "Model override for this invocation (e.g. 'anthropic/claude-sonnet-4.6')", - type: "string", - }, - { - key: "provider", - label: "Provider", - description: "Provider override (e.g. 'openrouter', 'anthropic')", - type: "string", - }, - { - key: "toolsets", - label: "Toolsets", - description: "Comma-separated toolsets to enable for the subshell", - type: "string", - }, -]; - -const SUGGESTED_ENV: { key: string; description: string }[] = [ - { key: "HERMES_HOME", description: "Config/data directory (default: ~/.hermes), isolation knob" }, - { key: "HERMES_INFERENCE_MODEL", description: "Model override (same value as -m)" }, - { key: "OPENROUTER_API_KEY", description: "OpenRouter API key" }, - { key: "ANTHROPIC_API_KEY", description: "Anthropic API key" }, -]; - -const SUGGESTED_FLAGS: { flag: string; description: string }[] = [ - { flag: "--tui", description: "Force the modern TUI (overrides display.interface)" }, - { flag: "--cli", description: "Force the classic prompt_toolkit REPL" }, - { flag: "-m anthropic/claude-sonnet-4.6", description: "Model override for this invocation" }, - { flag: "--provider openrouter", description: "Provider override" }, - { flag: "-t web,files", description: "Comma-separated toolsets to enable" }, - { flag: "--yolo", description: "Bypass dangerous-command approval prompts" }, - { flag: "--worktree", description: "Run in an isolated git worktree" }, - { flag: "--safe-mode", description: "Disable all customizations (config, rules, plugins, MCP)" }, - { flag: "--skills ", description: "Preload one or more skills" }, -]; - -const PLUGIN_KNOWN_PATHS = [".local/bin/hermes"]; - -/** - * Built-in harness: Hermes Agent (Nous Research). - * - * Launch shape: - * hermes [-m ] [--provider

] [-t ] [preset flags] [extra flags] - * A bare launch starts interactive chat; which interface (classic REPL vs - * --tui) is left to the user's own display.interface config. Hermes has no - * create-time flag for naming its own session, so the subshell's display name - * is not forwarded. - * - * CAVEAT — the `hermes` on PATH is a bash launcher script that execs the venv - * binary. Subshell launches are safe: `buildHarnessCommand` runs everything - * through `env -i`, so inherited shell state cannot reach it. But a backend - * started from a shell that exports SHELLOPTS containing `onecmd` would break - * the launcher for probe calls like getVersion() (bash exits after the first - * line). Start subshell's server from a normal shell, or `env -u SHELLOPTS`. - * - * NOTE: subshell MCP is NOT auto-injected. Hermes reads MCP servers only from the - * fixed ~/.hermes/config.yaml (no --config flag / env override exists), so a - * per-subshell file is impossible. mcpSetup() surfaces a one-time `hermes mcp - * add` command instead; the registration is subshell-correct on shared hosts - * because the subshell-mcp child inherits each subshell's baked SUBSHELL_* env. - */ -export class HermesPlugin { - readonly id = "hermes"; - readonly name = "Hermes Agent"; - readonly binaryName = "hermes"; - readonly description = "Nous Research's open agent CLI (interactive chat)"; - readonly icon = "📮"; - readonly ttyRequired = true; - readonly enabledByDefault = true; - readonly installHint = { - command: "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", - docsUrl: "https://hermes-agent.nousresearch.com/docs/getting-started/installation", - }; - - /** Binary override; injectable for tests. */ - readonly #binaryOverride: string | null; - - constructor(binaryOverride: string | null = null) { - this.#binaryOverride = binaryOverride; - } - - async detect(): Promise { - if (this.#binaryOverride) { - // An injected override that does not exist is the same class of mistake - // as a bad HERMES_PATH: the caller said where it is and was wrong. - return (await Bun.file(this.#binaryOverride).exists()) - ? { path: this.#binaryOverride } - : { path: null, reason: "override-invalid" }; - } - return detectBinary(this.binaryName, "HERMES_PATH", PLUGIN_KNOWN_PATHS); - } - - async findBinary(): Promise { - return (await this.detect()).path; - } - - async isInstalled(): Promise { - return (await this.detect()).path !== null; - } - - /** - * Hermes prints a multi-line block whose first line is a label like - * "Hermes Agent v0.16.0 (2026.6.5) · upstream 5e01a5db". Return just the - * version number so the UI can prefix it with a single "v" (falling back - * to the whole first line if no semver is present). - */ - async getVersion(): Promise { - const binary = await this.findBinary(); - if (!binary) return null; - const out = await probeVersion(binary); - if (!out) return null; - const firstLine = - out - .split("\n") - .map((l) => l.trim()) - .find(Boolean) ?? null; - if (!firstLine) return null; - const match = firstLine.match(/\d+\.\d+(?:\.\d+)?/); - return match ? match[0] : firstLine; - } - - buildCommand(input: BuildCommandInput): string[] { - const { binary, preset, extraFlags } = input; - const args: string[] = [binary]; - - const s = preset.settings ?? {}; - if (typeof s.model === "string" && s.model) args.push("-m", s.model); - if (typeof s.provider === "string" && s.provider) args.push("--provider", s.provider); - if (typeof s.toolsets === "string" && s.toolsets) args.push("-t", s.toolsets); - - // Each stored flag is one complete argv token (see opencode.ts note). - for (const flag of preset.flags) args.push(flag); - - if (extraFlags) args.push(...extraFlags); - return args; - } - - /** - * Manual, one-time registration (verified against the installed hermes CLI: - * `hermes mcp add` is non-interactive and writes the global config itself). - * Hermes has no per-subshell config override, so this registration covers all - * hermes subshells at once; each `subshell mcp` child inherits its pane's baked - * SUBSHELL_* env, making the single entry per-subshell-correct. The `--args` - * flag must come last (hermes' own parser requirement). - */ - mcpSetup(launch: McpLaunchSpec): McpSetupInfo { - const argsPart = launch.args.length ? ` --args ${launch.args.map(shellQuote).join(" ")}` : ""; - return { - mode: "manual", - steps: [ - { - label: `Register ${MCP_SERVER_NAME} once (adds it to ~/.hermes/config.yaml):`, - command: `hermes mcp add ${MCP_SERVER_NAME} --command ${shellQuote(launch.command)}${argsPart}`, - }, - { label: "Remove later with:", command: `hermes mcp remove ${MCP_SERVER_NAME}` }, - ], - }; - } - - validatePreset(preset: PresetDefinition): PresetValidationResult { - return validateGenericPreset(preset); - } - - settingsFields(): SettingsField[] { - return HERMES_SETTINGS_FIELDS; - } - - suggestedEnv(): { key: string; description: string }[] { - return SUGGESTED_ENV; - } - - suggestedFlags(): { flag: string; description: string }[] { - return SUGGESTED_FLAGS; - } -} diff --git a/packages/pane-runtime/src/__tests__/fixtures/opencode-legacy.ts b/packages/pane-runtime/src/__tests__/fixtures/opencode-legacy.ts deleted file mode 100644 index 3fa3f25b3..000000000 --- a/packages/pane-runtime/src/__tests__/fixtures/opencode-legacy.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * The pre-extraction opencode plugin, frozen as a REFERENCE. - * - * `plugin-parity.test.ts` compares the extracted package against this to prove - * the move changed nothing that gets executed. Deleted with that test once the - * extraction has been reviewed and shipped; nothing else may import it. - * @internal - */ -import { validateGenericPreset } from "@subshell-ai/plugin-api"; -import { type DetectionResult, detectBinary } from "../../binary-lookup.js"; -import type { - BuildCommandInput, - McpLaunchSpec, - McpRegistration, - McpSetupInfo, - PresetDefinition, - PresetValidationResult, - SettingsField, -} from "../../types.js"; -import { MCP_SERVER_NAME } from "../../types.js"; -import { probeVersion } from "../../version-probe.js"; - -/** Known opencode settings, applied as per-invocation CLI flags. */ -const OPENCODE_SETTINGS_FIELDS: SettingsField[] = [ - { - key: "model", - label: "Model", - description: "Model in provider/model form (e.g. 'anthropic/claude-sonnet-4-5')", - type: "string", - }, - { - key: "agent", - label: "Agent", - description: "Agent to use for the subshell", - type: "string", - }, - { - key: "auto", - label: "Auto-approve permissions", - description: "Approve permissions that are not explicitly denied (dangerous)", - type: "boolean", - }, -]; - -const SUGGESTED_ENV: { key: string; description: string }[] = [ - // OPENCODE_CONFIG is deliberately NOT suggested: subshell owns it — it points - // at each subshell's generated MCP config layer (mcpRegistration), and a - // preset that set it would shadow its own cross-subshell comms. Users who - // want their own extra layer use OPENCODE_CONFIG_CONTENT (independent merge - // source) or OPENCODE_CONFIG_DIR. - { key: "OPENCODE_CONFIG_CONTENT", description: "Inline JSON config merged at runtime" }, - { key: "OPENCODE_CONFIG_DIR", description: "Extra config dir (agents/commands/plugins), isolation knob" }, - { key: "OPENCODE_TUI_CONFIG", description: "Path to a custom TUI config file" }, - { key: "OPENCODE_API_KEY", description: "OpenCode Zen / OpenCode Go API key" }, -]; - -const SUGGESTED_FLAGS: { flag: string; description: string }[] = [ - { flag: "-m anthropic/claude-sonnet-4-5", description: "Model in provider/model form" }, - { flag: "--agent plan", description: "Start with a specific agent" }, - { flag: "--auto", description: "Auto-approve permissions not explicitly denied" }, - { flag: "--pure", description: "Run without external plugins" }, - { flag: "--prompt ", description: "Initial prompt for the subshell" }, - { flag: "--mini", description: "Start the minimal interactive interface" }, - { flag: "--print-logs", description: "Print logs to stderr (helps debugging)" }, -]; - -const PLUGIN_KNOWN_PATHS = [".opencode/bin/opencode"]; - -/** - * Built-in harness: OpenCode (opencode.ai). - * - * Launch shape: - * opencode [-m ] [--agent ] [--auto] [preset flags] [extra flags] - * A bare launch opens the TUI. opencode has no create-time flag for naming its - * own session, so the subshell's display name is deliberately not forwarded. - * Settings arrive as - * per-invocation flags (verified against opencode 1.18.18). - * - * subshell MCP is wired automatically: OpenCode merges any object under the - * OPENCODE_CONFIG env-var path into its config (verified against 1.18.18 — - * "Custom config is loaded between global and project configs", files are - * merged not replaced). subshell writes a per-subshell config file holding just an - * `mcp.subshell` local stdio entry and points OPENCODE_CONFIG at it, so the user's - * own global/project servers are preserved. The spawned `subshell mcp` child - * inherits the subshell's baked SUBSHELL_* env for its credential. - */ -export class OpencodePlugin { - readonly id = "opencode"; - readonly name = "OpenCode"; - readonly binaryName = "opencode"; - readonly description = "Open-source omnichannel agentic coding CLI (interactive TUI)"; - readonly icon = "✳️"; - readonly ttyRequired = true; - readonly enabledByDefault = true; - readonly installHint = { - command: "curl -fsSL https://opencode.ai/install | bash", - docsUrl: "https://opencode.ai/docs/cli/", - }; - - /** Binary override; injectable for tests. */ - readonly #binaryOverride: string | null; - - constructor(binaryOverride: string | null = null) { - this.#binaryOverride = binaryOverride; - } - - async detect(): Promise { - if (this.#binaryOverride) { - // An injected override that does not exist is the same class of mistake - // as a bad OPENCODE_PATH: the caller said where it is and was wrong. - return (await Bun.file(this.#binaryOverride).exists()) - ? { path: this.#binaryOverride } - : { path: null, reason: "override-invalid" }; - } - return detectBinary(this.binaryName, "OPENCODE_PATH", PLUGIN_KNOWN_PATHS); - } - - async findBinary(): Promise { - return (await this.detect()).path; - } - - async isInstalled(): Promise { - return (await this.detect()).path !== null; - } - - async getVersion(): Promise { - const binary = await this.findBinary(); - return binary ? await probeVersion(binary) : null; - } - - buildCommand(input: BuildCommandInput): string[] { - const { binary, preset, extraFlags } = input; - const args: string[] = [binary]; - - const s = preset.settings ?? {}; - if (typeof s.model === "string" && s.model) args.push("-m", s.model); - if (typeof s.agent === "string" && s.agent) args.push("--agent", s.agent); - if (s.auto === true) args.push("--auto"); - - // Each stored flag is one complete argv token (row editor guarantees it); - // never re-split on whitespace or multi-word values break. - for (const flag of preset.flags) args.push(flag); - - if (extraFlags) args.push(...extraFlags); - return args; - } - - /** - * OpenCode config fragment registering subshell as a local stdio MCP server. - * Merged (not replaced) into the user's config via the OPENCODE_CONFIG env - * var — see the class doc. `command` is the argv array OpenCode expects. - */ - mcpRegistration(launch: McpLaunchSpec, configPath: string): McpRegistration { - const doc = { - $schema: "https://opencode.ai/config.json", - mcp: { - [MCP_SERVER_NAME]: { type: "local", command: [launch.command, ...launch.args], enabled: true }, - }, - }; - return { fileContent: `${JSON.stringify(doc, null, 2)}\n`, env: { OPENCODE_CONFIG: configPath } }; - } - - /** Auto: subshell writes a merged config layer + OPENCODE_CONFIG for every subshell. */ - mcpSetup(_launch: McpLaunchSpec): McpSetupInfo { - return { - mode: "auto", - summary: - "Subshell registers itself with every OpenCode subshell automatically (a merged config layer + OPENCODE_CONFIG).", - }; - } - - validatePreset(preset: PresetDefinition): PresetValidationResult { - return validateGenericPreset(preset); - } - - settingsFields(): SettingsField[] { - return OPENCODE_SETTINGS_FIELDS; - } - - suggestedEnv(): { key: string; description: string }[] { - return SUGGESTED_ENV; - } - - suggestedFlags(): { flag: string; description: string }[] { - return SUGGESTED_FLAGS; - } -} diff --git a/packages/pane-runtime/src/__tests__/fixtures/pi-legacy.ts b/packages/pane-runtime/src/__tests__/fixtures/pi-legacy.ts deleted file mode 100644 index 4ba27278d..000000000 --- a/packages/pane-runtime/src/__tests__/fixtures/pi-legacy.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * The pre-extraction pi plugin, frozen as a REFERENCE. - * - * `plugin-parity.test.ts` compares the extracted package against this to prove - * the move changed nothing that gets executed. Deleted with that test once the - * extraction has been reviewed and shipped; nothing else may import it. - * @internal - */ -import { validateGenericPreset } from "@subshell-ai/plugin-api"; -import { type DetectionResult, detectBinary } from "../../binary-lookup.js"; -import type { - BuildCommandInput, - McpLaunchSpec, - McpSetupInfo, - PresetDefinition, - PresetValidationResult, - SettingsField, -} from "../../types.js"; -import { MCP_SERVER_NAME } from "../../types.js"; -import { probeVersion } from "../../version-probe.js"; - -/** pi per-invocation overrides, applied as CLI flags. */ -const PI_SETTINGS_FIELDS: SettingsField[] = [ - { - key: "model", - label: "Model", - description: "Model pattern or ID ('provider/id' with optional ':')", - type: "string", - }, - { - key: "provider", - label: "Provider", - description: "Provider name (e.g. 'anthropic', 'openai')", - type: "string", - }, - { - key: "thinking", - label: "Thinking level", - description: "Thinking budget for the subshell", - type: "select", - choices: ["off", "minimal", "low", "medium", "high", "xhigh"], - }, -]; - -const SUGGESTED_ENV: { key: string; description: string }[] = [ - { key: "PI_CODING_AGENT_DIR", description: "Config directory (default: ~/.pi/agent), isolation knob" }, - { key: "PI_CODING_AGENT_SESSION_DIR", description: "Subshell storage directory" }, - { key: "PI_OFFLINE", description: "Disable startup network operations (1/true)" }, - { key: "PI_TELEMETRY", description: "Override install telemetry (1/0)" }, - { key: "ANTHROPIC_API_KEY", description: "Anthropic API key" }, - { key: "OPENAI_API_KEY", description: "OpenAI API key" }, -]; - -const SUGGESTED_FLAGS: { flag: string; description: string }[] = [ - { flag: "--model sonnet:high", description: "Model pattern with thinking shorthand" }, - { flag: "--provider anthropic", description: "Provider name" }, - { flag: "--thinking high", description: "Thinking level for the subshell" }, - { flag: "--tools read,grep,find,ls", description: "Allowlist of tool names to enable" }, - { flag: "--exclude-tools ask_question", description: "Denylist of tool names to disable" }, - { flag: "--append-system-prompt ", description: "Append to the system prompt" }, - { flag: "--no-session", description: "Ephemeral subshell (nothing saved)" }, - { flag: "--offline", description: "Skip startup network operations" }, -]; - -const PLUGIN_KNOWN_PATHS = [".bun/bin/pi"]; - -/** - * Built-in harness: pi (pi.dev, Earendil Works). - * - * Launch shape: - * pi [--model ] [--provider

] [--thinking ] --name \ - * [preset flags] [extra flags] - * A bare launch opens the TUI. Unlike the other harnesses, pi supports a - * create-time name for its OWN session (`--name`), so the subshell's display - * name is forwarded to it. - * - * NOTE: subshell MCP is NOT auto-injected. pi deliberately has no built-in MCP — - * it comes from the community `pi-mcp-adapter` extension, which must be - * installed once per host. mcpSetup() surfaces the exact steps (install + - * the standard mcpServers snippet the adapter reads from ~/.config/mcp/mcp.json - * or a project .mcp.json); the spawned child inherits each subshell's SUBSHELL_* env. - */ -export class PiPlugin { - readonly id = "pi"; - readonly name = "pi"; - readonly binaryName = "pi"; - readonly description = "Minimal, extensible agent harness from pi.dev (interactive TUI)"; - readonly icon = "π"; - readonly ttyRequired = true; - readonly enabledByDefault = true; - readonly installHint = { - command: "curl -fsSL https://pi.dev/install.sh | sh", - docsUrl: "https://pi.dev/docs", - }; - - /** Binary override; injectable for tests. */ - readonly #binaryOverride: string | null; - - constructor(binaryOverride: string | null = null) { - this.#binaryOverride = binaryOverride; - } - - async detect(): Promise { - if (this.#binaryOverride) { - // An injected override that does not exist is the same class of mistake - // as a bad PI_PATH: the caller said where it is and was wrong. - return (await Bun.file(this.#binaryOverride).exists()) - ? { path: this.#binaryOverride } - : { path: null, reason: "override-invalid" }; - } - return detectBinary(this.binaryName, "PI_PATH", PLUGIN_KNOWN_PATHS); - } - - async findBinary(): Promise { - return (await this.detect()).path; - } - - async isInstalled(): Promise { - return (await this.detect()).path !== null; - } - - async getVersion(): Promise { - const binary = await this.findBinary(); - return binary ? await probeVersion(binary) : null; - } - - buildCommand(input: BuildCommandInput): string[] { - const { binary, preset, subshellName, extraFlags } = input; - const args: string[] = [binary]; - - const s = preset.settings ?? {}; - if (typeof s.model === "string" && s.model) args.push("--model", s.model); - if (typeof s.provider === "string" && s.provider) args.push("--provider", s.provider); - if (typeof s.thinking === "string" && s.thinking) args.push("--thinking", s.thinking); - - if (subshellName) args.push("--name", subshellName); - - // Each stored flag is one complete argv token (see opencode.ts note). - for (const flag of preset.flags) args.push(flag); - - if (extraFlags) args.push(...extraFlags); - return args; - } - - /** - * Manual, two-step setup: pi has no built-in MCP, so the community - * `pi-mcp-adapter` extension must be installed once, then subshell registered in - * the adapter's standard `mcpServers` file. Steps carry the resolved launch - * so the snippet is copy-paste correct. - */ - mcpSetup(launch: McpLaunchSpec): McpSetupInfo { - const snippet = JSON.stringify( - { mcpServers: { [MCP_SERVER_NAME]: { command: launch.command, args: launch.args } } }, - null, - 2, - ); - return { - mode: "manual", - steps: [ - { label: "Install the MCP adapter extension once:", command: "pi install npm:pi-mcp-adapter" }, - { - label: `Register ${MCP_SERVER_NAME} in ~/.config/mcp/mcp.json (or a project .mcp.json):`, - command: snippet, - }, - ], - }; - } - - validatePreset(preset: PresetDefinition): PresetValidationResult { - return validateGenericPreset(preset); - } - - settingsFields(): SettingsField[] { - return PI_SETTINGS_FIELDS; - } - - suggestedEnv(): { key: string; description: string }[] { - return SUGGESTED_ENV; - } - - suggestedFlags(): { flag: string; description: string }[] { - return SUGGESTED_FLAGS; - } -} diff --git a/packages/pane-runtime/src/__tests__/plugin-parity.test.ts b/packages/pane-runtime/src/__tests__/plugin-parity.test.ts deleted file mode 100644 index 03d51be70..000000000 --- a/packages/pane-runtime/src/__tests__/plugin-parity.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; -import type { - BuildCommandInput, - McpLaunchSpec, - McpRegistration, - McpSetupInfo, - PluginCapability, - PresetDefinition, - SettingsField, - SubshellPlugin, -} from "@subshell-ai/plugin-api"; -import { createInProcessRuntime } from "../plugin-runtime.js"; -import { ClaudeCodePlugin } from "./fixtures/claude-code-legacy.js"; -import { CodexPlugin } from "./fixtures/codex-legacy.js"; -import { HermesPlugin } from "./fixtures/hermes-legacy.js"; -import { OpencodePlugin } from "./fixtures/opencode-legacy.js"; -import { PiPlugin } from "./fixtures/pi-legacy.js"; - -/** - * The extraction must not change what gets executed. - * - * This compares the loaded plugin against the class it replaces on every - * branch of `buildCommand` that varies, and is DELETED along with the class - * once it has served its purpose. A difference here is a real behaviour - * change: understand it, never absorb it by editing the expectation. - */ - -/** A preset with nothing set, so a case's own field is the only variable. */ -const BLANK: PresetDefinition = { - name: "Default", - description: null, - env: {}, - flags: [], - settings: null, - configIsolation: false, -}; - -/** - * The reporter every claude-code case carries. The legacy class predates the - * field and ignores it; the extracted plugin needs one to emit hooks at all, - * so without it the two would differ by the whole `--settings` argument - * instead of by the one thing that deliberately changed. - */ -const REPORTER = { command: "/bin/subshell-server", args: ["report"] }; - -/** The fields every case shares; a case overrides only what it is testing. */ -function input(over: Partial): BuildCommandInput { - return { - binary: "/bin/claude", - cwd: "/tmp/work", - preset: BLANK, - subshellName: "", - reporter: REPORTER, - ...over, - }; -} - -/** - * The argv with each hook COMMAND replaced by a marker, and the Notification - * entries' `matcher` dropped — the two deliberate post-extraction divergences, - * normalized so the rest of the comparison keeps working. - * - * The legacy class runs `bun -e ''`, which assumed a bun on the - * pane's PATH; the extracted plugin re-enters the subshell binary instead, - * because most machines have no bun and every session opened on - * `bun: command not found`. And since spec 2026-09-23 the plugin's - * `Notification` hook carries a matcher so only the notification types that - * genuinely need a human ring; the legacy class predates it. Everything else - * about the hooks — which events carry one, their shape, that preset settings - * merge underneath — still compares exactly, and so does every other argv - * element. - */ -function normalizeHookCommands(argv: string[]): string[] { - const idx = argv.indexOf("--settings"); - if (idx === -1) return argv; - const settings = JSON.parse(argv[idx + 1]) as { - hooks?: Record; - }; - for (const [event, entries] of Object.entries(settings.hooks ?? {})) { - for (const entry of entries) { - if (event === "Notification") delete entry.matcher; - for (const hook of entry.hooks) hook.command = ""; - } - } - return argv.map((a, i) => (i === idx + 1 ? JSON.stringify(settings) : a)); -} - -const SESSION = "11111111-2222-3333-4444-555555555555"; - -const CASES: { name: string; input: BuildCommandInput }[] = [ - { name: "bare", input: input({}) }, - { name: "named subshell", input: input({ subshellName: "review" }) }, - { - name: "mcp args splice in after the binary", - input: input({ mcp: { fileContent: "{}", args: ["--mcp-config", "/tmp/x.json"] } }), - }, - { name: "a new conversation pins its id", input: input({ harnessSession: { id: SESSION, mode: "start" } }) }, - { name: "an existing conversation resumes", input: input({ harnessSession: { id: SESSION, mode: "resume" } }) }, - { - name: "preset settings merge under the attention hooks", - input: input({ preset: { ...BLANK, settings: { permissionMode: "plan", model: "sonnet" } } }), - }, - { - name: "a preset that sets its own hooks still gets ours", - input: input({ preset: { ...BLANK, settings: { hooks: { Stop: [] } } } }), - }, - { - name: "preset flags are passed as whole argv tokens", - input: input({ preset: { ...BLANK, flags: ["--append-system-prompt", "be brief"] } }), - }, - { name: "extra flags come last", input: input({ extraFlags: ["--verbose"] }) }, - { - name: "everything at once, in order", - input: input({ - subshellName: "all", - mcp: { fileContent: "{}", args: ["--mcp-config", "/tmp/x.json"] }, - harnessSession: { id: SESSION, mode: "resume" }, - preset: { ...BLANK, flags: ["--model", "opus"], settings: { permissionMode: "acceptEdits" } }, - extraFlags: ["--verbose"], - }), - }, -]; - -/** The extracted package, loaded exactly the way the agent will load it. */ -async function loadExtracted(): Promise { - const dir = join(import.meta.dir, "..", "..", "..", "plugins", "claude-code"); - const result = await createInProcessRuntime().load(dir); - if ("error" in result) throw new Error(`the extracted plugin failed to load: ${result.error}`); - // Every plugin in this file is a harness by manifest; the loader has already - // proved the object matches its type, so this narrows rather than assumes. - return result.plugin as SubshellPlugin; -} - -describe("claude-code parity", () => { - for (const c of CASES) { - it(`builds the same argv: ${c.name}`, async () => { - const legacy = new ClaudeCodePlugin().buildCommand(c.input); - const loaded = await loadExtracted(); - expect(normalizeHookCommands(loaded.buildCommand(c.input))).toEqual(normalizeHookCommands(legacy)); - }); - } - - it("renders the same MCP registration", async () => { - const launch = { command: "/bin/subshell-server", args: ["mcp"] }; - const legacy = new ClaudeCodePlugin().mcpRegistration(launch, "/tmp/mcp.json"); - const loaded = await loadExtracted(); - expect(loaded.mcpRegistration?.(launch, "/tmp/mcp.json")).toEqual(legacy); - }); - - it("describes MCP setup the same way", async () => { - const launch = { command: "/bin/subshell-server", args: ["mcp"] }; - const loaded = await loadExtracted(); - expect(loaded.mcpSetup?.(launch)).toEqual(new ClaudeCodePlugin().mcpSetup(launch)); - }); - - it("maps the same exit codes", async () => { - const legacy = new ClaudeCodePlugin(); - const loaded = await loadExtracted(); - for (const code of [0, 1, 5, 10, 11, 137]) { - expect(loaded.exitStatus?.(code) ?? null).toEqual(legacy.exitStatus(code)); - } - }); - - it("offers the same preset-editor reference data", async () => { - const legacy = new ClaudeCodePlugin(); - const loaded = await loadExtracted(); - expect(loaded.presetSettings?.()).toEqual(legacy.settingsFields()); - expect(loaded.suggestedEnv?.()).toEqual(legacy.suggestedEnv()); - expect(loaded.suggestedFlags?.()).toEqual(legacy.suggestedFlags()); - }); - - it("computes the transcript path the legacy probe checks (the resume contract's inversion)", async () => { - // Spec 2026-09-10 §5 moved the resume from node-local I/O to a PURE path - // computation the host stats. Parity now means: the path the extracted - // `resumePath` computes IS the path the legacy `canResume` existsSync'd, - // for the same environment. Planting a real transcript at the - // legacy-computed location and requiring the extracted path to land on - // the same file is what catches either side's slug/config-dir drifting — - // a bare both-false comparison would pass on any two mismatched paths. - const loaded = await loadExtracted(); - const resume = loaded.resume; - if (!resume) throw new Error("the extracted claude-code lost its resume member"); - const id = resume.allocateHarnessSessionId(); - expect(id).toMatch(/^[0-9a-f-]{36}$/); - - const cfg = mkdtempSync(join(tmpdir(), "subshell-parity-cfg-")); - const saved = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = cfg; // what the LEGACY class reads from the environment - try { - const computed = resume.resumePath(id, "/tmp/work", { homeDir: homedir(), env: { CLAUDE_CONFIG_DIR: cfg } }); - // Fresh id: the file is not there, and the legacy probe says the same. - expect(existsSync(computed)).toBe(false); - expect(new ClaudeCodePlugin().resume.canResume(id, "/tmp/work")).toBe(false); - - // Plant the transcript where the OLD implementation looked, and the new - // pure computation must name exactly that file. - mkdirSync(join(cfg, "projects", "-tmp-work"), { recursive: true }); - writeFileSync(join(cfg, "projects", "-tmp-work", `${id}.jsonl`), "{}"); - expect(computed).toBe(join(cfg, "projects", "-tmp-work", `${id}.jsonl`)); - expect(existsSync(computed)).toBe(true); - expect(new ClaudeCodePlugin().resume.canResume(id, "/tmp/work")).toBe(true); - // The same id in another project dir: neither side finds it there. - expect( - existsSync(resume.resumePath(id, "/tmp/other", { homeDir: homedir(), env: { CLAUDE_CONFIG_DIR: cfg } })), - ).toBe(false); - } finally { - if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR; - else process.env.CLAUDE_CONFIG_DIR = saved; - rmSync(cfg, { recursive: true, force: true }); - } - }); - - it("declares the capabilities it actually implements", async () => { - const loaded = await loadExtracted(); - // claude-code is the only built-in with resume, and the only one with - // native attention hooks. Both must survive the move. - expect(loaded.capabilities().sort()).toEqual(["attention", "mcp", "resume", "settings"]); - expect(loaded.supportsAttentionHooks).toBe(true); - }); -}); - -/* ------------------------------------------------------------------ */ -/* The other four. Same proof, less logic to compare. */ -/* ------------------------------------------------------------------ */ - -/** Cases every one of the four is checked against. */ -const SHARED_CASES: { name: string; input: BuildCommandInput }[] = [ - { name: "bare", input: input({ binary: "/bin/tool" }) }, - { name: "named subshell", input: input({ binary: "/bin/tool", subshellName: "review" }) }, - { - name: "mcp args", - input: input({ binary: "/bin/tool", mcp: { fileContent: "{}", args: ["-c", "x=1"] } }), - }, - { - name: "settings drive flags", - input: input({ - binary: "/bin/tool", - preset: { - ...BLANK, - settings: { - model: "m", - agent: "a", - auto: true, - provider: "p", - toolsets: "t", - sandbox: "s", - askForApproval: "never", - thinking: "high", - }, - }, - }), - }, - { - name: "preset flags are whole tokens", - input: input({ binary: "/bin/tool", preset: { ...BLANK, flags: ["--append", "two words"] } }), - }, - { name: "extra flags come last", input: input({ binary: "/bin/tool", extraFlags: ["--verbose"] }) }, -]; - -/** - * Exactly what a parity comparison reads off the pre-extraction class, and no - * more. - * - * Not `HarnessPlugin`: these are frozen snapshots, so holding them to an - * interface that keeps evolving would mean editing the reference every time - * the live one changes, which defeats the point of freezing it. Optional here - * for the two members hermes and pi never had. - */ -interface LegacyReference { - buildCommand(input: BuildCommandInput): string[]; - mcpSetup(launch: McpLaunchSpec): McpSetupInfo; - mcpRegistration?(launch: McpLaunchSpec, path: string): McpRegistration; - settingsFields(): SettingsField[]; - suggestedEnv(): { key: string; description: string }[]; - suggestedFlags(): { flag: string; description: string }[]; -} - -const OTHERS: { id: string; legacy: () => LegacyReference; caps: PluginCapability[] }[] = [ - { id: "opencode", legacy: () => new OpencodePlugin(), caps: ["mcp", "settings"] }, - { id: "hermes", legacy: () => new HermesPlugin(), caps: ["mcp", "settings"] }, - { id: "pi", legacy: () => new PiPlugin(), caps: ["mcp", "settings"] }, - { id: "codex", legacy: () => new CodexPlugin(), caps: ["mcp", "settings"] }, -]; - -for (const { id, legacy, caps } of OTHERS) { - describe(`${id} parity`, () => { - const load = async (): Promise => { - const result = await createInProcessRuntime().load(join(import.meta.dir, "..", "..", "..", "plugins", id)); - if ("error" in result) throw new Error(`${id} failed to load: ${result.error}`); - return result.plugin as SubshellPlugin; - }; - - for (const c of SHARED_CASES) { - it(`builds the same argv: ${c.name}`, async () => { - expect((await load()).buildCommand(c.input)).toEqual(legacy().buildCommand(c.input)); - }); - } - - it("renders the same MCP registration and setup", async () => { - const launch = { command: "/bin/subshell-server", args: ["mcp"] }; - const loaded = await load(); - const old = legacy(); - expect(loaded.mcpRegistration?.(launch, "/tmp/mcp.json")).toEqual(old.mcpRegistration?.(launch, "/tmp/mcp.json")); - expect(loaded.mcpSetup?.(launch)).toEqual(old.mcpSetup(launch)); - }); - - it("offers the same preset-editor reference data", async () => { - const loaded = await load(); - const old = legacy(); - expect(loaded.presetSettings?.()).toEqual(old.settingsFields()); - expect(loaded.suggestedEnv?.()).toEqual(old.suggestedEnv()); - expect(loaded.suggestedFlags?.()).toEqual(old.suggestedFlags()); - }); - - it("declares the capabilities it implements", async () => { - expect((await load()).capabilities().sort()).toEqual([...caps].sort()); - }); - }); -} From 421a23814cd1a4150fc234e10899484ce85b2a0d Mon Sep 17 00:00:00 2001 From: theogravity Date: Sun, 27 Sep 2026 14:48:26 -0700 Subject: [PATCH 3/7] fix(pane-runtime): make the mode statement authoritative, and pin it Review round one (075e07b0..5c4da4d5) findings, all addressed: - The preamble is now a FULL statement: all five DECSETs in h or l form per the flag values, emitted whenever every flag answered 0/1. The l forms are what let the pane-poll fallback (no log to tail, whole captures as its only channel) un-announce a mode the app dropped mid-session. An unanswered flag stays SILENT rather than inventing an OFF: a false l delivered by a poll re-send would clear a mode the client learned from live bytes, which is worse than silence. The all-off statement is an idempotent no-op for a reset()-fresh client. - The flag read carries its own 2.5 s per-call deadline: a wedged tmux must cost an attach the statement, not double the command timeout. - Six stub tests pin what nothing pinned before: mapping, order, the all-off statement, silence-on-unknown, degrade-on-failure (the capture still ships byte-identical), the capture's throw contract, and the deadline wiring itself. - paneModePreamble marked @internal (consumed by capturePane only). - The read-order comment states which stale case is kinder instead of claiming a description it cannot honestly give. - docs/ws-attach.md carries a fourth invariant bullet: captures lead with mirrored mode bytes, they are server-injected knowledge not pane output, they must not be "restored" away, and "" means unknown. Co-Authored-By: Claude Code --- apps/server/api/docs/ws-attach.md | 20 +++++ .../src/__tests__/tmux-runner.test.ts | 87 +++++++++++++++++++ packages/pane-runtime/src/tmux-runner.ts | 74 +++++++++------- 3 files changed, 149 insertions(+), 32 deletions(-) diff --git a/apps/server/api/docs/ws-attach.md b/apps/server/api/docs/ws-attach.md index 0bf29248e..3c19aedfa 100644 --- a/apps/server/api/docs/ws-attach.md +++ b/apps/server/api/docs/ws-attach.md @@ -124,6 +124,26 @@ Three invariants on that path are load-bearing and easy to regress: off by whatever sat between the pane's cursor row and the bottom of the grid (up to a full screen), the 2026-09-23 "prompt at the top, typing off-screen" report. +- The capture result **leads with a mirrored mode statement** + (`@internal/pane-runtime` `TmuxRunner.paneModePreamble`, prepended by + `capturePane`): all five alt-screen/mouse DECSETs (`?1049`, `?1000`, + `?1002`, `?1003`, `?1006`) in their `h` or `l` form per the pane app's + live tmux flags, read via `display-message`. `capture-pane` re-emits no + DECSET and the live join starts after the app announced them, so without + the mirror the client's xterm never learns the app owns the mouse and + every wheel notch fires xterm's passive-wheel fallback, one arrow key + through the socket (the 2026-09-27 "slow scroll, no scrollbar" report). + The `l` forms matter: the pane-poll fallback re-sends whole captures as + its only channel, and only a full statement can un-announce a mode the + app dropped mid-session. They ride the front of the `replay` frame: no + LF, no 2026 marker, so the capture-text pipeline lets them through and + the byte join is untouched, because they are server-injected knowledge, + not pane output. Do NOT "restore" a capture to pure `capture-pane` + bytes; that is exactly the hole they fill. A `""` preamble means + UNKNOWN, not "all off": an old agent binary, a wedged tmux (the read + fails fast, 2.5 s), or a tmux that could not answer a flag all stay + silent rather than inventing an OFF that a poll re-send could deliver as + a false clearing of live-learned state. ## Node presence is an announcement too (moved from AGENTS.md "Architecture") diff --git a/packages/pane-runtime/src/__tests__/tmux-runner.test.ts b/packages/pane-runtime/src/__tests__/tmux-runner.test.ts index b4cc673ee..b591370bf 100644 --- a/packages/pane-runtime/src/__tests__/tmux-runner.test.ts +++ b/packages/pane-runtime/src/__tests__/tmux-runner.test.ts @@ -981,6 +981,93 @@ echo "server exited unexpectedly" >&2; exit 1 } }); }); + + describe("capturePane leads with an authoritative mode statement", () => { + /** + * A tmux stub that answers exactly the two commands `capturePane` issues + * (`display-message` for the mode flags, `capture-pane` for the grid), + * each body deciding that command's stdout and exit. `echo` adds the + * trailing newline real tmux adds; the preamble reads trims it. + */ + function modeStub(displayBody: string, captureBody: string): { dir: string; path: string } { + const dir = mkdtempSync(join(tmpdir(), "subshell-mode-")); + const path = join(dir, "tmux-stub"); + writeFileSync( + path, + "#!/bin/sh\n" + + 'for a in "$@"; do\n' + + ` if [ "$a" = "display-message" ]; then ${displayBody} fi\n` + + ` if [ "$a" = "capture-pane" ]; then ${captureBody} fi\n` + + "done\nexit 0\n", + { mode: 0o755 }, + ); + return { dir, path }; + } + const answer = (flags: string) => `echo '${flags}'; exit 0;`; + const GRID = "echo 'grid'; exit 0;"; + const run = async (displayBody: string, captureBody: string) => { + const { dir, path } = modeStub(displayBody, captureBody); + try { + return await new TmuxRunner(path).capturePane("sock", "s1"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + + it("states all five modes, in order, as h or l per flag", async () => { + // The mapping was verified live on tmux 3.7c; this is the pin that + // keeps a refactor of capturePane from dropping the statement or + // reordering it. `1:0:0:1:1` = alt ON, standard OFF, drag OFF, + // any-motion ON, SGR ON: exactly what a Claude Code 2.1.283 pane + // measured. + expect(await run(answer("1:0:0:1:1"), GRID)).toBe( + "\x1b[?1049h\x1b[?1000l\x1b[?1002l\x1b[?1003h\x1b[?1006hgrid\n", + ); + }); + + it("answers an all-off pane too: the l forms are the point", async () => { + // Silence here would strand a poll-path client in a mode the app has + // dropped; the full statement is idempotent no-ops for a fresh one. + expect(await run(answer("0:0:0:0:0"), GRID)).toBe( + "\x1b[?1049l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006lgrid\n", + ); + }); + + it("stays SILENT, not all-off, when a flag did not answer", async () => { + // A tmux too old for one variable yields an empty token; inventing an + // OFF there would let a poll re-send CLEAR a mode the client learned + // from live bytes. Unknown must mean no statement at all. + expect(await run(answer("1::0:1:1"), GRID)).toBe("grid\n"); + expect(await run(answer("garbage"), GRID)).toBe("grid\n"); + }); + + it("a display-message failure costs the statement, never the capture", async () => { + // The capture contract stands unchanged: tmux ANSWERING "no" (or + // nothing usable) about modes still ships the grid byte-for-byte. + expect(await run("echo 'no pane' >&2; exit 1;", GRID)).toBe("grid\n"); + }); + + it("capture-pane still throws when IT fails", async () => { + // paneModePreamble swallows; capturePane must not — captureStable's + // null handling and the booting attach's 4004 refusal read that throw. + await expect(run(answer("0:0:0:0:0"), "echo 'no server' >&2; exit 1;")).rejects.toThrow(/no server/); + }); + + it("gives up on the mode read at its own 2.5 s deadline", async () => { + // The hardcoded cap is production wiring, same class as the + // TMUX_COMMAND_TIMEOUT_MS pin above: the preamble must never double + // an attach's wait on a wedged tmux. The stub answers modes only after + // 5 s; the run must end on the 2.5 s cap, silent, with the capture + // delivered. + const started = Date.now(); + const out = await run("sleep 5; echo '0:0:0:0:0'; exit 0;", GRID); + const elapsed = Date.now() - started; + expect(out).toBe("grid\n"); + expect(elapsed).toBeGreaterThanOrEqual(2_400); + expect(elapsed).toBeLessThan(4_900); + }); + }); + /** * `remain-on-exit` makes a finished pane OBSERVABLE (spec 2026-09-19 §4.3), * and that changes what `has-session` means — so `hasSubshell` must not use diff --git a/packages/pane-runtime/src/tmux-runner.ts b/packages/pane-runtime/src/tmux-runner.ts index 0d4d83e1e..f83d6210e 100644 --- a/packages/pane-runtime/src/tmux-runner.ts +++ b/packages/pane-runtime/src/tmux-runner.ts @@ -742,8 +742,9 @@ export class TmuxRunner { } /** - * The pane app's CURRENT input modes, as the DECSET bytes that announce - * them, for {@link capturePane} to lead its result with. + * The pane app's CURRENT input modes, as an AUTHORITATIVE DECSET + * statement (every mode's `h` or `l` form), for {@link capturePane} to + * lead its result with. `""` means "unknown", never "all off". * * **Why this exists** (measured 2026-09-27). The browser attach writes * `term.reset()` and then a replay built from `capture-pane`, but @@ -759,20 +760,26 @@ export class TmuxRunner { * every capture ships the knowledge it would have carried. * * The five flags come from ONE `display-message` read, mapped live on tmux - * 3.7c: `alternate_on` → `?1049h`, `mouse_standard_flag` → `?1000h`, - * `mouse_button_flag` → `?1002h`, `mouse_all_flag` → `?1003h`, - * `mouse_sgr_flag` → `?1006h`. Only the modes that are ON are emitted, in - * that order, with no separator. - * - * ANY failure degrades to `""` and never throws: a gone socket, a tmux too - * old to resolve a variable (its token comes back empty or literal), or - * otherwise unparseable output all mean "modes unknown", which is simply - * today's behavior. Losing the decoration is no reason to lose the capture - * it decorates. + * 3.7c: `alternate_on` (1049), `mouse_standard_flag` (1000), + * `mouse_button_flag` (1002), `mouse_all_flag` (1003), `mouse_sgr_flag` + * (1006), always all five emitted in that order. The `l` forms are not + * padding: the pane-poll fallback (a pane with no readable log) re-sends + * whole captures as its only channel, and only a FULL statement can + * un-announce a mode the app dropped mid-session. For a `reset()`-fresh + * client every `l` is an idempotent no-op, so all-off costs nothing. + * + * The statement is made only when every flag ANSWERED (`0` or `1`); any + * failure degrades to `""` and never throws (gone socket, the 2.5 s cap, a + * tmux too old to resolve a variable, any unparseable output). An + * unanswered flag must not become an invented OFF: mid-session, a false + * `l` delivered by a poll re-send would CLEAR a mode the client had + * learned from live bytes, which is strictly worse than saying nothing. + * Losing the statement is no reason to lose the capture it decorates. * * A mode that flips between this read and the capture is cosmetic: the * live byte stream re-announces DECSETs as the app's own bytes, so the * next mode change the app makes corrects the client. + * @internal consumed by {@link capturePane}; the stub suite pins it there. */ async paneModePreamble(socket: string, subshellName: string): Promise { try { @@ -787,20 +794,20 @@ export class TmuxRunner { "-p", "#{alternate_on}:#{mouse_standard_flag}:#{mouse_button_flag}:#{mouse_all_flag}:#{mouse_sgr_flag}", ], - {}, + // Fail FAST, not like the 15 s default: the contract is + // degrade-to-empty, so a wedged tmux must cost an attach the + // statement, never a doubled command timeout on the way to a + // refusal it would have taken anyway. + { timeoutMs: 2_500 }, ) ).stdout.trim(); - const [alternate, standard, button, all, sgr] = out.split(":"); - // `=== "1"` throughout: an empty token (a tmux too old for the - // variable), a literal one (a format that did not resolve) or anything - // else that is not `1` is not evidence a mode is ON. - let preamble = ""; - if (alternate === "1") preamble += "\x1b[?1049h"; - if (standard === "1") preamble += "\x1b[?1000h"; - if (button === "1") preamble += "\x1b[?1002h"; - if (all === "1") preamble += "\x1b[?1003h"; - if (sgr === "1") preamble += "\x1b[?1006h"; - return preamble; + // Order is part of the contract: mode number per position, matching the + // format string above. `entries`-style zipping reads worse for five + // fixed tokens; the guard below is what makes the indexing total. + const tokens = out.split(":"); + if (tokens.length !== 5 || tokens.some((t) => t !== "0" && t !== "1")) return ""; + const decsets = [1049, 1000, 1002, 1003, 1006]; + return decsets.map((n, i) => `\x1b[?${n}${tokens[i] === "1" ? "h" : "l"}`).join(""); } catch { return ""; } @@ -815,18 +822,21 @@ export class TmuxRunner { * at ANY geometry. Without it, only the visible grid is captured (preview/ * settle callers never wanted history bytes). * - * The result LEADS with {@link paneModePreamble}. Those bytes carry no LF, - * so they ride the front of row 1 through the capture→terminal CRLF - * normalization untouched; a consumer that strips ANSI sees the plain grid, - * and a browser terminal learns the modes the pane's app already owns. + * The result LEADS with {@link paneModePreamble}: the full statement of + * the pane app's input modes, or silence when tmux could not answer. Those + * bytes carry no LF, so they ride the front of row 1 through the + * capture→terminal CRLF normalization untouched, and a consumer that + * strips ANSI still sees the plain grid. */ async capturePane(socket: string, subshellName: string, scrollbackLines?: number): Promise { const args = ["-L", socket, "capture-pane", "-p", "-e", "-t", subshellName]; if (scrollbackLines && scrollbackLines > 0) args.push("-S", `-${scrollbackLines}`); - // The modes are read BEFORE the capture, not concurrently with it: the - // replay's preamble must describe the basis the captured grid was drawn - // on, and a flip inside the gap between the two reads is covered by the - // live join re-announcing DECSETs as app bytes ({@link paneModePreamble}). + // The modes are read BEFORE the capture because that is the kinder stale + // case, not an honest one: a flip inside the gap is re-announced by the + // live join either way (the gap sits above the join point), and read- + // before means the transient can only strand a grid in the client's + // scrollable primary buffer, where a later ?1049h still finds its history + // ({@link paneModePreamble}). const preamble = await this.paneModePreamble(socket, subshellName); const res = await this.runAsync(args, {}); return preamble + res.stdout; From d706c3c8ecadf5153704812f9349b13d2276f751 Mon Sep 17 00:00:00 2001 From: theogravity Date: Sun, 27 Sep 2026 15:02:52 -0700 Subject: [PATCH 4/7] fix(web): consume the private CSIs that captures now lead with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-two review found a Critical the branch itself introduced: the SPA preview parser's CSI grammar is `[0-9;]`-only (its premise: the server sends styling only), so a private-parameter CSI like `?1049h` does not match and falls through as VISIBLE JUNK at the head of every preview card, at every feed tick. The premise was true until this branch; the mode statement broke it. Reproduced, then fixed: the grammar is now the full ECMA-48 CSI form (private-range parameter starts, `:` sub-separators, intermediates, final byte), and SGR applies only for a plain `m` final with no private start and no intermediates. Four new ansi.test.ts cases: the statement consumed, SGR still applied after it, a private `?…m` not styled, cursor addressing consumed. Review Minors, same pass: - The mixed-flag stub vector is now adjacent-different (`1:0:1:0:1`): a swap of two format variables or two decsets entries can no longer pass the suite silently. A `01` token joins the silence vectors, pinning strict "0"/"1" equality over truthiness. - ws-attach.md and apps/server/api/AGENTS.md said "three invariants" while four now stand; AGENTS.md is what every agent session auto-loads, so the enumeration names the mode statement there too. (The next "restore pure capture-pane bytes" refactor starts here.) - Comment typo and three em dashes in this branch's added prose removed (operator ruling: none in authored prose anywhere; lint:prose only sees .md, so they needed the eye). Co-Authored-By: Claude Code --- apps/server/api/AGENTS.md | 7 +++- apps/server/api/docs/ws-attach.md | 2 +- .../server/web/src/lib/__tests__/ansi.test.ts | 28 +++++++++++++ apps/server/web/src/lib/ansi.ts | 40 +++++++++++++------ .../src/__tests__/tmux-runner.test.ts | 30 ++++++++------ 5 files changed, 78 insertions(+), 29 deletions(-) diff --git a/apps/server/api/AGENTS.md b/apps/server/api/AGENTS.md index 5d5290a13..968e0141f 100644 --- a/apps/server/api/AGENTS.md +++ b/apps/server/api/AGENTS.md @@ -275,9 +275,12 @@ are load-bearing and easy to regress: capture-text LF normalization gap-free OVERLAPPING join (a skipped byte desynchronizes a diff-rendering TUI permanently; the overlap's visible transient is deliberate damage control, and the zero-overlap "quiet join" was tried -and rolled back), and the replay's MISSING trailing terminator plus the +and rolled back), the replay's MISSING trailing terminator plus the absolute-cursor restore (without it, every later relative-positioned -frame lands on the wrong rows). `SUBSHELL_ATTACH_DEBUG=1` dumps real +frame lands on the wrong rows), and the mode statement every capture +LEADS with (the pane app's alt-screen/mouse DECSETs, authoritative h/l +per live tmux flags; `""` means UNKNOWN, never "all off"; +`TmuxRunner.paneModePreamble`). `SUBSHELL_ATTACH_DEBUG=1` dumps real screen contents to world-readable files: off by default, never swept. **Read `apps/server/api/docs/ws-attach.md` first: the full invariants, diff --git a/apps/server/api/docs/ws-attach.md b/apps/server/api/docs/ws-attach.md index 3c19aedfa..32ed08db0 100644 --- a/apps/server/api/docs/ws-attach.md +++ b/apps/server/api/docs/ws-attach.md @@ -84,7 +84,7 @@ attach, both under `journalctl --user -u subshell-server.service | grep "ws atta grid as the viewer found it vs. the exact bytes sent. **Off by default: the dumps are real screen contents, which can include secrets.** -Three invariants on that path are load-bearing and easy to regress: +Four invariants on that path are load-bearing and easy to regress: - Capture text (`replay`, pane-poll deltas) goes through `ws/capture-text.ts`; `capture-pane -p` emits **bare LFs**, and a bare LF diff --git a/apps/server/web/src/lib/__tests__/ansi.test.ts b/apps/server/web/src/lib/__tests__/ansi.test.ts index e0f1273a0..8138a3576 100644 --- a/apps/server/web/src/lib/__tests__/ansi.test.ts +++ b/apps/server/web/src/lib/__tests__/ansi.test.ts @@ -17,6 +17,34 @@ describe("parseAnsi", () => { expect(parseAnsi("[INFO] ready [1]")).toEqual([[{ text: "[INFO] ready [1]" }]]); }); + /** + * Every capture has led with the pane's input-mode statement since + * 2026-09-27 (pane-runtime's `paneModePreamble`): private-parameter CSIs + * (`?1049h` class) that a `[0-9;]`-only grammar renders as visible junk + * at the head of every preview card. This grammar is the regression fix. + */ + const MODE_STATEMENT = "\x1b[?1049h\x1b[?1000l\x1b[?1002l\x1b[?1003h\x1b[?1006h"; + + it("consumes the capture's leading mode statement", () => { + expect(parseAnsi(`${MODE_STATEMENT}grid line`)).toEqual([[{ text: "grid line" }]]); + }); + + it("still applies SGR that follows the statement", () => { + const [line] = parseAnsi(`${MODE_STATEMENT}${sgr("1")}bold${sgr("0")} plain`); + expect(line).toEqual([{ text: "bold", bold: true }, { text: " plain" }]); + }); + + it("consumes a private-prefixed CSI that ends in m instead of styling it", () => { + // DEC private sequences can share the `m` final byte; only plain SGR + // may change styling. + const [line] = parseAnsi(`a${sgr("?1")}b`); + expect(line).toEqual([{ text: "a" }, { text: "b" }]); + }); + + it("consumes cursor addressing too, though capture -e never emits it", () => { + expect(parseAnsi("a\x1b[34;1Hb")).toEqual([[{ text: "a" }, { text: "b" }]]); + }); + it("splits a line into styled runs", () => { const [line] = parseAnsi(`plain ${sgr("31")}red${sgr("0")} plain`); expect(line).toEqual([{ text: "plain " }, { text: "red", color: "#cd3131" }, { text: " plain" }]); diff --git a/apps/server/web/src/lib/ansi.ts b/apps/server/web/src/lib/ansi.ts index fa6832712..2ad191cd6 100644 --- a/apps/server/web/src/lib/ansi.ts +++ b/apps/server/web/src/lib/ansi.ts @@ -1,13 +1,17 @@ /** * Minimal ANSI SGR parser for terminal previews. * - * Scoped deliberately to SGR (colour and attribute) sequences, because that - * is all the server sends: previews come from `tmux capture-pane -p -e`, - * which renders the pane's *current screen* and emits styling only — never - * cursor movement, scroll regions or erases. A general terminal emulator - * would be the wrong tool here; xterm already fills that role on the pages - * that need a real terminal, at a cost (a socket and a WebGL context each) - * that a wall of preview cards cannot pay. + * Previews come from `tmux capture-pane -p -e`, which renders the pane's + * *current screen* in styling; since 2026-09-27 the capture result also + * LEADS with the pane's input-mode statement (`?1049h`-class private + * sequences, pane-runtime's `paneModePreamble`); still no cursor movement, + * scroll regions or erases, but NOT styling-only. So this parser applies + * SGR and CONSUMES everything else: any CSI it failed to recognize would + * surface as visible junk in every card, which is why the grammar is the + * full ECMA-48 form and not just `[0-9;]` parameters. A general + * terminal emulator would still be the wrong tool here; xterm already + * fills that role on the pages that need a real terminal, at a cost (a + * socket and a WebGL context each) that a wall of preview cards cannot pay. */ /** One run of text sharing the same styling. */ @@ -194,11 +198,17 @@ function spanFrom(state: SgrState, text: string): AnsiSpan { } /** - * Matches any CSI sequence. Only the SGR ones (final byte `m`) change - * styling; the rest are dropped so their bytes never reach the page. + * Matches any CSI sequence in its full ECMA-48 grammar: parameter bytes + * (including the private-range `?` through `/` starts and `:` + * sub-separators), intermediate bytes, final byte. Only plain SGR (final + * `m`, no private start, no intermediates) changes styling; everything + * else is CONSUMED so its bytes never reach the page, including the mode + * statement every capture now leads with, whose `?1049h`-class sequences + * a `[0-9;]*`-only grammar would let through as visible junk (the + * 2026-09-27 regression this grammar closes). */ // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the sequence being matched, not a stray byte -const CSI = /\x1b\[([0-9;]*)([a-zA-Z])/g; +const CSI = /\x1b\[([0-9:;<=>?]*)([ -/]*)([@-~])/g; /** * Parses a captured screen into styled spans, one array per line. @@ -216,10 +226,14 @@ export function parseAnsi(screen: string): AnsiSpan[][] { let match = CSI.exec(line); while (match !== null) { if (match.index > last) spans.push(spanFrom(state, line.slice(last, match.index))); - if (match[2] === "m") { + const params = match[1]; + // Private (`?`, `<`, `=`, `>`) and intermediates-disambiguated CSIs + // share `m` final bytes with SGR (DECSTR `?…$p` etc. never reach + // here, but a `CSI ? … m` must not masquerade as a style change). + if (match[3] === "m" && match[2] === "" && !/^[<=>?]/.test(params)) { // An empty parameter list means SGR 0 (`ESC[m` is a reset). - const params = match[1] === "" ? [0] : match[1].split(";").map((n) => Number.parseInt(n, 10) || 0); - applySgr(state, params); + const parsed = params === "" ? [0] : params.split(";").map((n) => Number.parseInt(n, 10) || 0); + applySgr(state, parsed); } last = match.index + match[0].length; match = CSI.exec(line); diff --git a/packages/pane-runtime/src/__tests__/tmux-runner.test.ts b/packages/pane-runtime/src/__tests__/tmux-runner.test.ts index b591370bf..bb0e54a2f 100644 --- a/packages/pane-runtime/src/__tests__/tmux-runner.test.ts +++ b/packages/pane-runtime/src/__tests__/tmux-runner.test.ts @@ -987,7 +987,7 @@ echo "server exited unexpectedly" >&2; exit 1 * A tmux stub that answers exactly the two commands `capturePane` issues * (`display-message` for the mode flags, `capture-pane` for the grid), * each body deciding that command's stdout and exit. `echo` adds the - * trailing newline real tmux adds; the preamble reads trims it. + * trailing newline real tmux adds, which the preamble's read trims. */ function modeStub(displayBody: string, captureBody: string): { dir: string; path: string } { const dir = mkdtempSync(join(tmpdir(), "subshell-mode-")); @@ -1015,13 +1015,13 @@ echo "server exited unexpectedly" >&2; exit 1 }; it("states all five modes, in order, as h or l per flag", async () => { - // The mapping was verified live on tmux 3.7c; this is the pin that - // keeps a refactor of capturePane from dropping the statement or - // reordering it. `1:0:0:1:1` = alt ON, standard OFF, drag OFF, - // any-motion ON, SGR ON: exactly what a Claude Code 2.1.283 pane - // measured. - expect(await run(answer("1:0:0:1:1"), GRID)).toBe( - "\x1b[?1049h\x1b[?1000l\x1b[?1002l\x1b[?1003h\x1b[?1006hgrid\n", + // The mapping was verified live on tmux 3.7c. `1:0:1:0:1` is chosen + // so EVERY adjacent pair differs: any swap of two flags in the + // format string, or of two entries in the decsets array, changes + // this exact string (a `1:0:0:1:1` vector, the shape a Claude Code + // 2.1.283 pane measured, cannot see a standard/button swap). + expect(await run(answer("1:0:1:0:1"), GRID)).toBe( + "\x1b[?1049h\x1b[?1000l\x1b[?1002h\x1b[?1003l\x1b[?1006hgrid\n", ); }); @@ -1036,20 +1036,24 @@ echo "server exited unexpectedly" >&2; exit 1 it("stays SILENT, not all-off, when a flag did not answer", async () => { // A tmux too old for one variable yields an empty token; inventing an // OFF there would let a poll re-send CLEAR a mode the client learned - // from live bytes. Unknown must mean no statement at all. + // from live bytes. Unknown must mean no statement at all. `"01"` + // pins strict equality over truthiness: a `tokens[i] &&`-style + // refactor would read it as ON. expect(await run(answer("1::0:1:1"), GRID)).toBe("grid\n"); + expect(await run(answer("1:01:0:1:1"), GRID)).toBe("grid\n"); expect(await run(answer("garbage"), GRID)).toBe("grid\n"); }); it("a display-message failure costs the statement, never the capture", async () => { - // The capture contract stands unchanged: tmux ANSWERING "no" (or - // nothing usable) about modes still ships the grid byte-for-byte. + // tmux ANSWERING "no" (or nothing usable) about modes still ships the + // grid byte-for-byte: losing the decoration is no reason to lose the + // capture it decorates. expect(await run("echo 'no pane' >&2; exit 1;", GRID)).toBe("grid\n"); }); it("capture-pane still throws when IT fails", async () => { - // paneModePreamble swallows; capturePane must not — captureStable's - // null handling and the booting attach's 4004 refusal read that throw. + // paneModePreamble swallows; capturePane must not. captureStable's + // null handling and the booting attach's 4004 refusal read the throw. await expect(run(answer("0:0:0:0:0"), "echo 'no server' >&2; exit 1;")).rejects.toThrow(/no server/); }); From e5b368f6b49c43f2082d89be1c42321631288319 Mon Sep 17 00:00:00 2001 From: theogravity Date: Sun, 27 Sep 2026 15:10:58 -0700 Subject: [PATCH 5/7] test(pane-runtime,web): make the two coverage claims true Round-four review proved by mutation that two of this branch's tests could not see what their comments claimed: - modeStub answers without reading the format string, so the adjacent-different vector pinned only the DECODE side; a swapped pair of format variables stayed green. The stub now records argv when asked, and a new test pins the display-message format string verbatim (the pipePane argv tests' shape). Query side and decode side each have their own pin, and the comments say exactly that, with the vector's rationale corrected. - the private-`m` gate test ran with no active style, so deleting the gate (parsing `?1` as SGR 0, resetting an already-empty state) kept it green. The case now asserts bold SURVIVES the `?1`, which the deletion breaks. Both fixes mutation-verified before this commit: format-swap red, gate-removal red, throwaway worktrees removed. The paneModePreamble @internal line, which claimed a pin the suite did not yet have, tells one truth now. Co-Authored-By: Claude Code --- .../server/web/src/lib/__tests__/ansi.test.ts | 13 ++++-- .../src/__tests__/tmux-runner.test.ts | 43 ++++++++++++++++--- packages/pane-runtime/src/tmux-runner.ts | 4 +- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/apps/server/web/src/lib/__tests__/ansi.test.ts b/apps/server/web/src/lib/__tests__/ansi.test.ts index 8138a3576..5a32976d8 100644 --- a/apps/server/web/src/lib/__tests__/ansi.test.ts +++ b/apps/server/web/src/lib/__tests__/ansi.test.ts @@ -34,11 +34,16 @@ describe("parseAnsi", () => { expect(line).toEqual([{ text: "bold", bold: true }, { text: " plain" }]); }); - it("consumes a private-prefixed CSI that ends in m instead of styling it", () => { + it("a private-prefixed CSI ending in m neither styles nor resets", () => { // DEC private sequences can share the `m` final byte; only plain SGR - // may change styling. - const [line] = parseAnsi(`a${sgr("?1")}b`); - expect(line).toEqual([{ text: "a" }, { text: "b" }]); + // may change styling. Bold must SURVIVE the `?1`: without the + // private gate the sequence parses as `parseInt("?1") || 0` = SGR 0 + // and resets, an unstyled version of this case could not tell. + const [line] = parseAnsi(`${sgr("1")}a${sgr("?1")}b`); + expect(line).toEqual([ + { text: "a", bold: true }, + { text: "b", bold: true }, + ]); }); it("consumes cursor addressing too, though capture -e never emits it", () => { diff --git a/packages/pane-runtime/src/__tests__/tmux-runner.test.ts b/packages/pane-runtime/src/__tests__/tmux-runner.test.ts index bb0e54a2f..7e053641a 100644 --- a/packages/pane-runtime/src/__tests__/tmux-runner.test.ts +++ b/packages/pane-runtime/src/__tests__/tmux-runner.test.ts @@ -989,14 +989,15 @@ echo "server exited unexpectedly" >&2; exit 1 * each body deciding that command's stdout and exit. `echo` adds the * trailing newline real tmux adds, which the preamble's read trims. */ - function modeStub(displayBody: string, captureBody: string): { dir: string; path: string } { + function modeStub(displayBody: string, captureBody: string, argvPath?: string): { dir: string; path: string } { const dir = mkdtempSync(join(tmpdir(), "subshell-mode-")); const path = join(dir, "tmux-stub"); + const record = argvPath ? `printf '%s\\n' "$@" >> '${argvPath}'; ` : ""; writeFileSync( path, "#!/bin/sh\n" + 'for a in "$@"; do\n' + - ` if [ "$a" = "display-message" ]; then ${displayBody} fi\n` + + ` if [ "$a" = "display-message" ]; then ${record}${displayBody} fi\n` + ` if [ "$a" = "capture-pane" ]; then ${captureBody} fi\n` + "done\nexit 0\n", { mode: 0o755 }, @@ -1015,16 +1016,44 @@ echo "server exited unexpectedly" >&2; exit 1 }; it("states all five modes, in order, as h or l per flag", async () => { - // The mapping was verified live on tmux 3.7c. `1:0:1:0:1` is chosen - // so EVERY adjacent pair differs: any swap of two flags in the - // format string, or of two entries in the decsets array, changes - // this exact string (a `1:0:0:1:1` vector, the shape a Claude Code - // 2.1.283 pane measured, cannot see a standard/button swap). + // The mapping was verified live on tmux 3.7c. This vector pins the + // DECODE side: `1:0:1:0:1` has every adjacent pair different, so a + // swap of two decsets entries or an off-by-one in the positional + // read changes the exact string (`1:0:0:1:1`, the shape a Claude + // Code 2.1.283 pane measured, could not see a standard/button + // swap). The stub answers without reading the format string, so + // the QUERY side is the verbatim argv pin that follows. expect(await run(answer("1:0:1:0:1"), GRID)).toBe( "\x1b[?1049h\x1b[?1000l\x1b[?1002h\x1b[?1003l\x1b[?1006hgrid\n", ); }); + it("queries the flags in the decoded order: the format string, verbatim", async () => { + // Tokens are read POSITIONALLY, so the order of the variables in + // the format string is part of the contract, and the stub cannot + // catch its reordering: a swapped or "alphabetized" pair would + // misannounce 1000/1002/1003 to every client with every other test + // green (precedent in this file: the tmux 3.4 format-variable + // history). Pinned argv, the pipePane tests' shape. + const marks = join(tmpdir(), `subshell-mode-argv-${process.pid}-${Date.now()}.txt`); + const { dir, path } = modeStub(answer("0:0:0:0:0"), GRID, marks); + try { + await new TmuxRunner(path).capturePane("sock", "s1"); + expect((await Bun.file(marks).text()).trim().split("\n")).toEqual([ + "-L", + "sock", + "display-message", + "-t", + "s1", + "-p", + "#{alternate_on}:#{mouse_standard_flag}:#{mouse_button_flag}:#{mouse_all_flag}:#{mouse_sgr_flag}", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + rmSync(marks, { force: true }); + } + }); + it("answers an all-off pane too: the l forms are the point", async () => { // Silence here would strand a poll-path client in a mode the app has // dropped; the full statement is idempotent no-ops for a fresh one. diff --git a/packages/pane-runtime/src/tmux-runner.ts b/packages/pane-runtime/src/tmux-runner.ts index f83d6210e..50940bd17 100644 --- a/packages/pane-runtime/src/tmux-runner.ts +++ b/packages/pane-runtime/src/tmux-runner.ts @@ -779,7 +779,9 @@ export class TmuxRunner { * A mode that flips between this read and the capture is cosmetic: the * live byte stream re-announces DECSETs as the app's own bytes, so the * next mode change the app makes corrects the client. - * @internal consumed by {@link capturePane}; the stub suite pins it there. + * @internal consumed by {@link capturePane}; the stub suite pins its + * decode, its silence and its deadline there, and the verbatim argv pin + * holds the flag query. */ async paneModePreamble(socket: string, subshellName: string): Promise { try { From 3ef4e9d14b42b8575aa14812afc220ffaab6801f Mon Sep 17 00:00:00 2001 From: theogravity Date: Sun, 27 Sep 2026 15:19:13 -0700 Subject: [PATCH 6/7] docs: two wording corrections from the round-five review AGENTS.md said "Three invariants" over the four-item enumeration this branch grew (the count sat one line above the edit that added the mode statement, and every agent session auto-loads this file). The argv-pin comment cited in-file precedent for the tmux 3.4 format-variable history that actually lives in tmux-runner.ts's SESSION_LIVENESS_FORMAT narration. Co-Authored-By: Claude Code --- apps/server/api/AGENTS.md | 2 +- packages/pane-runtime/src/__tests__/tmux-runner.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/server/api/AGENTS.md b/apps/server/api/AGENTS.md index 968e0141f..50e4534f4 100644 --- a/apps/server/api/AGENTS.md +++ b/apps/server/api/AGENTS.md @@ -269,7 +269,7 @@ remote pane, so nothing downstream of the attach branches on where a pane runs. A garbled live terminal is diagnosed from the journal first: the `geometry` and `painted` lines under `journalctl --user -u subshell-server.service | grep "ws attach"` say which bundle is talking -and what the pane did before the capture. Three invariants on that path +and what the pane did before the capture. Four invariants on that path are load-bearing and easy to regress: capture-text LF normalization (`ws/capture-text.ts`; the live tail must NOT be normalized), the gap-free OVERLAPPING join (a skipped byte desynchronizes a diff --git a/packages/pane-runtime/src/__tests__/tmux-runner.test.ts b/packages/pane-runtime/src/__tests__/tmux-runner.test.ts index 7e053641a..9ce1dc53b 100644 --- a/packages/pane-runtime/src/__tests__/tmux-runner.test.ts +++ b/packages/pane-runtime/src/__tests__/tmux-runner.test.ts @@ -1033,8 +1033,9 @@ echo "server exited unexpectedly" >&2; exit 1 // the format string is part of the contract, and the stub cannot // catch its reordering: a swapped or "alphabetized" pair would // misannounce 1000/1002/1003 to every client with every other test - // green (precedent in this file: the tmux 3.4 format-variable - // history). Pinned argv, the pipePane tests' shape. + // green (the precedent: the tmux 3.4 format-variable history + // narrated in `SESSION_LIVENESS_FORMAT`, tmux-runner.ts). Pinned + // argv, the pipePane tests' shape. const marks = join(tmpdir(), `subshell-mode-argv-${process.pid}-${Date.now()}.txt`); const { dir, path } = modeStub(answer("0:0:0:0:0"), GRID, marks); try { From f97978252b380c8f650a94ae61f6a06648086762 Mon Sep 17 00:00:00 2001 From: theogravity Date: Sun, 27 Sep 2026 15:24:28 -0700 Subject: [PATCH 7/7] test(server): count only capture-pane in the preview-dedupe stub The mode mirror made every capturePane pay two tmux invocations (the flag read plus the capture); the stub counted every call, so it reported the mode read as a cache miss. The dedupe under test is about the capture, so the counter now guards on capture-pane and keeps its file-counter honesty. Co-Authored-By: Claude Code --- .../subshell-manager.service.test.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts index 808215881..f2f2bd9aa 100644 --- a/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts +++ b/apps/server/api/src/services/__tests__/subshell-manager.service.test.ts @@ -1571,20 +1571,28 @@ describe("reconcile partition — agent rows (spec §6.3)", () => { describe("#preview — local rows read through the cache (cross-consumer dedupe)", () => { /** - * A tmux stub that answers every `capture-pane` with the same two rows and - * counts its invocations by growing a file. Counting through a file rather - * than a closure is what makes the assertion honest: every capture is a - * fresh child process, and the one that captured N times is the one whose - * counter says N, not the one whose JS scope thinks so. + * A tmux stub that answers every call with the same two rows and counts + * `capture-pane` invocations by growing a file. Counting through a file + * rather than a closure is what makes the assertion honest: every capture + * is a fresh child process, and the one that captured N times is the one + * whose counter says N, not the one whose JS scope thinks so. + * + * The counter guards on `capture-pane` because since the mode mirror + * (2026-09-27) every `capturePane` call pays TWO tmux invocations: the + * flag read that leads the grid with the mode statement, and the capture. + * The dedupe this test pins is about the CAPTURE, so that is what gets + * counted; counting every call would report the mode read as a cache miss. */ function captureStub(): { stub: string; captures: () => number; cleanup: () => void } { const dir = mkdtempSync(join(tmpdir(), "subshell-preview-stub-")); const counter = join(dir, "count"); writeFileSync(counter, ""); const stub = join(dir, "tmux-stub"); - writeFileSync(stub, `#!/bin/sh\nprintf x >> "${counter}"\necho "screen line 1"\necho "screen line 2"\n`, { - mode: 0o755, - }); + writeFileSync( + stub, + `#!/bin/sh\ncase "$*" in\n *capture-pane*) printf x >> "${counter}" ;;\nesac\necho "screen line 1"\necho "screen line 2"\n`, + { mode: 0o755 }, + ); return { stub, captures: () => readFileSync(counter, "utf8").length,