@tianmucreations/jeeves 0.3.0 → 0.3.2

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 (47) hide show
  1. package/LICENSE +37 -17
  2. package/README.md +5 -2
  3. package/dist/agent/errors.js +1 -1
  4. package/dist/agent/loop.js +40 -1
  5. package/dist/agent/permissions.js +19 -3
  6. package/dist/agent/systemPrompt.js +19 -5
  7. package/dist/agent/trust.js +29 -0
  8. package/dist/app.js +31 -4
  9. package/dist/checkpoints/index.js +34 -4
  10. package/dist/commands/clear.js +2 -0
  11. package/dist/commands/help.js +16 -13
  12. package/dist/commands/keys.js +2 -1
  13. package/dist/components/AddressPrompt.js +21 -6
  14. package/dist/components/Footer.js +6 -1
  15. package/dist/components/Header.js +5 -1
  16. package/dist/components/HelpView.js +1 -1
  17. package/dist/components/Input.js +181 -24
  18. package/dist/components/KeysManager.js +33 -18
  19. package/dist/components/ModelPicker.js +27 -8
  20. package/dist/components/OpenRouterConnect.js +114 -0
  21. package/dist/components/ProjectPicker.js +55 -16
  22. package/dist/components/Transcript.js +40 -3
  23. package/dist/components/input-layout.js +161 -0
  24. package/dist/components/markdown.js +185 -0
  25. package/dist/components/transcript-layout.js +64 -4
  26. package/dist/index.js +2 -1
  27. package/dist/ink/mouse.js +39 -6
  28. package/dist/ink/quit.js +21 -0
  29. package/dist/ink/selection.js +128 -0
  30. package/dist/keys/store.js +72 -2
  31. package/dist/platform/address.js +16 -0
  32. package/dist/platform/chat-folder.js +24 -0
  33. package/dist/platform/config.js +11 -0
  34. package/dist/platform/wording.js +10 -0
  35. package/dist/providers/direct.js +8 -2
  36. package/dist/providers/index.js +21 -3
  37. package/dist/providers/ollama.js +8 -2
  38. package/dist/providers/openrouter-signin.js +102 -0
  39. package/dist/providers/openrouter.js +17 -2
  40. package/dist/providers/silence.js +39 -0
  41. package/dist/providers/zai.js +17 -13
  42. package/dist/state/session.js +61 -2
  43. package/dist/tools/index.js +27 -6
  44. package/dist/tools/runBash.js +17 -5
  45. package/dist/tools/web/research.js +119 -21
  46. package/dist/tools/web/zai-search.js +79 -0
  47. package/package.json +3 -2
