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.
- package/main/acp.mjs +296 -0
- package/main/agent-homes.mjs +158 -0
- package/main/agent-sessions.mjs +57 -20
- package/main/agent-updates.mjs +9 -22
- package/main/analytics.mjs +30 -0
- package/main/claude-bin.mjs +45 -311
- package/main/claude-input.mjs +9 -3
- package/main/claude-launch-env.mjs +54 -0
- package/main/claude-models.mjs +39 -7
- package/main/claude-plan.mjs +16 -16
- package/main/claude-usage.mjs +82 -25
- package/main/codex-bin.mjs +51 -249
- package/main/codex-session.mjs +38 -3
- package/main/codex-threads.mjs +41 -8
- package/main/codex-watch.mjs +67 -3
- package/main/config.mjs +26 -4
- package/main/corner-tag.mjs +489 -0
- package/main/crash-report.mjs +26 -3
- package/main/engine-bin.mjs +595 -0
- package/main/engine-setup.mjs +4 -15
- package/main/feedback.mjs +11 -6
- package/main/harnesses/claude-stream.mjs +208 -0
- package/main/harnesses/claude.mjs +84 -0
- package/main/harnesses/codex.mjs +65 -0
- package/main/harnesses/common.mjs +78 -0
- package/main/harnesses/instructions-file.mjs +116 -0
- package/main/harnesses/opencode.mjs +234 -0
- package/main/harnesses.mjs +65 -0
- package/main/importable-threads.mjs +38 -0
- package/main/ipc.mjs +165 -31
- package/main/local-app-frames.mjs +82 -0
- package/main/main.mjs +100 -4
- package/main/memory-gate-history.mjs +51 -12
- package/main/memory-gate-server.mjs +67 -11
- package/main/memory-gate.mjs +49 -8
- package/main/memory-log.mjs +124 -0
- package/main/notify.mjs +47 -4
- package/main/opencode-server.mjs +271 -0
- package/main/opencode.mjs +287 -0
- package/main/phone-link.mjs +551 -0
- package/main/relay-address.mjs +28 -0
- package/main/relay-client.mjs +246 -0
- package/main/row-label.mjs +6 -6
- package/main/runs-on.mjs +32 -35
- package/main/serve.mjs +113 -9
- package/main/settings.mjs +148 -10
- package/main/shell-path.mjs +86 -0
- package/main/ship-queue.mjs +24 -6
- package/main/sign-in-files.mjs +3 -7
- package/main/source-updater.mjs +27 -1
- package/main/start-hold.mjs +73 -0
- package/main/store.mjs +1 -1
- package/main/supervisor.mjs +832 -428
- package/main/task-commands.mjs +13 -17
- package/main/task-folders-offthread.mjs +6 -1
- package/main/task-folders.mjs +490 -98
- package/main/task-terminals.mjs +37 -7
- package/main/updater.mjs +71 -9
- package/main/worktree-include.mjs +494 -0
- package/package.json +10 -1
- package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
- package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
- package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
- package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
- package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
- package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
- package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
- package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
- package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
- package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
- package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
- package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
- package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
- package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
- package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
- package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
- package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
- package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
- package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
- package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
- package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
- package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
- package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
- package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
- package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
- package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
- package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
- package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
- package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
- package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
- package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
- package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
- package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
- package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
- package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
- package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
- package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
- package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
- package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
- package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
- package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
- package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
- package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
- package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
- package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
- package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
- package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
- package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
- package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
- package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
- package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
- package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
- package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
- package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
- package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
- package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
- package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
- package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
- package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
- package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
- package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
- package/renderer/dist/corner-tag.html +13 -0
- package/renderer/dist/emulator/audio-rules.mjs +74 -0
- package/renderer/dist/emulator/data/compression/README.md +11 -0
- package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
- package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
- package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +22 -0
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +65 -0
- package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
- package/renderer/dist/emulator/data/emulator.css +1614 -0
- package/renderer/dist/emulator/data/loader.js +170 -0
- package/renderer/dist/emulator/data/localization/README.md +63 -0
- package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
- package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
- package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
- package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
- package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
- package/renderer/dist/emulator/data/localization/en-US.json +339 -0
- package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
- package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
- package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
- package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
- package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
- package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
- package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
- package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
- package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
- package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
- package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
- package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
- package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
- package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
- package/renderer/dist/emulator/data/src/GameManager.js +464 -0
- package/renderer/dist/emulator/data/src/compression.js +142 -0
- package/renderer/dist/emulator/data/src/emulator.js +6158 -0
- package/renderer/dist/emulator/data/src/gamepad.js +167 -0
- package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
- package/renderer/dist/emulator/data/src/shaders.js +236 -0
- package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
- package/renderer/dist/emulator/data/src/storage.js +125 -0
- package/renderer/dist/emulator/data/version.json +5 -0
- package/renderer/dist/emulator/host.js +297 -0
- package/renderer/dist/emulator/index.html +28 -0
- package/renderer/dist/emulator/provenance.json +6 -0
- package/renderer/dist/index.html +16 -3
- package/scripts/approval-server.sh +24 -5
- package/scripts/before-publish.mjs +12 -10
- package/shared/agent-import.mjs +2 -0
- package/shared/agents.d.mts +1 -0
- package/shared/bridge-map.mjs +18 -0
- package/shared/claude-commands.generated.mjs +6 -4
- package/shared/claude-models.generated.mjs +6 -4
- package/shared/corner-tag.d.mts +14 -0
- package/shared/corner-tag.mjs +141 -0
- package/shared/engines.mjs +20 -5
- package/shared/harness-definitions.mjs +103 -0
- package/shared/notify-rules.d.mts +25 -0
- package/shared/notify-rules.mjs +19 -0
- package/shared/provider-commands.mjs +10 -2
- package/shared/rank.d.mts +2 -0
- package/shared/rank.mjs +50 -0
- package/shared/relay-address.mjs +6 -0
- package/shared/relay-crypto.mjs +165 -0
- package/shared/runs-on.d.mts +0 -10
- package/shared/runs-on.mjs +25 -49
- package/shared/shortcuts.mjs +10 -4
- package/shared/spawn-trouble.mjs +57 -6
- package/shared/terminal-input.d.mts +1 -0
- package/shared/terminal-input.mjs +30 -0
- package/shared/terminal-keys.d.mts +1 -0
- package/shared/terminal-keys.mjs +16 -0
- package/shared/terminal-size.d.mts +4 -0
- package/shared/terminal-size.mjs +29 -0
- package/shared/usage.d.mts +3 -0
- package/shared/usage.mjs +7 -0
- package/shared/where-it-runs-from.d.mts +43 -0
- package/shared/where-it-runs-from.mjs +155 -0
- package/shared/work-items.mjs +25 -1
- package/shared/work-lines.mjs +1 -1
- package/shared/xterm-invalid-range.d.mts +2 -0
- package/shared/xterm-invalid-range.mjs +58 -0
- package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
- 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
|
+
}
|
package/main/agent-sessions.mjs
CHANGED
|
@@ -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
|
|
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"
|
|
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
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
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,
|