From 724a9e352bb7062dda2402c9f094c884cc77ec1c Mon Sep 17 00:00:00 2001 From: Metinu Date: Tue, 20 Oct 2026 19:27:51 +0200 Subject: [PATCH] fix(narrator): a session nobody is at is not narrated A `claude -p` session (a script, a cron job, a bot that runs Claude Code) was narrated after every turn: a Haiku call and a voice in the room each time. Narrator now notes isInteractive at session.start, as Use Haiku does, and narrates only interactive sessions. A mod loaded in the middle of a session does not see session.start, so it keeps narrating. Claude-Session: https://claude.ai/code/session_01BYz64SVroXE7xu269had2Y --- .claude-plugin/marketplace.json | 2 +- plugins/narrator/.claude-plugin/plugin.json | 2 +- plugins/narrator/README.md | 2 +- plugins/narrator/hooks/register.test.tsx | 12 ++++++++++++ plugins/narrator/hooks/register.tsx | 11 ++++++++--- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index db209c6..28daa62 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -61,7 +61,7 @@ "name": "narrator", "source": "./plugins/narrator", "description": "Click ⏸︎ narrator off in the footer, or /narrator, and after each turn Claude tells you out loud what it did, in your language, with the best free voice your system has.", - "version": "0.5.0", + "version": "0.5.1", "category": "productivity" } ] diff --git a/plugins/narrator/.claude-plugin/plugin.json b/plugins/narrator/.claude-plugin/plugin.json index f2608ad..291f9f4 100644 --- a/plugins/narrator/.claude-plugin/plugin.json +++ b/plugins/narrator/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "narrator", - "version": "0.5.0", + "version": "0.5.1", "description": "Click ⏸︎ narrator off in the footer, or /narrator, and after each turn Claude tells you out loud what it did, in your language, with the best free voice your system has: Siri's on macOS, Narrator's on Windows, speech-dispatcher's on Linux.", "author": { "name": "Miguel Ángel Durán (midudev)", diff --git a/plugins/narrator/README.md b/plugins/narrator/README.md index d01cb9c..9e6c35d 100644 --- a/plugins/narrator/README.md +++ b/plugins/narrator/README.md @@ -24,7 +24,7 @@ When a turn ends, Haiku (at low effort, so it is quick) reads your request, the | WSL | Windows' voices, through `powershell.exe`; Linux's where that does not run | | Linux | `spd-say` (speech-dispatcher, with whatever module it is set up with: espeak-ng, RHVoice, Piper...), else `espeak-ng` or `espeak` | -A new prompt cuts the narration short. It stays on in your next sessions until you switch it off. +A new prompt cuts the narration short. It stays on in your next sessions until you switch it off. A session nobody is at (`claude -p`, as a script or a bot that runs Claude Code uses) is not narrated. ```text /narrator switch on or off diff --git a/plugins/narrator/hooks/register.test.tsx b/plugins/narrator/hooks/register.test.tsx index 59aaeba..449c5ff 100644 --- a/plugins/narrator/hooks/register.test.tsx +++ b/plugins/narrator/hooks/register.test.tsx @@ -502,6 +502,18 @@ test("a subagent's turn and an interrupted turn are not narrated", async ($, on) expect(w.prompts[0]).not.toContain('subagent') }) +test('a session nobody is at (claude -p) is not narrated: no model call, no speaker', async ($, on) => { + const w = world(on, { store: { enabled: true } }) + await $.session.start({ cwd: '/project', surface: 'terminal', isInteractive: false }) + await turn($) + await turn($, { answer: 'ok' }) + await recap($) + await w.settled() + expect(w.prompts).toHaveLength(0) + expect(w.spoken).toHaveLength(0) + expect(w.said).toHaveLength(0) +}) + test('a new prompt silences the narration on its way', async ($, on) => { const w = world(on, { store: { enabled: true }, speaker: 'talks on' }) await startSession($) diff --git a/plugins/narrator/hooks/register.tsx b/plugins/narrator/hooks/register.tsx index ede454e..4a564dc 100644 --- a/plugins/narrator/hooks/register.tsx +++ b/plugins/narrator/hooks/register.tsx @@ -498,6 +498,9 @@ function roomOf (e: { surface: string; viewport?: RenderViewport }) { } export const register: Register = (on, options) => { + // Whether someone is at the session. Until session.start says otherwise (a mod loaded in the middle + // of a session does not see it), someone is. + let isInteractive = true const speed = Number(options.speed) const shortAnswer = Number(options.shortAnswer ?? SHORT_ANSWER) settings = { @@ -509,6 +512,8 @@ export const register: Register = (on, options) => { } on('session.start', async ($, e, next) => { + // A session nobody is at (`claude -p`: a script, a bot that runs Claude Code) is not narrated. + isInteractive = e.isInteractive const asked = String(options.language ?? '').trim() if (asked && !settings.language) $.ui.log(`narrator: "${cut(asked, 40)}" is not a language code (es, en, pt...): speaking the language you write in`, { to: 'debug' }) await $.command.register({ @@ -566,7 +571,7 @@ export const register: Register = (on, options) => { on('tool.call', async ($, e, next) => { const result = await next(e) - if (e.agentId === undefined && actions.length < MAX_ACTIONS && (await read($, enabled))) { + if (isInteractive && e.agentId === undefined && actions.length < MAX_ACTIONS && (await read($, enabled))) { actions.push(describe(e) + (result.deny !== undefined ? ' (denied)' : result.isError ? ' (failed)' : '')) } return result @@ -574,14 +579,14 @@ export const register: Register = (on, options) => { on('turn.complete', async ($, e, next) => { const result = await next(e) - if (e.agentId === undefined && e.reason === 'answer' && e.answer.trim() && (await read($, enabled))) void narrate($, e.answer) + if (isInteractive && e.agentId === undefined && e.reason === 'answer' && e.answer.trim() && (await read($, enabled))) void narrate($, e.answer) return result }) on('command.run', { command: 'recap' }, async ($, e, next) => { silence() const result = await next(e) - if (result.text?.trim() && !NO_RECAP.test(result.text) && (await read($, enabled))) { + if (isInteractive && result.text?.trim() && !NO_RECAP.test(result.text) && (await read($, enabled))) { actions = [] void narrate($, result.text) }