agentbox-app 0.1.13 → 0.1.16
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/README.md +24 -9
- package/bin/agentbox.mjs +23 -1
- package/briefs/message-rules.md +36 -8
- package/briefs/worker.md +46 -23
- package/main/account-discovery.mjs +122 -30
- package/main/account-tooling.mjs +29 -1
- package/main/account-usage.mjs +46 -0
- package/main/agent-sessions.mjs +282 -268
- package/main/agents.mjs +179 -466
- package/main/analytics.mjs +12 -2
- package/main/answer-modes.mjs +103 -37
- package/main/approval-prompt-core.mjs +197 -0
- package/main/approval-prompt-server.mjs +4 -138
- package/main/approvals.mjs +101 -297
- package/main/artifact-path.mjs +47 -18
- package/main/claude-input.mjs +48 -4
- package/main/claude-plan.mjs +48 -0
- package/main/claude-session-open.mjs +28 -0
- package/main/claude-usage-process.mjs +40 -0
- package/main/claude-usage.mjs +18 -40
- package/main/cloud/account.mjs +179 -0
- package/main/cloud/aws.mjs +127 -0
- package/main/cloud/browser-return.mjs +58 -0
- package/main/cloud/clients.mjs +15 -0
- package/main/cloud/codex-cloud.mjs +53 -0
- package/main/cloud/config.mjs +107 -0
- package/main/cloud/docs.mjs +219 -0
- package/main/cloud/exec.mjs +27 -0
- package/main/cloud/host.mjs +165 -0
- package/main/cloud/kit.mjs +80 -0
- package/main/cloud/logins.mjs +543 -0
- package/main/cloud/managed.mjs +115 -0
- package/main/cloud/marker.mjs +104 -0
- package/main/cloud/org.mjs +53 -0
- package/main/cloud/reuse.mjs +208 -0
- package/main/cloud/run-cli.mjs +177 -0
- package/main/cloud/run.mjs +1934 -0
- package/main/cloud/usage.mjs +76 -0
- package/main/cloud-permission.mjs +51 -0
- package/main/codex-account.mjs +78 -0
- package/main/codex-app-server.mjs +276 -409
- package/main/codex-approvals.mjs +148 -433
- package/main/codex-models.mjs +20 -2
- package/main/codex-session.mjs +212 -507
- package/main/codex-threads-worker.mjs +12 -0
- package/main/codex-threads.mjs +71 -3
- package/main/codex-usage-file.mjs +5 -3
- package/main/codex-usage.mjs +56 -7
- package/main/codex-watch.mjs +17 -3
- package/main/codex.mjs +95 -363
- package/main/config.mjs +11 -1
- package/main/corner-tag.mjs +340 -19
- package/main/dev-window.mjs +1 -1
- package/main/engine-bin.mjs +232 -282
- package/main/engine-setup.mjs +21 -9
- package/main/feedback.mjs +3 -25
- package/main/folder-pictures.mjs +2 -2
- package/main/freeze-recorder-thread.mjs +86 -0
- package/main/freeze-recorder.mjs +146 -0
- package/main/fresh-user.mjs +20 -9
- package/main/git-change-offthread.mjs +17 -3
- package/main/git-change-thread.mjs +5 -2
- package/main/harnesses/claude.mjs +32 -11
- package/main/harnesses/codex.mjs +9 -2
- package/main/importable-threads.mjs +64 -6
- package/main/ipc.mjs +362 -111
- package/main/leftovers.mjs +117 -25
- package/main/liquid-glass.mjs +26 -0
- package/main/live-replies.mjs +10 -4
- package/main/main-went-red.mjs +330 -0
- package/main/main.mjs +199 -84
- package/main/memory-gate-history.mjs +36 -10
- package/main/memory-gate-server.mjs +62 -0
- package/main/memory-gate.mjs +14 -1
- package/main/menu.mjs +4 -2
- package/main/message-priority.mjs +2 -2
- package/main/mic.mjs +38 -0
- package/main/native/liquid-glass/build/liquid-glass.node +0 -0
- package/main/new-chats-watch.mjs +110 -0
- package/main/new-user-window.mjs +25 -0
- package/main/notify.mjs +24 -8
- package/main/phone-alerts.mjs +117 -0
- package/main/phone-inbox.mjs +625 -0
- package/main/phone-link.mjs +29 -9
- package/main/phone-uploads.mjs +476 -0
- package/main/picture-shape.mjs +114 -0
- package/main/quit-sequence.mjs +143 -0
- package/main/refresh-agent-models.mjs +15 -2
- package/main/relay-client.mjs +69 -1
- package/main/repeats.mjs +10 -4
- package/main/row-label.mjs +12 -41
- package/main/run-change.mjs +13 -0
- package/main/run-ledger.mjs +135 -0
- package/main/save-attachments.mjs +41 -0
- package/main/serve.mjs +45 -3
- package/main/settings.mjs +220 -44
- package/main/shell-path.mjs +185 -37
- package/main/ship-conflict-brief.mjs +119 -0
- package/main/ship-queue.mjs +115 -19
- package/main/source-updater.mjs +18 -10
- package/main/stage-attachment.mjs +68 -0
- package/main/stall-watch.mjs +50 -0
- package/main/store/home.mjs +87 -232
- package/main/store/paths.mjs +11 -0
- package/main/store/teammates-files.mjs +99 -0
- package/main/store/work-items.mjs +231 -13
- package/main/store.mjs +226 -52
- package/main/supervisor.mjs +3103 -341
- package/main/task-folders.mjs +430 -30
- package/main/task-terminals.mjs +110 -34
- package/main/team/index.mjs +114 -10
- package/main/team/memory-cloud.mjs +183 -1
- package/main/team/room-hours.mjs +48 -0
- package/main/team/room-setup.mjs +31 -0
- package/main/team/rooms-memory.mjs +454 -0
- package/main/team/rooms-shape.mjs +42 -0
- package/main/team/rooms-supabase.mjs +324 -0
- package/main/team/rooms.mjs +575 -0
- package/main/team/session.mjs +96 -26
- package/main/team/supabase-backend.mjs +180 -0
- package/main/team/sync.mjs +109 -1
- package/main/teammates.mjs +580 -0
- package/main/terminal-host.mjs +143 -0
- package/main/terminal-hosts.mjs +261 -0
- package/main/transcript-search.mjs +80 -0
- package/main/webview-guard.mjs +45 -0
- package/main/worktree-include.mjs +90 -219
- package/mcp/core/documents.mjs +3 -1
- package/mcp/core/repeats.mjs +88 -0
- package/mcp/core/teammates.mjs +222 -0
- package/mcp/core/work.mjs +76 -4
- package/mcp/stdio.mjs +24 -11
- package/mcp/tools.mjs +116 -8
- package/package.json +21 -5
- package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
- package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
- package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
- package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
- package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
- package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
- package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
- package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
- package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
- package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
- package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
- package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
- package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
- package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
- package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
- package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
- package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
- package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
- package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
- package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
- package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
- package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
- package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
- package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
- package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
- package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
- package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
- package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
- package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
- package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
- package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
- package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
- package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
- package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
- package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
- package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
- package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
- package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
- package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
- package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
- package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
- package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
- package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
- package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
- package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
- package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
- package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
- package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
- package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
- package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
- package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
- package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
- package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
- package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
- package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
- package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
- package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
- package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
- package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
- package/renderer/dist/corner-tag.html +3 -3
- package/renderer/dist/index.html +3 -3
- package/scripts/after-install.mjs +30 -8
- package/scripts/cloud-shim.pl +57 -0
- package/shared/account-names.d.mts +6 -0
- package/shared/account-names.mjs +15 -0
- package/shared/agent-hours.mjs +136 -0
- package/shared/agent-import.mjs +60 -34
- package/shared/agents.mjs +126 -522
- package/shared/analytics-events.mjs +12 -0
- package/shared/approval-card.mjs +36 -0
- package/shared/bridge-map.mjs +27 -1
- package/shared/claude-code-shape.mjs +6 -0
- package/shared/claude-usage.d.mts +1 -0
- package/shared/claude-usage.mjs +42 -1
- package/shared/cloud-hand-back.d.mts +7 -0
- package/shared/cloud-hand-back.mjs +20 -0
- package/shared/cloud-kit.d.mts +219 -0
- package/shared/cloud-kit.mjs +341 -0
- package/shared/cloud-login-name.mjs +4 -0
- package/shared/cloud-start.mjs +17 -0
- package/shared/cloud-versions.mjs +23 -0
- package/shared/codex-import.mjs +2 -2
- package/shared/codex-modes.d.mts +22 -9
- package/shared/codex-modes.mjs +67 -11
- package/shared/codex-usage.mjs +108 -2
- package/shared/corner-tag.d.mts +23 -1
- package/shared/corner-tag.mjs +284 -29
- package/shared/dead-run-trace.mjs +19 -2
- package/shared/engines.mjs +70 -309
- package/shared/feedback.d.mts +7 -0
- package/shared/feedback.mjs +25 -0
- package/shared/first-run-practice.mjs +17 -24
- package/shared/harness-definitions.mjs +8 -0
- package/shared/instruction-defaults.json +2 -2
- package/shared/key-caps.d.mts +14 -0
- package/shared/key-caps.mjs +68 -0
- package/shared/message-edits.d.mts +7 -0
- package/shared/message-edits.mjs +35 -0
- package/shared/move-thread.d.mts +24 -0
- package/shared/move-thread.mjs +112 -0
- package/shared/named-threads.mjs +72 -0
- package/shared/not-shipped.d.mts +22 -0
- package/shared/not-shipped.mjs +89 -0
- package/shared/notify-rules.d.mts +6 -0
- package/shared/notify-rules.mjs +36 -8
- package/shared/phone-files.mjs +28 -0
- package/shared/phone-settings.mjs +118 -0
- package/shared/phone-snapshot.d.mts +16 -0
- package/shared/phone-snapshot.mjs +348 -0
- package/shared/picture-formats.d.mts +1 -0
- package/shared/picture-formats.mjs +13 -0
- package/shared/rank.d.mts +2 -1
- package/shared/rank.mjs +32 -19
- package/shared/repeats.d.mts +8 -0
- package/shared/repeats.mjs +33 -2
- package/shared/room-cards.mjs +167 -0
- package/shared/room-links.d.mts +4 -0
- package/shared/room-links.mjs +21 -0
- package/shared/runs-on.d.mts +3 -3
- package/shared/runs-on.mjs +8 -5
- package/shared/shipped-note.d.mts +4 -0
- package/shared/shipped-note.mjs +6 -0
- package/shared/shortcuts.mjs +14 -6
- package/shared/spawn-trouble.d.mts +4 -0
- package/shared/spawn-trouble.mjs +443 -40
- package/shared/team-rules.mjs +9 -2
- package/shared/teammates.d.mts +19 -0
- package/shared/teammates.mjs +756 -0
- package/shared/terminal-focus.d.mts +1 -0
- package/shared/terminal-focus.mjs +13 -0
- package/shared/thread-cards.mjs +4 -1
- package/shared/usage.d.mts +9 -0
- package/shared/usage.mjs +49 -3
- package/shared/websites.d.mts +15 -0
- package/shared/websites.mjs +61 -0
- package/shared/work-items.mjs +127 -30
- package/shared/work-lines.mjs +92 -244
- package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
- package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
- package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
package/main/agents.mjs
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { hookFailure } from '../shared/hook-failure.mjs';
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// Everything here is READ-ONLY except `reply`, which writes one line into a
|
|
7
|
-
// socket a live session is already listening on. Agentbox writes nothing to disk
|
|
8
|
-
// outside its own store, and in particular it does not touch
|
|
9
|
-
// ~/.claude/settings.json. That was the one thing the build was told to ask
|
|
10
|
-
// before doing, and it turned out not to be needed: see the note on `reply`.
|
|
2
|
+
// Reads the Claude Code sessions running on this machine, and replies to one.
|
|
3
|
+
// What the readings mean is decided in shared/agents.mjs; this file only
|
|
4
|
+
// measures and delivers. Everything here is read-only except `reply`, which
|
|
5
|
+
// writes one line into the socket a live session already listens on.
|
|
11
6
|
|
|
12
7
|
import fs from 'node:fs';
|
|
13
8
|
import net from 'node:net';
|
|
@@ -17,25 +12,21 @@ import readline from 'node:readline';
|
|
|
17
12
|
import { execFile } from 'node:child_process';
|
|
18
13
|
import { promisify } from 'node:util';
|
|
19
14
|
import { NAME, Name, WAS } from '../shared/product-name.mjs';
|
|
15
|
+
import { findTranscript, transcriptSearch } from './transcript-search.mjs';
|
|
20
16
|
import {
|
|
21
17
|
WORK_CHARS, agentKey, changedFile, commandWork, fullSubject, isBookkeeping, saidCount, sameInstant, threadWindow, workSubject, workVerb,
|
|
22
18
|
} from '../shared/agents.mjs';
|
|
23
19
|
|
|
24
20
|
const run = promisify(execFile);
|
|
25
21
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// the transcript reader be driven over a temporary home in a test, which is the
|
|
29
|
-
// only way the SHAPE of Claude Code's own jsonl gets checked at all.
|
|
22
|
+
// Resolved on every call rather than at import, so a test can point the
|
|
23
|
+
// transcript reader at a temporary home.
|
|
30
24
|
const SESSIONS_DIR = () => path.join(os.homedir(), '.claude', 'sessions');
|
|
31
25
|
const PROJECTS_DIR = () => path.join(os.homedir(), '.claude', 'projects');
|
|
32
26
|
|
|
33
|
-
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// dedupe key for the app's own workers), and when each one started (the guard
|
|
37
|
-
// against pid reuse). Sixteen sessions on her machine at the time of writing,
|
|
38
|
-
// so sixteen separate `ps` calls per snapshot poll is not a thing to do.
|
|
27
|
+
// One `ps` answers three questions: which pids are alive, who started each one
|
|
28
|
+
// (the dedupe key for the app's own workers), and when each one started (the
|
|
29
|
+
// guard against pid reuse).
|
|
39
30
|
async function processTable() {
|
|
40
31
|
const table = new Map();
|
|
41
32
|
let out = '';
|
|
@@ -53,9 +44,8 @@ async function processTable() {
|
|
|
53
44
|
return table;
|
|
54
45
|
}
|
|
55
46
|
|
|
56
|
-
// Is the process at that pid still the session the record describes? The rule
|
|
57
|
-
// and the timezone trap behind it are in shared/agents.mjs
|
|
58
|
-
// getting it wrong showed her three of her sixteen live agents.
|
|
47
|
+
// Is the process at that pid still the session the record describes? The rule,
|
|
48
|
+
// and the timezone trap behind it, are in `sameInstant` in shared/agents.mjs.
|
|
59
49
|
function stillTheSame(rec, proc) {
|
|
60
50
|
if (!proc) return false;
|
|
61
51
|
if (!/claude/i.test(proc.command)) return false;
|
|
@@ -63,12 +53,9 @@ function stillTheSame(rec, proc) {
|
|
|
63
53
|
return sameInstant(rec.procStart, proc.started);
|
|
64
54
|
}
|
|
65
55
|
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// while 16 were live with sockets bound; the per-session state files listed
|
|
70
|
-
// those 16 and none of the 3. They disagree about which sessions they know, and
|
|
71
|
-
// they agree about the shape of a session, so the honest read is both.
|
|
56
|
+
// Two sources, unioned. `claude agents --json` and the per-session state files
|
|
57
|
+
// each list sessions the other misses, and both describe a session in the same
|
|
58
|
+
// shape.
|
|
72
59
|
function fromStateFiles() {
|
|
73
60
|
const found = [];
|
|
74
61
|
let names = [];
|
|
@@ -88,8 +75,8 @@ async function fromCli(claudeBin) {
|
|
|
88
75
|
try {
|
|
89
76
|
const { stdout } = await run(claudeBin, ['agents', '--json'], {
|
|
90
77
|
cwd: os.tmpdir(), encoding: 'utf8', maxBuffer: 8 << 20, timeout: 8000,
|
|
91
|
-
//
|
|
92
|
-
//
|
|
78
|
+
// So the CLI does not think it is a child of this session, and never
|
|
79
|
+
// inherits an API key.
|
|
93
80
|
env: scrubbed(process.env),
|
|
94
81
|
});
|
|
95
82
|
const parsed = JSON.parse(stdout);
|
|
@@ -97,11 +84,8 @@ async function fromCli(claudeBin) {
|
|
|
97
84
|
} catch { return []; }
|
|
98
85
|
}
|
|
99
86
|
|
|
100
|
-
// The same scrub the supervisor does before every spawn
|
|
101
|
-
//
|
|
102
|
-
// subscription. `claude agents --json` does not talk to the API, but the rule
|
|
103
|
-
// in CLAUDE.md is about never handing one to the CLI at all, not about which
|
|
104
|
-
// subcommand it happens to be.
|
|
87
|
+
// The same scrub the supervisor does before every spawn. `claude agents --json`
|
|
88
|
+
// does not call the API, but no claude invocation gets an inherited key.
|
|
105
89
|
function scrubbed(env) {
|
|
106
90
|
const out = { ...env };
|
|
107
91
|
for (const key of Object.keys(out)) {
|
|
@@ -110,103 +94,36 @@ function scrubbed(env) {
|
|
|
110
94
|
return out;
|
|
111
95
|
}
|
|
112
96
|
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
// The transcript is `~/.claude/projects/<slug>/<sessionId>.jsonl` and it can be
|
|
120
|
-
// megabytes, so only the tail is read, and only when the file has moved since
|
|
121
|
-
// last time. Without the cache this runs on every snapshot poll.
|
|
122
|
-
//
|
|
123
|
-
// AND WHAT THE SESSION IS ABOUT, which is the half of this that was missing.
|
|
124
|
-
//
|
|
125
|
-
// Claude Code already writes three facts into the same transcript this function
|
|
126
|
-
// is already reading, and they were simply being skipped:
|
|
127
|
-
//
|
|
128
|
-
// ai-title a one-line title the session wrote for ITSELF. Every session
|
|
129
|
-
// measured had one inside the 96KB tail, and it is usually the
|
|
130
|
-
// sentence a person would use to recall the session.
|
|
131
|
-
// last-prompt the last thing the user typed into that session.
|
|
132
|
-
// tool_use the files it has been editing, reading and writing.
|
|
133
|
-
//
|
|
134
|
-
// So the reading is one pass over a tail that was already being read, and the
|
|
135
|
-
// card goes from three lines about a pid to what the work actually is.
|
|
136
|
-
//
|
|
137
|
-
// WHAT WAS MISSING WAS THE BEGINNING. The human side of a conversation fits on
|
|
138
|
-
// a card. The agent's side is what is megabytes.
|
|
139
|
-
//
|
|
140
|
-
// AND THE TAIL CANNOT SEE IT. The opening turn lands between 11KB and 614KB in
|
|
141
|
-
// (pasted images are single transcript lines of hundreds of KB), and 96KB
|
|
142
|
-
// off the end reaches none of it. Measured: a 1MB head plus a 512KB tail still
|
|
143
|
-
// saw only 46% of the human turns.
|
|
144
|
-
//
|
|
145
|
-
// So the whole file is read, ONCE, and then only what has been appended since.
|
|
146
|
-
// Measured: the first pass over 15 transcripts, 117MB, costs 353ms in total
|
|
147
|
-
// and never happens again for that session; after it, a refresh reads only the
|
|
148
|
-
// bytes that arrived. This is the same read the tail was already doing, moved
|
|
149
|
-
// from "the last 96KB" to "everything, once". No model sees any of it, by
|
|
150
|
-
// design.
|
|
97
|
+
// The transcript, `~/.claude/projects/<slug>/<sessionId>.jsonl`, says when the
|
|
98
|
+
// session last did anything (not when its process started), its `ai-title`,
|
|
99
|
+
// the `last-prompt`, the files its tool calls touched, and the human turns.
|
|
100
|
+
// The opening human turn can sit hundreds of KB in, behind pasted images, so
|
|
101
|
+
// the whole file is read once and after that only the bytes appended since.
|
|
102
|
+
// No model sees any of it.
|
|
151
103
|
const CHUNK_BYTES = 4 << 20;
|
|
152
104
|
const historyCache = new Map(); // sessionId -> { file, size, mtimeMs, offset, ...facts }
|
|
153
|
-
const dirCache = new Map(); // sessionId -> transcript path
|
|
154
105
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// the card will print and reading more of them is free, so the cap is applied
|
|
158
|
-
// where it is shown rather than here.
|
|
106
|
+
// Tools whose file paths count as touched. The card prints at most six, and
|
|
107
|
+
// that cap is applied where they are shown, not here.
|
|
159
108
|
const TOUCH_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Read', 'MultiEdit']);
|
|
160
109
|
|
|
161
|
-
|
|
162
|
-
return String(cwd || '').replace(/[^a-zA-Z0-9]/g, '-');
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function transcriptPath(sessionId, cwd) {
|
|
166
|
-
const cached = dirCache.get(sessionId);
|
|
167
|
-
if (cached && fs.existsSync(cached)) return cached;
|
|
168
|
-
const direct = path.join(PROJECTS_DIR(), slugFor(cwd), `${sessionId}.jsonl`);
|
|
169
|
-
if (fs.existsSync(direct)) { dirCache.set(sessionId, direct); return direct; }
|
|
170
|
-
// The slug rule is Claude Code's, not ours, so a miss falls back to looking.
|
|
171
|
-
let dirs = [];
|
|
172
|
-
try { dirs = fs.readdirSync(PROJECTS_DIR()); } catch { return null; }
|
|
173
|
-
for (const dir of dirs) {
|
|
174
|
-
const p = path.join(PROJECTS_DIR(), dir, `${sessionId}.jsonl`);
|
|
175
|
-
if (fs.existsSync(p)) { dirCache.set(sessionId, p); return p; }
|
|
176
|
-
}
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
110
|
+
const transcriptPath = (sessionId, cwd) => findTranscript(PROJECTS_DIR(), sessionId, cwd);
|
|
179
111
|
|
|
180
112
|
const NOTHING = { lastActiveAt: 0, lastSaid: '', saidAt: 0, saidOpen: false, about: '', lastAsked: '', touched: [], asked: [] };
|
|
181
113
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// rarely anywhere near 6KB.
|
|
114
|
+
// The card quotes the end of the last reply, so this bounds the accumulator,
|
|
115
|
+
// not what the user reads.
|
|
185
116
|
const SAID_CHARS = 8000;
|
|
186
117
|
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
// So a turn is hers only when it is a `user` line that is not flagged meta, is
|
|
194
|
-
// not a compaction summary, carries no tool result, and whose text does not
|
|
195
|
-
// open with a tag. Checked against 155 real human turns.
|
|
196
|
-
//
|
|
197
|
-
// THE CARD AND THE CONVERSATION READ THE SAME LINE DIFFERENTLY, which is why
|
|
198
|
-
// this is two functions and not one. A card quotes a sentence, so it flattens
|
|
199
|
-
// her paragraphs to a single line; the conversation shows what she actually
|
|
200
|
-
// typed, so it keeps them. Splitting it here means the FILTER — the part that
|
|
201
|
-
// decides what was written by a human at all — has one copy, and a transcript
|
|
202
|
-
// change breaks both at once instead of only the one nobody is looking at.
|
|
118
|
+
// Claude Code writes tool results, system reminders and expanded slash
|
|
119
|
+
// commands as `user` lines too. A turn counts as typed by a person only when it
|
|
120
|
+
// is a `user` line that is not meta, not a compaction summary, carries no tool
|
|
121
|
+
// result, and does not open with a tag. `herTurnRaw` keeps paragraphs for the
|
|
122
|
+
// conversation and `herTurn` flattens them for the card, so the filter has one
|
|
123
|
+
// copy.
|
|
203
124
|
const RELAY_MARK = `Relayed from ${NAME},`;
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
// marker matched on the current name alone stops matching the day the name
|
|
207
|
-
// changes, and the app's own preamble starts reading as something the user typed.
|
|
208
|
-
// The letter case is ignored as well: the name was written lowercase until
|
|
209
|
-
// 2026-10-02, and those transcripts say "Relayed from agentbox,".
|
|
125
|
+
// The marker is recognised under every name this app has had, in any letter
|
|
126
|
+
// case, because a transcript outlives a rename.
|
|
210
127
|
const RELAY_MARKERS = [RELAY_MARK, ...WAS.map((was) => `Relayed from ${was},`)].map((m) => m.toLowerCase());
|
|
211
128
|
const wasRelayed = (line) => RELAY_MARKERS.some((m) => line.toLowerCase().startsWith(m));
|
|
212
129
|
export { wasRelayed as __wasRelayed };
|
|
@@ -221,11 +138,9 @@ function herTurnRaw(obj) {
|
|
|
221
138
|
: '';
|
|
222
139
|
const clean = String(text).trim();
|
|
223
140
|
const flat = clean.replace(/\s+/g, ' ');
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
// instead, the card would quote Agentbox talking to an agent back at the user
|
|
228
|
-
// as their own words. `spoke` already holds what the user actually typed.
|
|
141
|
+
// Also drop the app's own preamble from `fromTheApp`. Claude Code stamps it
|
|
142
|
+
// `isMeta` today, but if a later version records it as a plain user turn the
|
|
143
|
+
// card would quote it as the user's words.
|
|
229
144
|
if (!flat || flat.startsWith('<') || flat.startsWith('[Request interrupted') || wasRelayed(flat)) return '';
|
|
230
145
|
return clean;
|
|
231
146
|
}
|
|
@@ -234,29 +149,16 @@ function herTurn(obj) {
|
|
|
234
149
|
return herTurnRaw(obj).replace(/\s+/g, ' ').trim();
|
|
235
150
|
}
|
|
236
151
|
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
// delivery notice comes back as neither, and `herTurnRaw` drops all three on
|
|
242
|
-
// purpose. Dropping them from the READING was right; dropping them from the
|
|
243
|
-
// COUNTING is what folded two days of separate answers into one bubble.
|
|
244
|
-
//
|
|
245
|
-
// MEASURED on session-46: its whole conversation came out as three turns, and
|
|
246
|
-
// the last one was stamped 2:48pm today while opening with a sentence it had
|
|
247
|
-
// said at 7:47pm two days earlier. She read that as the answer to what she had
|
|
248
|
-
// just asked. It was not.
|
|
249
|
-
//
|
|
250
|
-
// A tool result is also a `user` line and is the one thing that does NOT end a
|
|
251
|
-
// reply: it is the reply, still going.
|
|
152
|
+
// Any inbound `user` line ends the reply before it, including the ones
|
|
153
|
+
// `herTurnRaw` drops on purpose: messages Agentbox delivered, peer messages and
|
|
154
|
+
// delivery notices. A tool result is the exception, because it is part of the
|
|
155
|
+
// reply that is still going.
|
|
252
156
|
function arrived(obj) {
|
|
253
157
|
return obj?.type === 'user' && !obj.toolUseResult;
|
|
254
158
|
}
|
|
255
159
|
|
|
256
|
-
//
|
|
257
|
-
// ask; after
|
|
258
|
-
// Her longest session had 29 turns, so this loses nothing on today's machine
|
|
259
|
-
// and cannot grow without bound on a session that runs for a month.
|
|
160
|
+
// Human turns kept per session. The first is kept for good because it is the
|
|
161
|
+
// ask; after it, only the most recent.
|
|
260
162
|
const KEEP_TURNS = 24;
|
|
261
163
|
|
|
262
164
|
function remember(acc, line) {
|
|
@@ -266,42 +168,30 @@ function remember(acc, line) {
|
|
|
266
168
|
const ts = Date.parse(obj.timestamp);
|
|
267
169
|
if (Number.isFinite(ts)) acc.lastActiveAt = Math.max(acc.lastActiveAt, ts);
|
|
268
170
|
}
|
|
269
|
-
//
|
|
270
|
-
// LAST one in the file is the current one.
|
|
171
|
+
// Claude Code rewrites the title as the work changes, so the last one wins.
|
|
271
172
|
if (obj.type === 'ai-title' && obj.aiTitle) acc.about = String(obj.aiTitle);
|
|
272
|
-
// The last thing a HUMAN typed into it. Claude Code records it as its own
|
|
273
|
-
// line, so this needs no walking back through tool results to find the one
|
|
274
|
-
// user turn that was not a tool answering a tool.
|
|
275
173
|
if (obj.type === 'last-prompt' && obj.lastPrompt) acc.lastAsked = String(obj.lastPrompt);
|
|
276
174
|
const mine = herTurn(obj);
|
|
277
175
|
if (mine) {
|
|
278
176
|
acc.asked.push({ at: Date.parse(obj.timestamp ?? 0) || 0, text: mine });
|
|
279
|
-
// The first
|
|
177
|
+
// The first turn never leaves; the rest is a window on the end.
|
|
280
178
|
if (acc.asked.length > KEEP_TURNS) acc.asked.splice(1, 1);
|
|
281
179
|
}
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
// keep whichever block came last — routinely a "Done." under four paragraphs
|
|
285
|
-
// that said what was actually done. The blocks of one reply are joined, and
|
|
286
|
-
// the next thing to ARRIVE starts a new one.
|
|
180
|
+
// Claude Code writes one answer as a new line each time it stops for a tool,
|
|
181
|
+
// so the blocks of one reply are joined until the next thing arrives.
|
|
287
182
|
if (arrived(obj)) acc.saidOpen = false;
|
|
288
183
|
if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
|
|
289
184
|
const said = obj.message.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n').trim();
|
|
290
185
|
if (said) {
|
|
291
186
|
const open = acc.saidOpen;
|
|
292
187
|
acc.lastSaid = open ? `${acc.lastSaid}\n\n${said}` : said;
|
|
293
|
-
//
|
|
294
|
-
//
|
|
188
|
+
// A long reply keeps its end, because that is where the answer is. See
|
|
189
|
+
// `howItEnded` in shared/agents.mjs.
|
|
295
190
|
if (acc.lastSaid.length > SAID_CHARS) acc.lastSaid = acc.lastSaid.slice(-SAID_CHARS);
|
|
296
191
|
acc.saidOpen = true;
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
// WHEN IT BEGAN, not when it stopped, and the card's heading is why. A
|
|
302
|
-
// reply that STARTED after her message is an answer to it; one that
|
|
303
|
-
// started before and only finished after is not, and stamping the end
|
|
304
|
-
// would draw it as one.
|
|
192
|
+
// Stamped when the reply began, not when it ended. A reply that started
|
|
193
|
+
// before the user's message is not an answer to it, even if it finished
|
|
194
|
+
// after.
|
|
305
195
|
const at = Date.parse(obj.timestamp ?? 0) || 0;
|
|
306
196
|
if (at && !open) acc.saidAt = at;
|
|
307
197
|
}
|
|
@@ -310,8 +200,6 @@ function remember(acc, line) {
|
|
|
310
200
|
const p = c.input?.file_path ?? c.input?.notebook_path;
|
|
311
201
|
if (!p) continue;
|
|
312
202
|
const leaf = path.basename(String(p));
|
|
313
|
-
// Newest wins its place: a file touched again moves to the end rather
|
|
314
|
-
// than keeping the position it had an hour ago.
|
|
315
203
|
const at = acc.touched.indexOf(leaf);
|
|
316
204
|
if (at >= 0) acc.touched.splice(at, 1);
|
|
317
205
|
acc.touched.push(leaf);
|
|
@@ -320,10 +208,9 @@ function remember(acc, line) {
|
|
|
320
208
|
}
|
|
321
209
|
}
|
|
322
210
|
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
// left behind rather than parsed, and the next pass picks it up entire.
|
|
211
|
+
// Folds every whole line in [from, size) into the accumulator and returns the
|
|
212
|
+
// offset the next read starts at. The transcript is appended to while this
|
|
213
|
+
// runs, so a trailing half line is left for the next pass.
|
|
327
214
|
function fold(file, acc, from, size) {
|
|
328
215
|
let at = from;
|
|
329
216
|
let rest = '';
|
|
@@ -346,8 +233,6 @@ function fold(file, acc, from, size) {
|
|
|
346
233
|
}
|
|
347
234
|
} catch { /* a partly-read transcript is still worth what it gave us */ }
|
|
348
235
|
finally { try { fs.closeSync(fd); } catch { /* nothing to do */ } }
|
|
349
|
-
// Whatever is after the last newline has not been counted, so the next read
|
|
350
|
-
// starts there and sees that line whole.
|
|
351
236
|
return at - Buffer.byteLength(rest);
|
|
352
237
|
}
|
|
353
238
|
|
|
@@ -361,20 +246,17 @@ function readHistory(sessionId, cwd) {
|
|
|
361
246
|
return { ...hit, transcript: file };
|
|
362
247
|
}
|
|
363
248
|
|
|
364
|
-
// Carry on from
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
// case starts over rather than folding two sessions into one card.
|
|
249
|
+
// Carry on from the last read unless the file shrank. A smaller file was
|
|
250
|
+
// rotated or rewritten, and the old offset points into a different
|
|
251
|
+
// conversation.
|
|
368
252
|
const resume = hit && hit.file === file && stat.size >= hit.size;
|
|
369
253
|
const acc = resume
|
|
370
254
|
? {
|
|
371
255
|
lastActiveAt: hit.lastActiveAt,
|
|
372
256
|
lastSaid: hit.lastSaid,
|
|
373
257
|
saidAt: hit.saidAt ?? 0,
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
// still being written, so the reply stays open across the seam and the
|
|
377
|
-
// next block joins it rather than replacing it.
|
|
258
|
+
// The last pass may have stopped mid-reply, so the reply stays open
|
|
259
|
+
// across the seam.
|
|
378
260
|
saidOpen: hit.saidOpen ?? false,
|
|
379
261
|
about: hit.about,
|
|
380
262
|
lastAsked: hit.lastAsked,
|
|
@@ -389,54 +271,32 @@ function readHistory(sessionId, cwd) {
|
|
|
389
271
|
return { ...entry, transcript: file };
|
|
390
272
|
}
|
|
391
273
|
|
|
392
|
-
// Exported for
|
|
393
|
-
//
|
|
394
|
-
// card that quietly goes back to saying nothing, and only a test against real
|
|
395
|
-
// jsonl notices (tests/one-inbox-no-agents-tab.test.mjs).
|
|
274
|
+
// Exported for tests/one-inbox-no-agents-tab.test.mjs. The fields it reads are
|
|
275
|
+
// Claude Code's, so an upstream rename would blank the card without an error.
|
|
396
276
|
export { readHistory as __readTail, readHistory as __readHistory };
|
|
277
|
+
export const __transcriptSearch = transcriptSearch;
|
|
397
278
|
|
|
398
|
-
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
// costs 215ms, so reading this synchronously on every refetch would freeze the
|
|
403
|
-
// window for a quarter of a second at a time, every couple of seconds, forever.
|
|
404
|
-
//
|
|
405
|
-
// So the read is asynchronous and `listAgents` is a pure lookup that returns
|
|
406
|
-
// whatever was last measured and quietly asks for a fresh one when it is stale.
|
|
407
|
-
// The first call after boot returns nothing and the one a moment later is
|
|
408
|
-
// right, which is exactly the shape of every other live fact in this app.
|
|
279
|
+
// The list rides the snapshot, which the main process answers, and a `ps` plus
|
|
280
|
+
// `claude agents --json` take a quarter of a second. So `listAgents` returns the
|
|
281
|
+
// last reading and starts a fresh one in the background when it is stale. The
|
|
282
|
+
// first call after boot returns nothing.
|
|
409
283
|
let cache = { at: 0, agents: [] };
|
|
410
284
|
let inFlight = null;
|
|
411
|
-
//
|
|
412
|
-
// the
|
|
413
|
-
//
|
|
414
|
-
// on 2026-08-16 came back "That agent is no longer running" while it was
|
|
415
|
-
// sitting there listening on its socket. Anything visible through only one of
|
|
416
|
-
// the two sources was unreplyable.
|
|
285
|
+
// Remembered so a later `refresh()` with no options measures the same way.
|
|
286
|
+
// Without the binary path it skips `claude agents --json`, and a session only
|
|
287
|
+
// that source sees could not be replied to.
|
|
417
288
|
let lastOpts = {};
|
|
418
289
|
const TTL_MS = 4000;
|
|
419
290
|
|
|
420
|
-
// Called when the
|
|
421
|
-
//
|
|
422
|
-
// re-render the window for a `lastActiveAt` that ticked.
|
|
291
|
+
// Called only when the list changes, so a ticking `lastActiveAt` does not
|
|
292
|
+
// re-render the window.
|
|
423
293
|
export let onChange = null;
|
|
424
294
|
export function setOnChange(fn) { onChange = fn; }
|
|
425
295
|
|
|
426
296
|
function fingerprint(agents) {
|
|
427
|
-
// `
|
|
428
|
-
// a
|
|
429
|
-
//
|
|
430
|
-
// a session that went busy and back to idle between two readings shows the
|
|
431
|
-
// same `status` both times, and without this the row the user replied to would sit
|
|
432
|
-
// in In progress until some other push happened to come. AND WHEN IT LAST
|
|
433
|
-
// SAID SOMETHING, which is the fact this list existed to notice and did
|
|
434
|
-
// not.An agent that answers her while she is looking at its card moves
|
|
435
|
-
// `saidAt` and nothing else — the status was idle before the reply and is
|
|
436
|
-
// idle after it, and on a quiet machine the two readings either side can both
|
|
437
|
-
// catch it idle — so the row went on showing the answer to the message
|
|
438
|
-
// before. This is not the tick `lastActiveAt` is: it moves only when the
|
|
439
|
-
// session actually says something new.
|
|
297
|
+
// `lastActiveAt` is left out because it ticks. `statusAt` and `saidAt` are in,
|
|
298
|
+
// because a session can go busy and back to idle, or answer, between two
|
|
299
|
+
// readings that both see it idle, and the row still has to move.
|
|
440
300
|
return agents.map((a) => `${a.pid}:${a.status ?? ''}:${a.statusAt ?? 0}:${a.waitingFor ?? ''}:${a.saidAt ?? 0}`).sort().join('|');
|
|
441
301
|
}
|
|
442
302
|
|
|
@@ -448,8 +308,8 @@ export function listAgents(opts = {}) {
|
|
|
448
308
|
export async function refresh(opts) {
|
|
449
309
|
if (opts) lastOpts = opts;
|
|
450
310
|
const { zeroPid = process.pid, sessionIds = [], products = [], claudeBin = null } = opts ?? lastOpts;
|
|
451
|
-
// One measurement at a time
|
|
452
|
-
// agents --json`
|
|
311
|
+
// One measurement at a time, so a burst of pushes does not queue a
|
|
312
|
+
// `claude agents --json` each.
|
|
453
313
|
if (inFlight) return inFlight;
|
|
454
314
|
inFlight = measure({ zeroPid, sessionIds, products, claudeBin })
|
|
455
315
|
.then((agents) => {
|
|
@@ -459,8 +319,8 @@ export async function refresh(opts) {
|
|
|
459
319
|
return agents;
|
|
460
320
|
})
|
|
461
321
|
.catch(() => {
|
|
462
|
-
//
|
|
463
|
-
//
|
|
322
|
+
// A failed `ps` keeps the last good reading. The stamp moves so the
|
|
323
|
+
// next call retries.
|
|
464
324
|
cache = { ...cache, at: Date.now() };
|
|
465
325
|
return cache.agents;
|
|
466
326
|
})
|
|
@@ -473,7 +333,6 @@ async function measure({ zeroPid, sessionIds, products, claudeBin }) {
|
|
|
473
333
|
const zeroSessionIds = new Set(sessionIds.filter(Boolean));
|
|
474
334
|
|
|
475
335
|
// A product owns an agent when the agent is working in that product's repo.
|
|
476
|
-
// all 13 outside agents were in folders no the app product points at.
|
|
477
336
|
const owners = new Map();
|
|
478
337
|
for (const p of products) {
|
|
479
338
|
if (p?.repoPath) owners.set(path.resolve(p.repoPath), p);
|
|
@@ -509,25 +368,16 @@ async function measure({ zeroPid, sessionIds, products, claudeBin }) {
|
|
|
509
368
|
cwd: rec.cwd ?? '',
|
|
510
369
|
startedAt: rec.startedAt ?? 0,
|
|
511
370
|
status: rec.status ?? null,
|
|
512
|
-
//
|
|
513
|
-
// stop
|
|
514
|
-
//
|
|
515
|
-
// 2.1.234: the status is one of busy, shell, idle, waiting, and
|
|
516
|
-
// `statusUpdatedAt` is stamped whenever it moves). Without it a reply
|
|
517
|
-
// could never leave In progress, because the session is still idle for
|
|
518
|
-
// the moment or two before it picks the message up.
|
|
371
|
+
// Claude Code stamps `statusUpdatedAt` whenever `status` moves. It tells a
|
|
372
|
+
// stop before the user's reply from one after, while the session is still
|
|
373
|
+
// idle for a moment before it picks the message up.
|
|
519
374
|
statusAt: Number(rec.statusUpdatedAt) || 0,
|
|
520
375
|
waitingFor: rec.waitingFor ?? null,
|
|
521
376
|
socket: rec.messagingSocketPath ?? defaultSocket(rec.pid),
|
|
522
377
|
lastActiveAt: lastActiveAt || rec.startedAt || 0,
|
|
523
378
|
lastSaid,
|
|
524
|
-
//
|
|
525
|
-
// to her newest message from a sentence it left there two days ago.
|
|
526
|
-
// Without it every reply reads as "It stopped here" and nothing more, so
|
|
527
|
-
// six pings produced six cards she could not tell apart.
|
|
379
|
+
// Tells an answer to the newest message from an older one.
|
|
528
380
|
saidAt,
|
|
529
|
-
// What the session is about, what she last told it, and what it has been
|
|
530
|
-
// touching. All three come free with the read above.
|
|
531
381
|
about,
|
|
532
382
|
lastAsked,
|
|
533
383
|
touched,
|
|
@@ -536,7 +386,7 @@ async function measure({ zeroPid, sessionIds, products, claudeBin }) {
|
|
|
536
386
|
transcript,
|
|
537
387
|
product: owner?.slug ?? null,
|
|
538
388
|
productName: owner?.name ?? null,
|
|
539
|
-
// The dedupe key
|
|
389
|
+
// The dedupe key for the app's own workers.
|
|
540
390
|
startedByZero: rec.ppid === zeroPid || (!!rec.sessionId && zeroSessionIds.has(rec.sessionId)),
|
|
541
391
|
});
|
|
542
392
|
}
|
|
@@ -548,52 +398,9 @@ function defaultSocket(pid) {
|
|
|
548
398
|
return path.join(base, 'cc-socks', `${pid}.sock`);
|
|
549
399
|
}
|
|
550
400
|
|
|
551
|
-
|
|
552
|
-
//
|
|
553
|
-
//
|
|
554
|
-
// ("[uds-messaging] Inject messages { echo '{"type":"auth",...}'; echo
|
|
555
|
-
// '{"type":"user","message":{"role":"user","content":"hello"}}'; } | socat").
|
|
556
|
-
// An auth line is required only when the session published a key file; when it
|
|
557
|
-
// did, the token is in ~/.claude/sessions/<pid>.<hash>.key.
|
|
558
|
-
//
|
|
559
|
-
// MEASURED, NOT REASONED, 2026-08-16: sent into a throwaway session spawned in
|
|
560
|
-
// /tmp, the message arrived, was read as her turn, and the agent went back to
|
|
561
|
-
// work. It was NOT held for approval, and NO line was added to
|
|
562
|
-
// ~/.claude/settings.json. The `crossSessionInbound: "hold"` gate the research
|
|
563
|
-
// found applies to messages that carry a peer identity and assert a permission
|
|
564
|
-
// mode; a plain injected user turn does not, so the settings line this build
|
|
565
|
-
// was told to ask about is not needed and was never written.
|
|
566
|
-
//
|
|
567
|
-
// TWO DIFFERENT FACTS, AND NEITHER IS OVERSTATED. The socket takes the message
|
|
568
|
-
// and does not acknowledge it, so what `reply` can honestly report has two
|
|
569
|
-
// levels and it reports whichever one it actually has:
|
|
570
|
-
//
|
|
571
|
-
// ok, no movement seen The message was handed to a live process that was
|
|
572
|
-
// listening. That IS a fact and it is what "Sent"
|
|
573
|
-
// means everywhere else in this app.
|
|
574
|
-
// ok, movement seen The session was then observed going to work, which
|
|
575
|
-
// is strictly better news and says so.
|
|
576
|
-
//
|
|
577
|
-
// A failed connect is neither, and says so loudly. What is never done is the
|
|
578
|
-
// third thing: a green toast over a message that went nowhere. That is the failure
|
|
579
|
-
// this codebase cares about most.
|
|
580
|
-
//
|
|
581
|
-
// The movement check is BEST EFFORT and is honestly not proven on a real
|
|
582
|
-
// waiting agent: no throwaway session could be made to publish the state file
|
|
583
|
-
// that carries `status`, so it was only ever exercised against sessions that
|
|
584
|
-
// publish neither that nor a transcript. It is written to be a bonus, never a
|
|
585
|
-
// gate, precisely because of that.
|
|
586
|
-
// WHO IS ASKING, WHICH THE SESSION HAS NO OTHER WAY TO KNOW.
|
|
587
|
-
//
|
|
588
|
-
// Nothing here claims her authority.
|
|
589
|
-
//
|
|
590
|
-
// What it does say is the part the wrapper cannot: WHERE the message came from
|
|
591
|
-
// and WHAT it is about. MEASURED on a session that had given no response
|
|
592
|
-
// without it; one sentence of context is the whole of the difference.
|
|
593
|
-
//
|
|
594
|
-
// THE USER'S OWN TEXT IS UNCHANGED AND IS WHAT THE CARD QUOTES: `spoke` records
|
|
595
|
-
// what the user typed, never this, so nothing the user never wrote is ever drawn
|
|
596
|
-
// as their words.
|
|
401
|
+
// Tells the session where the message came from and what it is about, which it
|
|
402
|
+
// has no other way to know; a session given the bare message did not answer.
|
|
403
|
+
// `spoke` records only what the user typed, so the card never quotes this.
|
|
597
404
|
export function fromTheApp(message) {
|
|
598
405
|
return [
|
|
599
406
|
`${RELAY_MARK} the inbox your user reads. A person typed this, not an agent,`,
|
|
@@ -606,6 +413,15 @@ export function fromTheApp(message) {
|
|
|
606
413
|
].join('\n');
|
|
607
414
|
}
|
|
608
415
|
|
|
416
|
+
// The one write. A Claude Code session listens on a Unix socket for
|
|
417
|
+
// newline-delimited JSON, in the shape the CLI prints when it binds one. An
|
|
418
|
+
// auth line is needed only when the session published a key file,
|
|
419
|
+
// ~/.claude/sessions/<pid>.<hash>.key. A plain injected user turn needs no
|
|
420
|
+
// change to ~/.claude/settings.json.
|
|
421
|
+
//
|
|
422
|
+
// The socket does not acknowledge, so a result of ok means the message was
|
|
423
|
+
// handed to a live, listening process, and `working` says the session was then
|
|
424
|
+
// seen going to work. The movement check is best effort and never a gate.
|
|
609
425
|
export async function reply(pid, text, { timeoutMs = 3000 } = {}) {
|
|
610
426
|
const message = String(text ?? '').trim();
|
|
611
427
|
if (!message) return { ok: false, reason: 'There was nothing to send.' };
|
|
@@ -618,9 +434,9 @@ export async function reply(pid, text, { timeoutMs = 3000 } = {}) {
|
|
|
618
434
|
|
|
619
435
|
const before = await watermark(agent);
|
|
620
436
|
try {
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
437
|
+
// The wire carries the wrapped message. Matching and recording below use
|
|
438
|
+
// the user's own words, so `heldNotice` does not match on the shared
|
|
439
|
+
// preamble.
|
|
624
440
|
await writeToSocket(agent.socket, fromTheApp(message), tokenFor(pid));
|
|
625
441
|
} catch (err) {
|
|
626
442
|
return { ok: false, reason: reasonFor(err) };
|
|
@@ -628,23 +444,13 @@ export async function reply(pid, text, { timeoutMs = 3000 } = {}) {
|
|
|
628
444
|
|
|
629
445
|
const moved = await waitForMovement(agent, before, timeoutMs);
|
|
630
446
|
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
// and
|
|
638
|
-
// burned CPU drawing the warning. That is the green toast over a message that
|
|
639
|
-
// went nowhere this file says it will never write, so it is checked for.
|
|
640
|
-
//
|
|
641
|
-
// It is BEST EFFORT in one direction only: a session with no transcript
|
|
642
|
-
// cannot be seen holding anything, so a hold that is not found is not a
|
|
643
|
-
// promise there was none. A hold that IS found is a fact and outranks
|
|
644
|
-
// everything above it.
|
|
645
|
-
// Looked for a few times rather than once: `waitForMovement` returns the
|
|
646
|
-
// instant it sees CPU, which on a held message is the session drawing the
|
|
647
|
-
// warning, so the line saying so is often not on disk yet.
|
|
447
|
+
// Claude Code holds an incoming socket message for its own user to approve
|
|
448
|
+
// when the session bypasses permission prompts and the sender did not attest
|
|
449
|
+
// a matching permission mode, which Agentbox cannot do. The held session
|
|
450
|
+
// still burns CPU drawing the warning, so it looks like movement. A hold that
|
|
451
|
+
// is found outranks everything above; one that is not found proves nothing.
|
|
452
|
+
// Looked for a few times, because `waitForMovement` returns at the first CPU
|
|
453
|
+
// and the hold line may not be on disk yet.
|
|
648
454
|
let held = null;
|
|
649
455
|
for (let i = 0; i < 6 && !held; i += 1) {
|
|
650
456
|
held = heldNotice(agent, before.size, message);
|
|
@@ -655,10 +461,8 @@ export async function reply(pid, text, { timeoutMs = 3000 } = {}) {
|
|
|
655
461
|
return { ok: false, held: true, reason: held };
|
|
656
462
|
}
|
|
657
463
|
cache = { ...cache, at: 0 }; // the agent has moved; the next look re-measures
|
|
658
|
-
// The
|
|
659
|
-
//
|
|
660
|
-
// caller re-deriving it from a pid would be using the one thing that is not an
|
|
661
|
-
// identity (`agentKey`, shared/agents.mjs).
|
|
464
|
+
// The key is worked out here, where the agent record is, because a pid alone
|
|
465
|
+
// is not an identity (`agentKey`, shared/agents.mjs).
|
|
662
466
|
return { ok: true, delivered: true, working: moved, name: agent.name, key: agentKey(agent), at: Date.now() };
|
|
663
467
|
}
|
|
664
468
|
|
|
@@ -681,8 +485,8 @@ function writeToSocket(socketPath, message, token) {
|
|
|
681
485
|
sock.on('connect', () => {
|
|
682
486
|
if (token) sock.write(`${JSON.stringify({ type: 'auth', token })}\n`);
|
|
683
487
|
sock.write(`${JSON.stringify({ type: 'user', message: { role: 'user', content: message } })}\n`, () => {
|
|
684
|
-
// Half-close
|
|
685
|
-
//
|
|
488
|
+
// Half-close, because an abrupt destroy has been seen to drop the final
|
|
489
|
+
// frame.
|
|
686
490
|
sock.end();
|
|
687
491
|
resolve();
|
|
688
492
|
});
|
|
@@ -698,18 +502,10 @@ function reasonFor(err) {
|
|
|
698
502
|
return 'The message could not be handed over.';
|
|
699
503
|
}
|
|
700
504
|
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
// burning CPU, which is true of every session that took a turn and needs
|
|
706
|
-
// nothing published anywhere.
|
|
707
|
-
//
|
|
708
|
-
// The CPU signal was added after the probe on 2026-08-16: a throwaway session
|
|
709
|
-
// with no transcript and no state file took the message, answered it on screen,
|
|
710
|
-
// and `reply` still had to report that it could not see it move. `ps -o time`
|
|
711
|
-
// resolves to a hundredth of a second and an idle session drifts about 0.02s
|
|
712
|
-
// in three, so a third of a second of new CPU is a session that went to work.
|
|
505
|
+
// Three signs of movement, because none is always there: the transcript grows,
|
|
506
|
+
// the state file's status changes, or the process burns CPU. CPU needs nothing
|
|
507
|
+
// published. `ps -o time` resolves to a hundredth of a second and an idle
|
|
508
|
+
// session drifts about 0.02 s in three, so this is well clear of idle.
|
|
713
509
|
const CPU_MOVED = 0.15;
|
|
714
510
|
|
|
715
511
|
async function cpuSeconds(pid) {
|
|
@@ -751,16 +547,14 @@ async function waitForMovement(agent, before, timeoutMs) {
|
|
|
751
547
|
return false;
|
|
752
548
|
}
|
|
753
549
|
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
// transcript on her machine is 160MB and this runs on a click.
|
|
550
|
+
// A held session writes a `system` line that opens "Held peer message" and
|
|
551
|
+
// quotes the message back. Only bytes appended since the watermark are read,
|
|
552
|
+
// because a transcript can be over 100 MB and this runs on a click.
|
|
758
553
|
const HOLD_LINE = /Held peer message/;
|
|
759
554
|
|
|
760
555
|
function heldNotice(agent, sinceSize, message) {
|
|
761
|
-
// Resolved
|
|
762
|
-
//
|
|
763
|
-
// message, and that is exactly the session this is for.
|
|
556
|
+
// Resolved again, because a session may have written its first transcript
|
|
557
|
+
// since it was last measured.
|
|
764
558
|
const file = agent.transcript
|
|
765
559
|
?? (agent.sessionId ? transcriptPath(agent.sessionId, agent.cwd ?? '') : null);
|
|
766
560
|
if (!file) return null;
|
|
@@ -784,15 +578,12 @@ function heldNotice(agent, sinceSize, message) {
|
|
|
784
578
|
const content = String(obj?.content ?? '');
|
|
785
579
|
if (!HOLD_LINE.test(content)) continue;
|
|
786
580
|
if (opening && !content.includes(opening.slice(0, 40))) continue;
|
|
787
|
-
// Said in plain words, not the CLI's. What the user can DO about it is the point:
|
|
788
|
-
// the message is sitting in that session's own window waiting for a yes.
|
|
789
581
|
return `${agent.name} held your message for approval in its own window, so it has not read it yet.`;
|
|
790
582
|
}
|
|
791
583
|
return null;
|
|
792
584
|
}
|
|
793
585
|
|
|
794
|
-
// Exported for
|
|
795
|
-
// part of the surface: what `reply` returns is.
|
|
586
|
+
// Exported for tests/a-held-message-is-not-delivered.test.mjs.
|
|
796
587
|
export const __heldNotice = heldNotice;
|
|
797
588
|
|
|
798
589
|
function readState(pid) {
|
|
@@ -801,17 +592,11 @@ function readState(pid) {
|
|
|
801
592
|
} catch { return null; }
|
|
802
593
|
}
|
|
803
594
|
|
|
804
|
-
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
// `attach` and no `logs`, whatever the earlier write-up said. What CAN be done
|
|
810
|
-
// is bring the app that owns the process to the front, so this walks the parent
|
|
811
|
-
// chain to the first real application and offers that by name. If it finds
|
|
812
|
-
// none, it says so rather than pretending.
|
|
813
|
-
export function owningApp(pid) {
|
|
814
|
-
const table = processTable();
|
|
595
|
+
// A reply cannot clear a permission box, so that agent needs the user at its
|
|
596
|
+
// own window. Claude Code has no command to focus a session, so this walks the
|
|
597
|
+
// parent chain to the first macOS .app and brings that to the front.
|
|
598
|
+
export async function owningApp(pid) {
|
|
599
|
+
const table = await processTable();
|
|
815
600
|
let cur = table.get(pid);
|
|
816
601
|
for (let hops = 0; cur && hops < 12; hops += 1) {
|
|
817
602
|
const m = cur.command.match(/^(\/(?:Applications|System\/Applications)\/[^/]*?\.app)\//);
|
|
@@ -821,8 +606,8 @@ export function owningApp(pid) {
|
|
|
821
606
|
return null;
|
|
822
607
|
}
|
|
823
608
|
|
|
824
|
-
export function reveal(pid) {
|
|
825
|
-
const app = owningApp(pid);
|
|
609
|
+
export async function reveal(pid) {
|
|
610
|
+
const app = await owningApp(pid);
|
|
826
611
|
if (!app) return { ok: false, reason: `${Name} cannot tell which window it is running in.` };
|
|
827
612
|
return new Promise((resolve) => {
|
|
828
613
|
execFile('/usr/bin/open', ['-a', app.path], (err) => {
|
|
@@ -831,29 +616,16 @@ export function reveal(pid) {
|
|
|
831
616
|
});
|
|
832
617
|
}
|
|
833
618
|
|
|
834
|
-
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
// pressed something.
|
|
838
|
-
//
|
|
839
|
-
// IT IS STREAMED, AND THAT IS NOT A FLOURISH. The biggest transcript on her
|
|
840
|
-
// machine is 160MB and costs 729ms to pull the readable text out of (measured
|
|
841
|
-
// 2026-08-17 over all 1,250 files). Doing that with readFileSync on the main
|
|
842
|
-
// process is the window frozen for three quarters of a second on a click, on
|
|
843
|
-
// the one surface built to make her trust the app. readline hands the lines
|
|
844
|
-
// back a chunk at a time and the app draws in between.
|
|
845
|
-
//
|
|
846
|
-
// NO MODEL SEES ANY OF IT, which is a condition of the whole feature, and
|
|
847
|
-
// nothing is written anywhere: this opens a file the user already has and reads it.
|
|
619
|
+
// The conversation: what both sides said, read only when the user asks for it.
|
|
620
|
+
// It is streamed because a transcript can be over 100 MB, and reading that
|
|
621
|
+
// whole on the main process freezes the window. No model sees any of it.
|
|
848
622
|
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
// less than a session can hold.
|
|
623
|
+
// A turn is capped here rather than where it is drawn, so a pasted 600 KB blob
|
|
624
|
+
// never reaches React.
|
|
852
625
|
const TURN_CHARS = 4000;
|
|
853
626
|
|
|
854
|
-
// A session id
|
|
855
|
-
//
|
|
856
|
-
// outside ~/.claude/projects and there is no reason to leave that open.
|
|
627
|
+
// A session id becomes a filename, so it is checked first: a slash in it would
|
|
628
|
+
// read outside ~/.claude/projects.
|
|
857
629
|
function safeSessionId(id) {
|
|
858
630
|
return /^[A-Za-z0-9._-]+$/.test(String(id ?? '')) ? String(id) : null;
|
|
859
631
|
}
|
|
@@ -863,45 +635,30 @@ function saidByIt(obj) {
|
|
|
863
635
|
return obj.message.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n').trim();
|
|
864
636
|
}
|
|
865
637
|
|
|
866
|
-
|
|
867
|
-
//
|
|
868
|
-
// user line that answers it, keyed by the tool's id. So the events are built
|
|
869
|
-
// forwards and their output is filled in behind them, which is why `byId` is
|
|
870
|
-
// held across the read.
|
|
871
|
-
|
|
872
|
-
// Output keeps its HEAD, unlike a turn, which keeps its tail. An answer puts
|
|
873
|
-
// its conclusion at the end; a command puts the thing that went wrong at the
|
|
874
|
-
// start, and `ls` of a big folder is alphabetical. The count of what was cut
|
|
875
|
-
// goes on the line so nothing is rounded off in silence.
|
|
638
|
+
// Tool output keeps its head, unlike a turn, which keeps its tail: a command
|
|
639
|
+
// puts what went wrong at the start.
|
|
876
640
|
function cappedHead(text) {
|
|
877
641
|
const s = String(text ?? '');
|
|
878
642
|
return s.length > WORK_CHARS ? `${s.slice(0, WORK_CHARS - 1)}…` : s;
|
|
879
643
|
}
|
|
880
644
|
|
|
881
|
-
// `dir`
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
// `full` IS THE WHOLE STRING, and it only travels when shortening actually
|
|
886
|
-
// changed something. Her standing objection to every cap in this codebase is
|
|
887
|
-
// one that rounds off without saying so, and the fix for a line she cannot read
|
|
888
|
-
// cannot be a line she cannot recover.
|
|
645
|
+
// `dir` is the session's folder, used to shorten paths on screen. `full`
|
|
646
|
+
// carries the whole string only when shortening changed it, so a shortened line
|
|
647
|
+
// can always be recovered.
|
|
889
648
|
function workedOn(obj, events, byId, dir = '') {
|
|
890
649
|
if (obj.type !== 'assistant' || !Array.isArray(obj.message?.content)) return;
|
|
891
650
|
const at = Date.parse(obj.timestamp ?? 0) || 0;
|
|
892
651
|
for (const c of obj.message.content) {
|
|
893
652
|
if (c.type !== 'tool_use') continue;
|
|
894
|
-
//
|
|
895
|
-
// did, and a thread that opens on them reads as noise (w-0bd0d8b2ef).
|
|
653
|
+
// Store tool calls are how a session talks to the app, not work it did.
|
|
896
654
|
if (isBookkeeping(c.name)) continue;
|
|
897
|
-
//
|
|
898
|
-
//
|
|
655
|
+
// A command in plain words when the whole of it is recognised; otherwise
|
|
656
|
+
// null, and the line shows the command.
|
|
899
657
|
const plain = c.name === 'Bash' ? commandWork(c.input?.command, dir, os.homedir()) : null;
|
|
900
658
|
const subject = plain ? plain.subject : workSubject(c.input, dir, os.homedir());
|
|
901
659
|
const whole = fullSubject(c.input);
|
|
902
|
-
//
|
|
903
|
-
//
|
|
904
|
-
// a command, a search, a read.
|
|
660
|
+
// The file it changed, so the thread can offer a chip into the code. Empty
|
|
661
|
+
// for calls that wrote no file.
|
|
905
662
|
const changed = changedFile(c.name, c.input);
|
|
906
663
|
const event = {
|
|
907
664
|
kind: 'work',
|
|
@@ -909,9 +666,8 @@ function workedOn(obj, events, byId, dir = '') {
|
|
|
909
666
|
verb: plain ? plain.verb : workVerb(c.name),
|
|
910
667
|
subject,
|
|
911
668
|
...(changed ? { file: changed } : {}),
|
|
912
|
-
// A line put in words always carries the command,
|
|
913
|
-
//
|
|
914
|
-
// on the screen, so it has to stay one press behind them.
|
|
669
|
+
// A line put in words always carries the command, because the words
|
|
670
|
+
// replaced the only copy of it on screen.
|
|
915
671
|
full: plain || (whole && whole.replace(/\s+/g, ' ').trim() !== subject) ? cappedHead(whole) : '',
|
|
916
672
|
output: '',
|
|
917
673
|
lines: 0,
|
|
@@ -936,41 +692,20 @@ function cameBack(obj, byId) {
|
|
|
936
692
|
const all = String(text ?? '').replace(/\s+$/, '');
|
|
937
693
|
event.output = cappedHead(all);
|
|
938
694
|
event.lines = all ? all.split('\n').length : 0;
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
// error, because "0 errors" is not a failure and guessing at one on her
|
|
942
|
-
// screen is worse than saying nothing.
|
|
695
|
+
// The tool's own verdict. The output is never searched for "error",
|
|
696
|
+
// because "0 errors" is not a failure.
|
|
943
697
|
event.failed = b.is_error === true;
|
|
944
698
|
}
|
|
945
699
|
}
|
|
946
700
|
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
// through, so a single reply arrives here as three. Folding them keeps the
|
|
950
|
-
// count honest: "40 messages" should mean forty things somebody said, not forty
|
|
951
|
-
// rows in a file.
|
|
952
|
-
//
|
|
953
|
-
// UNTIL SOMETHING ARRIVES. Folding on the SIDE alone folded every answer a
|
|
954
|
-
// session ever gave into one bubble, because the things that end an answer —
|
|
955
|
-
// her message from Agentbox, a peer's, a delivery notice — are all dropped from
|
|
956
|
-
// the reading and so could not end anything. `seal` is that missing edge, and
|
|
957
|
-
// `arrived` above is what calls it.
|
|
958
|
-
//
|
|
959
|
-
// A TURN IS STAMPED WHEN IT BEGAN, not when it ended, and that is the other
|
|
960
|
-
// half of the same bug. The old code moved the stamp forward on every fold
|
|
961
|
-
// while the text she reads is the head, so session-46's last bubble said 2:48pm
|
|
962
|
-
// today over a sentence from 7:47pm two days earlier and she took it for the
|
|
963
|
-
// answer to what she had just asked. The time and the first line she sees now
|
|
964
|
-
// come from the same moment, and cannot drift apart again. The ellipsis is
|
|
965
|
-
// INSIDE the cap, not on top of it: this is a bound on what the screen is
|
|
966
|
-
// handed, and a bound that can be exceeded by one is not one.
|
|
701
|
+
// The ellipsis is inside the cap, so the result is never longer than
|
|
702
|
+
// TURN_CHARS.
|
|
967
703
|
function capped(text) {
|
|
968
704
|
return text.length > TURN_CHARS ? `…${text.slice(-(TURN_CHARS - 1))}` : text;
|
|
969
705
|
}
|
|
970
706
|
|
|
971
|
-
// The last thing anybody
|
|
972
|
-
//
|
|
973
|
-
// routinely a command rather than a sentence.
|
|
707
|
+
// The last thing anybody said, skipping the work lines that sit between
|
|
708
|
+
// messages in the thread.
|
|
974
709
|
function lastSpoken(turns) {
|
|
975
710
|
for (let i = turns.length - 1; i >= 0; i -= 1) {
|
|
976
711
|
if (turns[i].kind !== 'work') return turns[i];
|
|
@@ -981,44 +716,34 @@ function lastSpoken(turns) {
|
|
|
981
716
|
function seal(turns) {
|
|
982
717
|
const last = turns[turns.length - 1];
|
|
983
718
|
if (last) last.sealed = true;
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
// call and the next sentence would seal the tool call, leave the sentence
|
|
987
|
-
// above it open, and the two halves of two different answers would be counted
|
|
988
|
-
// as one message.
|
|
719
|
+
// Also seal the last spoken turn, which is not the last entry once work lines
|
|
720
|
+
// are in the thread. Otherwise two different answers would count as one.
|
|
989
721
|
const spoken = lastSpoken(turns);
|
|
990
722
|
if (spoken) spoken.sealed = true;
|
|
991
723
|
}
|
|
992
724
|
|
|
725
|
+
// Consecutive lines from the same side are one turn, because Claude Code splits
|
|
726
|
+
// one answer into several lines when it stops for a tool. A sealed turn takes
|
|
727
|
+
// nothing more, so separate answers never fold together. A turn keeps the time
|
|
728
|
+
// it began, so its time and its first line come from the same moment.
|
|
993
729
|
function addTurn(turns, who, text, at) {
|
|
994
730
|
const last = turns[turns.length - 1];
|
|
995
731
|
if (last && !last.sealed && last.who === who) {
|
|
996
|
-
//
|
|
997
|
-
// front, so the longer an answer ran the more surely its conclusion was the
|
|
998
|
-
// part thrown away. Measured on session-46: a bubble sat at exactly 4000
|
|
999
|
-
// characters with everything after the first 4000 gone.
|
|
732
|
+
// Overflow drops the oldest text, so a long answer keeps its conclusion.
|
|
1000
733
|
last.text = capped(`${last.text}\n\n${text}`);
|
|
1001
734
|
return;
|
|
1002
735
|
}
|
|
1003
|
-
// A
|
|
1004
|
-
//
|
|
1005
|
-
// is what happened. But it is still one reply: the count says so, and the
|
|
1006
|
-
// screen does not put a second name and time over the second half of a
|
|
1007
|
-
// sentence somebody was in the middle of.
|
|
736
|
+
// A continuation, not a new message: work between two blocks of one reply
|
|
737
|
+
// splits them on screen, but the count and the heading treat them as one.
|
|
1008
738
|
const spoken = lastSpoken(turns);
|
|
1009
739
|
const same = !!spoken && !spoken.sealed && spoken.who === who && turns[turns.length - 1] !== spoken;
|
|
1010
740
|
turns.push(same ? { who, at, text: capped(text), same: true } : { who, at, text: capped(text) });
|
|
1011
741
|
}
|
|
1012
742
|
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
// pid is only walked back to a live session when the caller has no session id,
|
|
1018
|
-
// which is the case a dead session leaves behind. `whole` is the gap line in
|
|
1019
|
-
// the thread being pressed: she is asking for the middle of the conversation
|
|
1020
|
-
// back, so every message is sent and `omitted` comes back zero. Only the work
|
|
1021
|
-
// stays capped.
|
|
743
|
+
// Uses the session id the row already has, because `refresh` runs `ps` over
|
|
744
|
+
// the whole machine and that is a visible delay on a click. The pid is walked
|
|
745
|
+
// back to a live session only when there is no session id. `whole` asks for
|
|
746
|
+
// every message, so `omitted` comes back zero; only the work stays capped.
|
|
1022
747
|
export async function conversation({ pid, sessionId, cwd, spoke = [], whole = false } = {}) {
|
|
1023
748
|
let id = safeSessionId(sessionId);
|
|
1024
749
|
let dir = cwd ?? '';
|
|
@@ -1035,9 +760,8 @@ export async function conversation({ pid, sessionId, cwd, spoke = [], whole = fa
|
|
|
1035
760
|
if (!file) return { ok: false, reason: 'This session has not written a transcript yet.' };
|
|
1036
761
|
|
|
1037
762
|
const turns = [];
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
// place the output exists.
|
|
763
|
+
// Tool calls by id. A call is in an assistant line and its output arrives
|
|
764
|
+
// later in a user line, so the events are filled in behind.
|
|
1041
765
|
const byId = new Map();
|
|
1042
766
|
try {
|
|
1043
767
|
const lines = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
|
@@ -1046,9 +770,7 @@ export async function conversation({ pid, sessionId, cwd, spoke = [], whole = fa
|
|
|
1046
770
|
let obj;
|
|
1047
771
|
try { obj = JSON.parse(line); } catch { continue; }
|
|
1048
772
|
const at = Date.parse(obj.timestamp ?? 0) || 0;
|
|
1049
|
-
// Anything inbound
|
|
1050
|
-
// shown: a message Agentbox delivered, a peer's, a delivery notice. None of
|
|
1051
|
-
// those are quoted here and all of them mean the answer above is over.
|
|
773
|
+
// Anything inbound ends the answer above it, shown or not.
|
|
1052
774
|
if (arrived(obj)) seal(turns);
|
|
1053
775
|
cameBack(obj, byId);
|
|
1054
776
|
const hook = hookFailure(obj);
|
|
@@ -1062,23 +784,16 @@ export async function conversation({ pid, sessionId, cwd, spoke = [], whole = fa
|
|
|
1062
784
|
if (mine) { addTurn(turns, 'you', mine, at); seal(turns); continue; }
|
|
1063
785
|
const its = saidByIt(obj);
|
|
1064
786
|
if (its) addTurn(turns, 'it', its, at);
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
// messages, so a reply that stopped twice for a tool is three things on
|
|
1068
|
-
// the screen in the order they occurred rather than one bubble with the
|
|
1069
|
-
// work swept behind it. This is the one place the fold is deliberately
|
|
1070
|
-
// broken, and breaking it is the shape that was chosen.
|
|
787
|
+
// Work goes in the thread in the order it happened, between messages.
|
|
788
|
+
// This is the one place the fold is broken on purpose.
|
|
1071
789
|
workedOn(obj, turns, byId, dir);
|
|
1072
790
|
}
|
|
1073
791
|
} catch {
|
|
1074
|
-
// A half-read transcript
|
|
1075
|
-
// missing its last line beats an empty screen and a shrug.
|
|
792
|
+
// A half-read transcript still shows what it gave.
|
|
1076
793
|
}
|
|
1077
794
|
|
|
1078
|
-
//
|
|
1079
|
-
// Agentbox
|
|
1080
|
-
// written at the moment of delivery. Slotted by time, so the thread still
|
|
1081
|
-
// reads in the order it happened.
|
|
795
|
+
// Replies sent from Agentbox are not in the file as the user's. The caller
|
|
796
|
+
// passes what Agentbox delivered, and each is slotted in by time.
|
|
1082
797
|
const said = new Set(turns.filter((t) => t.who === 'you').map((t) => t.text.replace(/\s+/g, ' ').trim()));
|
|
1083
798
|
for (const t of Array.isArray(spoke) ? spoke : []) {
|
|
1084
799
|
const text = String(t?.text ?? '').trim();
|
|
@@ -1089,13 +804,11 @@ export async function conversation({ pid, sessionId, cwd, spoke = [], whole = fa
|
|
|
1089
804
|
turns.splice(i, 0, { who: 'you', at, text: text.slice(0, TURN_CHARS) });
|
|
1090
805
|
}
|
|
1091
806
|
|
|
1092
|
-
//
|
|
1093
|
-
// it does not go over the wire pretending to be one.
|
|
807
|
+
// `sealed` is scaffolding for the fold, not a fact about a turn.
|
|
1094
808
|
for (const t of turns) delete t.sealed;
|
|
1095
809
|
|
|
1096
810
|
const { events: shown, omitted } = threadWindow(turns, { whole: !!whole });
|
|
1097
|
-
// `total` counts what somebody
|
|
1098
|
-
// reads "40 messages, oldest first", and a run of tool calls is not messages.
|
|
811
|
+
// `total` counts what somebody said; tool calls are not messages.
|
|
1099
812
|
const total = saidCount(turns);
|
|
1100
813
|
return { ok: true, name, turns: shown, omitted, total };
|
|
1101
814
|
}
|