termux-dev 1.8.1 → 1.9.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 +17 -4
- package/assets/banner.svg +1 -1
- package/assets/preview.svg +1 -1
- package/dist/cli/background-ui.js +116 -0
- package/dist/cli/files.js +2 -12
- package/dist/cli/finder.js +4 -0
- package/dist/cli/headless.js +6 -3
- package/dist/cli/index.js +259 -74
- package/dist/cli/keys.js +12 -3
- package/dist/cli/liveblock.js +38 -9
- package/dist/cli/mcp-panel.js +2 -0
- package/dist/cli/picker.js +2 -0
- package/dist/cli/prompt.js +93 -8
- package/dist/cli/provider-manager.js +197 -0
- package/dist/cli/providers.js +0 -5
- package/dist/cli/settings.js +38 -17
- package/dist/cli/typeahead.js +10 -17
- package/dist/cli/ui.js +189 -32
- package/dist/cli/updater.js +10 -8
- package/dist/cli/usage-panel.js +17 -4
- package/dist/cli/width.js +18 -0
- package/dist/core/background.js +390 -0
- package/dist/core/config-merge.js +67 -0
- package/dist/core/context-stats.js +1 -0
- package/dist/core/cost.js +25 -11
- package/dist/core/custom-providers.js +161 -0
- package/dist/core/images.js +46 -0
- package/dist/core/local-config.js +3 -1
- package/dist/core/locales/de.js +36 -7
- package/dist/core/locales/en.js +36 -7
- package/dist/core/locales/es.js +36 -7
- package/dist/core/locales/fr.js +36 -7
- package/dist/core/locales/ja.js +36 -7
- package/dist/core/locales/pt.js +36 -7
- package/dist/core/locales/ru.js +36 -7
- package/dist/core/locales/uk.js +36 -7
- package/dist/core/locales/zh.js +36 -7
- package/dist/core/loop.js +50 -2
- package/dist/core/prices.js +8 -4
- package/dist/core/session-media.js +115 -0
- package/dist/core/session.js +17 -2
- package/dist/core/title.js +19 -2
- package/dist/core/types.js +1 -1
- package/dist/core/usage.js +41 -4
- package/dist/permissions/guard.js +6 -1
- package/dist/prompts/builder.js +2 -1
- package/dist/providers/anthropic.js +67 -13
- package/dist/providers/index.js +16 -3
- package/dist/providers/openai.js +99 -41
- package/dist/providers/shared.js +83 -0
- package/dist/tools/background.js +118 -0
- package/dist/tools/bash.js +23 -1
- package/dist/tools/fs.js +32 -2
- package/dist/tools/index.js +2 -1
- package/package.json +1 -1
package/dist/providers/index.js
CHANGED
|
@@ -3,16 +3,29 @@ import { resolveFormat } from '../core/formats.js';
|
|
|
3
3
|
import { modelAcceptsImages } from '../core/vision.js';
|
|
4
4
|
import { OpenAIProvider } from './openai.js';
|
|
5
5
|
import { AnthropicProvider } from './anthropic.js';
|
|
6
|
-
|
|
6
|
+
const LEVEL_ORDER = ['off', 'low', 'normal', 'high', 'xhigh', 'max'];
|
|
7
|
+
export function createProvider(config, opts = {}) {
|
|
7
8
|
const baseUrl = config.baseUrl || 'https://api.openai.com/v1';
|
|
8
9
|
const apiKey = config.apiKey || process.env.OPENAI_API_KEY || '';
|
|
9
10
|
const model = config.model || 'gpt-4o-mini';
|
|
10
11
|
// OpenAI or Anthropic wire format: by the base URL, or by what the gateway offers for this model.
|
|
11
12
|
const format = resolveFormat({ baseUrl, model, apiFormat: config.apiFormat });
|
|
12
13
|
// The reasoning level chosen with /effort, translated for this model and this API.
|
|
13
|
-
const
|
|
14
|
+
const chosen = effectiveReasoning({ ...config, model, baseUrl });
|
|
15
|
+
let params = chosen.params;
|
|
16
|
+
if (opts.light && chosen.caps.supported) {
|
|
17
|
+
const lightest = LEVEL_ORDER.find((level) => chosen.caps.levels.includes(level));
|
|
18
|
+
if (lightest)
|
|
19
|
+
params = effectiveReasoning({ ...config, model, baseUrl, effortOverride: lightest }).params;
|
|
20
|
+
}
|
|
14
21
|
// Images are sent unless the catalogue says the model takes text only.
|
|
15
|
-
const
|
|
22
|
+
const seconds = Number(config.requestTimeoutSeconds);
|
|
23
|
+
const options = {
|
|
24
|
+
price: { provider: config.provider, prices: config.prices },
|
|
25
|
+
images: modelAcceptsImages(model) !== false,
|
|
26
|
+
// 0 turns the limit off; anything that is not a number keeps the default.
|
|
27
|
+
timeoutMs: Number.isFinite(seconds) && seconds >= 0 && config.requestTimeoutSeconds !== undefined && config.requestTimeoutSeconds !== '' ? seconds * 1000 : undefined,
|
|
28
|
+
};
|
|
16
29
|
if (format === 'anthropic') {
|
|
17
30
|
return new AnthropicProvider(baseUrl, apiKey || process.env.ANTHROPIC_API_KEY || '', model, params ?? undefined, options);
|
|
18
31
|
}
|
package/dist/providers/openai.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { UsageTracker } from '../core/usage.js';
|
|
2
2
|
import { ToolGuard } from './toolguard.js';
|
|
3
|
-
import { NOT_ABOUT_REASONING, PARAMETER_REJECTED, errorMessageOf, imageNotSentText, openAiUsage, parseToolArguments, toolTargetHint } from './shared.js';
|
|
3
|
+
import { DEFAULT_REQUEST_TIMEOUT_MS, NOT_ABOUT_REASONING, RequestWatchdog, PARAMETER_REJECTED, errorMessageOf, imageNotSentText, imagesToSend, openAiUsage, parseToolArguments, toolTargetHint } from './shared.js';
|
|
4
4
|
export { parseToolArguments };
|
|
5
5
|
// Fields the extra parameters may never overwrite.
|
|
6
6
|
const PROTECTED_FIELDS = new Set(['model', 'messages', 'stream', 'tools', 'stream_options']);
|
|
@@ -97,29 +97,41 @@ export class OpenAIProvider {
|
|
|
97
97
|
return retry;
|
|
98
98
|
}
|
|
99
99
|
buildPayload(request, stream, probe = false) {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
100
|
+
const send = imagesToSend(request.messages);
|
|
101
|
+
const messages = [];
|
|
102
|
+
// Pictures that tool results carry: this format only takes text in a tool message, so they
|
|
103
|
+
// follow the run of tool messages as one message of the user.
|
|
104
|
+
let toolPictures = [];
|
|
105
|
+
const flushToolPictures = () => {
|
|
106
|
+
if (toolPictures.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
messages.push({
|
|
109
|
+
role: 'user',
|
|
110
|
+
content: [
|
|
111
|
+
{ type: 'text', text: `Pictures returned by the tool call(s) above: ${toolPictures.map((img) => img.path).join(', ')}` },
|
|
112
|
+
...toolPictures.map((img) => ({ type: 'image_url', image_url: { url: img.dataUrl } })),
|
|
113
|
+
],
|
|
114
|
+
});
|
|
115
|
+
toolPictures = [];
|
|
116
|
+
};
|
|
117
|
+
request.messages.forEach((m, idx) => {
|
|
118
|
+
if (m.role !== 'tool')
|
|
119
|
+
flushToolPictures();
|
|
120
|
+
let content = m.content || "";
|
|
121
|
+
if (m.images && m.images.length > 0) {
|
|
122
|
+
if (this.options.images === false) {
|
|
123
|
+
const note = imageNotSentText(m.images.map(img => img.path));
|
|
124
|
+
content = m.content ? `${m.content}\n${note}` : note;
|
|
125
|
+
if (!this.imageNoticeShown) {
|
|
126
|
+
this.imageNoticeShown = true;
|
|
127
|
+
this.notices.push(`${this.model} cannot read images, so the image was not sent. Switch to a model with vision (/model).`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (send.has(idx)) {
|
|
131
|
+
if (m.role === 'tool') {
|
|
132
|
+
toolPictures.push(...m.images);
|
|
121
133
|
}
|
|
122
|
-
else
|
|
134
|
+
else {
|
|
123
135
|
content = [
|
|
124
136
|
{ type: 'text', text: m.content || "" },
|
|
125
137
|
...m.images.map(img => ({
|
|
@@ -128,24 +140,30 @@ export class OpenAIProvider {
|
|
|
128
140
|
}))
|
|
129
141
|
];
|
|
130
142
|
}
|
|
131
|
-
else {
|
|
132
|
-
const imgRef = m.images.map(img => `[Attached Image: ${img.path}]`).join(' ');
|
|
133
|
-
content = m.content ? `${m.content}\n${imgRef}` : imgRef;
|
|
134
|
-
}
|
|
135
143
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
144
|
+
else {
|
|
145
|
+
const imgRef = m.images.map(img => `[Attached Image: ${img.path}]`).join(' ');
|
|
146
|
+
content = m.content ? `${m.content}\n${imgRef}` : imgRef;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const msg = { role: m.role, content };
|
|
150
|
+
if (m.name)
|
|
151
|
+
msg.name = m.name;
|
|
152
|
+
if (m.tool_calls)
|
|
153
|
+
msg.tool_calls = m.tool_calls.map(tc => ({
|
|
154
|
+
id: tc.id,
|
|
155
|
+
type: 'function',
|
|
156
|
+
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }
|
|
157
|
+
}));
|
|
158
|
+
if (m.tool_call_id)
|
|
159
|
+
msg.tool_call_id = m.tool_call_id;
|
|
160
|
+
messages.push(msg);
|
|
161
|
+
});
|
|
162
|
+
flushToolPictures();
|
|
163
|
+
const payload = {
|
|
164
|
+
model: this.model,
|
|
165
|
+
stream,
|
|
166
|
+
messages,
|
|
149
167
|
};
|
|
150
168
|
// Tools the gateway refused are left out; a probe is a request that costs next to nothing.
|
|
151
169
|
const tools = this.toolGuard.apply(request.tools);
|
|
@@ -168,7 +186,43 @@ export class OpenAIProvider {
|
|
|
168
186
|
}
|
|
169
187
|
return payload;
|
|
170
188
|
}
|
|
189
|
+
lostRequestUsage(promptTokens, completionTokens) {
|
|
190
|
+
const usage = openAiUsage({}, this.priceContext(), { prompt: promptTokens, completion: completionTokens });
|
|
191
|
+
UsageTracker.getInstance().recordUsage(usage);
|
|
192
|
+
return usage;
|
|
193
|
+
}
|
|
194
|
+
get timeoutMs() {
|
|
195
|
+
return this.options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
196
|
+
}
|
|
171
197
|
async chat(request) {
|
|
198
|
+
const watch = new RequestWatchdog(this.timeoutMs, request.signal);
|
|
199
|
+
try {
|
|
200
|
+
return await this.chatOnce({ ...request, signal: watch.signal });
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
throw watch.timedOut ? watch.error() : err;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
watch.stop();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async *chatStream(request) {
|
|
210
|
+
const watch = new RequestWatchdog(this.timeoutMs, request.signal);
|
|
211
|
+
try {
|
|
212
|
+
const response = yield* this.streamOnce({ ...request, signal: watch.signal }, watch);
|
|
213
|
+
// The time ran out and the stream was cut: what came so far is not an answer.
|
|
214
|
+
if (watch.timedOut)
|
|
215
|
+
throw watch.error();
|
|
216
|
+
return response;
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
throw watch.timedOut ? watch.error() : err;
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
watch.stop();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async chatOnce(request) {
|
|
172
226
|
const res = await this.send(request, false);
|
|
173
227
|
if (!res.ok) {
|
|
174
228
|
const errText = await res.text();
|
|
@@ -216,7 +270,7 @@ export class OpenAIProvider {
|
|
|
216
270
|
usage
|
|
217
271
|
};
|
|
218
272
|
}
|
|
219
|
-
async *
|
|
273
|
+
async *streamOnce(request, watch) {
|
|
220
274
|
const res = await this.send(request, true);
|
|
221
275
|
if (!res.ok) {
|
|
222
276
|
const errText = await res.text();
|
|
@@ -255,6 +309,7 @@ export class OpenAIProvider {
|
|
|
255
309
|
if (request.signal?.aborted)
|
|
256
310
|
break;
|
|
257
311
|
const { done, value } = await reader.read();
|
|
312
|
+
watch.touch();
|
|
258
313
|
if (done || request.signal?.aborted)
|
|
259
314
|
break;
|
|
260
315
|
if (value) {
|
|
@@ -388,6 +443,9 @@ export class OpenAIProvider {
|
|
|
388
443
|
finally {
|
|
389
444
|
reader.releaseLock();
|
|
390
445
|
}
|
|
446
|
+
// Cut off because it went silent: nothing here is an answer, and it must not be counted as one.
|
|
447
|
+
if (watch.timedOut)
|
|
448
|
+
throw watch.error();
|
|
391
449
|
if (thinkBuffer.length > 0) {
|
|
392
450
|
if (inThinkTag) {
|
|
393
451
|
accumulatedReasoning += thinkBuffer;
|
package/dist/providers/shared.js
CHANGED
|
@@ -1,4 +1,85 @@
|
|
|
1
1
|
import { priceUsage } from '../core/prices.js';
|
|
2
|
+
/** A slow model may think for minutes without a byte: the limit is generous, it is there for a dead connection. */
|
|
3
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 300_000;
|
|
4
|
+
/**
|
|
5
|
+
* Gives up on a request that has gone silent. On a phone the connection can die without the app
|
|
6
|
+
* noticing (the network changes, the app sleeps), and the request would wait for ever. The timer
|
|
7
|
+
* starts with the request and starts over with every piece of data that arrives.
|
|
8
|
+
*
|
|
9
|
+
* `signal` is aborted when the time is up or when the caller's own signal is aborted; `timedOut`
|
|
10
|
+
* tells the two apart. The error it makes has the code ETIMEDOUT, which devx treats as a lost
|
|
11
|
+
* connection: the request is sent again.
|
|
12
|
+
*/
|
|
13
|
+
export class RequestWatchdog {
|
|
14
|
+
ms;
|
|
15
|
+
controller = new AbortController();
|
|
16
|
+
timer;
|
|
17
|
+
fired = false;
|
|
18
|
+
unlink;
|
|
19
|
+
constructor(ms, parent) {
|
|
20
|
+
this.ms = ms;
|
|
21
|
+
if (parent) {
|
|
22
|
+
if (parent.aborted)
|
|
23
|
+
this.controller.abort();
|
|
24
|
+
else {
|
|
25
|
+
const onAbort = () => this.controller.abort();
|
|
26
|
+
parent.addEventListener('abort', onAbort, { once: true });
|
|
27
|
+
this.unlink = () => parent.removeEventListener('abort', onAbort);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
this.touch();
|
|
31
|
+
}
|
|
32
|
+
get signal() {
|
|
33
|
+
return this.controller.signal;
|
|
34
|
+
}
|
|
35
|
+
get timedOut() {
|
|
36
|
+
return this.fired;
|
|
37
|
+
}
|
|
38
|
+
/** Something arrived: the clock starts over. */
|
|
39
|
+
touch() {
|
|
40
|
+
if (!(this.ms > 0) || this.fired || this.controller.signal.aborted)
|
|
41
|
+
return;
|
|
42
|
+
clearTimeout(this.timer);
|
|
43
|
+
this.timer = setTimeout(() => {
|
|
44
|
+
this.fired = true;
|
|
45
|
+
this.controller.abort();
|
|
46
|
+
}, this.ms);
|
|
47
|
+
this.timer.unref?.();
|
|
48
|
+
}
|
|
49
|
+
stop() {
|
|
50
|
+
clearTimeout(this.timer);
|
|
51
|
+
this.unlink?.();
|
|
52
|
+
this.unlink = undefined;
|
|
53
|
+
}
|
|
54
|
+
error() {
|
|
55
|
+
const seconds = Math.round(this.ms / 1000);
|
|
56
|
+
return Object.assign(new Error(`No response from the server for ${seconds} s (the connection may have dropped)`), { code: 'ETIMEDOUT' });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The indexes of the messages whose pictures are really sent. Only the newest pictures go out in
|
|
61
|
+
* full; older ones become a `[Attached Image: path]` note, which keeps a long chat cheap (the
|
|
62
|
+
* model can read the file again if it needs to). Pictures that parallel tool calls returned in
|
|
63
|
+
* one step belong together, so they are all kept.
|
|
64
|
+
*/
|
|
65
|
+
export function imagesToSend(messages) {
|
|
66
|
+
const keep = new Set();
|
|
67
|
+
let last = -1;
|
|
68
|
+
messages.forEach((m, i) => {
|
|
69
|
+
if (m.images && m.images.length > 0)
|
|
70
|
+
last = i;
|
|
71
|
+
});
|
|
72
|
+
if (last === -1)
|
|
73
|
+
return keep;
|
|
74
|
+
keep.add(last);
|
|
75
|
+
if (messages[last].role === 'tool') {
|
|
76
|
+
for (let j = last - 1; j >= 0 && messages[j].role === 'tool'; j--) {
|
|
77
|
+
if (messages[j].images?.length)
|
|
78
|
+
keep.add(j);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return keep;
|
|
82
|
+
}
|
|
2
83
|
/**
|
|
3
84
|
* Parses the JSON arguments of a tool call. Invalid JSON is reported instead of being
|
|
4
85
|
* silently replaced by `{}` (a truncated write_file would otherwise look like an empty call).
|
|
@@ -80,6 +161,8 @@ export function openAiUsage(raw, ctx, fallback) {
|
|
|
80
161
|
if (priced.cost !== undefined) {
|
|
81
162
|
usage.cost = priced.cost;
|
|
82
163
|
usage.costSource = guessed && priced.costSource !== 'free' ? 'estimated' : priced.costSource;
|
|
164
|
+
if (priced.parts)
|
|
165
|
+
usage.costParts = priced.parts;
|
|
83
166
|
}
|
|
84
167
|
return usage;
|
|
85
168
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { MAX_BACKGROUND_TASKS, formatDuration, getBackgroundManager } from '../core/background.js';
|
|
2
|
+
const ACTIONS = ['start', 'list', 'output', 'wait', 'stop'];
|
|
3
|
+
/** One line per task: `bg1 running 2m 03s npm run dev`. */
|
|
4
|
+
export function describeTask(info, now = Date.now()) {
|
|
5
|
+
const age = formatDuration((info.endedAt ?? now) - info.startedAt);
|
|
6
|
+
const state = info.status === 'running' ? 'running'
|
|
7
|
+
: info.status === 'stopped' ? 'stopped'
|
|
8
|
+
: info.status === 'exited' ? 'finished (exit 0)'
|
|
9
|
+
: `failed (exit ${info.code ?? '?'})`;
|
|
10
|
+
const watch = info.watching && info.status === 'running' ? ` [watching /${info.watching}/]` : '';
|
|
11
|
+
return `${info.id} ${state} ${age} ${info.label}${watch}`;
|
|
12
|
+
}
|
|
13
|
+
function outputBlock(text, omitted) {
|
|
14
|
+
if (!text)
|
|
15
|
+
return '(no output)';
|
|
16
|
+
return (omitted > 0 ? `[... ${omitted} earlier line${omitted === 1 ? '' : 's'} left out ...]\n` : '') + text;
|
|
17
|
+
}
|
|
18
|
+
export const backgroundTool = {
|
|
19
|
+
name: 'background',
|
|
20
|
+
definition: {
|
|
21
|
+
name: 'background',
|
|
22
|
+
description: 'Run a long-lived command in the background and keep working: dev servers, file watchers, long builds or test runs. ' +
|
|
23
|
+
'Do NOT use it for commands that finish quickly (use bash). Actions: ' +
|
|
24
|
+
'"start" (command, optional label, notify_on, max_runtime_seconds) launches it and returns its id and first output; ' +
|
|
25
|
+
'"list" shows all tasks; ' +
|
|
26
|
+
'"output" (id) reads what the task printed since the last read (mode "tail" gives the last lines instead; "grep" filters lines); ' +
|
|
27
|
+
'"wait" (id, optional pattern, timeout_seconds) blocks until the task ends, a line matches the regex, or the time is up; ' +
|
|
28
|
+
'"stop" (id) ends the task. ' +
|
|
29
|
+
'Set "notify_on" (a regex such as "ready|listening|error|FAIL") to be told when such a line appears, and you are always told when a task ends: ' +
|
|
30
|
+
'then you do not need to poll. Never poll in a loop; wait with a pattern instead. ' +
|
|
31
|
+
`At most ${MAX_BACKGROUND_TASKS} tasks run at once, and all of them are stopped when devx exits.`,
|
|
32
|
+
parameters: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
action: { type: 'string', enum: [...ACTIONS], description: 'What to do' },
|
|
36
|
+
command: { type: 'string', description: 'start: the shell command' },
|
|
37
|
+
label: { type: 'string', description: 'start: a short name shown to the user, e.g. "dev server"' },
|
|
38
|
+
notify_on: { type: 'string', description: 'start: regex; a matching output line is reported to you' },
|
|
39
|
+
max_runtime_seconds: { type: 'number', description: 'start: stop the task after this many seconds (default: no limit)' },
|
|
40
|
+
id: { type: 'string', description: 'output / wait / stop: the task id (bg1, bg2, ...)' },
|
|
41
|
+
mode: { type: 'string', enum: ['new', 'tail'], description: 'output: "new" (default) = since the last read, "tail" = last lines' },
|
|
42
|
+
lines: { type: 'number', description: 'output: how many lines at most (default 200 for new, 40 for tail)' },
|
|
43
|
+
grep: { type: 'string', description: 'output: only lines that match this regex' },
|
|
44
|
+
pattern: { type: 'string', description: 'wait: regex of an output line to wait for (default: wait for the end)' },
|
|
45
|
+
timeout_seconds: { type: 'number', description: 'wait: give up after this long (default 60, max 600)' },
|
|
46
|
+
},
|
|
47
|
+
required: ['action'],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
validateArgs(args) {
|
|
51
|
+
if (!args || !ACTIONS.includes(args.action))
|
|
52
|
+
throw new Error(`action must be one of: ${ACTIONS.join(', ')}`);
|
|
53
|
+
if (args.action === 'start' && (!args.command || typeof args.command !== 'string'))
|
|
54
|
+
throw new Error('command is required for "start"');
|
|
55
|
+
if (['output', 'wait', 'stop'].includes(args.action) && !args.id)
|
|
56
|
+
throw new Error(`id is required for "${args.action}" (see action "list")`);
|
|
57
|
+
},
|
|
58
|
+
async execute(args, _config, context) {
|
|
59
|
+
const manager = getBackgroundManager();
|
|
60
|
+
const action = args.action;
|
|
61
|
+
try {
|
|
62
|
+
if (action === 'start') {
|
|
63
|
+
const info = manager.start(args.command, {
|
|
64
|
+
label: args.label,
|
|
65
|
+
notifyOn: args.notify_on,
|
|
66
|
+
maxRuntimeSeconds: Number(args.max_runtime_seconds) || 0,
|
|
67
|
+
});
|
|
68
|
+
// Give it a moment: a command that dies at once (wrong script, busy port) should be seen now.
|
|
69
|
+
const first = await manager.wait(info.id, { timeoutSeconds: 1.5, signal: context?.signal });
|
|
70
|
+
const now = manager.get(info.id);
|
|
71
|
+
const out = manager.read(info.id, { mode: 'new', lines: 30 });
|
|
72
|
+
const head = now.status === 'running'
|
|
73
|
+
? `Started ${now.id} (pid ${now.pid ?? '?'}): ${now.label}. It runs in the background.`
|
|
74
|
+
: `${describeTask(now)} - it ended right after the start.`;
|
|
75
|
+
const hint = now.status === 'running'
|
|
76
|
+
? '\nYou will be told when it ends' + (now.watching ? ' or prints a line matching the pattern' : '') + '. Use "output" to read more, "wait" to wait for a line.'
|
|
77
|
+
: '';
|
|
78
|
+
return `${head}${hint}\n${outputBlock(out.text, out.omitted)}${first.reason === 'aborted' ? '\n[Operation cancelled by user]' : ''}`;
|
|
79
|
+
}
|
|
80
|
+
if (action === 'list') {
|
|
81
|
+
const tasks = manager.list();
|
|
82
|
+
return tasks.length === 0 ? 'No background tasks.' : tasks.map((t) => describeTask(t)).join('\n');
|
|
83
|
+
}
|
|
84
|
+
if (action === 'output') {
|
|
85
|
+
const info = manager.get(String(args.id));
|
|
86
|
+
const out = manager.read(String(args.id), {
|
|
87
|
+
mode: args.mode === 'tail' ? 'tail' : 'new',
|
|
88
|
+
lines: args.lines,
|
|
89
|
+
grep: args.grep,
|
|
90
|
+
});
|
|
91
|
+
return `${info ? describeTask(info) : args.id}\n${outputBlock(out.text, out.omitted)}`;
|
|
92
|
+
}
|
|
93
|
+
if (action === 'wait') {
|
|
94
|
+
const result = await manager.wait(String(args.id), {
|
|
95
|
+
pattern: args.pattern,
|
|
96
|
+
timeoutSeconds: Number(args.timeout_seconds) || 60,
|
|
97
|
+
signal: context?.signal,
|
|
98
|
+
});
|
|
99
|
+
const info = manager.get(String(args.id));
|
|
100
|
+
const out = manager.read(String(args.id), { mode: 'new', lines: 60 });
|
|
101
|
+
const why = result.reason === 'match' ? `A line matched: ${result.line}`
|
|
102
|
+
: result.reason === 'exit' ? 'The task has ended.'
|
|
103
|
+
: result.reason === 'timeout' ? 'Timed out; the task is still running.'
|
|
104
|
+
: '[Operation cancelled by user]';
|
|
105
|
+
return `${why}\n${describeTask(info)}\n${outputBlock(out.text, out.omitted)}`;
|
|
106
|
+
}
|
|
107
|
+
// stop
|
|
108
|
+
const before = manager.get(String(args.id));
|
|
109
|
+
if (before && before.status !== 'running')
|
|
110
|
+
return `${describeTask(before)} (already ended)`;
|
|
111
|
+
const info = await manager.stop(String(args.id));
|
|
112
|
+
return `Stopped. ${describeTask(info)}`;
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
return `Error: ${err.message}`;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
package/dist/tools/bash.js
CHANGED
|
@@ -63,6 +63,8 @@ export function runShell(command, options = {}) {
|
|
|
63
63
|
const stderrDecoder = new StringDecoder('utf8');
|
|
64
64
|
let stopReason = '';
|
|
65
65
|
let killTimer;
|
|
66
|
+
let killRepeat;
|
|
67
|
+
let settled = false;
|
|
66
68
|
const killProcessTree = (force) => {
|
|
67
69
|
if (!proc.pid)
|
|
68
70
|
return;
|
|
@@ -90,6 +92,10 @@ export function runShell(command, options = {}) {
|
|
|
90
92
|
killProcessTree(false);
|
|
91
93
|
// Commands can ignore SIGTERM. Do not leave them running after cancellation.
|
|
92
94
|
killTimer = setTimeout(() => killProcessTree(true), 1000);
|
|
95
|
+
// On Windows a command such as `npm test` starts its child a moment later, after the first
|
|
96
|
+
// kill has looked at the tree: kill again until the process is gone.
|
|
97
|
+
if (isWin)
|
|
98
|
+
killRepeat = setInterval(() => killProcessTree(true), 700);
|
|
93
99
|
};
|
|
94
100
|
const onAbort = () => stop('[Operation cancelled by user]');
|
|
95
101
|
const timeout = setTimeout(() => stop(`[Process killed due to timeout after ${timeoutSeconds}s. ` +
|
|
@@ -108,14 +114,30 @@ export function runShell(command, options = {}) {
|
|
|
108
114
|
const cleanup = () => {
|
|
109
115
|
clearTimeout(timeout);
|
|
110
116
|
clearTimeout(killTimer);
|
|
117
|
+
clearInterval(killRepeat);
|
|
111
118
|
signal?.removeEventListener('abort', onAbort);
|
|
112
119
|
};
|
|
113
|
-
|
|
120
|
+
const finish = (code) => {
|
|
121
|
+
if (settled)
|
|
122
|
+
return;
|
|
123
|
+
settled = true;
|
|
114
124
|
if (stopReason)
|
|
115
125
|
killProcessTree(true);
|
|
116
126
|
output.push(stdoutDecoder.end() + stderrDecoder.end());
|
|
117
127
|
cleanup();
|
|
118
128
|
resolve({ output: output.toString(), code, stopReason });
|
|
129
|
+
};
|
|
130
|
+
proc.on('close', finish);
|
|
131
|
+
// After a cancel or a timeout the shell is gone, but a child that got away can keep the pipes
|
|
132
|
+
// open for good: do not wait for them for more than a moment.
|
|
133
|
+
proc.on('exit', (code) => {
|
|
134
|
+
if (!stopReason)
|
|
135
|
+
return;
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
proc.stdout?.destroy();
|
|
138
|
+
proc.stderr?.destroy();
|
|
139
|
+
finish(code);
|
|
140
|
+
}, 300);
|
|
119
141
|
});
|
|
120
142
|
proc.on('error', (err) => {
|
|
121
143
|
cleanup();
|
package/dist/tools/fs.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { globalSnapshotManager } from '../core/snapshot.js';
|
|
4
|
+
import { IMAGE_WARN_BYTES, imageMimeForPath, sniffImageMime } from '../core/images.js';
|
|
5
|
+
import { modelAcceptsImages } from '../core/vision.js';
|
|
4
6
|
// A single read result lands in the model context, so it must stay bounded.
|
|
5
7
|
export const MAX_READ_CHARS = 60000;
|
|
6
8
|
const MAX_READ_FILE_BYTES = 10 * 1024 * 1024;
|
|
@@ -13,12 +15,36 @@ function toLineNumber(value, name) {
|
|
|
13
15
|
}
|
|
14
16
|
return n;
|
|
15
17
|
}
|
|
18
|
+
/** Hands an image file to the model. The text returned here is what the tool result says. */
|
|
19
|
+
async function readImage(file, size, model, context) {
|
|
20
|
+
const name = path.basename(file);
|
|
21
|
+
if (model && modelAcceptsImages(model) === false) {
|
|
22
|
+
return `[Image ${file}: the current model (${model}) cannot read images. Tell the user to switch to a model with vision (/model).]`;
|
|
23
|
+
}
|
|
24
|
+
if (!context?.attachImage) {
|
|
25
|
+
return `[Image ${file} (${size} bytes): images cannot be shown in this context]`;
|
|
26
|
+
}
|
|
27
|
+
// An image the API refuses stays in the history and breaks every following request,
|
|
28
|
+
// so the limit is the strictest one in use (Anthropic: 5 MB).
|
|
29
|
+
if (size > IMAGE_WARN_BYTES) {
|
|
30
|
+
throw new Error(`Image ${name} is ${(size / 1048576).toFixed(1)} MB: too large to show (limit ${IMAGE_WARN_BYTES / 1048576} MB). ` +
|
|
31
|
+
'Make a smaller copy first with bash (for example ImageMagick: convert in.png -resize 1600x out.png) and read that.');
|
|
32
|
+
}
|
|
33
|
+
const buffer = await fs.readFile(file);
|
|
34
|
+
const mime = sniffImageMime(buffer);
|
|
35
|
+
if (!mime) {
|
|
36
|
+
throw new Error(`${name} is not a valid PNG, JPEG, GIF or WebP image (the file contents do not match its extension)`);
|
|
37
|
+
}
|
|
38
|
+
context.attachImage({ path: file, dataUrl: `data:${mime};base64,${buffer.toString('base64')}` });
|
|
39
|
+
return `[Image ${file} (${mime}, ${(size / 1024).toFixed(0)} KB) is attached to this result: look at it]`;
|
|
40
|
+
}
|
|
16
41
|
export const readFileTool = {
|
|
17
42
|
name: 'read_file',
|
|
18
43
|
definition: {
|
|
19
44
|
name: 'read_file',
|
|
20
45
|
description: `Read contents of a file. Large files are returned in parts (max ~${MAX_READ_CHARS} characters per call): ` +
|
|
21
|
-
'use startLine/endLine (1-based, inclusive) to read a specific range or to continue reading.'
|
|
46
|
+
'use startLine/endLine (1-based, inclusive) to read a specific range or to continue reading. ' +
|
|
47
|
+
'Image files (png, jpg, webp, gif) are shown to you as a picture, so you can look at screenshots, photos and design mockups.',
|
|
22
48
|
parameters: {
|
|
23
49
|
type: 'object',
|
|
24
50
|
properties: {
|
|
@@ -38,12 +64,16 @@ export const readFileTool = {
|
|
|
38
64
|
throw new Error('endLine must be greater than or equal to startLine');
|
|
39
65
|
}
|
|
40
66
|
},
|
|
41
|
-
async execute(args) {
|
|
67
|
+
async execute(args, config, context) {
|
|
42
68
|
try {
|
|
43
69
|
const stat = await fs.stat(args.path);
|
|
44
70
|
if (stat.isDirectory()) {
|
|
45
71
|
throw new Error('Path is a directory, use list_dir instead');
|
|
46
72
|
}
|
|
73
|
+
// A picture is not text: it is shown to the model together with this result.
|
|
74
|
+
if (imageMimeForPath(args.path)) {
|
|
75
|
+
return await readImage(args.path, stat.size, config?.model, context);
|
|
76
|
+
}
|
|
47
77
|
if (stat.size > MAX_READ_FILE_BYTES) {
|
|
48
78
|
throw new Error(`File is too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Use the search tool, or bash (e.g. head/tail/sed -n) to look at part of it`);
|
|
49
79
|
}
|
package/dist/tools/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileTool, writeFileTool, editFileTool, listDirTool, mkdirTool } from './fs.js';
|
|
2
2
|
import { bashTool } from './bash.js';
|
|
3
|
+
import { backgroundTool } from './background.js';
|
|
3
4
|
import { searchTool } from './search.js';
|
|
4
5
|
import { askQuestionsTool } from './questions.js';
|
|
5
6
|
import { webSearchTool, fetchUrlTool } from './web.js';
|
|
@@ -17,6 +18,6 @@ export function getTools(planMode) {
|
|
|
17
18
|
if (planMode) {
|
|
18
19
|
return [...baseTools, ...mcpTools];
|
|
19
20
|
}
|
|
20
|
-
return [...baseTools, writeFileTool, editFileTool, mkdirTool, bashTool, diagnoseCodeTool, installPackageTool, servePreviewTool, mcpManageTool, ...mcpTools];
|
|
21
|
+
return [...baseTools, writeFileTool, editFileTool, mkdirTool, bashTool, backgroundTool, diagnoseCodeTool, installPackageTool, servePreviewTool, mcpManageTool, ...mcpTools];
|
|
21
22
|
}
|
|
22
23
|
export { webSearchTool, fetchUrlTool, diagnoseCodeTool, installPackageTool, saveMemoryTool, planReadyTool, lastPlanReady, resetPlanReady, todoListTool, currentTodoList, resetTodoList, servePreviewTool };
|
package/package.json
CHANGED