surshar / claude-code
Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.
Built by Anthropic · github.com/anthropics
| @@ -927,9 +927,7 @@ | ||
| 927 | 927 | result.deny === undefined && |
| 928 | 928 | result.isError !== true |
| 929 | 929 | |
| 930 | - const hasLanded = isEdit | |
| 931 | - ? hasEdited | |
| 932 | - : result === undefined || result.deny === undefined | |
| 930 | + const hasLanded = isEdit ? hasEdited : Tools.mayHaveWritten(result) | |
| 933 | 931 | |
| 934 | 932 | if (hasLanded) { |
| 935 | 933 | landed += 1 |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | export * from './editing-tools.js' |
| 2 | +export * from './may-have-written.js' | |
| 2 | 3 | export * from './shell-tools.js' |
| 3 | 4 | export * from './todo-tool.js' |
| 4 | 5 |
| @@ -1,0 +1,23 @@ | ||
| 1 | +import type { ResultOf } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Whether a shell tool's outcome may have changed the working tree, as the | |
| 5 | + * built-in gates its refresh: a throw or an answer, unless the engine says | |
| 6 | + * the tool held the call read-only (`ls`, `git status`); never a deny. | |
| 7 | + * | |
| 8 | + * `isReadOnly` is read through a type that may lack it, ahead of the | |
| 9 | + * declarations that name it; absent, the call counts as a write. | |
| 10 | + * | |
| 11 | + * @param result what `next` resolved to, or undefined when it threw | |
| 12 | + * @returns true when a refresh is due | |
| 13 | + */ | |
| 14 | +export function mayHaveWritten( | |
| 15 | + result: ResultOf['tool.call'] | undefined, | |
| 16 | +): boolean { | |
| 17 | + const settled: { deny?: string; isReadOnly?: boolean } | undefined = result | |
| 18 | + | |
| 19 | + return ( | |
| 20 | + settled === undefined || | |
| 21 | + (settled.deny === undefined && settled.isReadOnly !== true) | |
| 22 | + ) | |
| 23 | +} |
| @@ -40,6 +40,7 @@ | ||
| 40 | 40 | export * from './pane-view' |
| 41 | 41 | export * from './parse-file-diff' |
| 42 | 42 | export * from './poll-word.js' |
| 43 | +export * from './read-only-answer.js' | |
| 43 | 44 | export * from './renamed.js' |
| 44 | 45 | export * from './repository.js' |
| 45 | 46 | export * from './repository-of' |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import type { ResultOf } from 'claude-code' | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * A shell tool's answer as the engine gives it for a command the tool holds | |
| 5 | + * read-only (`ls`, `git status`): the result, and `isReadOnly` beside it. | |
| 6 | + * | |
| 7 | + * Built through Object.assign, since the declarations may not name the | |
| 8 | + * field yet; the plugin reads it through a type that may lack it too. | |
| 9 | + */ | |
| 10 | +export const READ_ONLY_ANSWER: ResultOf['tool.call'] = Object.assign( | |
| 11 | + { result: 'listed' }, | |
| 12 | + { isReadOnly: true as const }, | |
| 13 | +) |
| @@ -121,6 +121,41 @@ | ||
| 121 | 121 | ) |
| 122 | 122 | }) |
| 123 | 123 | |
| 124 | + test('a shell command the tool held read-only fetches nothing', async ($, on) => { | |
| 125 | + const world = Fixtures.inRepository(on) | |
| 126 | + | |
| 127 | + const fetchesSince = (read: number) => | |
| 128 | + world.runs.slice(read).filter(run => run.argv.includes('--numstat')) | |
| 129 | + .length | |
| 130 | + | |
| 131 | + on('tool.call', ($, e) => | |
| 132 | + e.tool === 'Bash' && e.command === 'ls' | |
| 133 | + ? Fixtures.READ_ONLY_ANSWER | |
| 134 | + : { result: 'done' }, | |
| 135 | + ) | |
| 136 | + | |
| 137 | + await $.session.start(Fixtures.SESSION) | |
| 138 | + await $.command.run(Fixtures.DIFF) | |
| 139 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 140 | + | |
| 141 | + const opened = world.runs.length | |
| 142 | + | |
| 143 | + await $.tool.call({ tool: 'Bash', command: 'ls' }) | |
| 144 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 145 | + | |
| 146 | + expect( | |
| 147 | + fetchesSince(opened), | |
| 148 | + "read-only, the pane open: nothing, as the built-in's touch is gated", | |
| 149 | + ).toBe(0) | |
| 150 | + | |
| 151 | + const listed = world.runs.length | |
| 152 | + | |
| 153 | + await $.tool.call({ tool: 'Bash', command: 'make' }) | |
| 154 | + await world.clock.advance(Fixtures.SETTLE_MS) | |
| 155 | + | |
| 156 | + expect(fetchesSince(listed), 'a command that may write: one fetch').toBe(1) | |
| 157 | + }) | |
| 158 | + | |
| 124 | 159 | test('/diff whose probe never answers probes once more', async ($, on) => { |
| 125 | 160 | const probes: (readonly string[])[] = [] |
| 126 | 161 | const clock = Fixtures.startsSession(on) |