@milpa/comal 0.1.0 → 0.1.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 (185) hide show
  1. package/README.md +30 -28
  2. package/backend/contracts/remote-commands-v1.json +125 -0
  3. package/bin/comal.mjs +16 -5
  4. package/docs/investigation.md +2 -2
  5. package/governance/chrome-task-v2.json +67 -0
  6. package/node_modules/@jitl/quickjs-ffi-types/LICENSE +21 -0
  7. package/node_modules/@jitl/quickjs-ffi-types/README.md +5 -0
  8. package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.mts +549 -0
  9. package/node_modules/@jitl/quickjs-ffi-types/dist/index.d.ts +549 -0
  10. package/node_modules/@jitl/quickjs-ffi-types/dist/index.js +2 -0
  11. package/node_modules/@jitl/quickjs-ffi-types/dist/index.js.map +1 -0
  12. package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs +2 -0
  13. package/node_modules/@jitl/quickjs-ffi-types/dist/index.mjs.map +1 -0
  14. package/node_modules/@jitl/quickjs-ffi-types/package.json +36 -0
  15. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/LICENSE +47 -0
  16. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/README.md +93 -0
  17. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.d.ts +15 -0
  18. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.browser.mjs +2 -0
  19. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cjs +2 -0
  20. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.cjs +2 -0
  21. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
  22. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.d.ts +15 -0
  23. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.mjs +2 -0
  24. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm +0 -0
  25. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/emscripten-module.wasm.map +1 -0
  26. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.mts +94 -0
  27. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.d.ts +94 -0
  28. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js +2 -0
  29. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.js.map +1 -0
  30. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs +2 -0
  31. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/ffi.mjs.map +1 -0
  32. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.mts +20 -0
  33. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.d.ts +20 -0
  34. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js +2 -0
  35. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.js.map +1 -0
  36. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs +2 -0
  37. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/dist/index.mjs.map +1 -0
  38. package/node_modules/@jitl/quickjs-wasmfile-debug-asyncify/package.json +61 -0
  39. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/LICENSE +47 -0
  40. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/README.md +88 -0
  41. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.d.ts +11 -0
  42. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.browser.mjs +5430 -0
  43. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cjs +5485 -0
  44. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.cjs +5424 -0
  45. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
  46. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.d.ts +11 -0
  47. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.mjs +5541 -0
  48. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm +0 -0
  49. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/emscripten-module.wasm.map +1 -0
  50. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.mts +85 -0
  51. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.d.ts +85 -0
  52. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js +2 -0
  53. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.js.map +1 -0
  54. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs +2 -0
  55. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/ffi.mjs.map +1 -0
  56. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.mts +20 -0
  57. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.d.ts +20 -0
  58. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js +2 -0
  59. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.js.map +1 -0
  60. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs +2 -0
  61. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/dist/index.mjs.map +1 -0
  62. package/node_modules/@jitl/quickjs-wasmfile-debug-sync/package.json +61 -0
  63. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/LICENSE +47 -0
  64. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/README.md +89 -0
  65. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.d.ts +15 -0
  66. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.browser.mjs +26 -0
  67. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cjs +25 -0
  68. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.cjs +25 -0
  69. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.cloudflare.d.ts +15 -0
  70. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.d.ts +15 -0
  71. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.mjs +30 -0
  72. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.wasm +0 -0
  73. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.mts +94 -0
  74. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.d.ts +94 -0
  75. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js +2 -0
  76. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.js.map +1 -0
  77. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs +2 -0
  78. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/ffi.mjs.map +1 -0
  79. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.mts +20 -0
  80. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.d.ts +20 -0
  81. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js +2 -0
  82. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.js.map +1 -0
  83. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs +2 -0
  84. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/dist/index.mjs.map +1 -0
  85. package/node_modules/@jitl/quickjs-wasmfile-release-asyncify/package.json +61 -0
  86. package/node_modules/@jitl/quickjs-wasmfile-release-sync/LICENSE +47 -0
  87. package/node_modules/@jitl/quickjs-wasmfile-release-sync/README.md +82 -0
  88. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.d.ts +11 -0
  89. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.browser.mjs +22 -0
  90. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cjs +21 -0
  91. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.cjs +21 -0
  92. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.cloudflare.d.ts +11 -0
  93. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.d.ts +11 -0
  94. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.mjs +25 -0
  95. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/emscripten-module.wasm +0 -0
  96. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.mts +85 -0
  97. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.d.ts +85 -0
  98. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js +2 -0
  99. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.js.map +1 -0
  100. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs +2 -0
  101. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/ffi.mjs.map +1 -0
  102. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.mts +20 -0
  103. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.d.ts +20 -0
  104. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js +2 -0
  105. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.js.map +1 -0
  106. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs +2 -0
  107. package/node_modules/@jitl/quickjs-wasmfile-release-sync/dist/index.mjs.map +1 -0
  108. package/node_modules/@jitl/quickjs-wasmfile-release-sync/package.json +61 -0
  109. package/node_modules/quickjs-emscripten/LICENSE +21 -0
  110. package/node_modules/quickjs-emscripten/README.md +928 -0
  111. package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs +2 -0
  112. package/node_modules/quickjs-emscripten/dist/chunk-OHAYRCBA.mjs.map +1 -0
  113. package/node_modules/quickjs-emscripten/dist/index.d.mts +57 -0
  114. package/node_modules/quickjs-emscripten/dist/index.d.ts +57 -0
  115. package/node_modules/quickjs-emscripten/dist/index.global.js +7309 -0
  116. package/node_modules/quickjs-emscripten/dist/index.js +2 -0
  117. package/node_modules/quickjs-emscripten/dist/index.js.map +1 -0
  118. package/node_modules/quickjs-emscripten/dist/index.mjs +2 -0
  119. package/node_modules/quickjs-emscripten/dist/index.mjs.map +1 -0
  120. package/node_modules/quickjs-emscripten/dist/variants.d.mts +39 -0
  121. package/node_modules/quickjs-emscripten/dist/variants.d.ts +39 -0
  122. package/node_modules/quickjs-emscripten/dist/variants.js +2 -0
  123. package/node_modules/quickjs-emscripten/dist/variants.js.map +1 -0
  124. package/node_modules/quickjs-emscripten/dist/variants.mjs +2 -0
  125. package/node_modules/quickjs-emscripten/dist/variants.mjs.map +1 -0
  126. package/node_modules/quickjs-emscripten/package.json +69 -0
  127. package/node_modules/quickjs-emscripten-core/LICENSE +21 -0
  128. package/node_modules/quickjs-emscripten-core/README.md +341 -0
  129. package/node_modules/quickjs-emscripten-core/README.template.md +70 -0
  130. package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs +2 -0
  131. package/node_modules/quickjs-emscripten-core/dist/chunk-TAV5CUKK.mjs.map +1 -0
  132. package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs +7 -0
  133. package/node_modules/quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs.map +1 -0
  134. package/node_modules/quickjs-emscripten-core/dist/index.d.mts +2033 -0
  135. package/node_modules/quickjs-emscripten-core/dist/index.d.ts +2033 -0
  136. package/node_modules/quickjs-emscripten-core/dist/index.js +7 -0
  137. package/node_modules/quickjs-emscripten-core/dist/index.js.map +1 -0
  138. package/node_modules/quickjs-emscripten-core/dist/index.mjs +2 -0
  139. package/node_modules/quickjs-emscripten-core/dist/index.mjs.map +1 -0
  140. package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs +2 -0
  141. package/node_modules/quickjs-emscripten-core/dist/module-ES6BEMUI.mjs.map +1 -0
  142. package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs +2 -0
  143. package/node_modules/quickjs-emscripten-core/dist/module-asyncify-2EFITU5U.mjs.map +1 -0
  144. package/node_modules/quickjs-emscripten-core/package.json +49 -0
  145. package/package.json +6 -2
  146. package/src/agent.mjs +17 -6
  147. package/src/artifact-tools.mjs +3 -2
  148. package/src/cli.mjs +261 -199
  149. package/src/cloud-client.mjs +50 -8
  150. package/src/code-run-worker.mjs +70 -0
  151. package/src/code-run.mjs +97 -0
  152. package/src/codex-provider.mjs +27 -27
  153. package/src/compaction.mjs +1 -1
  154. package/src/goals.mjs +2 -2
  155. package/src/house.mjs +24 -24
  156. package/src/i18n/cli-es.mjs +97 -0
  157. package/src/i18n/es.mjs +227 -0
  158. package/src/i18n/index.mjs +48 -0
  159. package/src/image-attachments.mjs +263 -0
  160. package/src/model-controls.mjs +4 -4
  161. package/src/onboarding.mjs +91 -0
  162. package/src/openai-provider.mjs +24 -24
  163. package/src/permissions.mjs +52 -16
  164. package/src/provider-auth.mjs +30 -30
  165. package/src/provider-profiles.mjs +27 -27
  166. package/src/provider.mjs +30 -30
  167. package/src/remote-commands.mjs +11 -0
  168. package/src/remote-control.mjs +172 -0
  169. package/src/sessions.mjs +3 -3
  170. package/src/subagents.mjs +7 -3
  171. package/src/tool-packs.mjs +5 -2
  172. package/src/tools.mjs +81 -31
  173. package/src/tui/brand.mjs +2 -1
  174. package/src/tui/clipboard.mjs +5 -4
  175. package/src/tui/composer.mjs +77 -11
  176. package/src/tui/controller.mjs +110 -29
  177. package/src/tui/discovery.mjs +105 -100
  178. package/src/tui/markdown.mjs +10 -9
  179. package/src/tui/message-queue.mjs +27 -9
  180. package/src/tui/screen.mjs +209 -118
  181. package/src/vision.mjs +31 -3
  182. package/src/work-assertions.mjs +7 -7
  183. package/src/work-plan.mjs +104 -14
  184. package/src/work-recovery-proof.mjs +13 -1
  185. package/src/workflows/chrome-task.mjs +24 -7