@@ -0,0 +1,102 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { spawn } from 'node:child_process';
4
+ // "Sign in with OpenRouter": the person approves Jeeves in their browser and
5
+ // OpenRouter hands back a key on their own account - nothing to copy or paste, the
6
+ // step that stops most non-coders. OpenRouter's OAuth PKCE flow, as documented at
7
+ // openrouter.ai/docs/use-cases/oauth-pkce (checked 18 Sept 2026): open
8
+ // https://openrouter.ai/auth with callback_url, code_challenge (base64url of the
9
+ // SHA-256 of a random verifier) and code_challenge_method=S256; the browser returns
10
+ // to the callback with ?code=; POST {code, code_verifier, code_challenge_method} to
11
+ // /api/v1/auth/keys and read "key". Localhost callbacks on any port are allowed.
12
+ export const AUTH_URL = 'https://openrouter.ai/auth';
13
+ export const EXCHANGE_URL = 'https://openrouter.ai/api/v1/auth/keys';
14
+ export function pkcePair() {
15
+ const verifier = randomBytes(32).toString('base64url');
16
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
17
+ return { verifier, challenge };
18
+ }
19
+ export function authorizeUrl(callbackUrl, challenge) {
20
+ const params = new URLSearchParams({ callback_url: callbackUrl, code_challenge: challenge, code_challenge_method: 'S256', key_label: 'Jeeves' });
21
+ return `${AUTH_URL}?${params.toString()}`;
22
+ }
23
+ // The program and arguments that open a web address on each system.
24
+ export function browserCommand(url, platform = process.platform) {
25
+ // Windows: rundll32, as Claude Code does (utils/browser.ts) - never cmd's "start",
26
+ // which splits a web address at its & signs (audit, 19 Sept).
27
+ return platform === 'darwin' ? ['open', [url]] : platform === 'win32' ? ['rundll32', ['url,OpenURL', url]] : ['xdg-open', [url]];
28
+ }
29
+ // Opens the address in the person's own browser, on every operating system.
30
+ export function openInBrowser(url) {
31
+ const command = browserCommand(url);
32
+ try {
33
+ const child = spawn(command[0], command[1], { stdio: 'ignore', detached: true });
34
+ child.on('error', () => { });
35
+ child.unref();
36
+ }
37
+ catch {
38
+ // The address is also shown on screen, so the person can open it themselves.
39
+ }
40
+ }
41
+ const PAGE = (message) => `<!doctype html><meta charset="utf-8"><title>Jeeves</title><body style="font-family:-apple-system,Segoe UI,sans-serif;background:#0b0b0d;color:#e6e6e6;display:grid;place-items:center;height:100vh;margin:0"><div style="text-align:center"><h1 style="font-weight:500">Jeeves</h1><p>${message}</p></div>`;
42
+ export async function signInWithOpenRouter(options) {
43
+ const { verifier, challenge } = pkcePair();
44
+ const exchange = options.exchange ?? ((body) => fetch(EXCHANGE_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }));
45
+ return new Promise((resolve) => {
46
+ let settled = false;
47
+ const server = createServer(async (request, response) => {
48
+ const url = new URL(request.url ?? '/', 'http://localhost');
49
+ const code = url.searchParams.get('code');
50
+ if (url.pathname !== '/callback' || !code) {
51
+ response.writeHead(404).end();
52
+ return;
53
+ }
54
+ try {
55
+ const reply = await exchange({ code, code_verifier: verifier, code_challenge_method: 'S256' });
56
+ const body = (await reply.json().catch(() => ({})));
57
+ if (!reply.ok || typeof body.key !== 'string')
58
+ throw new Error(`exchange ${reply.status}`);
59
+ response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(PAGE('Jeeves is connected to OpenRouter. You can close this tab and go back to Jeeves.'));
60
+ finish({ ok: true, key: body.key });
61
+ }
62
+ catch {
63
+ response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(PAGE("OpenRouter didn't complete the sign-in. Go back to Jeeves and try again."));
64
+ finish({ ok: false, reason: 'refused' });
65
+ }
66
+ });
67
+ // Fifteen minutes: long enough to make a new OpenRouter account and confirm an email.
68
+ const timer = setTimeout(() => finish({ ok: false, reason: 'timeout' }), options.timeoutMs ?? 15 * 60_000);
69
+ const onAbort = () => finish({ ok: false, reason: 'cancelled' });
70
+ options.signal?.addEventListener('abort', onAbort);
71
+ if (options.signal?.aborted) {
72
+ finish({ ok: false, reason: 'cancelled' });
73
+ return;
74
+ }
75
+ function finish(result) {
76
+ if (settled)
77
+ return;
78
+ settled = true;
79
+ clearTimeout(timer);
80
+ options.signal?.removeEventListener('abort', onAbort);
81
+ server.close();
82
+ resolve(result);
83
+ }
84
+ // Only this computer can reach the callback.
85
+ server.listen(0, '127.0.0.1', () => {
86
+ const address = server.address();
87
+ const port = typeof address === 'object' && address ? address.port : 0;
88
+ const url = authorizeUrl(`http://localhost:${port}/callback`, challenge);
89
+ options.onUrl?.(url);
90
+ (options.open ?? openInBrowser)(url);
91
+ });
92
+ });
93
+ }
94
+ // Said while the browser is open, in the terminal and the window alike. A person
95
+ // who is not logged in lands on OpenRouter's Sign Up page, which carries a return
96
+ // address to this same approval page (measured 19 Sept with a logged-out visit).
97
+ export const WAITING_STEPS = [
98
+ 'Your browser has opened at OpenRouter.',
99
+ 'Log in if it asks, then click Authorize.',
100
+ 'New to OpenRouter? Make an account on the page that opens - it brings you back to Authorize afterwards. If you end up somewhere else, come back here and choose Sign in again.',
101
+ "I'm waiting here - this moves on by itself once you approve.",
102
+ ];
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { streamText, stepCountIs } from 'ai';
3
3
  import { createOpenRouter } from '@openrouter/ai-sdk-provider';
