agentbox-app 0.1.12 → 0.1.13

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 (214) hide show
  1. package/main/acp.mjs +296 -0
  2. package/main/agent-homes.mjs +158 -0
  3. package/main/agent-sessions.mjs +57 -20
  4. package/main/agent-updates.mjs +9 -22
  5. package/main/analytics.mjs +30 -0
  6. package/main/claude-bin.mjs +45 -311
  7. package/main/claude-input.mjs +9 -3
  8. package/main/claude-launch-env.mjs +54 -0
  9. package/main/claude-models.mjs +39 -7
  10. package/main/claude-plan.mjs +16 -16
  11. package/main/claude-usage.mjs +82 -25
  12. package/main/codex-bin.mjs +51 -249
  13. package/main/codex-session.mjs +38 -3
  14. package/main/codex-threads.mjs +41 -8
  15. package/main/codex-watch.mjs +67 -3
  16. package/main/config.mjs +26 -4
  17. package/main/corner-tag.mjs +489 -0
  18. package/main/crash-report.mjs +26 -3
  19. package/main/engine-bin.mjs +595 -0
  20. package/main/engine-setup.mjs +4 -15
  21. package/main/feedback.mjs +11 -6
  22. package/main/harnesses/claude-stream.mjs +208 -0
  23. package/main/harnesses/claude.mjs +84 -0
  24. package/main/harnesses/codex.mjs +65 -0
  25. package/main/harnesses/common.mjs +78 -0
  26. package/main/harnesses/instructions-file.mjs +116 -0
  27. package/main/harnesses/opencode.mjs +234 -0
  28. package/main/harnesses.mjs +65 -0
  29. package/main/importable-threads.mjs +38 -0
  30. package/main/ipc.mjs +165 -31
  31. package/main/local-app-frames.mjs +82 -0
  32. package/main/main.mjs +100 -4
  33. package/main/memory-gate-history.mjs +51 -12
  34. package/main/memory-gate-server.mjs +67 -11
  35. package/main/memory-gate.mjs +49 -8
  36. package/main/memory-log.mjs +124 -0
  37. package/main/notify.mjs +47 -4
  38. package/main/opencode-server.mjs +271 -0
  39. package/main/opencode.mjs +287 -0
  40. package/main/phone-link.mjs +551 -0
  41. package/main/relay-address.mjs +28 -0
  42. package/main/relay-client.mjs +246 -0
  43. package/main/row-label.mjs +6 -6
  44. package/main/runs-on.mjs +32 -35
  45. package/main/serve.mjs +113 -9
  46. package/main/settings.mjs +148 -10
  47. package/main/shell-path.mjs +86 -0
  48. package/main/ship-queue.mjs +24 -6
  49. package/main/sign-in-files.mjs +3 -7
  50. package/main/source-updater.mjs +27 -1
  51. package/main/start-hold.mjs +73 -0
  52. package/main/store.mjs +1 -1
  53. package/main/supervisor.mjs +832 -428
  54. package/main/task-commands.mjs +13 -17
  55. package/main/task-folders-offthread.mjs +6 -1
  56. package/main/task-folders.mjs +490 -98
  57. package/main/task-terminals.mjs +37 -7
  58. package/main/updater.mjs +71 -9
  59. package/main/worktree-include.mjs +494 -0
  60. package/package.json +10 -1
  61. package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
  62. package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
  63. package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
  64. package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
  65. package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
  66. package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
  67. package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
  68. package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
  69. package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
  70. package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
  71. package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
  72. package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
  73. package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
  74. package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
  75. package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
  76. package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
  77. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
  78. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
  79. package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
  80. package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
  81. package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
  82. package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  83. package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
  84. package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  85. package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
  86. package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
  87. package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
  88. package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
  89. package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
  90. package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
  91. package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
  92. package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
  93. package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
  94. package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
  95. package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
  96. package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
  97. package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
  98. package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
  99. package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
  100. package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
  101. package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
  102. package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
  103. package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
  104. package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
  105. package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
  106. package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
  107. package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
  108. package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
  109. package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  110. package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
  111. package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
  112. package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
  113. package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
  114. package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
  115. package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
  116. package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
  117. package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
  118. package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
  119. package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
  120. package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
  121. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
  122. package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
  123. package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
  124. package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
  125. package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
  126. package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
  127. package/renderer/dist/corner-tag.html +13 -0
  128. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  129. package/renderer/dist/emulator/data/compression/README.md +11 -0
  130. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  131. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  132. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  133. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  134. package/renderer/dist/emulator/data/cores/README.md +22 -0
  135. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  136. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  137. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  138. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  139. package/renderer/dist/emulator/data/cores/package.json +65 -0
  140. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  141. package/renderer/dist/emulator/data/emulator.css +1614 -0
  142. package/renderer/dist/emulator/data/loader.js +170 -0
  143. package/renderer/dist/emulator/data/localization/README.md +63 -0
  144. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  145. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  146. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  147. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  148. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  149. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  150. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  151. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  152. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  153. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  154. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  155. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  156. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  157. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  158. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  159. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  160. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  161. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  162. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  163. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  164. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  165. package/renderer/dist/emulator/data/src/compression.js +142 -0
  166. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  167. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  168. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  169. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  170. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  171. package/renderer/dist/emulator/data/src/storage.js +125 -0
  172. package/renderer/dist/emulator/data/version.json +5 -0
  173. package/renderer/dist/emulator/host.js +297 -0
  174. package/renderer/dist/emulator/index.html +28 -0
  175. package/renderer/dist/emulator/provenance.json +6 -0
  176. package/renderer/dist/index.html +16 -3
  177. package/scripts/approval-server.sh +24 -5
  178. package/scripts/before-publish.mjs +12 -10
  179. package/shared/agent-import.mjs +2 -0
  180. package/shared/agents.d.mts +1 -0
  181. package/shared/bridge-map.mjs +18 -0
  182. package/shared/claude-commands.generated.mjs +6 -4
  183. package/shared/claude-models.generated.mjs +6 -4
  184. package/shared/corner-tag.d.mts +14 -0
  185. package/shared/corner-tag.mjs +141 -0
  186. package/shared/engines.mjs +20 -5
  187. package/shared/harness-definitions.mjs +103 -0
  188. package/shared/notify-rules.d.mts +25 -0
  189. package/shared/notify-rules.mjs +19 -0
  190. package/shared/provider-commands.mjs +10 -2
  191. package/shared/rank.d.mts +2 -0
  192. package/shared/rank.mjs +50 -0
  193. package/shared/relay-address.mjs +6 -0
  194. package/shared/relay-crypto.mjs +165 -0
  195. package/shared/runs-on.d.mts +0 -10
  196. package/shared/runs-on.mjs +25 -49
  197. package/shared/shortcuts.mjs +10 -4
  198. package/shared/spawn-trouble.mjs +57 -6
  199. package/shared/terminal-input.d.mts +1 -0
  200. package/shared/terminal-input.mjs +30 -0
  201. package/shared/terminal-keys.d.mts +1 -0
  202. package/shared/terminal-keys.mjs +16 -0
  203. package/shared/terminal-size.d.mts +4 -0
  204. package/shared/terminal-size.mjs +29 -0
  205. package/shared/usage.d.mts +3 -0
  206. package/shared/usage.mjs +7 -0
  207. package/shared/where-it-runs-from.d.mts +43 -0
  208. package/shared/where-it-runs-from.mjs +155 -0
  209. package/shared/work-items.mjs +25 -1
  210. package/shared/work-lines.mjs +1 -1
  211. package/shared/xterm-invalid-range.d.mts +2 -0
  212. package/shared/xterm-invalid-range.mjs +58 -0
  213. package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
  214. package/renderer/dist/assets/index-ClBXnq3B.css +0 -32
