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,148 @@
|
|
|
1
|
+
// WHICH CLAUDE PLAN THIS MACHINE IS SIGNED IN ON, and what Agentbox should do
|
|
2
|
+
// about it. Written for, 2026-08-29, on the one bullet from a tester's
|
|
3
|
+
// onboarding that no session had ever looked at: "not on max plan?"
|
|
4
|
+
//
|
|
5
|
+
// THE PROBLEM. Agentbox runs several Claude Code sessions at once and the number
|
|
6
|
+
// is a flat default (`maxConcurrentSessions: 3`, main/config.mjs) that was
|
|
7
|
+
// chosen on a Max subscription and has never been anything else. Nothing
|
|
8
|
+
// anywhere in the app mentions a plan: not the walk, not the Accounts page, not
|
|
9
|
+
// a row that dies. So a person on a smaller plan gets three sessions started on
|
|
10
|
+
// their behalf, all three burn the same allowance, and what they see is agents
|
|
11
|
+
// that are slow and rows that fail for a reason nobody named. That is what the
|
|
12
|
+
// founder was asking out loud while she watched a tester use it.
|
|
13
|
+
//
|
|
14
|
+
// THE ANSWER IS ALREADY ON THE DISK. Claude Code writes its own account profile
|
|
15
|
+
// into its config file and refreshes it as it runs. MEASURED on her Mac
|
|
16
|
+
// 2026-08-29 20:5x, both of her accounts:
|
|
17
|
+
//
|
|
18
|
+
// ~/.claude.json oauthAccount.organizationType claude_max
|
|
19
|
+
// oauthAccount.organizationRateLimitTier default_claude_max_20x
|
|
20
|
+
// profileFetchedAt 2026-08-29 05:00Z
|
|
21
|
+
// ~/.claude-second/.claude.json the same two values
|
|
22
|
+
// profileFetchedAt 2026-08-29 20:47Z
|
|
23
|
+
//
|
|
24
|
+
// Both fetched within the day, so the field is live rather than a one-time
|
|
25
|
+
// stamp from a login months ago, and it is the honest source: it is Claude
|
|
26
|
+
// Code's own answer about its own subscription, not a guess of ours.
|
|
27
|
+
//
|
|
28
|
+
// WHERE THE FILE IS. Claude Code keeps its config at CLAUDE_CONFIG_DIR when
|
|
29
|
+
// that is set, and at the home folder when it is not. Agentbox's default account
|
|
30
|
+
// runs with nothing set, so its file is `~/.claude.json`; a second account runs
|
|
31
|
+
// with CLAUDE_CONFIG_DIR pointing at a folder, so its file is inside that
|
|
32
|
+
// folder (main/supervisor.mjs sets exactly that per spawn). Both are verified
|
|
33
|
+
// above. Note that `~/.claude/.claude.json` also exists on her Mac and is NINE
|
|
34
|
+
// DAYS STALE — it is what a run with CLAUDE_CONFIG_DIR=~/.claude left behind.
|
|
35
|
+
// It is not read for the default account, and that is deliberate: the rule here
|
|
36
|
+
// is Claude Code's own rule, not a search for the likeliest looking file.
|
|
37
|
+
//
|
|
38
|
+
// WHAT THIS DELIBERATELY DOES NOT DO. It does not guess the strings for plans
|
|
39
|
+
// nobody here has seen. Only one plan has ever been measured on this machine,
|
|
40
|
+
// and inventing `default_claude_pro` in a table would be a number nobody
|
|
41
|
+
// measured. So `max` is decided by looking for the word max, the label is
|
|
42
|
+
// derived from the raw string mechanically, and anything unreadable comes back
|
|
43
|
+
// `known: false` and changes nothing anywhere.
|
|
44
|
+
|
|
45
|
+
import fs from 'node:fs';
|
|
46
|
+
import os from 'node:os';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* WHERE CLAUDE CODE'S CONFIG FILE IS FOR ONE OF AGENTBOX'S ACCOUNTS.
|
|
51
|
+
*
|
|
52
|
+
* 'default' is the account with no CLAUDE_CONFIG_DIR, so its file is beside
|
|
53
|
+
* the home folder rather than inside `~/.claude`. Anything else is the folder
|
|
54
|
+
* CLAUDE_CONFIG_DIR names, and the file is inside it. */
|
|
55
|
+
export function planFile(profile, home = os.homedir()) {
|
|
56
|
+
if (!profile || profile === 'default') return path.join(home, '.claude.json');
|
|
57
|
+
const dir = profile.startsWith('~/') ? path.join(home, profile.slice(2)) : profile;
|
|
58
|
+
return path.join(dir, '.claude.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* THE WORDS A PERSON WOULD USE FOR A RATE LIMIT TIER, worked out from the
|
|
63
|
+
* string rather than from a table, so a plan this code has never seen still
|
|
64
|
+
* reads as itself instead of as "unknown".
|
|
65
|
+
*
|
|
66
|
+
* `default_claude_max_20x` -> `Max 20x`. The `default_` and `claude_` parts
|
|
67
|
+
* are Anthropic's own scaffolding around the name and say nothing to anybody
|
|
68
|
+
* looking at a settings page. */
|
|
69
|
+
export function planLabel(raw) {
|
|
70
|
+
const s = String(raw ?? '').trim();
|
|
71
|
+
if (!s) return null;
|
|
72
|
+
const bare = s.replace(/^default_/, '').replace(/^claude_/, '');
|
|
73
|
+
if (!bare) return null;
|
|
74
|
+
return bare
|
|
75
|
+
.split('_')
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.map((word) => (/^\d/.test(word) ? word : word.charAt(0).toUpperCase() + word.slice(1)))
|
|
78
|
+
.join(' ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* WHETHER A TIER STRING IS A MAX PLAN. Whole word, so a plan that merely
|
|
83
|
+
* contains the letters (a hypothetical `maximum_something`) is not mistaken
|
|
84
|
+
* for one. */
|
|
85
|
+
export function isMaxTier(raw) {
|
|
86
|
+
return /(^|_)max(_|$)/i.test(String(raw ?? ''));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* WHAT PLAN ONE ACCOUNT IS ON.
|
|
91
|
+
*
|
|
92
|
+
* Never throws and never guesses. A missing file, a half-written one, a login
|
|
93
|
+
* Claude Code has not fetched a profile for yet: all of them come back
|
|
94
|
+
* `known: false`, which every caller here treats as "leave everything exactly
|
|
95
|
+
* as it was". */
|
|
96
|
+
export function readPlan(profile, { home = os.homedir(), file = null } = {}) {
|
|
97
|
+
const at = file ?? planFile(profile, home);
|
|
98
|
+
const none = { known: false, max: false, label: null, tier: null, from: null, file: at };
|
|
99
|
+
let account = null;
|
|
100
|
+
try {
|
|
101
|
+
account = JSON.parse(fs.readFileSync(at, 'utf8'))?.oauthAccount ?? null;
|
|
102
|
+
} catch {
|
|
103
|
+
return none;
|
|
104
|
+
}
|
|
105
|
+
if (!account || typeof account !== 'object') return none;
|
|
106
|
+
// THE ORDER IS MOST SPECIFIC FIRST. A rate limit tier names the plan AND its
|
|
107
|
+
// size (`max_20x` against `max_5x`), which is the thing worth showing; the
|
|
108
|
+
// organization type only says which family it is in. The user tier is read
|
|
109
|
+
// last because it is null on both accounts measured here and exists for seats
|
|
110
|
+
// inside an organization.
|
|
111
|
+
const tier = [account.organizationRateLimitTier, account.userRateLimitTier, account.organizationType]
|
|
112
|
+
.find((v) => typeof v === 'string' && v.trim());
|
|
113
|
+
if (!tier) return none;
|
|
114
|
+
const from = tier === account.organizationRateLimitTier
|
|
115
|
+
? 'org-tier'
|
|
116
|
+
: tier === account.userRateLimitTier ? 'user-tier' : 'org-type';
|
|
117
|
+
return { known: true, max: isMaxTier(tier), label: planLabel(tier), tier, from, file: at };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* THE PLAN ON EVERY ACCOUNT AGENTBOX IS CONFIGURED TO RUN ON, in the same order
|
|
122
|
+
* the supervisor round-robins them. */
|
|
123
|
+
export function readPlans(profiles, { home = os.homedir() } = {}) {
|
|
124
|
+
const list = (Array.isArray(profiles) && profiles.length ? profiles : ['default']).map((p) => p || 'default');
|
|
125
|
+
return list.map((profile) => ({ profile, ...readPlan(profile, { home }) }));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* HOW MANY SESSIONS AT ONCE, WHEN NOBODY HAS SAID.
|
|
130
|
+
*
|
|
131
|
+
* `fallback` is the number the app has always used and is what comes back in
|
|
132
|
+
* every case except one: at least one account is readable and is NOT a Max
|
|
133
|
+
* plan. Then it is one at a time.
|
|
134
|
+
*
|
|
135
|
+
* WHY THE SMALLEST ACCOUNT DECIDES. The cap is ONE number applied per account
|
|
136
|
+
* (main/supervisor.mjs multiplies it by the live accounts), so there is no way
|
|
137
|
+
* here to give a Max account three and a smaller one fewer. Between over- and
|
|
138
|
+
* under-running somebody's allowance, the under-run is the one that still
|
|
139
|
+
* leaves them a working app, and the number is a stepper on the Agents page
|
|
140
|
+
* that they can put back up.
|
|
141
|
+
*
|
|
142
|
+
* AND AN UNREADABLE ACCOUNT NEVER LOWERS IT. Somebody whose config file we
|
|
143
|
+
* cannot parse gets exactly the app they had before this existed. */
|
|
144
|
+
export function slotsForPlans(plans, fallback = 3) {
|
|
145
|
+
const known = (plans ?? []).filter((p) => p.known);
|
|
146
|
+
if (!known.length) return fallback;
|
|
147
|
+
return known.every((p) => p.max) ? fallback : 1;
|
|
148
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// RUNNING `/usage`, WHICH TAKES TWENTY-FIVE SECONDS AND MUST NEVER BE WAITED ON.
|
|
2
|
+
//
|
|
3
|
+
// The pure half is shared/claude-usage.mjs: the text in, the limits out, the
|
|
4
|
+
// words the pill prints. This is the half that shells out, and everything about
|
|
5
|
+
// its shape comes from one measurement.
|
|
6
|
+
//
|
|
7
|
+
// TIMED TWICE ON HER MAC, 2026-08-31: 23.2s and 28.0s for `claude -p '/usage'`.
|
|
8
|
+
// The percentage did not move between the two, which is the other half of the
|
|
9
|
+
// answer: this is not a number that changes second to second, so nothing is lost
|
|
10
|
+
// by reading it every few minutes and everything is lost by reading it on a
|
|
11
|
+
// timer the window waits for.
|
|
12
|
+
//
|
|
13
|
+
// SO IT IS A CACHE WITH A BACKGROUND REFRESH, and never a call anything awaits:
|
|
14
|
+
//
|
|
15
|
+
// - `read` answers instantly, always, from the last measurement or with null.
|
|
16
|
+
// It never blocks and never throws.
|
|
17
|
+
// - A refresh is kicked off when the cached figure is older than STALE_MS,
|
|
18
|
+
// and only ever one at a time.
|
|
19
|
+
// - The window is told when a new one lands, the same way everything else in
|
|
20
|
+
// this app tells it: a push.
|
|
21
|
+
//
|
|
22
|
+
// WHY NOT A SESSION. Agentbox spawns real workers through the supervisor and could
|
|
23
|
+
// have asked one of them. It must not: a worker is a unit of her work, it lands
|
|
24
|
+
// in her inbox and burns a slot in the fleet, and this is a status reading. It
|
|
25
|
+
// gets its own short-lived child process with no store, no brief and no row.
|
|
26
|
+
//
|
|
27
|
+
// WHAT IS NOT SENT ANYWHERE. The output carries her usage figures and nothing
|
|
28
|
+
// else leaves this machine because of it; `main/analytics.mjs` is the only file
|
|
29
|
+
// that sends, and none of its eight counts is fed from here.
|
|
30
|
+
|
|
31
|
+
import { execFile } from 'node:child_process';
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import os from 'node:os';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { readUsage } from '../shared/claude-usage.mjs';
|
|
36
|
+
import { localZone } from '../shared/usage.mjs';
|
|
37
|
+
|
|
38
|
+
// WHERE THE READING RUNS, AND WHY IT IS NOT "WHEREVER THE APP IS". An app
|
|
39
|
+
// opened from Finder has `/` as its working directory, and this used to spawn
|
|
40
|
+
// Claude Code without saying one, so it inherited that. Claude Code then did
|
|
41
|
+
// what it does in any project folder it is started in: it indexed the folder,
|
|
42
|
+
// which was the whole disk. Measured on her Mac the first time she opened the
|
|
43
|
+
// downloaded 0.1.3: a Claude Code transcript with `cwd: "/"` started at
|
|
44
|
+
// 18:07:31, and TCC recorded "the app" asking for files on a network volume at
|
|
45
|
+
// 18:07:41 and for her Apple Music library at 18:07:45. Those are `/Volumes/*`
|
|
46
|
+
// and `~/Music` being listed, ten seconds into a session whose only job was to
|
|
47
|
+
// print her usage.
|
|
48
|
+
//
|
|
49
|
+
// So the reading runs inside a folder of its own that holds nothing, under the
|
|
50
|
+
// temp directory, which macOS guards with no permission panel at all. Claude
|
|
51
|
+
// Code indexes an empty folder and asks for nothing. The same rule is already
|
|
52
|
+
// what `main/agents.mjs` does for `claude agents --json`.
|
|
53
|
+
export function usageCwd() {
|
|
54
|
+
const dir = path.join(os.tmpdir(), 'agentbox-usage');
|
|
55
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch { /* the fallback below still keeps it off the disk root */ }
|
|
56
|
+
return fs.existsSync(dir) ? dir : os.tmpdir();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// How old a reading may be before the next look at it starts a refresh. Five
|
|
60
|
+
// minutes against a five hour limit is about a percent of it, which is finer
|
|
61
|
+
// than the number itself is reported to.
|
|
62
|
+
export const STALE_MS = 5 * 60_000;
|
|
63
|
+
|
|
64
|
+
// Long enough for the slowest run measured plus most of the same again. Past
|
|
65
|
+
// this the reading is not late, it is not coming.
|
|
66
|
+
const TIMEOUT_MS = 90_000;
|
|
67
|
+
|
|
68
|
+
// After a failure, how long before it is worth trying again. A machine that is
|
|
69
|
+
// signed out, rate limited, or has no Claude Code at all would otherwise be
|
|
70
|
+
// asked every five minutes forever.
|
|
71
|
+
const AFTER_A_MISS_MS = 30 * 60_000;
|
|
72
|
+
|
|
73
|
+
export class ClaudeUsage {
|
|
74
|
+
/**
|
|
75
|
+
* @param {object} how @param { => string|null} how.bin Where Claude Code
|
|
76
|
+
* is, asked each time rather than captured: it can be installed while the
|
|
77
|
+
* app is running, and the settings screen already promises that works.
|
|
78
|
+
* @param { => void} [how.onChange] Told when a new reading lands, so the
|
|
79
|
+
* window redraws. Never called for a refresh that changed nothing.
|
|
80
|
+
*/
|
|
81
|
+
constructor({ bin, onChange = () => {}, run = execFile, now = () => Date.now(), where = usageCwd } = {}) {
|
|
82
|
+
this._bin = bin;
|
|
83
|
+
this._onChange = onChange;
|
|
84
|
+
this._run = run;
|
|
85
|
+
this._now = now;
|
|
86
|
+
this._where = where;
|
|
87
|
+
/** @type {{ limits: any[], at: number, zone: string|null }|null} */
|
|
88
|
+
this._last = null;
|
|
89
|
+
this._busy = false;
|
|
90
|
+
this._missedUntil = 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* THE LAST READING, INSTANTLY, and a refresh started if it has gone stale.
|
|
95
|
+
*
|
|
96
|
+
* Null until the first one lands, which is the honest answer for the first few
|
|
97
|
+
* seconds of a launch: nothing is known yet, so the corner draws nothing rather
|
|
98
|
+
* than a pill full of dashes.
|
|
99
|
+
*/
|
|
100
|
+
read() {
|
|
101
|
+
this._maybeRefresh();
|
|
102
|
+
return this._last ? { limits: this._last.limits, at: this._last.at } : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The secondary subscription can show its last reading without starting
|
|
106
|
+
// another coding process merely to populate the sidebar.
|
|
107
|
+
peek() { return this._last ? { limits: this._last.limits, at: this._last.at } : null; }
|
|
108
|
+
|
|
109
|
+
_maybeRefresh() {
|
|
110
|
+
const now = this._now();
|
|
111
|
+
if (this._busy || now < this._missedUntil) return;
|
|
112
|
+
if (this._last && now - this._last.at < STALE_MS) return;
|
|
113
|
+
this._refresh();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Ask again now, whatever the cache says. The settings screen's own button. */
|
|
117
|
+
refreshNow() {
|
|
118
|
+
this._missedUntil = 0;
|
|
119
|
+
if (!this._busy) this._refresh();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_refresh() {
|
|
123
|
+
const bin = (() => { try { return this._bin(); } catch { return null; } })();
|
|
124
|
+
if (!bin) { this._missed(); return; }
|
|
125
|
+
this._busy = true;
|
|
126
|
+
this._run(
|
|
127
|
+
bin,
|
|
128
|
+
['-p', '/usage', '--output-format', 'text'],
|
|
129
|
+
// `cwd` is the whole fix for the permission panels; see usageCwd above.
|
|
130
|
+
{ cwd: this._where(), timeout: TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, encoding: 'utf8' },
|
|
131
|
+
(err, stdout) => {
|
|
132
|
+
this._busy = false;
|
|
133
|
+
// A COMMAND THAT FAILED LEAVES THE OLD READING ALONE. It is stale rather
|
|
134
|
+
// than wrong, and a corner that empties itself every time the network
|
|
135
|
+
// hiccups is worse than one that is five minutes behind.
|
|
136
|
+
if (err && !stdout) { this._missed(); return; }
|
|
137
|
+
const limits = readUsage(stdout, this._now(), localZone());
|
|
138
|
+
if (!limits.length) { this._missed(); return; }
|
|
139
|
+
const before = JSON.stringify(this._last?.limits ?? null);
|
|
140
|
+
this._last = { limits, at: this._now(), zone: localZone() };
|
|
141
|
+
if (JSON.stringify(limits) !== before) this._onChange();
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_missed() {
|
|
147
|
+
this._missedUntil = this._now() + AFTER_A_MISS_MS;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// WHAT A RUN CHANGED, WRITTEN DOWN AS A FILE SHE CAN OPEN.
|
|
2
|
+
//
|
|
3
|
+
// This is the half that runs when the session ends. The pane that draws it is
|
|
4
|
+
// renderer/src/components/CodeArtifact.tsx and the arithmetic it draws with is
|
|
5
|
+
// renderer/src/code-artifact.ts; nothing here draws anything.
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS FILE READS, AND WHAT IT NO LONGER READS ALONE. The conversation:
|
|
8
|
+
// every edit a Claude Code session makes through the editing tool is a
|
|
9
|
+
// `tool_use` block in its own transcript, carrying the exact text it replaced
|
|
10
|
+
// and the exact text it wrote, stamped, in order. It is the only place the
|
|
11
|
+
// agent's own sentence beside a change exists, and the only reading available
|
|
12
|
+
// on a product with no repository.
|
|
13
|
+
//
|
|
14
|
+
// IT IS NOT ENOUGH ON ITS OWN, AND THAT WAS MEASURED, NOT ARGUED. An agent
|
|
15
|
+
// that rewrites a file with sed or a heredoc puts nothing in its transcript,
|
|
16
|
+
// and on her Mac 2026-08-24 that was 101 of the 105 runs that changed a file
|
|
17
|
+
// over two days. Every one of those left David an empty card. So the disk is
|
|
18
|
+
// read too, by main/git-change.mjs, and `mergeChange` below puts the two
|
|
19
|
+
// together: git's lines, the conversation's words, git winning where they
|
|
20
|
+
// disagree.
|
|
21
|
+
//
|
|
22
|
+
// WHAT IS STILL REFUSED, from her 2026-08-23 answer: a branch diffed against
|
|
23
|
+
// main, and a slab of raw patch handed to the window. What replaced it is a
|
|
24
|
+
// photograph of this run's own checkout, taken when the run spawns.
|
|
25
|
+
// tests/the-old-git-diff-screen-stays-gone.test.mjs holds the line.
|
|
26
|
+
//
|
|
27
|
+
// MEASURED ON HER MAC, 2026-08-23, over the sixty largest conversations: 4,442
|
|
28
|
+
// file writes, 99.7% with the agent's own prose earlier in the same message.
|
|
29
|
+
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { machineryPath } from './store/home.mjs';
|
|
32
|
+
|
|
33
|
+
/** The tools that change a file on disk. Read, Bash and the rest are not edits. */
|
|
34
|
+
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
35
|
+
|
|
36
|
+
/** Source we would never show her: the store's own bookkeeping and scratch. */
|
|
37
|
+
const NOT_CODE = /(^|\/)(node_modules|\.git)\//;
|
|
38
|
+
|
|
39
|
+
// AND SCRATCH, WHICH IS NOT THE PRODUCT. A run writes throwaway scripts to
|
|
40
|
+
// /tmp constantly and one of them landed in the tree as a folder called `tmp`
|
|
41
|
+
// on the first real change we read (18 files, and one of them was
|
|
42
|
+
// /tmp/shelf-shots.mjs). Dropped rather than shown: what David is being asked
|
|
43
|
+
// to review is the product, and a file nobody will ever open again reads as
|
|
44
|
+
// work he has to account for.
|
|
45
|
+
const SCRATCH = /^\/(private\/)?(tmp|var\/folders)\//;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A root, spelt the way the filesystem really spells it.
|
|
49
|
+
*
|
|
50
|
+
* Resolved off the DEEPEST ANCESTOR THAT STILL EXISTS rather than off the root
|
|
51
|
+
* itself, because this runs after the work is over: a worktree can be removed,
|
|
52
|
+
* a scratch folder cleaned up, a branch checked out elsewhere, and a change she
|
|
53
|
+
* can still open must not depend on the checkout outliving the run. `/tmp/gone`
|
|
54
|
+
* resolves through `/tmp` to `/private/tmp/gone` with nothing on disk at all.
|
|
55
|
+
*/
|
|
56
|
+
function realRoot(root) {
|
|
57
|
+
let head = path.resolve(root);
|
|
58
|
+
const tail = [];
|
|
59
|
+
for (let i = 0; i < 64; i += 1) {
|
|
60
|
+
try {
|
|
61
|
+
const real = fs.realpathSync(head);
|
|
62
|
+
const full = tail.length ? path.join(real, ...tail) : real;
|
|
63
|
+
return full === path.resolve(root) ? null : full;
|
|
64
|
+
} catch { /* not there; try its parent */ }
|
|
65
|
+
const parent = path.dirname(head);
|
|
66
|
+
if (parent === head) return null;
|
|
67
|
+
tail.unshift(path.basename(head));
|
|
68
|
+
head = parent;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function lines(text) {
|
|
74
|
+
const s = String(text ?? '');
|
|
75
|
+
if (!s) return [];
|
|
76
|
+
const out = s.split('\n');
|
|
77
|
+
// A trailing newline is a line terminator, not an empty last line.
|
|
78
|
+
if (out.length > 1 && out[out.length - 1] === '') out.pop();
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The rows of one edit: what came out, what went in, and the lines around them
|
|
84
|
+
* that did not move.
|
|
85
|
+
*
|
|
86
|
+
* A plain longest-common-subsequence over the two texts, which is what makes a
|
|
87
|
+
* one-line insertion read as one green line inside its own neighbours rather
|
|
88
|
+
* than as the whole block replaced. `old` empty (a Write) is every line green.
|
|
89
|
+
*/
|
|
90
|
+
export function rowsForEdit(oldText, newText) {
|
|
91
|
+
const a = lines(oldText);
|
|
92
|
+
const b = lines(newText);
|
|
93
|
+
if (!a.length) return b.map((l) => ['+', l]);
|
|
94
|
+
if (!b.length) return a.map((l) => ['-', l]);
|
|
95
|
+
|
|
96
|
+
// A guard on the table rather than on the file: a 400x400 table is 160k
|
|
97
|
+
// cells and instant, a 20k x 20k one is 400M and would hang the exit
|
|
98
|
+
// handler. Past it the edit is shown as a straight replacement, which is
|
|
99
|
+
// what a whole-file rewrite honestly is.
|
|
100
|
+
if (a.length * b.length > 4_000_000) {
|
|
101
|
+
return [...a.map((l) => ['-', l]), ...b.map((l) => ['+', l])];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const n = a.length, m = b.length;
|
|
105
|
+
const table = new Uint32Array((n + 1) * (m + 1));
|
|
106
|
+
const at = (i, j) => i * (m + 1) + j;
|
|
107
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
108
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
109
|
+
table[at(i, j)] = a[i] === b[j]
|
|
110
|
+
? table[at(i + 1, j + 1)] + 1
|
|
111
|
+
: Math.max(table[at(i + 1, j)], table[at(i, j + 1)]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const rows = [];
|
|
115
|
+
let i = 0, j = 0;
|
|
116
|
+
while (i < n && j < m) {
|
|
117
|
+
if (a[i] === b[j]) { rows.push(['=', a[i]]); i++; j++; }
|
|
118
|
+
else if (table[at(i + 1, j)] >= table[at(i, j + 1)]) { rows.push(['-', a[i]]); i++; }
|
|
119
|
+
else { rows.push(['+', b[j]]); j++; }
|
|
120
|
+
}
|
|
121
|
+
while (i < n) { rows.push(['-', a[i]]); i++; }
|
|
122
|
+
while (j < m) { rows.push(['+', b[j]]); j++; }
|
|
123
|
+
return rows;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every edit inside one tool call, as one hunk each. */
|
|
127
|
+
function hunksFromCall(input) {
|
|
128
|
+
const out = [];
|
|
129
|
+
if (Array.isArray(input?.edits)) {
|
|
130
|
+
for (const e of input.edits) out.push(rowsForEdit(e?.old_string, e?.new_string));
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
if (typeof input?.new_string === 'string' || typeof input?.old_string === 'string') {
|
|
134
|
+
out.push(rowsForEdit(input.old_string, input.new_string));
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
if (typeof input?.content === 'string') {
|
|
138
|
+
out.push(rowsForEdit('', input.content));
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
if (typeof input?.new_source === 'string') {
|
|
142
|
+
out.push(rowsForEdit(input.old_source ?? '', input.new_source));
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The agent's own sentence, taken from the text blocks of the same message the
|
|
150
|
+
* edit was in. One line, because that is all the file ever needs to carry.
|
|
151
|
+
*/
|
|
152
|
+
function proseFrom(content) {
|
|
153
|
+
if (!Array.isArray(content)) return '';
|
|
154
|
+
const said = content.filter((b) => b?.type === 'text').map((b) => String(b.text ?? '')).join('\n').trim();
|
|
155
|
+
if (!said) return '';
|
|
156
|
+
const first = said.split('\n').map((l) => l.trim()).filter(Boolean).pop() ?? '';
|
|
157
|
+
return first.slice(0, 400);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A change, built out of one session's own transcript.
|
|
162
|
+
*
|
|
163
|
+
* `roots` are the checkouts this product's work happens in. Anything written
|
|
164
|
+
* outside all of them is somebody else's file or /tmp scratch and is dropped,
|
|
165
|
+
* which is the same rule run-files.ts already applies to the chips on a card.
|
|
166
|
+
* When no root matches at all the absolute path is kept, because a change with
|
|
167
|
+
* no files in it is worse than one with a long name in it.
|
|
168
|
+
*/
|
|
169
|
+
/**
|
|
170
|
+
* The tally the reader fills in. It owns the two rules that decide whether a
|
|
171
|
+
* file belongs in her artifact at all — inside one of this run's own roots, and
|
|
172
|
+
* not scratch — and the arithmetic underneath the panel.
|
|
173
|
+
*/
|
|
174
|
+
function collector({ roots = [] } = {}) {
|
|
175
|
+
const files = new Map();
|
|
176
|
+
const totals = { plus: 0, minus: 0, editCount: 0, withProse: 0, startedAt: 0, endedAt: 0 };
|
|
177
|
+
|
|
178
|
+
// EACH ROOT AND ALSO WHAT IT REALLY IS, because a tool may report either
|
|
179
|
+
// spelling. Measured 2026-08-26 in a repo under /tmp: the app knew the
|
|
180
|
+
// checkout as `/tmp/proof/widget-repo` and an edit written against
|
|
181
|
+
// `/private/tmp/proof/widget-repo/math.js` matched no root, fell through to
|
|
182
|
+
// the scratch rule below and was dropped — a run that really did change her
|
|
183
|
+
// code finishing with an artifact that did not mention it.
|
|
184
|
+
//
|
|
185
|
+
// macOS is the common case (/tmp is a symlink to /private/tmp, and so is
|
|
186
|
+
// /var), but nothing here is macOS-specific: any checkout reached through a
|
|
187
|
+
// symlink has the same shape, and matching both spellings costs one stat per
|
|
188
|
+
// root, once.
|
|
189
|
+
const spellings = new Set();
|
|
190
|
+
for (const r of roots.filter(Boolean)) {
|
|
191
|
+
spellings.add(r);
|
|
192
|
+
const real = realRoot(r);
|
|
193
|
+
if (real) spellings.add(real);
|
|
194
|
+
}
|
|
195
|
+
const sorted = [...spellings].map((r) => (r.endsWith('/') ? r : `${r}/`)).sort((a, b) => b.length - a.length);
|
|
196
|
+
|
|
197
|
+
const relative = (abs) => {
|
|
198
|
+
for (const root of sorted) if (abs.startsWith(root)) return abs.slice(root.length);
|
|
199
|
+
return null;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
files,
|
|
204
|
+
totals,
|
|
205
|
+
add(abs, rows, { said = '', stamp = 0 } = {}) {
|
|
206
|
+
abs = String(abs ?? '').trim();
|
|
207
|
+
if (!abs || NOT_CODE.test(abs)) return;
|
|
208
|
+
const rel = relative(abs);
|
|
209
|
+
// Inside one of this run's own roots it is the product, wherever it
|
|
210
|
+
// sits. Outside all of them AND under scratch, it is nobody's.
|
|
211
|
+
if (rel === null && SCRATCH.test(abs)) return;
|
|
212
|
+
if (!rows.length) return;
|
|
213
|
+
const p = rows.filter((r) => r[0] === '+').length;
|
|
214
|
+
const m = rows.filter((r) => r[0] === '-').length;
|
|
215
|
+
if (!p && !m) return;
|
|
216
|
+
const key = rel ?? abs;
|
|
217
|
+
const file = files.get(key) ?? { path: key, abs, hunks: [], plus: 0, minus: 0, at: stamp };
|
|
218
|
+
file.hunks.push({ rows, said: said || undefined, at: stamp || undefined, plus: p, minus: m });
|
|
219
|
+
file.plus += p; file.minus += m;
|
|
220
|
+
files.set(key, file);
|
|
221
|
+
totals.plus += p; totals.minus += m;
|
|
222
|
+
totals.editCount += 1;
|
|
223
|
+
if (said) totals.withProse += 1;
|
|
224
|
+
if (stamp) {
|
|
225
|
+
if (!totals.startedAt || stamp < totals.startedAt) totals.startedAt = stamp;
|
|
226
|
+
if (stamp > totals.endedAt) totals.endedAt = stamp;
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
done() {
|
|
230
|
+
return {
|
|
231
|
+
files: [...files.values()],
|
|
232
|
+
// The checkouts this run's work happened in, so a change read back
|
|
233
|
+
// weeks later can still find the file a short path belongs to.
|
|
234
|
+
roots: [...roots].filter(Boolean),
|
|
235
|
+
plus: totals.plus,
|
|
236
|
+
minus: totals.minus,
|
|
237
|
+
editCount: totals.editCount,
|
|
238
|
+
withProse: totals.withProse,
|
|
239
|
+
startedAt: totals.startedAt || undefined,
|
|
240
|
+
endedAt: totals.endedAt || undefined,
|
|
241
|
+
};
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function changeFromTranscript(text, { roots = [], since = 0 } = {}) {
|
|
247
|
+
const into = collector({ roots });
|
|
248
|
+
|
|
249
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
250
|
+
if (!raw.trim()) continue;
|
|
251
|
+
let row;
|
|
252
|
+
try { row = JSON.parse(raw); } catch { continue; }
|
|
253
|
+
if (row?.type !== 'assistant') continue;
|
|
254
|
+
const content = row?.message?.content;
|
|
255
|
+
if (!Array.isArray(content)) continue;
|
|
256
|
+
const stamp = Date.parse(row?.timestamp ?? '') || 0;
|
|
257
|
+
if (since && stamp && stamp < since) continue;
|
|
258
|
+
const said = proseFrom(content);
|
|
259
|
+
for (const block of content) {
|
|
260
|
+
if (block?.type !== 'tool_use' || !EDIT_TOOLS.has(block.name)) continue;
|
|
261
|
+
// THE ABSOLUTE PATH IS KEPT BESIDE THE SHORT ONE, because a line she
|
|
262
|
+
// types into the change has to be written back to the file it came from,
|
|
263
|
+
// and `renderer/src/api.ts` on its own does not say which checkout that
|
|
264
|
+
// is. The short path is still what the tree and the crumb draw; this is
|
|
265
|
+
// never shown.
|
|
266
|
+
const abs = block?.input?.file_path ?? block?.input?.notebook_path ?? '';
|
|
267
|
+
for (const rows of hunksFromCall(block.input)) into.add(abs, rows, { said, stamp });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return into.done();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Where a run's change is written, product-relative. */
|
|
275
|
+
export function changePath(itemId) {
|
|
276
|
+
return `runs/${itemId}/the-change-it-made.change`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** An absolute path as the shortest of the run's roots can say it. */
|
|
280
|
+
export function shortPath(abs, roots = []) {
|
|
281
|
+
const sorted = [...roots].filter(Boolean).map((r) => (r.endsWith('/') ? r : `${r}/`)).sort((a, b) => b.length - a.length);
|
|
282
|
+
for (const root of sorted) if (String(abs).startsWith(root)) return String(abs).slice(root.length);
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The two readings of one run, put together.
|
|
288
|
+
*
|
|
289
|
+
* WHERE THEY DISAGREE, THE DISK WINS. The conversation only knows what the
|
|
290
|
+
* agent typed through the editing tool, and a run that used sed for half its
|
|
291
|
+
* work would otherwise show half a change, which reads as a finished one. What
|
|
292
|
+
* the transcript keeps is the agent's own sentence beside the hunk, so a file
|
|
293
|
+
* both of them have takes git's lines and the conversation's words.
|
|
294
|
+
*
|
|
295
|
+
* The transcript also keeps every file git cannot see: the drawings and
|
|
296
|
+
* reports a run writes into the product's own folder, which is not a checkout,
|
|
297
|
+
* and anything under a product with no repository at all.
|
|
298
|
+
*/
|
|
299
|
+
export function mergeChange(fromTranscript, repoChanges = [], { roots = [] } = {}) {
|
|
300
|
+
const files = new Map(fromTranscript.files.map((f) => [f.path, f]));
|
|
301
|
+
let fromDisk = 0;
|
|
302
|
+
for (const repo of repoChanges) {
|
|
303
|
+
if (!repo?.root) continue;
|
|
304
|
+
for (const file of repo.files ?? []) {
|
|
305
|
+
const abs = path.join(repo.root, file.path);
|
|
306
|
+
const key = shortPath(abs, roots) ?? file.path;
|
|
307
|
+
const said = files.get(key)?.hunks?.find((h) => h.said)?.said;
|
|
308
|
+
const at = files.get(key)?.at;
|
|
309
|
+
const hunks = file.hunks.map((h, i) => (i === 0 && said ? { ...h, said } : h));
|
|
310
|
+
files.set(key, { path: key, abs, hunks, plus: file.plus, minus: file.minus, at, fromDisk: true });
|
|
311
|
+
fromDisk += 1;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const out = [...files.values()];
|
|
315
|
+
const spawnedAt = repoChanges.map((r) => r?.at).filter(Boolean).sort()[0];
|
|
316
|
+
return {
|
|
317
|
+
...fromTranscript,
|
|
318
|
+
// A run whose conversation held no edits still has a clock: the moment its
|
|
319
|
+
// checkout was photographed, and the moment it was read back.
|
|
320
|
+
startedAt: fromTranscript.startedAt ?? spawnedAt,
|
|
321
|
+
endedAt: fromTranscript.endedAt ?? (fromDisk ? Date.now() : undefined),
|
|
322
|
+
files: out,
|
|
323
|
+
plus: out.reduce((n, f) => n + (f.plus ?? 0), 0),
|
|
324
|
+
minus: out.reduce((n, f) => n + (f.minus ?? 0), 0),
|
|
325
|
+
editCount: out.reduce((n, f) => n + (f.hunks?.length ?? 0), 0),
|
|
326
|
+
// How many of the files on the card came from reading the disk rather than
|
|
327
|
+
// the conversation. Not drawn; it is how the next round can measure this
|
|
328
|
+
// without re-deriving it.
|
|
329
|
+
fromDisk,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Build the change for one finished run and put it in the product's folders.
|
|
335
|
+
*
|
|
336
|
+
* `repos` are the snapshots taken of this run's checkouts when it spawned
|
|
337
|
+
* (main/git-change.mjs), already read back into changes. Passing none is the
|
|
338
|
+
* old behaviour exactly: the conversation alone.
|
|
339
|
+
*
|
|
340
|
+
* `change` IS THE CONVERSATION HALF, ALREADY READ, for a run whose conversation
|
|
341
|
+
* this file cannot read. `changeFromTranscript` speaks Claude Code's JSONL and
|
|
342
|
+
* only that; a Codex run's own edits arrive as app-server notifications and are
|
|
343
|
+
* read by `changeFromCodexTurn` in main/codex.mjs, which produces the same
|
|
344
|
+
* shape. When one is handed over, the transcript is not opened at all -- there
|
|
345
|
+
* is nothing in a Codex rollout this reader would find, and half an artifact
|
|
346
|
+
* looks exactly like a finished one.
|
|
347
|
+
*
|
|
348
|
+
* The caller owns the roots it prepared that half with. They are the same roots
|
|
349
|
+
* it passes here, and the repo roots this function adds are already among them
|
|
350
|
+
* on every path that does this (the checkout a run works in is its cwd).
|
|
351
|
+
*
|
|
352
|
+
* Returns the absolute path, or null when the run touched no code at all,
|
|
353
|
+
* which is most runs: a session that answered a question or drew a page has
|
|
354
|
+
* nothing to show here and must not leave an empty artifact behind.
|
|
355
|
+
*/
|
|
356
|
+
export function writeChangeForRun({ transcript, docsDir, itemId, roots = [], since = 0, repos = [], change: prepared = null }) {
|
|
357
|
+
if (!docsDir || !itemId) return null;
|
|
358
|
+
const allRoots = [...roots, ...repos.map((r) => r?.root).filter(Boolean)];
|
|
359
|
+
let said = prepared;
|
|
360
|
+
if (!said) {
|
|
361
|
+
let text = '';
|
|
362
|
+
try { text = fs.readFileSync(transcript, 'utf8'); } catch { text = ''; }
|
|
363
|
+
said = changeFromTranscript(text, { roots: allRoots, since });
|
|
364
|
+
}
|
|
365
|
+
const change = mergeChange(said, repos, { roots: allRoots });
|
|
366
|
+
if (!change.files.length) return null;
|
|
367
|
+
// THROUGH THE HOME, NOT INTO HER FOLDER. This line used to be
|
|
368
|
+
// `path.join(docsDir, ...)`, which is the one writer the move in
|
|
369
|
+
// main/store/home.mjs did not catch. So `runs/` was carried out of her folder
|
|
370
|
+
// once and then re-created in it by the next run that changed any code, which
|
|
371
|
+
// is why files kept reappearing there after the move was supposedly done.
|
|
372
|
+
const out = machineryPath(docsDir, changePath(itemId));
|
|
373
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
374
|
+
fs.writeFileSync(out, JSON.stringify({ item: itemId, ...change }, null, 1));
|
|
375
|
+
return out;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Read a change back off disk for the pane. */
|
|
379
|
+
export function readChange(file) {
|
|
380
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
381
|
+
if (!parsed || !Array.isArray(parsed.files)) throw new Error('that file is not a change');
|
|
382
|
+
return parsed;
|
|
383
|
+
}
|