@@ -1,23 +1,88 @@
1
- import { graphemes, safeText, width } from './text.mjs';
1
+ import { t } from '../i18n/index.mjs';
2
+ import { clip, graphemes, safeText, width } from './text.mjs';
3
+
4
+ const normalize = text => safeText(String(text).replace(/\r\n?/g, '\n'), { preserveTabs: true });
5
+ const IMAGE_COUNT = 2;
6
+ const imageLabel = /^\[Image ([1-9]\d*)\]$/;
2
7
 
3
8
  export class Composer {
4
- constructor(text = '') { this.history = []; this.historyIndex = -1; this.scratch = ''; this.setText(text); }
9
+ constructor(text = '') { this.history = []; this.historyAttachments = []; this.historyIndex = -1; this.scratch = ''; this.nextImage = 1; this.setText(text); }
5
10
  get text() { return this.buffer.join(''); }
6
- setText(text, cursor) {
7
- this.buffer = graphemes(safeText(String(text).replace(/\r\n?/g, '\n'), { preserveTabs: true }));
11
+ // Positions are UTF-16 offsets in display text, not filesystem paths. Only
12
+ // explicit host metadata may restore an attachment; a typed label is text.
13
+ get attachments() {
14
+ const result = []; let start = 0;
15
+ for (const atom of this.buffer) {
16
+ const descriptor = this.attachmentMap.get(atom);
17
+ if (descriptor) result.push({ ...structuredClone(descriptor), start, end: start + atom.length });
18
+ start += atom.length;
19
+ }
20
+ return result;
21
+ }
22
+ get attachmentDescriptors() { return this.attachments; }
23
+ snapshot() { return { text: this.text, cursor: this.cursor, attachments: this.attachments }; }
24
+ setText(text, cursor, attachments = []) {
25
+ const clean = normalize(text); this.attachmentMap = new Map();
26
+ const spans = [];
27
+ for (const candidate of attachments || []) {
28
+ if (!candidate || typeof candidate !== 'object' || !imageLabel.test(candidate.label || '') || spans.length >= IMAGE_COUNT || this.attachmentMap.has(candidate.label)) continue;
29
+ const start = Number.isInteger(candidate.start) ? candidate.start : clean.indexOf(candidate.label);
30
+ const end = start + candidate.label.length;
31
+ if (start < 0 || clean.slice(start, end) !== candidate.label || spans.some(span => start < span.end && end > span.start)) continue;
32
+ // Refuse offsets inside another grapheme, including joined emoji.
33
+ const boundaries = new Set([0]); let offset = 0;
34
+ for (const atom of graphemes(clean)) { offset += atom.length; boundaries.add(offset); }
35
+ if (!boundaries.has(start) || !boundaries.has(end)) continue;
36
+ const { start: _start, end: _end, ...descriptor } = structuredClone(candidate);
37
+ this.attachmentMap.set(candidate.label, descriptor); spans.push({ start, end, label: candidate.label });
38
+ this.nextImage = Math.max(this.nextImage, Number(imageLabel.exec(candidate.label)[1]) + 1);
39
+ }
40
+ spans.sort((a, b) => a.start - b.start); this.buffer = []; let offset = 0;
41
+ for (const span of spans) { this.buffer.push(...graphemes(clean.slice(offset, span.start)), span.label); offset = span.end; }
42
+ this.buffer.push(...graphemes(clean.slice(offset)));
8
43
  this.cursor = Math.max(0, Math.min(cursor ?? this.buffer.length, this.buffer.length));
9
44
  }
45
+ addAttachment(descriptor, range) {
46
+ if (!descriptor || typeof descriptor !== 'object') throw new Error(t("Invalid image attachment."));
47
+ if (this.attachments.length >= IMAGE_COUNT) throw new Error(t("Attach up to two images per message."));
48
+ const source = this.text;
49
+ const start = range?.start ?? this.buffer.slice(0, this.cursor).join('').length;
50
+ const end = range?.end ?? start;
51
+ let offset = 0; const boundaries = new Map([[0, 0]]);
52
+ for (let i = 0; i < this.buffer.length; i++) { offset += this.buffer[i].length; boundaries.set(offset, i + 1); }
53
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start > end || !boundaries.has(start) || !boundaries.has(end)) throw new Error(t("Invalid image attachment position."));
54
+ let label = descriptor.label;
55
+ if (!imageLabel.test(label || '') || source.includes(label)) {
56
+ do { label = `[Image ${this.nextImage++}]`; } while (source.includes(label));
57
+ } else this.nextImage = Math.max(this.nextImage, Number(imageLabel.exec(label)[1]) + 1);
58
+ const next = source.slice(0, start) + label + source.slice(end);
59
+ if (Buffer.byteLength(next) > 1024 * 1024) throw new Error(t("The composer supports up to 1 MiB."));
60
+ const first = boundaries.get(start), last = boundaries.get(end);
61
+ this.buffer.splice(first, last - first, label);
62
+ if (this.cursor >= last) this.cursor += 1 - (last - first);
63
+ else if (this.cursor > first) this.cursor = first + 1;
64
+ const { start: _start, end: _end, ...value } = structuredClone(descriptor);
65
+ this.attachmentMap.set(label, { ...value, label }); this.historyIndex = -1; this.pruneAttachments();
66
+ return this.attachments.find(attachment => attachment.label === label);
67
+ }
68
+ pruneAttachments() { for (const label of this.attachmentMap.keys()) if (!this.buffer.includes(label)) this.attachmentMap.delete(label); }
10
69
  insert(text) {
11
70
  const inserted = graphemes(safeText(String(text).replace(/\r\n?/g, '\n'), { preserveTabs: true }));
12
- if (Buffer.byteLength(this.text + inserted.join('')) > 1024 * 1024) throw new Error('El composer admite hasta 1 MiB.');
71
+ if (Buffer.byteLength(this.text + inserted.join('')) > 1024 * 1024) throw new Error(t("The composer supports up to 1 MiB."));
13
72
  this.buffer = this.buffer.slice(0, this.cursor).concat(inserted, this.buffer.slice(this.cursor)); this.cursor += inserted.length; this.historyIndex = -1;
14
73
  }
15
74
  clear() { this.setText(''); this.historyIndex = -1; }
16
- remember(text) { if (text.trim() && this.history.at(-1) !== text) this.history.push(text); this.history = this.history.slice(-100); this.historyIndex = -1; }
75
+ remember(text, attachments = text === this.text ? this.attachments : []) {
76
+ if (text.trim()) {
77
+ if (this.history.at(-1) !== text) { this.history.push(text); this.historyAttachments.push(structuredClone(attachments)); }
78
+ else this.historyAttachments[this.history.length - 1] = structuredClone(attachments);
79
+ }
80
+ this.history = this.history.slice(-100); this.historyAttachments = this.historyAttachments.slice(-100); this.historyIndex = -1;
81
+ }
17
82
  boundary(direction) {
18
83
  let cursor = this.cursor;
19
- if (direction < 0) { while (cursor > 0 && /\s/.test(this.buffer[cursor - 1])) cursor--; while (cursor > 0 && !/\s/.test(this.buffer[cursor - 1])) cursor--; }
20
- else { while (cursor < this.buffer.length && !/\s/.test(this.buffer[cursor])) cursor++; while (cursor < this.buffer.length && /\s/.test(this.buffer[cursor])) cursor++; }
84
+ if (direction < 0) { while (cursor > 0 && /^\s+$/.test(this.buffer[cursor - 1])) cursor--; while (cursor > 0 && !/^\s+$/.test(this.buffer[cursor - 1])) cursor--; }
85
+ else { while (cursor < this.buffer.length && !/^\s+$/.test(this.buffer[cursor])) cursor++; while (cursor < this.buffer.length && /^\s+$/.test(this.buffer[cursor])) cursor++; }
21
86
  return cursor;
22
87
  }
23
88
  lineStart() { let c = this.cursor; while (c > 0 && this.buffer[c - 1] !== '\n') c--; return c; }
@@ -39,9 +104,9 @@ export class Composer {
39
104
  }
40
105
  recall(direction) {
41
106
  if (!this.history.length) return;
42
- if (this.historyIndex === -1) { if (direction > 0) return; this.scratch = this.text; this.historyIndex = this.history.length; }
107
+ if (this.historyIndex === -1) { if (direction > 0) return; this.scratch = this.text; this.scratchAttachments = this.attachments; this.historyIndex = this.history.length; }
43
108
  this.historyIndex = Math.max(0, Math.min(this.history.length, this.historyIndex + direction));
44
- this.setText(this.historyIndex === this.history.length ? this.scratch : this.history[this.historyIndex]);
109
+ this.setText(this.historyIndex === this.history.length ? this.scratch : this.history[this.historyIndex], undefined, this.historyIndex === this.history.length ? this.scratchAttachments : this.historyAttachments[this.historyIndex]);
45
110
  }
46
111
  key(text, key = {}, columns) {
47
112
  const name = key.name;
@@ -58,11 +123,12 @@ export class Composer {
58
123
  else if (name === 'down' || (key.ctrl && name === 'n')) { if (key.ctrl || !this.vertical(1, columns)) this.recall(1); }
59
124
  else if ((name === 'return' && (key.meta || key.shift)) || name === 'linefeed' || (key.ctrl && name === 'j')) this.insert('\n');
60
125
  else if (!key.ctrl && !key.meta && text && !text.startsWith('\x1b')) this.insert(text);
126
+ this.pruneAttachments();
61
127
  }
62
128
  layout(columns) {
63
129
  columns = Math.max(2, columns); const lines = [''], positions = []; let row = 0, column = 0, cursorRow = 0, cursorColumn = 0;
64
130
  for (let i = 0; i <= this.buffer.length; i++) {
65
- const atom = this.buffer[i], visible = atom === '\t' ? ' ' : atom;
131
+ const atom = this.buffer[i], visible = this.attachmentMap.has(atom) ? clip(atom, columns, columns > 1 ? '…' : '') : atom === '\t' ? ' ' : atom;
66
132
  const size = atom && atom !== '\n' ? width(visible) : 0;
67
133
  if (atom && atom !== '\n' && column + size > columns) { lines.push(''); row++; column = 0; }
68
134
  positions.push({ index: i, row, column });
@@ -1,3 +1,4 @@
1
+ import { t } from '../i18n/index.mjs';
1
2
  import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -5,14 +6,18 @@ import { spawn } from 'node:child_process';
5
6
  import { once } from 'node:events';
6
7
  import { MessageQueue, QueueRunner } from './message-queue.mjs';
7
8
  import { shellCommands } from '../auto-guard.mjs';
9
+ import { findImageCandidates } from '../image-attachments.mjs';
10
+ import { Composer } from './composer.mjs';
11
+ import { isRemoteCommand } from '../remote-commands.mjs';
8
12
 
9
- const IMMEDIATE = new Set(['help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', 'operations', 'plugins', 'sessions', 'resume', 'clear', 'model', 'models', 'effort', 'capabilities', 'login', 'logout', 'permissions', 'rename', 'table', 'queue', 'agents', 'mcp','list-agents','inbox','providers','provider', 'exit', 'quit']);
13
+ const IMMEDIATE = new Set(['cloud', 'remote-control', 'rc', 'help', 'copy', 'select', 'status', 'context', 'tools', 'house', 'state', 'audit', 'board', 'tasks', 'work', 'operations', 'plugins', 'sessions', 'resume', 'clear', 'model', 'models', 'effort', 'capabilities', 'login', 'logout', 'permissions', 'rename', 'table', 'queue', 'agents', 'mcp','list-agents','inbox','providers','provider', 'exit', 'quit']);
10
14
 
11
15
  export class InteractiveController {
12
16
  constructor(ui, backend) { this.ui = ui; this.backend = backend; this.closed = false; this.switching = false; this.draftTimer = null; this.pendingDraft = null; }
13
17
  async start(session, initialPrompt = '') {
14
18
  this.attachSession(session);
15
- this.ui.configure({ onSubmit: text => this.submit(text), onAction: (name, arg) => this.action(name, arg), onDraft: text => this.scheduleDraft(text), onDiscover: () => this.refreshDiscovery() });
19
+ this.ui.configure({ onSubmit: (text, attachments) => this.submit(text, attachments), onPrepareSubmit: snapshot => this.prepareSubmit(snapshot), onAction: (name, arg) => this.action(name, arg), onDraft: (text, attachments) => this.scheduleDraft(text, attachments), onDiscover: () => this.refreshDiscovery(), onCapabilitiesChanged: () => this.previewImages() });
20
+ this.previewImages();
16
21
  if(this.backend.hasPeerMessages){this.peerTimer=setInterval(()=>this.kickPeers(),300);this.peerTimer.unref();}
17
22
  if (initialPrompt.trim()) await this.submit(initialPrompt);
18
23
  else if (session.goal?.status === 'active') await this.submit('/goal resume');
@@ -25,12 +30,13 @@ export class InteractiveController {
25
30
  const id = session.id;
26
31
  this.queue = new MessageQueue(session.queue, snapshot => this.backend.saveQueue(id, snapshot));
27
32
  this.runner = new QueueRunner(this.queue, async item => {
33
+ if(item.remote){await this.backend.validateRemote(item);if(item.text.trimStart().startsWith('/')&&!isRemoteCommand(item.text))throw new Error('This command is only available in the local terminal.');}
28
34
  this.ui.setBusy(true);
29
- try { return await this.backend.execute(item.text, { userIntent: item.text, shouldYield: () => this.queue.items.length > 0 }); }
35
+ try { return await this.backend.execute(item.text, { userIntent: item.text, attachments: item.attachments || [], shouldYield: () => this.queue.items.length > 0 }); }
30
36
  finally { this.ui.setBusy(false); }
31
37
  }, { blocked: () => Boolean(this.peerProcessing || this.ui.selecting || this.modalOverlay() || this.ui.editingQueue || this.switching || this.closed), onChange: () => { this.syncQueue(); this.refreshDiscovery(); }, onError: error => this.ui.notice(error.message, true) });
32
38
  this.syncQueue(); this.refreshDiscovery();
33
- if (this.queue.items.length || this.queue.interrupted.length) this.ui.notice('La cola recuperada está pausada. Revisa F4 antes de reanudar; los mensajes interrumpidos no se reenviaron.');
39
+ if (this.queue.items.length || this.queue.interrupted.length) this.ui.notice(t("The recovered queue is paused. Check F4 before resuming; interrupted messages were not resent."));
34
40
  }
35
41
  kickPeers(){
36
42
  if(this.peerProcessing || this.ui.busy || this.runner.processing || this.queue.paused || this.queue.items.length || this.modalOverlay() || this.ui.selecting || this.ui.editingQueue || this.switching || this.closed)return;
@@ -42,16 +48,80 @@ export class InteractiveController {
42
48
  })().catch(error=>this.ui.notice(error.message,true)).finally(()=>{this.peerProcessing=null;this.runner.kick();});
43
49
  }
44
50
  syncQueue() { this.ui.setQueue(this.queue.snapshot()); if (this.ui.overlay?.kind === 'queue') this.ui.overlay.index = Math.min(this.ui.overlay.index, Math.max(0, this.queue.items.length - 1)); }
45
- scheduleDraft(text) {
46
- clearTimeout(this.draftTimer); this.pendingDraft = { id: this.session.id, text };
51
+ scheduleDraft(text, attachments = this.ui.composer.attachments || []) {
52
+ clearTimeout(this.draftTimer); this.pendingDraft = { id: this.session.id, text, attachments };
47
53
  this.draftTimer = setTimeout(() => { this.flushDraft().catch(e => this.ui.notice(e.message, true)); }, 500);
54
+ clearTimeout(this.imageTimer);
55
+ if (this.backend.stageAttachment) this.imageTimer = setTimeout(() => this.previewImages(), 80);
48
56
  }
49
- async flushDraft() { clearTimeout(this.draftTimer); const pending = this.pendingDraft; this.pendingDraft = null; if (pending) await this.backend.saveDraft(pending.id, pending.text); }
50
- async submit(text) {
51
- if (this.closed || this.switching) throw new Error('La sesión está cambiando; conserva el mensaje y vuelve a enviarlo.');
57
+ async flushDraft() { clearTimeout(this.draftTimer); const pending = this.pendingDraft; this.pendingDraft = null; if (pending) await this.backend.saveDraft(pending.id, pending.text, pending.attachments); }
58
+ imageStatus(status) { this.ui.setAttachmentStatus?.(status); }
59
+ async normalizeImages(snapshot) {
60
+ if (!this.backend.stageAttachment) return snapshot;
61
+ const id = this.session.id, signature = JSON.stringify([id, snapshot.text, snapshot.attachments]);
62
+ if (this.imagePending?.signature === signature) return this.imagePending.promise;
63
+ const promise = (async () => {
64
+ const draft = new Composer(); draft.setText(snapshot.text, snapshot.cursor, snapshot.attachments);
65
+ const candidates = /^\/[a-z][\w-]*(?:\s|$)/i.test(snapshot.text.trimStart()) ? [] : findImageCandidates(snapshot.text, { cwd: this.backend.metadata().cwd });
66
+ for (const candidate of candidates.reverse()) {
67
+ if (draft.attachments.length >= 2) throw new Error(t('Attach up to two images per message.'));
68
+ if (this.closed || this.session.id !== id) throw new Error(t('The session changed; the image was not attached.'));
69
+ const descriptor = await this.backend.stageAttachment(candidate, id);
70
+ draft.addAttachment(descriptor, candidate);
71
+ }
72
+ return draft.snapshot();
73
+ })();
74
+ const pending = { signature, promise }; this.imagePending = pending;
75
+ try { return await promise; } finally { if (this.imagePending === pending) this.imagePending = null; }
76
+ }
77
+ async visionStatus(snapshot) {
78
+ const generation = this.visionGeneration = (this.visionGeneration || 0) + 1;
79
+ if (!snapshot.attachments?.length) { this.imageStatus(null); return true; }
80
+ const id = this.session.id, model = this.backend.metadata().model;
81
+ this.imageStatus({ state: 'checking', message: t('Checking vision support…') });
82
+ let capability;
83
+ try { capability = await this.backend.attachmentCapability?.(); }
84
+ catch { capability = { supported: false, source: 'unknown' }; }
85
+ if (this.closed || this.session.id !== id || this.backend.metadata().model !== model || this.visionGeneration !== generation) return false;
86
+ const supported = capability?.supported === true;
87
+ const message = supported ? t('{count} image(s) attached · Backspace removes a label', { count: snapshot.attachments.length })
88
+ : t('This model cannot receive images or vision is unverified. Switch model or remove the image before sending.');
89
+ if (this.ui.composer.text === snapshot.text) this.imageStatus({ state: supported ? 'ready' : 'unsupported', message });
90
+ return supported;
91
+ }
92
+ async previewImages() {
93
+ if (this.closed || this.switching || !this.backend.stageAttachment) return;
94
+ const id = this.session.id, snapshot = this.ui.composer.snapshot(), stamp = JSON.stringify(snapshot);
95
+ try {
96
+ if (findImageCandidates(snapshot.text, { cwd: this.backend.metadata().cwd }).length) this.imageStatus({ state: 'checking', message: t('Attaching image…') });
97
+ const prepared = await this.normalizeImages(snapshot);
98
+ if (this.closed || this.session.id !== id || JSON.stringify(this.ui.composer.snapshot()) !== stamp) return;
99
+ if (prepared.text !== snapshot.text) {
100
+ this.ui.composer.setText(prepared.text, prepared.cursor, prepared.attachments);
101
+ this.scheduleDraft(prepared.text, prepared.attachments); this.ui.schedule?.();
102
+ }
103
+ await this.visionStatus(prepared);
104
+ } catch (error) { if (!this.closed && this.session.id === id && this.ui.composer.text === snapshot.text) this.imageStatus({ state: 'invalid', message: error.message }); }
105
+ }
106
+ async prepareSubmit(snapshot) {
107
+ const id = this.session.id, prepared = await this.normalizeImages(snapshot);
108
+ if (this.closed || this.switching || this.session.id !== id) throw new Error(t('The session changed; the message was not sent.'));
109
+ if (JSON.stringify(this.ui.composer.snapshot()) === JSON.stringify(snapshot) && prepared.text !== snapshot.text) {
110
+ this.ui.composer.setText(prepared.text, prepared.cursor, prepared.attachments); this.scheduleDraft(prepared.text, prepared.attachments); this.ui.schedule?.();
111
+ }
112
+ if (!await this.visionStatus(prepared)) throw new Error(t('Image not sent: the current model does not support verified vision.'));
113
+ if (prepared.attachments?.length && prepared.text.trimStart().startsWith('/')) throw new Error(t('Use a plain message with images; commands do not accept image attachments.'));
114
+ return prepared;
115
+ }
116
+ async submit(text, attachments) {
117
+ if (this.closed || this.switching) throw new Error(t("The session is changing; keep the message and resend it."));
118
+ if (attachments === undefined && this.backend.stageAttachment) {
119
+ const prepared = await this.prepareSubmit({ text, attachments: [], cursor: undefined }); text = prepared.text; attachments = prepared.attachments;
120
+ }
121
+ attachments ||= [];
52
122
  if (this.ui.editingQueue) {
53
- const editing = this.ui.editingQueue; await this.queue.update(editing.id, text); this.ui.editingQueue = null;
54
- this.ui.setDraft(editing.draft); this.scheduleDraft(editing.draft); await this.queue.setPaused(editing.wasPaused); this.syncQueue(); this.runner.kick(); return;
123
+ const editing = this.ui.editingQueue; await this.queue.update(editing.id, text, attachments); this.ui.editingQueue = null;
124
+ this.ui.setDraft(editing.draft, editing.attachments); this.scheduleDraft(editing.draft, editing.attachments); await this.queue.setPaused(editing.wasPaused); this.syncQueue(); this.runner.kick(); return;
55
125
  }
56
126
  const trimmed = text.trim(), match = /^\/(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed);
57
127
  const mcpMutation = match?.[1] === 'mcp' && match[2] && !/^(?:status|list|config)(?:\s|$)/.test(match[2]);
@@ -65,21 +135,30 @@ export class InteractiveController {
65
135
  if (this.ui.busy && arg !== 'status') { await this.queue.setPaused(true); this.backend.interrupt(); await this.runner.processing; }
66
136
  await this.backend.goalControl(arg); this.syncQueue(); return;
67
137
  }
68
- await this.queue.enqueue(text); this.syncQueue(); this.runner.kick();
138
+ await this.queue.enqueue(text, attachments); this.syncQueue(); this.runner.kick();
139
+ }
140
+ async requireIdle() { if (this.peerProcessing || this.ui.busy || this.queue.running || this.runner.processing) throw new Error(t("Cancel the turn with Esc or Ctrl+C before switching session or model.")); }
141
+ async admitRemote(input, remote) {
142
+ if(this.closed || this.switching)throw new Error('Local session unavailable.');
143
+ const accepted=await this.queue.enqueueRemote(input.text,remote);this.syncQueue();this.runner.kick();return accepted;
144
+ }
145
+ async stopRemote(turnId) {
146
+ if(this.closed || !this.backend.remoteTurnMatches(turnId))return false;
147
+ await this.queue.setPaused(true);if(!this.backend.remoteTurnMatches(turnId))return false;this.backend.interrupt();this.syncQueue();return true;
69
148
  }
70
- async requireIdle() { if (this.peerProcessing || this.ui.busy || this.queue.running || this.runner.processing) throw new Error('Cancela el turno con Esc o Ctrl+C antes de cambiar sesión o modelo.'); }
71
149
  async command(command, arg) {
72
150
  if (['exit', 'quit'].includes(command)) return this.exit();
73
151
  if (command === 'copy' || command === 'select') {
74
- if (arg.trim()) throw new Error(`Usa /${command} sin argumentos.`);
152
+ if (arg.trim()) throw new Error(t("Use /{v0} without arguments.", {v0: command}));
75
153
  if (command === 'copy') await this.ui.copyResponse(); else this.ui.setSelection();
76
154
  return;
77
155
  }
156
+ if (['rc','remote-control'].includes(command) && ['', 'start'].includes(arg.trim()))await this.requireIdle();
78
157
  if (command === 'help') { this.ui.showHelp(); return; }
79
158
  if (command === 'sessions' || (command === 'resume' && !arg)) { this.ui.showSessions(await this.backend.listSessions()); return; }
80
159
  if (command === 'resume' || command === 'clear') {
81
160
  await this.requireIdle(); this.switching = true;
82
- try { await this.flushDraft(); const next = await this.backend.activate(command === 'clear' ? null : arg); this.ui.overlay = null; this.ui.editingQueue = null; this.attachSession(next); }
161
+ try { await this.backend.detachRemote?.(); await this.flushDraft(); const next = await this.backend.activate(command === 'clear' ? null : arg); this.ui.overlay = null; this.ui.editingQueue = null; this.attachSession(next); }
83
162
  finally { this.switching = false; }
84
163
  return;
85
164
  }
@@ -88,14 +167,14 @@ export class InteractiveController {
88
167
  else if (arg === 'pause') await this.queue.setPaused(true);
89
168
  else if (arg === 'resume') { await this.queue.setPaused(false); this.ui.closeOverlay(); this.runner.kick(); }
90
169
  else if (arg === 'clear') await this.queue.clear();
91
- else if (/^edit \d+$/.test(arg)) { const item = this.queue.items[Number(arg.slice(5)) - 1]; if (!item) throw new Error('Número de mensaje inválido.'); await this.action('queueEdit', item.id); }
92
- else throw new Error('Usa /queue, /queue pause|resume|clear o /queue edit N.');
170
+ else if (/^edit \d+$/.test(arg)) { const item = this.queue.items[Number(arg.slice(5)) - 1]; if (!item) throw new Error(t("Invalid message number.")); await this.action('queueEdit', item.id); }
171
+ else throw new Error(t("Use /queue, /queue pause|resume|clear or /queue edit N."));
93
172
  this.syncQueue(); return;
94
173
  }
95
174
  if (['board', 'tasks', 'work'].includes(command)) { this.ui.showBoard(JSON.parse(await this.backend.control('board', ''))); return; }
96
- if (['model', 'effort', 'provider', 'login', 'logout'].includes(command) && (arg.trim() || ['login', 'logout'].includes(command))) await this.requireIdle();
97
- if (command === 'table') { if (!arg) throw new Error('Usa /table archivo.csv|tsv|json|md.'); const table = await this.backend.readTable(arg); this.ui.addTable(table.text, table.format, arg); return; }
98
- const changing=['model','effort','provider','login','logout'].includes(command) && (arg.trim() || ['login','logout'].includes(command));
175
+ if (['cloud', 'model', 'effort', 'provider', 'login', 'logout'].includes(command) && (arg.trim() || ['login', 'logout'].includes(command))) await this.requireIdle();
176
+ if (command === 'table') { if (!arg) throw new Error(t("Use /table file.csv|tsv|json|md.")); const table = await this.backend.readTable(arg); this.ui.addTable(table.text, table.format, arg); return; }
177
+ const changing=(['cloud','model','effort','provider','login','logout'].includes(command) && (arg.trim() || ['login','logout'].includes(command))) || (['rc','remote-control'].includes(command) && ['', 'start'].includes(arg.trim()));
99
178
  if(changing)this.switching=true;
100
179
  try {
101
180
  const output=await this.backend.control(command,arg);if(output)this.ui.notice(output);
@@ -112,14 +191,14 @@ export class InteractiveController {
112
191
  if (name === 'resume') return this.command('resume', arg);
113
192
  if (name === 'queueKick') return this.runner.kick();
114
193
  if (name === 'queueEdit') {
115
- const item = this.queue.items.find(item => item.id === arg); if (!item) throw new Error('Ese mensaje ya se está ejecutando o salió de la cola.');
194
+ const item = this.queue.items.find(item => item.id === arg); if (!item) throw new Error(t("That message is already running or has left the queue."));
116
195
  const wasPaused = this.queue.paused; await this.queue.setPaused(true);
117
- this.ui.editingQueue = { id: arg, draft: this.ui.composer.text, wasPaused };
118
- this.ui.overlay = null; this.ui.setDraft(item.text); this.scheduleDraft(item.text); this.syncQueue(); return;
196
+ this.ui.editingQueue = { id: arg, draft: this.ui.composer.text, attachments: this.ui.composer.attachments, wasPaused };
197
+ this.ui.overlay = null; this.ui.setDraft(item.text, item.attachments); this.scheduleDraft(item.text, item.attachments); this.syncQueue(); return;
119
198
  }
120
199
  if (name === 'queueEditCancel') {
121
200
  const editing = this.ui.editingQueue; if (!editing) return;
122
- this.ui.editingQueue = null; this.ui.setDraft(editing.draft); this.scheduleDraft(editing.draft); await this.queue.setPaused(editing.wasPaused); this.syncQueue(); this.runner.kick(); return;
201
+ this.ui.editingQueue = null; this.ui.setDraft(editing.draft, editing.attachments); this.scheduleDraft(editing.draft, editing.attachments); await this.queue.setPaused(editing.wasPaused); this.syncQueue(); this.runner.kick(); return;
123
202
  }
124
203
  if (name === 'queueRemove') await this.queue.remove(arg);
125
204
  if (name === 'queueMove') { await this.queue.move(arg.id, arg.delta); if (this.ui.overlay?.kind === 'queue') this.ui.overlay.index = Math.max(0, this.queue.items.findIndex(item => item.id === arg.id)); }
@@ -129,21 +208,23 @@ export class InteractiveController {
129
208
  this.syncQueue(); this.runner.kick();
130
209
  }
131
210
  async externalEditor() {
211
+ const attachments = this.ui.composer.attachments || [];
132
212
  const commands = shellCommands(process.env.VISUAL || process.env.EDITOR || 'vi');
133
- if (!commands || commands.length !== 1 || !commands[0].length) throw new Error('$VISUAL/$EDITOR debe ser un ejecutable con argumentos, sin expansiones de shell.');
213
+ if (!commands || commands.length !== 1 || !commands[0].length) throw new Error(t("$VISUAL/$EDITOR must be an executable with arguments, without shell expansions."));
134
214
  const dir = await mkdtemp(path.join(os.tmpdir(), 'comal-composer-')), file = path.join(dir, 'message.md');
135
215
  await writeFile(file, this.ui.composer.text, { mode: 0o600 });
136
216
  const wasPaused = this.queue.paused; await this.queue.setPaused(true); this.syncQueue(); this.ui.suspend();
137
217
  try {
138
218
  const [command, ...args] = commands[0], child = spawn(command, [...args, file], { stdio: 'inherit' });
139
219
  const [code] = await once(child, 'close');
140
- if (code !== 0) throw new Error(`El editor terminó con código ${code}; el borrador anterior se conserva.`);
141
- const text = await readFile(file, 'utf8'); if (Buffer.byteLength(text) > 1024 * 1024) throw new Error('El editor produjo más de 1 MiB; el borrador anterior se conserva.');
142
- this.ui.setDraft(text); this.scheduleDraft(text);
220
+ if (code !== 0) throw new Error(t("The editor exited with code {v0}; the previous draft is kept.", {v0: code}));
221
+ const text = await readFile(file, 'utf8'); if (Buffer.byteLength(text) > 1024 * 1024) throw new Error(t("The editor produced more than 1 MiB; the previous draft is kept."));
222
+ const restored = attachments.map(({start,end,...attachment}) => attachment);
223
+ this.ui.setDraft(text, restored); this.scheduleDraft(text, this.ui.composer.attachments);
143
224
  } finally { await rm(dir, { recursive: true, force: true }); this.ui.resume(); await this.queue.setPaused(wasPaused); this.syncQueue(); this.runner.kick(); }
144
225
  }
145
226
  async exit() {
146
- if (this.closed) return; this.closed = true; clearInterval(this.peerTimer);this.runner.stopped = true; this.backend.interrupt();
227
+ if (this.closed) return; this.closed = true; clearInterval(this.peerTimer);clearTimeout(this.imageTimer);this.runner.stopped = true; this.backend.interrupt();
147
228
  this.ui.permission?.finish(false); await this.runner.processing;await this.peerProcessing; await this.flushDraft(); await this.queue.save(); this.ui.close();
148
229
  }
149
230
  }