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
|
@@ -0,0 +1,937 @@
|
|
|
1
|
+
// PURE. Her Claude Code agents, as rows: which ones Agentbox shows, which one
|
|
2
|
+
// reaches her inbox, and what each one says. Measurement lives in
|
|
3
|
+
// main/agents.mjs; nothing here touches a process, a socket or a file.
|
|
4
|
+
//
|
|
5
|
+
// The rules are out here, and not inside the reader, for the reason
|
|
6
|
+
// list-rules.ts is: WHAT IS MISSING FROM A LIST HAS NO SYMPTOM. Every inbox
|
|
7
|
+
// this could draw looks plausible, and the way it goes wrong is that an agent
|
|
8
|
+
// waiting four days is not in it and nothing anywhere says so.
|
|
9
|
+
//
|
|
10
|
+
// Measured on her machine 2026-08-16, and this is the whole reason the feature
|
|
11
|
+
// exists: 16 Claude Code sessions live, 13 of them started by another app, and
|
|
12
|
+
// session-71 sitting on `waitingFor: "input needed"` since Wednesday 17:03.
|
|
13
|
+
// Nothing on the machine surfaced that.
|
|
14
|
+
|
|
15
|
+
/* ------------------------- is that process still it ---------------------- */
|
|
16
|
+
// A PID IS NOT AN IDENTITY: a session's state file outlives the process it
|
|
17
|
+
// describes and pids get reused, so a record is only believed when a live
|
|
18
|
+
// process at that pid started at the same moment.
|
|
19
|
+
//
|
|
20
|
+
// THE TWO CLOCKS DO NOT AGREE ON THEIR LABEL, and this is the bug this function
|
|
21
|
+
// exists for. The state file prints its start time in whatever timezone the
|
|
22
|
+
// session was started under; `ps -o lstart` prints local. On her machine
|
|
23
|
+
// 2026-08-16 session-71 read "Wed Aug 12 22:57:59 2026" in its state file and
|
|
24
|
+
// "Wed Aug 12 15:57:59 2026" in ps — the same instant, seven hours apart on the
|
|
25
|
+
// label. Comparing the two strings called all sixteen of her live sessions dead
|
|
26
|
+
// and showed her three, which is the whole feature quietly not working.
|
|
27
|
+
//
|
|
28
|
+
// So they are compared as instants, and a gap that is an exact whole number of
|
|
29
|
+
// hours is exactly what a timezone label costs and nothing else: two genuinely
|
|
30
|
+
// different processes do not start a whole number of hours apart to the second.
|
|
31
|
+
import { NAME } from '../shared/product-name.mjs';
|
|
32
|
+
const HOUR = 3_600_000;
|
|
33
|
+
|
|
34
|
+
export function sameInstant(a, b) {
|
|
35
|
+
const one = Date.parse(a);
|
|
36
|
+
const two = Date.parse(b);
|
|
37
|
+
if (!Number.isFinite(one) || !Number.isFinite(two)) return false;
|
|
38
|
+
const gap = Math.abs(one - two);
|
|
39
|
+
return gap % HOUR === 0 && gap <= 14 * HOUR;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* ----------------------------- whose agent is it ------------------------- */
|
|
43
|
+
// ZERO'S OWN WORKERS ARE ITS OWN CHILDREN. The supervisor runs `claude -p` from
|
|
44
|
+
// the Electron main process, so a worker's ppid is the app's pid and nothing else
|
|
45
|
+
// on the machine has that shape. That is the dedupe key, and it needs no name
|
|
46
|
+
// matching or guessing.
|
|
47
|
+
//
|
|
48
|
+
// The sessionId is a second, independent key for the same fact: the supervisor
|
|
49
|
+
// already records each worker's sessionId when it spawns it. Either alone is
|
|
50
|
+
// enough; both means the fold cannot drift as one of them changes.
|
|
51
|
+
//
|
|
52
|
+
// This matters because without it Agentbox shouts its own work back at her. In
|
|
53
|
+
// the raw picture she asked to see, rows two and three were agentbox-bf and
|
|
54
|
+
// agentbox-a8 — Agentbox's own workers, already in her inbox as work items, filing
|
|
55
|
+
// a second row about themselves.
|
|
56
|
+
export function startedByZero(agent, { pids = new Set(), sessionIds = new Set() } = {}) {
|
|
57
|
+
if (agent.sessionId && sessionIds.has(agent.sessionId)) return true;
|
|
58
|
+
return !!agent.ppid && pids.has(agent.ppid);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* --------------------------- is it asking anything ----------------------- */
|
|
62
|
+
// A ROW THAT ASKS NOTHING DOES NOT GET FILED. Her standing rule, and the one
|
|
63
|
+
// place this feature could most easily break it: of the 13 outside agents on
|
|
64
|
+
// her machine, 12 were idle and 1 was waiting. Filing all 13 puts twelve rows
|
|
65
|
+
// in front of her that say "nothing is asked", which is the picture she
|
|
66
|
+
// rejected.
|
|
67
|
+
//
|
|
68
|
+
// So the inbox takes exactly the agents that are waiting on a human. The other
|
|
69
|
+
// twelve are still listed, still findable, one keystroke away — they just do
|
|
70
|
+
// not interrupt.
|
|
71
|
+
export function asksSomething(agent) {
|
|
72
|
+
return agent.status === 'waiting';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// WHAT A REPLY CAN ACTUALLY FIX, and the distinction is not cosmetic: the two
|
|
76
|
+
// waiting states behave completely differently and only one of them is a
|
|
77
|
+
// question a message answers.
|
|
78
|
+
//
|
|
79
|
+
// "input needed" the agent finished its turn and is waiting for a human
|
|
80
|
+
// to say something next. A message IS that human turn.
|
|
81
|
+
// Proven on this machine 2026-08-16: a message written
|
|
82
|
+
// to a live session's socket arrived, was read as her
|
|
83
|
+
// turn, and the agent went back to work.
|
|
84
|
+
//
|
|
85
|
+
// "permission prompt" the agent is frozen on a box asking to approve a tool
|
|
86
|
+
// call. A message queues BEHIND that box. Sending one
|
|
87
|
+
// would look like it worked and change nothing, which is
|
|
88
|
+
// the failure this codebase cares about most: the system
|
|
89
|
+
// swallowing something she said.
|
|
90
|
+
//
|
|
91
|
+
// So a reply box appears on the first and never on the second, and the second
|
|
92
|
+
// says out loud that it has to be answered where it is running.
|
|
93
|
+
//
|
|
94
|
+
// `replyReaches` is about the ROW: whether the agent stopped and is waiting on
|
|
95
|
+
// her, which is what decides the row's headline and whether it interrupts at
|
|
96
|
+
// all. It is NOT the gate on the reply box any more — see `replyIsSwallowed`.
|
|
97
|
+
export function replyReaches(agent) {
|
|
98
|
+
return asksSomething(agent) && agent.waitingFor === 'input needed';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// THE ONE AGENT SHE CANNOT WRITE TO, and it is only ever this one.
|
|
102
|
+
//
|
|
103
|
+
// There was not one. The box was hidden on every agent that was not waiting on
|
|
104
|
+
// `input needed`, which is nearly all of them, and the dock said out loud that
|
|
105
|
+
// there was nothing there to answer. That sentence was wrong.
|
|
106
|
+
//
|
|
107
|
+
// MEASURED 2026-08-17, not reasoned: a throwaway session was left sitting at
|
|
108
|
+
// its prompt with nothing asked — the exact state of the card she sent — and
|
|
109
|
+
// the same line `reply` writes was written into its socket. It arrived, the
|
|
110
|
+
// session read it and went back to work and answered it. An idle session is
|
|
111
|
+
// listening; idle is what a session at its prompt IS.
|
|
112
|
+
//
|
|
113
|
+
// So the gate is the single case where a message really is swallowed: the
|
|
114
|
+
// agent frozen on a permission box, where anything she types queues behind the
|
|
115
|
+
// box instead of clearing it. `main/agents.mjs` already refused exactly that
|
|
116
|
+
// one and nothing else, so the UI was the whole of the block.
|
|
117
|
+
export function replyIsSwallowed(agent) {
|
|
118
|
+
return agent?.waitingFor === 'permission prompt';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* ------------------------- SHE SPOKE, IT IS WORKING ---------------------- */
|
|
122
|
+
// WHAT A REPLY DOES TO THE ROW, which until now was nothing at all.
|
|
123
|
+
//
|
|
124
|
+
// It had not moved because no rule in this app had ever heard of a reply to an
|
|
125
|
+
// agent.
|
|
126
|
+
//
|
|
127
|
+
// So a replied-to agent goes where a replied-to work item goes: In progress,
|
|
128
|
+
// which in this app is a PROMISE that something is on it or about to be. The
|
|
129
|
+
// promise has to be one that ends, or the row never comes back and she has a
|
|
130
|
+
// list that only grows. It ends the moment the session comes to a stop she can
|
|
131
|
+
// see, and there are exactly two of those:
|
|
132
|
+
//
|
|
133
|
+
// idle it finished the turn and is back at its prompt. That is the
|
|
134
|
+
// answer arriving, and the answer belongs in her inbox.
|
|
135
|
+
// waiting it stopped to ask her something. Same, more so.
|
|
136
|
+
//
|
|
137
|
+
// MEASURED, not reasoned, on her machine 2026-08-17 (claude 2.1.234): a session
|
|
138
|
+
// publishes `status` as one of busy, shell, idle, waiting, and stamps
|
|
139
|
+
// `statusUpdatedAt` every time that moves. 15 of her 16 live sessions were
|
|
140
|
+
// publishing one. So the comparison is against a real clock the session keeps
|
|
141
|
+
// itself, and the row leaves In progress because the SESSION stopped, never
|
|
142
|
+
// because a timer here ran out.
|
|
143
|
+
//
|
|
144
|
+
// The comparison is `>`, against the stamp and not the status alone, because
|
|
145
|
+
// the session is still idle for the moment or two between her pressing send and
|
|
146
|
+
// it picking the message up. Reading the status alone would send the row
|
|
147
|
+
// straight back to the inbox, which is the bug she reported wearing a hat.
|
|
148
|
+
//
|
|
149
|
+
// AND THE ONE SESSION THIS CANNOT PROMISE ANYTHING ABOUT: one that publishes no
|
|
150
|
+
// status at all (1 of her 16, an older CLI). Agentbox cannot see that one work
|
|
151
|
+
// and cannot see it stop, so it is not moved anywhere and the reply is still
|
|
152
|
+
// delivered. A promise that could never be kept is worse than no promise.
|
|
153
|
+
export function tookHerReply(agent) {
|
|
154
|
+
const spoke = agent?.repliedAt ?? 0;
|
|
155
|
+
if (!spoke) return false;
|
|
156
|
+
if (!agent?.status) return false; // nothing published: no promise to make
|
|
157
|
+
if (putAway(agent)) return false; // she closed the row; closed is closed
|
|
158
|
+
return !stoppedSince(agent, spoke);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Has it come to a stop since the moment given? `statusAt` is the session's own
|
|
162
|
+
// stamp on its own status, so a session that never publishes one has never been
|
|
163
|
+
// seen to stop and this is false — which is why `tookHerReply` refuses that
|
|
164
|
+
// case up front rather than trusting this.
|
|
165
|
+
export function stoppedSince(agent, since) {
|
|
166
|
+
const at = agent?.statusAt ?? 0;
|
|
167
|
+
if (at <= since) return false;
|
|
168
|
+
return agent?.status === 'idle' || agent?.status === 'waiting';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ------------------------------- until later ----------------------------- */
|
|
172
|
+
// AN AGENT ROW SHE CAN PUT OFF. A silent no-op is the failure this codebase
|
|
173
|
+
// cares about most wearing different clothes — she pressed a key, the app took
|
|
174
|
+
// it, and nothing happened.
|
|
175
|
+
//
|
|
176
|
+
// What deferring one MEANS is only ever about her inbox. Nothing is sent, no
|
|
177
|
+
// process is touched and the session keeps waiting exactly as it was: the row
|
|
178
|
+
// stops interrupting her until the moment she named, and it is in Scheduled the
|
|
179
|
+
// whole time. That is the honest whole of it, and it is why
|
|
180
|
+
// this is a moment kept beside the reading rather than anything written into
|
|
181
|
+
// somebody else's terminal.
|
|
182
|
+
|
|
183
|
+
// WHICH AGENT SHE PUT OFF, and it cannot be the pid. The row's id is
|
|
184
|
+
// `agent:<pid>` because that is what the list needs today, but a pid is not an
|
|
185
|
+
// identity (see sameInstant above): pids get reused, and the next session to
|
|
186
|
+
// land on 68909 would inherit a deferral she set on session-71. The sessionId
|
|
187
|
+
// is the session's own name for itself and is what the transcript, the state
|
|
188
|
+
// file and the CLI all agree on, so it is the key whenever there is one. When
|
|
189
|
+
// there is not, the pid is qualified by the instant that process started, which
|
|
190
|
+
// is the same pair `stillTheSame` trusts.
|
|
191
|
+
export function agentKey(agent) {
|
|
192
|
+
if (agent?.sessionId) return `session:${agent.sessionId}`;
|
|
193
|
+
return `pid:${agent?.pid ?? 0}:${agent?.startedAt ?? 0}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The moment she said she would deal with it. A past moment is not a deferral:
|
|
197
|
+
// once it passes the row is back, which is the same predicate rule work items
|
|
198
|
+
// follow. So a missed one comes back late rather than being lost, and nothing
|
|
199
|
+
// has to fire at 8am.
|
|
200
|
+
export function putOff(agent, now = Date.now()) {
|
|
201
|
+
return (agent?.runAt ?? 0) > now;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// AND DONE STAYS DONE.
|
|
205
|
+
//
|
|
206
|
+
// This used to be a watermark. `doneThrough` held the agent's own last activity
|
|
207
|
+
// at the moment she closed the row, and the row came back the moment that
|
|
208
|
+
// session did anything newer. The reasoning was that a session which has spoken
|
|
209
|
+
// since she dismissed it is news again. A session she has closed goes on
|
|
210
|
+
// working in its own terminal, its activity passed the mark within minutes, and
|
|
211
|
+
// the row she had dealt with came back.
|
|
212
|
+
//
|
|
213
|
+
// So the mark is now read as a flag: closed is closed, for as long as that
|
|
214
|
+
// session exists. WHAT THIS COSTS, stated rather than hidden: a session she
|
|
215
|
+
// closes while it is working will not return to her inbox when it later stops
|
|
216
|
+
// and asks something. That is the same sentence read the other way, and it is
|
|
217
|
+
// the one she wrote.
|
|
218
|
+
//
|
|
219
|
+
// "UNLESS ASKED OTHERWISE" IS THE UNDO, and it is why this stays a number in
|
|
220
|
+
// the file instead of becoming a boolean: `close(key, 0)` clears it, which is
|
|
221
|
+
// what Z does after she closes a row (`closeAgentRow` in App.tsx). The value
|
|
222
|
+
// still records WHEN she closed it, which is worth keeping and is no longer
|
|
223
|
+
// compared against anything.
|
|
224
|
+
//
|
|
225
|
+
// Nothing is sent and the session is not touched, exactly as with putting one
|
|
226
|
+
// off: this is a fact about her inbox.
|
|
227
|
+
export function putAway(agent) {
|
|
228
|
+
return (agent?.doneThrough ?? 0) > 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/* -------------------------------- the words ------------------------------ */
|
|
232
|
+
// Plain sentences, no jargon, no metaphor (hers, 2026-08-16), and the first
|
|
233
|
+
// line of the body is the one thing she has to do about it, in bold, one
|
|
234
|
+
// sentence, under fifteen words (instructions.md). The row clips at
|
|
235
|
+
// SUMMARY_BUDGET = 112 characters on the last full stop inside it, so that
|
|
236
|
+
// sentence has to carry the whole point on its own.
|
|
237
|
+
export function howLong(ms) {
|
|
238
|
+
if (!Number.isFinite(ms) || ms < 0) return 'a moment';
|
|
239
|
+
const days = Math.floor(ms / 86400000);
|
|
240
|
+
if (days >= 1) return `${days} day${days === 1 ? '' : 's'}`;
|
|
241
|
+
const hours = Math.floor(ms / 3600000);
|
|
242
|
+
if (hours >= 1) return `${hours} hour${hours === 1 ? '' : 's'}`;
|
|
243
|
+
const mins = Math.max(1, Math.floor(ms / 60000));
|
|
244
|
+
return `${mins} minute${mins === 1 ? '' : 's'}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// The folder it is working in, said the way she would say it. Not the path: A
|
|
248
|
+
// long "/Users/you/Desktop/dev/<repo>" is four words of noise around the one
|
|
249
|
+
// word that identifies it.
|
|
250
|
+
export function whereItRuns(agent) {
|
|
251
|
+
const owner = agent.productName;
|
|
252
|
+
if (owner) return owner;
|
|
253
|
+
const cwd = agent.cwd ?? '';
|
|
254
|
+
const leaf = cwd.split('/').filter(Boolean).pop();
|
|
255
|
+
return leaf || 'an unknown folder';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Somebody's typing, flattened to one readable line. Markdown markers go
|
|
259
|
+
// because the card quotes this inside its own markdown; a fenced code block
|
|
260
|
+
// goes because a card is not a diff viewer.
|
|
261
|
+
//
|
|
262
|
+
// Split out from `oneSentence` when `howItEnded` needed the same cleaning from
|
|
263
|
+
// the other end of the message. One copy, so a quote of hers and a quote of its
|
|
264
|
+
// answer can never be tidied by two different rules.
|
|
265
|
+
function readableWords(raw) {
|
|
266
|
+
return String(raw ?? '')
|
|
267
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
268
|
+
.replace(/\[Image[^\]]*\]/g, ' ')
|
|
269
|
+
.replace(/[*_`#>]/g, '')
|
|
270
|
+
.replace(/\s+/g, ' ')
|
|
271
|
+
.replace(/ +([.,;:!?])/g, '$1')
|
|
272
|
+
.trim();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// One line of somebody's typing, cut to a sentence, FROM THE FRONT. That is
|
|
276
|
+
// right for her and wrong for an agent; see `howItEnded`.
|
|
277
|
+
function oneSentence(raw, budget = 180) {
|
|
278
|
+
const text = readableWords(raw);
|
|
279
|
+
if (!text) return '';
|
|
280
|
+
if (text.length <= budget) return text;
|
|
281
|
+
const window = text.slice(0, budget + 1);
|
|
282
|
+
const end = Math.max(window.lastIndexOf('. '), window.lastIndexOf('? '), window.lastIndexOf('! '));
|
|
283
|
+
if (end > 40) return text.slice(0, end + 1);
|
|
284
|
+
return `${text.slice(0, window.lastIndexOf(' ') > 40 ? window.lastIndexOf(' ') : budget).trimEnd()}…`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function lastWord(agent, budget = 180) {
|
|
288
|
+
return oneSentence(agent.lastSaid, budget);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// HOW ITS ANSWER ENDED, WHICH IS WHERE THE ANSWER IS.
|
|
292
|
+
//
|
|
293
|
+
// `oneSentence` takes the FRONT of a message, and for her that is right: she
|
|
294
|
+
// opens with the ask. An agent does the opposite. It opens with what it looked
|
|
295
|
+
// at and closes with what it found, so quoting its first sentence on the card
|
|
296
|
+
// reliably showed her the preamble and hid the point.
|
|
297
|
+
//
|
|
298
|
+
// So this reads the same message from the other end. Same cleaning as
|
|
299
|
+
// `oneSentence`, then whole sentences off the BACK until the budget is full,
|
|
300
|
+
// with a leading ellipsis when there was more in front of them.
|
|
301
|
+
export function howItEnded(raw, budget = 320) {
|
|
302
|
+
const text = readableWords(raw);
|
|
303
|
+
if (!text) return '';
|
|
304
|
+
if (text.length <= budget) return text;
|
|
305
|
+
const tail = text.slice(-budget);
|
|
306
|
+
// Start at a sentence, so the quote never opens mid-clause. If the last
|
|
307
|
+
// sentence is itself longer than the budget there is no boundary to find, and
|
|
308
|
+
// a clean word break is the best honest cut.
|
|
309
|
+
const starts = [tail.indexOf('. '), tail.indexOf('? '), tail.indexOf('! ')].filter((i) => i >= 0);
|
|
310
|
+
const at = starts.length ? Math.min(...starts) + 2 : -1;
|
|
311
|
+
const cut = at >= 0 && at < budget - 40 ? tail.slice(at) : tail.slice(tail.indexOf(' ') + 1);
|
|
312
|
+
return `…${cut.trim()}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// WHEN IT LAST SPOKE, AND WHETHER THAT WAS AN ANSWER TO HER.
|
|
316
|
+
//
|
|
317
|
+
// The card used to head the quote "It stopped here:" with no time on it at all,
|
|
318
|
+
// so a session that had answered every one of her messages and a session that
|
|
319
|
+
// had said nothing for two days drew the identical heading. Six cards that
|
|
320
|
+
// looked the same because the one fact that told them apart was not on any of
|
|
321
|
+
// them.
|
|
322
|
+
function saidWhen(agent, now) {
|
|
323
|
+
const at = Number(agent?.saidAt) || 0;
|
|
324
|
+
if (!at) return '**It stopped here:**';
|
|
325
|
+
const when = ago(at, now);
|
|
326
|
+
const hers = Math.max(
|
|
327
|
+
Number(agent?.repliedAt) || 0,
|
|
328
|
+
...herWords(agent).map((t) => Number(t?.at) || 0),
|
|
329
|
+
);
|
|
330
|
+
if (hers && hers > at) return `**It has not answered yet. It last spoke ${when}:**`;
|
|
331
|
+
return `**It answered ${when}:**`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// THE LAST THING SHE TYPED INTO IT. Read straight off the session's own
|
|
335
|
+
// `last-prompt` line, so it is her turn and never a tool result wearing a user
|
|
336
|
+
// message's clothes.
|
|
337
|
+
export function lastAsk(agent, budget = 180) {
|
|
338
|
+
return oneSentence(agent.lastAsked, budget);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* ------------------------------- what it IS ------------------------------ */
|
|
342
|
+
// (hers), about a card whose loudest word was `session-71`. A session name is a
|
|
343
|
+
// handle, not a memory.
|
|
344
|
+
//
|
|
345
|
+
// Every Claude Code session writes a one-line title of ITSELF into its own
|
|
346
|
+
// transcript and keeps it current. Measured across her machine 2026-08-17: 14
|
|
347
|
+
// of her 14 outside sessions had one, and session-71's was "Agentbox product not
|
|
348
|
+
// displaying in the app" — the sentence she said she could not remember, three
|
|
349
|
+
// days old, sitting in a file Agentbox was already reading and throwing away.
|
|
350
|
+
//
|
|
351
|
+
// So the title of the row is what the work IS, with the session's name in front
|
|
352
|
+
// of it because she has two of them running in the same folder and the name is
|
|
353
|
+
// how she tells them apart. When a session has no title of its own the row says
|
|
354
|
+
// what it used to say, which is better than a blank.
|
|
355
|
+
export function whatItIs(agent) {
|
|
356
|
+
return String(agent?.about ?? '').replace(/\s+/g, ' ').trim();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function agentTitle(agent, now = Date.now()) {
|
|
360
|
+
const about = whatItIs(agent);
|
|
361
|
+
if (about) return `${agent.name}: ${about}`;
|
|
362
|
+
if (replyReaches(agent)) return `${agent.name} is waiting for you`;
|
|
363
|
+
if (asksSomething(agent)) return `${agent.name} needs you where it is running`;
|
|
364
|
+
// NOT "QUIET" ONCE SHE HAS SPOKEN TO IT. Without this the row she just
|
|
365
|
+
// replied to sat in In progress under a heading saying it had been quiet for
|
|
366
|
+
// three days, which is the same sentence it wore before she said anything.
|
|
367
|
+
if (tookHerReply(agent)) return `${agent.name} is working on what you told it`;
|
|
368
|
+
return `${agent.name} has been quiet for ${howLong(now - (agent.lastActiveAt ?? agent.startedAt ?? now))}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* ------------------------------ RECALLING IT ----------------------------- */
|
|
372
|
+
// Hers, 2026-08-17, about a card that already carried the title and the last
|
|
373
|
+
// thing she typed.
|
|
374
|
+
//
|
|
375
|
+
// WHAT WAS MISSING WAS THE BEGINNING, and it is the one thing on a transcript
|
|
376
|
+
// that never changes. Measured over all 16 of her live sessions on 2026-08-17:
|
|
377
|
+
// her FIRST message is the ask in 16 of 16, and no card had ever shown it.
|
|
378
|
+
// `last-prompt` is the LAST thing she typed, and five days into a session that
|
|
379
|
+
// is "okay no worries. just make sure that auth persists across sessions" —
|
|
380
|
+
// perfectly true, and no help whatsoever in remembering what the thing is for.
|
|
381
|
+
//
|
|
382
|
+
// Her side of a conversation is also small enough to just print: 155 turns
|
|
383
|
+
// across 16 sessions, median 6 per session, median 131 characters each. The
|
|
384
|
+
// megabytes in a transcript are the agent's half, not hers. So the recall is
|
|
385
|
+
// her own words, read off the file, and no model sees any of it.
|
|
386
|
+
//
|
|
387
|
+
// ONE SHAPE, NOT THREE, AND IT IS NOT CONFIGURABLE.
|
|
388
|
+
//
|
|
389
|
+
// So the card does the same thing on every session: the message she started it
|
|
390
|
+
// with, up to three later messages of hers, where it stopped, and where it is
|
|
391
|
+
// working. `ask` and `trail` are gone, and there is deliberately no setting for
|
|
392
|
+
// this — deciding per session which of them a card deserves is exactly the
|
|
393
|
+
// judgement she said she did not want made, whether by a model or by a knob.
|
|
394
|
+
|
|
395
|
+
// The messages she typed, oldest first, with the newest ones kept whole and the
|
|
396
|
+
// opening always present.
|
|
397
|
+
//
|
|
398
|
+
// AN EMPTY LIST IS NOT A FALLBACK TO `lastAsked`, and the difference is the
|
|
399
|
+
// whole point of this change rather than a nicety. A session whose transcript
|
|
400
|
+
// could not be read has exactly one thing she typed on record, and it is the
|
|
401
|
+
// LAST one. So the caller is told which of the two it has. WHAT SHE SAID FROM
|
|
402
|
+
// AGENTBOX IS ALSO WHAT SHE SAID.
|
|
403
|
+
//
|
|
404
|
+
// So the card went on saying only what she started the session with.
|
|
405
|
+
//
|
|
406
|
+
// `spoke` is the other half, written by main/agent-schedule.mjs at the moment
|
|
407
|
+
// of delivery. It is a RECORD, not a guess: Agentbox does not try to work out
|
|
408
|
+
// which wrapped peer message in a transcript was hers, because another of her
|
|
409
|
+
// sessions pinging this one is a real peer and drawing that as her words would
|
|
410
|
+
// put a sentence she never typed on the one card built to be trusted.
|
|
411
|
+
//
|
|
412
|
+
// Merged by time, so the thread reads forwards however it arrived, and deduped
|
|
413
|
+
// on the text because a session that DOES record her injected turn as its own
|
|
414
|
+
// (a future Claude Code, or one she typed into directly) must not show it
|
|
415
|
+
// twice.
|
|
416
|
+
export function herWords(agent) {
|
|
417
|
+
const asked = (agent?.asked ?? []).filter((t) => t && t.text);
|
|
418
|
+
const spoke = (agent?.spoke ?? []).filter((t) => t && t.text);
|
|
419
|
+
// AN EMPTY LIST STAYS EMPTY, and that is not laziness. Nothing here is
|
|
420
|
+
// allowed to invent an opening; see the note above this function.
|
|
421
|
+
if (!spoke.length || !asked.length) return asked;
|
|
422
|
+
const seen = new Set(asked.map((t) => t.text.replace(/\s+/g, ' ').trim()));
|
|
423
|
+
const merged = [...asked];
|
|
424
|
+
for (const t of spoke) {
|
|
425
|
+
const flat = String(t.text).replace(/\s+/g, ' ').trim();
|
|
426
|
+
if (seen.has(flat)) continue;
|
|
427
|
+
seen.add(flat);
|
|
428
|
+
merged.push({ at: t.at, text: flat });
|
|
429
|
+
}
|
|
430
|
+
// The opening keeps its place even if a clock skewed: it is the ask, and the
|
|
431
|
+
// card labels it as the thing she started with.
|
|
432
|
+
const [first, ...rest] = merged;
|
|
433
|
+
rest.sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
|
|
434
|
+
return first ? [first, ...rest] : [];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// WHICH OF HER MESSAGES ACTUALLY RECALL ANYTHING, and the cheapest honest
|
|
438
|
+
// answer is how long they are. Half of what she types while an agent works is
|
|
439
|
+
// driving it, not describing it: measured across her 155 real turns, "restart
|
|
440
|
+
// it", "move forward", "lgtm!", "copy it to clipboard", "oh that works got it"
|
|
441
|
+
// and "open again" are all under sixty characters, and her median turn is 131.
|
|
442
|
+
//
|
|
443
|
+
// So the long ones are preferred, and the short ones are still there when
|
|
444
|
+
// there is nothing longer, because a card that drops to silence is worse.
|
|
445
|
+
const RECALLS_SOMETHING = 60;
|
|
446
|
+
|
|
447
|
+
function worthRecalling(turns) {
|
|
448
|
+
const meaty = turns.filter((t) => t.text.length >= RECALLS_SOMETHING);
|
|
449
|
+
return meaty.length ? meaty : turns;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// How long ago, said the way she would say it, and never a clock time: a
|
|
453
|
+
// weekday name five days later is a small puzzle and "5 days ago" is not.
|
|
454
|
+
function ago(at, now) {
|
|
455
|
+
if (!at) return '';
|
|
456
|
+
const gap = now - at;
|
|
457
|
+
if (gap < 0) return 'just now';
|
|
458
|
+
if (gap < 90_000) return 'just now';
|
|
459
|
+
return `${howLong(gap)} ago`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// The rest of the context, in her sentences: what she asked for, where the
|
|
463
|
+
// thread got to, what it last said back, and what it has had its hands on.
|
|
464
|
+
function context(agent, now = Date.now()) {
|
|
465
|
+
const words = herWords(agent);
|
|
466
|
+
const said = howItEnded(agent.lastSaid);
|
|
467
|
+
const where = whereItRuns(agent);
|
|
468
|
+
const lines = [];
|
|
469
|
+
|
|
470
|
+
const [opening, ...rest] = words;
|
|
471
|
+
if (opening) {
|
|
472
|
+
const when = ago(opening.at, now);
|
|
473
|
+
lines.push(`**You started it${when ? ` ${when}` : ''} with:**\n\n“${oneSentence(opening.text, 420)}”`);
|
|
474
|
+
} else {
|
|
475
|
+
// No history, so the only thing on record is the LAST thing she typed, and
|
|
476
|
+
// it gets the label that is true of it.
|
|
477
|
+
const last = lastAsk(agent);
|
|
478
|
+
if (last) lines.push(`**The last thing you told it:**\n\n“${last}”`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Everything else she typed, oldest first, so the thread reads forwards.
|
|
482
|
+
// Three of them; more than that is the transcript rather than a card.
|
|
483
|
+
if (rest.length) {
|
|
484
|
+
const recent = worthRecalling(rest).slice(-3);
|
|
485
|
+
const bullets = recent.map((t) => {
|
|
486
|
+
const when = ago(t.at, now);
|
|
487
|
+
return `- ${when ? `${when}: ` : ''}“${oneSentence(t.text, 260)}”`;
|
|
488
|
+
});
|
|
489
|
+
const over = rest.length > recent.length ? `, over ${rest.length} more messages,` : '';
|
|
490
|
+
lines.push(`**Then${over} you said:**\n\n${bullets.join('\n')}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// `howItEnded` has already cut this to a quotable length from the right end,
|
|
494
|
+
// so it is not run through `oneSentence` again: doing that took the tail it
|
|
495
|
+
// had just chosen and handed back the front of it.
|
|
496
|
+
if (said) lines.push(`${saidWhen(agent, now)}\n\n“${said}”`);
|
|
497
|
+
|
|
498
|
+
const files = (agent.touched ?? []).slice(-3);
|
|
499
|
+
lines.push(files.length
|
|
500
|
+
? `It is working in ${where}, in ${list(files)}.`
|
|
501
|
+
: `It is working in ${where}.`);
|
|
502
|
+
return `\n\n${lines.join('\n\n')}`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function list(names) {
|
|
506
|
+
if (names.length === 1) return names[0];
|
|
507
|
+
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function agentRow(agent, now = Date.now()) {
|
|
511
|
+
const waited = howLong(now - (agent.lastActiveAt ?? agent.startedAt ?? now));
|
|
512
|
+
const title = agentTitle(agent, now);
|
|
513
|
+
const rest = context(agent, now);
|
|
514
|
+
|
|
515
|
+
// SHE HAS SPOKEN TO IT AND IT IS WORKING. First, because every branch below
|
|
516
|
+
// describes a session that has stopped, and this one has not: the row that
|
|
517
|
+
// sent her back here was in her inbox under "It is asking for nothing and has
|
|
518
|
+
// done nothing for 1 minute" moments after she told it to do something. This
|
|
519
|
+
// row asks her for nothing on purpose — it is in In progress, which is where
|
|
520
|
+
// the app puts what it is getting on with — so the opening line is the news
|
|
521
|
+
// rather than a question.
|
|
522
|
+
if (tookHerReply(agent)) {
|
|
523
|
+
return {
|
|
524
|
+
title,
|
|
525
|
+
body: `**You replied, and ${agent.name} is working on it.**\n\nIt comes back to your inbox when it stops, whether it finishes or asks you something. Nothing here needs you meanwhile.${rest}`,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
if (replyReaches(agent)) {
|
|
529
|
+
return {
|
|
530
|
+
title,
|
|
531
|
+
body: `**Reply to ${agent.name}, which has been waiting ${waited}.**\n\nIt finished what it was doing and stopped for an answer. Whatever you type here goes straight into it.${rest}`,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
if (asksSomething(agent)) {
|
|
535
|
+
return {
|
|
536
|
+
title,
|
|
537
|
+
body: `**Answer ${agent.name} in its own window, not here.**\n\nIt is asking to approve something, and a message from ${NAME} would wait behind that box instead of clearing it.${rest}`,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
// A QUIET AGENT IS IN HER INBOX NOW, so it has to say what she is meant to do
|
|
541
|
+
// with it, which is the one thing it never said before: close it. Closing one
|
|
542
|
+
// is about her inbox and nothing else — the session is not touched — and the
|
|
543
|
+
// body says exactly that, because a row that looks like it might kill
|
|
544
|
+
// somebody's terminal is a row she cannot press at speed.
|
|
545
|
+
return {
|
|
546
|
+
title,
|
|
547
|
+
body: `**Close ${agent.name} if you no longer need it here.**\n\nIt is asking for nothing and has done nothing for ${waited}. Closing it takes it out of your inbox and leaves the session running.${rest}`,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/* -------------------------------- ONE LIST ------------------------------- */
|
|
552
|
+
// THERE IS NO SECOND TAB.So `Your agents` is gone and every outside agent is
|
|
553
|
+
// either in her inbox or nowhere, which is the whole of "merge the inbox into
|
|
554
|
+
// one".
|
|
555
|
+
//
|
|
556
|
+
// WHICH OF THEM IS IN IT IS HER SETTING, because she named both sides of it
|
|
557
|
+
// herself in the same message:
|
|
558
|
+
export const AGENT_MODES = ['all', 'waiting', 'off'];
|
|
559
|
+
|
|
560
|
+
export function reachesInbox(agent, now = Date.now(), mode = 'all') {
|
|
561
|
+
if (agent.startedByZero) return false;
|
|
562
|
+
if (mode === 'off') return false;
|
|
563
|
+
// ONE ROW, ONE LIST. A session she just spoke to is in In progress until it
|
|
564
|
+
// stops for her again; showing it here at the same time is the "one row, two
|
|
565
|
+
// tabs" failure this codebase has already had once, and it would still be
|
|
566
|
+
// sitting under a headline saying it asks for nothing.
|
|
567
|
+
if (tookHerReply(agent)) return false;
|
|
568
|
+
// A SESSION THAT CANNOT SAY WHAT IT IS NEVER FILES A ROW, WHATEVER THE
|
|
569
|
+
// MODE.That sentence is what `agentTitle` falls back to when a session has
|
|
570
|
+
// published no title of its own, and the body under it asks her to close a
|
|
571
|
+
// row that asked her nothing, which instructions.md says should never have
|
|
572
|
+
// been filed at all. It cannot be fixed by closing one either: a throwaway
|
|
573
|
+
// session gets a fresh pid every time, so E is keyed to an id that never
|
|
574
|
+
// comes back and the next one arrives just as loud.
|
|
575
|
+
//
|
|
576
|
+
// The sweep `all` was built for is untouched. A forgotten session that has a
|
|
577
|
+
// title still carries it here, which is the one she can recognise and decide
|
|
578
|
+
// about; this drops only the ones with nothing to show her.
|
|
579
|
+
if (!asksSomething(agent) && !whatItIs(agent)) return false;
|
|
580
|
+
if (mode === 'waiting' && !asksSomething(agent)) return false;
|
|
581
|
+
return !putOff(agent, now) && !putAway(agent);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// AND THE LIST IT IS IN INSTEAD. Same shape as `belongsInProgress` for work
|
|
585
|
+
// items and the same promise: something is on this right now. The deferral
|
|
586
|
+
// argument is not taken because a reply cancels a deferral (`replyClearsSchedule`
|
|
587
|
+
// does the same for work items) — she spoke to it, so it is not put off any more.
|
|
588
|
+
//
|
|
589
|
+
// THIS IS NOT `reachesProgress`, WHICH SHE KILLED, and the name is different on
|
|
590
|
+
// purpose. It stays dead: a working session she has not spoken to is in her
|
|
591
|
+
// inbox, exactly as she left it. What moves a row here is HER, and only her —
|
|
592
|
+
// the reply she wrote.
|
|
593
|
+
export function progressAfterReply(agent, now = Date.now(), mode = 'all') {
|
|
594
|
+
if (agent.startedByZero) return false;
|
|
595
|
+
if (mode === 'off') return false;
|
|
596
|
+
return tookHerReply(agent);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Everything Agentbox will admit exists: every live session except its own
|
|
600
|
+
// workers, which are already in her inbox as work items.
|
|
601
|
+
export function listed(agent) {
|
|
602
|
+
return !agent.startedByZero;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/* --------------------- ACTIVE AGENTS, IN THE SIDEBAR --------------------- */
|
|
606
|
+
// Then her pick of the design at 18:17: one line per session in its own words,
|
|
607
|
+
// the clock on the right, and no dot, mark, colour, wash, edge or line of any
|
|
608
|
+
// kind.
|
|
609
|
+
//
|
|
610
|
+
// THE CUT IS A DAY, AND IT IS THE ONLY FILTER.The premise is worth correcting
|
|
611
|
+
// where the rule lives, because it decides what the rule can be: nothing
|
|
612
|
+
// FINISHED is ever on this list and never has been. `main/agents.mjs` drops any
|
|
613
|
+
// record whose process is gone, so a session that finishes exits and disappears
|
|
614
|
+
// by itself. Measured on her machine that evening, ten sessions alive and six
|
|
615
|
+
// of them her own terminals sitting at a prompt for between two and seven days.
|
|
616
|
+
// So length comes from sessions that are alive and forgotten, and a day is
|
|
617
|
+
// where the app's own clock (`format.ts`) stops saying hours and starts saying
|
|
618
|
+
// `1d`. The panel therefore holds exactly what the clock beside each row can
|
|
619
|
+
// say in hours, which is the same sentence read twice.
|
|
620
|
+
export const RAIL_CUT_MS = 24 * 60 * 60 * 1000;
|
|
621
|
+
|
|
622
|
+
export function onTheRail(agent, now = Date.now()) {
|
|
623
|
+
if (!listed(agent)) return false;
|
|
624
|
+
const moved = agent.lastActiveAt || agent.startedAt || 0;
|
|
625
|
+
return moved > 0 && now - moved <= RAIL_CUT_MS;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// WHAT ONE ROW SAYS, and it is the session's own sentence rather than ours.
|
|
629
|
+
// `about` is the one-line title every Claude Code session keeps current for
|
|
630
|
+
// itself; `lastSaid` is the fallback for a session Agentbox started, which has no
|
|
631
|
+
// title. Neither is cut here: the row is one line with `text-overflow` on it,
|
|
632
|
+
// so the width she is actually looking at does the cutting, once.
|
|
633
|
+
export function railLine(agent) {
|
|
634
|
+
return whatItIs(agent) || lastWord(agent) || 'Has not said anything yet';
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// MOST RECENTLY ACTIVE FIRST, which is the order every other list in this app
|
|
638
|
+
// is in, and the order the day headings above the rows assume. Sorting the
|
|
639
|
+
// stopped one to the top instead read well in a sentence and drew badly: the
|
|
640
|
+
// list groups by day as it goes, so a waiting agent from Wednesday above a
|
|
641
|
+
// row from July put "Last 7 days" on screen twice, once at the top and once
|
|
642
|
+
// eight rows down (measured in the built app, 2026-08-16).
|
|
643
|
+
//
|
|
644
|
+
// The stopped one does not need pinning here. It is scored in the inbox by the
|
|
645
|
+
// same rule everything else there is scored by, which puts an agent asking
|
|
646
|
+
// something above one asking nothing without this having to know about it.
|
|
647
|
+
export function byRecency(a, b) {
|
|
648
|
+
return (b.lastActiveAt ?? 0) - (a.lastActiveAt ?? 0);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/* --------------------------- THE CONVERSATION ---------------------------- */
|
|
652
|
+
// Hers, 2026-08-17. Her "go ahead" on the same row, an hour later.
|
|
653
|
+
//
|
|
654
|
+
// THE CARD IS THE RECALL AND THIS IS THE REST OF IT. The card is drawn for her
|
|
655
|
+
// whether she asked or not, so it is four sentences; this is only ever drawn
|
|
656
|
+
// because she pressed something, so it can be the conversation.
|
|
657
|
+
//
|
|
658
|
+
// STILL NO MODEL, AND NOW NO COST AT ALL UNTIL SHE PRESSES IT. Measured on her
|
|
659
|
+
// machine 2026-08-17: the readable conversation is 0.6% of a transcript's
|
|
660
|
+
// bytes, and the worst single file on the disk — 160MB — carries 3,800 readable
|
|
661
|
+
// turns, 2.6MB of text, 729ms to pull out. That last number is why the read is
|
|
662
|
+
// streamed and never happens on a draw.
|
|
663
|
+
//
|
|
664
|
+
// WHY IT IS A WINDOW AND NOT THE WHOLE FILE. 3,800 turns is not a conversation
|
|
665
|
+
// she can get up to speed on, it is a scroll bar. So it is the opening, which
|
|
666
|
+
// is the ask in 16 of her 16 sessions, and the end, which is where it got to,
|
|
667
|
+
// and one plain line saying how many are in between rather than pretending
|
|
668
|
+
// there are none.
|
|
669
|
+
export const TRAIL_OPENING = 3;
|
|
670
|
+
export const TRAIL_TURNS = 40;
|
|
671
|
+
|
|
672
|
+
export function conversationWindow(turns, { opening = TRAIL_OPENING, keep = TRAIL_TURNS } = {}) {
|
|
673
|
+
const all = (turns ?? []).filter((t) => t && t.text);
|
|
674
|
+
if (all.length <= keep) return { turns: all, omitted: 0 };
|
|
675
|
+
const head = all.slice(0, opening);
|
|
676
|
+
const tail = all.slice(all.length - (keep - opening));
|
|
677
|
+
return { turns: [...head, ...tail], omitted: all.length - head.length - tail.length };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// The one line that stands where the missing middle is. It says the number
|
|
681
|
+
// because "some messages" is the kind of sentence that makes her wonder what
|
|
682
|
+
// else the app is rounding off.
|
|
683
|
+
//
|
|
684
|
+
// She is right and it is not rare. Measured over her own Agentbox store the same
|
|
685
|
+
// day, rebuilding every row's thread the way the pane draws it: 267 rows have a
|
|
686
|
+
// conversation, 123 of them hide part of it, and 97 hide messages SHE wrote.
|
|
687
|
+
// 550 of her own messages were behind this line, and the line was a plain div
|
|
688
|
+
// with nothing to press. "Not shown" was true and it was also the end of the
|
|
689
|
+
// road, so a thread that had eaten her follow-up simply said so and stopped.
|
|
690
|
+
//
|
|
691
|
+
// So the words say what pressing does. The window itself is untouched: it still
|
|
692
|
+
// opens on the end of a long conversation, because an 18,100px page is the
|
|
693
|
+
// thing it was built for. What changed is that the middle can be asked for.
|
|
694
|
+
export function conversationGap(omitted) {
|
|
695
|
+
if (!omitted) return '';
|
|
696
|
+
return `Show the ${omitted} message${omitted === 1 ? '' : 's'} in between`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/* ------------------------------- THE WORK ---------------------------------- */
|
|
700
|
+
// So the item is the conversation, and each thing the agent ran sits on its own
|
|
701
|
+
// quiet line between the messages, in the order it happened. This half of the
|
|
702
|
+
// file decides what one of those lines SAYS. What it looks like is the frosted
|
|
703
|
+
// treatment she picked in round two and lives in the stylesheet.
|
|
704
|
+
//
|
|
705
|
+
// WHY IT IS BOUNDED THREE WAYS, all measured over the 40 largest transcripts on
|
|
706
|
+
// her machine, 2026-08-19:
|
|
707
|
+
//
|
|
708
|
+
// * one session held 6,070 tool calls and 8MB of output. So output is capped
|
|
709
|
+
// per line (WORK_CHARS) rather than sent whole.
|
|
710
|
+
// * the longest unbroken run of tool calls between two messages was 111, and
|
|
711
|
+
// the median session's longest run was 25. So a run is capped (WORK_RUN)
|
|
712
|
+
// and says how many it is not showing, because 111 quiet lines standing
|
|
713
|
+
// between her question and its answer is her original complaint wearing a
|
|
714
|
+
// different hat.
|
|
715
|
+
// * a windowed thread of 40 messages could still carry thousands of work
|
|
716
|
+
// lines, so the whole thread is capped too (WORK_TOTAL), oldest first.
|
|
717
|
+
//
|
|
718
|
+
// Every cap says its own number out loud where it bites. Nothing here rounds
|
|
719
|
+
// off silently: "some output" is the kind of sentence that makes her wonder
|
|
720
|
+
// what else the app is not telling her.
|
|
721
|
+
export const WORK_CHARS = 2000;
|
|
722
|
+
export const WORK_RUN = 20;
|
|
723
|
+
export const WORK_TOTAL = 200;
|
|
724
|
+
|
|
725
|
+
// WHAT A TOOL CALL SAYS lives in its own file now (`shared/work-lines.mjs`),
|
|
726
|
+
// because her 2026-08-20 report on the imported threads turned one rule into
|
|
727
|
+
// three: the verb, the subject and the fold. It is re-exported here so that
|
|
728
|
+
// nothing that already reads this file has to learn a second import.
|
|
729
|
+
export {
|
|
730
|
+
workVerb, workSubject, fullSubject, shortPath, plainCommand,
|
|
731
|
+
groupWork, runSummary, runFailures, RUN_MIN,
|
|
732
|
+
changedFile, fileInChange, CHANGES_A_FILE,
|
|
733
|
+
} from './work-lines.mjs';
|
|
734
|
+
|
|
735
|
+
// The line that stands where a capped run of work is. Same rule as the message
|
|
736
|
+
// gap above: say the number.
|
|
737
|
+
export function runOverflow(more) {
|
|
738
|
+
if (!more) return '';
|
|
739
|
+
return `and ${more} more thing${more === 1 ? '' : 's'} it ran, not shown`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// The line at the foot of an output that was longer than we keep.
|
|
743
|
+
export function outputCut(shownLines, allLines) {
|
|
744
|
+
if (!allLines || allLines <= shownLines) return '';
|
|
745
|
+
return `${allLines - shownLines} more line${allLines - shownLines === 1 ? '' : 's'} came back, not kept`;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// THE WINDOW, MESSAGE-AWARE. `conversationWindow` above counts turns, and a
|
|
749
|
+
// thread with work in it must not let a run of tool calls push her own messages
|
|
750
|
+
// out of the window: the whole point of the shape is that her question and its
|
|
751
|
+
// answer sit next to each other. So the window is computed over the MESSAGES,
|
|
752
|
+
// exactly as before, and the work that sits between two kept messages comes
|
|
753
|
+
// with them. `omitted` still counts messages, because that is what the line
|
|
754
|
+
// under the gap says.
|
|
755
|
+
//
|
|
756
|
+
// `whole` IS WHAT THE GAP LINE PRESSES. Every message stands, and nothing else
|
|
757
|
+
// changes: the work is still capped by `capRuns` below, because 530 tool calls
|
|
758
|
+
// is not a conversation and she asked for her follow-ups, not for the output
|
|
759
|
+
// under them. Opening it is her decision and it lasts until she leaves the row,
|
|
760
|
+
// so no thread is ever born at this size.
|
|
761
|
+
export function threadWindow(events, { opening = TRAIL_OPENING, keep = TRAIL_TURNS, whole = false } = {}) {
|
|
762
|
+
const all = (events ?? []).filter((e) => e && (e.kind === 'work' ? true : !!e.text));
|
|
763
|
+
const saidAt = [];
|
|
764
|
+
all.forEach((e, i) => { if (e.kind !== 'work') saidAt.push(i); });
|
|
765
|
+
if (whole || saidAt.length <= keep) return { events: capRuns(all), omitted: 0 };
|
|
766
|
+
|
|
767
|
+
// The first index that goes, and the first index that comes back. Everything
|
|
768
|
+
// between them is dropped whole, work and messages together, so the thread
|
|
769
|
+
// never shows output belonging to a message that is not on screen.
|
|
770
|
+
const from = saidAt[opening];
|
|
771
|
+
const to = saidAt[saidAt.length - (keep - opening)];
|
|
772
|
+
|
|
773
|
+
// THE CONVERSATION RESUMES WITH A NAME ON IT.
|
|
774
|
+
//
|
|
775
|
+
// A `same` block is one agent STILL TALKING: it wears no name and no time
|
|
776
|
+
// because the message above it is its own first half. Across the cut that is
|
|
777
|
+
// a lie. Whatever it was continuing is in the missing middle, so it draws as
|
|
778
|
+
// a nameless paragraph hanging off whichever message the window happened to
|
|
779
|
+
// end the opening with, which is a different run from a different hour.
|
|
780
|
+
//
|
|
781
|
+
// Measured on her store, 2026-08-28: of the 123 rows with a gap in them, 93
|
|
782
|
+
// resumed on a nameless block.
|
|
783
|
+
//
|
|
784
|
+
// `resumed` RATHER THAN CLEARING `same`, and the difference matters. `same`
|
|
785
|
+
// is what `saidCount` folds on, so a block that stops being a continuation
|
|
786
|
+
// becomes a second message in the header's count while still being one
|
|
787
|
+
// message in the row. Measured before this note was written: clearing it put
|
|
788
|
+
// `omitted` at -1 on 15 of her rows, which is the gap line quoting her a
|
|
789
|
+
// number that cannot be true. So the fold is left exactly as it was and only
|
|
790
|
+
// the DRAWING changes: this block gets its name and its time back.
|
|
791
|
+
const tail = all.slice(to);
|
|
792
|
+
if (tail.length && tail[0].kind !== 'work' && tail[0].same) tail[0] = { ...tail[0], resumed: true };
|
|
793
|
+
const kept = [...all.slice(0, from), ...tail];
|
|
794
|
+
return { events: capRuns(kept), omitted: saidAt.length - keep };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* WHERE THE GAP LINE STANDS, in the windowed thread `threadWindow` returned.
|
|
799
|
+
*
|
|
800
|
+
* IT LIVES HERE BECAUSE THE CUT LIVES HERE. The renderer used to work this out
|
|
801
|
+
* for itself with a second, different count: it walked the messages and SKIPPED
|
|
802
|
+
* `same` continuations, while the cut above keeps the first `opening` BLOCKS
|
|
803
|
+
* with continuations counted. The two agree only when no run's second sentence
|
|
804
|
+
* lands in the opening, and the ordinary shape of a thread is the ask, a run's
|
|
805
|
+
* first line, that run's second line.
|
|
806
|
+
*
|
|
807
|
+
* Measured over her own store, 2026-08-28, rebuilding all 267 rows the way the
|
|
808
|
+
* pane draws them: 123 have a gap, and on 56 of those the line was printed away
|
|
809
|
+
* from the seam, by as much as 30 messages. It sat under messages she could read
|
|
810
|
+
* saying some were missing, and where they had really gone there was nothing.
|
|
811
|
+
*
|
|
812
|
+
* Returns the index of the LAST event the opening kept, so work belonging to the
|
|
813
|
+
* opening stays above the line rather than below it, and -1 when nothing is
|
|
814
|
+
* missing.
|
|
815
|
+
*/
|
|
816
|
+
export function gapIndex(events, omitted, { opening = TRAIL_OPENING } = {}) {
|
|
817
|
+
if (!omitted) return -1;
|
|
818
|
+
let blocks = 0;
|
|
819
|
+
for (let i = 0; i < (events?.length ?? 0); i += 1) {
|
|
820
|
+
if (events[i].kind === 'work') continue;
|
|
821
|
+
blocks += 1;
|
|
822
|
+
if (blocks === opening + 1) return i - 1;
|
|
823
|
+
}
|
|
824
|
+
// Every block on the screen belongs to the opening: the seam is the end of it.
|
|
825
|
+
return (events?.length ?? 0) - 1;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Both work caps, applied in one pass over the windowed thread. A run is cut to
|
|
829
|
+
// WORK_RUN and told how many it lost; the thread as a whole is cut to
|
|
830
|
+
// WORK_TOTAL, oldest work first, because the newest is the part she came for.
|
|
831
|
+
export function capRuns(events, { run = WORK_RUN, total = WORK_TOTAL } = {}) {
|
|
832
|
+
const out = [];
|
|
833
|
+
let inRun = 0;
|
|
834
|
+
let dropped = 0;
|
|
835
|
+
for (const e of events) {
|
|
836
|
+
if (e.kind !== 'work') {
|
|
837
|
+
if (dropped) { out[out.length - 1] = { ...out[out.length - 1], more: dropped }; dropped = 0; }
|
|
838
|
+
inRun = 0;
|
|
839
|
+
out.push(e);
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
inRun += 1;
|
|
843
|
+
if (inRun > run) { dropped += 1; continue; }
|
|
844
|
+
out.push(e);
|
|
845
|
+
}
|
|
846
|
+
if (dropped) out[out.length - 1] = { ...out[out.length - 1], more: dropped };
|
|
847
|
+
|
|
848
|
+
const work = out.filter((e) => e.kind === 'work').length;
|
|
849
|
+
if (work <= total) return out;
|
|
850
|
+
// Drop the oldest work lines and leave every message standing.
|
|
851
|
+
let over = work - total;
|
|
852
|
+
const trimmed = [];
|
|
853
|
+
for (const e of out) {
|
|
854
|
+
if (over && e.kind === 'work' && !e.more) { over -= 1; continue; }
|
|
855
|
+
trimmed.push(e);
|
|
856
|
+
}
|
|
857
|
+
return trimmed;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// HOW MANY MESSAGES THAT IS, which is not how many blocks are on the screen.
|
|
861
|
+
// Shape B draws a reply that stopped twice for a tool as three blocks in the
|
|
862
|
+
// order they happened, and that is right: it is what the session actually did.
|
|
863
|
+
// But a person counting would say the agent sent ONE message, so the header
|
|
864
|
+
// that reads "40 messages, oldest first" counts messages and not blocks.
|
|
865
|
+
// Getting this wrong would put "128 messages" over a conversation she
|
|
866
|
+
// remembers having six times.
|
|
867
|
+
//
|
|
868
|
+
// The reader marks every block that is a continuation of the one above it, and
|
|
869
|
+
// it is the only thing that knows: a block is a continuation when nothing but
|
|
870
|
+
// work stands between it and the same side's last block, and when nothing
|
|
871
|
+
// ARRIVED in the gap. A message that arrived ends the answer above it even
|
|
872
|
+
// when it is never drawn, which is the whole of's first defect.
|
|
873
|
+
export function saidCount(events) {
|
|
874
|
+
let n = 0;
|
|
875
|
+
for (const e of events ?? []) {
|
|
876
|
+
if (!e || e.kind === 'work' || !e.text || e.same) continue;
|
|
877
|
+
n += 1;
|
|
878
|
+
}
|
|
879
|
+
return n;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// TOOL-CALL SCAFFOLDING THAT LEAKED INTO THE PROSE.
|
|
883
|
+
//
|
|
884
|
+
// A provider writing a tool call sometimes emits its closing tags as ordinary
|
|
885
|
+
// text, and the field it was writing then holds the answer with the machinery
|
|
886
|
+
// of the call still stuck to the end. On w-b108b1d596 the result that reached
|
|
887
|
+
// her pane ended "7,369 tests both times.</result>\n</invoke>" and she read it.
|
|
888
|
+
// Measured across her store, 2026-09-23: 49 field writes on 27 rows carry it,
|
|
889
|
+
// and 50 messages on screen.
|
|
890
|
+
//
|
|
891
|
+
// IT ALSO BREAKS THE FOLD THAT SAYS ONE THING ONCE. That run noticed the leak
|
|
892
|
+
// and wrote its result again, clean. The fold keeps the FULLER of two copies of
|
|
893
|
+
// one answer, and the junk made the broken copy the fuller one, so the leak was
|
|
894
|
+
// what stayed on the screen and the good rewrite was the copy thrown away. That
|
|
895
|
+
// is the row she photographed, and it is why the cut happens before anything
|
|
896
|
+
// compares two messages.
|
|
897
|
+
//
|
|
898
|
+
// The cut runs from the first tag to the end, because nothing after one is
|
|
899
|
+
// prose: it is the rest of a call that was never meant to be said out loud. On
|
|
900
|
+
// that row it was a `note` parameter, which the next write then sent properly.
|
|
901
|
+
const LEAKED = /<\/(?:antml:)?(?:result|invoke|parameter|function_calls)>|<(?:antml:)?parameter\s+name=/;
|
|
902
|
+
|
|
903
|
+
// A TAG INSIDE CODE WAS TYPED ON PURPOSE AND IS NOT A LEAK. The row that asked
|
|
904
|
+
// for this is the proof: the answer explaining the bug to her quotes the tags in
|
|
905
|
+
// backticks, mid-paragraph, and a cut at the first one anywhere would have eaten
|
|
906
|
+
// the rest of her answer to make the example go away. So the spans in backticks,
|
|
907
|
+
// inline and fenced, are stepped over and only prose is cut.
|
|
908
|
+
function firstLeak(text) {
|
|
909
|
+
const code = [];
|
|
910
|
+
let i = 0;
|
|
911
|
+
while (i < text.length) {
|
|
912
|
+
if (text.startsWith('```', i)) {
|
|
913
|
+
const end = text.indexOf('```', i + 3);
|
|
914
|
+
const stop = end < 0 ? text.length : end + 3;
|
|
915
|
+
code.push([i, stop]); i = stop; continue;
|
|
916
|
+
}
|
|
917
|
+
if (text[i] === '`') {
|
|
918
|
+
const end = text.indexOf('`', i + 1);
|
|
919
|
+
const stop = end < 0 ? i + 1 : end + 1;
|
|
920
|
+
code.push([i, stop]); i = stop; continue;
|
|
921
|
+
}
|
|
922
|
+
i += 1;
|
|
923
|
+
}
|
|
924
|
+
const re = new RegExp(LEAKED.source, 'g');
|
|
925
|
+
let m = re.exec(text);
|
|
926
|
+
while (m) {
|
|
927
|
+
if (!code.some(([s, e]) => m.index >= s && m.index < e)) return m.index;
|
|
928
|
+
m = re.exec(text);
|
|
929
|
+
}
|
|
930
|
+
return -1;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
export function unleaked(text) {
|
|
934
|
+
if (typeof text !== 'string') return text;
|
|
935
|
+
const hit = firstLeak(text);
|
|
936
|
+
return hit < 0 ? text : text.slice(0, hit).trimEnd();
|
|
937
|
+
}
|