agentbox-app 0.1.6
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/LICENSE +674 -0
- package/README.md +176 -0
- package/bin/agentbox.mjs +103 -0
- package/briefs/adhd-mode.md +5 -0
- package/briefs/message-rules.md +95 -0
- package/briefs/worker.md +230 -0
- package/briefs/writing-rules.md +336 -0
- package/main/account-discovery.mjs +145 -0
- package/main/account-tooling.mjs +261 -0
- package/main/agent-activity.mjs +56 -0
- package/main/agent-files.mjs +422 -0
- package/main/agent-schedule.mjs +254 -0
- package/main/agent-sessions.mjs +435 -0
- package/main/agents.mjs +1095 -0
- package/main/analytics.mjs +206 -0
- package/main/answer-modes.mjs +206 -0
- package/main/approval-prompt-server.mjs +160 -0
- package/main/approvals.mjs +487 -0
- package/main/artifact-path.mjs +144 -0
- package/main/claude-bin.mjs +311 -0
- package/main/claude-input.mjs +117 -0
- package/main/claude-models.mjs +244 -0
- package/main/claude-plan.mjs +148 -0
- package/main/claude-usage.mjs +149 -0
- package/main/code-change.mjs +383 -0
- package/main/code-file.mjs +116 -0
- package/main/codex-account.mjs +146 -0
- package/main/codex-app-server.mjs +796 -0
- package/main/codex-approvals.mjs +712 -0
- package/main/codex-bin.mjs +261 -0
- package/main/codex-compaction.mjs +49 -0
- package/main/codex-models.mjs +194 -0
- package/main/codex-session.mjs +797 -0
- package/main/codex-threads.mjs +268 -0
- package/main/codex-usage-file.mjs +192 -0
- package/main/codex-usage.mjs +149 -0
- package/main/codex-watch.mjs +35 -0
- package/main/codex.mjs +757 -0
- package/main/config.mjs +345 -0
- package/main/copy-selection.mjs +55 -0
- package/main/crash-report.mjs +158 -0
- package/main/demo.mjs +254 -0
- package/main/dev-window.mjs +103 -0
- package/main/doc-file.mjs +99 -0
- package/main/doc-scheme.mjs +126 -0
- package/main/first-run.mjs +190 -0
- package/main/folders.mjs +91 -0
- package/main/fresh-user.mjs +120 -0
- package/main/git-change.mjs +323 -0
- package/main/img-scheme.mjs +90 -0
- package/main/instruction-settings.mjs +205 -0
- package/main/ipc.mjs +1558 -0
- package/main/live-replies.mjs +77 -0
- package/main/machine.mjs +113 -0
- package/main/main.mjs +972 -0
- package/main/menu.mjs +177 -0
- package/main/notify.mjs +155 -0
- package/main/open-source-file.mjs +8 -0
- package/main/project-identity.mjs +149 -0
- package/main/provider-commands.mjs +32 -0
- package/main/rail-note.mjs +74 -0
- package/main/repeats.mjs +261 -0
- package/main/row-label.mjs +185 -0
- package/main/screen-detail.mjs +44 -0
- package/main/serve.mjs +262 -0
- package/main/settings.mjs +912 -0
- package/main/staleness.mjs +76 -0
- package/main/store/chats.mjs +188 -0
- package/main/store/home.mjs +383 -0
- package/main/store/migrate.mjs +40 -0
- package/main/store/paths.mjs +82 -0
- package/main/store/pending-writes.mjs +122 -0
- package/main/store/project-lock.mjs +200 -0
- package/main/store/project.mjs +523 -0
- package/main/store/queues.mjs +31 -0
- package/main/store/work-items.mjs +299 -0
- package/main/store/write-queue.mjs +14 -0
- package/main/store-modules.mjs +23 -0
- package/main/store.mjs +1269 -0
- package/main/supervisor.mjs +6875 -0
- package/main/task-commands.mjs +161 -0
- package/main/task-folders-offthread.mjs +84 -0
- package/main/task-folders-thread.mjs +13 -0
- package/main/task-folders.mjs +409 -0
- package/main/task-remote-control.mjs +65 -0
- package/main/task-terminals.mjs +169 -0
- package/main/terminal-access.mjs +6 -0
- package/main/updater.mjs +240 -0
- package/main/zoom-keys.mjs +54 -0
- package/main/zoom-readout.mjs +20 -0
- package/mcp/agentbox-mcp.sh +34 -0
- package/mcp/core/account.mjs +77 -0
- package/mcp/core/chat.mjs +61 -0
- package/mcp/core/documents.mjs +238 -0
- package/mcp/core/page.mjs +152 -0
- package/mcp/core/products.mjs +110 -0
- package/mcp/core/skills.mjs +126 -0
- package/mcp/core/work.mjs +377 -0
- package/mcp/stdio.mjs +70 -0
- package/mcp/tools.mjs +257 -0
- package/package.json +186 -0
- package/renderer/dist/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
- package/renderer/dist/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
- package/renderer/dist/assets/addon-fit-YJmn1quW.js +12 -0
- package/renderer/dist/assets/addon-serialize-CsFQTvP_.js +13 -0
- package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
- package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
- package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
- package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
- package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
- package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
- package/renderer/dist/assets/gouache-valley-ChSu14-t.webp +0 -0
- package/renderer/dist/assets/gouache-valley-d9qasI6k.webp +0 -0
- package/renderer/dist/assets/gouache-valley-sm-CdLjT5Ec.webp +0 -0
- package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
- package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
- package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
- package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
- package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
- package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
- package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
- package/renderer/dist/assets/index-CwiDv5bL.css +32 -0
- package/renderer/dist/assets/index-D9hCAVPl.js +684 -0
- package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
- package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
- package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
- package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
- package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
- package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
- package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
- package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
- package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
- package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
- package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
- package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
- package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
- package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
- package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
- package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
- package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
- package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
- package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
- package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
- package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
- package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
- package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
- package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
- package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
- package/renderer/dist/assets/source-sans-3-cyrillic-ext-wght-normal-DzyfIafT.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-cyrillic-wght-normal-BMDVbyM7.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-greek-ext-wght-normal-BWSLJLk6.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-greek-wght-normal-C9H9m1vD.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-latin-wght-normal-BqRLTx4X.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-vietnamese-wght-normal-C1uRvKPU.woff2 +0 -0
- package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
- package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
- package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
- package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
- package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
- package/renderer/dist/assets/xterm-BT7tBf8A.js +27 -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 +299 -0
- package/renderer/dist/emulator/index.html +28 -0
- package/renderer/dist/emulator/provenance.json +6 -0
- package/renderer/dist/fixtures/onboard/designs/w-n13/the-first-row.html +1 -0
- package/renderer/dist/index.html +13 -0
- package/scripts/after-install.mjs +59 -0
- package/scripts/approval-server.sh +43 -0
- package/scripts/before-publish.mjs +133 -0
- package/shared/agent-import.d.mts +13 -0
- package/shared/agent-import.mjs +252 -0
- package/shared/agents.d.mts +190 -0
- package/shared/agents.mjs +937 -0
- package/shared/analytics-events.mjs +63 -0
- package/shared/answers.d.mts +12 -0
- package/shared/answers.mjs +80 -0
- package/shared/artifact-keys.d.mts +28 -0
- package/shared/artifact-keys.mjs +140 -0
- package/shared/bridge-map.mjs +128 -0
- package/shared/claude-commands.d.mts +20 -0
- package/shared/claude-commands.generated.mjs +149 -0
- package/shared/claude-commands.mjs +131 -0
- package/shared/claude-models.generated.d.mts +15 -0
- package/shared/claude-models.generated.mjs +26 -0
- package/shared/claude-usage.d.mts +16 -0
- package/shared/claude-usage.mjs +172 -0
- package/shared/codex-commands.d.mts +4 -0
- package/shared/codex-commands.mjs +17 -0
- package/shared/codex-import.d.mts +38 -0
- package/shared/codex-import.mjs +232 -0
- package/shared/codex-modes.mjs +159 -0
- package/shared/codex-usage.mjs +180 -0
- package/shared/contracts.mjs +412 -0
- package/shared/crash-scrub.mjs +182 -0
- package/shared/dashboard.mjs +481 -0
- package/shared/dead-run-trace.mjs +127 -0
- package/shared/demo-world.mjs +415 -0
- package/shared/effort-levels.d.mts +15 -0
- package/shared/effort-levels.mjs +76 -0
- package/shared/engines.d.mts +62 -0
- package/shared/engines.mjs +389 -0
- package/shared/first-run-examples.mjs +60 -0
- package/shared/first-run-line.mjs +149 -0
- package/shared/first-run-practice.d.mts +32 -0
- package/shared/first-run-practice.mjs +254 -0
- package/shared/first-run-shape.mjs +100 -0
- package/shared/fresh-user-home.mjs +189 -0
- package/shared/hook-failure.mjs +22 -0
- package/shared/install-id.mjs +39 -0
- package/shared/instruction-defaults.json +5 -0
- package/shared/library-folder.mjs +28 -0
- package/shared/notify-rules.mjs +120 -0
- package/shared/product-name.d.mts +6 -0
- package/shared/product-name.mjs +144 -0
- package/shared/project-folder-check.d.mts +22 -0
- package/shared/project-folder-check.mjs +152 -0
- package/shared/provider-commands.d.mts +8 -0
- package/shared/provider-commands.mjs +58 -0
- package/shared/rank.d.mts +19 -0
- package/shared/rank.mjs +146 -0
- package/shared/recovery.mjs +26 -0
- package/shared/referenced-files.d.mts +11 -0
- package/shared/referenced-files.mjs +122 -0
- package/shared/repeats.d.mts +45 -0
- package/shared/repeats.mjs +193 -0
- package/shared/schemes.mjs +33 -0
- package/shared/settings-terminal.d.mts +1 -0
- package/shared/settings-terminal.mjs +14 -0
- package/shared/side-build.mjs +57 -0
- package/shared/spawn-trouble.d.mts +8 -0
- package/shared/spawn-trouble.mjs +599 -0
- package/shared/terminal-state.d.mts +1 -0
- package/shared/terminal-state.mjs +58 -0
- package/shared/usage.d.mts +53 -0
- package/shared/usage.mjs +319 -0
- package/shared/work-items.mjs +561 -0
- package/shared/work-lines.mjs +360 -0
- package/worker-permissions.json +6 -0
package/main/agents.mjs
ADDED
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
import { hookFailure } from '../shared/hook-failure.mjs';
|
|
2
|
+
// HER CLAUDE CODE AGENTS, READ OFF THE MACHINE — and the one way Agentbox speaks
|
|
3
|
+
// back to one. The rules about what any of it MEANS are in shared/agents.mjs,
|
|
4
|
+
// pure and pinned; this file only measures and delivers.
|
|
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`.
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import net from 'node:net';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import readline from 'node:readline';
|
|
17
|
+
import { execFile } from 'node:child_process';
|
|
18
|
+
import { promisify } from 'node:util';
|
|
19
|
+
import { NAME, Name, WAS } from '../shared/product-name.mjs';
|
|
20
|
+
import {
|
|
21
|
+
WORK_CHARS, agentKey, changedFile, fullSubject, saidCount, sameInstant, threadWindow, workSubject, workVerb,
|
|
22
|
+
} from '../shared/agents.mjs';
|
|
23
|
+
|
|
24
|
+
const run = promisify(execFile);
|
|
25
|
+
|
|
26
|
+
// Asked for each time rather than resolved once at import. It costs nothing —
|
|
27
|
+
// os.homedir is a syscall-free read of the environment — and it is what lets
|
|
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.
|
|
30
|
+
const SESSIONS_DIR = () => path.join(os.homedir(), '.claude', 'sessions');
|
|
31
|
+
const PROJECTS_DIR = () => path.join(os.homedir(), '.claude', 'projects');
|
|
32
|
+
|
|
33
|
+
/* ---------------------------- what is actually up ------------------------ */
|
|
34
|
+
// ONE `ps`, FOR EVERYTHING. It answers three questions at once and each of them
|
|
35
|
+
// costs a process otherwise: which pids are alive, who started each one (the
|
|
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.
|
|
39
|
+
async function processTable() {
|
|
40
|
+
const table = new Map();
|
|
41
|
+
let out = '';
|
|
42
|
+
try {
|
|
43
|
+
({ stdout: out } = await run('/bin/ps', ['-Ao', 'pid=,ppid=,lstart=,command='], {
|
|
44
|
+
encoding: 'utf8', maxBuffer: 16 << 20, timeout: 5000,
|
|
45
|
+
}));
|
|
46
|
+
} catch { return table; }
|
|
47
|
+
for (const line of out.split('\n')) {
|
|
48
|
+
// pid ppid <lstart: Www Mmm dd hh:mm:ss yyyy = 5 fields> command...
|
|
49
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+(.*)$/);
|
|
50
|
+
if (!m) continue;
|
|
51
|
+
table.set(Number(m[1]), { ppid: Number(m[2]), started: m[3].replace(/\s+/g, ' '), command: m[4] });
|
|
52
|
+
}
|
|
53
|
+
return table;
|
|
54
|
+
}
|
|
55
|
+
|
|
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, pure and pinned:
|
|
58
|
+
// getting it wrong showed her three of her sixteen live agents.
|
|
59
|
+
function stillTheSame(rec, proc) {
|
|
60
|
+
if (!proc) return false;
|
|
61
|
+
if (!/claude/i.test(proc.command)) return false;
|
|
62
|
+
if (!rec.procStart) return true;
|
|
63
|
+
return sameInstant(rec.procStart, proc.started);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ------------------------------- the sources ----------------------------- */
|
|
67
|
+
// TWO SOURCES, UNIONED, because on 2026-08-16 neither one alone saw her
|
|
68
|
+
// machine. `claude agents --json` (the public scripting flag) listed 3 sessions
|
|
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.
|
|
72
|
+
function fromStateFiles() {
|
|
73
|
+
const found = [];
|
|
74
|
+
let names = [];
|
|
75
|
+
try { names = fs.readdirSync(SESSIONS_DIR()); } catch { return found; }
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
if (!name.endsWith('.json')) continue;
|
|
78
|
+
try {
|
|
79
|
+
const rec = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR(), name), 'utf8'));
|
|
80
|
+
if (rec && rec.pid) found.push(rec);
|
|
81
|
+
} catch { /* a half-written state file is not a reason to show her nothing */ }
|
|
82
|
+
}
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function fromCli(claudeBin) {
|
|
87
|
+
if (!claudeBin) return [];
|
|
88
|
+
try {
|
|
89
|
+
const { stdout } = await run(claudeBin, ['agents', '--json'], {
|
|
90
|
+
cwd: os.tmpdir(), encoding: 'utf8', maxBuffer: 8 << 20, timeout: 8000,
|
|
91
|
+
// The CLI must not think it is a child of this session, and the app's own
|
|
92
|
+
// billing rule applies to every claude invocation it makes.
|
|
93
|
+
env: scrubbed(process.env),
|
|
94
|
+
});
|
|
95
|
+
const parsed = JSON.parse(stdout);
|
|
96
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
97
|
+
} catch { return []; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The same scrub the supervisor does before every spawn, for the same reason:
|
|
101
|
+
// an inherited ANTHROPIC_API_KEY silently redirects billing off her
|
|
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.
|
|
105
|
+
function scrubbed(env) {
|
|
106
|
+
const out = { ...env };
|
|
107
|
+
for (const key of Object.keys(out)) {
|
|
108
|
+
if (/^(ANTHROPIC_|CLAUDE_CODE_|CLAUDECODE$|CLAUDE_PID$|CLAUDE_EFFORT$)/.test(key)) delete out[key];
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ------------------------------ the transcript --------------------------- */
|
|
114
|
+
// WHEN IT LAST DID ANYTHING, which is a very different number from when its
|
|
115
|
+
// process started: her six forgotten agents were all opened within five seconds
|
|
116
|
+
// of each other on 12 August and the last thing anyone said in them was 29 and
|
|
117
|
+
// 30 July. Started-at would have called them minutes old.
|
|
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. Measured across
|
|
129
|
+
// her 14 outside sessions 2026-08-17: 14 of 14 had one, inside the
|
|
130
|
+
// 96KB tail. session-71's is "Agentbox product not displaying in
|
|
131
|
+
// the app", which is the exact sentence she could not remember.
|
|
132
|
+
// last-prompt the last thing SHE typed into that session. "okay no worries.
|
|
133
|
+
// just make sure that auth persists across sessions…"
|
|
134
|
+
// tool_use the files it has been editing, reading and writing.
|
|
135
|
+
//
|
|
136
|
+
// So the reading is one pass over a tail that was already being read, and the
|
|
137
|
+
// card goes from three lines about a pid to what the work actually is.
|
|
138
|
+
//
|
|
139
|
+
// WHAT WAS MISSING WAS THE BEGINNING. Measured over all 16 of her live sessions
|
|
140
|
+
// on 2026-08-17, reading every human turn in 117MB of transcript:
|
|
141
|
+
//
|
|
142
|
+
// The conversation she needs to recall fits on a card. The agent's side is what
|
|
143
|
+
// is megabytes.
|
|
144
|
+
//
|
|
145
|
+
// AND THE TAIL CANNOT SEE IT. The opening turn lands between 11KB and 614KB in
|
|
146
|
+
// (her pasted images are single transcript lines of hundreds of KB), and 96KB
|
|
147
|
+
// off the end reaches none of it. Measured: a 1MB head plus a 512KB tail still
|
|
148
|
+
// saw only 72 of her 155 turns — 46% — and one of the two it missed on
|
|
149
|
+
// session-71 was the one she is complaining about.
|
|
150
|
+
//
|
|
151
|
+
// So the whole file is read, ONCE, and then only what has been appended since.
|
|
152
|
+
// Measured on her machine: the first pass over all 15 transcripts, 117MB, costs
|
|
153
|
+
// 353ms in total and never happens again for that session; after it, a refresh
|
|
154
|
+
// reads only the bytes that arrived. This is the same read the tail was already
|
|
155
|
+
// doing, moved from "the last 96KB" to "everything, once" — no model sees any
|
|
156
|
+
// of it, which was her condition.
|
|
157
|
+
const CHUNK_BYTES = 4 << 20;
|
|
158
|
+
const historyCache = new Map(); // sessionId -> { file, size, mtimeMs, offset, ...facts }
|
|
159
|
+
const dirCache = new Map(); // sessionId -> transcript path
|
|
160
|
+
|
|
161
|
+
// The files it has had its hands on, newest last, deduplicated, and only the
|
|
162
|
+
// ones a person would recognise: the basename, never the path. Six is the most
|
|
163
|
+
// the card will print and reading more of them is free, so the cap is applied
|
|
164
|
+
// where it is shown rather than here.
|
|
165
|
+
const TOUCH_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'Read', 'MultiEdit']);
|
|
166
|
+
|
|
167
|
+
function slugFor(cwd) {
|
|
168
|
+
return String(cwd || '').replace(/[^a-zA-Z0-9]/g, '-');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function transcriptPath(sessionId, cwd) {
|
|
172
|
+
const cached = dirCache.get(sessionId);
|
|
173
|
+
if (cached && fs.existsSync(cached)) return cached;
|
|
174
|
+
const direct = path.join(PROJECTS_DIR(), slugFor(cwd), `${sessionId}.jsonl`);
|
|
175
|
+
if (fs.existsSync(direct)) { dirCache.set(sessionId, direct); return direct; }
|
|
176
|
+
// The slug rule is Claude Code's, not ours, so a miss falls back to looking.
|
|
177
|
+
let dirs = [];
|
|
178
|
+
try { dirs = fs.readdirSync(PROJECTS_DIR()); } catch { return null; }
|
|
179
|
+
for (const dir of dirs) {
|
|
180
|
+
const p = path.join(PROJECTS_DIR(), dir, `${sessionId}.jsonl`);
|
|
181
|
+
if (fs.existsSync(p)) { dirCache.set(sessionId, p); return p; }
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const NOTHING = { lastActiveAt: 0, lastSaid: '', saidAt: 0, saidOpen: false, about: '', lastAsked: '', touched: [], asked: [] };
|
|
187
|
+
|
|
188
|
+
// HOW MUCH OF THE LAST REPLY IS KEPT. The card quotes the end of it, so this is
|
|
189
|
+
// a bound on the accumulator and not on what she reads. Her longest single
|
|
190
|
+
// reply on this machine is under 6KB.
|
|
191
|
+
const SAID_CHARS = 8000;
|
|
192
|
+
|
|
193
|
+
// WHAT COUNTS AS SOMETHING SHE TYPED. A Claude Code transcript calls a great
|
|
194
|
+
// many things a `user` message that no human wrote: every tool result comes
|
|
195
|
+
// back as one, every system reminder is one, every slash command expands into
|
|
196
|
+
// one. Reading those onto the card would fill her recall with the machine
|
|
197
|
+
// talking to itself, which is worse than the blank it replaces.
|
|
198
|
+
//
|
|
199
|
+
// So a turn is hers only when it is a `user` line that is not flagged meta, is
|
|
200
|
+
// not a compaction summary, carries no tool result, and whose text does not
|
|
201
|
+
// open with a tag. Checked against all 155 real turns in her 16 sessions.
|
|
202
|
+
//
|
|
203
|
+
// THE CARD AND THE CONVERSATION READ THE SAME LINE DIFFERENTLY, which is why
|
|
204
|
+
// this is two functions and not one. A card quotes a sentence, so it flattens
|
|
205
|
+
// her paragraphs to a single line; the conversation shows what she actually
|
|
206
|
+
// typed, so it keeps them. Splitting it here means the FILTER — the part that
|
|
207
|
+
// decides what was written by a human at all — has one copy, and a transcript
|
|
208
|
+
// change breaks both at once instead of only the one nobody is looking at.
|
|
209
|
+
const RELAY_MARK = `Relayed from ${NAME},`;
|
|
210
|
+
// WRITTEN UNDER THE CURRENT NAME, RECOGNISED UNDER EVERY NAME THIS APP HAS
|
|
211
|
+
// HAD. A transcript is a file on her disk and it outlives a rename, so a
|
|
212
|
+
// marker matched on the current name alone stops matching the day the name
|
|
213
|
+
// changes, and the app's own preamble starts reading as something she typed.
|
|
214
|
+
const RELAY_MARKERS = [RELAY_MARK, ...WAS.map((was) => `Relayed from ${was},`)];
|
|
215
|
+
const wasRelayed = (line) => RELAY_MARKERS.some((m) => line.startsWith(m));
|
|
216
|
+
|
|
217
|
+
function herTurnRaw(obj) {
|
|
218
|
+
if (obj.type !== 'user' || obj.isMeta || obj.isCompactSummary || obj.toolUseResult) return '';
|
|
219
|
+
const content = obj.message?.content;
|
|
220
|
+
const text = typeof content === 'string'
|
|
221
|
+
? content
|
|
222
|
+
: Array.isArray(content)
|
|
223
|
+
? content.filter((c) => c.type === 'text').map((c) => c.text).join('\n')
|
|
224
|
+
: '';
|
|
225
|
+
const clean = String(text).trim();
|
|
226
|
+
const flat = clean.replace(/\s+/g, ' ');
|
|
227
|
+
// AND NEVER AGENTBOX'S OWN PREAMBLE. `fromTheApp` puts a sentence of context in
|
|
228
|
+
// front of what she typed, and today Claude Code stamps the result `isMeta`
|
|
229
|
+
// so this never sees it. If a future one records it as a plain user turn
|
|
230
|
+
// instead, the card would quote Agentbox talking to an agent back at her as her
|
|
231
|
+
// own words. `spoke` already holds what she actually typed.
|
|
232
|
+
if (!flat || flat.startsWith('<') || flat.startsWith('[Request interrupted') || wasRelayed(flat)) return '';
|
|
233
|
+
return clean;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function herTurn(obj) {
|
|
237
|
+
return herTurnRaw(obj).replace(/\s+/g, ' ').trim();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// SOMETHING ARRIVED, SO WHATEVER IT WAS SAYING IS FINISHED. An assistant does
|
|
241
|
+
// not speak twice without being spoken to, so the line that ends a reply is the
|
|
242
|
+
// next inbound one — and MOST OF THOSE ARE NOT QUOTED ANYWHERE. A message
|
|
243
|
+
// Agentbox delivers comes back as `isMeta`, a peer's comes back wrapped, a
|
|
244
|
+
// delivery notice comes back as neither, and `herTurnRaw` drops all three on
|
|
245
|
+
// purpose. Dropping them from the READING was right; dropping them from the
|
|
246
|
+
// COUNTING is what folded two days of separate answers into one bubble.
|
|
247
|
+
//
|
|
248
|
+
// MEASURED on session-46: its whole conversation came out as three turns, and
|
|
249
|
+
// the last one was stamped 2:48pm today while opening with a sentence it had
|
|
250
|
+
// said at 7:47pm two days earlier. She read that as the answer to what she had
|
|
251
|
+
// just asked. It was not.
|
|
252
|
+
//
|
|
253
|
+
// A tool result is also a `user` line and is the one thing that does NOT end a
|
|
254
|
+
// reply: it is the reply, still going.
|
|
255
|
+
function arrived(obj) {
|
|
256
|
+
return obj?.type === 'user' && !obj.toolUseResult;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// HOW MUCH OF HER SIDE IS KEPT. The opening is kept forever because it is the
|
|
260
|
+
// ask; after that the most recent are what tell her where the thread got to.
|
|
261
|
+
// Her longest session had 29 turns, so this loses nothing on today's machine
|
|
262
|
+
// and cannot grow without bound on a session that runs for a month.
|
|
263
|
+
const KEEP_TURNS = 24;
|
|
264
|
+
|
|
265
|
+
function remember(acc, line) {
|
|
266
|
+
let obj;
|
|
267
|
+
try { obj = JSON.parse(line); } catch { return; }
|
|
268
|
+
if (obj.timestamp) {
|
|
269
|
+
const ts = Date.parse(obj.timestamp);
|
|
270
|
+
if (Number.isFinite(ts)) acc.lastActiveAt = Math.max(acc.lastActiveAt, ts);
|
|
271
|
+
}
|
|
272
|
+
// The session's own one-line title, rewritten as the work changes, so the
|
|
273
|
+
// LAST one in the file is the current one.
|
|
274
|
+
if (obj.type === 'ai-title' && obj.aiTitle) acc.about = String(obj.aiTitle);
|
|
275
|
+
// The last thing a HUMAN typed into it. Claude Code records it as its own
|
|
276
|
+
// line, so this needs no walking back through tool results to find the one
|
|
277
|
+
// user turn that was not a tool answering a tool.
|
|
278
|
+
if (obj.type === 'last-prompt' && obj.lastPrompt) acc.lastAsked = String(obj.lastPrompt);
|
|
279
|
+
const mine = herTurn(obj);
|
|
280
|
+
if (mine) {
|
|
281
|
+
acc.asked.push({ at: Date.parse(obj.timestamp ?? 0) || 0, text: mine });
|
|
282
|
+
// The first one never leaves. Everything after it is a window on the end.
|
|
283
|
+
if (acc.asked.length > KEEP_TURNS) acc.asked.splice(1, 1);
|
|
284
|
+
}
|
|
285
|
+
// ONE REPLY IS SEVERAL LINES AND THE CARD WANTS THE REPLY. Claude Code writes
|
|
286
|
+
// an answer as a new line every time it stops to use a tool, so this used to
|
|
287
|
+
// keep whichever block came last — routinely a "Done." under four paragraphs
|
|
288
|
+
// that said what was actually done. The blocks of one reply are joined, and
|
|
289
|
+
// the next thing to ARRIVE starts a new one.
|
|
290
|
+
if (arrived(obj)) acc.saidOpen = false;
|
|
291
|
+
if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
|
|
292
|
+
const said = obj.message.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n').trim();
|
|
293
|
+
if (said) {
|
|
294
|
+
const open = acc.saidOpen;
|
|
295
|
+
acc.lastSaid = open ? `${acc.lastSaid}\n\n${said}` : said;
|
|
296
|
+
// The END is what is kept when a reply runs long, because the end is
|
|
297
|
+
// where an answer is. See `howItEnded` in shared/agents.mjs.
|
|
298
|
+
if (acc.lastSaid.length > SAID_CHARS) acc.lastSaid = acc.lastSaid.slice(-SAID_CHARS);
|
|
299
|
+
acc.saidOpen = true;
|
|
300
|
+
// WHEN IT SAID IT, which is the fact the card was missing entirely: six
|
|
301
|
+
// pings and six cards that looked the same, because the quote underneath
|
|
302
|
+
// them carried no time and could not be told from the last one.
|
|
303
|
+
//
|
|
304
|
+
// WHEN IT BEGAN, not when it stopped, and the card's heading is why. A
|
|
305
|
+
// reply that STARTED after her message is an answer to it; one that
|
|
306
|
+
// started before and only finished after is not, and stamping the end
|
|
307
|
+
// would draw it as one.
|
|
308
|
+
const at = Date.parse(obj.timestamp ?? 0) || 0;
|
|
309
|
+
if (at && !open) acc.saidAt = at;
|
|
310
|
+
}
|
|
311
|
+
for (const c of obj.message.content) {
|
|
312
|
+
if (c.type !== 'tool_use' || !TOUCH_TOOLS.has(c.name)) continue;
|
|
313
|
+
const p = c.input?.file_path ?? c.input?.notebook_path;
|
|
314
|
+
if (!p) continue;
|
|
315
|
+
const leaf = path.basename(String(p));
|
|
316
|
+
// Newest wins its place: a file touched again moves to the end rather
|
|
317
|
+
// than keeping the position it had an hour ago.
|
|
318
|
+
const at = acc.touched.indexOf(leaf);
|
|
319
|
+
if (at >= 0) acc.touched.splice(at, 1);
|
|
320
|
+
acc.touched.push(leaf);
|
|
321
|
+
if (acc.touched.length > 40) acc.touched.shift();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Read [from, size) and fold every WHOLE line into the accumulator, returning
|
|
327
|
+
// the offset the next read should start at. A transcript is appended to while
|
|
328
|
+
// this runs, so the last line in the buffer is routinely half a line; it is
|
|
329
|
+
// left behind rather than parsed, and the next pass picks it up entire.
|
|
330
|
+
function fold(file, acc, from, size) {
|
|
331
|
+
let at = from;
|
|
332
|
+
let rest = '';
|
|
333
|
+
let fd;
|
|
334
|
+
try { fd = fs.openSync(file, 'r'); } catch { return from; }
|
|
335
|
+
try {
|
|
336
|
+
while (at < size) {
|
|
337
|
+
const want = Math.min(CHUNK_BYTES, size - at);
|
|
338
|
+
const buf = Buffer.alloc(want);
|
|
339
|
+
const got = fs.readSync(fd, buf, 0, want, at);
|
|
340
|
+
if (got <= 0) break;
|
|
341
|
+
const text = rest + buf.slice(0, got).toString('utf8');
|
|
342
|
+
const cut = text.lastIndexOf('\n');
|
|
343
|
+
at += got;
|
|
344
|
+
if (cut < 0) { rest = text; continue; }
|
|
345
|
+
rest = text.slice(cut + 1);
|
|
346
|
+
for (const line of text.slice(0, cut).split('\n')) {
|
|
347
|
+
if (line.trim()) remember(acc, line);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} catch { /* a partly-read transcript is still worth what it gave us */ }
|
|
351
|
+
finally { try { fs.closeSync(fd); } catch { /* nothing to do */ } }
|
|
352
|
+
// Whatever is after the last newline has not been counted, so the next read
|
|
353
|
+
// starts there and sees that line whole.
|
|
354
|
+
return at - Buffer.byteLength(rest);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function readHistory(sessionId, cwd) {
|
|
358
|
+
const file = transcriptPath(sessionId, cwd);
|
|
359
|
+
if (!file) return { ...NOTHING, transcript: null };
|
|
360
|
+
let stat;
|
|
361
|
+
try { stat = fs.statSync(file); } catch { return { ...NOTHING, transcript: null }; }
|
|
362
|
+
const hit = historyCache.get(sessionId);
|
|
363
|
+
if (hit && hit.file === file && hit.size === stat.size && hit.mtimeMs === stat.mtimeMs) {
|
|
364
|
+
return { ...hit, transcript: file };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Carry on from where the last read stopped, unless the file is not the same
|
|
368
|
+
// file any more. A transcript that SHRANK was rotated or rewritten and the
|
|
369
|
+
// old cursor points into the middle of a different conversation, so that
|
|
370
|
+
// case starts over rather than folding two sessions into one card.
|
|
371
|
+
const resume = hit && hit.file === file && stat.size >= hit.size;
|
|
372
|
+
const acc = resume
|
|
373
|
+
? {
|
|
374
|
+
lastActiveAt: hit.lastActiveAt,
|
|
375
|
+
lastSaid: hit.lastSaid,
|
|
376
|
+
saidAt: hit.saidAt ?? 0,
|
|
377
|
+
// A RESUMED READ IS MID-REPLY UNTIL PROVEN OTHERWISE. The last line the
|
|
378
|
+
// previous pass folded may well have been one block of an answer that is
|
|
379
|
+
// still being written, so the reply stays open across the seam and the
|
|
380
|
+
// next block joins it rather than replacing it.
|
|
381
|
+
saidOpen: hit.saidOpen ?? false,
|
|
382
|
+
about: hit.about,
|
|
383
|
+
lastAsked: hit.lastAsked,
|
|
384
|
+
touched: [...hit.touched],
|
|
385
|
+
asked: [...hit.asked],
|
|
386
|
+
}
|
|
387
|
+
: { ...NOTHING, touched: [], asked: [] };
|
|
388
|
+
const offset = fold(file, acc, resume ? hit.offset : 0, stat.size);
|
|
389
|
+
|
|
390
|
+
const entry = { file, size: stat.size, mtimeMs: stat.mtimeMs, offset, ...acc };
|
|
391
|
+
historyCache.set(sessionId, entry);
|
|
392
|
+
return { ...entry, transcript: file };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Exported for the test that drives this against a real transcript's shape.
|
|
396
|
+
// The fields it reads are Claude Code's, not ours, so a rename upstream is a
|
|
397
|
+
// card that quietly goes back to saying nothing, and only a test against real
|
|
398
|
+
// jsonl notices (tests/one-inbox-no-agents-tab.test.mjs).
|
|
399
|
+
export { readHistory as __readTail, readHistory as __readHistory };
|
|
400
|
+
|
|
401
|
+
/* --------------------------------- the list ------------------------------ */
|
|
402
|
+
// NOTHING IN HERE MAY EVER BLOCK THE MAIN PROCESS. The list rides the snapshot,
|
|
403
|
+
// the snapshot is answered on the main thread, and the main thread is also the
|
|
404
|
+
// app: measured 2026-08-16, one `ps` costs 45ms and `claude agents --json`
|
|
405
|
+
// costs 215ms, so reading this synchronously on every refetch would freeze the
|
|
406
|
+
// window for a quarter of a second at a time, every couple of seconds, forever.
|
|
407
|
+
//
|
|
408
|
+
// So the read is asynchronous and `listAgents` is a pure lookup that returns
|
|
409
|
+
// whatever was last measured and quietly asks for a fresh one when it is stale.
|
|
410
|
+
// The first call after boot returns nothing and the one a moment later is
|
|
411
|
+
// right, which is exactly the shape of every other live fact in this app.
|
|
412
|
+
let cache = { at: 0, agents: [] };
|
|
413
|
+
let inFlight = null;
|
|
414
|
+
// HOW THE LIST WAS MEASURED LAST TIME, so that anything measuring again does it
|
|
415
|
+
// the same way. This is not tidiness: `reply` re-read the list with default
|
|
416
|
+
// options, which drops `claude agents --json` (no binary path), and the probe
|
|
417
|
+
// on 2026-08-16 came back "That agent is no longer running" while it was
|
|
418
|
+
// sitting there listening on its socket. Anything visible through only one of
|
|
419
|
+
// the two sources was unreplyable.
|
|
420
|
+
let lastOpts = {};
|
|
421
|
+
const TTL_MS = 4000;
|
|
422
|
+
|
|
423
|
+
// Called when the measured list actually CHANGES, not on every refresh: the
|
|
424
|
+
// renderer already refetches on a push, and pushing at the refresh rate would
|
|
425
|
+
// re-render the window for a `lastActiveAt` that ticked.
|
|
426
|
+
export let onChange = null;
|
|
427
|
+
export function setOnChange(fn) { onChange = fn; }
|
|
428
|
+
|
|
429
|
+
function fingerprint(agents) {
|
|
430
|
+
// `statusAt` is in here and `lastActiveAt` is deliberately not. The second is
|
|
431
|
+
// a tick and pushing on it re-renders her window for nothing. The first moves
|
|
432
|
+
// only when the session's status moves, and since a row's LIST depends on it:
|
|
433
|
+
// a session that went busy and back to idle between two readings shows the
|
|
434
|
+
// same `status` both times, and without this the row she replied to would sit
|
|
435
|
+
// in In progress until some other push happened to come. AND WHEN IT LAST
|
|
436
|
+
// SAID SOMETHING, which is the fact this list existed to notice and did
|
|
437
|
+
// not.An agent that answers her while she is looking at its card moves
|
|
438
|
+
// `saidAt` and nothing else — the status was idle before the reply and is
|
|
439
|
+
// idle after it, and on a quiet machine the two readings either side can both
|
|
440
|
+
// catch it idle — so the row went on showing the answer to the message
|
|
441
|
+
// before. This is not the tick `lastActiveAt` is: it moves only when the
|
|
442
|
+
// session actually says something new.
|
|
443
|
+
return agents.map((a) => `${a.pid}:${a.status ?? ''}:${a.statusAt ?? 0}:${a.waitingFor ?? ''}:${a.saidAt ?? 0}`).sort().join('|');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function listAgents(opts = {}) {
|
|
447
|
+
if (Date.now() - cache.at >= TTL_MS) refresh(opts).catch(() => {});
|
|
448
|
+
return cache.agents;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export async function refresh(opts) {
|
|
452
|
+
if (opts) lastOpts = opts;
|
|
453
|
+
const { zeroPid = process.pid, sessionIds = [], products = [], claudeBin = null } = opts ?? lastOpts;
|
|
454
|
+
// One measurement at a time. Without this a burst of pushes starts a `claude
|
|
455
|
+
// agents --json` per push and they queue up behind each other.
|
|
456
|
+
if (inFlight) return inFlight;
|
|
457
|
+
inFlight = measure({ zeroPid, sessionIds, products, claudeBin })
|
|
458
|
+
.then((agents) => {
|
|
459
|
+
const changed = fingerprint(agents) !== fingerprint(cache.agents);
|
|
460
|
+
cache = { at: Date.now(), agents };
|
|
461
|
+
if (changed && onChange) { try { onChange(); } catch { /* a push is not worth a crash */ } }
|
|
462
|
+
return agents;
|
|
463
|
+
})
|
|
464
|
+
.catch(() => {
|
|
465
|
+
// Her inbox is not allowed to go empty because a `ps` failed. The last
|
|
466
|
+
// good reading stands, and the stamp moves so the next call retries.
|
|
467
|
+
cache = { ...cache, at: Date.now() };
|
|
468
|
+
return cache.agents;
|
|
469
|
+
})
|
|
470
|
+
.finally(() => { inFlight = null; });
|
|
471
|
+
return inFlight;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function measure({ zeroPid, sessionIds, products, claudeBin }) {
|
|
475
|
+
const [table, cliRecords] = await Promise.all([processTable(), fromCli(claudeBin)]);
|
|
476
|
+
const zeroSessionIds = new Set(sessionIds.filter(Boolean));
|
|
477
|
+
|
|
478
|
+
// A product owns an agent when the agent is working in that product's repo.
|
|
479
|
+
// all 13 outside agents were in folders no the app product points at.
|
|
480
|
+
const owners = new Map();
|
|
481
|
+
for (const p of products) {
|
|
482
|
+
if (p?.repoPath) owners.set(path.resolve(p.repoPath), p);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const byPid = new Map();
|
|
486
|
+
const add = (rec, source) => {
|
|
487
|
+
if (!rec?.pid) return;
|
|
488
|
+
const proc = table.get(rec.pid);
|
|
489
|
+
if (!stillTheSame(rec, proc)) return;
|
|
490
|
+
const prev = byPid.get(rec.pid) ?? {};
|
|
491
|
+
byPid.set(rec.pid, {
|
|
492
|
+
...prev,
|
|
493
|
+
...rec,
|
|
494
|
+
ppid: proc.ppid,
|
|
495
|
+
sources: [...(prev.sources ?? []), source],
|
|
496
|
+
});
|
|
497
|
+
};
|
|
498
|
+
for (const rec of fromStateFiles()) add(rec, 'state-file');
|
|
499
|
+
for (const rec of cliRecords) add(rec, 'cli');
|
|
500
|
+
|
|
501
|
+
const agents = [];
|
|
502
|
+
for (const rec of byPid.values()) {
|
|
503
|
+
const { lastActiveAt, lastSaid, saidAt, about, lastAsked, touched, asked, transcript } = rec.sessionId
|
|
504
|
+
? readHistory(rec.sessionId, rec.cwd)
|
|
505
|
+
: { ...NOTHING, transcript: null };
|
|
506
|
+
const owner = owners.get(path.resolve(rec.cwd ?? '/')) ?? null;
|
|
507
|
+
agents.push({
|
|
508
|
+
pid: rec.pid,
|
|
509
|
+
ppid: rec.ppid,
|
|
510
|
+
sessionId: rec.sessionId ?? null,
|
|
511
|
+
name: rec.name ?? `session ${rec.pid}`,
|
|
512
|
+
cwd: rec.cwd ?? '',
|
|
513
|
+
startedAt: rec.startedAt ?? 0,
|
|
514
|
+
status: rec.status ?? null,
|
|
515
|
+
// WHEN IT LAST SAID WHAT IT WAS DOING, which is the only way to tell a
|
|
516
|
+
// stop that happened BEFORE she spoke to it from one that happened after.
|
|
517
|
+
// The CLI publishes it beside `status` on every change (measured in
|
|
518
|
+
// 2.1.234: the status is one of busy, shell, idle, waiting, and
|
|
519
|
+
// `statusUpdatedAt` is stamped whenever it moves). Without it a reply
|
|
520
|
+
// could never leave In progress, because the session is still idle for
|
|
521
|
+
// the moment or two before it picks the message up.
|
|
522
|
+
statusAt: Number(rec.statusUpdatedAt) || 0,
|
|
523
|
+
waitingFor: rec.waitingFor ?? null,
|
|
524
|
+
socket: rec.messagingSocketPath ?? defaultSocket(rec.pid),
|
|
525
|
+
lastActiveAt: lastActiveAt || rec.startedAt || 0,
|
|
526
|
+
lastSaid,
|
|
527
|
+
// WHEN IT LAST SAID SOMETHING, which is how the card can tell an answer
|
|
528
|
+
// to her newest message from a sentence it left there two days ago.
|
|
529
|
+
// Without it every reply reads as "It stopped here" and nothing more, so
|
|
530
|
+
// six pings produced six cards she could not tell apart.
|
|
531
|
+
saidAt,
|
|
532
|
+
// What the session is about, what she last told it, and what it has been
|
|
533
|
+
// touching. All three come free with the read above.
|
|
534
|
+
about,
|
|
535
|
+
lastAsked,
|
|
536
|
+
touched,
|
|
537
|
+
// No model reads any of it.
|
|
538
|
+
asked,
|
|
539
|
+
transcript,
|
|
540
|
+
product: owner?.slug ?? null,
|
|
541
|
+
productName: owner?.name ?? null,
|
|
542
|
+
// The dedupe key. Both halves of it, so it cannot drift as either changes.
|
|
543
|
+
startedByZero: rec.ppid === zeroPid || (!!rec.sessionId && zeroSessionIds.has(rec.sessionId)),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
return agents;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function defaultSocket(pid) {
|
|
550
|
+
const base = process.env.XDG_RUNTIME_DIR || os.tmpdir();
|
|
551
|
+
return path.join(base, 'cc-socks', `${pid}.sock`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/* ------------------------------ speaking to one -------------------------- */
|
|
555
|
+
// THE ONE WRITE. Claude Code sessions listen on a Unix socket and take
|
|
556
|
+
// newline-delimited JSON; the CLI prints the shape itself when it binds one
|
|
557
|
+
// ("[uds-messaging] Inject messages { echo '{"type":"auth",...}'; echo
|
|
558
|
+
// '{"type":"user","message":{"role":"user","content":"hello"}}'; } | socat").
|
|
559
|
+
// An auth line is required only when the session published a key file; when it
|
|
560
|
+
// did, the token is in ~/.claude/sessions/<pid>.<hash>.key.
|
|
561
|
+
//
|
|
562
|
+
// MEASURED, NOT REASONED, 2026-08-16: sent into a throwaway session spawned in
|
|
563
|
+
// /tmp, the message arrived, was read as her turn, and the agent went back to
|
|
564
|
+
// work. It was NOT held for approval, and NO line was added to
|
|
565
|
+
// ~/.claude/settings.json. The `crossSessionInbound: "hold"` gate the research
|
|
566
|
+
// found applies to messages that carry a peer identity and assert a permission
|
|
567
|
+
// mode; a plain injected user turn does not, so the settings line this build
|
|
568
|
+
// was told to ask about is not needed and was never written.
|
|
569
|
+
//
|
|
570
|
+
// TWO DIFFERENT FACTS, AND NEITHER IS OVERSTATED. The socket takes the message
|
|
571
|
+
// and does not acknowledge it, so what `reply` can honestly report has two
|
|
572
|
+
// levels and it reports whichever one it actually has:
|
|
573
|
+
//
|
|
574
|
+
// ok, no movement seen The message was handed to a live process that was
|
|
575
|
+
// listening. That IS a fact and it is what "Sent"
|
|
576
|
+
// means everywhere else in this app.
|
|
577
|
+
// ok, movement seen The session was then observed going to work, which
|
|
578
|
+
// is strictly better news and says so.
|
|
579
|
+
//
|
|
580
|
+
// A failed connect is neither, and says so loudly. What is never done is the
|
|
581
|
+
// third thing: a green toast over a message that went nowhere. That is the failure
|
|
582
|
+
// this codebase cares about most.
|
|
583
|
+
//
|
|
584
|
+
// The movement check is BEST EFFORT and is honestly not proven on a real
|
|
585
|
+
// waiting agent: no throwaway session could be made to publish the state file
|
|
586
|
+
// that carries `status`, so it was only ever exercised against sessions that
|
|
587
|
+
// publish neither that nor a transcript. It is written to be a bonus, never a
|
|
588
|
+
// gate, precisely because of that.
|
|
589
|
+
// WHO IS ASKING, WHICH THE SESSION HAS NO OTHER WAY TO KNOW.
|
|
590
|
+
//
|
|
591
|
+
// Nothing here claims her authority.
|
|
592
|
+
//
|
|
593
|
+
// What it does say is the part the wrapper cannot: WHERE the message came from
|
|
594
|
+
// and WHAT it is about. MEASURED on session-46, which is the session in her
|
|
595
|
+
// screenshot. There was no response to see, and one sentence of context is the
|
|
596
|
+
// whole of the difference.
|
|
597
|
+
//
|
|
598
|
+
// HER OWN TEXT IS UNCHANGED AND IS WHAT THE CARD QUOTES: `spoke` records what
|
|
599
|
+
// she typed, never this, so nothing she never wrote is ever drawn as her words.
|
|
600
|
+
export function fromTheApp(message) {
|
|
601
|
+
return [
|
|
602
|
+
`${RELAY_MARK} the inbox your user reads. A person typed this, not an agent,`,
|
|
603
|
+
"and they are asking about THIS session's own work. \"this\", \"it\" and \"the project\"",
|
|
604
|
+
'mean what you are doing here, not another session\'s. Answer in a few plain sentences',
|
|
605
|
+
'they can read on a card, and do not go looking for other sessions to ask.',
|
|
606
|
+
'',
|
|
607
|
+
'They said:',
|
|
608
|
+
message,
|
|
609
|
+
].join('\n');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export async function reply(pid, text, { timeoutMs = 3000 } = {}) {
|
|
613
|
+
const message = String(text ?? '').trim();
|
|
614
|
+
if (!message) return { ok: false, reason: 'There was nothing to send.' };
|
|
615
|
+
|
|
616
|
+
const agent = (await refresh()).find((a) => a.pid === pid);
|
|
617
|
+
if (!agent) return { ok: false, reason: 'That agent is no longer running.' };
|
|
618
|
+
if (agent.waitingFor === 'permission prompt') {
|
|
619
|
+
return { ok: false, reason: 'It is waiting on a permission box, which only its own window can answer.' };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const before = await watermark(agent);
|
|
623
|
+
try {
|
|
624
|
+
// What goes on the wire says who is asking; what is matched and recorded
|
|
625
|
+
// below stays HER words, so `heldNotice` cannot start matching every
|
|
626
|
+
// message by its shared opening and the card cannot quote a preamble.
|
|
627
|
+
await writeToSocket(agent.socket, fromTheApp(message), tokenFor(pid));
|
|
628
|
+
} catch (err) {
|
|
629
|
+
return { ok: false, reason: reasonFor(err) };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const moved = await waitForMovement(agent, before, timeoutMs);
|
|
633
|
+
|
|
634
|
+
// A THIRD ANSWER, AND IT IS A FAILURE: THE SESSION HELD IT AND HAS NOT READ
|
|
635
|
+
// IT. Claude Code parks an incoming socket message for the session's own user
|
|
636
|
+
// to approve when the session bypasses permission prompts and the sender did
|
|
637
|
+
// not attest a matching permission mode, which Agentbox cannot do. MEASURED
|
|
638
|
+
// 2026-08-17 against a session started with --permission-mode
|
|
639
|
+
// bypassPermissions: the message never became a turn, the agent never saw it,
|
|
640
|
+
// and this function returned ok, delivered and working, because the session
|
|
641
|
+
// burned CPU drawing the warning. That is the green toast over a message that
|
|
642
|
+
// went nowhere this file says it will never write, so it is checked for.
|
|
643
|
+
//
|
|
644
|
+
// It is BEST EFFORT in one direction only: a session with no transcript
|
|
645
|
+
// cannot be seen holding anything, so a hold that is not found is not a
|
|
646
|
+
// promise there was none. A hold that IS found is a fact and outranks
|
|
647
|
+
// everything above it.
|
|
648
|
+
// Looked for a few times rather than once: `waitForMovement` returns the
|
|
649
|
+
// instant it sees CPU, which on a held message is the session drawing the
|
|
650
|
+
// warning, so the line saying so is often not on disk yet.
|
|
651
|
+
let held = null;
|
|
652
|
+
for (let i = 0; i < 6 && !held; i += 1) {
|
|
653
|
+
held = heldNotice(agent, before.size, message);
|
|
654
|
+
if (!held) await new Promise((r) => setTimeout(r, 250));
|
|
655
|
+
}
|
|
656
|
+
if (held) {
|
|
657
|
+
cache = { ...cache, at: 0 };
|
|
658
|
+
return { ok: false, held: true, reason: held };
|
|
659
|
+
}
|
|
660
|
+
cache = { ...cache, at: 0 }; // the agent has moved; the next look re-measures
|
|
661
|
+
// The KEY goes back with the answer so the caller can record that she spoke,
|
|
662
|
+
// and it is worked out HERE because this is where the agent record is. A
|
|
663
|
+
// caller re-deriving it from a pid would be using the one thing that is not an
|
|
664
|
+
// identity (`agentKey`, shared/agents.mjs).
|
|
665
|
+
return { ok: true, delivered: true, working: moved, name: agent.name, key: agentKey(agent), at: Date.now() };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function tokenFor(pid) {
|
|
669
|
+
let names = [];
|
|
670
|
+
try { names = fs.readdirSync(SESSIONS_DIR()); } catch { return null; }
|
|
671
|
+
const key = names.find((n) => n.startsWith(`${pid}.`) && n.endsWith('.key'));
|
|
672
|
+
if (!key) return null;
|
|
673
|
+
try {
|
|
674
|
+
return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR(), key), 'utf8')).peerToken ?? null;
|
|
675
|
+
} catch { return null; }
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function writeToSocket(socketPath, message, token) {
|
|
679
|
+
return new Promise((resolve, reject) => {
|
|
680
|
+
const sock = net.createConnection({ path: socketPath });
|
|
681
|
+
const fail = (err) => { sock.destroy(); reject(err); };
|
|
682
|
+
sock.setTimeout(4000, () => fail(new Error('ETIMEDOUT')));
|
|
683
|
+
sock.on('error', fail);
|
|
684
|
+
sock.on('connect', () => {
|
|
685
|
+
if (token) sock.write(`${JSON.stringify({ type: 'auth', token })}\n`);
|
|
686
|
+
sock.write(`${JSON.stringify({ type: 'user', message: { role: 'user', content: message } })}\n`, () => {
|
|
687
|
+
// Half-close: the session reads to end of line, and an abrupt destroy
|
|
688
|
+
// has been seen to drop the final frame.
|
|
689
|
+
sock.end();
|
|
690
|
+
resolve();
|
|
691
|
+
});
|
|
692
|
+
});
|
|
693
|
+
sock.on('close', () => resolve());
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function reasonFor(err) {
|
|
698
|
+
const code = err?.code ?? err?.message ?? '';
|
|
699
|
+
if (String(code).includes('ENOENT')) return 'That agent is not listening any more.';
|
|
700
|
+
if (String(code).includes('ECONNREFUSED')) return 'That agent stopped listening.';
|
|
701
|
+
return 'The message could not be handed over.';
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// THREE WAYS TO SEE AN AGENT MOVE, because no single one of them is always
|
|
705
|
+
// there. The transcript grows when the session writes the new turn (absent when
|
|
706
|
+
// the session has transcript saving off); the state file's status leaves
|
|
707
|
+
// `waiting` (absent when a session never published one); and the process starts
|
|
708
|
+
// burning CPU, which is true of every session that took a turn and needs
|
|
709
|
+
// nothing published anywhere.
|
|
710
|
+
//
|
|
711
|
+
// The CPU signal was added after the probe on 2026-08-16: a throwaway session
|
|
712
|
+
// with no transcript and no state file took the message, answered it on screen,
|
|
713
|
+
// and `reply` still had to report that it could not see it move. `ps -o time`
|
|
714
|
+
// resolves to a hundredth of a second and an idle session drifts about 0.02s
|
|
715
|
+
// in three, so a third of a second of new CPU is a session that went to work.
|
|
716
|
+
const CPU_MOVED = 0.15;
|
|
717
|
+
|
|
718
|
+
async function cpuSeconds(pid) {
|
|
719
|
+
try {
|
|
720
|
+
const { stdout } = await run('/bin/ps', ['-o', 'time=', '-p', String(pid)], { encoding: 'utf8', timeout: 3000 });
|
|
721
|
+
const m = stdout.trim().match(/(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)/);
|
|
722
|
+
if (!m) return null;
|
|
723
|
+
const [, d = 0, h = 0, mi, sec] = m;
|
|
724
|
+
return Number(d) * 86400 + Number(h) * 3600 + Number(mi) * 60 + Number(sec);
|
|
725
|
+
} catch { return null; }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function watermark(agent) {
|
|
729
|
+
let size = 0;
|
|
730
|
+
let mtimeMs = 0;
|
|
731
|
+
try {
|
|
732
|
+
if (agent.transcript) { const s = fs.statSync(agent.transcript); size = s.size; mtimeMs = s.mtimeMs; }
|
|
733
|
+
} catch { /* no transcript is not a failure; there are two other signals */ }
|
|
734
|
+
return { size, mtimeMs, status: agent.status, cpu: await cpuSeconds(agent.pid) };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function waitForMovement(agent, before, timeoutMs) {
|
|
738
|
+
const deadline = Date.now() + timeoutMs;
|
|
739
|
+
while (Date.now() < deadline) {
|
|
740
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
741
|
+
try {
|
|
742
|
+
if (agent.transcript) {
|
|
743
|
+
const s = fs.statSync(agent.transcript);
|
|
744
|
+
if (s.size > before.size || s.mtimeMs > before.mtimeMs) return true;
|
|
745
|
+
}
|
|
746
|
+
} catch { /* keep watching the other two */ }
|
|
747
|
+
const state = readState(agent.pid);
|
|
748
|
+
if (state && state.status !== before.status) return true;
|
|
749
|
+
if (before.cpu != null) {
|
|
750
|
+
const now = await cpuSeconds(agent.pid);
|
|
751
|
+
if (now != null && now - before.cpu >= CPU_MOVED) return true;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// THE SESSION SAYS SO ITSELF, in the lines written after the send: a `system`
|
|
758
|
+
// line whose content opens "Held peer message" and quotes the message back.
|
|
759
|
+
// Only the bytes appended since the watermark are read, because the biggest
|
|
760
|
+
// transcript on her machine is 160MB and this runs on a click.
|
|
761
|
+
const HOLD_LINE = /Held peer message/;
|
|
762
|
+
|
|
763
|
+
function heldNotice(agent, sinceSize, message) {
|
|
764
|
+
// Resolved rather than taken off the record: a session that had not written a
|
|
765
|
+
// transcript when it was last measured has one by the time it holds a
|
|
766
|
+
// message, and that is exactly the session this is for.
|
|
767
|
+
const file = agent.transcript
|
|
768
|
+
?? (agent.sessionId ? transcriptPath(agent.sessionId, agent.cwd ?? '') : null);
|
|
769
|
+
if (!file) return null;
|
|
770
|
+
let tail = '';
|
|
771
|
+
try {
|
|
772
|
+
const s = fs.statSync(file);
|
|
773
|
+
if (s.size <= sinceSize) return null;
|
|
774
|
+
const fd = fs.openSync(file, 'r');
|
|
775
|
+
try {
|
|
776
|
+
const buf = Buffer.alloc(Math.min(s.size - sinceSize, 1 << 20));
|
|
777
|
+
fs.readSync(fd, buf, 0, buf.length, sinceSize);
|
|
778
|
+
tail = buf.toString('utf8');
|
|
779
|
+
} finally { fs.closeSync(fd); }
|
|
780
|
+
} catch { return null; }
|
|
781
|
+
|
|
782
|
+
const opening = message.slice(0, 60);
|
|
783
|
+
for (const line of tail.split('\n')) {
|
|
784
|
+
if (!HOLD_LINE.test(line)) continue;
|
|
785
|
+
let obj;
|
|
786
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
787
|
+
const content = String(obj?.content ?? '');
|
|
788
|
+
if (!HOLD_LINE.test(content)) continue;
|
|
789
|
+
if (opening && !content.includes(opening.slice(0, 40))) continue;
|
|
790
|
+
// Said in her words, not the CLI's. What she can DO about it is the point:
|
|
791
|
+
// the message is sitting in that session's own window waiting for a yes.
|
|
792
|
+
return `${agent.name} held your message for approval in its own window, so it has not read it yet.`;
|
|
793
|
+
}
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Exported for the test that pins the hold, under a name that says it is not
|
|
798
|
+
// part of the surface: what `reply` returns is.
|
|
799
|
+
export const __heldNotice = heldNotice;
|
|
800
|
+
|
|
801
|
+
function readState(pid) {
|
|
802
|
+
try {
|
|
803
|
+
return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR(), `${pid}.json`), 'utf8'));
|
|
804
|
+
} catch { return null; }
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/* ----------------------------- taking her to it -------------------------- */
|
|
808
|
+
// A REPLY CANNOT CLEAR A PERMISSION BOX, so that agent needs her where it is
|
|
809
|
+
// running, and the honest version of "take me there" is smaller than it sounds.
|
|
810
|
+
// All 13 of her outside agents are panes inside another app (CNVS), and there
|
|
811
|
+
// is no public command that focuses one: `claude` in this version has no
|
|
812
|
+
// `attach` and no `logs`, whatever the earlier write-up said. What CAN be done
|
|
813
|
+
// is bring the app that owns the process to the front, so this walks the parent
|
|
814
|
+
// chain to the first real application and offers that by name. If it finds
|
|
815
|
+
// none, it says so rather than pretending.
|
|
816
|
+
export function owningApp(pid) {
|
|
817
|
+
const table = processTable();
|
|
818
|
+
let cur = table.get(pid);
|
|
819
|
+
for (let hops = 0; cur && hops < 12; hops += 1) {
|
|
820
|
+
const m = cur.command.match(/^(\/(?:Applications|System\/Applications)\/[^/]*?\.app)\//);
|
|
821
|
+
if (m) return { path: m[1], name: path.basename(m[1], '.app') };
|
|
822
|
+
cur = table.get(cur.ppid);
|
|
823
|
+
}
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export function reveal(pid) {
|
|
828
|
+
const app = owningApp(pid);
|
|
829
|
+
if (!app) return { ok: false, reason: `${Name} cannot tell which window it is running in.` };
|
|
830
|
+
return new Promise((resolve) => {
|
|
831
|
+
execFile('/usr/bin/open', ['-a', app.path], (err) => {
|
|
832
|
+
resolve(err ? { ok: false, reason: `${app.name} would not come to the front.` } : { ok: true, name: app.name });
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/* ------------------------------ THE CONVERSATION -------------------------- */
|
|
838
|
+
// What the rest of this file reads on a draw is the four facts a card needs;
|
|
839
|
+
// this reads what the two of them actually SAID, and only ever because she
|
|
840
|
+
// pressed something.
|
|
841
|
+
//
|
|
842
|
+
// IT IS STREAMED, AND THAT IS NOT A FLOURISH. The biggest transcript on her
|
|
843
|
+
// machine is 160MB and costs 729ms to pull the readable text out of (measured
|
|
844
|
+
// 2026-08-17 over all 1,250 files). Doing that with readFileSync on the main
|
|
845
|
+
// process is the window frozen for three quarters of a second on a click, on
|
|
846
|
+
// the one surface built to make her trust the app. readline hands the lines
|
|
847
|
+
// back a chunk at a time and the app draws in between.
|
|
848
|
+
//
|
|
849
|
+
// NO MODEL SEES ANY OF IT, which was her condition on the whole feature, and
|
|
850
|
+
// nothing is written anywhere: this opens a file she already has and reads it.
|
|
851
|
+
|
|
852
|
+
// One turn is capped here rather than where it is drawn, so a pasted 600KB
|
|
853
|
+
// blob never becomes 600KB of React. It is far more than anybody reads and far
|
|
854
|
+
// less than a session can hold.
|
|
855
|
+
const TURN_CHARS = 4000;
|
|
856
|
+
|
|
857
|
+
// A session id is used as a FILENAME, so it is checked before it is one. The
|
|
858
|
+
// renderer is ours, but a path with a slash in it reaching this would be a read
|
|
859
|
+
// outside ~/.claude/projects and there is no reason to leave that open.
|
|
860
|
+
function safeSessionId(id) {
|
|
861
|
+
return /^[A-Za-z0-9._-]+$/.test(String(id ?? '')) ? String(id) : null;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function saidByIt(obj) {
|
|
865
|
+
if (obj.type !== 'assistant' || !Array.isArray(obj.message?.content)) return '';
|
|
866
|
+
return obj.message.content.filter((c) => c.type === 'text').map((c) => c.text).join('\n').trim();
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/* --------------------------- THE WORK IN BETWEEN -------------------------- */
|
|
870
|
+
// WHAT IT DID lives in the assistant line; WHAT CAME BACK arrives later, in the
|
|
871
|
+
// user line that answers it, keyed by the tool's id. So the events are built
|
|
872
|
+
// forwards and their output is filled in behind them, which is why `byId` is
|
|
873
|
+
// held across the read.
|
|
874
|
+
|
|
875
|
+
// Output keeps its HEAD, unlike a turn, which keeps its tail. An answer puts
|
|
876
|
+
// its conclusion at the end; a command puts the thing that went wrong at the
|
|
877
|
+
// start, and `ls` of a big folder is alphabetical. The count of what was cut
|
|
878
|
+
// goes on the line so nothing is rounded off in silence.
|
|
879
|
+
function cappedHead(text) {
|
|
880
|
+
const s = String(text ?? '');
|
|
881
|
+
return s.length > WORK_CHARS ? `${s.slice(0, WORK_CHARS - 1)}…` : s;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// `dir` IS THE FOLDER THIS SESSION RUNS IN, and it is what turns
|
|
885
|
+
// `/Users/you/Desktop/dev/zero/renderer/src/components/Tweaks.tsx` into
|
|
886
|
+
// `renderer/src/components/Tweaks.tsx` on her screen.
|
|
887
|
+
//
|
|
888
|
+
// `full` IS THE WHOLE STRING, and it only travels when shortening actually
|
|
889
|
+
// changed something. Her standing objection to every cap in this codebase is
|
|
890
|
+
// one that rounds off without saying so, and the fix for a line she cannot read
|
|
891
|
+
// cannot be a line she cannot recover.
|
|
892
|
+
function workedOn(obj, events, byId, dir = '') {
|
|
893
|
+
if (obj.type !== 'assistant' || !Array.isArray(obj.message?.content)) return;
|
|
894
|
+
const at = Date.parse(obj.timestamp ?? 0) || 0;
|
|
895
|
+
for (const c of obj.message.content) {
|
|
896
|
+
if (c.type !== 'tool_use') continue;
|
|
897
|
+
const subject = workSubject(c.input, dir, os.homedir());
|
|
898
|
+
const whole = fullSubject(c.input);
|
|
899
|
+
// THE FILE IT CHANGED, WHOLE, so the thread can offer it as a chip into the
|
|
900
|
+
// code. Empty on everything that did not write a file, which is most calls:
|
|
901
|
+
// a command, a search, a read.
|
|
902
|
+
const changed = changedFile(c.name, c.input);
|
|
903
|
+
const event = {
|
|
904
|
+
kind: 'work',
|
|
905
|
+
at,
|
|
906
|
+
verb: workVerb(c.name),
|
|
907
|
+
subject,
|
|
908
|
+
...(changed ? { file: changed } : {}),
|
|
909
|
+
full: whole && whole.replace(/\s+/g, ' ').trim() !== subject ? cappedHead(whole) : '',
|
|
910
|
+
output: '',
|
|
911
|
+
lines: 0,
|
|
912
|
+
failed: false,
|
|
913
|
+
};
|
|
914
|
+
if (c.id) byId.set(c.id, event);
|
|
915
|
+
events.push(event);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function cameBack(obj, byId) {
|
|
920
|
+
if (!obj?.toolUseResult || !Array.isArray(obj.message?.content)) return;
|
|
921
|
+
for (const b of obj.message.content) {
|
|
922
|
+
if (b.type !== 'tool_result') continue;
|
|
923
|
+
const event = byId.get(b.tool_use_id);
|
|
924
|
+
if (!event) continue;
|
|
925
|
+
const text = typeof b.content === 'string'
|
|
926
|
+
? b.content
|
|
927
|
+
: Array.isArray(b.content)
|
|
928
|
+
? b.content.filter((x) => x.type === 'text').map((x) => x.text).join('\n')
|
|
929
|
+
: '';
|
|
930
|
+
const all = String(text ?? '').replace(/\s+$/, '');
|
|
931
|
+
event.output = cappedHead(all);
|
|
932
|
+
event.lines = all ? all.split('\n').length : 0;
|
|
933
|
+
// THE ONLY COLOUR ON THE WHOLE SURFACE, and it is the tool's own verdict
|
|
934
|
+
// rather than ours: nothing here reads the output looking for the word
|
|
935
|
+
// error, because "0 errors" is not a failure and guessing at one on her
|
|
936
|
+
// screen is worse than saying nothing.
|
|
937
|
+
event.failed = b.is_error === true;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// CONSECUTIVE LINES FROM THE SAME SIDE ARE ONE TURN. Claude Code writes an
|
|
942
|
+
// assistant's answer as several lines when it stops to use a tool half way
|
|
943
|
+
// through, so a single reply arrives here as three. Folding them keeps the
|
|
944
|
+
// count honest: "40 messages" should mean forty things somebody said, not forty
|
|
945
|
+
// rows in a file.
|
|
946
|
+
//
|
|
947
|
+
// UNTIL SOMETHING ARRIVES. Folding on the SIDE alone folded every answer a
|
|
948
|
+
// session ever gave into one bubble, because the things that end an answer —
|
|
949
|
+
// her message from Agentbox, a peer's, a delivery notice — are all dropped from
|
|
950
|
+
// the reading and so could not end anything. `seal` is that missing edge, and
|
|
951
|
+
// `arrived` above is what calls it.
|
|
952
|
+
//
|
|
953
|
+
// A TURN IS STAMPED WHEN IT BEGAN, not when it ended, and that is the other
|
|
954
|
+
// half of the same bug. The old code moved the stamp forward on every fold
|
|
955
|
+
// while the text she reads is the head, so session-46's last bubble said 2:48pm
|
|
956
|
+
// today over a sentence from 7:47pm two days earlier and she took it for the
|
|
957
|
+
// answer to what she had just asked. The time and the first line she sees now
|
|
958
|
+
// come from the same moment, and cannot drift apart again. The ellipsis is
|
|
959
|
+
// INSIDE the cap, not on top of it: this is a bound on what the screen is
|
|
960
|
+
// handed, and a bound that can be exceeded by one is not one.
|
|
961
|
+
function capped(text) {
|
|
962
|
+
return text.length > TURN_CHARS ? `…${text.slice(-(TURN_CHARS - 1))}` : text;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// The last thing anybody SAID, looking back past however many things it ran in
|
|
966
|
+
// between. Shape B puts work lines in the thread, so the block above a block is
|
|
967
|
+
// routinely a command rather than a sentence.
|
|
968
|
+
function lastSpoken(turns) {
|
|
969
|
+
for (let i = turns.length - 1; i >= 0; i -= 1) {
|
|
970
|
+
if (turns[i].kind !== 'work') return turns[i];
|
|
971
|
+
}
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function seal(turns) {
|
|
976
|
+
const last = turns[turns.length - 1];
|
|
977
|
+
if (last) last.sealed = true;
|
|
978
|
+
// AND THE LAST THING SAID, which is not the last thing in the list once work
|
|
979
|
+
// lines are in the thread. Without this, a reminder arriving between a tool
|
|
980
|
+
// call and the next sentence would seal the tool call, leave the sentence
|
|
981
|
+
// above it open, and the two halves of two different answers would be counted
|
|
982
|
+
// as one message.
|
|
983
|
+
const spoken = lastSpoken(turns);
|
|
984
|
+
if (spoken) spoken.sealed = true;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function addTurn(turns, who, text, at) {
|
|
988
|
+
const last = turns[turns.length - 1];
|
|
989
|
+
if (last && !last.sealed && last.who === who) {
|
|
990
|
+
// WHAT OVERFLOWS IS THE OLDEST, NEVER THE NEWEST. This used to cut from the
|
|
991
|
+
// front, so the longer an answer ran the more surely its conclusion was the
|
|
992
|
+
// part thrown away. Measured on session-46: a bubble sat at exactly 4000
|
|
993
|
+
// characters with everything after the first 4000 gone.
|
|
994
|
+
last.text = capped(`${last.text}\n\n${text}`);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
// A CONTINUATION, NOT A NEW MESSAGE. The work between these two blocks is why
|
|
998
|
+
// they are two blocks, and the thread draws them in that order because that
|
|
999
|
+
// is what happened. But it is still one reply: the count says so, and the
|
|
1000
|
+
// screen does not put a second name and time over the second half of a
|
|
1001
|
+
// sentence somebody was in the middle of.
|
|
1002
|
+
const spoken = lastSpoken(turns);
|
|
1003
|
+
const same = !!spoken && !spoken.sealed && spoken.who === who && turns[turns.length - 1] !== spoken;
|
|
1004
|
+
turns.push(same ? { who, at, text: capped(text), same: true } : { who, at, text: capped(text) });
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// THE ROW ALREADY KNOWS WHICH SESSION IT IS, so this does not re-measure the
|
|
1008
|
+
// machine to find out. That is not a saving of milliseconds: `refresh` runs a
|
|
1009
|
+
// `ps` over every process on the machine, and doing it on a click would put a
|
|
1010
|
+
// third of a second between her pressing the line and anything happening. The
|
|
1011
|
+
// pid is only walked back to a live session when the caller has no session id,
|
|
1012
|
+
// which is the case a dead session leaves behind. `whole` is the gap line in
|
|
1013
|
+
// the thread being pressed: she is asking for the middle of the conversation
|
|
1014
|
+
// back, so every message is sent and `omitted` comes back zero. Only the work
|
|
1015
|
+
// stays capped.
|
|
1016
|
+
export async function conversation({ pid, sessionId, cwd, spoke = [], whole = false } = {}) {
|
|
1017
|
+
let id = safeSessionId(sessionId);
|
|
1018
|
+
let dir = cwd ?? '';
|
|
1019
|
+
let name = null;
|
|
1020
|
+
if (!id) {
|
|
1021
|
+
const live = (await refresh()).find((a) => a.pid === Number(pid));
|
|
1022
|
+
id = safeSessionId(live?.sessionId);
|
|
1023
|
+
dir = live?.cwd ?? dir;
|
|
1024
|
+
name = live?.name ?? null;
|
|
1025
|
+
}
|
|
1026
|
+
if (!id) return { ok: false, reason: `${Name} cannot tell which conversation this is.` };
|
|
1027
|
+
|
|
1028
|
+
const file = transcriptPath(id, dir);
|
|
1029
|
+
if (!file) return { ok: false, reason: 'This session has not written a transcript yet.' };
|
|
1030
|
+
|
|
1031
|
+
const turns = [];
|
|
1032
|
+
// WHAT IT RAN, KEYED BY ITS ID, because the tool call and the thing that came
|
|
1033
|
+
// back from it are two lines apart in the file and the second one is the only
|
|
1034
|
+
// place the output exists.
|
|
1035
|
+
const byId = new Map();
|
|
1036
|
+
try {
|
|
1037
|
+
const lines = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
|
1038
|
+
for await (const line of lines) {
|
|
1039
|
+
if (!line.trim()) continue;
|
|
1040
|
+
let obj;
|
|
1041
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
1042
|
+
const at = Date.parse(obj.timestamp ?? 0) || 0;
|
|
1043
|
+
// Anything inbound closes whatever was being said, whether or not it is
|
|
1044
|
+
// shown: a message Agentbox delivered, a peer's, a delivery notice. None of
|
|
1045
|
+
// those are quoted here and all of them mean the answer above is over.
|
|
1046
|
+
if (arrived(obj)) seal(turns);
|
|
1047
|
+
cameBack(obj, byId);
|
|
1048
|
+
const hook = hookFailure(obj);
|
|
1049
|
+
if (hook) {
|
|
1050
|
+
seal(turns);
|
|
1051
|
+
addTurn(turns, 'it', hook, at);
|
|
1052
|
+
seal(turns);
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
const mine = herTurnRaw(obj);
|
|
1056
|
+
if (mine) { addTurn(turns, 'you', mine, at); seal(turns); continue; }
|
|
1057
|
+
const its = saidByIt(obj);
|
|
1058
|
+
if (its) addTurn(turns, 'it', its, at);
|
|
1059
|
+
// THE WORK GOES IN THE THREAD, IN THE ORDER IT HAPPENED, and it ends the
|
|
1060
|
+
// message above it. Her shape says each thing it ran sits BETWEEN the
|
|
1061
|
+
// messages, so a reply that stopped twice for a tool is three things on
|
|
1062
|
+
// the screen in the order they occurred rather than one bubble with the
|
|
1063
|
+
// work swept behind it. This is the one place the fold is deliberately
|
|
1064
|
+
// broken, and breaking it is the shape she picked.
|
|
1065
|
+
workedOn(obj, turns, byId, dir);
|
|
1066
|
+
}
|
|
1067
|
+
} catch {
|
|
1068
|
+
// A half-read transcript is still worth what it gave us: a conversation
|
|
1069
|
+
// missing its last line beats an empty screen and a shrug.
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// HER REPLIES FROM AGENTBOX ARE NOT IN THE FILE AS HERS. What IS hers is what
|
|
1073
|
+
// Agentbox itself delivered, which the caller passes in from the record
|
|
1074
|
+
// written at the moment of delivery. Slotted by time, so the thread still
|
|
1075
|
+
// reads in the order it happened.
|
|
1076
|
+
const said = new Set(turns.filter((t) => t.who === 'you').map((t) => t.text.replace(/\s+/g, ' ').trim()));
|
|
1077
|
+
for (const t of Array.isArray(spoke) ? spoke : []) {
|
|
1078
|
+
const text = String(t?.text ?? '').trim();
|
|
1079
|
+
const at = Number(t?.at) || 0;
|
|
1080
|
+
if (!text || !at || said.has(text.replace(/\s+/g, ' ').trim())) continue;
|
|
1081
|
+
let i = turns.length;
|
|
1082
|
+
while (i > 0 && (turns[i - 1].at ?? 0) > at) i -= 1;
|
|
1083
|
+
turns.splice(i, 0, { who: 'you', at, text: text.slice(0, TURN_CHARS) });
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// The seal is scaffolding for the fold above and not a fact about a turn, so
|
|
1087
|
+
// it does not go over the wire pretending to be one.
|
|
1088
|
+
for (const t of turns) delete t.sealed;
|
|
1089
|
+
|
|
1090
|
+
const { events: shown, omitted } = threadWindow(turns, { whole: !!whole });
|
|
1091
|
+
// `total` counts what somebody SAID, not what is on the screen. The header
|
|
1092
|
+
// reads "40 messages, oldest first", and a run of tool calls is not messages.
|
|
1093
|
+
const total = saidCount(turns);
|
|
1094
|
+
return { ok: true, name, turns: shown, omitted, total };
|
|
1095
|
+
}
|