@tianmucreations/jeeves 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +79 -18
  2. package/bin/jeeves +8 -1
  3. package/dist/agent/auto-ids.js +66 -0
  4. package/dist/agent/auto.js +178 -0
  5. package/dist/agent/context.js +55 -13
  6. package/dist/agent/errors.js +83 -22
  7. package/dist/agent/expert-chat.js +33 -0
  8. package/dist/agent/housekeeping.js +55 -0
  9. package/dist/agent/loop.js +168 -12
  10. package/dist/agent/permissions.js +167 -0
  11. package/dist/agent/research-gate.js +267 -0
  12. package/dist/agent/review.js +135 -0
  13. package/dist/agent/spending.js +73 -0
  14. package/dist/agent/systemPrompt.js +112 -0
  15. package/dist/app.js +25 -10
  16. package/dist/checkpoints/index.js +103 -0
  17. package/dist/checkpoints/store.js +239 -0
  18. package/dist/commands/address.js +5 -0
  19. package/dist/commands/clear.js +2 -0
  20. package/dist/commands/help.js +7 -4
  21. package/dist/commands/keys.js +1 -1
  22. package/dist/commands/verbose.js +1 -1
  23. package/dist/components/AddressPrompt.js +31 -0
  24. package/dist/components/Footer.js +74 -102
  25. package/dist/components/Input.js +76 -25
  26. package/dist/components/KeysManager.js +65 -20
  27. package/dist/components/ModelPicker.js +348 -75
  28. package/dist/components/ProjectPicker.js +4 -1
  29. package/dist/components/Transcript.js +29 -14
  30. package/dist/components/input-layout.js +34 -0
  31. package/dist/components/transcript-layout.js +13 -17
  32. package/dist/index.js +25 -7
  33. package/dist/ink/AlternateScreen.js +33 -16
  34. package/dist/ink/cursor.js +18 -0
  35. package/dist/ink/mouse.js +48 -0
  36. package/dist/keys/store.js +2 -1
  37. package/dist/models/registry.js +18 -2
  38. package/dist/platform/config.js +63 -7
  39. package/dist/providers/catalogue.js +293 -0
  40. package/dist/providers/direct-services.js +65 -0
  41. package/dist/providers/direct.js +145 -0
  42. package/dist/providers/index.js +123 -13
  43. package/dist/providers/models-snapshot.js +1037 -0
  44. package/dist/providers/ollama.js +21 -4
  45. package/dist/providers/openrouter.js +39 -4
  46. package/dist/providers/step-control.js +28 -0
  47. package/dist/providers/zai.js +31 -11
  48. package/dist/state/session.js +90 -36
  49. package/dist/state/today-spend.js +26 -0
  50. package/dist/tools/index.js +118 -10
  51. package/dist/tools/runBash.js +58 -11
  52. package/dist/tools/web/htmlToText.js +32 -0
  53. package/dist/tools/web/openrouterChat.js +31 -0
  54. package/dist/tools/web/research.js +191 -0
  55. package/package.json +32 -6
