termux-dev 1.8.2 → 1.9.1

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 (51) hide show
  1. package/README.md +16 -6
  2. package/assets/banner.svg +1 -1
  3. package/assets/preview.svg +1 -1
  4. package/dist/cli/background-ui.js +8 -8
  5. package/dist/cli/files.js +2 -12
  6. package/dist/cli/finder.js +10 -2
  7. package/dist/cli/headless.js +3 -2
  8. package/dist/cli/index.js +131 -75
  9. package/dist/cli/keys.js +12 -3
  10. package/dist/cli/liveblock.js +47 -9
  11. package/dist/cli/mcp-panel.js +2 -0
  12. package/dist/cli/picker.js +2 -0
  13. package/dist/cli/prompt.js +54 -7
  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 +33 -13
  19. package/dist/cli/update-screen.js +383 -0
  20. package/dist/cli/updater.js +119 -145
  21. package/dist/cli/usage-panel.js +17 -4
  22. package/dist/cli/width.js +18 -0
  23. package/dist/core/background.js +14 -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 +69 -7
  30. package/dist/core/locales/en.js +69 -7
  31. package/dist/core/locales/es.js +69 -7
  32. package/dist/core/locales/fr.js +69 -7
  33. package/dist/core/locales/ja.js +69 -7
  34. package/dist/core/locales/pt.js +69 -7
  35. package/dist/core/locales/ru.js +69 -7
  36. package/dist/core/locales/uk.js +69 -7
  37. package/dist/core/locales/zh.js +69 -7
  38. package/dist/core/loop.js +32 -1
  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 +3 -0
  46. package/dist/providers/anthropic.js +67 -13
  47. package/dist/providers/index.js +16 -3
  48. package/dist/providers/openai.js +99 -41
  49. package/dist/providers/shared.js +83 -0
  50. package/dist/tools/fs.js +32 -2
  51. package/package.json +1 -1
