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,561 @@
|
|
|
1
|
+
// Work items: the neutral substrate under a self-driving product.
|
|
2
|
+
//
|
|
3
|
+
// This module is the PURE core, zero-dep ESM, so every writer shares one set of
|
|
4
|
+
// rules: the MCP server (many processes at once), the app's main process, and a
|
|
5
|
+
// renderer that folds the same lines to draw the same list. No filesystem, no
|
|
6
|
+
// network, no Date.now except as an injected default.
|
|
7
|
+
//
|
|
8
|
+
// What lives here is MECHANISM ONLY: identity, lifecycle, claiming, leases,
|
|
9
|
+
// fencing. What a work item MEANS (a milestone, a question, a bug, a next move,
|
|
10
|
+
// whether a category has earned the right to act without asking) is deliberately
|
|
11
|
+
// NOT here. That judgment belongs to the surface above this one, which is going
|
|
12
|
+
// to iterate hard, and a substrate that encoded this week's theory of work would
|
|
13
|
+
// have to be rewritten every time the theory moved.
|
|
14
|
+
//
|
|
15
|
+
// The storage shape is the ledger the dashboard already uses: append-only jsonl,
|
|
16
|
+
// folded newest-wins per field. There is no read-modify-write, so a dozen server
|
|
17
|
+
// processes cannot lose each other's writes. Two rules make that safe rather
|
|
18
|
+
// than merely likely:
|
|
19
|
+
//
|
|
20
|
+
// FENCING Every claim takes a monotonically increasing epoch. Every later
|
|
21
|
+
// agent line carries the epoch it was written under, and the fold
|
|
22
|
+
// DISCARDS any agent line whose epoch is below the current one.
|
|
23
|
+
// Without it, lease expiry is a zombie-writer bug: worker A's lease
|
|
24
|
+
// expires mid-task, B claims, then A (alive, just slow) appends
|
|
25
|
+
// "done" and newest-wins believes A. With it, breaking a lease is
|
|
26
|
+
// safe rather than mostly safe.
|
|
27
|
+
//
|
|
28
|
+
// AUTHORITY The founder is not a worker, so a founder line carries NO epoch, is
|
|
29
|
+
// never discarded as stale, and outranks any agent line for the fields it sets.
|
|
30
|
+
// This is what lets the app write an answer while a worker still holds the
|
|
31
|
+
// claim: the answer lands, the worker's own status keeps flowing under its
|
|
32
|
+
// epoch, and neither erases the other. Same rule as beats in
|
|
33
|
+
// shared/dashboard.mjs.
|
|
34
|
+
|
|
35
|
+
/* ------------------------------- lifecycle ------------------------------- */
|
|
36
|
+
// The only states the claim machinery reasons about. Everything else about what
|
|
37
|
+
// a work item is (kind, labels) is free-form text the layer above interprets.
|
|
38
|
+
//
|
|
39
|
+
// blocked is not a synonym for "hard": it means this item cannot proceed until
|
|
40
|
+
// something outside it changes, which is the one distinction a puller has to
|
|
41
|
+
// make, since claiming a blocked item wastes a whole session.
|
|
42
|
+
export const WORK_ITEM_STATUSES = ['open', 'claimed', 'done', 'blocked'];
|
|
43
|
+
|
|
44
|
+
// Claimable states. done is terminal; claimed belongs to someone until its lease
|
|
45
|
+
// lapses; blocked is skipped by a filter-based pull but can still be claimed by
|
|
46
|
+
// id, because a worker sent specifically to unblock something should not be told
|
|
47
|
+
// no by its own queue.
|
|
48
|
+
const PULLABLE = ['open'];
|
|
49
|
+
|
|
50
|
+
export const WORK_ITEM_SOURCES = ['agent', 'founder', 'system'];
|
|
51
|
+
|
|
52
|
+
// CLAUDE CODE'S OWN SIX PERMISSION MODES, by its own values. Read off
|
|
53
|
+
// `claude --help` on 2.1.241 and off code.claude.com/docs/en/permission-modes,
|
|
54
|
+
// 2026-08-23, and every one of them handed to the real CLI to check it starts.
|
|
55
|
+
// The renderer, main/settings.mjs and main/answer-modes.mjs each have their own
|
|
56
|
+
// reason to know this list. IT IS NOT A LEDGER FIELD ANY MORE: what one reply is
|
|
57
|
+
// allowed to do moved out of this file entirely; the note further down says
|
|
58
|
+
// why. This list stays here because it is a plain browser-safe constant and
|
|
59
|
+
// everything that needs it already imports this module.
|
|
60
|
+
export const CLAUDE_PERMISSION_MODES = [
|
|
61
|
+
'default', 'acceptEdits', 'plan', 'auto', 'dontAsk', 'bypassPermissions',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
// THE ONE FIELD THAT WAS A GRANT RATHER THAN AN OPINION HAS LEFT THIS FILE.
|
|
65
|
+
//
|
|
66
|
+
// `answerMode` used to live on the ledger: what one reply is allowed to do on
|
|
67
|
+
// the founder's Mac, turned straight into `--permission-mode` on a real process
|
|
68
|
+
// by spawnPlan. A guard here stripped it off any line that did not claim to be
|
|
69
|
+
// hers, which closed the STRUCTURED path, the MCP and the app.
|
|
70
|
+
//
|
|
71
|
+
// IT COULD NOT CLOSE THE FILE, AND NOTHING WRITTEN HERE EVER COULD. `source` is
|
|
72
|
+
// a plain string in a line and nothing checks it. A worker with Bash could
|
|
73
|
+
// append one line saying `"source":"founder"` and grant its own next run
|
|
74
|
+
// bypassPermissions; the rule and the forgery lived in the same file. Proved on
|
|
75
|
+
// a scratch ledger, 2026-08-24.
|
|
76
|
+
//
|
|
77
|
+
// So the grant left rather than being guarded harder. It is kept in the app's
|
|
78
|
+
// own storage now, written by main and by nothing else (main/answer-modes.mjs),
|
|
79
|
+
// which is the convention Claude Code uses for its standing policy. Everything
|
|
80
|
+
// on the ledger today is a claim about the WORK, and a forged line can still
|
|
81
|
+
// fake her archive or her answer — true of any append-only log nobody signs, and
|
|
82
|
+
// filed for her rather than papered over here. What it can no longer do is hand
|
|
83
|
+
// anything a permission.
|
|
84
|
+
//
|
|
85
|
+
// If a future field is a grant rather than an opinion, the answer is the same
|
|
86
|
+
// one: it does not go on the ledger.
|
|
87
|
+
|
|
88
|
+
// Fields a patch may set. An unrecognized key is DROPPED rather than stored: the
|
|
89
|
+
// ledger is a contract, not a bag, and a typo'd field that silently becomes
|
|
90
|
+
// state is worse than one that visibly does nothing.
|
|
91
|
+
// `label` IS THE NAME ON THE ROW, AND IT EXISTS BECAUSE `title` CANNOT BE ONE.
|
|
92
|
+
//
|
|
93
|
+
// A message she dictates becomes an item whose title is its first line,
|
|
94
|
+
// verbatim (message-split.ts). Measured on her Agentbox store, 2026-09-03: 214
|
|
95
|
+
// items are hers, median title 84 characters, 145 of them over 60, 37 ending in
|
|
96
|
+
// an ellipsis mid sentence, and 76 opening with "I", "I've been", "Can you" or
|
|
97
|
+
// "We". Eleven of those in a column is eleven rows that look like one row.
|
|
98
|
+
//
|
|
99
|
+
// The obvious fix, having a session write a better title, does not work and
|
|
100
|
+
// never did: `beats` below makes her outrank an agent per field, so 208 of those
|
|
101
|
+
// 214 titles are hers and an agent's rewrite of one is accepted here and then
|
|
102
|
+
// dropped on the floor, silently. That is the whole reason this is a second
|
|
103
|
+
// field rather than a better-behaved writer.
|
|
104
|
+
//
|
|
105
|
+
// So `title` stays exactly what she typed and nothing about who owns it changes.
|
|
106
|
+
// `label` is the short written name a session puts beside it, and the LIST
|
|
107
|
+
// prefers it (`rowTitle`, list-rules.ts). Everything else still reads the title,
|
|
108
|
+
// so her own words are what she finds when she opens the row, and search matches
|
|
109
|
+
// either name.
|
|
110
|
+
export const WORK_ITEM_FIELDS = [
|
|
111
|
+
'title', 'label', 'body', 'kind', 'labels', 'priority', 'parent',
|
|
112
|
+
'status', 'result', 'note', 'answer', 'product', 'runAt', 'answeredThrough',
|
|
113
|
+
'engine', 'model', 'effort',
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
// How long a claim survives without a heartbeat. The MCP server heartbeats on a
|
|
117
|
+
// timer while it holds any claim, so this is a liveness signal about the PROCESS
|
|
118
|
+
// (session dies, child dies, heartbeats stop) rather than anything the model has
|
|
119
|
+
// to remember to do. Generous on purpose: a worker wrongly losing a claim
|
|
120
|
+
// mid-task is a worse failure than an abandoned item sitting idle a few minutes.
|
|
121
|
+
export const LEASE_MS = 5 * 60 * 1000;
|
|
122
|
+
|
|
123
|
+
/* --------------------------------- limits -------------------------------- */
|
|
124
|
+
// Every budget here is in BYTES, because the only ceiling that actually breaks
|
|
125
|
+
// is a byte one: the disk writer refuses a line over MAX_LINE_BYTES outright,
|
|
126
|
+
// and a refused write loses a whole report rather than its tail.
|
|
127
|
+
const MAX_TITLE = 200;
|
|
128
|
+
const MAX_SHORT = 200;
|
|
129
|
+
// ONE budget for every long text, because a result is the answer to a body and
|
|
130
|
+
// there is no reason an answer may be shorter than the question. It used to be
|
|
131
|
+
// a quarter of it, and on 2026-08-11 three of the founder's finished research
|
|
132
|
+
// answers reached her reading pane cut mid-sentence for no reason she could
|
|
133
|
+
// see. Four fields at this size still leave a line at half the append ceiling.
|
|
134
|
+
export const MAX_TEXT_BYTES = 32 * 1024;
|
|
135
|
+
const MAX_LABELS = 20;
|
|
136
|
+
const MAX_ID_CHARS = 64;
|
|
137
|
+
// A single line longer than this is treated as torn or hostile and skipped
|
|
138
|
+
// rather than parsed. Matches the ceiling records.mjs already uses.
|
|
139
|
+
export const MAX_LINE_BYTES = 256 * 1024;
|
|
140
|
+
|
|
141
|
+
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
142
|
+
|
|
143
|
+
export function isWorkItemId(id) {
|
|
144
|
+
return typeof id === 'string' && ID_RE.test(id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* --------------------------------- lines --------------------------------- */
|
|
148
|
+
// One appended line. Kept as a builder rather than an object literal at each
|
|
149
|
+
// call site so the MCP and the app cannot drift on shape, and so validation runs
|
|
150
|
+
// once, here, before anything reaches disk.
|
|
151
|
+
export function buildLine({ id, patch, source = 'agent', epoch = null, now = Date.now() }) {
|
|
152
|
+
if (!isWorkItemId(id)) throw new Error(`invalid work item id: ${id}`);
|
|
153
|
+
const src = WORK_ITEM_SOURCES.includes(source) ? source : 'agent';
|
|
154
|
+
const clean = pickFields(patch);
|
|
155
|
+
if (!clean || !Object.keys(clean).length) throw new Error('patch had no recognized work item fields');
|
|
156
|
+
const line = { id, ts: Number.isFinite(now) ? now : Date.now(), source: src, patch: clean };
|
|
157
|
+
// Only an agent is fenced. A founder line carrying an epoch would be claiming
|
|
158
|
+
// to be a worker, so the epoch is dropped rather than honored.
|
|
159
|
+
if (src === 'agent' && Number.isFinite(epoch)) line.epoch = epoch;
|
|
160
|
+
return line;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A claim line is its own shape: it is the thing that MINTS an epoch rather than
|
|
164
|
+
// being fenced by one, so it never goes through buildLine.
|
|
165
|
+
export function buildClaimLine({ id, epoch, holder, now = Date.now(), leaseMs = LEASE_MS }) {
|
|
166
|
+
if (!isWorkItemId(id)) throw new Error(`invalid work item id: ${id}`);
|
|
167
|
+
if (!Number.isFinite(epoch) || epoch < 1) throw new Error(`invalid epoch: ${epoch}`);
|
|
168
|
+
const ts = Number.isFinite(now) ? now : Date.now();
|
|
169
|
+
return {
|
|
170
|
+
id, ts, source: 'agent', epoch, claim: {
|
|
171
|
+
holder: String(holder ?? 'unknown').slice(0, MAX_ID_CHARS),
|
|
172
|
+
leaseUntil: ts + (Number.isFinite(leaseMs) ? leaseMs : LEASE_MS),
|
|
173
|
+
},
|
|
174
|
+
patch: { status: 'claimed' },
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A heartbeat extends the lease and nothing else. Written by the server on a
|
|
179
|
+
// timer, never by a model.
|
|
180
|
+
//
|
|
181
|
+
// It carries NO patch, and that is the whole point: built from buildClaimLine it
|
|
182
|
+
// inherited `status: 'claimed'`, so every tick restated a status the worker may
|
|
183
|
+
// have moved on from, and one landed three seconds after the founder archived an
|
|
184
|
+
// item and pulled it back out of her archive. The fold ignores a heartbeat's
|
|
185
|
+
// patch too, for the lines already written.
|
|
186
|
+
export function buildHeartbeatLine({ id, epoch, holder, now = Date.now(), leaseMs = LEASE_MS }) {
|
|
187
|
+
const line = buildClaimLine({ id, epoch, holder, now, leaseMs });
|
|
188
|
+
line.heartbeat = true;
|
|
189
|
+
line.patch = {};
|
|
190
|
+
return line;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Release: hand the item back without completing it. Fenced like any agent line,
|
|
194
|
+
// so a zombie cannot release its successor's claim.
|
|
195
|
+
//
|
|
196
|
+
// A RELEASE GIVES UP A CLAIM AND SAYS NOTHING ABOUT STATUS. It used to carry
|
|
197
|
+
// patch:{status:'open'}, which made a clean shutdown erase a status the worker
|
|
198
|
+
// had deliberately set: mark an item blocked, exit tidily, and the goodbye
|
|
199
|
+
// handler released the still-held claim and reopened it, so the queue spawned
|
|
200
|
+
// another worker onto work that could not proceed (four times on one item). The
|
|
201
|
+
// tidier the exit, the worse the outcome, since a worker killed outright kept
|
|
202
|
+
// its blocked status. Handing back an UNFINISHED claim still reads open,
|
|
203
|
+
// because the fold derives that from the claim being gone rather than from
|
|
204
|
+
// anyone writing it down.
|
|
205
|
+
export function buildReleaseLine({ id, epoch, now = Date.now() }) {
|
|
206
|
+
if (!isWorkItemId(id)) throw new Error(`invalid work item id: ${id}`);
|
|
207
|
+
return { id, ts: Number.isFinite(now) ? now : Date.now(), source: 'agent', epoch, release: true };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* -------------------------------- reading -------------------------------- */
|
|
211
|
+
// One line, defensively read. Anything unparseable returns null so a corrupt
|
|
212
|
+
// ledger degrades to an older picture rather than a broken list.
|
|
213
|
+
export function normalizeLine(line) {
|
|
214
|
+
let raw = line;
|
|
215
|
+
if (typeof raw === 'string') {
|
|
216
|
+
const text = raw.trim();
|
|
217
|
+
if (!text) return null;
|
|
218
|
+
try { raw = JSON.parse(text); } catch { return null; }
|
|
219
|
+
}
|
|
220
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
221
|
+
if (!isWorkItemId(raw.id)) return null;
|
|
222
|
+
const patch = raw.patch;
|
|
223
|
+
if (patch != null && (typeof patch !== 'object' || Array.isArray(patch))) return null;
|
|
224
|
+
const source = WORK_ITEM_SOURCES.includes(raw.source) ? raw.source : 'agent';
|
|
225
|
+
// Validate on READ, not only on write. buildLine cleans what WE append, but
|
|
226
|
+
// the file is the contract and anything can be in it: a line from an older
|
|
227
|
+
// build, a hand edit, a future writer that learned a field we do not know.
|
|
228
|
+
// A garbled field must leave the older value standing rather than become
|
|
229
|
+
// state, so coercion happens here, where every reader passes.
|
|
230
|
+
// Stripped on READ as well as on write, because the file is the contract and
|
|
231
|
+
// anything can be in it: an older build, a hand edit, or a writer that is not
|
|
232
|
+
// us. A grant a non-founder line carries never becomes state.
|
|
233
|
+
const cleanPatch = pickFields(patch) ?? {};
|
|
234
|
+
return {
|
|
235
|
+
id: raw.id,
|
|
236
|
+
ts: Number.isFinite(raw.ts) ? raw.ts : 0,
|
|
237
|
+
source,
|
|
238
|
+
// A founder line is never fenced, so its epoch is ignored even if present.
|
|
239
|
+
epoch: source === 'agent' && Number.isFinite(raw.epoch) ? raw.epoch : null,
|
|
240
|
+
claim: raw.claim && typeof raw.claim === 'object' ? raw.claim : null,
|
|
241
|
+
heartbeat: raw.heartbeat === true,
|
|
242
|
+
release: raw.release === true,
|
|
243
|
+
patch: cleanPatch,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Keep only recognized fields, coerced. Returns null when nothing survives, so a
|
|
248
|
+
// caller can refuse the write instead of appending a line that means nothing.
|
|
249
|
+
export function pickFields(patch) {
|
|
250
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return null;
|
|
251
|
+
const out = {};
|
|
252
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
253
|
+
if (!WORK_ITEM_FIELDS.includes(field)) continue;
|
|
254
|
+
const clean = coerceField(field, value);
|
|
255
|
+
if (clean !== undefined) out[field] = clean;
|
|
256
|
+
}
|
|
257
|
+
return Object.keys(out).length ? out : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Over-long text is CUT WITH A MARK, never silently. Five digest rows sit in
|
|
261
|
+
// the founder's ledger because workers appending to a nearly-full note could
|
|
262
|
+
// not tell whether the store would keep what they wrote, and correctly refused
|
|
263
|
+
// to risk dropping news she had not read; each opened a fresh row instead
|
|
264
|
+
// (2026-08-06). A writer that can see it lost 400 characters can summarise and
|
|
265
|
+
// try again. One that cannot has to guess, and guessing is what flooded her.
|
|
266
|
+
const encoder = new TextEncoder();
|
|
267
|
+
const byteLength = (text) => encoder.encode(text).length;
|
|
268
|
+
|
|
269
|
+
function str(value, max) {
|
|
270
|
+
if (typeof value !== 'string') return undefined;
|
|
271
|
+
const trimmed = value.trim();
|
|
272
|
+
if (!trimmed) return undefined;
|
|
273
|
+
if (byteLength(trimmed) <= max) return trimmed;
|
|
274
|
+
const mark = (n) => `\n\n[truncated: ${n} characters dropped by the store]`;
|
|
275
|
+
const fits = (keep) => byteLength(trimmed.slice(0, keep) + mark(trimmed.length - keep)) <= max;
|
|
276
|
+
// The longest prefix that still leaves room for the mark describing the cut,
|
|
277
|
+
// since the mark must not itself overflow the field it is describing. Cost
|
|
278
|
+
// rises with every character kept and the count it removes from the mark
|
|
279
|
+
// never adds a byte, so the edge is a clean one and a search finds it exactly.
|
|
280
|
+
let lo = 0;
|
|
281
|
+
let hi = trimmed.length;
|
|
282
|
+
while (lo < hi) {
|
|
283
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
284
|
+
if (fits(mid)) lo = mid; else hi = mid - 1;
|
|
285
|
+
}
|
|
286
|
+
// Never end on half of a surrogate pair: the budget is bytes, but the cut is
|
|
287
|
+
// made in code units, and an emoji split down the middle is a broken glyph in
|
|
288
|
+
// her reading pane on top of the loss the mark is already reporting.
|
|
289
|
+
const high = lo > 0 && lo < trimmed.length ? trimmed.charCodeAt(lo - 1) : 0;
|
|
290
|
+
if (high >= 0xd800 && high <= 0xdbff) lo -= 1;
|
|
291
|
+
return trimmed.slice(0, lo) + mark(trimmed.length - lo);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function coerceField(field, value) {
|
|
295
|
+
switch (field) {
|
|
296
|
+
case 'title': return str(value, MAX_TITLE);
|
|
297
|
+
// The written name the list draws. Bounded the same way the title is and no
|
|
298
|
+
// tighter: what a NAME may be (three to seven words, 64 characters) is a
|
|
299
|
+
// rule about how the app writes, and it lives with the writer in
|
|
300
|
+
// main/row-label.mjs. This module holds no taxonomy, only storage.
|
|
301
|
+
case 'label': return str(value, MAX_TITLE);
|
|
302
|
+
case 'body': case 'result': case 'note': case 'answer': return str(value, MAX_TEXT_BYTES);
|
|
303
|
+
// kind and product are free strings on purpose: the taxonomy of work is the
|
|
304
|
+
// caller's to invent and change without a migration here.
|
|
305
|
+
//
|
|
306
|
+
// `engine` (WHICH CODING AGENT PICKS THIS UP) and `model` join them for the
|
|
307
|
+
// same stated reason: the list of engines is the layer above's to grow, and
|
|
308
|
+
// this module holds no taxonomy. Nothing here has to validate them because
|
|
309
|
+
// nothing here reads them — `engineFor` in shared/engines.mjs falls back to
|
|
310
|
+
// Claude Code for any name it does not know, so an unrecognised value is a
|
|
311
|
+
// working session rather than a row that silently never runs. `effort` (HOW
|
|
312
|
+
// HARD IT THINKS) is the same kind of word: the five the CLI takes live in
|
|
313
|
+
// shared/effort-levels.mjs, and the spawn gates on them, so a value this
|
|
314
|
+
// module does not recognise is a run at Claude Code's own choice rather
|
|
315
|
+
// than a row that never runs.
|
|
316
|
+
case 'kind': case 'product': case 'parent': case 'engine': case 'model': case 'effort': return str(value, MAX_SHORT);
|
|
317
|
+
case 'status': return WORK_ITEM_STATUSES.includes(value) ? value : undefined;
|
|
318
|
+
case 'priority': return Number.isFinite(value) ? Math.trunc(value) : undefined;
|
|
319
|
+
// When this item may next be acted on, in epoch ms. 0 means unscheduled,
|
|
320
|
+
// and it is a REAL value rather than a missing one on purpose: a field
|
|
321
|
+
// that coerces to undefined is dropped by the fold, so without a falsy
|
|
322
|
+
// sentinel a schedule could be set and never cleared.
|
|
323
|
+
case 'runAt': return Number.isFinite(value) ? Math.trunc(value) : undefined;
|
|
324
|
+
// WHICH ANSWER A SESSION HAS ALREADY ACTED ON, as that answer's own ts.
|
|
325
|
+
//
|
|
326
|
+
// An answer had two states and the ledger recorded one. A worker finishes
|
|
327
|
+
// acting on the founder's words and leaves the row where it found it, open
|
|
328
|
+
// and still carrying them, because the thread is alive and closing it is
|
|
329
|
+
// the wrong move. Nothing on disk said the words had been DEALT WITH, so a
|
|
330
|
+
// finished thread and a worker that died mid-sentence were the same item,
|
|
331
|
+
// and every reader guessed: the app kept them out of her inbox, printed them
|
|
332
|
+
// red as "stopped", and respawned workers on them forever (one row took 23
|
|
333
|
+
// sessions on the same two-day-old word, every one a successful no-op).
|
|
334
|
+
//
|
|
335
|
+
// A ts rather than a flag, so her NEXT answer outdates it by carrying a
|
|
336
|
+
// later one, with nothing to clear and nobody who has to remember to.
|
|
337
|
+
case 'answeredThrough': return Number.isFinite(value) ? Math.trunc(value) : undefined;
|
|
338
|
+
case 'labels': {
|
|
339
|
+
if (!Array.isArray(value)) return undefined;
|
|
340
|
+
const labels = value.map((l) => str(l, MAX_SHORT)).filter(Boolean).slice(0, MAX_LABELS);
|
|
341
|
+
return labels.length ? labels : undefined;
|
|
342
|
+
}
|
|
343
|
+
default: return undefined;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* --------------------------------- folding ------------------------------- */
|
|
348
|
+
// Fold every line into the current picture: a Map of id to work item.
|
|
349
|
+
//
|
|
350
|
+
// Each item carries `wrote`: per field, WHO set the value that survived and
|
|
351
|
+
// WHEN. It is the fold's own bookkeeping (it is how founder-outranks-agent is
|
|
352
|
+
// enforced) and it used to be deleted on the way out, on the grounds that no
|
|
353
|
+
// reader needed it. Two readers did, and both failures were silent:
|
|
354
|
+
//
|
|
355
|
+
// WHO An agent's `done` and the founder's archive are the same word for
|
|
356
|
+
// opposite events. Her own ask, ended by an agent, is an answer she has
|
|
357
|
+
// not received; ended by her, it is filed. the app's inbox could not tell
|
|
358
|
+
// them apart, so every answer to a question she asked went straight to
|
|
359
|
+
// the archive and she reasonably concluded the work never happened
|
|
360
|
+
// (2026-08-06: she asked the same question three times in six hours,
|
|
361
|
+
// and all three had been answered within twenty minutes).
|
|
362
|
+
// WHEN Two identical nudges are two asks. A delivery keyed on the words
|
|
363
|
+
// alone treats the second as already handled and swallows it.
|
|
364
|
+
//
|
|
365
|
+
// Order of business per line, and the order matters:
|
|
366
|
+
// 1. skip anything unparseable
|
|
367
|
+
// 2. skip an agent line fenced out by a newer epoch (the zombie writer)
|
|
368
|
+
// 3. apply the claim, if this line carries one
|
|
369
|
+
// 4. apply the patch field by field, founder outranking agent
|
|
370
|
+
export function foldWorkItems(lines, now = Date.now()) {
|
|
371
|
+
const items = new Map();
|
|
372
|
+
|
|
373
|
+
for (const raw of Array.isArray(lines) ? lines : []) {
|
|
374
|
+
const line = normalizeLine(raw);
|
|
375
|
+
if (!line) continue;
|
|
376
|
+
|
|
377
|
+
let item = items.get(line.id);
|
|
378
|
+
if (!item) {
|
|
379
|
+
item = {
|
|
380
|
+
id: line.id, status: 'open', title: '', kind: '', labels: [],
|
|
381
|
+
priority: 0, epoch: 0, claim: null, createdAt: line.ts, updatedAt: line.ts,
|
|
382
|
+
wrote: {},
|
|
383
|
+
};
|
|
384
|
+
items.set(line.id, item);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Fencing. An agent line written under a superseded epoch is the whole
|
|
388
|
+
// reason this field exists: it is a live worker whose lease lapsed, and
|
|
389
|
+
// everything it has to say about this item is now someone else's business.
|
|
390
|
+
if (line.source === 'agent' && line.epoch != null && line.epoch < item.epoch) continue;
|
|
391
|
+
|
|
392
|
+
if (line.claim) {
|
|
393
|
+
// A claim at or above the current epoch takes the item. A heartbeat only
|
|
394
|
+
// extends a lease it still legitimately holds.
|
|
395
|
+
if (line.epoch >= item.epoch) {
|
|
396
|
+
item.epoch = line.epoch;
|
|
397
|
+
item.claim = {
|
|
398
|
+
holder: String(line.claim.holder ?? ''),
|
|
399
|
+
leaseUntil: Number.isFinite(line.claim.leaseUntil) ? line.claim.leaseUntil : line.ts,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (line.release && line.epoch != null && line.epoch >= item.epoch) item.claim = null;
|
|
405
|
+
|
|
406
|
+
// A HEARTBEAT EXTENDS A LEASE AND NOTHING ELSE, which is what its own
|
|
407
|
+
// comment always said. It is built from buildClaimLine, so it carried that
|
|
408
|
+
// line's `patch: { status: 'claimed' }`, and a timer tick has therefore
|
|
409
|
+
// been quietly rewriting status: over an item a live worker had just set to
|
|
410
|
+
// 'blocked', and, three seconds after the founder archived one, over that.
|
|
411
|
+
// Enforced on READ as well as at the write, because the lines that did it
|
|
412
|
+
// are already in her ledger and will be there forever.
|
|
413
|
+
const patch = line.heartbeat ? {} : line.patch;
|
|
414
|
+
|
|
415
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
416
|
+
const held = item.wrote[field];
|
|
417
|
+
// There used to be an exception here, for machinery spending a one-off
|
|
418
|
+
// permission mode over the founder's own grant. That field is not on the
|
|
419
|
+
// ledger any more (see the note beside WORK_ITEM_FIELDS), so
|
|
420
|
+
// the plain rule is the only rule: a weaker writer never overwrites a
|
|
421
|
+
// stronger one's value.
|
|
422
|
+
if (held && !beats(line, held)) continue;
|
|
423
|
+
item[field] = value;
|
|
424
|
+
item.wrote[field] = { ts: line.ts, source: authorityOf(line, field, value) };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
item.updatedAt = Math.max(item.updatedAt, line.ts);
|
|
428
|
+
if (line.ts < item.createdAt) item.createdAt = line.ts;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Lease expiry is computed at READ time, never written. A held claim whose
|
|
432
|
+
// lease has lapsed reads as open again, which is what makes an abandoned item
|
|
433
|
+
// recoverable without anyone having to notice it was abandoned.
|
|
434
|
+
//
|
|
435
|
+
// The same derivation covers a released claim, and that is the point: only an
|
|
436
|
+
// item still sitting at `claimed` with nobody holding it goes back to open.
|
|
437
|
+
// Any status a worker or the founder actually chose (done, blocked, or one we
|
|
438
|
+
// have not invented yet) is theirs, and nothing about giving a claim back
|
|
439
|
+
// overwrites it.
|
|
440
|
+
for (const item of items.values()) {
|
|
441
|
+
item.claimExpired = !!(item.claim && item.claim.leaseUntil < now);
|
|
442
|
+
if (item.status === 'claimed' && (!item.claim || item.claimExpired)) item.status = 'open';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return items;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/* -------------------------------- the thread ----------------------------- */
|
|
449
|
+
// EVERY TURN OF ONE ROW, OLDEST FIRST. The fold answers "what does this item
|
|
450
|
+
// say now"; this answers "what has been said here", which is a different
|
|
451
|
+
// question and nothing could ask it before.
|
|
452
|
+
//
|
|
453
|
+
// She was describing a real hole. Every reply she wrote spawned a fresh session
|
|
454
|
+
// holding one field, `answer`, with no sight of her earlier answers on the same
|
|
455
|
+
// row and no sight of the work it was replying about. One session built
|
|
456
|
+
// thirty-nine images off a bare line asking for more options, because cold that
|
|
457
|
+
// is what the line says. The whole exchange was on disk the entire time: the
|
|
458
|
+
// ledger is append-only, so every superseded answer, result, note and rewritten
|
|
459
|
+
// body is still a line in it. Only the fold was throwing them away.
|
|
460
|
+
//
|
|
461
|
+
// FOUR FIELDS ARE THE CONVERSATION and the rest are bookkeeping. Her answers,
|
|
462
|
+
// the results sessions wrote back, the checkpoints they left her, and the body
|
|
463
|
+
// each round rewrote to ask the next question. status, labels, priority and
|
|
464
|
+
// claims are how the row moves, not anything anyone said.
|
|
465
|
+
const THREAD_FIELDS = ['body', 'note', 'result', 'answer'];
|
|
466
|
+
|
|
467
|
+
export function threadOf(lines, id) {
|
|
468
|
+
const turns = [];
|
|
469
|
+
for (const raw of Array.isArray(lines) ? lines : []) {
|
|
470
|
+
const line = normalizeLine(raw);
|
|
471
|
+
if (!line || line.id !== id) continue;
|
|
472
|
+
// A heartbeat carries a patch it never meant (see the fold), so it would
|
|
473
|
+
// otherwise put a stale body into the transcript every five minutes.
|
|
474
|
+
if (line.heartbeat) continue;
|
|
475
|
+
for (const field of THREAD_FIELDS) {
|
|
476
|
+
const text = line.patch?.[field];
|
|
477
|
+
if (typeof text !== 'string' || !text.trim()) continue;
|
|
478
|
+
turns.push({ ts: line.ts, source: authorityOf(line, field, text), field, text });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// Stable by time. Within one millisecond THREAD_FIELDS order decides, so an
|
|
482
|
+
// append that sets a body and a result reads body-then-result rather than in
|
|
483
|
+
// whatever order Object.entries happened to hand them over.
|
|
484
|
+
turns.sort((a, b) => a.ts - b.ts);
|
|
485
|
+
// THE SAME TEXT TWICE IS ONE THING SAID ONCE. A patch that rewrites a title
|
|
486
|
+
// and re-sends an unchanged body is the ordinary case, and the second copy is
|
|
487
|
+
// not a turn: it is the store's shape leaking into what reads as a transcript.
|
|
488
|
+
const out = [];
|
|
489
|
+
const seen = new Set();
|
|
490
|
+
for (const turn of turns) {
|
|
491
|
+
const key = `${turn.field}:${turn.text}`;
|
|
492
|
+
if (seen.has(key)) continue;
|
|
493
|
+
seen.add(key);
|
|
494
|
+
out.push(turn);
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// How much authority a write actually claims over the field it sets.
|
|
500
|
+
//
|
|
501
|
+
// The founder's word wins per field, and for a status that has to mean her
|
|
502
|
+
// terminal word: an item she archived is archived, whatever a worker still
|
|
503
|
+
// running on it writes next. But 'open' from her is not a verdict, it is a
|
|
504
|
+
// HANDBACK, and holding the field with it would freeze the very machinery
|
|
505
|
+
// she just handed it to: the claim, the blocked, the done that follow could
|
|
506
|
+
// never land. So a handback claims no authority, and the item moves again
|
|
507
|
+
// as it should.
|
|
508
|
+
function authorityOf(line, field, value) {
|
|
509
|
+
if (line.source === 'founder' && field === 'status' && value === 'open') return 'system';
|
|
510
|
+
return line.source;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Does the incoming line outrank the one already holding this field? The founder
|
|
514
|
+
// wins over any agent regardless of time; between equals, later wins.
|
|
515
|
+
function beats(incoming, held) {
|
|
516
|
+
if (held.source === 'founder' && incoming.source !== 'founder') return false;
|
|
517
|
+
if (incoming.source === 'founder' && held.source !== 'founder') return true;
|
|
518
|
+
return incoming.ts >= held.ts;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* --------------------------------- queries ------------------------------- */
|
|
522
|
+
// Can this item be pulled right now by a worker that holds nothing?
|
|
523
|
+
// Whether an item's scheduled moment has arrived. A `runAt` in the future means
|
|
524
|
+
// nothing happens with this item yet: it is not claimable, it does not spawn,
|
|
525
|
+
// and it is not in the founder's inbox.
|
|
526
|
+
//
|
|
527
|
+
// A MISSED MOMENT NEEDS NO CATCH-UP. Nothing fires, so nothing can be missed:
|
|
528
|
+
// once the moment passes this is true forever, and the first read after the
|
|
529
|
+
// machine wakes is as good as the one that would have happened at 6am. That is
|
|
530
|
+
// the whole reason this is a predicate over the clock rather than a timer, and
|
|
531
|
+
// it is what makes a schedule survive an app restart, a reboot, and a store
|
|
532
|
+
// restored onto another machine (the founder's requirement, 2026-08-06).
|
|
533
|
+
export function isDue(item, now = Date.now()) {
|
|
534
|
+
return !item?.runAt || item.runAt <= now;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function isClaimable(item, now = Date.now()) {
|
|
538
|
+
if (!item) return false;
|
|
539
|
+
if (!PULLABLE.includes(item.status)) return false;
|
|
540
|
+
if (!isDue(item, now)) return false;
|
|
541
|
+
return !item.claim || item.claim.leaseUntil < now;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// The list, in the order a puller should consider it: highest priority first,
|
|
545
|
+
// then oldest, so nothing starves behind a stream of new arrivals.
|
|
546
|
+
export function sortForPull(items) {
|
|
547
|
+
return [...items].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.createdAt - b.createdAt);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export function matchesFilter(item, filter = {}) {
|
|
551
|
+
if (!item) return false;
|
|
552
|
+
if (filter.status && item.status !== filter.status) return false;
|
|
553
|
+
if (filter.kind && item.kind !== filter.kind) return false;
|
|
554
|
+
if (filter.product && item.product !== filter.product) return false;
|
|
555
|
+
if (filter.parent && item.parent !== filter.parent) return false;
|
|
556
|
+
if (Array.isArray(filter.labels) && filter.labels.length) {
|
|
557
|
+
const has = new Set(item.labels ?? []);
|
|
558
|
+
if (!filter.labels.every((l) => has.has(l))) return false;
|
|
559
|
+
}
|
|
560
|
+
return true;
|
|
561
|
+
}
|