@@ -0,0 +1,33 @@
1
+ import { generateText } from 'ai';
2
+ import { session } from '../state/session.js';
3
+ import { getOpenRouterKey, serviceKey } from '../providers/index.js';
4
+ import { openrouterChat } from '../tools/web/openrouterChat.js';
5
+ import { isDirectService } from '../providers/direct-services.js';
6
+ import { modelFactory } from '../providers/direct.js';
7
+ import { estimateCost, priceOf } from '../providers/catalogue.js';
8
+ import { reportSpend } from './spending.js';
9
+ export const expertChat = async (model, messages, maxTokens) => {
10
+ const providerId = session.providerId;
11
+ if (providerId === 'openrouter') {
12
+ const key = getOpenRouterKey();
13
+ if (!key)
14
+ throw new Error('the expert needs an OpenRouter key');
15
+ return (await openrouterChat(key, { model, messages, max_tokens: maxTokens })).text;
16
+ }
17
+ if (isDirectService(providerId)) {
18
+ const key = serviceKey(providerId);
19
+ if (!key)
20
+ throw new Error('the expert needs a key for this service');
21
+ const result = await generateText({
22
+ model: modelFactory(providerId, key)(model),
23
+ instructions: messages.filter((m) => m.role === 'system').map((m) => m.content).join('\n\n') || undefined,
24
+ messages: messages.filter((m) => m.role === 'user').map((m) => ({ role: 'user', content: m.content })),
25
+ // Thinking models spend part of the allowance before answering, so it is larger here.
26
+ maxOutputTokens: maxTokens * 4,
27
+ abortSignal: AbortSignal.timeout(120_000),
28
+ });
29
+ reportSpend(estimateCost(priceOf(providerId, model), result.usage), true);
30
+ return result.text.trim();
31
+ }
32
+ throw new Error('no expert is available for this service');
33
+ };
@@ -0,0 +1,55 @@
1
+ // Quiet housekeeping that keeps the conversation small, because every message
2
+ // re-sends the whole conversation. Modelled on Anthropic's published context
3
+ // editing (platform.claude.com/docs/en/build-with-claude/context-editing): old
4
+ // tool results are cleared first - they are the bulk, and a file can simply be
5
+ // read again - keeping the most recent 3, and only when enough is freed to be
6
+ // worth losing the prompt-cache discount for one message. The trigger and minimum
7
+ // are the figures from Anthropic's own worked example (30,000 and 5,000 tokens);
8
+ // their default trigger is 100,000, which is where summarising starts.
9
+ export const CLEAR_TRIGGER_TOKENS = 30_000;
10
+ export const CLEAR_AT_LEAST_TOKENS = 5_000;
11
+ export const KEEP_RECENT_TOOL_RESULTS = 3;
12
+ export const SUMMARY_TRIGGER_TOKENS = 100_000;
13
+ export const CLEARED_PLACEHOLDER = '[Earlier output removed to keep the conversation small. Run the tool again if it is needed.]';
14
+ // The same rough measure used elsewhere in the app: about 4 characters per token.
15
+ export function estimateTokens(value) {
16
+ return Math.ceil(JSON.stringify(value).length / 4);
17
+ }
18
+ export function clearOldToolResults(messages) {
19
+ if (estimateTokens(messages) < CLEAR_TRIGGER_TOKENS)
20
+ return { messages, freedTokens: 0 };
21
+ const positions = [];
22
+ messages.forEach((message, messageIndex) => {
23
+ if (message.role !== 'tool' || !Array.isArray(message.content))
24
+ return;
25
+ message.content.forEach((part, partIndex) => {
26
+ if (part.type === 'tool-result')
27
+ positions.push({ message: messageIndex, part: partIndex });
28
+ });
29
+ });
30
+ const older = positions.slice(0, Math.max(0, positions.length - KEEP_RECENT_TOOL_RESULTS));
31
+ let freed = 0;
32
+ const copy = messages.map((message) => message.role === 'tool' && Array.isArray(message.content) ? { ...message, content: [...message.content] } : message);
33
+ for (const { message, part } of older) {
34
+ const content = copy[message].content;
35
+ const result = content[part];
36
+ if (result.output?.type === 'text' && result.output.value === CLEARED_PLACEHOLDER)
37
+ continue;
38
+ const before = estimateTokens(result.output);
39
+ const replacement = { type: 'text', value: CLEARED_PLACEHOLDER };
40
+ freed += before - estimateTokens(replacement);
41
+ content[part] = { ...result, output: replacement };
42
+ }
43
+ if (freed < CLEAR_AT_LEAST_TOKENS)
44
+ return { messages, freedTokens: 0 };
45
+ return { messages: copy, freedTokens: freed };
46
+ }
47
+ // Anthropic's structured summary (the five parts of their compaction prompt), so a
48
+ // summary keeps what matters for carrying on with the job.
49
+ export const SUMMARY_INSTRUCTIONS = `Summarise the conversation so far so the work can continue from the summary alone. Use exactly these headings:
50
+ 1. Task overview - what the person asked for and what counts as done.
51
+ 2. Current state - what has been completed, which files were changed or created.
52
+ 3. Important discoveries - facts checked, decisions made, errors met and how they were resolved, approaches that failed.
53
+ 4. Next steps - what remains, in order, and anything blocking it.
54
+ 5. Context to preserve - the person's preferences, how they like to be addressed, promises made.
55
+ Be specific: keep exact file names, figures, and quoted facts. Reply with only the summary.`;
@@ -1,12 +1,29 @@
1
1
  import { session } from '../state/session.js';