@@ -20,25 +20,23 @@ export const zh = {
20
20
  'conn.classicRestored': '✓ 连接已恢复!继续生成...',
21
21
  'stop.connection': '连接已取消',
22
22
  'stop.stopped': '已停止',
23
- 'stop.interrupted': '已中断,正在发送你的消息',
24
23
  'stop.classicConnection': '连接已取消 (Ctrl+C)。',
25
24
  'stop.classicStopped': '生成已停止 (Ctrl+C)。',
26
- 'stop.classicInterrupted': '已中断:正在发送你的消息。',
27
25
  'status.waiting': '等待模型',
28
26
  'status.thinking': '思考中',
29
27
  'status.writing': '撰写中',
30
28
  'status.running': '运行中',
31
29
  'status.reconnecting': '重连中',
32
- 'status.stop': 'ctrl+c 停止',
30
+ 'status.stop': 'esc 停止',
33
31
  'status.thinkingLine': '思考',
34
32
  'status.thoughtFor': '思考了 {sec} 秒',
35
33
  'composer.placeholder': '描述任务或提出问题…',
36
34
  'composer.queued': '已排队',
37
35
  'composer.queuedMany': '{n} 条已排队',
38
- 'composer.queuedHint': 'ctrl+enter 立即发送 · ↓ 编辑',
39
- 'composer.hint1': 'enter 排队 · ctrl+c 停止',
36
+ 'composer.queuedHint': '↓ 编辑',
37
+ 'composer.hint1': 'enter 排队 · esc 停止',
40
38
  'composer.hint2': 'enter 排队',
41
- 'composer.hint3': 'enter 排队 · ctrl+c 停止',
39
+ 'composer.hint3': 'enter 排队 · esc 停止',
42
40
  'composer.hint4': 'enter 排队',
43
41
  'summary.toolOne': '{n} 个工具',
44
42
  'summary.toolMany': '{n} 个工具',
@@ -54,6 +52,7 @@ export const zh = {
54
52
  'settings.row.layout': '界面',
55
53
  'settings.row.pureBlack': '黑色背景',
56
54
  'settings.row.autoApprove': '自动批准 (YOLO)',
55
+ 'settings.row.extra': 'Extra(不再询问)',
57
56
  'settings.row.maxIterations': '最大步数',
58
57
  'settings.row.memory': '项目记忆',
59
58
  'settings.row.updates': '检查更新',
@@ -63,6 +62,7 @@ export const zh = {
63
62
  'settings.short.layout': '界面',
64
63
  'settings.short.pureBlack': '黑背景',
65
64
  'settings.short.autoApprove': 'YOLO',
65
+ 'settings.short.extra': 'Extra',
66
66
  'settings.short.maxIterations': '步数',
67
67
  'settings.short.memory': '记忆',
68
68
  'settings.short.updates': '更新',
@@ -78,12 +78,15 @@ export const zh = {
78
78
  'settings.hint.layout': '简洁:带边框的输入框、卡片和安静的工具行。经典:原来的 ASCII 布局。',
79
79
  'settings.hint.pureBlack': '将终端背景设为深邃的 OLED 黑 (#0a0a0c)。关闭则保留你自己的背景。',
80
80
  'settings.hint.autoApprove': '跳过常规的权限确认。危险命令和凭据文件仍会先询问。仅在本次会话有效:下次启动 devx 时会重新关闭。',
81
+ 'settings.hint.extra': '不经询问执行任何命令:包括破坏性命令(rm -rf、sudo、git push --force)、下载的脚本、读取密钥和机密,以及新的 MCP 服务器。这里没有沙箱。仅在本次会话有效。',
81
82
  'settings.hint.maxIterations': '智能体处理一次请求最多可执行多少个工具步骤(编辑文件、运行命令)。',
82
83
  'settings.hint.memory': '每次会话都会加载 .devx/memory.md 中的规则。',
83
84
  'settings.hint.updates': '启动时在 GitHub 上查找 devx 新版本。无论开关,/update 都可用。',
84
85
  'settings.hint.language': '界面语言:消息、设置和提示。代码和工具名称保持英文。',
85
86
  'settings.hint.about': 'ApvCode 出品的终端原生 AI 编程智能体。MIT 许可证。github.com/apvcode/Termux-Dev',
86
87
  'settings.warnYolo': '已开启:智能体将不经询问直接运行命令并编辑文件。',
88
+ 'settings.warnExtra': '警告:不再询问任何事,仅在可承受全部丢失的环境中使用。',
89
+ 'settings.extraEnabled': 'Extra 已开启:智能体将不经询问执行任何命令,包括危险命令。下次启动 devx 时会再次关闭。',
87
90
  'settings.keys.move': '移动',
88
91
  'settings.keys.change': '更改',
89
92
  'settings.keys.toggle': '切换',
@@ -219,9 +222,68 @@ export const zh = {
219
222
  'onboard.language.note': '之后可以在 /settings 中更改。',
220
223
  'onboard.provider.title': '服务商',
221
224
  'onboard.provider.placeholder': '输入以查找服务商',
222
- 'onboard.provider.note': '提供模型的服务。没有找到?请选择自定义地址。',
225
+ 'onboard.provider.note': '为你提供模型的服务。不在列表里?用“添加提供商”添加你自己的。',
223
226
  'onboard.custom': '自定义地址',
224
227
  'onboard.customDetail': '任何兼容 OpenAI 的地址',
228
+ 'update.title': '更新',
229
+ 'update.titleBusy': '正在更新',
230
+ 'update.titleDone': '已更新',
231
+ 'update.titleFailed': '更新失败',
232
+ 'update.checking': '正在检查更新',
233
+ 'update.upToDate': 'devx 已是最新版本({version})',
234
+ 'update.offline': '无法检查更新({reason})',
235
+ 'update.available': 'devx {version} 已发布',
236
+ 'update.have': '你当前是 {version}',
237
+ 'update.released': '发布于 {date}',
238
+ 'update.whatsNew': '新内容',
239
+ 'update.viaNpm': '通过 npm 安装:更新会执行 npm install -g termux-dev。',
240
+ 'update.viaGit': '从 git 克隆安装:更新会拉取、构建并链接。',
241
+ 'update.now': '立即更新',
242
+ 'update.nowDetail': '安装 {version} 并重启 devx',
243
+ 'update.later': '稍后',
244
+ 'update.laterDetail': '暂时保留此版本',
245
+ 'update.skip': '跳过此版本',
246
+ 'update.skipDetail': '不再询问 {version}',
247
+ 'update.step.registry': '正在检查注册表',
248
+ 'update.step.install': '正在安装 devx {version}',
249
+ 'update.step.verify': '正在检查新版本',
250
+ 'update.step.pull': '正在从 GitHub 拉取',
251
+ 'update.step.build': '正在安装并构建',
252
+ 'update.step.link': '正在链接命令',
253
+ 'update.keepOpen': '请保持 devx 打开,这需要一点时间。',
254
+ 'update.installed': 'devx {version} 已安装',
255
+ 'update.restarting': '正在重启 devx…',
256
+ 'update.failedStep': '停在:{step}',
257
+ 'update.keepsRunning': 'devx 继续运行在 {version}。',
258
+ 'update.byHand': '手动更新:',
259
+ 'update.notPublished': 'npm 上还没有 {version},请几分钟后再试。',
260
+ 'update.skipped': '明白:不再询问 {version}。',
261
+ 'provider.add': '添加提供商',
262
+ 'provider.addDetail': '任何兼容 OpenAI 或 Anthropic 的地址',
263
+ 'provider.customTag': '自定义',
264
+ 'provider.new': '新提供商',
265
+ 'provider.name.label': '提供商名称',
266
+ 'provider.name.placeholder': '我的网关',
267
+ 'provider.name.empty': '请输入名称。',
268
+ 'provider.name.long': '名称太长(最多 40 个字符)。',
269
+ 'provider.name.taken': '已有同名的提供商。',
270
+ 'provider.url.note': '到 /chat/completions 或 /messages 之前的地址,例如 https://api.example.com/v1。',
271
+ 'provider.url.invalid': '这不是有效的地址。示例:https://api.example.com/v1',
272
+ 'provider.url.scheme': '地址必须以 http:// 或 https:// 开头。',
273
+ 'provider.key.none': '不需要密钥',
274
+ 'provider.key.noneDetail': '本机或本网络上的服务器',
275
+ 'provider.key.enter': '输入密钥',
276
+ 'provider.menu.use': '使用此提供商',
277
+ 'provider.menu.rename': '修改名称',
278
+ 'provider.menu.url': '修改地址',
279
+ 'provider.menu.key': '修改密钥',
280
+ 'provider.menu.delete': '删除',
281
+ 'provider.menu.deleteDetail': '连同密钥一起移除',
282
+ 'provider.delete.question': '删除提供商 {name} 及其密钥?',
283
+ 'provider.delete.active': '这是正在使用的提供商:在你选择其他提供商之前它仍可继续使用。',
284
+ 'provider.saved': '提供商 {name} 已保存',
285
+ 'provider.updated': '提供商 {name} 已更新',
286
+ 'provider.deleted': '提供商 {name} 已删除',
225
287
  'onboard.baseUrl.title': '地址',
226
288
  'onboard.baseUrl.label': 'API 的基础 URL',
227
289
  'onboard.baseUrl.placeholder': 'https://api.example.com/v1',
package/dist/core/loop.js CHANGED
@@ -61,6 +61,15 @@ export function getActionDescription(toolName, args) {
61
61
  return `→ Executing ${toolName}...`;
62
62
  }
63
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
+ }
64
73
  function isContextLengthError(err) {
65
74
  const status = err?.status;
66
75
  if (status !== undefined && status !== 400 && status !== 413 && status !== 422)
@@ -120,21 +129,32 @@ export class Agent {
120
129
  let attempt = 0;
121
130
  let contextRetries = 0;
122
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));
123
137
  while (true) {
124
138
  if (signal?.aborted)
125
139
  return;
140
+ receivedChars = 0;
126
141
  try {
127
142
  if (this.provider.chatStream) {
128
143
  let receivedAnyChunk = false;
129
144
  for await (const chunk of this.provider.chatStream(request)) {
130
145
  if (signal?.aborted) {
146
+ const lost = lostUsage();
147
+ if (lost)
148
+ yield { type: 'usage', usage: lost };
131
149
  return;
132
150
  }
133
151
  receivedAnyChunk = true;
134
152
  if (chunk.type === 'reasoning_delta') {
153
+ receivedChars += chunk.delta.length;
135
154
  yield { type: 'reasoning_delta', delta: chunk.delta };
136
155
  }
137
156
  else if (chunk.type === 'content_delta') {
157
+ receivedChars += chunk.delta.length;
138
158
  yield { type: 'text_delta', delta: chunk.delta };
139
159
  }
140
160
  else if (chunk.type === 'tool_generating') {
@@ -159,6 +179,9 @@ export class Agent {
159
179
  }
160
180
  catch (err) {
161
181
  if (signal?.aborted || err.name === 'AbortError' || err.message?.includes('aborted')) {
182
+ const lost = lostUsage();
183
+ if (lost)
184
+ yield { type: 'usage', usage: lost };
162
185
  return;
163
186
  }
164
187
  // The provider says the prompt is too long (our estimate was too optimistic):
@@ -172,6 +195,11 @@ export class Agent {
172
195
  continue;
173
196
  }
174
197
  const isNetwork = isNetworkOrRetryableError(err);
198
+ if (isNetwork && probablyBilled(err)) {
199
+ const lost = lostUsage();
200
+ if (lost)
201
+ yield { type: 'usage', usage: lost };
202
+ }
175
203
  if (isNetwork && attempt < maxRetries) {
176
204
  attempt++;
177
205
  const delayMs = Math.min(1500 * Math.pow(1.5, attempt - 1), 8000);
@@ -240,6 +268,8 @@ export class Agent {
240
268
  if (signal?.aborted)
241
269
  return;
242
270
  let result = '';
271
+ // Pictures a tool wants the model to see (read_file on an image) travel with its result.
272
+ const pictures = [];
243
273
  const tool = this.tools.get(call.name);
244
274
  if (!tool) {
245
275
  result = `Error: Tool '${call.name}' not found.`;
@@ -263,7 +293,7 @@ export class Agent {
263
293
  if (signal?.aborted)
264
294
  return;
265
295
  result = allowed
266
- ? await tool.execute(call.arguments, this.config, { signal })
296
+ ? await tool.execute(call.arguments, this.config, { signal, attachImage: (image) => pictures.push(image) })
267
297
  : 'Action denied by user.';
268
298
  if (allowed) {
269
299
  const post = await runHooks(this.config.hooks, 'PostToolUse', tool.name, call.arguments, result);
@@ -286,6 +316,7 @@ export class Agent {
286
316
  content: result,
287
317
  tool_call_id: call.id,
288
318
  name: call.name,
319
+ ...(pictures.length > 0 ? { images: pictures } : {}),
289
320
  });
290
321
  executedToolCallIds.add(call.id);
291
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.2';
1
+ export const DEVX_VERSION = '1.9.1';
@@ -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
  };
@@ -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;