@tianmucreations/jeeves 0.2.1 → 0.3.1

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 (57) hide show
  1. package/LICENSE +37 -17
  2. package/README.md +82 -18
  3. package/bin/jeeves +8 -1
  4. package/dist/agent/auto-ids.js +66 -0
  5. package/dist/agent/auto.js +178 -0
  6. package/dist/agent/context.js +55 -13
  7. package/dist/agent/errors.js +83 -22
  8. package/dist/agent/expert-chat.js +33 -0
  9. package/dist/agent/housekeeping.js +55 -0
  10. package/dist/agent/loop.js +174 -12
  11. package/dist/agent/permissions.js +186 -3
  12. package/dist/agent/research-gate.js +267 -0
  13. package/dist/agent/review.js +135 -0
  14. package/dist/agent/spending.js +73 -0
  15. package/dist/agent/systemPrompt.js +112 -0
  16. package/dist/agent/trust.js +29 -0
  17. package/dist/app.js +31 -11
  18. package/dist/checkpoints/index.js +103 -0
  19. package/dist/checkpoints/store.js +239 -0
  20. package/dist/commands/address.js +5 -0
  21. package/dist/commands/clear.js +2 -0
  22. package/dist/commands/help.js +8 -4
  23. package/dist/commands/keys.js +1 -1
  24. package/dist/commands/verbose.js +1 -1
  25. package/dist/components/AddressPrompt.js +31 -0
  26. package/dist/components/Footer.js +74 -102
  27. package/dist/components/Input.js +115 -29
  28. package/dist/components/KeysManager.js +65 -20
  29. package/dist/components/ModelPicker.js +348 -75
  30. package/dist/components/ProjectPicker.js +4 -1
  31. package/dist/components/Transcript.js +29 -14
  32. package/dist/components/input-layout.js +92 -0
  33. package/dist/components/transcript-layout.js +27 -19
  34. package/dist/index.js +25 -7
  35. package/dist/ink/AlternateScreen.js +33 -16
  36. package/dist/ink/cursor.js +18 -0
  37. package/dist/ink/mouse.js +48 -0
  38. package/dist/keys/store.js +2 -1
  39. package/dist/models/registry.js +18 -2
  40. package/dist/platform/config.js +71 -7
  41. package/dist/providers/catalogue.js +293 -0
  42. package/dist/providers/direct-services.js +65 -0
  43. package/dist/providers/direct.js +145 -0
  44. package/dist/providers/index.js +123 -13
  45. package/dist/providers/models-snapshot.js +1037 -0
  46. package/dist/providers/ollama.js +21 -4
  47. package/dist/providers/openrouter.js +39 -4
  48. package/dist/providers/step-control.js +28 -0
  49. package/dist/providers/zai.js +31 -11
  50. package/dist/state/session.js +110 -36
  51. package/dist/state/today-spend.js +26 -0
  52. package/dist/tools/index.js +123 -11
  53. package/dist/tools/runBash.js +58 -11
  54. package/dist/tools/web/htmlToText.js +32 -0
  55. package/dist/tools/web/openrouterChat.js +31 -0
  56. package/dist/tools/web/research.js +191 -0
  57. package/package.json +33 -7