2
2
  import { getActiveProvider, refreshCredit } from '../providers/index.js';
3
3
  import { getTools } from '../tools/index.js';
4
- import { buildTurnMessages } from './context.js';
4
+ import { buildTurnMessages, getSystemPrompt, contextLimitFor, summaryDue, summariseHistory } from './context.js';
5
5
  import { plainError } from './errors.js';
6
6
  import { toggleVerbose } from '../commands/verbose.js';
7
7
  import { openModelPicker } from '../commands/model.js';
8
8
  import { clearConversation } from '../commands/clear.js';
9
+ import { openAddressPrompt } from '../commands/address.js';
9
10
  import { isToolCapable } from '../models/filter.js';
11
+ import { autoCatalogue } from './auto.js';
12
+ import { isAuto, workingModelId, workerModel, expertModel, topModel, AUTO_NOTE, shouldTakeOver, createAskExpertTool, newAutoTurnState, topModelPriceRatio, topModelQuestion, REVIEW_FINISHED_JOBS } from './auto.js';
13
+ import { jobNeedsReview, reviewJob, fixRequest, startReproducing, stopReproducing, UNCHECKED_NOTICE } from './review.js';
14
+ import { requestApproval } from './permissions.js';
15
+ import { clearOldToolResults } from './housekeeping.js';
16
+ import { startJob, endJob, reportStepCost, withinLimits } from './spending.js';
17
+ import { getAddress } from '../platform/config.js';
18
+ import { startTurnCheckpoints, undoLastChange } from '../checkpoints/index.js';
19
+ import { noteSkipRequest } from './research-gate.js';
20
+ // Added to the rulebook when the chosen model cannot use tools, so a task request
21
+ // gets a plain answer instead of a pretend attempt.
22
+ export const CHAT_ONLY_NOTE = `
23
+
24
+ Chat-Only Model
25
+
26
+ The model currently selected can only chat. For now you have no tools: you cannot read files, write files, list folders, or run commands, whatever the sections above say. If you are asked to do something that needs them, say plainly that the model in use can only chat, and suggest typing /model to choose one that can do tasks. Never pretend to have done it.`;
10
27
  const DISCONNECTING = new Set(['auth', 'network', 'payment']);
