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/CLAUDE.md b/CLAUDE.md index 323d9d4..f5769d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -579,6 +579,31 @@ 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), 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 + `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/.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/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/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..c031362 --- /dev/null +++ b/core/claude-plugin/hook.cmd @@ -0,0 +1,15 @@ +@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. +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 ( + 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..446cabb --- /dev/null +++ b/core/claude-plugin/hooks/hooks.json @@ -0,0 +1,464 @@ +{ + "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": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "working" + ], + "timeout": 5 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "working" + ], + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "working" + ], + "timeout": 5 + } + ] + } + ], + "PostToolUseFailure": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "working" + ], + "timeout": 5 + } + ] + } + ], + "SubagentStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "working" + ], + "timeout": 5 + } + ] + } + ], + "PreCompact": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "working" + ], + "timeout": 5 + } + ] + } + ], + "PermissionRequest": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "question" + ], + "timeout": 5 + } + ] + } + ], + "Elicitation": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "question" + ], + "timeout": 5 + } + ] + } + ], + "Notification": [ + { + "matcher": "permission_prompt", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "question" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "elicitation_dialog", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "question" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "elicitation_url_dialog", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "question" + ], + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "done" + ], + "timeout": 5 + } + ] + } + ], + "StopFailure": [ + { + "matcher": "rate_limit", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "rate_limit" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "overloaded", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "overloaded" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "authentication_failed", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "authentication_failed" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "oauth_org_not_allowed", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "oauth_org_not_allowed" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "account_on_hold", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "account_on_hold" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "billing_error", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "billing_error" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "invalid_request", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "invalid_request" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "model_not_found", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "model_not_found" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "server_error", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "server_error" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "max_output_tokens", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "max_output_tokens" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "cloud_credential_error", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "cloud_credential_error" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "unknown", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${CLAUDE_PLUGIN_ROOT}/hook.cmd", + "failed", + "unknown" + ], + "timeout": 5 + } + ] + }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "cmd.exe", + "args": [ + "/d", + "/c", + "call", + "${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.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.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..de2b97b 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,8 +288,18 @@ 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) + // 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 @@ -258,9 +323,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 +413,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 +431,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 +442,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 +472,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/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/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..91a1b9a --- /dev/null +++ b/core/renderer/lib/agentHookSignal.test.ts @@ -0,0 +1,127 @@ +import { execFileSync } from 'child_process' +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' + +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; args?: 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) + // 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(' ') } + }) + ) + ) + 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. 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 + }) + 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/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. 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/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", 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()