4
+ import { silenceGuard } from './silence.js';
4
5
  import { prepareStepFor, stepCost } from './step-control.js';
5
6
  // Assumption: the spec's "maxSteps" is called stopWhen/stepCountIs in AI SDK 7 (the installed version); same cap of 25.
6
7
  const MAX_TOOL_STEPS = 25;
@@ -38,6 +39,15 @@ export async function fetchCreditInfo(apiKey) {
38
39
  return null;
39
40
  }
40
41
  }
42
+ // A brand-new OpenRouter account has no credit: it connects, but only the free
43
+ // models answer. Said plainly right after connecting, or '' when there is credit
44
+ // (or the balance can't be read - never a guess).
45
+ export async function noCreditNote(apiKey) {
46
+ const info = await fetchCreditInfo(apiKey);
47
+ if (!info || info.remaining > 0)
48
+ return '';
49
+ return 'Your OpenRouter account has no credit yet. The free models work now - choose one from the Free list. To use the others, add credit at openrouter.ai/credits.';
50
+ }
41
51
  // The key's all-time spend in dollars, from GET /api/v1/key (field "usage",
42
52
  // confirmed against a live response). Used to work out today's spend in local time.
43
53
  export async function fetchKeyUsage(apiKey) {
@@ -67,6 +77,7 @@ export function createOpenRouterProvider(apiKey) {
67
77
  id: 'openrouter',
68
78
  name: 'OpenRouter',
69
79
  async stream({ modelId, messages, tools, instructions, onToken, onReasoning, onToolCall, beforeStep, abortSignal }) {
80
+ const guard = silenceGuard(abortSignal);
70
81
  const result = streamText({
71
82
  instructions,
72
83
  // A stalled request must never wedge the app in the working state forever -
@@ -76,14 +87,16 @@ export function createOpenRouterProvider(apiKey) {
76
87
  // started, and 10 minutes for any single step, which also covers a request that
77
88
  // never starts answering. (A plain number here limits the entire multi-step
78
89
  // job; a 3-minute one killed healthy jobs mid-way in testing.)
79
- timeout: { firstChunkMs: 120_000, chunkMs: 90_000, stepMs: 600_000 },
90
+ // Silence while the model answers is watched by silenceGuard (it pauses while a
91
+ // command runs or waits for the person); the first piece still has 2 minutes.
92
+ timeout: { firstChunkMs: 120_000 },
80
93
  // Usage accounting makes OpenRouter report each step's exact cost.
81
94
  model: openrouter.chat(modelId, { usage: { include: true } }),
82
95
  messages,
83
96
  tools,
84
97
  stopWhen: stepCountIs(MAX_TOOL_STEPS),
85
98
  prepareStep: prepareStepFor(beforeStep, (id) => openrouter.chat(id, { usage: { include: true } })),
86
- abortSignal,
99
+ abortSignal: guard.signal,
87
100
  // The library prints every failure to the screen by default, over Jeeves's
88
101
  // window; the failure still arrives below and is explained in plain English.
89
102
  onError: () => { },
@@ -95,6 +108,7 @@ export function createOpenRouterProvider(apiKey) {
95
108
  });
96
109
  let streamedError = null;
97
110
  for await (const part of result.stream) {
111
+ guard.onPart(part);
98
112
  if (part.type === 'text-delta') {
99
113
  onToken(part.text);
100
114
  }
@@ -108,6 +122,7 @@ export function createOpenRouterProvider(apiKey) {
108
122
  streamedError = part.error;
109
123
  }
110
124
  }
125
+ guard.stop();
111
126
  // The real stream error (a rejected key, a missing model) must win over the
112
127
  // SDK's generic no-output error, which would otherwise mask the cause.
113
128
  if (streamedError !== null) {
@@ -0,0 +1,39 @@
1
+ // A stalled reply must never leave Jeeves "working" forever - but the time a
2
+ // command runs, or the time the person takes to answer "allow?", is not the model
3
+ // going quiet. The AI SDK's timeout.chunkMs counts both (measured 19 Sept: a 2 s tool
4
+ // tripped a 1 s chunkMs), so any command over 90 s cancelled the whole job and
5
+ // blamed the service. This watchdog counts silence only while no tool is running.
6
+ export const SILENCE_MS = 90_000;
7
+ export function silenceGuard(outer, ms = SILENCE_MS) {
8
+ const controller = new AbortController();
9
+ let timer;
10
+ let toolsRunning = 0;
11
+ const arm = () => {
12
+ clearTimeout(timer);
13
+ if (toolsRunning > 0)
14
+ return;
15
+ // Named as the SDK names its own, so the plain-English message stays the same.
16
+ timer = setTimeout(() => controller.abort(new DOMException(`Chunk timeout of ${ms}ms exceeded`, 'TimeoutError')), ms);
17
+ // A finished or stopped reply never keeps Jeeves (or a test run) waiting on this timer.
18
+ timer.unref();
19
+ };
20
+ if (outer?.aborted)
21
+ controller.abort(outer.reason);
22
+ else
23
+ outer?.addEventListener('abort', () => controller.abort(outer.reason), { once: true });
24
+ return {
25
+ signal: controller.signal,
26
+ onPart(part) {
27
+ if (part.type === 'tool-call')
28
+ toolsRunning += 1;
29
+ else if (part.type === 'tool-result' || part.type === 'tool-error' || part.type === 'tool-output-denied')
30
+ toolsRunning = Math.max(0, toolsRunning - 1);
31
+ arm();
32
+ },
33
+ stop() {
34
+ clearTimeout(timer);
35
+ if (controller.signal.aborted && !outer?.aborted)
36
+ throw controller.signal.reason;
37
+ },
38
+ };
39
+ }
@@ -1,11 +1,14 @@
1
1
  import { streamText, stepCountIs } from 'ai';
2
- import { createOpenRouter } from '@openrouter/ai-sdk-provider';
2
+ import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
3
+ import { silenceGuard } from './silence.js';
3
4
  import { prepareStepFor } from './step-control.js';
4
5
  // The GLM Coding Plan endpoint: OpenAI Chat Completions protocol at
5
6
  // https://api.z.ai/api/coding/paas/v4 - NOT the standard /api/paas/v4, which is
6
- // the pay-per-token API. The OpenRouter client speaks the OpenAI protocol, so it
7
- // talks to the coding endpoint directly (the same trick the Ollama adapter uses
8
- // for its OpenAI-compatible endpoint).
7
+ // the pay-per-token API. Connected with @ai-sdk/openai-compatible, as OpenCode does
8
+ // (models.dev "zai-coding-plan"): unlike the OpenRouter client used before, it reads
9
+ // Z.ai's thinking (reasoning_content), so Jeeves can say it is thinking. GLM-5.3
10
+ // thinks for up to half a minute before answering; with the old client that time
11
+ // showed nothing at all (measured 19 Sept: 1,425 thinking tokens, first words at 26 s).
9
12
  export const ZAI_CODING_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
10
13
  const MAX_TOOL_STEPS = 25;
11
14
  // The GLM Coding Plan is flat-rate, so prices are meaningless per token; the picker
@@ -53,15 +56,12 @@ export const ZAI_MODELS = [
53
56
  },
54
57
  ];
55
58
  export function createZaiProvider(apiKey) {
56
- const client = createOpenRouter({
57
- apiKey,
58
- baseURL: ZAI_CODING_BASE_URL,
59
- compatibility: 'compatible',
60
- });
59
+ const client = createOpenAICompatible({ name: 'zai', apiKey, baseURL: ZAI_CODING_BASE_URL, includeUsage: true });
61
60
  return {
62
61
  id: 'zai',
63
62
  name: 'Z.ai',
64
63
  async stream({ modelId, messages, tools, instructions, onToken, onReasoning, onToolCall, beforeStep, abortSignal }) {
64
+ const guard = silenceGuard(abortSignal);
65
65
  const result = streamText({
66
66
  instructions,
67
67
  // A stalled request must never wedge the app in the working state forever -
@@ -71,19 +71,22 @@ export function createZaiProvider(apiKey) {
71
71
  // started, and 10 minutes for any single step, which also covers a request that
72
72
  // never starts answering. (A plain number here limits the entire multi-step
73
73
  // job; a 3-minute one killed healthy jobs mid-way in testing.)
74
- timeout: { firstChunkMs: 120_000, chunkMs: 90_000, stepMs: 600_000 },
75
- model: client.chat(modelId),
74
+ // Silence while the model answers is watched by silenceGuard (it pauses while a
75
+ // command runs or waits for the person); the first piece still has 2 minutes.
76
+ timeout: { firstChunkMs: 120_000 },
77
+ model: client.chatModel(modelId),
76
78
  messages,
77
79
  tools,
78
80
  stopWhen: stepCountIs(MAX_TOOL_STEPS),
79
- prepareStep: prepareStepFor(beforeStep, (id) => client.chat(id)),
80
- abortSignal,
81
+ prepareStep: prepareStepFor(beforeStep, (id) => client.chatModel(id)),
82
+ abortSignal: guard.signal,
81
83
  // The library prints every failure to the screen by default, over Jeeves's
82
84
  // window; the failure still arrives below and is explained in plain English.
83
85
  onError: () => { },
84
86
  });
85
87
  let streamedError = null;
86
88
  for await (const part of result.stream) {
89
+ guard.onPart(part);
87
90
  if (part.type === 'text-delta') {
88
91
  onToken(part.text);
89
92
  }
@@ -97,6 +100,7 @@ export function createZaiProvider(apiKey) {
97
100
  streamedError = part.error;
98
101
  }
99
102
  }
103
+ guard.stop();
100
104
  // The real stream error (a rejected key, a missing model) must win over the
101
105
  // SDK's generic no-output error, which would otherwise mask the cause.
102
106
  if (streamedError !== null) {
@@ -44,6 +44,9 @@ class SessionStore {
44
44
  tidying = false;
45
45
  // A short note for the info bar while something quick runs, like 'backing up…'.
46
46
  busyNote = null;
47
+ // When the model began thinking privately before writing (ms), or null. Thinking
48
+ // can last half a minute, so the screen says so instead of showing nothing.
49
+ thinkingSince = null;
47
50
  // Something the model must be told with the next message (for example, that /undo ran).
48
51
  pendingContextNote = null;
49
52
  // The daily spending limit in dollars, and any extra allowance granted today.
@@ -58,6 +61,19 @@ class SessionStore {
58
61
  // The furthest the transcript can scroll up (contentHeight - viewportHeight),
59
62
  // reported by the Transcript from its live measurements.
60
63
  transcriptScrollMax = Number.POSITIVE_INFINITY;
64
+ // Text selected with the mouse in the conversation (Claude Code's in-app selection):
65
+ // line and character positions within the drawn lines, so it stays on its words
66
+ // while the view scrolls. null when nothing is selected.
67
+ selection = null;
68
+ // What the conversation area shows, published by the Transcript each time it draws,
69
+ // so a mouse position can be turned into a line and character.
70
+ // Set by the typing box: puts the cursor at a clicked screen position.
71
+ inputClick = null;
72
+ transcriptView = null;
73
+ // What is being typed in the input box (the window sizes the box to fit it).
74
+ inputText = '';
75
+ // Messages sent while Jeeves was busy, in order; each is sent when he finishes.
76
+ queued = [];
61
77
  turnEvents = [];
62
78
  nextId = 1;
63
79
  version = 0;
@@ -73,6 +89,22 @@ class SessionStore {
73
89
  for (const listener of this.listeners)
74
90
  listener();
75
91
  }
92
+ setInputText(text) {
93
+ if (text === this.inputText)
94
+ return;
95
+ this.inputText = text;
96
+ this.emit();
97
+ }
98
+ queueMessage(text) {
99
+ this.queued = [...this.queued, text];
100
+ this.emit();
101
+ }
102
+ takeQueued() {
103
+ const [next, ...rest] = this.queued;
104
+ this.queued = rest;
105
+ this.emit();
106
+ return next;
107
+ }
76
108
  setStatus(status) {
77
109
  this.status = status;
78
110
  this.emit();
@@ -202,9 +234,11 @@ class SessionStore {
202
234
  this.wizardActive = true;
203
235
  this.emit();
204
236
  }
205
- endWizard() {
237
+ // skipped: the person chose "not now" - straight to the conversation, not a second
238
+ // list of services (audit 19 Sept).
239
+ endWizard(skipped = false) {
206
240
  this.wizardActive = false;
207
- const shouldOpenModelPicker = this.wizardFromLaunch;
241
+ const shouldOpenModelPicker = this.wizardFromLaunch && !skipped;
208
242
  this.wizardFromLaunch = false;
209
243
  this.emit();
210
244
  if (shouldOpenModelPicker) {
@@ -213,6 +247,19 @@ class SessionStore {
213
247
  }
214
248
  launchComplete() {
215
249
  this.launchStage = 'ready';
250
+ this.switchingFolder = false;
251
+ this.emit();
252
+ }
253
+ // /folder: the folder list again, from inside a conversation (owner, 19 Sept: from
254
+ // "Just chat" there was no way into a folder). Between tasks only, like /model.
255
+ switchingFolder = false;
256
+ openFolderPicker() {
257
+ if (this.status === 'working' || this.approvalPending) {
258
+ this.addNotice('The folder can be changed between tasks.');
259
+ return;
260
+ }
261
+ this.switchingFolder = true;
262
+ this.launchStage = 'project';
216
263
  this.emit();
217
264
  }
218
265
  // The address question runs before the project picker on first launch only;
@@ -273,6 +320,12 @@ class SessionStore {
273
320
  this.transcriptScrollUp = next;
274
321
  this.emit();
275
322
  }
323
+ setSelection(selection) {
324
+ if (selection === null && this.selection === null)
325
+ return;
326
+ this.selection = selection;
327
+ this.emit();
328
+ }
276
329
  setTranscriptScrollMax(max) {
277
330
  this.transcriptScrollMax = Math.max(0, max);
278
331
  if (this.transcriptScrollUp > this.transcriptScrollMax) {
@@ -330,6 +383,12 @@ class SessionStore {
330
383
  this.activeModel = model;
331
384
  this.emit();
332
385
  }
386
+ setThinking(on) {
387
+ if (on === (this.thinkingSince !== null))
388
+ return;
389
+ this.thinkingSince = on ? Date.now() : null;
390
+ this.emit();
391
+ }
333
392
  setBusyNote(note) {
334
393
  if (this.busyNote === note)
335
394
  return;
@@ -5,9 +5,10 @@ import { readFileSchema, runReadFile } from './readFile.js';
5
5
  import { writeFileSchema, runWriteFile } from './writeFile.js';
6
6
  import { listDirSchema, runListDir } from './listDir.js';
7
7
  import { runBashSchema, runRunBash } from './runBash.js';
8
- import { webSearchSchema, runWebSearch, readWebPageSchema, runReadWebPage } from './web/research.js';
8
+ import { webSearchSchema, runWebSearch, readWebPageSchema, runReadWebPage, researchService, borrowedSearchNeedsAsking, borrowedSearchQuestion } from './web/research.js';
9
9
  import { ensureCheckpoint, isOutsideProject, commandMayReachOutside } from '../checkpoints/index.js';
10
10
  import { resolveFromCwd } from '../platform/paths.js';
11
+ import { isProjectTrusted } from '../agent/trust.js';
11
12
  import { holdForWrite, holdForCommand, recordCommandResult, recordNote, noteResearchSchema, HELD_PREFIX, commandEditsFiles, } from '../agent/research-gate.js';
12
13
  import { holdUntilReproduced } from '../agent/review.js';
13
14
  // Assumption: every tool result is capped to keep huge outputs from flooding the conversation.
@@ -40,6 +41,10 @@ export function plainToolFailure(error) {
40
41
  return 'that folder is not empty';
41
42
  if (text.includes('no space left'))
42
43
  return 'the disk is full';
44
+ // Say how long it was allowed ("didn't finish in 2 minutes"), not just "took too long".
45
+ const limit = /didn't finish in ([0-9]+ (?:minutes?|seconds))/.exec(raw)?.[1];
46
+ if (limit)
47
+ return `still running after ${limit}, so it was stopped`;
43
48
  if (text.includes('timed out') || text.includes('etimedout') || text.includes('stopped after') || text.includes("didn't finish"))
44
49
  return 'took too long';
45
50
  if (text.includes('binary file'))
@@ -48,6 +53,12 @@ export function plainToolFailure(error) {
48
53
  return 'needs an OpenRouter key (type /keys)';
49
54
  if (text.includes('web search is not available'))
50
55
  return 'web search is not available right now';
56
+ if (text.includes("web search isn't available with"))
57
+ return "web search isn't available with this service yet";
58
+ if (text.includes('was not allowed in this conversation'))
59
+ return 'not allowed';
60
+ if (text.includes('needs your z.ai key'))
61
+ return 'needs your Z.ai key (type /keys)';
51
62
  if (text.includes("couldn't open"))
52
63
  return "that website wouldn't open";
53
64
  if (text.includes('not a valid web address') || text.includes('only web pages'))
@@ -58,6 +69,10 @@ export function plainToolFailure(error) {
58
69
  // and explains it in plain English.
59
70
  return 'something unexpected went wrong';
60
71
  }
72
+ function researchLabel() {
73
+ const service = researchService();
74
+ return service === 'zai' ? ' (Z.ai plan)' : service === 'borrowed' ? ' (via OpenRouter)' : '';
75
+ }
61
76
  function clip(text, max) {
62
77
  return text.length > max ? text.slice(0, max - 1) + '…' : text;
63
78
  }
@@ -78,13 +93,15 @@ function defineTool(config) {
78
93
  });
79
94
  throw new Error(held);
80
95
  }
81
- const needsPermission = typeof config.permission === 'function' ? config.permission(input) : config.permission;
82
96
  const warning = config.warning?.(input) ?? null;
97
+ // A change inside the project folder (no outside warning) may be "always allowed".
98
+ const trustable = warning === null;
99
+ const needsPermission = (typeof config.permission === 'function' ? config.permission(input) : config.permission) && !(trustable && isProjectTrusted());
83
100
  if (warning)
84
101
  session.addNotice(warning);
85
102
  const lineId = session.addToolLine(config.name, summary, needsPermission ? 'awaiting' : 'running');
86
103
  if (needsPermission) {
87
- const approved = await requestApproval();
104
+ const approved = await requestApproval({ trustable });
88
105
  if (!approved) {
89
106
  session.updateToolLine(lineId, { state: 'declined' });
90
107
  throw new Error(`Permission denied by the user - ${config.name} ${summary} was not executed.`);
@@ -152,9 +169,13 @@ export const TOOLS = {
152
169
  name: 'webSearch',
153
170
  description: 'Search the web. Returns titles, addresses and short snippets - a list of where to look, not checked facts.',
154
171
  schema: webSearchSchema,
155
- permission: false,
172
+ // On a service with no search of its own, a search borrows OpenRouter (about a cent
173
+ // each) only after a yes, asked once per conversation - never "always allowed".
174
+ permission: () => borrowedSearchNeedsAsking(),
175
+ warning: () => (borrowedSearchNeedsAsking() ? borrowedSearchQuestion() : null),
156
176
  summarize: (input) => clip(input.query, 60),
157
- label: (input) => `Searched ${clip(input.query, 60)}`,
177
+ // Every search line says which service it went through.
178
+ label: (input) => `Searched ${clip(input.query, 50)}${researchLabel()}`,
158
179
  run: runWebSearch,
159
180
  }),
160
181
  readWebPage: defineTool({
@@ -163,7 +184,7 @@ export const TOOLS = {
163
184
  schema: readWebPageSchema,
164
185
  permission: false,
165
186
  summarize: (input) => clip(input.url, 60),
166
- label: (input) => `Read ${clip(input.url.replace(/^https?:\/\//, ''), 60)}`,
187
+ label: (input) => `Read ${clip(input.url.replace(/^https?:\/\//, ''), 50)}${researchService() === 'zai' ? ' (Z.ai plan)' : ''}`,
167
188
  run: runReadWebPage,
168
189
  }),
169
190
  runBash: defineTool({
@@ -2,8 +2,17 @@ import { execa } from 'execa';
2
2
  import { z } from 'zod';
3
3
  import { getShell } from '../platform/shell.js';
4
4
  import { stripQuotes } from '../agent/permissions.js';
5
+ // Claude Code's limits (utils/timeouts.ts): 2 minutes unless the model asks for
6
+ // more, 10 minutes at most. Jeeves had 60 seconds, which cut off every large
7
+ // download or install (owner's screenshot, 19 Sept).
8
+ export const DEFAULT_COMMAND_MS = 120_000;
9
+ export const MAX_COMMAND_MS = 600_000;
5
10
  export const runBashSchema = z.object({
6
11
  command: z.string().describe('The shell command to run'),
12
+ timeout: z
13
+ .number()
14
+ .optional()
15
+ .describe(`How long the command may run, in milliseconds - up to ${MAX_COMMAND_MS} (10 minutes). Without it: ${DEFAULT_COMMAND_MS} (2 minutes). Ask for more for downloads, installs and builds.`),
7
16
  });
8
17
  // Every running command is registered so the exit paths (Ctrl+C, /exit, kill
9
18
  // signals) can terminate them immediately - the UI must never be left waiting on
@@ -33,28 +42,31 @@ function interactiveCommandIn(command) {
33
42
  }
34
43
  return null;
35
44
  }
36
- // A hard ceiling per command: anything still running after this is killed and
37
- // reported to the model, which continues the conversation.
38
- const COMMAND_TIMEOUT_MS = 60_000;
45
+ export function describeLimit(ms) {
46
+ return ms % 60_000 === 0 ? `${ms / 60_000} minute${ms === 60_000 ? '' : 's'}` : `${Math.round(ms / 1000)} seconds`;
47
+ }
39
48
  export async function runRunBash(input) {
40
49
  const refusal = interactiveCommandIn(input.command);
41
50
  if (refusal !== null) {
42
51
  throw new Error(`${refusal} needs an interactive terminal, which this tool does not provide - it was not run. Use a non-interactive alternative (for example cat or grep) instead.`);
43
52
  }
53
+ // A hard ceiling per command: anything still running after this is killed and
54
+ // reported to the model, which continues the conversation.
55
+ const limit = Math.min(Math.max(input.timeout ?? DEFAULT_COMMAND_MS, 1_000), MAX_COMMAND_MS);
44
56
  const shell = getShell();
45
57
  const child = execa(shell.program, [shell.flag, input.command], {
46
58
  reject: false,
47
59
  // stdin is /dev/null: a command that reads input gets an immediate end-of-file
48
60
  // instead of sitting forever waiting for keystrokes that will never come.
49
61
  stdin: 'ignore',
50
- timeout: COMMAND_TIMEOUT_MS,
62
+ timeout: limit,
51
63
  forceKillAfterDelay: 2_000,
52
64
  });
53
65
  running.add(child);
54
66
  try {
55
67
  const result = await child;
56
68
  if (result.timedOut === true) {
57
- throw new Error(`that command didn't finish in ${COMMAND_TIMEOUT_MS / 1000} seconds - it may be waiting for input. It was stopped.`);
69
+ throw new Error(`that command didn't finish in ${describeLimit(limit)} - it may be waiting for input, or need longer (up to 10 minutes can be asked for). It was stopped.`);
58
70
  }
59
71
  const parts = [`$ ${input.command}`, `exit code: ${result.exitCode ?? 'unknown'}`];
60
72
  if (result.stdout)