11
28
  export async function runTurn(input) {
12
29
  if (input.startsWith('/') && input.length > 1 && !input.startsWith('/ ')) {
@@ -22,6 +39,27 @@ export async function runTurn(input) {
22
39
  else if (input === '/verbose') {
23
40
  session.addNotice(toggleVerbose());
24
41
  }
42
+ else if (input === '/address') {
43
+ openAddressPrompt();
44
+ }
45
+ else if (input === '/undo') {
46
+ if (session.status === 'working') {
47
+ session.addNotice('Undo works between tasks - wait for this one to finish, then type /undo.');
48
+ }
49
+ else {
50
+ try {
51
+ const outcome = await undoLastChange();
52
+ session.addNotice(outcome.message);
53
+ if (outcome.historyNote)
54
+ session.pendingContextNote = outcome.historyNote;
55
+ }
56
+ catch (error) {
57
+ session.addError("Undo didn't work this time - nothing was changed. Please try /undo again.");
58
+ if (session.verbose)
59
+ session.addNotice(`Technical details: ${error instanceof Error ? error.message : String(error)}`);
60
+ }
61
+ }
62
+ }
25
63
  else if (input === '/clear') {
26
64
  clearConversation();
27
65
  }
@@ -29,24 +67,87 @@ export async function runTurn(input) {
29
67
  session.requestExit();
30
68
  }
31
69
  else {
32
- session.addNotice('Unknown command. Try /help.');
70
+ session.addNotice("I don't know that command - type /help to see them all.");
33
71
  }
34
72
  return;
35
73
  }
36
74
  session.addUser(input);
75
+ // "Skip the research" must come from the person, so it is read from their own words.
76
+ const skipped = noteSkipRequest(input);
77
+ if (skipped)
78
+ session.addNotice(skipped);
79
+ startTurnCheckpoints(input);
80
+ startJob();
81
+ // Nothing is sent once today's limit is reached, unless the person agrees.
82
+ if (!(await withinLimits())) {
83
+ endJob();
84
+ session.addNotice('Stopped - nothing was sent.');
85
+ return;
86
+ }
87
+ // Quiet housekeeping: old tool output is cleared, and a long conversation summarised.
88
+ const cleared = clearOldToolResults(session.history);
89
+ if (cleared.freedTokens > 0)
90
+ session.setHistory(cleared.messages);
91
+ const modelId = workingModelId(session.model);
92
+ if (summaryDue(session.estimateContextTokens(), contextLimitFor(modelId, session.models))) {
93
+ await summariseHistory();
94
+ }
95
+ const auto = isAuto(session.model);
96
+ const autoState = newAutoTurnState();
97
+ session.setActiveModel(auto ? workerModel() : null);
98
+ const stop = new AbortController();
99
+ let countedSteps = 0;
37
100
  session.beginTurn();
38
101
  session.setStatus('working');
102
+ const turnStart = session.transcript.length;
39
103
  let assistantId = null;
40
104
  try {
41
105
  const provider = getActiveProvider();
42
- const messages = buildTurnMessages(session.history, input);
106
+ // A note from /undo travels with the next message, so the model knows files changed back.
107
+ const note_ = session.pendingContextNote;
108
+ session.pendingContextNote = null;
109
+ const messages = buildTurnMessages(session.history, note_ ? `${note_}\n\n${input}` : input);
43
110
  // Models without tool support get a tool-free chat mode automatically (spec 4.2).
44
- const currentModel = session.models.find((model) => model.id === session.model);
45
- const tools = !currentModel || isToolCapable(currentModel) ? getTools() : {};
46
- const result = await provider.stream({
47
- modelId: session.model,
111
+ const currentModel = session.models.find((model) => model.id === modelId);
112
+ const toolCapable = !currentModel || isToolCapable(currentModel);
113
+ const tools = toolCapable ? { ...getTools(), ...(auto ? { askExpert: createAskExpertTool(autoState) } : {}) } : {};
114
+ const note = !toolCapable ? CHAT_ONLY_NOTE : auto ? AUTO_NOTE.replaceAll('{{ADDRESS}}', getAddress() ?? 'Sir') : '';
115
+ const streamOptions = {
116
+ modelId,
48
117
  messages,
49
118
  tools,
119
+ instructions: getSystemPrompt() + note,
120
+ abortSignal: stop.signal,
121
+ beforeStep: async ({ stepFailures, stepCosts, messages: stepMessages }) => {
122
+ for (const cost of stepCosts.slice(countedSteps))
123
+ reportStepCost(cost);
124
+ countedSteps = stepCosts.length;
125
+ if (!(await withinLimits())) {
126
+ stop.abort();
127
+ return {};
128
+ }
129
+ // In Auto mode the expert takes over the rest of a job the worker keeps failing.
130
+ // If the expert keeps failing too, Jeeves asks before trying the strongest model.
131
+ let stepModel;
132
+ const expert = expertModel();
133
+ const strongest = topModel();
134
+ if (auto && expert && !autoState.expertTookOver && shouldTakeOver(stepFailures))
135
+ autoState.expertTookOver = true;
136
+ // (The expert can also take over by saying so when consulted.)
137
+ if (autoState.expertTookOver && autoState.takeoverStep < 0)
138
+ autoState.takeoverStep = stepFailures.length;
139
+ if (auto && strongest && autoState.expertTookOver && !autoState.askedAboutTop && shouldTakeOver(stepFailures.slice(autoState.takeoverStep))) {
140
+ autoState.askedAboutTop = true;
141
+ session.addNotice(topModelQuestion(getAddress() ?? 'Sir', topModelPriceRatio(autoCatalogue())));
142
+ autoState.onTopModel = await requestApproval();
143
+ }
144
+ if (auto && autoState.expertTookOver && expert) {
145
+ stepModel = autoState.onTopModel && strongest ? strongest : expert;
146
+ session.setActiveModel(stepModel);
147
+ }
148
+ const tidied = clearOldToolResults(stepMessages);
149
+ return { modelId: stepModel, messages: tidied.freedTokens > 0 ? tidied.messages : undefined };
150
+ },
50
151
  onToken: (token) => {
51
152
  if (assistantId === null)
52
153
  assistantId = session.startAssistant();
@@ -57,28 +158,83 @@ export async function runTurn(input) {
57
158
  session.appendReasoning(delta);
58
159
  },
59
160
  onToolCall: () => {
60
- // Hide pre-tool chatter so only the final answer stays visible (spec 2.3).
61
- if (assistantId !== null)
161
+ // Hide pre-tool chatter so only the final answer stays visible (spec 2.3). The
162
+ // entry is removed, not just emptied, so the final answer appears below the
163
+ // actions it reports on rather than above them.
164
+ if (assistantId !== null) {
62
165
  session.setAssistantText(assistantId, '');
166
+ session.finishAssistant(assistantId);
167
+ assistantId = null;
168
+ }
63
169
  session.closeReasoningEntry();
64
170
  },
65
- });
171
+ };
172
+ let uncheckedNotice = false;
173
+ let result = await provider.stream(streamOptions);
174
+ let allMessages = [...messages, ...result.messages];
175
+ // Auto's double-check, once, when this job changed a program or wrote a document
176
+ // (where the cheap worker's mistakes were measured - see review.ts).
177
+ if (auto && REVIEW_FINISHED_JOBS && jobNeedsReview(session.transcript.slice(turnStart)) && !stop.signal.aborted) {
178
+ for (const cost of (result.stepCosts ?? []).slice(countedSteps))
179
+ reportStepCost(cost);
180
+ countedSteps = result.stepCosts?.length ?? countedSteps;
181
+ const review = await reviewJob(allMessages);
182
+ if (review.kind === 'unavailable') {
183
+ uncheckedNotice = true;
184
+ }
185
+ else if (review.kind === 'problems') {
186
+ countedSteps = 0;
187
+ if (assistantId !== null)
188
+ session.setAssistantText(assistantId, '');
189
+ const fixMessages = [...allMessages, { role: 'user', content: fixRequest(review.problems) }];
190
+ // Changing files waits until the worker has reproduced a problem.
191
+ startReproducing();
192
+ try {
193
+ result = await provider.stream({ ...streamOptions, messages: fixMessages });
194
+ }
195
+ finally {
196
+ stopReproducing();
197
+ }
198
+ allMessages = [...fixMessages, ...result.messages];
199
+ }
200
+ }
66
201
  if (assistantId === null)
67
202
  assistantId = session.startAssistant();
68
203
  session.setAssistantText(assistantId, result.text);
69
204
  session.finishAssistant(assistantId);
70
- session.setHistory([...messages, ...result.messages]);
205
+ if (uncheckedNotice)
206
+ session.addNotice(UNCHECKED_NOTICE);
207
+ session.setHistory(allMessages);
71
208
  session.setLastReasoning(result.reasoning);
72
209
  session.addUsage(result.usage.input, result.usage.output, result.cost, result.usage.cached ?? 0);
73
210
  session.setRateLimit(result.rateLimit);
74
211
  void refreshCredit();
212
+ for (const cost of (result.stepCosts ?? []).slice(countedSteps))
213
+ reportStepCost(cost);
214
+ session.setPlanResetAt(null);
215
+ session.setActiveModel(auto ? workerModel() : null);
216
+ endJob();
75
217
  session.setStatus('idle');
76
218
  }
77
219
  catch (error) {
78
- const plain = plainError(error);
220
+ endJob();
221
+ session.setActiveModel(auto ? workerModel() : null);
222
+ if (stop.signal.aborted) {
223
+ if (assistantId !== null)
224
+ session.finishAssistant(assistantId);
225
+ session.addNotice('Stopped, as you asked - nothing more will be spent on this.');
226
+ session.setStatus('idle');
227
+ return;
228
+ }
229
+ const plain = plainError(error, session.providerId);
79
230
  if (assistantId !== null)
80
231
  session.finishAssistant(assistantId);
81
232
  session.addError(plain.message);
233
+ if (plain.resetAt !== undefined)
234
+ session.setPlanResetAt(plain.resetAt);
235
+ // The technical text stays off screen unless /verbose is on.
236
+ if (session.verbose && plain.detail)
237
+ session.addNotice(`Technical details: ${plain.detail}`);
82
238
  session.setStatus(DISCONNECTING.has(plain.kind) ? 'disconnected' : 'idle');
83
239
  }
84
240
  }
@@ -1,4 +1,171 @@
1
1
  import { session } from '../state/session.js';
2
+ // Pure-output commands that never require permission (the rule: harmless
3
+ // output must never prompt). A command is auto-approved only when every stage of
4
+ // it - across pipes, && and || - is one of the commands below, and the only
5
+ // redirection anywhere is to /dev/null. Anything that writes, deletes, installs,
6
+ // or reaches the network still prompts.
7
+ const READ_ONLY_COMMANDS = new Set([
8
+ 'yes',
9
+ 'head',
10
+ 'tail',
11
+ 'cat',
12
+ 'ls',
13
+ 'pwd',
14
+ 'wc',
15
+ 'file',
16
+ 'which',
17
+ 'whoami',
18
+ 'date',
19
+ 'echo',
20
+ 'printf',
21
+ 'seq',
22
+ 'sort',
23
+ 'uniq',
24
+ 'tr',
25
+ 'grep',
26
+ 'awk',
27
+ 'sed',
28
+ 'cut',
29
+ 'fold',
30
+ 'column',
31
+ ]);
32
+ // Multi-word commands where the words themselves are the read-only form.
33
+ const READ_ONLY_TWO_WORD_COMMANDS = new Set([
34
+ 'git status',
35
+ 'git log',
36
+ 'git diff',
37
+ 'node --version',
38
+ 'npm --version',
39
+ ]);
40
+ // git branch lists branches when nothing but flags follows; any name argument
41
+ // would create, delete, or modify a branch, so only the listing form is allowed.
42
+ function isReadOnlyGitBranch(rest) {
43
+ return rest.every((token) => token.startsWith('-'));
44
+ }
45
+ // sed with -n only prints (the p command); -i edits files in place, and the w
46
+ // command writes files. The whole argument string is checked for a 'w' because
47
+ // scanning sed script syntax reliably is not worth the risk - a false "ask" is
48
+ // safe, a false "allow" is not.
49
+ function isReadOnlySed(rest) {
50
+ return rest.includes('-n') && !rest.some((token) => token === '-i' || token.startsWith('-i')) && !rest.join(' ').includes('w');
51
+ }
52
+ // awk reads input unless the program itself writes a file or runs a command;
53
+ // a | inside the program pipes to a command, so it disqualifies too.
54
+ function isReadOnlyAwk(rest) {
55
+ const program = rest.join(' ');
56
+ return !program.includes('system(') && !program.includes('>') && !program.includes('|') && !program.includes('getline');
57
+ }
58
+ function isReadOnlyStage(tokens, isFinalStage) {
59
+ if (tokens.length === 0)
60
+ return false;
61
+ const command = tokens[0];
62
+ const rest = tokens.slice(1);
63
+ if (command === 'git') {
64
+ if (rest.length === 0)
65
+ return false;
66
+ if (READ_ONLY_TWO_WORD_COMMANDS.has(`git ${rest[0]}`))
67
+ return true;
68
+ if (rest[0] === 'branch')
69
+ return isReadOnlyGitBranch(rest.slice(1));
70
+ return false;
71
+ }
72
+ if (command === 'node' || command === 'npm') {
73
+ return rest.length === 1 && rest[0] === '--version';
74
+ }
75
+ if (command === 'sed')
76
+ return isReadOnlySed(rest);
77
+ if (command === 'awk')
78
+ return isReadOnlyAwk(rest);
79
+ if (command === 'yes') {
80
+ // yes on its own streams forever; it qualifies only feeding a pipe that ends.
81
+ return !isFinalStage;
82
+ }
83
+ return READ_ONLY_COMMANDS.has(command);
84
+ }
85
+ // One pipe stage: tokens with any /dev/null redirection stripped. Redirection to
86
+ // any other target disqualifies the whole command.
87
+ function parseStage(stage) {
88
+ const tokens = [];
89
+ const parts = stage.trim().split(/\s+/).filter((part) => part.length > 0);
90
+ for (let i = 0; i < parts.length; i++) {
91
+ const part = parts[i];
92
+ // Forms: > /dev/null, 2> /dev/null, &> /dev/null, < /dev/null, and the
93
+ // no-space variants. Redirection to any other target disqualifies.
94
+ if (/^(\d*)>$|^&>$|^<$/.test(part) || /^(?:\d*>|&>|<)\/dev\/null$/.test(part)) {
95
+ const target = /^(?:\d*>|&>|<)\/dev\/null$/.test(part) ? part.replace(/^(?:\d*>|&>|<)/, '') : parts[++i];
96
+ if (target !== '/dev/null')
97
+ return null;
98
+ continue;
99
+ }
100
+ // Any other redirect or input form is a write or an unknown - disqualify.
101
+ if (/[<>]/.test(part))
102
+ return null;
103
+ tokens.push(part);
104
+ }
105
+ return { tokens };
106
+ }
107
+ // Splits on a separator character, ignoring quoted text, so a pipe inside quotes
108
+ // (awk '{print $1 | "sort"}') never counts as a shell pipe.
109
+ function splitOutsideQuotes(text, separator) {
110
+ const parts = [];
111
+ let current = '';
112
+ let quote = null;
113
+ for (let i = 0; i < text.length; i++) {
114
+ const char = text[i];
115
+ if (quote) {
116
+ current += char;
117
+ if (char === quote)
118
+ quote = null;
119
+ continue;
120
+ }
121
+ if (char === '"' || char === "'") {
122
+ quote = char;
123
+ current += char;
124
+ continue;
125
+ }
126
+ if (text.startsWith(separator, i)) {
127
+ parts.push(current);
128
+ current = '';
129
+ i += separator.length - 1;
130
+ continue;
131
+ }
132
+ current += char;
133
+ }
134
+ parts.push(current);
135
+ return parts;
136
+ }
137
+ // True when the command is composed entirely of read-only stages: pipes, && and
138
+ // || chains of allowlisted commands, with redirection to /dev/null only. Anything
139
+ // else - writes, deletes, installs, network, substitutions, semicolons - is false.
140
+ export function isReadOnlyBashCommand(command) {
141
+ const trimmed = command.trim();
142
+ if (trimmed.length === 0)
143
+ return false;
144
+ // Substitution constructs can turn any read into a write - and "$(...)" inside
145
+ // double quotes still executes - so any of these disqualifies outright.
146
+ if (trimmed.includes('`') || trimmed.includes('$('))
147
+ return false;
148
+ if (/[;\n]/.test(stripQuotes(trimmed)))
149
+ return false;
150
+ for (const chain of splitOutsideQuotes(trimmed, '&&')) {
151
+ for (const alternative of splitOutsideQuotes(chain, '||')) {
152
+ const stages = splitOutsideQuotes(alternative, '|');
153
+ for (let i = 0; i < stages.length; i++) {
154
+ const parsed = parseStage(stages[i]);
155
+ if (parsed === null)
156
+ return false;
157
+ if (!isReadOnlyStage(parsed.tokens, i === stages.length - 1))
158
+ return false;
159
+ }
160
+ }
161
+ }
162
+ return true;
163
+ }
164
+ // Replaces quoted spans with empty quoted strings, so metacharacter checks see
165
+ // only what the shell will actually interpret. Shared with the runBash tool.
166
+ export function stripQuotes(text) {
167
+ return text.replace(/"[^"]*"/g, '""').replace(/'[^']*'/g, "''");
168
+ }
2
169
  // Approvals are queued so that parallel tool calls never overwrite each other's prompt.
3
170
  // The amber transcript prompt itself is rendered by the tool line in 'awaiting' state.
4
171
  const queue = [];