@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.
- package/LICENSE +37 -17
- package/README.md +5 -2
- package/dist/agent/errors.js +1 -1
- package/dist/agent/loop.js +40 -1
- package/dist/agent/permissions.js +19 -3
- package/dist/agent/systemPrompt.js +19 -5
- package/dist/agent/trust.js +29 -0
- package/dist/app.js +31 -4
- package/dist/checkpoints/index.js +34 -4
- package/dist/commands/clear.js +2 -0
- package/dist/commands/help.js +16 -13
- package/dist/commands/keys.js +2 -1
- package/dist/components/AddressPrompt.js +21 -6
- package/dist/components/Footer.js +6 -1
- package/dist/components/Header.js +5 -1
- package/dist/components/HelpView.js +1 -1
- package/dist/components/Input.js +181 -24
- package/dist/components/KeysManager.js +33 -18
- package/dist/components/ModelPicker.js +27 -8
- package/dist/components/OpenRouterConnect.js +114 -0
- package/dist/components/ProjectPicker.js +55 -16
- package/dist/components/Transcript.js +40 -3
- package/dist/components/input-layout.js +161 -0
- package/dist/components/markdown.js +185 -0
- package/dist/components/transcript-layout.js +64 -4
- package/dist/index.js +2 -1
- package/dist/ink/mouse.js +39 -6
- package/dist/ink/quit.js +21 -0
- package/dist/ink/selection.js +128 -0
- package/dist/keys/store.js +72 -2
- package/dist/platform/address.js +16 -0
- package/dist/platform/chat-folder.js +24 -0
- package/dist/platform/config.js +11 -0
- package/dist/platform/wording.js +10 -0
- package/dist/providers/direct.js +8 -2
- package/dist/providers/index.js +21 -3
- package/dist/providers/ollama.js +8 -2
- package/dist/providers/openrouter-signin.js +102 -0
- package/dist/providers/openrouter.js +17 -2
- package/dist/providers/silence.js +39 -0
- package/dist/providers/zai.js +17 -13
- package/dist/state/session.js +61 -2
- package/dist/tools/index.js +27 -6
- package/dist/tools/runBash.js +17 -5
- package/dist/tools/web/research.js +119 -21
- package/dist/tools/web/zai-search.js +79 -0
- 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
|
-
|
|
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
|
+
}
|
package/dist/providers/zai.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { streamText, stepCountIs } from 'ai';
|
|
2
|
-
import {
|
|
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.
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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 =
|
|
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
|
-
|
|
75
|
-
|
|
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.
|
|
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) {
|
package/dist/state/session.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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?:\/\//, ''),
|
|
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({
|
package/dist/tools/runBash.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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:
|
|
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 ${
|
|
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)
|