From 7950d7722cc96fc4b94259bd7f2036457fc95e1e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 06:49:50 +0000 Subject: [PATCH] fix(statusline): link PRs from tool.call so cc-plugin-sec-default can't skip it On Team and Enterprise plans Claude Code's built-in cc-plugin-sec-default seats itself in the outermost (prepend) tier and answers every classic.* event with next.to(e, "append"), skipping the user tier where installed mods sit. The mod's classic.PostToolUse hook therefore never ran there, and PRs opened with gh pr create were never linked to the session. Read the Bash call from a tool.call hook instead, which sec-default does not hook. Only successful, unrefused calls count, as PostToolUse did, and the session id now comes from $.session.id(). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W1cER89rF43oNjGycbv9WL --- plugins/shelltime-statusline/README.md | 3 +- .../hooks/pullRequests.test.ts | 100 +++++++++++------- .../hooks/pullRequests.ts | 18 ++-- .../shelltime-statusline/hooks/register.tsx | 20 +++- 4 files changed, 84 insertions(+), 57 deletions(-) diff --git a/plugins/shelltime-statusline/README.md b/plugins/shelltime-statusline/README.md index 863e3c6..b5274f5 100644 --- a/plugins/shelltime-statusline/README.md +++ b/plugins/shelltime-statusline/README.md @@ -66,7 +66,8 @@ The CLI hands the URLs to the ShellTime daemon, which sends them to ShellTime, a - The mod looks for `~/.shelltime/bin/shelltime` first, then `shelltime` on `PATH`. - Each URL is sent once per session. If the CLI can't be started, the next `gh pr create` that prints the URL tries again, and the failure goes to Claude Code's debug log. - If the CLI can't be found, or is too old to have `cc pr`, nothing is linked. Errors from the CLI itself (not logged in, server unreachable) go to `~/.shelltime/log.log`. -- This runs from a `PostToolUse` hook, in the background, after the tool returns. It never delays or changes the Bash result Claude sees. +- This runs from a `tool.call` hook on Bash, in the background, after the tool returns. It never delays or changes the Bash result Claude sees. Only a call that succeeded counts. +- It is not a `PostToolUse` hook because on Team and Enterprise plans Claude Code's built-in `cc-plugin-sec-default` runs before every installed plugin and sends `PostToolUse` (every `classic.*` event) on past them, so such a hook never runs there. ### Session cost comment diff --git a/plugins/shelltime-statusline/hooks/pullRequests.test.ts b/plugins/shelltime-statusline/hooks/pullRequests.test.ts index 6138492..9bfb4b6 100644 --- a/plugins/shelltime-statusline/hooks/pullRequests.test.ts +++ b/plugins/shelltime-statusline/hooks/pullRequests.test.ts @@ -1,6 +1,6 @@ import { expect, mock, test } from 'claude-code/testing' import type { Engine } from 'claude-code/testing' -import type { On } from 'claude-code' +import type { On, ToolCallResult } from 'claude-code' import { createdPullRequestUrls, extractPullRequestUrls, isPrCreateCommand } from './pullRequests' @@ -12,34 +12,38 @@ function ran(exitCode = 0, stderr = '') { return { value: { exitCode, stdout: '', stderr, isStdoutTruncated: false, isStderrTruncated: false } } } -// Beneath the plugin: HOME, a clock, and no settings hooks to run. +// What the next Bash call answers, and the session it runs in. +type Shell = { sessionId: string; answer: ToolCallResult } + +// Beneath the plugin: HOME, a clock, the session, and a Bash that answers +// whatever the test last set. function base(on: On) { mock.env(on, { HOME: '/home/me' }) - on('classic.PostToolUse', () => ({})) - return mock.clock(on, { now: Date.UTC(2026, 9, 8, 12) }) + const shell: Shell = { sessionId: 'sess-1', answer: { result: {} } } + on('session.id', () => ({ value: shell.sessionId })) + on('tool.call', () => shell.answer) + return { clock: mock.clock(on, { now: Date.UTC(2026, 9, 8, 12) }), shell } } -// The world beneath the plugin, with every command it runs recorded; `missing` -// names executables that cannot start. +// The world beneath the plugin, with every `shelltime` command it runs +// recorded (the statusline's own `git` refresh is answered and left out); +// `missing` names executables that cannot start. function world(on: On, missing: string[] = []) { - const clock = base(on) + const { clock, shell } = base(on) const runs: string[][] = [] on('process.run', ($, e) => { + if (e.argv[0] === 'git') return ran(128) runs.push([...e.argv]) if (missing.includes(e.argv[0] ?? '')) throw new Error(`spawn ${e.argv[0]} ENOENT`) return ran() }) - return { clock, runs } + return { clock, shell, runs } } -function bash($: Engine, command: string, stdout: string, session_id = 'sess-1') { - return $.classic.PostToolUse({ - session_id, - tool_name: 'Bash', - tool_input: { command, description: 'Open a PR' }, - tool_response: { stdout, stderr: '', interrupted: false }, - tool_use_id: 'toolu_1', - }) +function bash($: Engine, shell: Shell, command: string, stdout: string, sessionId = 'sess-1') { + shell.sessionId = sessionId + shell.answer = { result: { stdout, stderr: '', interrupted: false } } + return $.tool.call({ tool: 'Bash', command, description: 'Open a PR' }) } test('spots gh pr create and the PR URLs it printed', () => { @@ -56,51 +60,66 @@ test('spots gh pr create and the PR URLs it printed', () => { ]) expect(extractPullRequestUrls('https://github.com/o/r/issues/1')).toEqual([]) - expect(createdPullRequestUrls('Bash', { command: 'gh pr create' }, { stdout: `${PR_1}\n` })).toEqual([PR_1]) - expect(createdPullRequestUrls('Bash', { command: 'gh pr view 1' }, { stdout: `${PR_1}\n` })).toEqual([]) - expect(createdPullRequestUrls('Read', { command: 'gh pr create' }, { stdout: `${PR_1}\n` })).toEqual([]) - expect(createdPullRequestUrls('Bash', null, undefined)).toEqual([]) + expect(createdPullRequestUrls('gh pr create', `${PR_1}\n`)).toEqual([PR_1]) + expect(createdPullRequestUrls('gh pr view 1', `${PR_1}\n`)).toEqual([]) + expect(createdPullRequestUrls('gh pr create', '')).toEqual([]) }) test('links every PR a gh pr create printed to the session, once', async ($, on) => { - const { clock, runs } = world(on) + const { clock, shell, runs } = world(on) - await bash($, `gh pr create --fill && cd ../web && gh pr create --fill`, `${PR_1}\n${PR_2}\n`) + await bash($, shell, `gh pr create --fill && cd ../web && gh pr create --fill`, `${PR_1}\n${PR_2}\n`) await clock.settle() expect(runs).toEqual([[CLI, 'cc', 'pr', '--session-id', 'sess-1', PR_1, PR_2]]) // The same PR printed again (say, by a retry) is not sent twice. - await bash($, 'gh pr create --fill', `${PR_1}\n`) + await bash($, shell, 'gh pr create --fill', `${PR_1}\n`) await clock.settle() expect(runs).toHaveLength(1) // Another session that opens the same PR still gets it. - await bash($, 'gh pr create --fill', `${PR_1}\n`, 'sess-2') + await bash($, shell, 'gh pr create --fill', `${PR_1}\n`, 'sess-2') await clock.settle() expect(runs[1]).toEqual([CLI, 'cc', 'pr', '--session-id', 'sess-2', PR_1]) }) +test('the Bash result reaches the model unchanged', async ($, on) => { + const { clock, shell } = world(on) + + const result = await bash($, shell, 'gh pr create --fill', `${PR_1}\n`) + await clock.settle() + + expect(result).toEqual({ result: { stdout: `${PR_1}\n`, stderr: '', interrupted: false } }) +}) + test('leaves other commands and tools alone', async ($, on) => { - const { clock, runs } = world(on) - - await bash($, `gh pr view ${PR_1}`, `${PR_1}\n`) - await bash($, 'gh pr create --fill', 'no url here\n') - await $.classic.PostToolUse({ - session_id: 'sess-1', - tool_name: 'Read', - tool_input: { file_path: '/work/notes.md' }, - tool_response: { stdout: `gh pr create ${PR_1}` }, - tool_use_id: 'toolu_2', - }) + const { clock, shell, runs } = world(on) + + await bash($, shell, `gh pr view ${PR_1}`, `${PR_1}\n`) + await bash($, shell, 'gh pr create --fill', 'no url here\n') + shell.answer = { result: { stdout: `gh pr create ${PR_1}` } } + await $.tool.call({ tool: 'Read', file_path: '/work/notes.md' }) + await clock.settle() + + expect(runs).toEqual([]) +}) + +test('a gh pr create that failed or was refused links nothing', async ($, on) => { + const { clock, shell, runs } = world(on) + + shell.answer = { isError: true, result: `${PR_1}\nExit code 1`, text: `${PR_1}\nExit code 1` } + await $.tool.call({ tool: 'Bash', command: 'gh pr create --fill && false', description: 'Open a PR' }) + shell.answer = { deny: 'not here' } + await $.tool.call({ tool: 'Bash', command: 'gh pr create --fill', description: 'Open a PR' }) await clock.settle() expect(runs).toEqual([]) }) test('falls back to shelltime on PATH when ~/.shelltime/bin has none', async ($, on) => { - const { clock, runs } = world(on, [CLI]) + const { clock, shell, runs } = world(on, [CLI]) - await bash($, 'gh pr create --fill', `${PR_1}\n`) + await bash($, shell, 'gh pr create --fill', `${PR_1}\n`) await clock.settle() expect(runs).toEqual([ [CLI, 'cc', 'pr', '--session-id', 'sess-1', PR_1], @@ -109,16 +128,17 @@ test('falls back to shelltime on PATH when ~/.shelltime/bin has none', async ($, }) test('a PR the CLI could not link is tried again next time', async ($, on) => { - const clock = base(on) + const { clock, shell } = base(on) const runs: string[][] = [] on('process.run', ($, e) => { + if (e.argv[0] === 'git') return ran(128) runs.push([...e.argv]) return runs.length === 1 ? ran(1, 'not logged in') : ran() }) - await bash($, 'gh pr create --fill', `${PR_1}\n`) + await bash($, shell, 'gh pr create --fill', `${PR_1}\n`) await clock.settle() - await bash($, 'gh pr create --fill', `${PR_1}\n`) + await bash($, shell, 'gh pr create --fill', `${PR_1}\n`) await clock.settle() expect(runs).toEqual([ diff --git a/plugins/shelltime-statusline/hooks/pullRequests.ts b/plugins/shelltime-statusline/hooks/pullRequests.ts index 0fdd257..c536f65 100644 --- a/plugins/shelltime-statusline/hooks/pullRequests.ts +++ b/plugins/shelltime-statusline/hooks/pullRequests.ts @@ -15,18 +15,12 @@ export function extractPullRequestUrls(stdout: string): string[] { return [...new Set(stdout.match(PR_URL) ?? [])] } -function stringField(value: unknown, key: string): string { - if (typeof value !== 'object' || value === null) return '' - const field = (value as Record)[key] - return typeof field === 'string' ? field : '' -} - -// The URLs a PostToolUse event's Bash call printed when it ran `gh pr create`; -// none for any other tool or command. stdout only: `gh` reports an existing PR -// for the branch on stderr, and that one wasn't opened here. -export function createdPullRequestUrls(toolName: string, toolInput: unknown, toolResponse: unknown): string[] { - if (toolName !== 'Bash' || !isPrCreateCommand(stringField(toolInput, 'command'))) return [] - return extractPullRequestUrls(stringField(toolResponse, 'stdout')) +// The URLs a Bash call printed when it ran `gh pr create`; none for any other +// command. stdout only: `gh` reports an existing PR for the branch on stderr, +// and that one wasn't opened here. +export function createdPullRequestUrls(command: string, stdout: string): string[] { + if (!isPrCreateCommand(command)) return [] + return extractPullRequestUrls(stdout) } export function ccPrArgs(sessionId: string, urls: readonly string[]): string[] { diff --git a/plugins/shelltime-statusline/hooks/register.tsx b/plugins/shelltime-statusline/hooks/register.tsx index 778c6c1..dbd8f3f 100644 --- a/plugins/shelltime-statusline/hooks/register.tsx +++ b/plugins/shelltime-statusline/hooks/register.tsx @@ -285,11 +285,23 @@ export const register: Register = on => { return result }) - on('classic.PostToolUse', async ($, e, next) => { - const result = await next(e) + // tool.call, not classic.PostToolUse: on Team and Enterprise plans the + // built-in cc-plugin-sec-default sits above every installed plugin and sends + // each classic.* event past them, so a classic hook here never runs. + on('tool.call', { tool: 'Bash' }, async ($, e, next) => { + const ran = await next(e) + // Only a call that succeeded, as PostToolUse saw it: a failed or refused + // one opened nothing to link. + const urls = + ran.deny === undefined && ran.isError !== true ? createdPullRequestUrls(e.command, ran.result.stdout) : [] // In the background: the hook never holds up or changes the tool's result. - void linkPullRequests($, e.session_id, createdPullRequestUrls(e.tool_name, e.tool_input, e.tool_response)) - return result + if (urls.length > 0) { + void $.session.id().then( + sessionId => linkPullRequests($, sessionId, urls), + err => logOnce($, 'pull-request link', err), + ) + } + return ran }) on('session.end', async ($, e, next) => {