package/main/acp.mjs ADDED
@@ -0,0 +1,296 @@
1
+ /**
2
+ * ONE TRANSPORT FOR EVERY AGENT THAT SPEAKS THE AGENT CLIENT PROTOCOL.
3
+ *
4
+ * Grok (`grok agent stdio`), pi, Gemini CLI and Hermes (`hermes acp`) all speak
5
+ * it, so this file is what a user of ours asked for when they wrote
6
+ * github.com/savannahfeder/agentbox/issues/21: one engine instead of one
7
+ * adapter per tool. ACP is JSON-RPC 2.0 over stdio, one object per line, and
8
+ * the reason it is worth a file of its own is that permission requests are IN
9
+ * the protocol — an approval card can carry a real approval rather than one
10
+ * inferred from the bottom of a terminal.
11
+ *
12
+ * WHAT THIS FILE IS AND IS NOT. It is the wire: framing, negotiation, the
13
+ * session calls, and the two directions of request. It holds no opinion about
14
+ * which tool is on the other end, no card drawing, and no admission policy. A
15
+ * tool is not offered to anyone because this file works; see docs/harnesses.md.
16
+ *
17
+ * THE RULES IT EXISTS TO KEEP, every one of them a lesson
18
+ * main/codex-app-server.mjs paid for first:
19
+ *
20
+ * - A PERMISSION REQUEST IS ALWAYS ANSWERED. The agent is blocked on us while
21
+ * it waits, so a dropped answer is not a missing card, it is a task parked
22
+ * forever with nothing on the row. If the asker throws, or never comes back
23
+ * inside the deadline, the answer is a refusal rather than silence.
24
+ * - A REFUSAL IS SPOKEN IN THE AGENT'S OWN WORDS. Option ids belong to the
25
+ * tool, so "deny" means finding the option whose KIND rejects. If it offered
26
+ * none, the answer is `cancelled`: we never invent an id, and we never
27
+ * silently pick `allow_always` for someone who pressed Allow once.
28
+ * - A CANCELLED TURN ANSWERS `cancelled`, which the protocol requires, so a
29
+ * card nobody pressed cannot land as an approval after the fact. And a
30
+ * request is answered ONCE.
31
+ * - A DOOR WE NEVER OPENED IS REFUSED OUT LOUD. We advertise no filesystem and
32
+ * no terminal, so `fs/*` and `terminal/*` get a method-not-found error.
33
+ * Going quiet on a JSON-RPC request is the same hang in the other direction.
34
+ * - READING NEVER STOPS WHILE A QUESTION WAITS, because the agent may still be
35
+ * streaming, and because its other sessions must not be held up by this one.
36
+ */
37
+ import { StringDecoder } from 'node:string_decoder';
38
+
39
+ // The version this client negotiates. ACP numbers its protocol, and an agent
40
+ // that answers with another one has told us something we must not paper over.
41
+ export const ACP_PROTOCOL_VERSION = 1;
42
+
43
+ // What we lend the agent. Nothing: it has its own filesystem and its own
44
+ // terminal, and anything advertised here is a door we then have to police.
45
+ export const CLIENT_CAPABILITIES = Object.freeze({
46
+ fs: Object.freeze({ readTextFile: false, writeTextFile: false }),
47
+ terminal: false,
48
+ });
49
+
50
+ // The four kinds the protocol defines for a permission option. The ids beside
51
+ // them are the tool's own; only the kind is portable.
52
+ export const PERMISSION_KINDS = Object.freeze(['allow_once', 'allow_always', 'reject_once', 'reject_always']);
53
+ const REJECTIONS = Object.freeze(['reject_once', 'reject_always']);
54
+
55
+ // How long a question may stand before it is refused on the person's behalf.
56
+ // Matches the Codex spool, because it is the same person and the same patience.
57
+ export const PERMISSION_TIMEOUT_MS = 15 * 60_000;
58
+ export const REQUEST_TIMEOUT_MS = 2 * 60_000;
59
+ // A line that never ends is not a message, it is a leak.
60
+ export const MAX_FRAME_LENGTH = 16 * 1024 * 1024;
61
+
62
+ /**
63
+ * THE OPTION THAT MEANS NO, out of the ones this agent actually offered.
64
+ *
65
+ * `reject_once` before `reject_always`, because refusing one operation is the
66
+ * smaller claim and the only one a single card can honestly make: remembering
67
+ * a refusal forever is a different decision that nobody pressed. Null when the
68
+ * agent offered no refusal at all, which is answered by cancelling the turn.
69
+ */
70
+ export function rejectionOption(options = []) {
71
+ for (const kind of REJECTIONS) {
72
+ const found = options.find(option => option?.kind === kind);
73
+ if (found?.optionId) return found.optionId;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** Whether an id the asker chose is one the agent really put on the table. */
79
+ export function offeredOption(options = [], optionId) {
80
+ return options.some(option => option?.optionId === optionId) ? optionId : null;
81
+ }
82
+
83
+ export function createAcpClient({
84
+ transport = null,
85
+ spawn = null,
86
+ clientInfo = { name: 'agentbox', version: '1.0.0' },
87
+ onNotification = null,
88
+ onPermission = null,
89
+ permissionTimeoutMs = PERMISSION_TIMEOUT_MS,
90
+ requestTimeoutMs = REQUEST_TIMEOUT_MS,
91
+ maxFrameLength = MAX_FRAME_LENGTH,
92
+ stderrLimit = 64 * 1024,
93
+ } = {}) {
94
+ const child = transport ?? spawn?.();
95
+ if (!child) throw Error('createAcpClient needs a transport or a spawn that makes one');
96
+
97
+ const pending = new Map(); // our request id -> { resolve, reject, timer, method }
98
+ const permissions = new Map(); // their request id -> { sessionId, options, answer }
99
+ const decoder = new StringDecoder('utf8');
100
+ let nextId = 1;
101
+ let buffer = '';
102
+ let stderrText = '';
103
+ let gone = null; // null while live; the reason once not
104
+ let negotiated = null; // what initialize came back with
105
+
106
+ /* ------------------------------- writing -------------------------------- */
107
+
108
+ function write(message) {
109
+ if (gone) return false;
110
+ const line = `${JSON.stringify(message)}\n`;
111
+ try {
112
+ // A write that is accepted and then fails reports it in the callback,
113
+ // and that is the one that matters: a refusal that was "written" and
114
+ // never arrived cannot be told apart from an approval.
115
+ child.stdin.write(line, error => { if (error) close(`Write failed: ${error.message}`); });
116
+ } catch (error) {
117
+ close(`Write failed: ${error.message}`);
118
+ return false;
119
+ }
120
+ return true;
121
+ }
122
+
123
+ function request(method, params) {
124
+ if (gone) return Promise.reject(Error(gone));
125
+ const id = nextId++;
126
+ return new Promise((resolve, reject) => {
127
+ const timer = setTimeout(() => {
128
+ pending.delete(id);
129
+ reject(Error(`${method} did not answer within ${requestTimeoutMs}ms`));
130
+ }, requestTimeoutMs);
131
+ timer.unref?.();
132
+ pending.set(id, { resolve, reject, timer, method });
133
+ if (!write({ jsonrpc: '2.0', id, method, params })) {
134
+ clearTimeout(timer);
135
+ pending.delete(id);
136
+ reject(Error(gone ?? 'The agent connection is gone'));
137
+ }
138
+ });
139
+ }
140
+
141
+ const notify = (method, params) => write({ jsonrpc: '2.0', method, params });
142
+
143
+ /* ------------------------------ permissions ------------------------------ */
144
+
145
+ /**
146
+ * Answer one permission request, once.
147
+ *
148
+ * `optionId` when the person chose one of the offered options; null for
149
+ * `cancelled`, which is what a cancelled turn and an agent that offered no
150
+ * refusal both come to. The entry is dropped before the write, so a late
151
+ * answer from a card, a deadline and a cancellation racing each other still
152
+ * only ever put one response on the wire.
153
+ */
154
+ function settlePermission(id, optionId) {
155
+ const entry = permissions.get(id);
156
+ if (!entry) return false;
157
+ permissions.delete(id);
158
+ clearTimeout(entry.timer);
159
+ const outcome = optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
160
+ write({ jsonrpc: '2.0', id, result: { outcome } });
161
+ return true;
162
+ }
163
+
164
+ function askPermission(id, params) {
165
+ const options = Array.isArray(params?.options) ? params.options : [];
166
+ const deny = () => settlePermission(id, rejectionOption(options));
167
+ const timer = setTimeout(deny, permissionTimeoutMs);
168
+ timer.unref?.();
169
+ permissions.set(id, { sessionId: params?.sessionId ?? null, options, timer });
170
+ if (!onPermission) return deny();
171
+ // The asker may answer whenever: the reading loop is not waiting on it.
172
+ (async () => onPermission({ requestId: id, ...params }))()
173
+ .then(chosen => settlePermission(id, offeredOption(options, chosen)))
174
+ .catch(() => deny());
175
+ return true;
176
+ }
177
+
178
+ /* ------------------------------- reading -------------------------------- */
179
+
180
+ function handle(message) {
181
+ if (message.id != null && (message.result !== undefined || message.error !== undefined)) {
182
+ const waiting = pending.get(message.id);
183
+ if (!waiting) return; // a response to something we gave up on
184
+ pending.delete(message.id);
185
+ clearTimeout(waiting.timer);
186
+ if (message.error) waiting.reject(Error(`${waiting.method}: ${message.error.message ?? 'failed'}`));
187
+ else waiting.resolve(message.result);
188
+ return;
189
+ }
190
+ if (message.id != null && message.method) {
191
+ if (message.method === 'session/request_permission') return void askPermission(message.id, message.params);
192
+ // Everything else is a capability we did not advertise. Say so.
193
+ write({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: `${message.method} is not supported by this client` } });
194
+ return;
195
+ }
196
+ if (message.method) onNotification?.(message.method, message.params ?? {});
197
+ }
198
+
199
+ function absorb(chunk) {
200
+ if (gone) return;
201
+ buffer += decoder.write(chunk);
202
+ let index;
203
+ while ((index = buffer.indexOf('\n')) >= 0) {
204
+ const line = buffer.slice(0, index).trim();
205
+ buffer = buffer.slice(index + 1);
206
+ if (!line) continue;
207
+ let message = null;
208
+ try { message = JSON.parse(line); } catch { continue; } // a line we cannot read is not a reason to die
209
+ if (message && typeof message === 'object') handle(message);
210
+ }
211
+ if (buffer.length > maxFrameLength) close(`A frame longer than ${maxFrameLength} bytes was too long to be a message`);
212
+ }
213
+
214
+ function close(reason) {
215
+ if (gone) return gone;
216
+ gone = reason;
217
+ // Every question still standing goes with the connection: an answer whose
218
+ // asker has quit is worth nothing, and the agent is already gone.
219
+ for (const id of [...permissions.keys()]) {
220
+ const entry = permissions.get(id);
221
+ permissions.delete(id);
222
+ clearTimeout(entry.timer);
223
+ }
224
+ for (const [id, waiting] of [...pending]) {
225
+ pending.delete(id);
226
+ clearTimeout(waiting.timer);
227
+ waiting.reject(Error(reason));
228
+ }
229
+ try { child.stdin.end(); } catch {}
230
+ try { child.kill?.(); } catch {}
231
+ return gone;
232
+ }
233
+
234
+ child.stdout?.on('data', absorb);
235
+ child.stdout?.on('end', () => close(gone ?? 'The agent closed its output'));
236
+ child.stderr?.on('data', chunk => {
237
+ if (stderrText.length < stderrLimit) stderrText += chunk.toString().slice(0, stderrLimit - stderrText.length);
238
+ });
239
+ child.on?.('error', error => close(`The agent could not be run: ${error.message}`));
240
+ child.on?.('exit', code => close(gone ?? `The agent exited with code ${code}`));
241
+
242
+ /* -------------------------------- the API -------------------------------- */
243
+
244
+ return {
245
+ async initialize() {
246
+ negotiated = await request('initialize', {
247
+ protocolVersion: ACP_PROTOCOL_VERSION,
248
+ clientCapabilities: { fs: { ...CLIENT_CAPABILITIES.fs }, terminal: CLIENT_CAPABILITIES.terminal },
249
+ clientInfo,
250
+ });
251
+ return negotiated;
252
+ },
253
+ /**
254
+ * WHAT THE AGENT SAID IT CAN DO, and nothing we assumed. Speaking ACP does
255
+ * not make a tool resumable: `loadSession` is optional in the protocol, so
256
+ * a tool that cannot do it must be read as cannot rather than as untested.
257
+ */
258
+ can(capability) {
259
+ const agent = negotiated?.agentCapabilities ?? {};
260
+ if (capability === 'image' || capability === 'audio' || capability === 'embeddedContext') {
261
+ return agent.promptCapabilities?.[capability] === true;
262
+ }
263
+ return agent[capability] === true;
264
+ },
265
+ authMethods: () => negotiated?.authMethods ?? [],
266
+ authenticate: methodId => request('authenticate', { methodId }),
267
+ newSession: ({ cwd, mcpServers = [], additionalDirectories }) =>
268
+ request('session/new', { cwd, mcpServers, ...(additionalDirectories ? { additionalDirectories } : {}) }),
269
+ loadSession({ sessionId, cwd, mcpServers = [] }) {
270
+ // Refused before anything reaches the wire, so a thread cannot be
271
+ // silently restarted as an empty conversation on a tool that cannot
272
+ // resume. The caller's job is then to say so on the row.
273
+ if (!this.can('loadSession')) return Promise.reject(Error('This agent cannot resume a session, so there is no thread to load'));
274
+ return request('session/load', { sessionId, cwd, mcpServers });
275
+ },
276
+ prompt: ({ sessionId, text, blocks = null }) =>
277
+ request('session/prompt', { sessionId, prompt: blocks ?? [{ type: 'text', text }] }),
278
+ /**
279
+ * Stop the turn, and answer what it was parked on. The protocol says a
280
+ * cancelled turn MUST be answered `cancelled`, and this is the only place
281
+ * that is true of, so it happens here rather than at a call site.
282
+ */
283
+ cancel(sessionId) {
284
+ for (const [id, entry] of [...permissions]) {
285
+ if (!sessionId || entry.sessionId === sessionId) settlePermission(id, null);
286
+ }
287
+ return notify('session/cancel', { sessionId });
288
+ },
289
+ /** An answer from a card. False when the question is no longer standing. */
290
+ answerPermission: (requestId, optionId) => settlePermission(requestId, optionId),
291
+ openPermissions: () => permissions.size,
292
+ closed: () => gone,
293
+ close,
294
+ diagnostics: () => stderrText,
295
+ };
296
+ }
@@ -0,0 +1,158 @@
1
+ // EVERY FOLDER CLAUDE CODE AND CODEX KEEP SESSIONS IN, so the import reads all
2
+ // of them rather than the one each tool uses by default (w-e46f2e9cea).
3
+ //
4
+ // Claude Code writes a session to `$CLAUDE_CONFIG_DIR/projects`, and to
5
+ // `~/.claude/projects` only when that variable is unset (read out of the 2.1.293
6
+ // binary, 2026-10-11). Codex does the same with `CODEX_HOME` and `~/.codex`.
7
+ // The import read only the two defaults, and on the Mac this was measured on
8
+ // every Claude Code session its owner had run lived in `~/.claude-2`. The walk's
9
+ // last card is drawn only when the finder hands something back, so a finder
10
+ // looking in the wrong folder does not draw an empty card. It draws no card,
11
+ // and the person is never told importing exists.
12
+ //
13
+ // READING THE VARIABLE IS NOT ENOUGH. An app opened from the Dock never sees
14
+ // what a shell profile exports, so somebody with `export CLAUDE_CONFIG_DIR=...`
15
+ // in their .zshrc gets an app whose environment has no such variable. The
16
+ // folders are looked for instead, by the same rule main/account-discovery.mjs
17
+ // uses to find a second login: `.claude` or `.claude-<anything>` directly under
18
+ // home. That module asks whether a folder holds a LOGIN; this one asks whether
19
+ // it holds SESSIONS, which is a different question with a different answer. A
20
+ // backup folder holds no login and may hold the only copy of a conversation,
21
+ // and two folders signed in as one account are one account but two sets of
22
+ // sessions.
23
+ //
24
+ // A FOLDER COUNTS ONLY IF IT HAS SESSIONS IN IT: `projects/` for Claude Code,
25
+ // `sessions/` for Codex. `~/.claude-worktrees` is named like a data folder and
26
+ // is not one. A folder named `claude-src`, with no dot, is somebody's checkout
27
+ // and is never looked at.
28
+ //
29
+ // Duplicates are the reader's to settle, by session id: a backup folder with a
30
+ // copy of a conversation is ordinary, and the newest copy is the one to offer.
31
+
32
+ import fs from 'node:fs';
33
+ import os from 'node:os';
34
+ import path from 'node:path';
35
+ import { shellDataDirsNow } from './shell-path.mjs';
36
+
37
+ const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
38
+
39
+ /** `.claude`, `.claude-work`, `.codex-2`: every directory under home named `.<tool>` or `.<tool>-<anything>`. */
40
+ function shapedUnderHome(home, tool) {
41
+ let names = [];
42
+ try { names = fs.readdirSync(home); } catch { return []; }
43
+ return names
44
+ .filter((n) => n === `.${tool}` || n.startsWith(`.${tool}-`))
45
+ .sort()
46
+ .map((n) => path.join(home, n));
47
+ }
48
+
49
+ /**
50
+ * The candidates, in the order given, kept when they hold `inner/` and named
51
+ * once each. Two spellings of one folder (a trailing slash, a symlink) are
52
+ * one folder. */
53
+ function keepWithSessions(candidates, inner) {
54
+ const out = [];
55
+ const seen = new Set();
56
+ for (const c of candidates) {
57
+ if (typeof c !== 'string' || !c.trim()) continue;
58
+ const dir = path.resolve(c.trim());
59
+ let real = dir;
60
+ try { real = fs.realpathSync(dir); } catch { continue; }
61
+ if (seen.has(real)) continue;
62
+ if (!isDir(path.join(dir, inner))) continue;
63
+ seen.add(real);
64
+ out.push(dir);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ /** A configured profile is 'default' (the tool's own default folder) or a folder. */
70
+ const profileDirs = (configured, dflt) => (Array.isArray(configured) ? configured : [configured])
71
+ .filter((p) => typeof p === 'string' && p.trim())
72
+ .map((p) => (p === 'default' ? dflt : p));
73
+
74
+ /**
75
+ * EVERY CLAUDE CODE DATA FOLDER ON THIS MACHINE THAT HOLDS SESSIONS.
76
+ *
77
+ * `env` is the app's own environment and is passed in rather than read here,
78
+ * so a test with a made-up home is not handed the real folder the test runner
79
+ * happens to have in CLAUDE_CONFIG_DIR. `configured` is `authProfiles`. The
80
+ * last candidate is `~/.config/claude`, where some Claude Code versions kept
81
+ * their data; 2.1.293 no longer does, but a machine that still has sessions
82
+ * there gets them read. */
83
+ export function claudeDataDirs({ home = os.homedir(), env = {}, configured = [] } = {}) {
84
+ const dflt = path.join(home, '.claude');
85
+ const xdg = env?.XDG_CONFIG_HOME ? path.join(env.XDG_CONFIG_HOME, 'claude') : path.join(home, '.config', 'claude');
86
+ return keepWithSessions([
87
+ env?.CLAUDE_CONFIG_DIR,
88
+ ...profileDirs(configured, dflt),
89
+ dflt,
90
+ ...shapedUnderHome(home, 'claude'),
91
+ xdg,
92
+ ], 'projects');
93
+ }
94
+
95
+ /**
96
+ * BOTH LISTS, AS THE RUNNING APP SEES THEM: its own environment, the Claude
97
+ * profiles it was configured with, and the Codex homes its supervisor runs on.
98
+ * The one call the import card, the Codex watch and the import answer share,
99
+ * so the three cannot disagree about where somebody's sessions are. */
100
+ export function sessionFolders({ claudeProfiles = [], codexHomes = [], env = process.env, home = os.homedir() } = {}) {
101
+ return {
102
+ dirs: claudeDataDirs({ home, env, configured: claudeProfiles }),
103
+ codexDirs: codexDataDirs({ home, env, configured: codexHomes }),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * THE CODEX HOMES THE SUPERVISOR RUNS ON, or as many of them as can be named.
109
+ * Each profile is resolved on its own, so one that throws costs that one home
110
+ * and not the list (found by Codex's review of this change, 2026-10-11). */
111
+ export function codexHomesOf(supervisor) {
112
+ let profiles = [];
113
+ try { profiles = supervisor?._codexProfiles?.() ?? []; } catch { profiles = []; }
114
+ if (!Array.isArray(profiles)) return [];
115
+ const out = [];
116
+ for (const p of profiles) {
117
+ try {
118
+ const h = supervisor._codexProfileHome(p);
119
+ if (typeof h === 'string' && h.trim()) out.push(h);
120
+ } catch { /* that one home is unknown; the rest still count */ }
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * WHAT THE RUNNING APP HANDS EVERY READER: its own config and supervisor, made
127
+ * safe. A missing config, a supervisor without the Codex methods, or a home
128
+ * that will not resolve each lose only their own part. The first draft built
129
+ * both lists inside one try, so any one of those left the import reading
130
+ * nothing beyond the defaults, and without the app's own CLAUDE_CONFIG_DIR or
131
+ * CODEX_HOME either. */
132
+ export function appSessionFolders({ config = null, supervisor = null, env = process.env, shellEnv = shellDataDirsNow(), home = os.homedir() } = {}) {
133
+ let claudeProfiles = [];
134
+ try { claudeProfiles = config?.authProfiles ?? []; } catch { claudeProfiles = []; }
135
+ if (!Array.isArray(claudeProfiles)) claudeProfiles = [claudeProfiles];
136
+ // AND WHAT THE LOGIN SHELL EXPORTS (main/shell-path.mjs), which an app opened
137
+ // from the Dock never sees in its own environment.
138
+ return sessionFolders({
139
+ claudeProfiles: [...claudeProfiles, shellEnv?.CLAUDE_CONFIG_DIR],
140
+ codexHomes: [...codexHomesOf(supervisor), shellEnv?.CODEX_HOME],
141
+ env,
142
+ home,
143
+ });
144
+ }
145
+
146
+ /**
147
+ * EVERY CODEX DATA FOLDER ON THIS MACHINE THAT HOLDS SESSIONS. Same rule:
148
+ * `CODEX_HOME`, whatever the app was configured with, `~/.codex` and every
149
+ * `~/.codex-<name>`. */
150
+ export function codexDataDirs({ home = os.homedir(), env = {}, configured = [] } = {}) {
151
+ const dflt = path.join(home, '.codex');
152
+ return keepWithSessions([
153
+ env?.CODEX_HOME,
154
+ ...profileDirs(configured, dflt),
155
+ dflt,
156
+ ...shapedUnderHome(home, 'codex'),
157
+ ], 'sessions');
158
+ }
@@ -12,7 +12,9 @@
12
12
  //
13
13
  // WHERE A THREAD LIVES. Two places, because she named two:
14
14
  //
15
- // ~/.claude/projects/<folder>/<id>.jsonl every terminal session
15
+ // ~/.claude/projects/<folder>/<id>.jsonl every terminal session (and the
16
+ // same under every other Claude
17
+ // data folder: main/agent-homes.mjs)
16
18
  // ~/Library/Application Support/Claude/
17
19
  // claude-code-sessions/**/local_<id>.json the desktop app
18
20
  //
@@ -49,6 +51,7 @@
49
51
  import fs from 'node:fs';
50
52
  import os from 'node:os';
51
53
  import path from 'node:path';
54
+ import { claudeDataDirs } from './agent-homes.mjs';
52
55
 
53
56
  /**
54
57
  * "Recent = last few days", in days.
@@ -149,9 +152,22 @@ export function isScratchProjectDir(name, home = os.homedir()) {
149
152
  *
150
153
  * An entrypoint we have never seen is treated as not hers. The cost of being
151
154
  * wrong that way is one missing row on a card; the other way is the hundreds
152
- * she is complaining about. */
155
+ * she is complaining about.
156
+ *
157
+ * `claude-vscode` IS A PERSON TOO (w-e46f2e9cea, 2026-10-11). It is what the
158
+ * Claude Code extension writes in VS Code, Cursor and Windsurf, and leaving it
159
+ * out threw away every session of everybody who runs Claude Code in an editor.
160
+ * Read out of the 2.1.293 binary, which maps it to `claude_code_vscode`. The
161
+ * `remote*` family stays out: those run in the cloud, not on this disk. */
162
+ const BY_HAND = new Set(['cli', 'claude-desktop', 'claude-vscode']);
153
163
  export function startedByHand(entrypoint) {
154
- return entrypoint === 'cli' || entrypoint === 'claude-desktop';
164
+ return BY_HAND.has(entrypoint);
165
+ }
166
+
167
+ /** Which of the three places a person starts Claude Code a session came from. */
168
+ export function sourceOf(entrypoint) {
169
+ if (entrypoint === 'claude-desktop') return 'desktop';
170
+ return entrypoint === 'claude-vscode' ? 'editor' : 'terminal';
155
171
  }
156
172
 
157
173
  /**
@@ -251,9 +267,11 @@ export function readTranscriptHead(file, bytes = HEAD_BYTES, most = FIRST_TURN_B
251
267
  const n = fs.readSync(fd, buf, 0, bytes, 0);
252
268
  const text = buf.slice(0, n).toString('utf8');
253
269
  // The last line of a fixed read is usually cut in half. Dropping it costs
254
- // nothing, because what is wanted is at the top.
270
+ // nothing, because what is wanted is at the top. UNLESS THE WHOLE FILE FIT:
271
+ // then the last line is complete, and a file whose one row has no newline
272
+ // after it lost that row (found by Codex's review, 2026-10-11).
255
273
  const lines = text.split('\n');
256
- const cut = lines.pop();
274
+ const cut = n < bytes ? null : lines.pop();
257
275
  let cwd = null;
258
276
  let entrypoint = null;
259
277
  const said = [];
@@ -278,7 +296,7 @@ export function readTranscriptHead(file, bytes = HEAD_BYTES, most = FIRST_TURN_B
278
296
  // only when nothing she said was found and the session is not already
279
297
  // known to be the app's own, so the thousands of worker transcripts are
280
298
  // never read past their head.
281
- if (!said.length && n === bytes && cut && /"type":"user"/.test(cut.slice(0, 2048))
299
+ if (!said.length && n === bytes && cut && /"type"\s*:\s*"user"/.test(cut.slice(0, 2048))
282
300
  && (!entrypoint || startedByHand(entrypoint))) {
283
301
  const rest = restOfLine(fd, n, most - n);
284
302
  if (rest) take(Buffer.concat([buf.subarray(buf.lastIndexOf(0x0a, n - 1) + 1, n), rest]).toString('utf8'));
@@ -331,25 +349,35 @@ function messageText(row) {
331
349
  * `skipped` comes back with it because the counts are the argument for the
332
350
  * filters, and a session that changes one of them should be able to see what
333
351
  * it changed rather than take this comment's word for it. */
334
- function readTerminalThreads({ home, cutoff, max, skipIds = new Set() }) {
335
- const root = path.join(home, '.claude', 'projects');
336
- const skipped = { old: 0, scratch: 0, started: 0, subagent: 0, empty: 0, unread: 0, desktopDupe: 0 };
352
+ function readTerminalThreads({ home, dirs, cutoff, max, skipIds = new Set() }) {
353
+ const skipped = { old: 0, scratch: 0, started: 0, subagent: 0, empty: 0, unread: 0, desktopDupe: 0, copy: 0 };
337
354
  const out = [];
338
- const all = [];
339
- walkTranscripts(root, all);
340
355
  // Stat everything, keep the recent, newest first, and only then start opening
341
356
  // files. This is the order that makes `max` safe: whatever it cuts off is
342
357
  // older than everything it kept.
358
+ //
359
+ // EVERY DATA FOLDER, NOT ONLY ~/.claude (main/agent-homes.mjs). They are
360
+ // pooled before the sort, so the cap and the time budget are spent on the
361
+ // newest sessions on the machine, whichever folder they are in.
343
362
  const recent = [];
344
- for (const file of all) {
345
- let when = 0;
346
- try { when = fs.statSync(file).mtimeMs; } catch { continue; }
347
- if (when < cutoff) { skipped.old += 1; continue; }
348
- if (isSubagentPath(file.slice(root.length))) { skipped.subagent += 1; continue; }
349
- if (isScratchProjectDir(path.relative(root, file).split(path.sep)[0], home)) { skipped.scratch += 1; continue; }
350
- recent.push({ file, when });
363
+ for (const dir of dirs) {
364
+ const root = path.join(dir, 'projects');
365
+ const all = [];
366
+ walkTranscripts(root, all);
367
+ for (const file of all) {
368
+ let when = 0;
369
+ try { when = fs.statSync(file).mtimeMs; } catch { continue; }
370
+ if (when < cutoff) { skipped.old += 1; continue; }
371
+ if (isSubagentPath(file.slice(root.length))) { skipped.subagent += 1; continue; }
372
+ if (isScratchProjectDir(path.relative(root, file).split(path.sep)[0], home)) { skipped.scratch += 1; continue; }
373
+ recent.push({ file, when });
374
+ }
351
375
  }
352
376
  recent.sort((a, b) => b.when - a.when);
377
+ // ONE SESSION IS ONE ROW, even when two folders hold it: a backup folder with
378
+ // a copy of a conversation is ordinary. Newest first, so the copy kept is the
379
+ // one still being written to.
380
+ const seenIds = new Set();
353
381
  // THE CAP COUNTS WHAT IS KEPT, NOT WHAT IS OPENED (w-ec62ab6b38, 2026-09-28).
354
382
  // On a busy machine the last seven days held 9,073 transcripts, the newest
355
383
  // 1,200 were opened, and every one of those was started by agentbox itself,
@@ -373,14 +401,18 @@ function readTerminalThreads({ home, cutoff, max, skipIds = new Set() }) {
373
401
  // one desktop thread in her store. Its own record wins, because the app
374
402
  // wrote a real title into it and this side would have to guess one.
375
403
  if (skipIds.has(id)) { skipped.desktopDupe += 1; continue; }
404
+ if (seenIds.has(id)) { skipped.copy += 1; continue; }
376
405
  const { cwd, entrypoint, said } = readTranscriptHead(file);
406
+ // A copy is only the copy once it could be read. A damaged newer one, cut
407
+ // off mid-write, would otherwise hide the good older one in another folder.
408
+ if (cwd || entrypoint) seenIds.add(id);
377
409
  if (!startedByHand(entrypoint)) { skipped.started += 1; continue; }
378
410
  if (isScratchFolder(cwd, home)) { skipped.scratch += 1; continue; }
379
411
  const title = threadTitle(said);
380
412
  if (!title) { skipped.empty += 1; continue; }
381
413
  out.push({
382
414
  id,
383
- source: entrypoint === 'claude-desktop' ? 'desktop' : 'terminal',
415
+ source: sourceOf(entrypoint),
384
416
  folder: cwd,
385
417
  folderName: path.basename(cwd),
386
418
  short: shortFolder(cwd, home),
@@ -484,6 +516,11 @@ function walkDesktop(dir, into, max, depth = 3) {
484
516
  * the card groups by it, the same way it already groups agent files. */
485
517
  export function readSessionThreads({
486
518
  home = os.homedir(),
519
+ // Every Claude Code data folder to read (main/agent-homes.mjs). The app hands
520
+ // in the list it made with its own environment and settings; left out, it is
521
+ // the folders under `home`, and nothing from whatever CLAUDE_CONFIG_DIR the
522
+ // caller's process happens to carry.
523
+ dirs = claudeDataDirs({ home }),
487
524
  days = RECENT_DAYS,
488
525
  now = Date.now(),
489
526
  max = MAX_READS,
@@ -492,7 +529,7 @@ export function readSessionThreads({
492
529
  // The desktop side goes first so its ids are known before the transcripts are
493
530
  // walked, which is what stops one thread appearing twice.
494
531
  const desk = readDesktopThreads({ home, cutoff, max });
495
- const term = readTerminalThreads({ home, cutoff, max, skipIds: desk.owned });
532
+ const term = readTerminalThreads({ home, dirs, cutoff, max, skipIds: desk.owned });
496
533
  const threads = [...term.threads, ...desk.threads].sort((a, b) => b.when - a.when);
497
534
  return {
498
535
  threads,