@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.
- package/README.md +79 -18
- package/bin/jeeves +8 -1
- package/dist/agent/auto-ids.js +66 -0
- package/dist/agent/auto.js +178 -0
- package/dist/agent/context.js +55 -13
- package/dist/agent/errors.js +83 -22
- package/dist/agent/expert-chat.js +33 -0
- package/dist/agent/housekeeping.js +55 -0
- package/dist/agent/loop.js +168 -12
- package/dist/agent/permissions.js +167 -0
- package/dist/agent/research-gate.js +267 -0
- package/dist/agent/review.js +135 -0
- package/dist/agent/spending.js +73 -0
- package/dist/agent/systemPrompt.js +112 -0
- package/dist/app.js +25 -10
- package/dist/checkpoints/index.js +103 -0
- package/dist/checkpoints/store.js +239 -0
- package/dist/commands/address.js +5 -0
- package/dist/commands/clear.js +2 -0
- package/dist/commands/help.js +7 -4
- package/dist/commands/keys.js +1 -1
- package/dist/commands/verbose.js +1 -1
- package/dist/components/AddressPrompt.js +31 -0
- package/dist/components/Footer.js +74 -102
- package/dist/components/Input.js +76 -25
- package/dist/components/KeysManager.js +65 -20
- package/dist/components/ModelPicker.js +348 -75
- package/dist/components/ProjectPicker.js +4 -1
- package/dist/components/Transcript.js +29 -14
- package/dist/components/input-layout.js +34 -0
- package/dist/components/transcript-layout.js +13 -17
- package/dist/index.js +25 -7
- package/dist/ink/AlternateScreen.js +33 -16
- package/dist/ink/cursor.js +18 -0
- package/dist/ink/mouse.js +48 -0
- package/dist/keys/store.js +2 -1
- package/dist/models/registry.js +18 -2
- package/dist/platform/config.js +63 -7
- package/dist/providers/catalogue.js +293 -0
- package/dist/providers/direct-services.js +65 -0
- package/dist/providers/direct.js +145 -0
- package/dist/providers/index.js +123 -13
- package/dist/providers/models-snapshot.js +1037 -0
- package/dist/providers/ollama.js +21 -4
- package/dist/providers/openrouter.js +39 -4
- package/dist/providers/step-control.js +28 -0
- package/dist/providers/zai.js +31 -11
- package/dist/state/session.js +90 -36
- package/dist/state/today-spend.js +26 -0
- package/dist/tools/index.js +118 -10
- package/dist/tools/runBash.js +58 -11
- package/dist/tools/web/htmlToText.js +32 -0
- package/dist/tools/web/openrouterChat.js +31 -0
- package/dist/tools/web/research.js +191 -0
- 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.`;
|
package/dist/agent/loop.js
CHANGED
|
@@ -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('
|
|
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
|
-
|
|
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 ===
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [];
|