From 64deb83c152bf26eda8a0828f1609a533aa6daf3 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:38:20 +0200 Subject: [PATCH 1/4] docs(spec): agent states from Claude Code hooks, design and plan (#131) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- .../specs/2026-10-05-agent-hooks-design.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-05-agent-hooks-design.md diff --git a/docs/superpowers/specs/2026-10-05-agent-hooks-design.md b/docs/superpowers/specs/2026-10-05-agent-hooks-design.md new file mode 100644 index 0000000..b83613c --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-agent-hooks-design.md @@ -0,0 +1,134 @@ +# Agent states from Claude Code hooks (#131): design and plan + +Owner, 2026-10-05. The design dialogue happened in session. The owner chose the small version, Claude only +("go ahead and build that"), with recommendations accepted throughout. Research and spike evidence: +`C:\Users\Admin\Documents\Claude\research\prism-terminal\2026-10-05-agent-hooks-inventory.md` (sections 1-9). + +## Why +Today's indicator infers state from the outside: the terminal title, a process poll, output activity, and screen +text for questions. +- **"Waiting on you" is fragile.** It matches Claude's English footer text (`agentQuestion.looksLikeQuestion`), so a + Claude Code rewording would silence the Question line and the taskbar count without any error. +- **Failure is invisible.** A rate-limited or errored turn looks finished. + +Claude Code hooks report these states exactly. + +## Decisions (owner) +1. **Claude Code only** in this PR. Codex stays on its title, plus one small fix: Codex's `[ ! ] Action Required` / + `[ . ] Action Required` title means "needs you" (Question). Codex hooks need a per-hash trust in Codex and add + almost nothing over its title. They stay a possible later opt-in on the same reader. +2. **In the core**, shared with Prism. The plugin files live in `core/` so both apps can ship them. +3. **On by default**, with a switch. Off means off: the variable is not set, so no plugin is loaded. +4. **Indicator only.** No notifications, cost display or the like. +5. **States:** + - Waiting for permission, an MCP elicitation and Claude's own question all show the existing Question line. + - Done shows Finished. + - **Failed** is a new line in the theme's red, behind its own switch, with its kind (rate limit, overloaded, ...) + in the tab's tooltip. + - Subagents and compacting count as Working. +6. **What stays of today's method:** + - For a session that has spoken through hooks, screen reading for questions is off. + - The title still clears Working after an Esc (no hook fires on interrupt; measured: the idle title comes 72 ms + after Esc). + - The process poll still decides whether an agent is present at all. + - Sessions that never speak through hooks keep the whole current method: Codex, a claude started before the + update, a folder not yet trusted, or plugins blocked by policy. + +## Design +**Transport: in-band OSC 777.** +- A bundled Claude Code plugin's command hooks print + `{"terminalSequence":"\u001b]777;prism-agent;state=[;kind=]\u0007"}`. +- Claude writes that sequence into its own terminal, so it reaches the tab's pty. +- **No network, listener or tab ids.** `claude -p` and SDK runs never write it (measured), so nested runs cannot + light up a tab. +- **The hook must stay trivial**, because blocking events wait for it: a `.cmd` that echoes a static JSON line (no + node, 20-100 ms measured). +- **One static entry per event.** StopFailure has one per `error` matcher value, so each kind is a static string. + +**Plugin:** `core/claude-plugin/`, with `.claude-plugin/plugin.json`, `hooks/hooks.json` and `hook.cmd`. + +| Event | `state=` | +|---|---| +| UserPromptSubmit, PreToolUse, PostToolUse, PostToolUseFailure, SubagentStart, PreCompact | `working` | +| PermissionRequest, Elicitation | `question` | +| Notification, matchers `permission_prompt`, `elicitation_dialog`, `elicitation_url_dialog` | `question` | +| Stop | `done` | +| StopFailure, each matcher | `failed;kind=` | + +SessionStart and SessionEnd are left out: their bytes rarely reach the pty (measured 1 of 3 and 0 of 3). + +**Attaching:** +- Main's `ptyEnv` (core) appends the plugin directory to `CLAUDE_CODE_PLUGIN_DIRS`, keeping any value the user set, + joined with `;`. It does this only when the host passes a plugin path and the setting is on. +- The app copies `core/claude-plugin` to `resources/claude-plugin` (electron-builder `extraResources`), and main + passes that path. In dev, it passes the source path. +- A host that passes no path (Prism, until it ships the files) gets nothing, and nothing changes there. + +**Reader (core renderer):** +- `TerminalPanel` registers an OSC 777 handler. +- A pure parser (`lib/agentHookSignal.ts`) accepts only `prism-agent;...` payloads with a known state and a sane kind. + It ignores everything else and returns `false`, so other OSC 777 users are unaffected. +- The parsed state goes onto termBus for the session. +- `useAgentIndicator` treats a hook state as the agent's own word, above the title, and remembers per session that + hooks are live: + - **working:** working. + - **question:** the Question state (the Question line when not looking, as today). + - **done:** finished. + - **failed:** a new failed state with its kind. +- **Precedence:** + - An idle title after a hook `working`, with no Stop, means interrupted. That gives idle with no Finished line + (an Esc is not a finish). + - A new `working` clears question and failed. + - The process poll's "no agent" still clears everything. + +**Failed line:** +- Like Finished and Question: a static 3 px line when you were not looking, cleared when the tab is opened. +- It outranks Finished; Question outranks Failed. +- **Switch:** `agent-failed-on`, key `prism.term.agentFailedOn`, on by default, in the core options list next to the + other two. Each app's options e2e must show it, and Prism's page composes from the core rows, so it appears there. +- **Colour:** the theme's red, moved to the contrast floor like Finished's green. A colour picker is not asked for. +- **Taskbar badge:** counts a failed tab too. +- **Tab tooltip:** "Failed: rate limit" and so on. + +**Setting:** "Exact status from Claude Code", key `prism.term.agentHooks`, on by default. It lives in the core next +to the indicator rows, in the same options list. Off means the env var is not added for new shells; running shells +keep what they started with. Its plain-words hint follows `settingsCopy`. + +**Codex title:** `agentTitle` maps an `Action Required` title from Codex to "needs you". + +**Privacy:** PRIVACY.md gets a line: PT adds a local plugin to Claude Code sessions started in its tabs, to read the +agent's state. Nothing leaves the PC, and the setting turns it off. + +**Versions:** core and app take a minor bump. CLAUDE.md gets a rule section. + +## Testing +- **Unit tests:** + - the signal parser (valid, unknown state, other prefixes, malformed); + - the indicator state machine with hook states (precedence, interrupt, failed, a new prompt clearing it, falling + back when there are no hooks); + - `ptyEnv` appending to and keeping the user's `CLAUDE_CODE_PLUGIN_DIRS`, and leaving it alone when off; + - `hooks.json` covering every event and matcher above with valid JSON and the static strings, plus `hook.cmd` + printing valid JSON per state when run (cmd is on Windows CI). +- **E2E** (headless, `npm run e2e`): a stand-in agent prints the OSC 777 sequences in a real pwsh. The scenario + asserts: + - Working, then Question (the line when not looking), Done, and Failed with its tooltip; + - the badge count; + - an idle title after Working with no Stop ends with no Finished line; + - the switch off means no Failed line; + - another OSC 777 payload is ignored. +- **Real Claude check (hands-on, before merge):** run the packaged app with a real claude: + - an invalid model gives Failed (`model_not_found`, no tokens); + - a permission prompt gives Question; + - a reply gives Done. + +## Plan +1. Plugin files in `core/claude-plugin/`, plus the hooks.json and hook.cmd unit tests. +2. `ptyEnv` plugin-dir injection, the host dependency, the setting read in main; builder `extraResources`; tests. +3. The parser plus the OSC handler in TerminalPanel, then termBus. +4. Hook states in `useAgentIndicator`: precedence, interrupt, the failed state, the screen question off for hooked + sessions; tests. +5. The Failed line, switch, colour, badge and tooltip, plus the "Exact status from Claude Code" switch; options and + settingsCopy tests. +6. Codex `Action Required` in agentTitle; test. +7. The e2e scenario, a fail-on-main proof, and the full gate. +8. PRIVACY.md, the CLAUDE.md section, the versions, a hands-on check with the packaged build, and the PR. From f7da905c915c5e20938860dcdd675672edcaa462 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:59:19 +0200 Subject: [PATCH 2/4] feat(core): agent states from Claude Code hooks, a Failed mark (#131) A bundled Claude Code plugin prints a fixed OSC 777 through terminalSequence for each hook event; the core reads it as the agent's own word: working, question, done, failed with its kind. Failed is a new attention line, the Exact status setting hands new shells the plugin. Codex's Action Required title is a question. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- .gitattributes | 3 + core/claude-plugin/.claude-plugin/plugin.json | 5 + core/claude-plugin/hook.cmd | 12 + core/claude-plugin/hooks/hooks.json | 277 ++++++++++++++++++ core/main/claudePlugin.ts | 29 ++ core/main/ipc.ts | 21 +- core/main/terminal.test.ts | 87 +++++- core/main/terminal.ts | 79 ++++- core/preload/api.ts | 14 +- core/renderer/components/TerminalPanel.tsx | 15 +- core/renderer/host.ts | 9 +- core/renderer/lib/agentColors.ts | 10 +- core/renderer/lib/agentHookSignal.test.ts | 108 +++++++ core/renderer/lib/agentHookSignal.ts | 116 ++++++++ core/renderer/lib/agentHookState.test.ts | 73 +++++ core/renderer/lib/agentHookState.ts | 65 ++++ core/renderer/lib/agentTitle.test.ts | 22 ++ core/renderer/lib/agentTitle.ts | 16 +- core/renderer/lib/termBus.ts | 18 ++ core/renderer/lib/termLook.ts | 35 +++ core/renderer/lib/useAgentIndicator.ts | 104 ++++++- core/renderer/settings/TerminalBehaviour.tsx | 26 +- core/renderer/settings/options.ts | 4 + electron-builder.yml | 5 + src/main/index.ts | 20 +- src/renderer/src/App.tsx | 13 +- src/renderer/src/components/TabStrip.tsx | 33 ++- src/renderer/src/lib/agentColors.test.ts | 30 +- src/renderer/src/lib/agentColors.ts | 50 +++- src/renderer/src/lib/taskbarBadge.test.ts | 9 + src/renderer/src/lib/taskbarBadge.ts | 8 +- tools/e2e/run.mjs | 129 +++++++- 32 files changed, 1388 insertions(+), 57 deletions(-) create mode 100644 .gitattributes create mode 100644 core/claude-plugin/.claude-plugin/plugin.json create mode 100644 core/claude-plugin/hook.cmd create mode 100644 core/claude-plugin/hooks/hooks.json create mode 100644 core/main/claudePlugin.ts create mode 100644 core/renderer/lib/agentHookSignal.test.ts create mode 100644 core/renderer/lib/agentHookSignal.ts create mode 100644 core/renderer/lib/agentHookState.test.ts create mode 100644 core/renderer/lib/agentHookState.ts diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e3c5a6a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# The Claude Code plugin's hook (#131) is a batch file: cmd reads it line by +# line and is only reliable with CRLF, whatever the checkout's own setting is. +*.cmd text eol=crlf diff --git a/core/claude-plugin/.claude-plugin/plugin.json b/core/claude-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..1b405cd --- /dev/null +++ b/core/claude-plugin/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "prism-terminal-status", + "version": "1.0.0", + "description": "Lets a Prism Terminal tab show what this Claude Code session is doing: working, waiting on you, finished or failed. Each hook prints one fixed terminal sequence; nothing leaves the PC." +} diff --git a/core/claude-plugin/hook.cmd b/core/claude-plugin/hook.cmd new file mode 100644 index 0000000..c7008a2 --- /dev/null +++ b/core/claude-plugin/hook.cmd @@ -0,0 +1,12 @@ +@echo off +rem PRISM TERMINAL READS THE AGENT STATE FROM THIS (#131). Claude Code runs it for +rem the events in hooks\hooks.json and writes the terminalSequence it prints into +rem its OWN terminal, so the sequence reaches that tab and nothing else. A static +rem echo and no node: blocking events wait for it (MEASURED 20 to 100 ms). +rem The arguments are fixed words from hooks.json: a state, and the error kind +rem of a failure. Nothing from stdin is read or repeated. +if "%~2"=="" ( + echo {"terminalSequence":"\u001b]777;prism-agent;state=%~1\u0007"} +) else ( + echo {"terminalSequence":"\u001b]777;prism-agent;state=%~1;kind=%~2\u0007"} +) diff --git a/core/claude-plugin/hooks/hooks.json b/core/claude-plugin/hooks/hooks.json new file mode 100644 index 0000000..3377bbb --- /dev/null +++ b/core/claude-plugin/hooks/hooks.json @@ -0,0 +1,277 @@ +{ + "description": "Prism Terminal reads the agent state from these. Each prints one fixed OSC 777 line through terminalSequence.", + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" working", + "timeout": 5 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" working", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" working", + "timeout": 5 + } + ] + } + ], + "PostToolUseFailure": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" working", + "timeout": 5 + } + ] + } + ], + "SubagentStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" working", + "timeout": 5 + } + ] + } + ], + "PreCompact": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" working", + "timeout": 5 + } + ] + } + ], + "PermissionRequest": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" question", + "timeout": 5 + } + ] + } + ], + "Elicitation": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" question", + "timeout": 5 + } + ] + } + ], + "Notification": [ + { + "matcher": "permission_prompt", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" question", + "timeout": 5 + } + ] + }, + { + "matcher": "elicitation_dialog", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" question", + "timeout": 5 + } + ] + }, + { + "matcher": "elicitation_url_dialog", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" question", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" done", + "timeout": 5 + } + ] + } + ], + "StopFailure": [ + { + "matcher": "rate_limit", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed rate_limit", + "timeout": 5 + } + ] + }, + { + "matcher": "overloaded", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed overloaded", + "timeout": 5 + } + ] + }, + { + "matcher": "authentication_failed", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed authentication_failed", + "timeout": 5 + } + ] + }, + { + "matcher": "oauth_org_not_allowed", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed oauth_org_not_allowed", + "timeout": 5 + } + ] + }, + { + "matcher": "account_on_hold", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed account_on_hold", + "timeout": 5 + } + ] + }, + { + "matcher": "billing_error", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed billing_error", + "timeout": 5 + } + ] + }, + { + "matcher": "invalid_request", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed invalid_request", + "timeout": 5 + } + ] + }, + { + "matcher": "model_not_found", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed model_not_found", + "timeout": 5 + } + ] + }, + { + "matcher": "server_error", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed server_error", + "timeout": 5 + } + ] + }, + { + "matcher": "max_output_tokens", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed max_output_tokens", + "timeout": 5 + } + ] + }, + { + "matcher": "cloud_credential_error", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed cloud_credential_error", + "timeout": 5 + } + ] + }, + { + "matcher": "unknown", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed unknown", + "timeout": 5 + } + ] + }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/core/main/claudePlugin.ts b/core/main/claudePlugin.ts new file mode 100644 index 0000000..caeaa6a --- /dev/null +++ b/core/main/claudePlugin.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'fs' +import { join } from 'path' + +/** + * Is this folder OUR Claude Code plugin (#131)? The app inherits whatever + * started it: launched from a tab of another copy (the owner's stable copy, + * a dev build), its environment already names that copy's plugin. Passed on, + * every Claude would run two of them, and with the setting off, one would still + * be there. So a shell's environment drops every folder that holds a plugin of + * this name and adds the one this app ships, if any. + */ +export const PLUGIN_NAME = 'prism-terminal-status' + +const seen = new Map() + +export function isOurPlugin(dir: string): boolean { + const key = dir.trim().toLowerCase() + const known = seen.get(key) + if (known !== undefined) return known + let ours = false + try { + const m = JSON.parse(readFileSync(join(dir.trim(), '.claude-plugin', 'plugin.json'), 'utf8')) as { name?: unknown } + ours = m.name === PLUGIN_NAME + } catch { + /* not a plugin, or not there: the user's, left alone */ + } + seen.set(key, ours) + return ours +} diff --git a/core/main/ipc.ts b/core/main/ipc.ts index 3e10d5e..132a1b8 100644 --- a/core/main/ipc.ts +++ b/core/main/ipc.ts @@ -2,7 +2,7 @@ import { CH } from '../shared/channels' import { validResume } from './agentResume' import { pollAgentsNow, pollAgentsSoon, startAgentPoll } from './agentPoll' import { detectShells } from './shells' -import { cdTerm, killTerm, prewarmShell, resizeTerm, spawnTerm, writeTerm } from './terminal' +import { cdTerm, killTerm, prewarmShell, resizeTerm, spawnTerm, writeTerm, type ClaudePluginEnv } from './terminal' import { openTermPath, pathKinds, PATHS_MAX, type PathOpeners } from './termPathOpen' /** @@ -65,6 +65,13 @@ export interface TermIpcDeps { * nothing is painted that a click could not open. */ paths?: PathOpeners + /** + * Where this host's copy of the Claude Code plugin is (#131): the folder + * holding `.claude-plugin/plugin.json`. A shell gets it while the page's + * setting is on. Absent (Prism, until it ships the files): no shell's + * environment is touched, whatever the page says. + */ + claudePluginDir?: string } /** Registers every terminal channel and starts the agent poll. Returns the @@ -72,17 +79,21 @@ export interface TermIpcDeps { export function registerTermIpc(deps: TermIpcDeps): () => void { const { ipcMain, send } = deps const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) + // The page says whether its setting is on; only a literal true turns it on, + // and only where the host ships the plugin. + const plugin = (hooks: unknown): ClaudePluginEnv | undefined => + deps.claudePluginDir ? { dir: deps.claudePluginDir, on: hooks === true } : undefined ipcMain.handle(CH.shells, () => detectShells()) - ipcMain.handle(CH.spawn, async (_e: unknown, id: unknown, cwd: unknown, shellId: unknown, resume: unknown) => { + ipcMain.handle(CH.spawn, async (_e: unknown, id: unknown, cwd: unknown, shellId: unknown, resume: unknown, hooks: unknown) => { if (typeof id !== 'string' || !id || typeof cwd !== 'string') return false const dir = await deps.spawnDir(cwd) if (!dir) return false // The resume id came from main's own scan of the agent's session files, // but it crossed the renderer on the way back: shape-check it again before // it goes anywhere near a command line. - const ok = await spawnTerm(id, dir, str(shellId), send, validResume(str(resume))) + const ok = await spawnTerm(id, dir, str(shellId), send, validResume(str(resume)), plugin(hooks)) // Warm the agent-poll pipeline now: the first process query is the slow // one, and running it while the user is still typing their first command // means the mark can appear on the poll that actually matters. @@ -106,10 +117,10 @@ export function registerTermIpc(deps: TermIpcDeps): () => void { // A title claimed an agent the poll has not seen (#73): look now. ipcMain.on(CH.agentLook, () => pollAgentsNow()) - ipcMain.on(CH.prewarm, (_e: unknown, cwd: unknown, shellId: unknown) => { + ipcMain.on(CH.prewarm, (_e: unknown, cwd: unknown, shellId: unknown, hooks: unknown) => { if (typeof cwd !== 'string') return void deps.mayPrewarm(cwd).then((ok) => { - if (ok) void prewarmShell(cwd, str(shellId)) + if (ok) void prewarmShell(cwd, str(shellId), plugin(hooks)) }) }) diff --git a/core/main/terminal.test.ts b/core/main/terminal.test.ts index f552a2e..8bbe0c1 100644 --- a/core/main/terminal.test.ts +++ b/core/main/terminal.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from 'vitest' -import { OutputBatcher, ptyEnv } from './terminal' +import { mkdirSync, mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { OutputBatcher, pluginKey, ptyEnv, withPluginDir } from './terminal' +import { isOurPlugin, PLUGIN_NAME } from './claudePlugin' describe('OutputBatcher', () => { it('coalesces chunks and flushes once per window', () => { @@ -92,3 +96,84 @@ describe('ptyEnv', () => { expect('GONE' in env).toBe(false) }) }) + +// THE CLAUDE CODE PLUGIN (#131): the folder rides CLAUDE_CODE_PLUGIN_DIRS. +describe('ptyEnv and the Claude Code plugin', () => { + const DIR = 'C:\\PT\\resources\\claude-plugin' + const on = { dir: DIR, on: true } + const off = { dir: DIR, on: false } + + it('adds the plugin folder when the setting is on', () => { + expect(ptyEnv({}, 'pwsh', on).CLAUDE_CODE_PLUGIN_DIRS).toBe(DIR) + }) + + it("keeps the user's own folders and appends ours after them", () => { + expect(ptyEnv({ CLAUDE_CODE_PLUGIN_DIRS: 'D:\\mine;E:\\too' }, 'pwsh', on).CLAUDE_CODE_PLUGIN_DIRS).toBe( + `D:\\mine;E:\\too;${DIR}` + ) + }) + + it('writes under the spelling the environment already uses, once', () => { + const env = ptyEnv({ Claude_Code_Plugin_Dirs: 'D:\\mine' }, 'pwsh', on) + expect(env.Claude_Code_Plugin_Dirs).toBe(`D:\\mine;${DIR}`) + expect('CLAUDE_CODE_PLUGIN_DIRS' in env).toBe(false) + }) + + it('does not add it twice', () => { + expect(ptyEnv({ CLAUDE_CODE_PLUGIN_DIRS: DIR.toLowerCase() }, 'pwsh', on).CLAUDE_CODE_PLUGIN_DIRS).toBe( + DIR.toLowerCase() + ) + }) + + it("leaves it out when the setting is off, and the user's value as it was", () => { + expect('CLAUDE_CODE_PLUGIN_DIRS' in ptyEnv({}, 'pwsh', off)).toBe(false) + expect(ptyEnv({ CLAUDE_CODE_PLUGIN_DIRS: 'D:\\mine' }, 'pwsh', off).CLAUDE_CODE_PLUGIN_DIRS).toBe('D:\\mine') + }) + + it('drops a copy of OUR plugin inherited from another copy of the app, on or off', () => { + const ours = (d: string): boolean => d.endsWith('stable\\claude-plugin') + const from = { CLAUDE_CODE_PLUGIN_DIRS: 'D:\\mine;C:\\stable\\claude-plugin' } + expect(ptyEnv(from, 'pwsh', on, ours).CLAUDE_CODE_PLUGIN_DIRS).toBe(`D:\\mine;${DIR}`) + expect(ptyEnv(from, 'pwsh', off, ours).CLAUDE_CODE_PLUGIN_DIRS).toBe('D:\\mine') + expect('CLAUDE_CODE_PLUGIN_DIRS' in ptyEnv({ CLAUDE_CODE_PLUGIN_DIRS: 'C:\\stable\\claude-plugin' }, 'pwsh', off, ours)).toBe(false) + }) + + it('changes nothing for a host that ships no plugin (Prism), whatever is inherited', () => { + const from = { CLAUDE_CODE_PLUGIN_DIRS: 'D:\\mine;;C:\\stable\\claude-plugin', FOO: 'x' } + expect(ptyEnv(from, 'pwsh', undefined, () => true)).toEqual(ptyEnv(from, 'pwsh')) + expect(ptyEnv(from, 'pwsh').CLAUDE_CODE_PLUGIN_DIRS).toBe(from.CLAUDE_CODE_PLUGIN_DIRS) + expect('CLAUDE_CODE_PLUGIN_DIRS' in ptyEnv({}, 'pwsh')).toBe(false) + }) + + it('keys a warm shell by what it was started with', () => { + expect(pluginKey(undefined)).toBe('') + expect(pluginKey(on)).not.toBe(pluginKey(off)) + expect(pluginKey(on)).toBe(pluginKey({ ...on })) + }) + + it('withPluginDir drops empty entries', () => { + expect(withPluginDir(';a;;', 'b')).toBe('a;b') + expect(withPluginDir(undefined, undefined)).toBe('') + }) +}) + +describe('isOurPlugin', () => { + it('knows our plugin by its manifest name, and nothing else', () => { + const base = mkdtempSync(join(tmpdir(), 'pt-plugin-')) + const mk = (name: string, manifest: string | null): string => { + const d = join(base, name) + mkdirSync(join(d, '.claude-plugin'), { recursive: true }) + if (manifest !== null) writeFileSync(join(d, '.claude-plugin', 'plugin.json'), manifest) + return d + } + expect(isOurPlugin(mk('ours', JSON.stringify({ name: PLUGIN_NAME })))).toBe(true) + expect(isOurPlugin(mk('theirs', JSON.stringify({ name: 'something-else' })))).toBe(false) + expect(isOurPlugin(mk('broken', '{'))).toBe(false) + expect(isOurPlugin(mk('empty', null))).toBe(false) + expect(isOurPlugin(join(base, 'missing'))).toBe(false) + }) + + it('names the plugin the app ships', () => { + expect(isOurPlugin(join(__dirname, '..', 'claude-plugin'))).toBe(true) + }) +}) diff --git a/core/main/terminal.ts b/core/main/terminal.ts index 5376d7d..996a488 100644 --- a/core/main/terminal.ts +++ b/core/main/terminal.ts @@ -2,6 +2,7 @@ import type { IPty } from 'node-pty' import { cdCommand } from '../shared/termCwd' import { detectShells, shellById } from './shells' import { cmdPrompt } from './termPrompt' +import { isOurPlugin } from './claudePlugin' // The pty host. Sessions are keyed by an id the renderer assigns - the same // pattern as tabs, where the renderer owns the list and main owns the @@ -118,7 +119,50 @@ const SESSION_MARKERS = new Set([ 'CODEX_COMPANION_TRANSCRIPT_PATH' ]) -export function ptyEnv(from: NodeJS.ProcessEnv, shellId?: string): Record { +/** + * THE CLAUDE CODE PLUGIN (#131): Claude loads every folder named in this + * variable as a plugin (2.1.280+, MEASURED on 2.1.289, `;` between several), + * so a plain `claude` typed in the tab reports its state with nothing written + * to the user's own settings. The user's own value is KEPT and ours appended. + */ +const PLUGIN_DIRS = 'CLAUDE_CODE_PLUGIN_DIRS' + +/** + * What a host that ships the plugin says about a shell: where its plugin is, + * and whether the setting is on. A host that ships none (Prism, until it does) + * passes nothing, and the variable is left exactly as inherited. + */ +export interface ClaudePluginEnv { + dir: string + on: boolean +} + +/** Which warm shell a spawn may adopt: one started with the same answer. */ +export const pluginKey = (p: ClaudePluginEnv | undefined): string => + p ? `${p.on ? 1 : 0}|${p.dir}` : '' + +/** + * The user's plugin folders, with ours appended when `dir` is given. Any copy + * of OUR plugin the app inherited (`isOurs`, see claudePlugin.ts: launched + * from another copy's tab) is dropped first, so a Claude never runs two and + * "off" leaves none. Empty: the variable goes. + */ +export function withPluginDir( + dirs: string | undefined, + dir: string | undefined, + isOurs: (d: string) => boolean = () => false +): string { + const list = (dirs ?? '').split(';').filter((d) => d.trim() && !isOurs(d)) + if (dir && !list.some((d) => d.trim().toLowerCase() === dir.toLowerCase())) list.push(dir) + return list.join(';') +} + +export function ptyEnv( + from: NodeJS.ProcessEnv, + shellId?: string, + plugin?: ClaudePluginEnv, + isOurs?: (d: string) => boolean +): Record { const env: Record = {} let prompt: string | undefined for (const [k, v] of Object.entries(from)) { @@ -136,6 +180,13 @@ export function ptyEnv(from: NodeJS.ProcessEnv, shellId?: string): Record k.toUpperCase() === PLUGIN_DIRS) ?? PLUGIN_DIRS + if (plugin) { + const dirs = withPluginDir(env[key], plugin.on ? plugin.dir : undefined, isOurs) + if (dirs) env[key] = dirs + else delete env[key] + } return env } @@ -168,6 +219,10 @@ const sessions = new Map() interface WarmShell { pty: IPty defId: string + /** `pluginKey` of what it was started with (#131): adopted only by a spawn + * that wants the same, so a switched-off setting is never handed a shell + * that still carries the plugin. */ + plugin: string buf: string sub: { dispose(): void } exited: boolean @@ -233,7 +288,11 @@ export function shellsGone(timeoutMs: number): Promise { ]) } -export async function prewarmShell(root: string, shellId: string | undefined): Promise { +export async function prewarmShell( + root: string, + shellId: string | undefined, + plugin?: ClaudePluginEnv +): Promise { const key = rootKey(root) if (warm.has(key)) return const def = shellById(shellId, await detectShells()) @@ -258,9 +317,9 @@ export async function prewarmShell(root: string, shellId: string | undefined): P cols: size.cols, rows: size.rows, cwd: root, - env: ptyEnv(process.env, def.id) + env: ptyEnv(process.env, def.id, plugin, isOurPlugin) }) - const w: WarmShell = { pty: p, defId: def.id, buf: '', sub: { dispose: () => {} }, exited: false } + const w: WarmShell = { pty: p, defId: def.id, plugin: pluginKey(plugin), buf: '', sub: { dispose: () => {} }, exited: false } w.sub = p.onData((d) => { // The banner and prompt, kept for replay. Capped: a warm shell should // be quiet, and a runaway one is not worth adopting anyway. @@ -348,12 +407,13 @@ export async function spawnTerm( root: string, shellId: string | undefined, send: Send, - resume?: string + resume?: string, + plugin?: ClaudePluginEnv ): Promise { if (sessions.has(id) || pending.has(id)) return false pending.add(id) try { - return await spawnPending(id, root, shellId, send, resume) + return await spawnPending(id, root, shellId, send, resume, plugin) } finally { pending.delete(id) killedWhilePending.delete(id) @@ -365,7 +425,8 @@ async function spawnPending( root: string, shellId: string | undefined, send: Send, - resume?: string + resume: string | undefined, + plugin: ClaudePluginEnv | undefined ): Promise { const def = shellById(shellId, await detectShells()) if (!def) return false @@ -375,7 +436,7 @@ async function spawnPending( // waiting (the banner, the prompt), then wire it up like any session. // Never for a resume: the warm shell was spawned without the command. const w = warm.get(rootKey(root)) - if (!resume && w && !w.exited && w.defId === def.id) { + if (!resume && w && !w.exited && w.defId === def.id && w.plugin === pluginKey(plugin)) { warm.delete(rootKey(root)) w.sub.dispose() if (w.buf) send('term:data', id, w.buf) @@ -405,7 +466,7 @@ async function spawnPending( cols: size.cols, rows: size.rows, cwd: root, - env: ptyEnv(process.env, def.id) + env: ptyEnv(process.env, def.id, plugin, isOurPlugin) }) // Closed while node-pty loaded: the tab is gone, so is this shell. if (killedWhilePending.has(id)) { diff --git a/core/preload/api.ts b/core/preload/api.ts index c4c6d50..5c36aad 100644 --- a/core/preload/api.ts +++ b/core/preload/api.ts @@ -31,13 +31,15 @@ export interface ClipboardRead { export interface TermPreloadApi { /** The shells main detected; the only things term:spawn will ever launch. */ termShells(): Promise - /** `resume` is what the host's restore handed back for this shell, untouched. */ - termSpawn(id: string, cwd: string, shellId?: string, resume?: string): Promise + /** `resume` is what the host's restore handed back for this shell, untouched. + * `hooks`: the page's "Exact status from Claude Code" setting (#131); main + * hands the shell the plugin only where its host ships one. */ + termSpawn(id: string, cwd: string, shellId?: string, resume?: string, hooks?: boolean): Promise termInput(id: string, data: string): void termResize(id: string, cols: number, rows: number): void termKill(id: string): void /** Start a shell in `cwd` ahead of the click. Best-effort. */ - termPrewarm(cwd: string, shellId?: string): void + termPrewarm(cwd: string, shellId?: string, hooks?: boolean): void /** Move a shell to a folder it should follow (Prism's #99); main writes the line. */ termCd(id: string, path: string): void onTermData(cb: (id: string, data: string) => void): () => void @@ -84,12 +86,12 @@ export function createTermApi(ipc: IpcRendererLike): TermPreloadApi { } return { termShells: () => ipc.invoke(CH.shells) as Promise, - termSpawn: (id, cwd, shellId, resume) => - ipc.invoke(CH.spawn, id, cwd, shellId, resume) as Promise, + termSpawn: (id, cwd, shellId, resume, hooks) => + ipc.invoke(CH.spawn, id, cwd, shellId, resume, hooks) as Promise, termInput: (id, data) => ipc.send(CH.input, id, data), termResize: (id, cols, rows) => ipc.send(CH.resize, id, cols, rows), termKill: (id) => ipc.send(CH.kill, id), - termPrewarm: (cwd, shellId) => ipc.send(CH.prewarm, cwd, shellId), + termPrewarm: (cwd, shellId, hooks) => ipc.send(CH.prewarm, cwd, shellId, hooks), termCd: (id, path) => ipc.send(CH.cd, id, path), onTermData: (cb) => on(CH.data, cb), onTermAgent: (cb) => on(CH.agent, cb), diff --git a/core/renderer/components/TerminalPanel.tsx b/core/renderer/components/TerminalPanel.tsx index 656b978..791938a 100644 --- a/core/renderer/components/TerminalPanel.tsx +++ b/core/renderer/components/TerminalPanel.tsx @@ -9,6 +9,7 @@ import { shellOfShellId } from '../../shared/help/shells' import { onResumingChange, registerPaste, + reportAgentSignal, reportCwd, reportTitle, resumingIds, @@ -28,6 +29,7 @@ import { } from '../lib/resumeReveal' import { ResumeSkeleton } from './ResumeSkeleton' import { parseOsc9 } from '../../shared/termCwd' +import { parseAgentSignal } from '../lib/agentHookSignal' import { resolveTermTheme, watchTermTheme } from '../lib/termTheme' import { onGround } from '../lib/termGround' import { xtermTheme, type XtermTheme } from '../lib/termXterm' @@ -54,6 +56,7 @@ import { cellFrom, cellText, type CellInfo } from '../lib/termCells' import { onTermLookChange, termBaseFontPx, + agentHooksOn, termAcrylic, termFontStack, termThemeId @@ -746,6 +749,15 @@ function createSession(id: string, root: string, shellId: string | undefined): S } return true }) + // CLAUDE CODE'S OWN WORD, through the bundled plugin's hooks (#131): our + // OSC 777 payloads only. Anything else answers false, so another OSC 777 + // user is left exactly as before. + term.parser.registerOscHandler(777, (data) => { + const signal = parseAgentSignal(data) + if (!signal) return false + reportAgentSignal(id, signal) + return true + }) // A RESUMING TAB WEARS A SKELETON (#106), not a text spinner: the shell's // own words (its prompt, the resume command) are cleared the moment the // agent takes the console, and the terminal is shown once the agent has @@ -923,7 +935,8 @@ function createSession(id: string, root: string, shellId: string | undefined): S // A session restored over a Claude conversation launches straight into it: // the resume id rides the SPAWN (main builds it into the shell's startup // command), so nothing is ever visibly typed. - void termApi().termSpawn(id, root, shellId, resume ?? undefined).then((ok) => { + // The plugin rides the spawn too (#131), while its setting is on. + void termApi().termSpawn(id, root, shellId, resume ?? undefined, agentHooksOn()).then((ok) => { if (!ok && sessions.has(id)) { // The resume spinner would go on writing over the error for the tab's // whole life (code review 2026-09-24, #24): no pty data will ever stop it. diff --git a/core/renderer/host.ts b/core/renderer/host.ts index 1c1b4f0..52474d8 100644 --- a/core/renderer/host.ts +++ b/core/renderer/host.ts @@ -32,11 +32,11 @@ export interface ClipboardRead { * signatures exist in one place. */ export interface TermApi { termShells(): Promise - termSpawn(id: string, cwd: string, shellId?: string, resume?: string): Promise + termSpawn(id: string, cwd: string, shellId?: string, resume?: string, hooks?: boolean): Promise termInput(id: string, data: string): void termResize(id: string, cols: number, rows: number): void termKill(id: string): void - termPrewarm(cwd: string, shellId?: string): void + termPrewarm(cwd: string, shellId?: string, hooks?: boolean): void onTermData(cb: (id: string, data: string) => void): () => void onTermAgent(cb: (id: string, has: boolean, kind: DetectedAgent | null) => void): () => void /** Ask the process poll to look NOW, past its backoff (#73). Optional so a @@ -99,9 +99,10 @@ export interface TermHostConfig { * host's own accent for "working", and a green that reads on its ground for * "finished". The tab strip is the host's chrome, so the accent is the * host's to name: Prism Terminal derives its chrome from the terminal theme, - * Prism takes it from the app style. + * Prism takes it from the app style. `failed` (#131) is the theme's red on + * the host's ground; absent, the core's own red (`FAILED_RED`). */ - themedAgentColors(themeId: string): { working: string; finished: string; question?: string } + themedAgentColors(themeId: string): { working: string; finished: string; question?: string; failed?: string } /** * The colour the terminal is really painted on, where the host lets somebody diff --git a/core/renderer/lib/agentColors.ts b/core/renderer/lib/agentColors.ts index 6a71704..9ee2b8a 100644 --- a/core/renderer/lib/agentColors.ts +++ b/core/renderer/lib/agentColors.ts @@ -30,7 +30,11 @@ const chromeSnapshot = (): number => chromeRev * moves it to its ground's floor (`themedAgentColors().question`). */ export const QUESTION_BLUE = '#3b82f6' -export function useAgentColors(): { working: string; finished: string; question: string } { +/** The Failed mark's colour (#131) where the host names none: a clear red. A + * host moves its theme's own red to its ground's floor (`themedAgentColors().failed`). */ +export const FAILED_RED = '#ef4444' + +export function useAgentColors(): { working: string; finished: string; question: string; failed: string } { const rev = useSyncExternalStore(subscribeChrome, chromeSnapshot) const themeId = useTermThemeId() // A custom theme edited in place keeps its id; its palette is the dependency. @@ -43,7 +47,9 @@ export function useAgentColors(): { working: string; finished: string; question: return { working: working || themed.working, finished: finished || themed.finished, - question: question || themed.question || QUESTION_BLUE + question: question || themed.question || QUESTION_BLUE, + // Not a picker (#131): the theme's red, as the host resolves it. + failed: themed.failed || FAILED_RED } // `custom` is read by the host's resolver, not named in the body. // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/core/renderer/lib/agentHookSignal.test.ts b/core/renderer/lib/agentHookSignal.test.ts new file mode 100644 index 0000000..ce1c513 --- /dev/null +++ b/core/renderer/lib/agentHookSignal.test.ts @@ -0,0 +1,108 @@ +import { execFileSync } from 'child_process' +import { readFileSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' +import { AGENT_HOOK_EVENTS, failedLabel, parseAgentSignal, STOP_FAILURE_KINDS } from './agentHookSignal' + +describe('parseAgentSignal', () => { + it('reads each state from our plugin', () => { + expect(parseAgentSignal('prism-agent;state=working')).toEqual({ state: 'working' }) + expect(parseAgentSignal('prism-agent;state=question')).toEqual({ state: 'question' }) + expect(parseAgentSignal('prism-agent;state=done')).toEqual({ state: 'done' }) + expect(parseAgentSignal('prism-agent;state=failed')).toEqual({ state: 'failed' }) + expect(parseAgentSignal('prism-agent;state=failed;kind=rate_limit')).toEqual({ state: 'failed', kind: 'rate_limit' }) + }) + + it('refuses an unknown state', () => { + expect(parseAgentSignal('prism-agent;state=sleeping')).toBeNull() + expect(parseAgentSignal('prism-agent;state=')).toBeNull() + expect(parseAgentSignal('prism-agent')).toBeNull() + }) + + it('leaves other OSC 777 users alone', () => { + expect(parseAgentSignal('notify;Build;done')).toBeNull() + expect(parseAgentSignal('prism-agentx;state=done')).toBeNull() + expect(parseAgentSignal('PRISM-AGENT;state=done')).toBeNull() + expect(parseAgentSignal('')).toBeNull() + }) + + it('refuses a malformed payload or a kind that is not a plain word', () => { + expect(parseAgentSignal('prism-agent;state')).toBeNull() + expect(parseAgentSignal('prism-agent;=done')).toBeNull() + expect(parseAgentSignal('prism-agent;state=failed;kind=x')).toBeNull() + expect(parseAgentSignal('prism-agent;state=failed;kind=Rate Limit')).toBeNull() + expect(parseAgentSignal(`prism-agent;state=failed;kind=${'a'.repeat(41)}`)).toBeNull() + expect(parseAgentSignal(`prism-agent;state=done;${'x'.repeat(300)}`)).toBeNull() + }) + + it('keeps the state when a newer plugin adds a field, and drops a kind on anything but a failure', () => { + expect(parseAgentSignal('prism-agent;state=done;turn=3')).toEqual({ state: 'done' }) + expect(parseAgentSignal('prism-agent;state=done;kind=rate_limit')).toEqual({ state: 'done' }) + }) +}) + +describe('failedLabel', () => { + it('names each kind in plain words, and an unknown one by its own', () => { + expect(failedLabel('rate_limit')).toBe('Failed: rate limit') + expect(failedLabel('model_not_found')).toBe('Failed: model not found') + expect(failedLabel('brand_new_error')).toBe('Failed: brand new error') + expect(failedLabel(undefined)).toBe('Failed') + for (const k of STOP_FAILURE_KINDS) expect(failedLabel(k)).toMatch(/^Failed: [a-z -]+$/) + }) +}) + +// THE PLUGIN ITSELF (core/claude-plugin): what Claude Code loads, held to the +// table above. A hook that printed anything else would be dropped by the +// parser, silently, so the files are checked here rather than on a real run. +const PLUGIN = join(__dirname, '..', '..', 'claude-plugin') + +describe('the Claude Code plugin', () => { + const hooks = JSON.parse(readFileSync(join(PLUGIN, 'hooks', 'hooks.json'), 'utf8')).hooks as Record< + string, + Array<{ matcher: string; hooks: Array<{ type: string; command: string; timeout: number }> }> + > + + it('has a manifest Claude Code accepts', () => { + const m = JSON.parse(readFileSync(join(PLUGIN, '.claude-plugin', 'plugin.json'), 'utf8')) + expect(m.name).toMatch(/^[a-z][a-z0-9-]+$/) + expect(typeof m.description).toBe('string') + }) + + it('hooks exactly the events and matchers of the table, one static command each', () => { + const found = Object.entries(hooks).flatMap(([event, entries]) => + entries.flatMap((e) => + e.hooks.map((h) => { + expect(h.type).toBe('command') + // Small: blocking events wait for it. + expect(h.timeout).toBeLessThanOrEqual(10) + const m = /^"\$\{CLAUDE_PLUGIN_ROOT\}\/hook\.cmd" ([a-z_ ]+)$/.exec(h.command) + expect(m, h.command).not.toBeNull() + return { event, matcher: e.matcher, args: m![1] } + }) + ) + ) + const key = (r: { event: string; matcher: string; args: string }): string => `${r.event}|${r.matcher}|${r.args}` + expect(found.map(key).sort()).toEqual(AGENT_HOOK_EVENTS.map(key).sort()) + }) + + it('leaves out SessionStart and SessionEnd, whose bytes rarely reach the pty', () => { + expect(Object.keys(hooks)).not.toContain('SessionStart') + expect(Object.keys(hooks)).not.toContain('SessionEnd') + }) + + // cmd is on every Windows machine this runs on, CI's runner included. + it.runIf(process.platform === 'win32')('prints valid JSON with the sequence the parser reads, for every row', () => { + for (const args of new Set(AGENT_HOOK_EVENTS.map((r) => r.args))) { + const out = execFileSync('cmd.exe', ['/d', '/c', join(PLUGIN, 'hook.cmd'), ...args.split(' ')], { + encoding: 'utf8', + input: '{"hook_event_name":"Stop"}', + windowsHide: true + }) + const seq = JSON.parse(out.trim()).terminalSequence as string + const open = '\u001b]777;' + expect(seq.startsWith(open) && seq.endsWith('\u0007'), args).toBe(true) + const [state, kind] = args.split(' ') + expect(parseAgentSignal(seq.slice(open.length, -1))).toEqual(kind ? { state, kind } : { state }) + } + }) +}) diff --git a/core/renderer/lib/agentHookSignal.ts b/core/renderer/lib/agentHookSignal.ts new file mode 100644 index 0000000..401f01f --- /dev/null +++ b/core/renderer/lib/agentHookSignal.ts @@ -0,0 +1,116 @@ +/** + * WHAT CLAUDE CODE SAYS ABOUT ITSELF THROUGH ITS HOOKS (#131; owner, + * 2026-10-05: "go ahead and build that"). + * + * The bundled plugin (`core/claude-plugin`) has a command hook on each event + * below, and each prints a FIXED `terminalSequence`: Claude writes it into its + * own terminal, so it arrives in the tab's pty as + * + * ESC ] 777 ; prism-agent ; state= [; kind=] BEL + * + * In-band, so there is no listener, no port and no tab id to match: the + * sequence can only reach the tab whose Claude ran the hook. `claude -p` and + * SDK runs never write one (MEASURED), so a nested run cannot light a tab. + * SessionStart and SessionEnd are left out: their bytes reached the pty in 1 of + * 3 and 0 of 3 runs (MEASURED, 2026-10-05). + * + * Pure: xterm hands the OSC's data (everything after `777;`) to `parse`, and + * anything that is not ours answers null so other OSC 777 users are untouched. + */ + +export type AgentHookState = 'working' | 'question' | 'done' | 'failed' + +export interface AgentSignal { + state: AgentHookState + /** Why a turn failed (StopFailure's `error`), only with `failed`. */ + kind?: string +} + +const STATES: ReadonlySet = new Set(['working', 'question', 'done', 'failed']) + +/** Claude Code's StopFailure `error` values (2.1.289), one static hook each. A + * catch-all hook says `failed` with no kind too, so a kind added in a later + * Claude still marks the tab, only without its name. */ +export const STOP_FAILURE_KINDS = [ + 'rate_limit', + 'overloaded', + 'authentication_failed', + 'oauth_org_not_allowed', + 'account_on_hold', + 'billing_error', + 'invalid_request', + 'model_not_found', + 'server_error', + 'max_output_tokens', + 'cloud_credential_error', + 'unknown' +] as const + +/** + * Which hook says what: the plugin's `hooks.json` is held to this by a test. + * A Notification counts only for the three types that mean "waiting on you"; + * PermissionRequest is the INSTANT one (Notification's permission_prompt comes + * about 6 s later, MEASURED). Subagents and compacting are the agent at work. + */ +export const AGENT_HOOK_EVENTS: ReadonlyArray<{ event: string; matcher: string; args: string }> = [ + ...['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStart', 'PreCompact'].map( + (event) => ({ event, matcher: '*', args: 'working' }) + ), + { event: 'PermissionRequest', matcher: '*', args: 'question' }, + { event: 'Elicitation', matcher: '*', args: 'question' }, + ...['permission_prompt', 'elicitation_dialog', 'elicitation_url_dialog'].map((matcher) => ({ + event: 'Notification', + matcher, + args: 'question' + })), + { event: 'Stop', matcher: '*', args: 'done' }, + ...STOP_FAILURE_KINDS.map((kind) => ({ event: 'StopFailure', matcher: kind, args: `failed ${kind}` })), + { event: 'StopFailure', matcher: '*', args: 'failed' } +] + +const PREFIX = 'prism-agent' +const KIND = /^[a-z][a-z0-9_]{0,39}$/ + +/** One OSC 777 payload, or null when it is not a state from our plugin. */ +export function parseAgentSignal(data: string): AgentSignal | null { + if (typeof data !== 'string' || data.length > 200) return null + const parts = data.split(';') + if (parts[0] !== PREFIX) return null + let state: string | undefined + let kind: string | undefined + for (const part of parts.slice(1)) { + const eq = part.indexOf('=') + if (eq <= 0) return null + const key = part.slice(0, eq) + const value = part.slice(eq + 1) + if (key === 'state') state = value + else if (key === 'kind') kind = value + // An unknown field is a newer plugin's: the state still stands. + } + if (!state || !STATES.has(state)) return null + if (state !== 'failed') return { state: state as AgentHookState } + if (kind === undefined) return { state: 'failed' } + return KIND.test(kind) ? { state: 'failed', kind } : null +} + +/** Plain words for a failure's kind, for the tab's tooltip. */ +const KIND_WORDS: Record = { + rate_limit: 'rate limit', + overloaded: 'servers overloaded', + authentication_failed: 'sign-in failed', + oauth_org_not_allowed: 'organisation not allowed', + account_on_hold: 'account on hold', + billing_error: 'billing', + invalid_request: 'invalid request', + model_not_found: 'model not found', + server_error: 'server error', + max_output_tokens: 'reply too long', + cloud_credential_error: 'cloud credentials', + unknown: 'unknown error' +} + +/** "Failed: rate limit", or "Failed" when the kind is not known. */ +export function failedLabel(kind: string | undefined): string { + if (!kind) return 'Failed' + return `Failed: ${KIND_WORDS[kind] ?? kind.replace(/_/g, ' ')}` +} diff --git a/core/renderer/lib/agentHookState.test.ts b/core/renderer/lib/agentHookState.test.ts new file mode 100644 index 0000000..aaba07c --- /dev/null +++ b/core/renderer/lib/agentHookState.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { hookStep, type HookEvent, type HookSession } from './agentHookState' + +/** Run events through the rules from no state, as the indicator does. */ +function run(events: HookEvent[]): { s: HookSession | undefined; last: ReturnType } { + let s: HookSession | undefined + let last: ReturnType = null + for (const ev of events) { + last = hookStep(s, ev) + if (last) s = { phase: last.phase, kind: last.kind } + } + return { s, last } +} + +describe('hookStep', () => { + it('a prompt is work, and clears every mark', () => { + const o = hookStep(undefined, { state: 'working' }) + expect(o).toMatchObject({ phase: 'working', working: true, raise: [] }) + expect(o?.clear.sort()).toEqual(['failed', 'finished', 'question']) + }) + + it('a Stop finishes: the Finished line, and no question or failure left', () => { + const { last } = run([{ state: 'working' }, { state: 'done' }]) + expect(last).toMatchObject({ phase: 'done', working: false, raise: ['finished'] }) + expect(last?.clear.sort()).toEqual(['failed', 'question']) + }) + + it('a permission prompt or a question is the Question line, and not a finish', () => { + const { last } = run([{ state: 'working' }, { state: 'question' }]) + expect(last).toMatchObject({ phase: 'question', working: false, raise: ['question'] }) + expect(last?.raise).not.toContain('finished') + }) + + it('answering it is work again, which takes the question down', () => { + const { last } = run([{ state: 'working' }, { state: 'question' }, { state: 'working' }]) + expect(last?.working).toBe(true) + expect(last?.clear).toContain('question') + }) + + it('a failure is Failed with its kind, and Finished under it for when the Failed line is off', () => { + const { last } = run([{ state: 'working' }, { state: 'failed', kind: 'rate_limit' }]) + expect(last).toMatchObject({ phase: 'failed', kind: 'rate_limit', working: false }) + expect(last?.raise).toEqual(['failed', 'finished']) + }) + + it('keeps the kind whichever of the two failure hooks lands first', () => { + expect(run([{ state: 'failed', kind: 'overloaded' }, { state: 'failed' }]).s?.kind).toBe('overloaded') + expect(run([{ state: 'failed' }, { state: 'failed', kind: 'overloaded' }]).s?.kind).toBe('overloaded') + expect(run([{ state: 'failed' }]).s?.kind).toBeUndefined() + }) + + it('a new prompt clears a failure, and the next failure starts without the old kind', () => { + const { last } = run([{ state: 'failed', kind: 'rate_limit' }, { state: 'working' }]) + expect(last?.clear).toContain('failed') + expect(run([{ state: 'failed', kind: 'rate_limit' }, { state: 'working' }, { state: 'failed' }]).s?.kind).toBeUndefined() + }) + + it('an idle title after work with no Stop is an Esc: idle, and no Finished line', () => { + const { last } = run([{ state: 'working' }, { state: 'idle-title' }]) + expect(last).toEqual({ phase: 'stopped', working: false, raise: [], clear: [] }) + }) + + it('an idle title anywhere else changes nothing', () => { + for (const before of ['question', 'done', 'failed'] as const) + expect(run([{ state: before }, { state: 'idle-title' }]).last).toBeNull() + expect(hookStep(undefined, { state: 'idle-title' })).toBeNull() + }) + + it('a Stop that lands after the idle title still finishes', () => { + const { last } = run([{ state: 'working' }, { state: 'idle-title' }, { state: 'done' }]) + expect(last?.raise).toEqual(['finished']) + }) +}) diff --git a/core/renderer/lib/agentHookState.ts b/core/renderer/lib/agentHookState.ts new file mode 100644 index 0000000..de62b2e --- /dev/null +++ b/core/renderer/lib/agentHookState.ts @@ -0,0 +1,65 @@ +import type { AgentSignal } from './agentHookSignal' + +/** + * THE INDICATOR'S RULES FOR A SESSION THAT SPEAKS THROUGH HOOKS (#131). Pure: + * the hook (`useAgentIndicator`) keeps one phase per session and asks this what + * a signal, or an idle title, does to it. + * + * A hook is the agent's own word, above its title: the title cannot tell a + * permission prompt from a finished answer (both show the idle star, MEASURED) + * and a failed turn looks finished. Two things are still the title's: + * - AN ESC. No hook fires on an interrupt (MEASURED: no Stop, no StopFailure; + * the idle title came 72 ms after the Esc). So an idle title while the hooks + * last said `working` is an interrupt: the tab stops working and shows no + * Finished line, since an Esc is not a finish. A Stop that arrives after the + * idle title (the two race) still raises Finished. + * - Whether an agent is there at all stays the process poll's. + * + * Marks are RAISED only on a tab nobody is looking at; the hook decides that. + * Question outranks Failed, which outranks Finished (the strip draws one). + */ + +export type HookPhase = 'working' | 'question' | 'done' | 'failed' | 'stopped' +export type AttentionMark = 'finished' | 'question' | 'failed' + +export interface HookSession { + phase: HookPhase + /** The failure's kind, while the phase is `failed`. */ + kind?: string +} + +export interface HookOutcome extends HookSession { + working: boolean + /** Marks this step puts on a tab nobody is looking at. */ + raise: AttentionMark[] + /** Marks this step takes down, looking or not. */ + clear: AttentionMark[] +} + +export type HookEvent = AgentSignal | { state: 'idle-title' } + +/** What one event does, or null when it changes nothing. */ +export function hookStep(prev: HookSession | undefined, ev: HookEvent): HookOutcome | null { + switch (ev.state) { + case 'idle-title': + // Only an interrupt is the title's to say; after a question, a finish or + // a failure the idle title is just the agent at rest. + if (prev?.phase !== 'working') return null + return { phase: 'stopped', working: false, raise: [], clear: [] } + case 'working': + // A new prompt, a tool call, a subagent: whatever was waiting is over. + return { phase: 'working', working: true, raise: [], clear: ['question', 'failed', 'finished'] } + case 'question': + return { phase: 'question', working: false, raise: ['question'], clear: ['failed'] } + case 'done': + return { phase: 'done', working: false, raise: ['finished'], clear: ['question', 'failed'] } + case 'failed': { + // Two hooks answer one failure (the kind's own and the catch-all), in + // either order: a kind once said is kept. + const kind = ev.kind ?? (prev?.phase === 'failed' ? prev.kind : undefined) + // Finished too, so with the Failed line switched off the tab says what it + // said before hooks: the turn ended. + return { phase: 'failed', kind, working: false, raise: ['failed', 'finished'], clear: ['question'] } + } + } +} diff --git a/core/renderer/lib/agentTitle.test.ts b/core/renderer/lib/agentTitle.test.ts index cba8d15..88ca223 100644 --- a/core/renderer/lib/agentTitle.test.ts +++ b/core/renderer/lib/agentTitle.test.ts @@ -38,6 +38,28 @@ describe('readAgentTitle, the Codex dialect (measured 2026-09-04)', () => { }) }) +describe('readAgentTitle, Codex asking (#131, measured on 0.153.2)', () => { + it('reads "Action Required" as waiting on you, in both of its frames', () => { + expect(read('⠙ proj')).toBe('codex:starting') + expect(read('proj')).toBe('codex:idle') + expect(read('⠴ proj')).toBe('codex:working') + expect(read('[ ! ] Action Required | proj')).toBe('codex:question') + expect(read('[ . ] Action Required | proj')).toBe('codex:question') + // Answered: back to work, then at rest under the name it had. + expect(read('⠦ proj')).toBe('codex:working') + expect(read('proj')).toBe('codex:idle') + }) + + it('is Codex asking even before anything else was seen', () => { + expect(read('[ ! ] Action Required | proj')).toBe('codex:question') + }) + + it('is not set off by a title that only mentions it', () => { + expect(read('Action Required')).toBeNull() + expect(read('notes [ ! ] Action Required')).toBeNull() + }) +}) + describe('readAgentTitle, everything else', () => { it('is null for the shell, a command, a path, an empty title', () => { expect(read('C:\\Program Files\\PowerShell\\7\\pwsh.exe')).toBeNull() diff --git a/core/renderer/lib/agentTitle.ts b/core/renderer/lib/agentTitle.ts index 6ab5040..208496d 100644 --- a/core/renderer/lib/agentTitle.ts +++ b/core/renderer/lib/agentTitle.ts @@ -29,7 +29,15 @@ const isBraille = (c: string): boolean => { return cp >= 0x2800 && cp <= 0x28ff } -export type AgentTitleState = 'working' | 'idle' | 'starting' +/** `question`: Codex waits on you (#131; its "Action Required" title). */ +export type AgentTitleState = 'working' | 'idle' | 'starting' | 'question' + +/** + * CODEX ASKING (#131). MEASURED on Codex CLI 0.153.2: while it waits for an + * approval its title is `[ ! ] Action Required | `, alternating with + * `[ . ] Action Required | `; at rest it goes back to the bare folder. + */ +const CODEX_ASKS = /^\[\s*[!.]\s*\]\s*Action Required\b/ export interface AgentTitle { kind: 'claude' | 'codex' state: AgentTitleState @@ -52,6 +60,12 @@ export function readAgentTitle(id: string, title: string): AgentTitle | null { if (!first) return null const rest = t.slice(first.length).trim() const s = seen.get(id) + if (CODEX_ASKS.test(t)) { + // Asking is past startup; the name it returns to at rest is kept. + if (s?.kind === 'codex') s.ready = true + else seen.set(id, { kind: 'codex', name: s?.name ?? '', ready: true }) + return { kind: 'codex', state: 'question' } + } if (HALF.has(first) || isBraille(first)) { const kind = HALF.has(first) ? 'claude' : 'codex' const next: Seen = { kind, name: rest, ready: s?.kind === kind ? s.ready : false } diff --git a/core/renderer/lib/termBus.ts b/core/renderer/lib/termBus.ts index 9a06be7..defc736 100644 --- a/core/renderer/lib/termBus.ts +++ b/core/renderer/lib/termBus.ts @@ -1,3 +1,5 @@ +import type { AgentSignal } from './agentHookSignal' + /** * Reaching a live terminal from outside its own component. * @@ -94,6 +96,22 @@ export function onTitle(fn: (sessionId: string, title: string) => void): () => v } } +// WHAT CLAUDE CODE'S HOOKS SAY (#131). TerminalPanel hears our OSC 777 through +// xterm's parser and posts the parsed state here; the indicator listens, so it +// never imports the panel (which sits behind Prism's lazy boundary). +const signalListeners = new Set<(sessionId: string, signal: AgentSignal) => void>() + +export function reportAgentSignal(sessionId: string, signal: AgentSignal): void { + signalListeners.forEach((fn) => fn(sessionId, signal)) +} + +export function onAgentSignal(fn: (sessionId: string, signal: AgentSignal) => void): () => void { + signalListeners.add(fn) + return () => { + signalListeners.delete(fn) + } +} + // WHICH SESSIONS ARE STILL COMING BACK (#106): a tab resuming Claude or Codex // wears a skeleton until the agent has drawn itself. TerminalPanel says when a // session starts and stops resuming; its own overlay and a host's tab strip diff --git a/core/renderer/lib/termLook.ts b/core/renderer/lib/termLook.ts index 2a36f8c..353e88c 100644 --- a/core/renderer/lib/termLook.ts +++ b/core/renderer/lib/termLook.ts @@ -177,6 +177,35 @@ export function setAgentQuestionOn(on: boolean): void { localStorage.setItem(QUESTION_ON_KEY, on ? '1' : '0') notify() } + +const FAILED_ON_KEY = 'prism.term.agentFailedOn' +const HOOKS_KEY = 'prism.term.agentHooks' + +/** + * THE FAILED MARK (#131): a turn that ended on an error (a rate limit, an + * overload), told by Claude Code's own hook. A switch beside the other two, + * on by default for their reason. + */ +export function agentFailedOn(): boolean { + return localStorage.getItem(FAILED_ON_KEY) !== '0' +} +export function setAgentFailedOn(on: boolean): void { + localStorage.setItem(FAILED_ON_KEY, on ? '1' : '0') + notify() +} + +/** + * "EXACT STATUS FROM CLAUDE CODE" (#131; owner, 2026-10-05: on by default, + * with a switch). On, a NEW shell is handed the bundled plugin; off, it is + * not, and no plugin loads. A running shell keeps what it started with. + */ +export function agentHooksOn(): boolean { + return localStorage.getItem(HOOKS_KEY) !== '0' +} +export function setAgentHooksOn(on: boolean): void { + localStorage.setItem(HOOKS_KEY, on ? '1' : '0') + notify() +} /** Six digits, or eight with an alpha (#112): every reader takes both. */ const HEX = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i @@ -440,6 +469,12 @@ export function useAgentDoneOn(): boolean { export function useAgentQuestionOn(): boolean { return useSyncExternalStore(sub, agentQuestionOn) } +export function useAgentFailedOn(): boolean { + return useSyncExternalStore(sub, agentFailedOn) +} +export function useAgentHooksOn(): boolean { + return useSyncExternalStore(sub, agentHooksOn) +} export function useCustomTermTheme(): CustomTermTheme | null { // Cache per notify tick: useSyncExternalStore needs a stable snapshot. return useSyncExternalStore(sub, customSnapshot) diff --git a/core/renderer/lib/useAgentIndicator.ts b/core/renderer/lib/useAgentIndicator.ts index 73b4e51..fdcd581 100644 --- a/core/renderer/lib/useAgentIndicator.ts +++ b/core/renderer/lib/useAgentIndicator.ts @@ -3,8 +3,9 @@ import type { DetectedAgent } from '../../shared/types' import { activitySuppressed, inputEcho, markBorn, startupOutput } from './termActivity' import { forgetAgentTitle, readAgentTitle } from './agentTitle' import { noteWorking } from './agentClock' -import { onTitle, readScreenTail } from './termBus' +import { onAgentSignal, onTitle, readScreenTail } from './termBus' import { looksLikeQuestion } from './agentQuestion' +import { hookStep, type AttentionMark, type HookEvent, type HookSession } from './agentHookState' import { termApi } from '../host' /** @@ -25,6 +26,12 @@ export interface AgentIndicator { /** Sessions whose agent is waiting on YOU (a question or a permission * prompt), marked while you were not looking at them (2026-09-28). */ questionIds: ReadonlySet + /** Sessions whose turn ended on an error, told by Claude Code's own hook + * (#131), marked while you were not looking at them. */ + failedIds: ReadonlySet + /** Each failed session's error kind (`rate_limit`...), when the hook named + * one. A ref, read whenever `failedIds` changes. */ + failedKinds: RefObject> /** Which agent each session hosts. A ref: read at the moment of asking. */ agentKinds: RefObject> /** The session ended: every mark it carried goes with it. */ @@ -43,6 +50,17 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { const [workingIds, setWorkingIds] = useState>(new Set()) const [doneIds, setDoneIds] = useState>(new Set()) const [questionIds, setQuestionIds] = useState>(new Set()) + const [failedIds, setFailedIds] = useState>(new Set()) + const failedKinds = useRef(new Map()) + /** + * SESSIONS THAT SPEAK THROUGH HOOKS (#131), and the phase each last said. + * For these the hooks are the agent's word: the title only says an Esc + * (`agentHookState`), the output is never scored, and the screen is never + * read for a question. A session that never sends one keeps everything + * below as it was: Codex, a Claude started before the update, a folder not + * trusted yet, plugins blocked by policy, or the setting off. + */ + const hooked = useRef(new Map()) /** * LOOKING AT A TAB is having it in front AND the window focused (2026-09-28; * owner: the marks "stay there until you click the tab"). A tab that finished @@ -101,11 +119,37 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { } }, []) + /** One hook signal, or an idle title, for a hooked session: the rules are + * `agentHookState`'s; this only carries them out. A mark is raised only on + * a tab nobody is looking at, as every mark is. */ + const applyHook = useCallback((id: string, ev: HookEvent): void => { + const o = hookStep(hooked.current.get(id), ev) + if (!o) return + hooked.current.set(id, { phase: o.phase, kind: o.kind }) + if (o.phase === 'failed' && o.kind) failedKinds.current.set(id, o.kind) + else if (o.phase === 'failed' || o.clear.includes('failed')) failedKinds.current.delete(id) + const away = !lookedAt(id) + const marks = (mark: AttentionMark) => (prev: ReadonlySet): ReadonlySet => { + if (away && o.raise.includes(mark)) return prev.has(id) ? prev : new Set(prev).add(id) + return o.clear.includes(mark) ? without(prev, id) : prev + } + setDoneIds(marks('finished')) + setQuestionIds(marks('question')) + setFailedIds(marks('failed')) + setWorkingIds((prev) => { + if (prev.has(id) === o.working) return prev + return o.working ? new Set(prev).add(id) : without(prev, id) + }) + }, []) + useEffect( () => termApi().onTermAgent((id, present, kind) => { if (present) polled.current.add(id) else polled.current.delete(id) + // Another agent in this shell now: whatever Claude's hooks said was + // about the one that left. + if (!present || (kind && kind !== 'claude')) hooked.current.delete(id) if (present && kind) agentKinds.current.set(id, kind) else if (!present) { agentKinds.current.delete(id) @@ -115,6 +159,8 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { forgetAgentTitle(id) stopFallback(id) setWorkingIds((prev) => without(prev, id)) + failedKinds.current.delete(id) + setFailedIds((prev) => without(prev, id)) } if (present) { // An agent's BIRTH state is idle: its startup paint (banner, welcome @@ -143,6 +189,8 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { useEffect( () => termApi().onTermData((id) => { + // Its hooks say everything, questions included (#131). + if (hooked.current.has(id)) return // A session whose agent SAYS what it is doing (through the title, // see onTitle below) is never scored from its output: the agent's // own word is exact, and its repaints would only second-guess it. @@ -213,9 +261,17 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { if (!agentKinds.current.has(id)) agentKinds.current.set(id, r.kind) setAgentIds((prev) => (prev.has(id) ? prev : new Set(prev).add(id))) titleState.current.set(id, r.state) - // Idle is where a question is asked: read the screen for its box. Any - // other state is the agent at work again, so no question is pending. + // A session speaking through hooks: the title only says an Esc (#131). + if (hooked.current.has(id)) { + if (r.state === 'idle') applyHook(id, { state: 'idle-title' }) + return + } + // Idle is where a question is asked: read the screen for its box. + // Codex says it outright (#131). Any other state is the agent at work + // again, so no question is pending. if (r.state === 'idle') checkQuestion(id) + else if (r.state === 'question') + setQuestionIds((prev) => (prev.has(id) || lookedAt(id) ? prev : new Set(prev).add(id))) else setQuestionIds((prev) => without(prev, id)) const working = r.state === 'working' setWorkingIds((prev) => { @@ -226,7 +282,31 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { return next }) }), - [stopFallback, checkQuestion] + [stopFallback, checkQuestion, applyHook] + ) + + /** + * CLAUDE CODE'S HOOKS (#131), through the bundled plugin's OSC 777. Only + * Claude's UI writes one (never `claude -p`, MEASURED), so a signal is that + * agent being present, as a Claude title is: the poll is asked to look, and + * only the poll takes it back. From the first signal the session is hooked. + */ + useEffect( + () => + onAgentSignal((id, signal) => { + if (!polled.current.has(id)) termApi().termAgentLook?.() + if (!hooked.current.has(id)) { + outputRuns.current.delete(id) + stopFallback(id) + const t = questionTimers.current.get(id) + if (t !== undefined) clearTimeout(t) + questionTimers.current.delete(id) + } + if (!agentKinds.current.has(id)) agentKinds.current.set(id, 'claude') + setAgentIds((prev) => (prev.has(id) ? prev : new Set(prev).add(id))) + applyHook(id, signal) + }), + [stopFallback, applyHook] ) // Finished-while-away: an agent that STOPS working on a background tab @@ -245,15 +325,22 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { for (const id of was) { // Stopped, still an agent session, and nobody was looking at it: its // tab was in the background, or the window was. - if (!workingIds.has(id) && agentIds.has(id) && !seeing(id)) mut().add(id) + // A hooked session is told apart by its hooks instead (#131): a + // question or an Esc stops the work too, and neither is a finish. + if (!workingIds.has(id) && agentIds.has(id) && !seeing(id) && !hooked.current.has(id)) mut().add(id) } for (const id of prev) { if (workingIds.has(id) || seeing(id) || !agentIds.has(id)) mut().delete(id) } return next ?? prev }) - // A question you are now looking at has been seen. + // A question, or a failure, you are now looking at has been seen. setQuestionIds((prev) => (activeId && focused && prev.has(activeId) ? without(prev, activeId) : prev)) + setFailedIds((prev) => { + let next = prev + for (const id of prev) if (seeing(id) || workingIds.has(id) || !agentIds.has(id)) next = without(next, id) + return next + }) }, [workingIds, activeId, agentIds, focused]) // STABLE: a host subscribes to the pty's exit ONCE and calls this from there. @@ -268,11 +355,14 @@ export function useAgentIndicator(activeId: string | null): AgentIndicator { setWorkingIds((prev) => without(prev, id)) setDoneIds((prev) => without(prev, id)) setQuestionIds((prev) => without(prev, id)) + setFailedIds((prev) => without(prev, id)) + hooked.current.delete(id) + failedKinds.current.delete(id) titleState.current.delete(id) const t = questionTimers.current.get(id) if (t !== undefined) clearTimeout(t) questionTimers.current.delete(id) }, [stopFallback]) - return { agentIds, workingIds, doneIds, questionIds, agentKinds, forget } + return { agentIds, workingIds, doneIds, questionIds, failedIds, failedKinds, agentKinds, forget } } diff --git a/core/renderer/settings/TerminalBehaviour.tsx b/core/renderer/settings/TerminalBehaviour.tsx index 9dbcd11..89abc76 100644 --- a/core/renderer/settings/TerminalBehaviour.tsx +++ b/core/renderer/settings/TerminalBehaviour.tsx @@ -3,9 +3,13 @@ import { termApi } from '../host' import { savedShellId, saveShellId } from '../lib/termPrefs' import { setAgentDoneOn, + setAgentFailedOn, + setAgentHooksOn, setAgentIndicator, setAgentQuestionOn, useAgentDoneOn, + useAgentFailedOn, + useAgentHooksOn, useAgentIndicator, useAgentQuestionOn, type AgentIndicator @@ -56,13 +60,17 @@ export function ShellSetting(): JSX.Element | null { * the indicator, not part of a colour theme: a theme pick never resets it and * a saved theme never carries it (owner, 2026-09-19, for both apps). */ /** - * THE TWO ATTENTION MARKS, each a switch (2026-09-28; owner: "an optional + * THE ATTENTION MARKS, each a switch (2026-09-28; owner: "an optional * completion indicator" and "a question indicator ... also optional"). What - * each shows, the tab strip draws; their colours are with the theme's. + * each shows, the tab strip draws; their colours are with the theme's. The + * Failed mark and the switch for Claude Code's hooks that tell it (#131) + * follow them, on by default; off, a new shell is not handed the plugin. */ export function AttentionSettings(): JSX.Element { const doneOn = useAgentDoneOn() const questionOn = useAgentQuestionOn() + const failedOn = useAgentFailedOn() + const hooksOn = useAgentHooksOn() return ( <> + + + + + + ) } diff --git a/core/renderer/settings/options.ts b/core/renderer/settings/options.ts index 6176ccd..4615145 100644 --- a/core/renderer/settings/options.ts +++ b/core/renderer/settings/options.ts @@ -29,6 +29,10 @@ export const TERMINAL_OPTIONS: readonly TerminalOption[] = [ { id: 'agent-indicator', label: 'Agent indicator', type: 'choice', key: 'prism.term.agentIndicator' }, { id: 'agent-done-on', label: 'Finished indicator', type: 'switch', key: 'prism.term.agentDoneOn' }, { id: 'agent-question-on', label: 'Question indicator', type: 'switch', key: 'prism.term.agentQuestionOn' }, + // Claude Code's own word through its hooks (#131): a Failed mark, and the + // switch that hands new shells the plugin which tells it. + { id: 'agent-failed-on', label: 'Failed indicator', type: 'switch', key: 'prism.term.agentFailedOn' }, + { id: 'agent-hooks', label: 'Exact status from Claude Code', type: 'switch', key: 'prism.term.agentHooks' }, // The theme wall, and under it only what a theme sets (2026-09-28). { id: 'term-theme', label: 'Theme', type: 'theme', key: 'prism.term.theme' }, { id: 'term-acrylic', label: 'Acrylic background', type: 'switch', key: 'prism.term.acrylic' }, diff --git a/electron-builder.yml b/electron-builder.yml index e29eab6..0fdaf4d 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -38,6 +38,11 @@ extraResources: # in the asar: it is an exe and its DLLs, and Windows has to be able to run it. - from: vendor/whisper to: bin/whisper + # The Claude Code plugin that reports an agent's state to its tab (#131): + # Claude loads it from CLAUDE_CODE_PLUGIN_DIRS, so it has to be real files + # on disk, never inside the asar (core/** is left out of it above). + - from: core/claude-plugin + to: claude-plugin win: icon: build/icon.ico executableName: PrismTerminal diff --git a/src/main/index.ts b/src/main/index.ts index 001c712..81cdf60 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -89,6 +89,20 @@ const pathOpeners = { } } +/** + * The Claude Code plugin this app hands its shells (#131), or undefined when + * it is not where it should be: then no shell is given a folder that is not + * there. Installed: `resources\claude-plugin`. A dev build and the e2e read + * the core's own copy, so they test the very files that ship. + */ +function claudePluginDir(): string | undefined { + // Unpackaged, main runs from out/main, two folders under the repo (the icon + // is found the same way): app.getAppPath() is that folder when the e2e + // launches the script directly. + const dir = app.isPackaged ? join(process.resourcesPath, 'claude-plugin') : join(__dirname, '../../core/claude-plugin') + return existsSync(join(dir, 'hooks', 'hooks.json')) ? dir : undefined +} + // userData is `%APPDATA%\PrismTerminal`, whatever the product name's spacing // would have made it. The e2e (and anyone else) passing Chromium's own // --user-data-dir keeps the profile they asked for: Electron has already @@ -455,7 +469,11 @@ function wireIpc(): void { mayPrewarm: async (cwd) => !awaitingRestore && (await isDir(cwd)), // Prism's reroot. This app never moves a shell it did not start there. mayCd: () => false, - paths: pathOpeners + paths: pathOpeners, + // AGENT STATES FROM CLAUDE CODE'S HOOKS (#131): the plugin ships beside + // the app (electron-builder's extraResources), and a dev build reads the + // core's own copy. A shell gets it while the page's setting is on. + claudePluginDir: claudePluginDir() }) // DICTATION (#13) is the core's too. What is this app's own: where ITS // installer put the CPU engine, the folder it shares with Prism, and the GPU diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b34f521..247292d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -68,7 +68,7 @@ import { onWindowEdgesChange, windowEdges } from './lib/edgesPrefs' import { onWindowAccentChange, windowAccent } from './lib/accentPrefs' import { onWindowBackgroundChange, windowBackground } from './lib/backgroundPrefs' import { attentionCount, drawBadge, useTaskbarBadgeOn } from './lib/taskbarBadge' -import { useAgentDoneOn, useAgentQuestionOn } from '@core/renderer/lib/termLook' +import { agentHooksOn, useAgentDoneOn, useAgentFailedOn, useAgentQuestionOn } from '@core/renderer/lib/termLook' const Settings = lazy(() => import('./components/Settings')) // Loaded when it is first opened: the popup brings the whole catalogue with it, @@ -190,7 +190,7 @@ export default function App(): JSX.Element { // nothing, and with the setting off it listens to nothing. useDictationArm(activeShell ? activeShell.id : null) const findOpen = !!activeShell && findFor === activeShell.id - const { agentIds, workingIds, doneIds, questionIds, agentKinds } = indicator + const { agentIds, workingIds, doneIds, questionIds, failedIds, failedKinds, agentKinds } = indicator // The latest of everything, for listeners registered once. const live = useRef({ state, workingIds, agentIds, blocked: false, front: '' }) @@ -237,7 +237,9 @@ export default function App(): JSX.Element { const prewarm = useCallback(() => { // Only a folder known ahead of the click can be warmed; asking cannot. const dir = newTabMode() === 'folder' ? newTabFolder() || home.current : '' - if (dir) window.prism.termPrewarm(dir, savedShellId()) + // With the plugin or without, as the spawn will ask (#131): a warm shell + // is only adopted by a spawn that wants what it was started with. + if (dir) window.prism.termPrewarm(dir, savedShellId(), agentHooksOn()) }, []) const newTab = useCallback(async () => { @@ -472,7 +474,8 @@ export default function App(): JSX.Element { const badgeOn = useTaskbarBadgeOn() const doneOn = useAgentDoneOn() const questionOn = useAgentQuestionOn() - const need = attentionCount({ doneIds, questionIds, workingIds, doneOn, questionOn }) + const failedOn = useAgentFailedOn() + const need = attentionCount({ doneIds, questionIds, workingIds, doneOn, questionOn, failedIds, failedOn }) useEffect(() => { if (!badgeOn || need.count === 0) { window.prism.setTaskbarBadge(null, '') @@ -585,6 +588,8 @@ export default function App(): JSX.Element { workingIds={workingIds} doneIds={doneIds} questionIds={questionIds} + failedIds={failedIds} + failedKinds={failedKinds} agentIds={agentIds} loadingIds={loadingIds} onPick={(id) => setState((s) => pickTab(s, id))} diff --git a/src/renderer/src/components/TabStrip.tsx b/src/renderer/src/components/TabStrip.tsx index 82baa95..02dbff1 100644 --- a/src/renderer/src/components/TabStrip.tsx +++ b/src/renderer/src/components/TabStrip.tsx @@ -8,7 +8,8 @@ import { } from 'react' import { tabLabels, type Tab } from '../lib/tabs' import { DictationTabMark } from '@core/renderer/components/DictationTabMark' -import { useAgentDoneOn, useAgentIndicator, useAgentQuestionOn } from '@core/renderer/lib/termLook' +import { useAgentDoneOn, useAgentFailedOn, useAgentIndicator, useAgentQuestionOn } from '@core/renderer/lib/termLook' +import { failedLabel } from '@core/renderer/lib/agentHookSignal' import { useAgentColors } from '@core/renderer/lib/agentColors' import { inkOn } from '@core/renderer/lib/colour' import { pinnedRoots, plusMenuList, recentLabels, recentRoots, togglePin } from '@core/renderer/lib/recentRoots' @@ -64,6 +65,8 @@ export function TabStrip({ workingIds, doneIds, questionIds, + failedIds, + failedKinds, agentIds, loadingIds, onPick, @@ -84,6 +87,10 @@ export function TabStrip({ doneIds: ReadonlySet /** Sessions whose agent waits for your answer, unseen (2026-09-28). */ questionIds: ReadonlySet + /** Sessions whose turn ended on an error, unseen (#131), and the error's + * kind where Claude Code named one. */ + failedIds?: ReadonlySet + failedKinds?: { readonly current: ReadonlyMap } /** Sessions whose shell currently hosts an AI CLI (Claude Code, codex). */ agentIds: ReadonlySet /** Tabs still coming back to an agent (#106): a ring before the name, @@ -110,9 +117,10 @@ export function TabStrip({ const indicator = useAgentIndicator() const width = useTabWidth() // The user's pick where there is one, else the theme's accent and green. - const { working: agentColor, finished: doneColor, question: questionColor } = useAgentColors() + const { working: agentColor, finished: doneColor, question: questionColor, failed: failedColor } = useAgentColors() const doneOn = useAgentDoneOn() const questionOn = useAgentQuestionOn() + const failedOn = useAgentFailedOn() // Full mode fills the tab with the colour. Text biases WHITE: strict // contrast maths picks black on a mid orange or indigo, but white on a // saturated fill is the look; black only wins on genuinely light fills @@ -307,15 +315,18 @@ export function TabStrip({ // switch; a question outranks a plain finish. Full's fill is for // WORKING alone now: the finished fill it used to have is this line. const question = questionOn && !working && questionIds.has(t.id) - const done = doneOn && !working && !question && doneIds.has(t.id) - const mark = question ? questionColor : done ? doneColor : null + // FAILED (#131): a turn that ended on an error, said by Claude Code's + // own hook. Under a question, over a plain finish. + const failed = failedOn && !working && !question && !!failedIds?.has(t.id) + const done = doneOn && !working && !question && !failed && doneIds.has(t.id) + const mark = question ? questionColor : failed ? failedColor : done ? doneColor : null const tint = working ? agentColor : mark const loud = working && indicator === 'full' return (
{ // A press that travelled is a drag, not a pick. if (dragging.current) { diff --git a/src/renderer/src/lib/agentColors.test.ts b/src/renderer/src/lib/agentColors.test.ts index 4774b3d..d7f90ff 100644 --- a/src/renderer/src/lib/agentColors.test.ts +++ b/src/renderer/src/lib/agentColors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { themeAgentColors } from './agentColors' +import { marksApart, themeAgentColors } from './agentColors' import { chromeTokens } from './chromeTheme' import { contrastRatio } from '@core/renderer/lib/termAnsi' import { TERM_PRESETS, presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' @@ -28,6 +28,34 @@ describe('themeAgentColors', () => { ) }) +// #131: the Failed mark wears the theme's red, on every preset. +describe('the failed colour', () => { + it.each(TERM_PRESETS.map((p) => p.id))('%s: shows on the ground, and is neither of the other two', (id) => { + const bg = chromeTokens(resolveTermTheme(id)).vars['--p-bg-solid'] + const { working, finished, failed } = themeAgentColors(id) + expect(contrastRatio(failed, bg)).toBeGreaterThanOrEqual(3) + expect(failed.toLowerCase()).not.toBe(working.toLowerCase()) + expect(failed.toLowerCase()).not.toBe(finished.toLowerCase()) + expect(marksApart(failed, working)).toBe(true) + expect(marksApart(failed, finished)).toBe(true) + }) + + it("is the theme's own red where it reads apart from the accent", () => { + // PT Default: red #ff615a beside the orange accent, 24 degrees apart. + const { failed } = themeAgentColors('pt-default') + expect(failed.toLowerCase()).toBe(resolveTermTheme('pt-default').red?.toLowerCase()) + }) +}) + +describe('marksApart', () => { + it('tells a red from an orange of the same lightness, and not a red from a red', () => { + expect(marksApart('#ff615a', '#fe8f34')).toBe(true) + expect(marksApart('#ef4444', '#e5484d')).toBe(false) + expect(marksApart('#22c55e', '#ef4444')).toBe(true) + expect(marksApart('#777777', '#7a7a7a')).toBe(false) + }) +}) + // #114: a picked accent may be see-through; the indicator that follows it is // a line, and stays opaque. describe('a see-through picked accent', () => { diff --git a/src/renderer/src/lib/agentColors.ts b/src/renderer/src/lib/agentColors.ts index c7964c6..81cd294 100644 --- a/src/renderer/src/lib/agentColors.ts +++ b/src/renderer/src/lib/agentColors.ts @@ -1,5 +1,5 @@ import { chromeTokens } from './chromeTheme' -import { QUESTION_BLUE } from '@core/renderer/lib/agentColors' +import { FAILED_RED, QUESTION_BLUE } from '@core/renderer/lib/agentColors' import { windowAccent } from './accentPrefs' import { windowBackground } from './backgroundPrefs' import { contrastRatio, ensureContrast, normalizeColor } from '@core/renderer/lib/termAnsi' @@ -21,7 +21,12 @@ import { presetAccent, resolveTermTheme } from '@core/renderer/lib/termTheme' const FALLBACK_DONE = '#22c55e' const FLOOR = 3 -export function themeAgentColors(themeId: string): { working: string; finished: string; question: string } { +export function themeAgentColors(themeId: string): { + working: string + finished: string + question: string + failed: string +} { // What the chrome REALLY wears (2026-09-28): the picked background and // accent, where there are any, exactly as paintChrome derives the window. An // unpicked indicator follows "the accent you see", which is the pick. @@ -39,5 +44,44 @@ export function themeAgentColors(themeId: string): { working: string; finished: contrastRatio(green, working) < 1.15 ? ensureContrast(FALLBACK_DONE, bg, FLOOR) : green // The question mark's blue, moved only as far as this ground needs. const question = ensureContrast(QUESTION_BLUE, bg, FLOOR) - return { working, finished, question } + // FAILED is the theme's red (#131), at the same floor. Where that red is + // too near the accent or the green (Garnet's accent IS red), the plain red, + // then the theme's magenta: two states in one colour say nothing. Told + // apart by HUE, or failing that by distance, never by contrast, which only + // sees lightness and called a red and an orange of one luminance the same + // (MEASURED on Paper). + const tries = [theme.red, FAILED_RED, theme.magenta, FALLBACK_FAIL_ALT].map((c) => + ensureContrast(normalizeColor(c ?? '', FAILED_RED), bg, FLOOR) + ) + const failed = tries.find((c) => marksApart(c, working) && marksApart(c, finished)) ?? tries[0] + return { working, finished, question, failed } +} + +/** The last resort for Failed, a magenta, for a palette whose reds are all + * taken by its accent. */ +const FALLBACK_FAIL_ALT = '#d946ef' + +const rgbOf = (h: string): number[] => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) + +/** Hue in degrees, and saturation (HSL), of a `#rrggbb`. */ +function hueSat(h: string): { hue: number; sat: number } { + const [r, g, b] = rgbOf(h) + const max = Math.max(r, g, b) + const min = Math.min(r, g, b) + const d = max - min + const l = (max + min) / 2 + const sat = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1)) + if (d === 0) return { hue: 0, sat } + const hue = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4 + return { hue: (hue * 60 + 360) % 360, sat } +} + +/** Two marks that read as two: hues 20 degrees apart (both coloured), or an + * RGB distance of 80 or more. Exported for its test. */ +export function marksApart(a: string, b: string): boolean { + const [x, y] = [hueSat(a), hueSat(b)] + const turn = Math.abs(x.hue - y.hue) + if (x.sat > 0.25 && y.sat > 0.25 && Math.min(turn, 360 - turn) >= 20) return true + const [p, q] = [rgbOf(a), rgbOf(b)] + return Math.hypot(p[0] - q[0], p[1] - q[1], p[2] - q[2]) * 255 >= 80 } diff --git a/src/renderer/src/lib/taskbarBadge.test.ts b/src/renderer/src/lib/taskbarBadge.test.ts index f1d63de..9411bd5 100644 --- a/src/renderer/src/lib/taskbarBadge.test.ts +++ b/src/renderer/src/lib/taskbarBadge.test.ts @@ -22,6 +22,15 @@ describe('attentionCount', () => { attentionCount({ doneIds: set('a'), questionIds: set(), workingIds: set('a'), doneOn: true, questionOn: true }) ).toEqual({ count: 0, question: false }) }) + it('counts a failed tab too (#131), once, and only while its switch is on', () => { + const base = { doneIds: set('a'), questionIds: set(), workingIds: set(), doneOn: true, questionOn: true } + expect(attentionCount({ ...base, failedIds: set('a', 'f'), failedOn: true })).toEqual({ count: 2, question: false }) + expect(attentionCount({ ...base, failedIds: set('f'), failedOn: false })).toEqual({ count: 1, question: false }) + expect(attentionCount({ ...base, failedIds: set('f'), failedOn: true, workingIds: set('f') })).toEqual({ + count: 1, + question: false + }) + }) }) describe('badgeText', () => { diff --git a/src/renderer/src/lib/taskbarBadge.ts b/src/renderer/src/lib/taskbarBadge.ts index 4811d81..efe9ca6 100644 --- a/src/renderer/src/lib/taskbarBadge.ts +++ b/src/renderer/src/lib/taskbarBadge.ts @@ -3,8 +3,8 @@ import { useSyncExternalStore } from 'react' /** * THE TASKBAR BADGE (2026-09-28; owner: "a badge on the taskbar icon, where * it says how many sessions have completed without me having taken a look at - * them yet"). The number is every tab showing an attention mark (finished or - * a question, each only while its own switch is on), since both are sessions + * them yet"). The number is every tab showing an attention mark (finished, + * a question or failed (#131), each only while its own switch is on), since all are sessions * that need a look. Windows has no numeric badge for a desktop app, so it is * the window's OVERLAY icon: a small disc drawn here and set by main. This * app's own setting, on by default (`prism.window.taskbarBadge`). @@ -42,6 +42,9 @@ export function attentionCount(p: { workingIds: ReadonlySet doneOn: boolean questionOn: boolean + /** A turn that ended on an error (#131); absent, nothing failed. */ + failedIds?: ReadonlySet + failedOn?: boolean }): { count: number; question: boolean } { const ids = new Set() let question = false @@ -51,6 +54,7 @@ export function attentionCount(p: { ids.add(id) question = true } + if (p.failedOn && p.failedIds) for (const id of p.failedIds) if (!p.workingIds.has(id)) ids.add(id) if (p.doneOn) for (const id of p.doneIds) if (!p.workingIds.has(id)) ids.add(id) return { count: ids.size, question } } diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 474dee9..115b6af 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -470,6 +470,133 @@ const scenarios = { } }, + /** + * CLAUDE CODE'S HOOKS (#131). A real pwsh stands in for Claude and prints the + * bundled plugin's OSC 777 lines itself, exactly the bytes Claude writes for + * a hook's terminalSequence (the plugin's own output is held to them by the + * unit test). What is proved: the plugin rides a new shell's environment and + * the switch takes it away; Working, Question, Done and Failed with its kind + * in the tooltip; the badge counts them; an idle title after Working with no + * Stop (an Esc) leaves no Finished line; the Failed switch; another OSC 777 + * is ignored; and a hooked session's screen is not read for a question. + */ + async agentHooks(ok) { + const w = world() + const { app, page } = await launch(w, { args: [w.alpha, w.beta] }) + try { + await until(async () => (await tabLabels(page)).length === 2) + const tab = (i) => page.locator('[data-tab]').nth(i) + const state = (i) => tab(i).getAttribute('data-agent-state') + const line = (i) => tab(i).locator('[data-attention]').getAttribute('data-attention').catch(() => null) + const badge = () => page.evaluate(() => window.prism.e2eTaskbarBadge()) + // The sequence as Claude writes it: ESC ] 777 ; payload BEL. + const osc = (payload) => `[Console]::Write([char]27 + ']777;${payload}' + [char]7)` + const say = (state) => osc(`prism-agent;state=${state}`) + const at = (i, cmd) => tab(i).click().then(() => typeLine(page, cmd)) + const later = (cmd) => `Start-Sleep -Milliseconds 1500; ${cmd}` + const IDLE = "$Host.UI.RawUI.WindowTitle = [char]0x2733 + ' Claude Code'" + // Opening a tab only counts as looking with the window focused, and the + // parked e2e window never is: say it is, as `attention` does. + const look = async (i) => { + await page.evaluate(() => window.dispatchEvent(new Event('focus'))) + await tab(i).click() + } + const PLUG = + "Write-Host ('PLUG-' + @($env:CLAUDE_CODE_PLUGIN_DIRS -split ';' | Where-Object { $_ -and (Test-Path (Join-Path $_ '.claude-plugin\\plugin.json')) }).Count)" + + // THE PLUGIN RIDES THE SHELL'S ENVIRONMENT, and it is real files. + await at(0, PLUG) + ok(!!(await until(async () => (await termText(page)).includes('PLUG-1'), 10000)), 'a new shell is handed the Claude Code plugin') + await polled(page) + + // WORKING, by the hook alone: no title, no output scoring. + await at(0, say('working')) + ok(!!(await until(async () => (await state(0)) === 'working', 8000, 50)), 'a working hook lights the tab') + + // A QUESTION on a background tab: the Question line and the badge. + await at(0, later(say('question'))) + await tab(1).click() + ok(!!(await until(async () => (await state(0)) === 'question', 10000, 50)), 'a question hook on a background tab marks it') + ok((await line(0)) === 'question', 'with the Question line') + ok(!!(await until(async () => (await badge()) === '1 tab needs a look', 4000, 50)), `and the taskbar badge counts it (${await badge()})`) + await look(0) + ok(!!(await until(async () => (await state(0)) === null, 4000, 50)), 'opening the tab clears it') + + // DONE on a background tab: the Finished line. + await at(0, say('working')) + await at(0, later(say('done'))) + await tab(1).click() + ok(!!(await until(async () => (await state(0)) === 'done', 10000, 50)), 'a Stop hook on a background tab leaves the Finished line') + await look(0) + await until(async () => (await state(0)) === null, 4000, 50) + + // FAILED, with its kind: the two hooks of one failure, the kind's first. + await at(0, say('working')) + await at(0, later(`${osc('prism-agent;state=failed;kind=rate_limit')}; ${say('failed')}`)) + await tab(1).click() + ok(!!(await until(async () => (await state(0)) === 'failed', 10000, 50)), 'a StopFailure hook on a background tab marks it Failed') + ok((await line(0)) === 'failed', 'with the Failed line') + const failedTip = (await tabTitles(page))[0] + ok(/\nFailed: rate limit$/.test(failedTip), `and the tooltip says what failed (${JSON.stringify(failedTip)})`) + ok(!!(await until(async () => (await badge()) === '1 tab needs a look', 4000, 50)), 'the badge counts a failed tab') + const colourOf = (i) => tab(i).locator('[data-attention]').evaluate((e) => getComputedStyle(e).backgroundColor) + const failedColour = await colourOf(0) + await page.locator('[data-tab-strip]').screenshot({ path: resolve(process.cwd(), '.e2e-shots/agent-hooks-failed.png') }).catch(() => {}) + + // THE FAILED SWITCH: off, the line goes and the turn reads as finished. + await page.locator('[data-title-settings]').click() + await page.locator('[data-settings-tab="appearance"]').click() + const failedSwitch = page.locator('[data-pref="agent-failed-on"] [role="switch"]') + ok((await failedSwitch.getAttribute('aria-checked')) === 'true', 'the Failed indicator is on by default') + await failedSwitch.click() + ok(!!(await until(async () => (await line(0)) === 'done', 4000, 50)), 'switched off, no Failed line: the Finished one shows instead') + const doneColour = await colourOf(0) + ok(failedColour !== doneColour, `the Failed line is a colour of its own (${failedColour} vs ${doneColour})`) + await failedSwitch.click() + ok(!!(await until(async () => (await line(0)) === 'failed', 4000, 50)), 'and back on, it is Failed again') + ok((await page.locator('[data-pref="agent-hooks"] [role="switch"]').getAttribute('aria-checked')) === 'true', 'Exact status from Claude Code is on by default') + + // AN ESC: working, then the idle title with no Stop. Not a finish. + await look(0) + await until(async () => (await state(0)) === null, 4000, 50) + await at(0, say('working')) + await until(async () => (await state(0)) === 'working', 8000, 50) + await at(0, later(IDLE)) + await tab(1).click() + ok(!!(await until(async () => (await state(0)) !== 'working', 10000, 50)), 'an idle title after a working hook stops the work') + await sleep(1000) + ok((await state(0)) === null && (await line(0)) === null, `and leaves no Finished line, an Esc is not a finish (${await state(0)})`) + ok((await badge()) === '', 'nor anything for the badge') + + // ANOTHER OSC 777, and our prefix with a state we do not know: nothing. + await at(0, later(`${osc('notify;Build;done')}; ${osc('prism-agent;state=sleeping')}; ${osc('prism-agentx;state=failed')}`)) + await tab(1).click() + await sleep(2500) + ok((await state(0)) === null, `another OSC 777 payload changes nothing (${await state(0)})`) + + // A HOOKED SESSION'S SCREEN IS NOT READ FOR A QUESTION: Claude's footer on + // screen with the title idle used to mark it; the hooks say it now. + await at(0, later(`Write-Host 'Enter to select, Esc to cancel'; ${IDLE}`)) + await tab(1).click() + await sleep(2500) + ok((await state(0)) === null, `the question footer on screen does not mark a hooked session (${await state(0)})`) + + // OFF MEANS OFF: a shell opened with the setting off gets no plugin. + const before = (await tabLabels(page)).length + await page.evaluate(() => localStorage.setItem('prism.term.agentHooks', '0')) + await tab(1).click() + await page.locator('.xterm').first().click({ force: true }) + await page.keyboard.press('Control+t') + await until(async () => (await tabLabels(page)).length === before + 1) + await typeLine(page, PLUG) + ok(!!(await until(async () => (await termText(page)).includes('PLUG-0'), 10000)), 'with the setting off, a new shell has no plugin') + // End idle, so closing asks nothing. + await at(0, "$Host.UI.RawUI.WindowTitle = 'pwsh'") + } finally { + await closeApp(app) + } + }, + /** A light theme makes a light window: measured, never read off a name. */ async theme(ok) { const w = world() @@ -2453,7 +2580,7 @@ const scenarios = { // WHAT NO THEME OWNS SITS ABOVE THE WALL, WHAT A THEME SETS UNDER IT // (owner, 2026-09-28). const rows = await page.evaluate(() => [...document.querySelectorAll('[data-pref]')].map((e) => e.getAttribute('data-pref'))) - const want = ['tab-width', 'title-bar', 'window-edges', 'term-font-family', 'term-font', 'agent-indicator', 'agent-done-on', 'agent-question-on', 'term-theme', 'window-background', 'window-accent'] + const want = ['tab-width', 'title-bar', 'window-edges', 'term-font-family', 'term-font', 'agent-indicator', 'agent-done-on', 'agent-question-on', 'agent-failed-on', 'agent-hooks', 'term-theme', 'window-background', 'window-accent'] ok(JSON.stringify(rows.slice(0, want.length)) === JSON.stringify(want), `the page runs ${want.join(' > ')} (${rows.slice(0, want.length).join(' > ')})`) // Font size is 50% to 200% in tens. await page.locator('[data-pref="term-font"] button[aria-haspopup="listbox"]').click() From f6e2242f573a3b622bc05dfb60ee10e03d357317 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:08:38 +0200 Subject: [PATCH 3/4] docs(core): hooks rules, privacy line, core 0.24.0 and app 0.30.0 (#131) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- CLAUDE.md | 23 +++++++++++++++++++++++ PRIVACY.md | 9 +++++++++ core/README.md | 10 ++++++++++ core/package.json | 6 ++++-- package-lock.json | 4 ++-- package.json | 2 +- 6 files changed, 49 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 323d9d4..6b3f949 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -579,6 +579,29 @@ the owner's own call, #99.) first idle title is the agent STARTING, not working. Output scoring (`termActivity`) is only the fallback, and an agent's startup paint is not work (`markBorn` / `startupOutput`). The rules live in `lib/useAgentIndicator.ts`; change them there, with Prism's reasoning in hand. +- **CLAUDE CODE'S HOOKS ARE ITS WORD, ABOVE THE TITLE** (#131; owner, 2026-10-05: "go ahead and build + that"; spec `docs/superpowers/specs/2026-10-05-agent-hooks-design.md`, evidence in the research + folder's `prism-terminal/2026-10-05-agent-hooks-inventory.md`). `core/claude-plugin` is a Claude Code + plugin: each hook in `hooks/hooks.json` runs `hook.cmd`, a static echo (blocking events wait for it; + MEASURED 20-100 ms, so no node), whose `terminalSequence` Claude writes into its OWN terminal as + `ESC]777;prism-agent;state=working|question|done|failed[;kind=]BEL`. In-band: no + listener, no tab ids, and `claude -p` / SDK runs never write it (MEASURED). SessionStart/End are left + out (their bytes reached the pty 1 of 3 and 0 of 3). Main hands a shell the plugin through + `CLAUDE_CODE_PLUGIN_DIRS` (`ptyEnv`, the user's value kept, an inherited copy of ours dropped: + `claudePlugin.ts`), only where the host passes `claudePluginDir` (this app: `resources\claude-plugin`, + extraResources; dev reads the core's copy) AND the page's "Exact status from Claude Code" + (`agent-hooks`, on) is on; a warm shell is adopted only with the same answer. Prism passes no dir, so + nothing changes there until it ships the files. The reader: `lib/agentHookSignal` (parse, our prefix + only, else `false` so other OSC 777 users are untouched), termBus, `lib/agentHookState` (the pure + rules), `useAgentIndicator`. A HOOKED session is never scored from output nor read for a question; + its title only says an Esc (no hook fires on one, MEASURED: idle title 72 ms after): an idle title + after `working` with no Stop is idle with NO Finished line, and a Stop that lands after it still + finishes. The poll still decides presence. **Failed** is a third line (`agent-failed-on`, on), the + theme's red apart from the accent by hue (`marksApart`), over Finished and under Question, counted by + the badge, its kind on the tab's tooltip ("Failed: rate limit"); it raises Finished under it, so + with the switch off the tab reads as before. Sessions that never send a signal (Codex, an older + claude, an untrusted folder, plugins blocked by policy) keep the old method whole. Codex's + `[ ! ] Action Required` title is its Question. The `agentHooks` e2e holds it (it fails on main). - **FINISHED AND QUESTION ARE LINES, EACH OPTIONAL; EVERY WORKING TAB HAS ITS OWN BAR; THE TASKBAR COUNTS** (owner, 2026-09-28; spec `docs/superpowers/specs/2026-09-28-attention-and-warm-dictation-design.md`). A tab whose agent finished, or waits on you, while you were NOT LOOKING (another tab in front, or diff --git a/PRIVACY.md b/PRIVACY.md index efe3b28..00778a3 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -27,6 +27,15 @@ Everything else happens **only when you ask for it**: - **What you run:** the terminal runs your own shell, and whatever you run in it reaches the network as you tell it to. +## What it adds to Claude Code + +Prism Terminal adds a small local plugin to the Claude Code sessions started in its tabs, so the tab +can show what the agent is doing: working, waiting on you, finished or failed. The plugin's hooks +print one fixed line into that tab's own terminal; they send nothing over the network, read +nothing, and write no file. It is passed through an environment variable of the tab's shell, never +written into your Claude Code settings. Settings > Appearance > "Exact status from Claude Code" +turns it off for every terminal opened afterwards. + This file is the privacy statement the [code signing policy](README.md#code-signing-policy) refers to. If the app ever gains a request that is not listed here, this file changes in the same pull request. diff --git a/core/README.md b/core/README.md index f68bf73..9573df2 100644 --- a/core/README.md +++ b/core/README.md @@ -38,6 +38,16 @@ ask me."* And: *"why can't this repo be the core?"* It can, and this is it. runs `core/tools/fetch-whisper.mjs ` at build time and ships that folder as `resources/bin/whisper`, and grants its own window the `media` permission (audio only). + **Claude Code's hooks** (#131) are in here too: the plugin itself + (`claude-plugin/`), the env injection (`main/terminal.ts` `ptyEnv`, + `main/claudePlugin.ts`), the reader (`renderer/lib/agentHookSignal.ts`, + `agentHookState.ts`, the OSC 777 handler in the panel) and the Failed line's + state and switches. The indicator reads signals in any host. A host ships the + plugin by copying `core/claude-plugin` beside its app (real files, not in an + asar) and passing that folder as `claudePluginDir` to `registerTermIpc`, plus + the page's setting to `termPrewarm`; one that passes no folder changes no + shell's environment. Its strip draws `failedIds` as a third line, and its + `themedAgentColors` may name `failed` (else `FAILED_RED`). **AND WHAT THE TWO APPS MUST SHOW IDENTICALLY, TERMINAL OR NOT** (#28, owner, 2026-09-19). This WIDENS the core, on purpose, from "the terminal" to "what the two apps share". Asked to build the update window (*"when you click the diff --git a/core/package.json b/core/package.json index 321805e..b17e193 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "prism-term-core", - "version": "0.23.1", + "version": "0.24.0", "description": "What Prism Terminal and Prism share: the terminal (pty, shells, agent detection and indicator, themes, links, the panel, dictation) and the update chip with its window. TypeScript source, compiled by the host.", "license": "MIT", "private": true, @@ -18,7 +18,8 @@ "./renderer/settings/options": "./renderer/settings/options.ts", "./renderer/settings/*": "./renderer/settings/*.tsx", "./renderer/settings/dictationOptions": "./renderer/settings/dictationOptions.ts", - "./tools/*": "./tools/*" + "./tools/*": "./tools/*", + "./claude-plugin/*": "./claude-plugin/*" }, "files": [ "main", @@ -26,6 +27,7 @@ "shared", "renderer", "tools", + "claude-plugin", "!**/*.test.ts", "!**/__fixtures__/**", "README.md" diff --git a/package-lock.json b/package-lock.json index 8e21f03..6e656b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism-terminal", - "version": "0.29.0", + "version": "0.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism-terminal", - "version": "0.29.0", + "version": "0.30.0", "license": "MIT", "dependencies": { "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index 4cd7617..5095681 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "prism-terminal", "productName": "Prism Terminal", - "version": "0.29.0", + "version": "0.30.0", "description": "A tabbed Windows terminal for AI CLIs.", "main": "./out/main/index.js", "author": "Max", From 94aabe1b403ae67e44e8311f81e77e5415d8d1fa Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Mon, 5 Oct 2026 01:41:40 +0200 Subject: [PATCH 4/4] fix(core): plugin hooks run without a shell, a switched setting re-warms (#131) Review of #131, each MEASURED on Claude Code 2.1.289: - Without Git Bash, Claude Code runs a hook's command string through PowerShell, which reads `"/hook.cmd" working` as a ParserError: no states, and a visible hook error on every event. The hooks now use exec form (`cmd.exe` + args, no shell, available since 2.1.139); `call` keeps cmd from stripping the quotes off a plugin folder with brackets in it. The unit test runs every hook as written from such a folder. - A warm shell started with the other plugin answer was never adopted and never replaced, so every new tab in that folder started cold after the setting was switched. It now makes way. - core/.gitattributes carries the CRLF rule for hook.cmd into core-dist. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FHHaWKR4M5QtW7Wecyuk4t --- CLAUDE.md | 4 +- core/.gitattributes | 4 + core/claude-plugin/hook.cmd | 3 + core/claude-plugin/hooks/hooks.json | 239 +++++++++++++++++++--- core/main/terminal.sessions.test.ts | 15 ++ core/main/terminal.ts | 6 + core/renderer/lib/agentHookSignal.test.ts | 37 +++- 7 files changed, 272 insertions(+), 36 deletions(-) create mode 100644 core/.gitattributes diff --git a/CLAUDE.md b/CLAUDE.md index 6b3f949..f5769d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -583,7 +583,9 @@ the owner's own call, #99.) that"; spec `docs/superpowers/specs/2026-10-05-agent-hooks-design.md`, evidence in the research folder's `prism-terminal/2026-10-05-agent-hooks-inventory.md`). `core/claude-plugin` is a Claude Code plugin: each hook in `hooks/hooks.json` runs `hook.cmd`, a static echo (blocking events wait for it; - MEASURED 20-100 ms, so no node), whose `terminalSequence` Claude writes into its OWN terminal as + MEASURED 20-100 ms, so no node), in EXEC FORM (`cmd.exe` + `args` `/d /c call /hook.cmd`): a + command string runs through PowerShell where Git Bash is missing and is a ParserError there, and + without `call` a plugin folder with brackets fails (both MEASURED, review of #131). Its `terminalSequence` Claude writes into its OWN terminal as `ESC]777;prism-agent;state=working|question|done|failed[;kind=]BEL`. In-band: no listener, no tab ids, and `claude -p` / SDK runs never write it (MEASURED). SessionStart/End are left out (their bytes reached the pty 1 of 3 and 0 of 3). Main hands a shell the plugin through diff --git a/core/.gitattributes b/core/.gitattributes new file mode 100644 index 0000000..22af6f9 --- /dev/null +++ b/core/.gitattributes @@ -0,0 +1,4 @@ +# The Claude Code plugin's hook (#131) is a batch file: cmd reads it line by +# line and is only reliable with CRLF. Here as well as at the repo root so the +# rule travels with core-dist, the split Prism checks out. +*.cmd text eol=crlf diff --git a/core/claude-plugin/hook.cmd b/core/claude-plugin/hook.cmd index c7008a2..c031362 100644 --- a/core/claude-plugin/hook.cmd +++ b/core/claude-plugin/hook.cmd @@ -5,6 +5,9 @@ rem its OWN terminal, so the sequence reaches that tab and nothing else. A stati rem echo and no node: blocking events wait for it (MEASURED 20 to 100 ms). rem The arguments are fixed words from hooks.json: a state, and the error kind rem of a failure. Nothing from stdin is read or repeated. +rem hooks.json starts it as cmd.exe /d /c call (exec form, no +rem shell): a command STRING runs through PowerShell where Git Bash is +rem missing, which cannot parse a quoted path and an argument (MEASURED). if "%~2"=="" ( echo {"terminalSequence":"\u001b]777;prism-agent;state=%~1\u0007"} ) else ( diff --git a/core/claude-plugin/hooks/hooks.json b/core/claude-plugin/hooks/hooks.json index 3377bbb..446cabb 100644 --- a/core/claude-plugin/hooks/hooks.json +++ b/core/claude-plugin/hooks/hooks.json @@ -1,5 +1,5 @@ { - "description": "Prism Terminal reads the agent state from these. Each prints one fixed OSC 777 line through terminalSequence.", + "description": "Prism Terminal reads the agent state from these. Each runs hook.cmd, which prints one fixed OSC 777 line through terminalSequence. Exec form (args): no shell, so it runs the same with Git Bash or PowerShell, in a folder with spaces or brackets.", "hooks": { "UserPromptSubmit": [ { @@ -7,7 +7,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" working", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "working" + ], "timeout": 5 } ] @@ -19,7 +26,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" working", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "working" + ], "timeout": 5 } ] @@ -31,7 +45,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" working", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "working" + ], "timeout": 5 } ] @@ -43,7 +64,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" working", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "working" + ], "timeout": 5 } ] @@ -55,7 +83,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" working", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "working" + ], "timeout": 5 } ] @@ -67,7 +102,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" working", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "working" + ], "timeout": 5 } ] @@ -79,7 +121,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" question", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "question" + ], "timeout": 5 } ] @@ -91,7 +140,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" question", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "question" + ], "timeout": 5 } ] @@ -103,7 +159,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" question", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "question" + ], "timeout": 5 } ] @@ -113,7 +176,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" question", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "question" + ], "timeout": 5 } ] @@ -123,7 +193,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" question", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "question" + ], "timeout": 5 } ] @@ -135,7 +212,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" done", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "done" + ], "timeout": 5 } ] @@ -147,7 +231,15 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed rate_limit", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "rate_limit" + ], "timeout": 5 } ] @@ -157,7 +249,15 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed overloaded", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "overloaded" + ], "timeout": 5 } ] @@ -167,7 +267,15 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed authentication_failed", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "authentication_failed" + ], "timeout": 5 } ] @@ -177,7 +285,15 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed oauth_org_not_allowed", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "oauth_org_not_allowed" + ], "timeout": 5 } ] @@ -187,7 +303,15 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed account_on_hold", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "account_on_hold" + ], "timeout": 5 } ] @@ -197,7 +321,15 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed billing_error", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "billing_error" + ], "timeout": 5 } ] @@ -207,7 +339,15 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed invalid_request", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "invalid_request" + ], "timeout": 5 } ] @@ -217,7 +357,15 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed model_not_found", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "model_not_found" + ], "timeout": 5 } ] @@ -227,7 +375,15 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed server_error", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "server_error" + ], "timeout": 5 } ] @@ -237,7 +393,15 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed max_output_tokens", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "max_output_tokens" + ], "timeout": 5 } ] @@ -247,7 +411,15 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed cloud_credential_error", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "cloud_credential_error" + ], "timeout": 5 } ] @@ -257,7 +429,15 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed unknown", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "unknown" + ], "timeout": 5 } ] @@ -267,7 +447,14 @@ "hooks": [ { "type": "command", - "command": "\"${CLAUDE_PLUGIN_ROOT}/hook.cmd\" failed", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed" + ], "timeout": 5 } ] diff --git a/core/main/terminal.sessions.test.ts b/core/main/terminal.sessions.test.ts index 1b3bde0..2570232 100644 --- a/core/main/terminal.sessions.test.ts +++ b/core/main/terminal.sessions.test.ts @@ -127,4 +127,19 @@ describe('the warm shell (#2)', () => { expect(made).toHaveLength(2) expect(made[1].killed).toBe(false) }) + + it('a warm shell started with the other plugin answer makes way, and the next tab adopts the new one (#131)', async () => { + const on = { dir: 'C:\\PT\\claude-plugin', on: true } + const off = { dir: on.dir, on: false } + await prewarmShell('C:\\home', 'pwsh', on) // W1, with the plugin + await prewarmShell('C:\\home', 'pwsh', off) // switched off: W1 goes, W2 comes without + expect(made).toHaveLength(2) + expect(made[0].killed).toBe(true) + expect(await spawnTerm('tab', 'C:\\home', 'pwsh', send, undefined, off)).toBe(true) // adopts W2 + expect(made).toHaveLength(2) + await prewarmShell('C:\\home', 'pwsh', off) // W3, the same answer + await prewarmShell('C:\\home', 'pwsh', off) // kept, nothing new + expect(made).toHaveLength(3) + expect(made[2].killed).toBe(false) + }) }) diff --git a/core/main/terminal.ts b/core/main/terminal.ts index 996a488..de2b97b 100644 --- a/core/main/terminal.ts +++ b/core/main/terminal.ts @@ -294,6 +294,12 @@ export async function prewarmShell( plugin?: ClaudePluginEnv ): Promise { const key = rootKey(root) + // A warm shell started with the other plugin answer (#131 review) is never + // adopted, and it sat in this slot for good: every later tab in the folder + // started cold once the setting was switched. It makes way for one that + // matches what the next spawn will ask. + const held = warm.get(key) + if (held && held.plugin !== pluginKey(plugin)) killWarm(root) if (warm.has(key)) return const def = shellById(shellId, await detectShells()) if (!def) return diff --git a/core/renderer/lib/agentHookSignal.test.ts b/core/renderer/lib/agentHookSignal.test.ts index ce1c513..91a1b9a 100644 --- a/core/renderer/lib/agentHookSignal.test.ts +++ b/core/renderer/lib/agentHookSignal.test.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'child_process' -import { readFileSync } from 'fs' +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync } from 'fs' +import { tmpdir } from 'os' import { join } from 'path' import { describe, expect, it } from 'vitest' import { AGENT_HOOK_EVENTS, failedLabel, parseAgentSignal, STOP_FAILURE_KINDS } from './agentHookSignal' @@ -59,7 +60,7 @@ const PLUGIN = join(__dirname, '..', '..', 'claude-plugin') describe('the Claude Code plugin', () => { const hooks = JSON.parse(readFileSync(join(PLUGIN, 'hooks', 'hooks.json'), 'utf8')).hooks as Record< string, - Array<{ matcher: string; hooks: Array<{ type: string; command: string; timeout: number }> }> + Array<{ matcher: string; hooks: Array<{ type: string; command: string; args?: string[]; timeout: number }> }> > it('has a manifest Claude Code accepts', () => { @@ -75,9 +76,17 @@ describe('the Claude Code plugin', () => { expect(h.type).toBe('command') // Small: blocking events wait for it. expect(h.timeout).toBeLessThanOrEqual(10) - const m = /^"\$\{CLAUDE_PLUGIN_ROOT\}\/hook\.cmd" ([a-z_ ]+)$/.exec(h.command) - expect(m, h.command).not.toBeNull() - return { event, matcher: e.matcher, args: m![1] } + // EXEC FORM, no shell (#131 review): without Git Bash, Claude Code + // runs a hook's command string through PowerShell, which reads + // `"path\hook.cmd" working` as a ParserError and shows a hook error + // on every event (MEASURED on 2.1.289). cmd.exe takes no shell; `call` + // keeps cmd from stripping the quotes off a folder with brackets in + // it, and the slash is kept since Claude drops a `\` before the name. + expect(h.command).toBe('cmd.exe') + const [d, c, call, script, ...rest] = h.args ?? [] + expect([d, c, call, script]).toEqual(['/d', '/c', 'call', '$' + '{CLAUDE_PLUGIN_ROOT}/hook.cmd']) + for (const a of rest) expect(a).toMatch(/^[a-z_]+$/) + return { event, matcher: e.matcher, args: rest.join(' ') } }) ) ) @@ -90,10 +99,20 @@ describe('the Claude Code plugin', () => { expect(Object.keys(hooks)).not.toContain('SessionEnd') }) - // cmd is on every Windows machine this runs on, CI's runner included. - it.runIf(process.platform === 'win32')('prints valid JSON with the sequence the parser reads, for every row', () => { - for (const args of new Set(AGENT_HOOK_EVENTS.map((r) => r.args))) { - const out = execFileSync('cmd.exe', ['/d', '/c', join(PLUGIN, 'hook.cmd'), ...args.split(' ')], { + // cmd is on every Windows machine this runs on, CI's runner included. Each + // hook is run AS WRITTEN (its command and args, the plugin root put in the + // way Claude Code does) from a copy in a folder with a space and brackets: + // `cmd /c "...(x)\hook.cmd"` strips its own quotes there and fails, which + // `call` is in the args to prevent (MEASURED with Claude Code 2.1.289). + it.runIf(process.platform === 'win32')('prints valid JSON with the sequence the parser reads, for every hook', () => { + const root = join(mkdtempSync(join(tmpdir(), 'pt-hooks-')), 'Prism (x) T') + mkdirSync(root) + copyFileSync(join(PLUGIN, 'hook.cmd'), join(root, 'hook.cmd')) + const placeholder = '$' + '{CLAUDE_PLUGIN_ROOT}' + const all = Object.values(hooks).flatMap((entries) => entries.flatMap((e) => e.hooks)) + for (const h of all) { + const args = (h.args ?? []).slice(4).join(' ') + const out = execFileSync(h.command, (h.args ?? []).map((a) => a.replace(placeholder, root)), { encoding: 'utf8', input: '{"hook_event_name":"Stop"}', windowsHide: true