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.
Files changed (55) hide show
  1. package/README.md +17 -4
  2. package/assets/banner.svg +1 -1
  3. package/assets/preview.svg +1 -1
  4. package/dist/cli/background-ui.js +116 -0
  5. package/dist/cli/files.js +2 -12
  6. package/dist/cli/finder.js +4 -0
  7. package/dist/cli/headless.js +6 -3
  8. package/dist/cli/index.js +259 -74
  9. package/dist/cli/keys.js +12 -3
  10. package/dist/cli/liveblock.js +38 -9
  11. package/dist/cli/mcp-panel.js +2 -0
  12. package/dist/cli/picker.js +2 -0
  13. package/dist/cli/prompt.js +93 -8
  14. package/dist/cli/provider-manager.js +197 -0
  15. package/dist/cli/providers.js +0 -5
  16. package/dist/cli/settings.js +38 -17
  17. package/dist/cli/typeahead.js +10 -17
  18. package/dist/cli/ui.js +189 -32
  19. package/dist/cli/updater.js +10 -8
  20. package/dist/cli/usage-panel.js +17 -4
  21. package/dist/cli/width.js +18 -0
  22. package/dist/core/background.js +390 -0
  23. package/dist/core/config-merge.js +67 -0
  24. package/dist/core/context-stats.js +1 -0
  25. package/dist/core/cost.js +25 -11
  26. package/dist/core/custom-providers.js +161 -0
  27. package/dist/core/images.js +46 -0
  28. package/dist/core/local-config.js +3 -1
  29. package/dist/core/locales/de.js +36 -7
  30. package/dist/core/locales/en.js +36 -7
  31. package/dist/core/locales/es.js +36 -7
  32. package/dist/core/locales/fr.js +36 -7
  33. package/dist/core/locales/ja.js +36 -7
  34. package/dist/core/locales/pt.js +36 -7
  35. package/dist/core/locales/ru.js +36 -7
  36. package/dist/core/locales/uk.js +36 -7
  37. package/dist/core/locales/zh.js +36 -7
  38. package/dist/core/loop.js +50 -2
  39. package/dist/core/prices.js +8 -4
  40. package/dist/core/session-media.js +115 -0
  41. package/dist/core/session.js +17 -2
  42. package/dist/core/title.js +19 -2
  43. package/dist/core/types.js +1 -1
  44. package/dist/core/usage.js +41 -4
  45. package/dist/permissions/guard.js +6 -1
  46. package/dist/prompts/builder.js +2 -1
  47. package/dist/providers/anthropic.js +67 -13
  48. package/dist/providers/index.js +16 -3
  49. package/dist/providers/openai.js +99 -41
  50. package/dist/providers/shared.js +83 -0
  51. package/dist/tools/background.js +118 -0
  52. package/dist/tools/bash.js +23 -1
  53. package/dist/tools/fs.js +32 -2
  54. package/dist/tools/index.js +2 -1
  55. package/package.json +1 -1
@@ -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
- export function createProvider(config) {
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 { params } = effectiveReasoning({ ...config, model, baseUrl });
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 options = { price: { provider: config.provider, prices: config.prices }, images: modelAcceptsImages(model) !== false };
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
  }
@@ -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 totalMsgs = request.messages.length;
101
- let lastImageMsgIdx = -1;
102
- for (let i = totalMsgs - 1; i >= 0; i--) {
103
- if (request.messages[i].images && request.messages[i].images.length > 0) {
104
- lastImageMsgIdx = i;
105
- break;
106
- }
107
- }
108
- const payload = {
109
- model: this.model,
110
- stream,
111
- messages: request.messages.map((m, idx) => {
112
- let content = m.content || "";
113
- if (m.images && m.images.length > 0) {
114
- if (this.options.images === false) {
115
- const note = imageNotSentText(m.images.map(img => img.path));
116
- content = m.content ? `${m.content}\n${note}` : note;
117
- if (!this.imageNoticeShown) {
118
- this.imageNoticeShown = true;
119
- this.notices.push(`${this.model} cannot read images, so the image was not sent. Switch to a model with vision (/model).`);
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 if (idx === lastImageMsgIdx) {
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
- const msg = { role: m.role, content };
137
- if (m.name)
138
- msg.name = m.name;
139
- if (m.tool_calls)
140
- msg.tool_calls = m.tool_calls.map(tc => ({
141
- id: tc.id,
142
- type: 'function',
143
- function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }
144
- }));
145
- if (m.tool_call_id)
146
- msg.tool_call_id = m.tool_call_id;
147
- return msg;
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 *chatStream(request) {
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;
@@ -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
+ };
@@ -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
- proc.on('close', (code) => {
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termux-dev",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Ultra-fast, terminal-native AI coding assistant and agent built for Android Termux, Windows, macOS, and Linux.",
5
5
  "main": "dist/cli/index.js",
6
6
  "type": "module",