@@ -1,41 +1,102 @@
1
- function firstLine(text) {
2
- const line = text.split('\n')[0].trim();
3
- return line.length > 0 ? line : 'Something went wrong - please ask again.';
1
+ import { directService, serviceNameFor, CUSTOM_SERVICE_ID } from '../providers/direct-services.js';
2
+ import { getCustomService } from '../platform/config.js';
3
+ // The AI service's name as the user knows it, for messages that name it.
4
+ function serviceName(providerId) {
5
+ if (providerId === 'zai')
6
+ return 'Z.ai';
7
+ if (providerId === 'ollama')
8
+ return 'Ollama';
9
+ if (providerId === CUSTOM_SERVICE_ID) {
10
+ const saved = getCustomService();
11
+ return saved ? serviceNameFor(saved.baseURL) : 'The service';
12
+ }
13
+ const direct = providerId ? directService(providerId) : undefined;
14
+ if (direct)
15
+ return direct.label;
16
+ return 'OpenRouter';
17
+ }
18
+ // Turns technical failures into plain English (Phase 9 polish). Nothing technical
19
+ // reaches the screen: unrecognised failures get a plain sentence, and the raw text
20
+ // travels in detail for /verbose.
21
+ // The HTTP status of a failed request. The AI SDK puts it on the error itself, or on
22
+ // lastError once its retries give up; the message text alone often doesn't say it
23
+ // (a bad OpenRouter key reads just "User not found.", measured live).
24
+ export function statusOf(error) {
25
+ const candidates = [error, error?.lastError];
26
+ for (const candidate of candidates) {
27
+ const code = candidate?.statusCode;
28
+ if (typeof code === 'number')
29
+ return code;
30
+ }
31
+ return null;
4
32
  }
5
- // Turns technical failures into plain English (Phase 9 polish).
6
- export function plainError(error) {
33
+ export function plainError(error, providerId) {
7
34
  const raw = error instanceof Error ? error.message : String(error);
35
+ const status = statusOf(error);
8
36
  const text = raw.toLowerCase();
9
- if (text.includes('no openrouter api key')) {
10
- return { message: "There's no key yet - type /keys to add one.", kind: 'auth' };
37
+ const service = serviceName(providerId);
38
+ const make = (message, kind) => ({ message, kind, detail: raw });
39
+ if (/^no .*\bkey\b/.test(text)) {
40
+ return make("There's no key yet - type /keys to add one.", 'auth');
11
41
  }
12
- if (text.includes('401') || text.includes('unauthorized') || text.includes('invalid api key') || text.includes('not authenticated')) {
13
- return { message: "That key wasn't accepted - type /keys to check or replace it.", kind: 'auth' };
42
+ if (status === 401 ||
43
+ status === 403 ||
44
+ text.includes('401') ||
45
+ text.includes('unauthorized') ||
46
+ text.includes('invalid api key') ||
47
+ // Google answers 400 "API key not valid"; OpenAI "Incorrect API key provided";
48
+ // Anthropic "invalid x-api-key".
49
+ text.includes('api key not valid') ||
50
+ text.includes('incorrect api key') ||
51
+ text.includes('invalid x-api-key') ||
52
+ text.includes('not authenticated') ||
53
+ text.includes('authentication failed') ||
54
+ text.includes('user not found')) {
55
+ return make(`${service} didn't accept the key - type /keys to check or replace it.`, 'auth');
14
56
  }
15
- if (text.includes('402') || text.includes('insufficient') || text.includes('out of credit') || text.includes('quota')) {
16
- return { message: 'OpenRouter credit ran out - top up at openrouter.ai/credits, then ask again.', kind: 'payment' };
57
+ // Z.ai's flat plan reports its time window as "Usage limit reached for 5 hour.
58
+ // Your limit will reset at 2026-09-17 13:03:01" (seen in a real session). The
59
+ // reset time is repeated exactly as Z.ai gave it - its time zone is not stated.
60
+ if (text.includes('usage limit')) {
61
+ const reset = raw.match(/reset at \d{4}-\d{2}-\d{2} (\d{2}:\d{2})/i);
62
+ const when = reset ? ` Z.ai says it resets at ${reset[1]}.` : '';
63
+ return {
64
+ ...make(`Your ${service} plan has used up its allowance for now.${when} Type /model to use a different model meanwhile.`, 'payment'),
65
+ resetAt: reset ? reset[1] : '',
66
+ };
17
67
  }
18
- if (text.includes('429') || text.includes('rate limit') || text.includes('rate_limit') || text.includes('too many requests')) {
19
- return { message: 'OpenRouter is asking us to slow down - wait a few seconds and ask again.', kind: 'rate-limit' };
68
+ if (status === 402 ||
69
+ text.includes('402') ||
70
+ text.includes('insufficient') ||
71
+ text.includes('out of credit') ||
72
+ text.includes('quota') ||
73
+ // Anthropic: "Your credit balance is too low to access the Anthropic API".
74
+ text.includes('credit balance')) {
75
+ const topUp = service === 'OpenRouter' ? 'top up at openrouter.ai/credits' : `add credit on the ${service} website`;
76
+ return make(`${service} credit ran out - ${topUp}, then ask again.`, 'payment');
77
+ }
78
+ if (status === 429 || text.includes('429') || text.includes('rate limit') || text.includes('rate_limit') || text.includes('too many requests')) {
79
+ return make(`${service} is asking us to slow down - wait a few seconds and ask again.`, 'rate-limit');
80
+ }
81
+ const name = error?.name;
82
+ if (name === 'TimeoutError' || text.includes('timed out') || text.includes('timeout')) {
83
+ return make(`${service} stopped responding partway through - please ask again.`, 'network');
20
84
  }
21
85
  if (text.includes('fetch failed') ||
22
86
  text.includes('network') ||
23
87
  text.includes('enotfound') ||
24
88
  text.includes('econnrefused') ||
89
+ text.includes('econnreset') ||
25
90
  text.includes('etimedout') ||
26
- text.includes('timeout') ||
27
91
  text.includes('eai_again') ||
28
92
  text.includes('socket hang up')) {
29
- return { message: 'Lost connection - please ask again in a moment.', kind: 'network' };
93
+ return make(`Couldn't reach ${service} - check the internet connection and ask again in a moment.`, 'network');
30
94
  }
31
- if (text.includes('model') && (text.includes('not found') || text.includes('404'))) {
32
- return { message: "That model isn't available any more - type /model to pick another.", kind: 'model' };
95
+ if ((status === 404 || text.includes('404') || text.includes('not found')) && text.includes('model')) {
96
+ return make("That model isn't available any more - type /model to pick another.", 'model');
33
97
  }
34
98
  if (text.includes('context') && (text.includes('length') || text.includes('too long'))) {
35
- return {
36
- message: 'This conversation grew too long for the model - type /model to switch, or /clear to start fresh.',
37
- kind: 'context',
38
- };
99
+ return make('This conversation grew too long for the model - type /model to switch, or /clear to start fresh.', 'context');
39
100
  }
40
- return { message: firstLine(raw), kind: 'other' };
101
+ return make("Something went wrong with that request and I don't recognise the reason - please ask again. Type /verbose to see the technical details next time.", 'other');
41
102
  }
@@ -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,30 @@
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
+ import { untrustProject } from './trust.js';
21
+ // Added to the rulebook when the chosen model cannot use tools, so a task request
22
+ // gets a plain answer instead of a pretend attempt.
23
+ export const CHAT_ONLY_NOTE = `
24
+
25
+ Chat-Only Model
26
+
27
+ 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
28
  const DISCONNECTING = new Set(['auth', 'network', 'payment']);
11
29
  export async function runTurn(input) {
12
30
  if (input.startsWith('/') && input.length > 1 && !input.startsWith('/ ')) {
@@ -22,6 +40,32 @@ export async function runTurn(input) {
22
40
  else if (input === '/verbose') {
23
41
  session.addNotice(toggleVerbose());
24
42
  }
43
+ else if (input === '/address') {
44
+ openAddressPrompt();
45
+ }
46
+ else if (input === '/ask') {
47
+ session.addNotice(untrustProject()
48
+ ? "I'll ask before every change in this project folder again."
49
+ : 'I already ask before every change in this project folder.');
50
+ }
51
+ else if (input === '/undo') {
52
+ if (session.status === 'working') {
53
+ session.addNotice('Undo works between tasks - wait for this one to finish, then type /undo.');
54
+ }
55
+ else {
56
+ try {
57
+ const outcome = await undoLastChange();
58
+ session.addNotice(outcome.message);
59
+ if (outcome.historyNote)
60
+ session.pendingContextNote = outcome.historyNote;
61
+ }
62
+ catch (error) {
63
+ session.addError("Undo didn't work this time - nothing was changed. Please try /undo again.");
64
+ if (session.verbose)
65
+ session.addNotice(`Technical details: ${error instanceof Error ? error.message : String(error)}`);
66
+ }
67
+ }
68
+ }
25
69
  else if (input === '/clear') {
26
70
  clearConversation();
27
71
  }
@@ -29,24 +73,87 @@ export async function runTurn(input) {
29
73
  session.requestExit();
30
74
  }
31
75
  else {
32
- session.addNotice('Unknown command. Try /help.');
76
+ session.addNotice("I don't know that command - type /help to see them all.");
33
77
  }
34
78
  return;
35
79
  }
36
80
  session.addUser(input);
81
+ // "Skip the research" must come from the person, so it is read from their own words.
82
+ const skipped = noteSkipRequest(input);
83
+ if (skipped)
84
+ session.addNotice(skipped);
85
+ startTurnCheckpoints(input);
86
+ startJob();
87
+ // Nothing is sent once today's limit is reached, unless the person agrees.
88
+ if (!(await withinLimits())) {
89
+ endJob();
90
+ session.addNotice('Stopped - nothing was sent.');
91
+ return;
92
+ }
93
+ // Quiet housekeeping: old tool output is cleared, and a long conversation summarised.
94
+ const cleared = clearOldToolResults(session.history);
95
+ if (cleared.freedTokens > 0)
96
+ session.setHistory(cleared.messages);
97
+ const modelId = workingModelId(session.model);
98
+ if (summaryDue(session.estimateContextTokens(), contextLimitFor(modelId, session.models))) {
99
+ await summariseHistory();
100
+ }
101
+ const auto = isAuto(session.model);
102
+ const autoState = newAutoTurnState();
103
+ session.setActiveModel(auto ? workerModel() : null);
104
+ const stop = new AbortController();
105
+ let countedSteps = 0;
37
106
  session.beginTurn();
38
107
  session.setStatus('working');
108
+ const turnStart = session.transcript.length;
39
109
  let assistantId = null;
40
110
  try {
41
111
  const provider = getActiveProvider();
42
- const messages = buildTurnMessages(session.history, input);
112
+ // A note from /undo travels with the next message, so the model knows files changed back.
113
+ const note_ = session.pendingContextNote;
114
+ session.pendingContextNote = null;
115
+ const messages = buildTurnMessages(session.history, note_ ? `${note_}\n\n${input}` : input);
43
116
  // 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,
117
+ const currentModel = session.models.find((model) => model.id === modelId);
118
+ const toolCapable = !currentModel || isToolCapable(currentModel);
119
+ const tools = toolCapable ? { ...getTools(), ...(auto ? { askExpert: createAskExpertTool(autoState) } : {}) } : {};
120
+ const note = !toolCapable ? CHAT_ONLY_NOTE : auto ? AUTO_NOTE.replaceAll('{{ADDRESS}}', getAddress() ?? 'Sir') : '';
121
+ const streamOptions = {
122
+ modelId,
48
123
  messages,
49
124
  tools,
125
+ instructions: getSystemPrompt() + note,
126
+ abortSignal: stop.signal,
127
+ beforeStep: async ({ stepFailures, stepCosts, messages: stepMessages }) => {
128
+ for (const cost of stepCosts.slice(countedSteps))
129
+ reportStepCost(cost);
130
+ countedSteps = stepCosts.length;
131
+ if (!(await withinLimits())) {
132
+ stop.abort();
133
+ return {};
134
+ }
135
+ // In Auto mode the expert takes over the rest of a job the worker keeps failing.
136
+ // If the expert keeps failing too, Jeeves asks before trying the strongest model.
137
+ let stepModel;
138
+ const expert = expertModel();
139
+ const strongest = topModel();
140
+ if (auto && expert && !autoState.expertTookOver && shouldTakeOver(stepFailures))
141
+ autoState.expertTookOver = true;
142
+ // (The expert can also take over by saying so when consulted.)
143
+ if (autoState.expertTookOver && autoState.takeoverStep < 0)
144
+ autoState.takeoverStep = stepFailures.length;
145
+ if (auto && strongest && autoState.expertTookOver && !autoState.askedAboutTop && shouldTakeOver(stepFailures.slice(autoState.takeoverStep))) {
146
+ autoState.askedAboutTop = true;
147
+ session.addNotice(topModelQuestion(getAddress() ?? 'Sir', topModelPriceRatio(autoCatalogue())));
148
+ autoState.onTopModel = await requestApproval();
149
+ }
150
+ if (auto && autoState.expertTookOver && expert) {
151
+ stepModel = autoState.onTopModel && strongest ? strongest : expert;
152
+ session.setActiveModel(stepModel);
153
+ }
154
+ const tidied = clearOldToolResults(stepMessages);
155
+ return { modelId: stepModel, messages: tidied.freedTokens > 0 ? tidied.messages : undefined };
156
+ },
50
157
  onToken: (token) => {
51
158
  if (assistantId === null)
52
159
  assistantId = session.startAssistant();
@@ -57,28 +164,83 @@ export async function runTurn(input) {
57
164
  session.appendReasoning(delta);
58
165
  },
59
166
  onToolCall: () => {
60
- // Hide pre-tool chatter so only the final answer stays visible (spec 2.3).
61
- if (assistantId !== null)
167
+ // Hide pre-tool chatter so only the final answer stays visible (spec 2.3). The
168
+ // entry is removed, not just emptied, so the final answer appears below the
169
+ // actions it reports on rather than above them.
170
+ if (assistantId !== null) {
62
171
  session.setAssistantText(assistantId, '');
172
+ session.finishAssistant(assistantId);
173
+ assistantId = null;
174
+ }
63
175
  session.closeReasoningEntry();
64
176
  },
65
- });
177
+ };
178
+ let uncheckedNotice = false;
179
+ let result = await provider.stream(streamOptions);
180
+ let allMessages = [...messages, ...result.messages];
181
+ // Auto's double-check, once, when this job changed a program or wrote a document
182
+ // (where the cheap worker's mistakes were measured - see review.ts).
183
+ if (auto && REVIEW_FINISHED_JOBS && jobNeedsReview(session.transcript.slice(turnStart)) && !stop.signal.aborted) {
184
+ for (const cost of (result.stepCosts ?? []).slice(countedSteps))
185
+ reportStepCost(cost);
186
+ countedSteps = result.stepCosts?.length ?? countedSteps;
187
+ const review = await reviewJob(allMessages);
188
+ if (review.kind === 'unavailable') {
189
+ uncheckedNotice = true;
190
+ }
191
+ else if (review.kind === 'problems') {
192
+ countedSteps = 0;
193
+ if (assistantId !== null)
194
+ session.setAssistantText(assistantId, '');
195
+ const fixMessages = [...allMessages, { role: 'user', content: fixRequest(review.problems) }];
196
+ // Changing files waits until the worker has reproduced a problem.
197
+ startReproducing();
198
+ try {
199
+ result = await provider.stream({ ...streamOptions, messages: fixMessages });
200
+ }
201
+ finally {
202
+ stopReproducing();
203
+ }
204
+ allMessages = [...fixMessages, ...result.messages];
205
+ }
206
+ }
66
207
  if (assistantId === null)
67
208
  assistantId = session.startAssistant();
68
209
  session.setAssistantText(assistantId, result.text);
69
210
  session.finishAssistant(assistantId);
70
- session.setHistory([...messages, ...result.messages]);
211
+ if (uncheckedNotice)
212
+ session.addNotice(UNCHECKED_NOTICE);
213
+ session.setHistory(allMessages);
71
214
  session.setLastReasoning(result.reasoning);
72
215
  session.addUsage(result.usage.input, result.usage.output, result.cost, result.usage.cached ?? 0);
73
216
  session.setRateLimit(result.rateLimit);
74
217
  void refreshCredit();
218
+ for (const cost of (result.stepCosts ?? []).slice(countedSteps))
219
+ reportStepCost(cost);
220
+ session.setPlanResetAt(null);
221
+ session.setActiveModel(auto ? workerModel() : null);
222
+ endJob();
75
223
  session.setStatus('idle');
76
224
  }
77
225
  catch (error) {
78
- const plain = plainError(error);
226
+ endJob();
227
+ session.setActiveModel(auto ? workerModel() : null);
228
+ if (stop.signal.aborted) {
229
+ if (assistantId !== null)
230
+ session.finishAssistant(assistantId);
231
+ session.addNotice('Stopped, as you asked - nothing more will be spent on this.');
232
+ session.setStatus('idle');
233
+ return;
234
+ }
235
+ const plain = plainError(error, session.providerId);
79
236
  if (assistantId !== null)
80
237
  session.finishAssistant(assistantId);
81
238
  session.addError(plain.message);
239
+ if (plain.resetAt !== undefined)
240
+ session.setPlanResetAt(plain.resetAt);
241
+ // The technical text stays off screen unless /verbose is on.
242
+ if (session.verbose && plain.detail)
243
+ session.addNotice(`Technical details: ${plain.detail}`);
82
244
  session.setStatus(DISCONNECTING.has(plain.kind) ? 'disconnected' : 'idle');
83
245
  }
84
246
  }