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
package/dist/core/loop.js CHANGED
@@ -32,6 +32,17 @@ export function getActionDescription(toolName, args) {
32
32
  if (t === 'bash' || t === 'execute_command' || t === 'run_command' || t === 'exec') {
33
33
  return `→ Run: ${args.command || args.cmd || ''}`;
34
34
  }
35
+ if (t === 'background') {
36
+ const id = args.id ? ` ${args.id}` : '';
37
+ switch (args.action) {
38
+ case 'start': return `→ Background: ${args.label ? `${args.label} · ` : ''}${args.command || ''}`;
39
+ case 'list': return `→ Background: list tasks`;
40
+ case 'output': return `→ Background: output${id}`;
41
+ case 'wait': return `→ Background: wait${id}${args.pattern ? ` for /${args.pattern}/` : ''}`;
42
+ case 'stop': return `→ Background: stop${id}`;
43
+ default: return `→ Background`;
44
+ }
45
+ }
35
46
  if (t === 'todo_list' || t === 'update_todos') {
36
47
  return `→ Update Plan & Tasks`;
37
48
  }
@@ -50,6 +61,15 @@ export function getActionDescription(toolName, args) {
50
61
  return `→ Executing ${toolName}...`;
51
62
  }
52
63
  const CONTEXT_LENGTH_ERROR = /context[_ ]length|maximum context|context window|too many tokens|prompt is too long|reduce the length|input is too long|exceeds? the (?:model|maximum|max)/i;
64
+ /**
65
+ * A request that failed in a way that usually still costs money: the connection broke or timed out
66
+ * after the request had gone out (no HTTP status), or a gateway gave up waiting for a slow model
67
+ * (504, 524) that went on working. Rejections (400, 401, 429, ...) are not billed.
68
+ */
69
+ function probablyBilled(err) {
70
+ const status = err?.status ?? err?.statusCode;
71
+ return status === undefined || status === 0 || status === 504 || status === 524;
72
+ }
53
73
  function isContextLengthError(err) {
54
74
  const status = err?.status;
55
75
  if (status !== undefined && status !== 400 && status !== 413 && status !== 422)
@@ -83,6 +103,12 @@ export class Agent {
83
103
  this.history.addMessage(message);
84
104
  yield { type: 'user_message', content: message.content };
85
105
  }
106
+ // A background task ended or printed what was asked for: the model learns it before it decides.
107
+ const news = hooks?.takeNotes?.();
108
+ if (news) {
109
+ this.history.addMessage({ role: 'user', content: news });
110
+ yield { type: 'background_note', content: news };
111
+ }
86
112
  const toolDefinitions = Array.from(this.tools.values()).map((t) => t.definition);
87
113
  // Tool schemas are sent with every request and count against the context window.
88
114
  const schemaTokens = toolSchemaTokens(toolDefinitions);
@@ -103,21 +129,32 @@ export class Agent {
103
129
  let attempt = 0;
104
130
  let contextRetries = 0;
105
131
  const maxRetries = 4;
132
+ // A request that goes out and gets no answer (cancelled, connection lost, gateway timeout) is
133
+ // usually billed all the same, and a retry sends the whole conversation again. Such requests
134
+ // are counted as an estimate instead of being left out of the cost.
135
+ let receivedChars = 0;
136
+ const lostUsage = () => this.provider.lostRequestUsage?.(this.history.getTotalTokens() + schemaTokens, Math.ceil(receivedChars * 0.33));
106
137
  while (true) {
107
138
  if (signal?.aborted)
108
139
  return;
140
+ receivedChars = 0;
109
141
  try {
110
142
  if (this.provider.chatStream) {
111
143
  let receivedAnyChunk = false;
112
144
  for await (const chunk of this.provider.chatStream(request)) {
113
145
  if (signal?.aborted) {
146
+ const lost = lostUsage();
147
+ if (lost)
148
+ yield { type: 'usage', usage: lost };
114
149
  return;
115
150
  }
116
151
  receivedAnyChunk = true;
117
152
  if (chunk.type === 'reasoning_delta') {
153
+ receivedChars += chunk.delta.length;
118
154
  yield { type: 'reasoning_delta', delta: chunk.delta };
119
155
  }
120
156
  else if (chunk.type === 'content_delta') {
157
+ receivedChars += chunk.delta.length;
121
158
  yield { type: 'text_delta', delta: chunk.delta };
122
159
  }
123
160
  else if (chunk.type === 'tool_generating') {
@@ -142,6 +179,9 @@ export class Agent {
142
179
  }
143
180
  catch (err) {
144
181
  if (signal?.aborted || err.name === 'AbortError' || err.message?.includes('aborted')) {
182
+ const lost = lostUsage();
183
+ if (lost)
184
+ yield { type: 'usage', usage: lost };
145
185
  return;
146
186
  }
147
187
  // The provider says the prompt is too long (our estimate was too optimistic):
@@ -155,6 +195,11 @@ export class Agent {
155
195
  continue;
156
196
  }
157
197
  const isNetwork = isNetworkOrRetryableError(err);
198
+ if (isNetwork && probablyBilled(err)) {
199
+ const lost = lostUsage();
200
+ if (lost)
201
+ yield { type: 'usage', usage: lost };
202
+ }
158
203
  if (isNetwork && attempt < maxRetries) {
159
204
  attempt++;
160
205
  const delayMs = Math.min(1500 * Math.pow(1.5, attempt - 1), 8000);
@@ -207,7 +252,7 @@ export class Agent {
207
252
  }
208
253
  if (!response.toolCalls || response.toolCalls.length === 0) {
209
254
  // The user wrote something while the model was finishing: answer it instead of stopping.
210
- if (hooks?.hasPending?.()) {
255
+ if (hooks?.hasPending?.() || hooks?.hasNotes?.()) {
211
256
  iterations++;
212
257
  continue;
213
258
  }
@@ -223,6 +268,8 @@ export class Agent {
223
268
  if (signal?.aborted)
224
269
  return;
225
270
  let result = '';
271
+ // Pictures a tool wants the model to see (read_file on an image) travel with its result.
272
+ const pictures = [];
226
273
  const tool = this.tools.get(call.name);
227
274
  if (!tool) {
228
275
  result = `Error: Tool '${call.name}' not found.`;
@@ -246,7 +293,7 @@ export class Agent {
246
293
  if (signal?.aborted)
247
294
  return;
248
295
  result = allowed
249
- ? await tool.execute(call.arguments, this.config, { signal })
296
+ ? await tool.execute(call.arguments, this.config, { signal, attachImage: (image) => pictures.push(image) })
250
297
  : 'Action denied by user.';
251
298
  if (allowed) {
252
299
  const post = await runHooks(this.config.hooks, 'PostToolUse', tool.name, call.arguments, result);
@@ -269,6 +316,7 @@ export class Agent {
269
316
  content: result,
270
317
  tool_call_id: call.id,
271
318
  name: call.name,
319
+ ...(pictures.length > 0 ? { images: pictures } : {}),
272
320
  });
273
321
  executedToolCallIds.add(call.id);
274
322
  yield { type: 'tool_end', id: call.id, name: call.name, result };
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { NATIVE_PROVIDERS, devxDir, normalizeModelKey, readJsonFile } from './catalog-util.js';
5
5
  import { ensureSecureDir } from './secure-file.js';
6
- import { computeCost, isFreeModel, reportedCost, } from './cost.js';
6
+ import { costParts, isFreeModel, reportedCost, scaleParts, } from './cost.js';
7
7
  /**
8
8
  * Prices of models: where they come from and how a request is priced.
9
9
  *
@@ -240,10 +240,14 @@ export function priceUsage(ctx, counts, rawUsage) {
240
240
  const tokens = (counts.promptTokens || 0) + (counts.completionTokens || 0);
241
241
  if (reported !== undefined) {
242
242
  const suspicious = reported === 0 && tokens > 0 && !!info && info.source !== 'free' && info.rates.input + info.rates.output > 0;
243
- if (!suspicious)
244
- return { cost: reported, costSource: reported === 0 && info?.source === 'free' ? 'free' : 'reported' };
243
+ if (!suspicious) {
244
+ // The provider gave only the total: split it in the proportions of the known rates.
245
+ const parts = info ? scaleParts(costParts(info.rates, counts), reported) : undefined;
246
+ return { cost: reported, costSource: reported === 0 && info?.source === 'free' ? 'free' : 'reported', parts };
247
+ }
245
248
  }
246
249
  if (!info)
247
250
  return {};
248
- return { cost: computeCost(info.rates, counts), costSource: info.source };
251
+ const parts = costParts(info.rates, counts);
252
+ return { cost: parts.input + parts.cacheRead + parts.cacheWrite + parts.output, costSource: info.source, parts };
249
253
  }
@@ -0,0 +1,115 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { ensureSecureDir } from './secure-file.js';
5
+ /**
6
+ * Pictures of a saved chat live in files next to it, not inside its JSON. A picture is a data URL
7
+ * of up to several megabytes; written into the chat file it made every save (after every turn)
8
+ * rewrite all of them, and the list of chats (/resume) had to read and parse every one.
9
+ *
10
+ * On disk a message carries `{ path, file }`; the bytes are in `<chat id>.images/<file>`, named by
11
+ * their hash, so the same picture is stored once however often it is saved.
12
+ */
13
+ const EXT_BY_MIME = {
14
+ 'image/png': 'png',
15
+ 'image/jpeg': 'jpg',
16
+ 'image/webp': 'webp',
17
+ 'image/gif': 'gif',
18
+ };
19
+ const MIME_BY_EXT = Object.fromEntries(Object.entries(EXT_BY_MIME).map(([mime, ext]) => [ext, mime]));
20
+ export const imagesDirFor = (sessionsDir, sessionId) => path.join(sessionsDir, `${sessionId}.images`);
21
+ const DATA_URL = /^data:([^;,]+);base64,(.*)$/s;
22
+ /**
23
+ * The messages as they are written to disk: pictures replaced by the name of their file, the
24
+ * files written. Returns the names in use, so the others can be removed. The messages passed in
25
+ * are not changed.
26
+ */
27
+ export async function externalizeImages(messages, dir, index = new Map()) {
28
+ const used = new Set();
29
+ let dirReady = false;
30
+ const store = async (image) => {
31
+ const match = DATA_URL.exec(image.dataUrl ?? '');
32
+ const ext = match ? EXT_BY_MIME[match[1]] : undefined;
33
+ if (!match || !ext)
34
+ return image; // not something we can store: keep it as it is
35
+ let file = index.get(image.dataUrl);
36
+ if (!file) {
37
+ file = `${crypto.createHash('sha256').update(image.dataUrl).digest('hex').slice(0, 32)}.${ext}`;
38
+ index.set(image.dataUrl, file);
39
+ }
40
+ used.add(file);
41
+ const target = path.join(dir, file);
42
+ try {
43
+ await fs.access(target);
44
+ }
45
+ catch {
46
+ if (!dirReady) {
47
+ await ensureSecureDir(dir);
48
+ dirReady = true;
49
+ }
50
+ // Written aside and renamed: a crash must not leave half a picture under the real name.
51
+ const part = `${target}.part`;
52
+ await fs.writeFile(part, Buffer.from(match[2], 'base64'), { mode: 0o600 });
53
+ await fs.rename(part, target);
54
+ }
55
+ return { path: image.path, dataUrl: '', file };
56
+ };
57
+ const out = [];
58
+ for (const message of messages) {
59
+ if (!message.images || message.images.length === 0) {
60
+ out.push(message);
61
+ continue;
62
+ }
63
+ const images = [];
64
+ for (const image of message.images)
65
+ images.push(await store(image));
66
+ out.push({ ...message, images });
67
+ }
68
+ return { messages: out, used };
69
+ }
70
+ /**
71
+ * The messages of a loaded chat with their pictures read back from the files. A picture whose
72
+ * file is gone is dropped (the text of the message still says what it was); a chat saved by an
73
+ * older devx, with the data inside, is left as it is.
74
+ */
75
+ export async function internalizeImages(messages, dir) {
76
+ const out = [];
77
+ for (const message of messages) {
78
+ if (!message.images || message.images.length === 0) {
79
+ out.push(message);
80
+ continue;
81
+ }
82
+ const images = [];
83
+ for (const image of message.images) {
84
+ const file = image.file;
85
+ if (!file) {
86
+ if (image.dataUrl)
87
+ images.push(image);
88
+ continue;
89
+ }
90
+ const mime = MIME_BY_EXT[path.extname(file).slice(1).toLowerCase()];
91
+ // A name from a file on disk must not lead out of the folder.
92
+ if (!mime || path.basename(file) !== file)
93
+ continue;
94
+ try {
95
+ const data = await fs.readFile(path.join(dir, file));
96
+ images.push({ path: image.path, dataUrl: `data:${mime};base64,${data.toString('base64')}` });
97
+ }
98
+ catch { }
99
+ }
100
+ const { images: _dropped, ...rest } = message;
101
+ out.push(images.length > 0 ? { ...rest, images } : rest);
102
+ }
103
+ return out;
104
+ }
105
+ /** Removes the picture files that no message uses any more (after a /rewind, for example). */
106
+ export async function pruneImages(dir, used) {
107
+ let names;
108
+ try {
109
+ names = await fs.readdir(dir);
110
+ }
111
+ catch {
112
+ return;
113
+ }
114
+ await Promise.all(names.filter((n) => !used.has(n)).map((n) => fs.rm(path.join(dir, n), { force: true }).catch(() => { })));
115
+ }
@@ -3,9 +3,12 @@ import fsSync from 'fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import { ensureSecureDir, writeSecureFile } from './secure-file.js';
6
+ import { externalizeImages, imagesDirFor, internalizeImages, pruneImages } from './session-media.js';
6
7
  const SESSIONS_DIR = path.join(os.homedir(), '.devx', 'sessions');
7
8
  export class SessionManager {
8
9
  currentSession;
10
+ /** The files of the pictures already seen (see session-media.ts). */
11
+ media = new Map();
9
12
  constructor(model, planMode) {
10
13
  this.currentSession = this.createEmptySession(model, planMode);
11
14
  }
@@ -36,6 +39,7 @@ export class SessionManager {
36
39
  }
37
40
  startNewSession(model, planMode) {
38
41
  this.currentSession = this.createEmptySession(model, planMode);
42
+ this.media = new Map();
39
43
  return this.currentSession;
40
44
  }
41
45
  async save(messages, totalCost, model, planMode, costEstimated) {
@@ -61,8 +65,12 @@ export class SessionManager {
61
65
  this.currentSession.title = title || 'Session';
62
66
  }
63
67
  }
68
+ // Pictures go into their own files; the chat file keeps only their names.
69
+ const imagesDir = imagesDirFor(SESSIONS_DIR, this.currentSession.id);
70
+ const stored = await externalizeImages(messages, imagesDir, this.media);
64
71
  const filePath = path.join(SESSIONS_DIR, `${this.currentSession.id}.json`);
65
- await writeSecureFile(filePath, JSON.stringify(this.currentSession, null, 2));
72
+ await writeSecureFile(filePath, JSON.stringify({ ...this.currentSession, messages: stored.messages }, null, 2));
73
+ await pruneImages(imagesDir, stored.used);
66
74
  }
67
75
  static async listSessions() {
68
76
  if (!fsSync.existsSync(SESSIONS_DIR)) {
@@ -95,7 +103,9 @@ export class SessionManager {
95
103
  return null;
96
104
  try {
97
105
  const content = await fs.readFile(filePath, 'utf8');
98
- return JSON.parse(content);
106
+ const session = JSON.parse(content);
107
+ session.messages = await internalizeImages(session.messages ?? [], imagesDirFor(SESSIONS_DIR, sessionId));
108
+ return session;
99
109
  }
100
110
  catch {
101
111
  return null;
@@ -104,6 +114,7 @@ export class SessionManager {
104
114
  static async deleteSession(sessionId) {
105
115
  const filePath = path.join(SESSIONS_DIR, `${sessionId}.json`);
106
116
  try {
117
+ await fs.rm(imagesDirFor(SESSIONS_DIR, sessionId), { recursive: true, force: true }).catch(() => { });
107
118
  if (fsSync.existsSync(filePath)) {
108
119
  await fs.unlink(filePath);
109
120
  return true;
@@ -128,6 +139,10 @@ export class SessionManager {
128
139
  }
129
140
  catch { }
130
141
  }
142
+ // The pictures of the chats, and those of chats whose file is already gone.
143
+ for (const name of files.filter(f => f.endsWith('.images'))) {
144
+ await fs.rm(path.join(SESSIONS_DIR, name), { recursive: true, force: true }).catch(() => { });
145
+ }
131
146
  return count;
132
147
  }
133
148
  catch {
@@ -31,9 +31,15 @@ export function cleanTitle(raw) {
31
31
  export function provisionalTitle(firstUserMessage) {
32
32
  return cleanTitle(firstUserMessage.replace(/\s+/g, ' ').slice(0, 80));
33
33
  }
34
- /** Asks the model for the name. Never throws: on a failure or a timeout the answer is ''. */
34
+ /**
35
+ * Asks the model for the name. Never throws: on a failure or a timeout the answer is ''. When the
36
+ * time is up the request is cut off, so a slow thinking model does not go on (and bill) for a
37
+ * title nobody waits for.
38
+ */
35
39
  export async function generateChatTitle(provider, firstUser, firstReply, timeoutMs = 20000) {
36
40
  const sample = `User: ${firstUser.slice(0, 600)}\n\nAssistant: ${firstReply.slice(0, 600)}`;
41
+ const controller = new AbortController();
42
+ let timer;
37
43
  try {
38
44
  const answer = await Promise.race([
39
45
  provider.chat({
@@ -41,14 +47,25 @@ export async function generateChatTitle(provider, firstUser, firstReply, timeout
41
47
  { role: 'system', content: TITLE_SYSTEM_PROMPT },
42
48
  { role: 'user', content: sample },
43
49
  ],
50
+ signal: controller.signal,
51
+ }),
52
+ // Also for a provider that ignores the signal: the title is given up on when the time is up.
53
+ new Promise((_, reject) => {
54
+ timer = setTimeout(() => {
55
+ controller.abort();
56
+ reject(new Error('timeout'));
57
+ }, timeoutMs);
58
+ timer.unref?.();
44
59
  }),
45
- new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs).unref?.()),
46
60
  ]);
47
61
  return cleanTitle(answer.content);
48
62
  }
49
63
  catch {
50
64
  return '';
51
65
  }
66
+ finally {
67
+ clearTimeout(timer);
68
+ }
52
69
  }
53
70
  // ───────────────────────── terminal window title ─────────────────────────
54
71
  /** The escape sequence that sets the title of the terminal window (OSC 0): `◈ Short title`. */
@@ -1 +1 @@
1
- export const DEVX_VERSION = '1.8.1';
1
+ export const DEVX_VERSION = '1.9.0';
@@ -14,6 +14,11 @@ export class UsageTracker {
14
14
  ledger = new CostLedger();
15
15
  cacheReadTokens = 0;
16
16
  cacheWriteTokens = 0;
17
+ uncachedTokens = 0;
18
+ parts = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
19
+ /** Priced requests that came without a split. */
20
+ unsplit = 0;
21
+ restored = 0;
17
22
  lastPromptTokens = 0;
18
23
  dataSaverLimitMB;
19
24
  warnedThreshold = false;
@@ -37,9 +42,22 @@ export class UsageTracker {
37
42
  this.ledger.reset();
38
43
  this.cacheReadTokens = 0;
39
44
  this.cacheWriteTokens = 0;
45
+ this.uncachedTokens = 0;
46
+ this.parts = { input: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
47
+ this.unsplit = 0;
48
+ this.restored = 0;
40
49
  this.lastPromptTokens = 0;
41
50
  this.warnedThreshold = false;
42
51
  }
52
+ /**
53
+ * A saved chat was resumed: what it cost so far is the starting point of the total, so that
54
+ * /usage and the prompt frame show the same number. Counting starts from zero before this.
55
+ */
56
+ restoreCost(total, estimated = false) {
57
+ this.reset();
58
+ this.ledger.restore(total, estimated);
59
+ this.restored = Number.isFinite(total) && total > 0 ? total : 0;
60
+ }
43
61
  recordRequest(payloadBytes) {
44
62
  this.bytesSent += payloadBytes;
45
63
  this.requestsCount += 1;
@@ -48,15 +66,29 @@ export class UsageTracker {
48
66
  this.bytesReceived += chunkBytes;
49
67
  }
50
68
  /** Records the tokens of one request; an undefined cost means the price is not known. */
51
- recordTokens(promptTokens, completionTokens, cost, costSource) {
69
+ recordTokens(promptTokens, completionTokens, cost, costSource, parts) {
52
70
  this.promptTokens += promptTokens;
53
71
  this.completionTokens += completionTokens;
54
72
  this.ledger.add({ cost, costSource });
73
+ if (typeof cost === 'number' && Number.isFinite(cost)) {
74
+ if (parts) {
75
+ this.parts.input += parts.input;
76
+ this.parts.cacheRead += parts.cacheRead;
77
+ this.parts.cacheWrite += parts.cacheWrite;
78
+ this.parts.output += parts.output;
79
+ }
80
+ else {
81
+ this.unsplit++;
82
+ }
83
+ }
55
84
  }
56
85
  recordUsage(usage) {
57
- this.recordTokens(usage.promptTokens, usage.completionTokens, usage.cost, usage.costSource);
58
- this.cacheReadTokens += usage.cacheReadTokens || 0;
59
- this.cacheWriteTokens += usage.cacheWriteTokens || 0;
86
+ this.recordTokens(usage.promptTokens, usage.completionTokens, usage.cost, usage.costSource, usage.costParts);
87
+ const read = Math.min(usage.promptTokens, usage.cacheReadTokens || 0);
88
+ const written = Math.min(usage.promptTokens - read, usage.cacheWriteTokens || 0);
89
+ this.cacheReadTokens += read;
90
+ this.cacheWriteTokens += written;
91
+ this.uncachedTokens += Math.max(0, usage.promptTokens - read - written);
60
92
  if (!usage.tokensEstimated)
61
93
  this.lastPromptTokens = usage.promptTokens;
62
94
  }
@@ -72,6 +104,11 @@ export class UsageTracker {
72
104
  totalCost: this.ledger.total,
73
105
  costState: this.ledger.state(),
74
106
  cacheReadTokens: this.cacheReadTokens,
107
+ cacheWriteTokens: this.cacheWriteTokens,
108
+ uncachedTokens: this.uncachedTokens,
109
+ // The split covers only what was counted since launch: with a restored total it would not add up.
110
+ costParts: this.unsplit === 0 && this.restored === 0 && this.ledger.priced > 0 ? { ...this.parts } : undefined,
111
+ restoredCost: this.restored > 0 ? this.restored : undefined,
75
112
  lastPromptTokens: this.lastPromptTokens,
76
113
  dataSaverLimitMB: this.dataSaverLimitMB
77
114
  };
@@ -277,7 +277,7 @@ function hasComplexChainingOrRedirection(commandStr) {
277
277
  commandStr.includes('\r'));
278
278
  }
279
279
  // ───────────────────────── guard ─────────────────────────
280
- const SHELL_TOOLS = new Set(['bash', 'exec', 'run_command', 'execute_command']);
280
+ const SHELL_TOOLS = new Set(['bash', 'background', 'exec', 'run_command', 'execute_command']);
281
281
  const READ_TOOLS = new Set(['read_file', 'view_file', 'read', 'list_dir', 'list_files', 'ls', 'search', 'grep_search', 'grep', 'diagnose_code']);
282
282
  const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'make_dir', 'mkdir', 'create_file', 'write_to_file', 'patch']);
283
283
  function pathArg(args) {
@@ -328,6 +328,9 @@ export class CLIConsoleGuard {
328
328
  const t = (toolName || '').toLowerCase();
329
329
  const cmd = (args?.command || args?.cmd || '').trim();
330
330
  const target = pathArg(args);
331
+ // Extra: the agent does anything it likes, nothing is asked.
332
+ if (this.options.extra && this.autoApprove)
333
+ return false;
331
334
  // Critical safety net. These ask for confirmation even in YOLO mode.
332
335
  if (SHELL_TOOLS.has(t) && (isDangerousBashCommand(cmd) || shellTouchesSecrets(cmd)))
333
336
  return true;
@@ -409,6 +412,8 @@ export function buildPermissionRequest(toolName, args) {
409
412
  notes.push('looks destructive or runs downloaded code');
410
413
  else if (shellTouchesSecrets(cmd))
411
414
  notes.push('reads a credentials / secrets file: its content would reach the model');
415
+ if (t === 'background')
416
+ notes.push('keeps running in the background until it ends or you stop it');
412
417
  return { title: t === 'bash' ? 'bash' : toolName, danger, lines: cmd.split('\n'), notes };
413
418
  }
414
419
  if (t === 'mcp_manage') {
@@ -34,7 +34,8 @@ export async function buildSystemPrompt(planMode) {
34
34
  prompt += `- Before using tools on a task, say in one short line what you are about to do. Do not narrate every step.\n`;
35
35
  prompt += `- Do the whole task step by step. For a task with several steps, keep the checklist with 'todo_list'.\n`;
36
36
  prompt += `- Read a file before you edit it. Change only what the task needs, with 'edit_file' for small changes. Do not rewrite a whole file or touch unrelated code.\n`;
37
- prompt += `- 'bash' stops after 120 s and cuts long output: give long jobs a timeout or run them in the background, and print only what you need (head, tail, grep).\n`;
37
+ prompt += `- 'bash' stops after 120 s and cuts long output: give a long job a timeout, and print only what you need (head, tail, grep).\n`;
38
+ prompt += `- Anything that keeps running (dev server, watcher, long test run or build) goes to the 'background' tool, not to 'bash': action 'start' returns at once and the work goes on while you continue. Set notify_on to a regex ('ready|listening|error|FAIL') to be told when such a line appears; you are always told when a task ends. Then do other work or finish your answer: do not poll. To wait for a line use action 'wait' with a pattern, to read output use 'output', to end it use 'stop'. The user sees the tasks under /tasks. Never start the same server twice: check 'list' first.\n`;
38
39
  prompt += `- After you change code, run 'diagnose_code' and fix what it reports. Install missing dependencies with 'install_package'.\n`;
39
40
  prompt += `- After you create or change a web page or frontend app, start the local preview with 'serve_preview' (action 'start').\n`;
40
41
  if (MemoryManager.enabled)
@@ -1,7 +1,7 @@
1
1
  import { UsageTracker } from '../core/usage.js';
2
2
  import { priceUsage } from '../core/prices.js';
3
3
  import { ToolGuard } from './toolguard.js';
4
- import { NOT_ABOUT_REASONING, PARAMETER_REJECTED, errorMessageOf, imageNotSentText, parseToolArguments, readSseData, toolTargetHint } from './shared.js';
4
+ import { DEFAULT_REQUEST_TIMEOUT_MS, NOT_ABOUT_REASONING, RequestWatchdog, PARAMETER_REJECTED, errorMessageOf, imageNotSentText, imagesToSend, parseToolArguments, readSseData, toolTargetHint } from './shared.js';
5
5
  /**
6
6
  * Speaks the native Anthropic Messages API: `POST {origin}/v1/messages`.
7
7
  *
@@ -30,11 +30,11 @@ function splitDataUrl(dataUrl) {
30
30
  export function toAnthropicMessages(messages, options = {}) {
31
31
  const systemParts = [];
32
32
  const out = [];
33
- let lastImageIdx = -1;
34
- messages.forEach((m, i) => {
35
- if (m.images && m.images.length > 0)
36
- lastImageIdx = i;
37
- });
33
+ const send = imagesToSend(messages);
34
+ const imageBlock = (img) => {
35
+ const parts = splitDataUrl(img.dataUrl);
36
+ return parts ? { type: 'image', source: { type: 'base64', media_type: parts.mediaType, data: parts.data } } : null;
37
+ };
38
38
  const push = (role, blocks) => {
39
39
  if (blocks.length === 0)
40
40
  return;
@@ -52,7 +52,21 @@ export function toAnthropicMessages(messages, options = {}) {
52
52
  return;
53
53
  }
54
54
  if (m.role === 'tool') {
55
- push('user', [{ type: 'tool_result', tool_use_id: m.tool_call_id, content: m.content || '(empty)' }]);
55
+ const text = m.content || '(empty)';
56
+ let content = text;
57
+ if (m.images && m.images.length > 0) {
58
+ if (options.images === false) {
59
+ content = `${text}\n${imageNotSentText(m.images.map((img) => img.path))}`;
60
+ }
61
+ else if (send.has(idx)) {
62
+ // This API takes pictures inside a tool result.
63
+ content = [{ type: 'text', text }, ...m.images.map(imageBlock).filter((b) => !!b)];
64
+ }
65
+ else {
66
+ content = `${text}\n${m.images.map((img) => `[Attached Image: ${img.path}]`).join(' ')}`;
67
+ }
68
+ }
69
+ push('user', [{ type: 'tool_result', tool_use_id: m.tool_call_id, content }]);
56
70
  return;
57
71
  }
58
72
  if (m.role === 'assistant') {
@@ -76,11 +90,11 @@ export function toAnthropicMessages(messages, options = {}) {
76
90
  const note = imageNotSentText(m.images.map((img) => img.path));
77
91
  text = text ? `${text}\n${note}` : note;
78
92
  }
79
- else if (idx === lastImageIdx) {
93
+ else if (send.has(idx)) {
80
94
  for (const img of m.images) {
81
- const parts = splitDataUrl(img.dataUrl);
82
- if (parts)
83
- blocks.push({ type: 'image', source: { type: 'base64', media_type: parts.mediaType, data: parts.data } });
95
+ const block = imageBlock(img);
96
+ if (block)
97
+ blocks.push(block);
84
98
  }
85
99
  }
86
100
  else {
@@ -287,6 +301,10 @@ export class AnthropicProvider {
287
301
  err.status = res.status;
288
302
  throw err;
289
303
  }
304
+ lostRequestUsage(promptTokens, completionTokens) {
305
+ // usageOf records the usage itself.
306
+ return this.usageOf(null, { prompt: promptTokens, completion: completionTokens });
307
+ }
290
308
  usageOf(raw, fallback) {
291
309
  const guessed = !raw || (raw.input + raw.cacheRead + raw.cacheWrite + raw.output === 0);
292
310
  const base = raw ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
@@ -304,11 +322,44 @@ export class AnthropicProvider {
304
322
  if (priced.cost !== undefined) {
305
323
  usage.cost = priced.cost;
306
324
  usage.costSource = guessed && fallback && priced.costSource !== 'free' ? 'estimated' : priced.costSource;
325
+ if (priced.parts)
326
+ usage.costParts = priced.parts;
307
327
  }
308
328
  UsageTracker.getInstance().recordUsage(usage);
309
329
  return usage;
310
330
  }
331
+ get timeoutMs() {
332
+ return this.options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
333
+ }
311
334
  async chat(request) {
335
+ const watch = new RequestWatchdog(this.timeoutMs, request.signal);
336
+ try {
337
+ return await this.chatOnce({ ...request, signal: watch.signal });
338
+ }
339
+ catch (err) {
340
+ throw watch.timedOut ? watch.error() : err;
341
+ }
342
+ finally {
343
+ watch.stop();
344
+ }
345
+ }
346
+ async *chatStream(request) {
347
+ const watch = new RequestWatchdog(this.timeoutMs, request.signal);
348
+ try {
349
+ const response = yield* this.streamOnce({ ...request, signal: watch.signal }, watch);
350
+ // The time ran out and the stream was cut: what came so far is not an answer.
351
+ if (watch.timedOut)
352
+ throw watch.error();
353
+ return response;
354
+ }
355
+ catch (err) {
356
+ throw watch.timedOut ? watch.error() : err;
357
+ }
358
+ finally {
359
+ watch.stop();
360
+ }
361
+ }
362
+ async chatOnce(request) {
312
363
  const res = await this.send(request, false);
313
364
  if (!res.ok)
314
365
  await this.failure(res);
@@ -363,7 +414,7 @@ export class AnthropicProvider {
363
414
  usage,
364
415
  };
365
416
  }
366
- async *chatStream(request) {
417
+ async *streamOnce(request, watch) {
367
418
  const res = await this.send(request, true);
368
419
  if (!res.ok)
369
420
  await this.failure(res);
@@ -402,7 +453,7 @@ export class AnthropicProvider {
402
453
  tally.cacheWrite = num(u.cache_creation_input_tokens);
403
454
  };
404
455
  try {
405
- for await (const payload of readSseData(reader, request.signal, (n) => UsageTracker.getInstance().recordResponseChunk(n))) {
456
+ for await (const payload of readSseData(reader, request.signal, (n) => { watch.touch(); UsageTracker.getInstance().recordResponseChunk(n); })) {
406
457
  if (payload === '[DONE]')
407
458
  continue;
408
459
  let event;
@@ -473,6 +524,9 @@ export class AnthropicProvider {
473
524
  }
474
525
  catch { }
475
526
  }
527
+ // Cut off because it went silent: nothing here is an answer, and it must not be counted as one.
528
+ if (watch.timedOut)
529
+ throw watch.error();
476
530
  const toolCalls = [];
477
531
  const thinkingBlocks = [];
478
532
  for (const index of [...blocks.keys()].sort((a, b) => a - b)) {