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,76 @@
|
|
|
1
|
+
// IS THE RUNNING APP THE APP ON DISK?
|
|
2
|
+
//
|
|
3
|
+
// Electron reads main/*.mjs once, at boot. Every fix to the supervisor, the IPC
|
|
4
|
+
// surface or the preload is therefore invisible until a restart, and nothing on
|
|
5
|
+
// screen said which build was running. That cost real hours three times in two
|
|
6
|
+
// days (2026-08-07 to 08-11): the app ran for 2d22h while four main-process
|
|
7
|
+
// fixes landed on disk beneath it, and the last of those was a correction to a
|
|
8
|
+
// bug that was actively stopping her agents. Each time the code was right, the
|
|
9
|
+
// tests were green, it was pushed, and none of it was running.
|
|
10
|
+
//
|
|
11
|
+
// Worse than merely stale: ⌘R reloads the RENDERER only. So a reload picks up
|
|
12
|
+
// new UI against an old main process, and a renderer that calls an IPC handler
|
|
13
|
+
// its main process has never heard of fails silently. That exact mismatch was
|
|
14
|
+
// one keystroke away on 2026-08-10 (the palette's resume-any-row calling
|
|
15
|
+
// zero:resume-items against a main process without the handler).
|
|
16
|
+
//
|
|
17
|
+
// So the app checks itself, continuously rather than at boot, because the
|
|
18
|
+
// staleness develops WHILE it runs: it rides the snapshot the renderer already
|
|
19
|
+
// polls, and says plainly that a restart, not a reload, is what is needed.
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
// When this module was first imported, which is when the main process read its
|
|
25
|
+
// code. Every file below is compared against it.
|
|
26
|
+
export const BOOTED_AT = Date.now();
|
|
27
|
+
|
|
28
|
+
// Everything the MAIN process holds in memory from boot. The renderer is
|
|
29
|
+
// deliberately absent: it reloads with ⌘R, so it is never the thing a restart
|
|
30
|
+
// is needed for, and listing it here would cry wolf on every rebuild.
|
|
31
|
+
const WATCHED_DIRS = ['main', 'shared'];
|
|
32
|
+
const WATCHED_FILES = ['preload.cjs'];
|
|
33
|
+
|
|
34
|
+
// PURE, and the whole rule: a file written after the process read it is code
|
|
35
|
+
// that exists and is not running.
|
|
36
|
+
export function changedSinceBoot(bootedAt, files) {
|
|
37
|
+
return files
|
|
38
|
+
.filter((f) => Number.isFinite(f.mtimeMs) && f.mtimeMs > bootedAt)
|
|
39
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
40
|
+
.map((f) => f.name);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function listWatched(appDir) {
|
|
44
|
+
const found = [];
|
|
45
|
+
for (const rel of WATCHED_FILES) {
|
|
46
|
+
try { found.push({ name: rel, mtimeMs: fs.statSync(path.join(appDir, rel)).mtimeMs }); } catch {}
|
|
47
|
+
}
|
|
48
|
+
for (const dir of WATCHED_DIRS) {
|
|
49
|
+
let entries = [];
|
|
50
|
+
try { entries = fs.readdirSync(path.join(appDir, dir), { withFileTypes: true }); } catch { continue; }
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
if (!e.isFile() || !/\.(mjs|cjs|js)$/.test(e.name)) continue;
|
|
53
|
+
try {
|
|
54
|
+
found.push({ name: `${dir}/${e.name}`, mtimeMs: fs.statSync(path.join(appDir, dir, e.name)).mtimeMs });
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Cached: the snapshot is polled every ten seconds and pushed on every store
|
|
62
|
+
// change, and this is a disk walk. Staleness is not urgent to the second.
|
|
63
|
+
let cache = { at: 0, value: null };
|
|
64
|
+
const TTL_MS = 15_000;
|
|
65
|
+
|
|
66
|
+
export function restartNeeded(appDir, bootedAt = BOOTED_AT, now = Date.now()) {
|
|
67
|
+
if (cache.value !== undefined && now - cache.at < TTL_MS) return cache.value;
|
|
68
|
+
const files = changedSinceBoot(bootedAt, listWatched(appDir));
|
|
69
|
+
const value = files.length ? { files, since: bootedAt } : null;
|
|
70
|
+
cache = { at: now, value };
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function _resetCacheForTests() {
|
|
75
|
+
cache = { at: 0, value: null };
|
|
76
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Chat store: per-product conversations under <project>/chats/, one JSONL log
|
|
2
|
+
// per chat plus a registry (chats/index.json: id, title, createdAt, updatedAt,
|
|
3
|
+
// lastSessionId). Same rules as the rest of the store: single writer (main),
|
|
4
|
+
// atomic index writes, crash-safe appends, and self-repair on read (an entry
|
|
5
|
+
// whose file vanished drops out instead of wedging the UI). A message is
|
|
6
|
+
// UPDATED by appending a line with the same id; reading folds last-line-wins.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { atomicWriteJson, readJson } from './project.mjs';
|
|
12
|
+
|
|
13
|
+
const iso = () => new Date().toISOString();
|
|
14
|
+
const chatsDir = (projectDir) => path.join(projectDir, 'chats');
|
|
15
|
+
const indexFile = (projectDir) => path.join(chatsDir(projectDir), 'index.json');
|
|
16
|
+
|
|
17
|
+
export function sanitizeChatId(id) {
|
|
18
|
+
const s = String(id ?? '').replace(/[^a-zA-Z0-9_-]/g, '');
|
|
19
|
+
if (!s) throw new Error(`bad chat id: ${id}`);
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function chatFile(projectDir, chatId) {
|
|
24
|
+
return path.join(chatsDir(projectDir), `${sanitizeChatId(chatId)}.jsonl`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function mutateIndex(projectDir, mutate) {
|
|
28
|
+
const idx = readJson(indexFile(projectDir), { chats: [] });
|
|
29
|
+
mutate(idx);
|
|
30
|
+
atomicWriteJson(indexFile(projectDir), idx);
|
|
31
|
+
return idx;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Idempotent bootstrap: creates chats/, migrates the legacy single chat.jsonl
|
|
35
|
+
// ONCE (copy to chats/main.jsonl, rename the original to .migrated so a failed
|
|
36
|
+
// first-open never strands history), carries project.lastSessionId over, and
|
|
37
|
+
// guarantees at least one chat exists.
|
|
38
|
+
export function ensureChats(projectDir, project = {}) {
|
|
39
|
+
fs.mkdirSync(chatsDir(projectDir), { recursive: true });
|
|
40
|
+
if (!fs.existsSync(indexFile(projectDir))) {
|
|
41
|
+
const chats = [];
|
|
42
|
+
const legacy = path.join(projectDir, 'chat.jsonl');
|
|
43
|
+
if (fs.existsSync(legacy)) {
|
|
44
|
+
fs.copyFileSync(legacy, chatFile(projectDir, 'main'));
|
|
45
|
+
fs.renameSync(legacy, `${legacy}.migrated`);
|
|
46
|
+
chats.push({ id: 'main', title: 'First conversation', createdAt: iso(), updatedAt: iso(), lastSessionId: project.lastSessionId ?? null });
|
|
47
|
+
}
|
|
48
|
+
atomicWriteJson(indexFile(projectDir), { chats });
|
|
49
|
+
}
|
|
50
|
+
if (listChats(projectDir).length === 0) createChat(projectDir);
|
|
51
|
+
return listChats(projectDir);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Registry with self-repair: entries whose log file is gone are dropped
|
|
55
|
+
// (and pruned from the index so they stay gone), newest first.
|
|
56
|
+
export function listChats(projectDir) {
|
|
57
|
+
const idx = readJson(indexFile(projectDir), { chats: [] });
|
|
58
|
+
const alive = (idx.chats ?? []).filter((c) => {
|
|
59
|
+
try { return c?.id && fs.existsSync(chatFile(projectDir, c.id)); } catch { return false; }
|
|
60
|
+
});
|
|
61
|
+
if (alive.length !== (idx.chats ?? []).length) {
|
|
62
|
+
mutateIndex(projectDir, (i) => { i.chats = alive; });
|
|
63
|
+
}
|
|
64
|
+
return [...alive].sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? '')));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getChat(projectDir, chatId) {
|
|
68
|
+
return listChats(projectDir).find((c) => c.id === chatId) ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createChat(projectDir, title = 'New chat', { modelId } = {}) {
|
|
72
|
+
fs.mkdirSync(chatsDir(projectDir), { recursive: true });
|
|
73
|
+
const id = `c-${crypto.randomBytes(4).toString('hex')}`;
|
|
74
|
+
fs.writeFileSync(chatFile(projectDir, id), '');
|
|
75
|
+
// modelId pins THIS conversation's model at birth (the caller passes the
|
|
76
|
+
// workspace's most recent choice). Undefined = follow the global default,
|
|
77
|
+
// which is what every pre-existing chat does.
|
|
78
|
+
const chat = { id, title, createdAt: iso(), updatedAt: iso(), lastSessionId: null, ...(modelId ? { modelId } : {}) };
|
|
79
|
+
mutateIndex(projectDir, (idx) => { idx.chats = [...(idx.chats ?? []), chat]; });
|
|
80
|
+
return chat;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function patchChat(projectDir, chatId, patch) {
|
|
84
|
+
let out = null;
|
|
85
|
+
mutateIndex(projectDir, (idx) => {
|
|
86
|
+
idx.chats = (idx.chats ?? []).map((c) => {
|
|
87
|
+
if (c.id !== chatId) return c;
|
|
88
|
+
out = { ...c, ...patch };
|
|
89
|
+
return out;
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function renameChat(projectDir, chatId, title) {
|
|
96
|
+
const t = String(title ?? '').trim();
|
|
97
|
+
return t ? patchChat(projectDir, chatId, { title: t }) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Delete = move the log into chats/.trash/ (never rm) and drop the entry.
|
|
101
|
+
// The caller must close the chat's session FIRST; a trashed chat takes no
|
|
102
|
+
// further writes (appendChatMessage refuses unregistered chats).
|
|
103
|
+
export function trashChat(projectDir, chatId) {
|
|
104
|
+
const entry = getChat(projectDir, chatId);
|
|
105
|
+
if (!entry) return false;
|
|
106
|
+
const trash = path.join(chatsDir(projectDir), '.trash');
|
|
107
|
+
fs.mkdirSync(trash, { recursive: true });
|
|
108
|
+
fs.renameSync(chatFile(projectDir, chatId), path.join(trash, `${sanitizeChatId(chatId)}-${Date.now()}.jsonl`));
|
|
109
|
+
// Drop this chat's attachment assets (chats/<chatId>/assets/, written on send):
|
|
110
|
+
// they belong to the conversation, so a trashed chat takes them with it and the
|
|
111
|
+
// storage does not silt up with orphaned images. force so a chat with no assets
|
|
112
|
+
// is a clean no-op.
|
|
113
|
+
fs.rmSync(path.join(chatsDir(projectDir), sanitizeChatId(chatId)), { recursive: true, force: true });
|
|
114
|
+
mutateIndex(projectDir, (idx) => { idx.chats = (idx.chats ?? []).filter((c) => c.id !== chatId); });
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Auto-scrub abandoned empty chats. createChat writes an empty log the moment
|
|
119
|
+
// a chat is born, so a founder who opens "New chat" and walks away silts the
|
|
120
|
+
// sidebar up forever. An empty chat (zero messages, the same last-line-wins fold
|
|
121
|
+
// used everywhere) carries no history worth keeping, so we trash it (recoverable,
|
|
122
|
+
// never rm) UNLESS it is the active chat or its id is in keepIds (a chat with
|
|
123
|
+
// unsent composer draft text). The active chat is always protected, which is what
|
|
124
|
+
// keeps a scrub-then-ensure cycle from churning: ensureChats only recreates when
|
|
125
|
+
// zero chats remain, and the one you are looking at never gets scrubbed.
|
|
126
|
+
export function scrubEmptyChats(projectDir, { keepIds = [], activeId } = {}) {
|
|
127
|
+
const keep = new Set([...(keepIds ?? []), ...(activeId ? [activeId] : [])]);
|
|
128
|
+
const removed = [];
|
|
129
|
+
for (const c of listChats(projectDir)) {
|
|
130
|
+
if (keep.has(c.id)) continue;
|
|
131
|
+
if (readChatLog(projectDir, c.id).length > 0) continue;
|
|
132
|
+
if (trashChat(projectDir, c.id)) removed.push(c.id);
|
|
133
|
+
}
|
|
134
|
+
return { removed };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function appendChatMessage(projectDir, chatId, message) {
|
|
138
|
+
if (!getChat(projectDir, chatId)) throw new Error(`no such chat: ${chatId}`);
|
|
139
|
+
fs.appendFileSync(chatFile(projectDir, chatId), JSON.stringify(message) + '\n');
|
|
140
|
+
patchChat(projectDir, chatId, { updatedAt: iso() });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Resolve a pending ask directly on the chat log: for a question whose
|
|
144
|
+
// owning session died (an app restart), no live asks registry can answer it,
|
|
145
|
+
// but the question must still be answerable, or it sits pending forever
|
|
146
|
+
// (eating tapped answers and suppressing the chat's thinking indicator).
|
|
147
|
+
// Folds any tapped answers into the card, persists it as answered
|
|
148
|
+
// (last-line-wins), and returns the resolved message, or null when there is
|
|
149
|
+
// no matching pending ask.
|
|
150
|
+
export function resolvePendingAsk(projectDir, chatId, requestId, answers = []) {
|
|
151
|
+
const card = readChatLog(projectDir, chatId)
|
|
152
|
+
.find((m) => m.ask?.requestId === requestId && m.ask.status === 'pending');
|
|
153
|
+
if (!card) return null;
|
|
154
|
+
const list = Array.isArray(answers) ? answers : [];
|
|
155
|
+
const questions = (card.ask.questions ?? []).map((q) => {
|
|
156
|
+
const a = list.find((x) => x?.id === q.id);
|
|
157
|
+
return a
|
|
158
|
+
? {
|
|
159
|
+
...q,
|
|
160
|
+
answer: {
|
|
161
|
+
choiceId: a.choiceId,
|
|
162
|
+
// "pick all that apply": every pill she tapped, not just the first
|
|
163
|
+
...(Array.isArray(a.choiceIds) ? { choiceIds: a.choiceIds } : {}),
|
|
164
|
+
freeText: a.freeText,
|
|
165
|
+
skipped: !!a.skipped,
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
: q;
|
|
169
|
+
});
|
|
170
|
+
const resolved = { ...card, ask: { ...card.ask, questions, status: 'answered' } };
|
|
171
|
+
appendChatMessage(projectDir, chatId, resolved);
|
|
172
|
+
return resolved;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function readChatLog(projectDir, chatId) {
|
|
176
|
+
let lines;
|
|
177
|
+
try {
|
|
178
|
+
lines = fs.readFileSync(chatFile(projectDir, chatId), 'utf8').split('\n').filter(Boolean);
|
|
179
|
+
} catch { return []; }
|
|
180
|
+
const order = []; const byId = new Map();
|
|
181
|
+
for (const l of lines) {
|
|
182
|
+
let m; try { m = JSON.parse(l); } catch { continue; } // torn tail line: skip
|
|
183
|
+
if (!m?.id) continue;
|
|
184
|
+
if (!byId.has(m.id)) order.push(m.id);
|
|
185
|
+
byId.set(m.id, { ...(byId.get(m.id) ?? {}), ...m });
|
|
186
|
+
}
|
|
187
|
+
return order.map((id) => byId.get(id));
|
|
188
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// THE APP'S OWN HOME, so nothing Agentbox generates lands in the folder a worker
|
|
2
|
+
// is standing in.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS FILE EXISTS. A new user uploaded a file into her product folder,
|
|
5
|
+
// watched a worker create a directory and delete things next to it, and asked
|
|
6
|
+
// why it needed to.
|
|
7
|
+
//
|
|
8
|
+
// MEASURED on her Mac 2026-08-24 and again 2026-08-26: Claude Code writes
|
|
9
|
+
// nothing into a working directory. Per-project state goes to
|
|
10
|
+
// `~/.claude/projects/<encoded path>/`, pasted files to `~/.claude/paste-cache`,
|
|
11
|
+
// edit history to `~/.claude/file-history`. Codex does the same under `~/.codex`.
|
|
12
|
+
// Agentbox did the opposite: a product with no code repo registered makes the
|
|
13
|
+
// product docs directory the worker's own cwd (main/supervisor.mjs), which is
|
|
14
|
+
// every new user's first product, and our ledger, session traces, run records
|
|
15
|
+
// and repeat rules all sat in it.
|
|
16
|
+
//
|
|
17
|
+
// So this is the same convention, one name over: a dot-folder in her home
|
|
18
|
+
// named after this app.
|
|
19
|
+
//
|
|
20
|
+
// WHAT MOVES AND WHAT DOES NOT, because the line is not "everything of ours".
|
|
21
|
+
// The app's own RECORDS move: the work item ledger, session traces, run
|
|
22
|
+
// records, repeat rules, parked writes. What DESCRIBES THE FOLDER ITSELF stays
|
|
23
|
+
// in it: `project.json` says this directory is a product at all, `index.json`
|
|
24
|
+
// catalogs the founder's own documents by their paths inside it, and `.lock`
|
|
25
|
+
// guards that catalog. A folder you could copy to another Mac and still open is
|
|
26
|
+
// worth more than four fewer files, and none of those three is what a worker
|
|
27
|
+
// scribbles.
|
|
28
|
+
//
|
|
29
|
+
// WHAT THIS DOES NOT DO, said out loud so nobody reads it as more than it is.
|
|
30
|
+
// It does not make the ledger unforgeable. A worker with Bash reaches that
|
|
31
|
+
// folder exactly as easily as it reaches its own cwd. Claude Code has the
|
|
32
|
+
// identical property and does not pretend otherwise. This moves our files out
|
|
33
|
+
// of her folder; the grant that used to ride on the ledger is dealt with
|
|
34
|
+
// separately, by not keeping it on the ledger at all (main/answer-modes.mjs).
|
|
35
|
+
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import os from 'node:os';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import crypto from 'node:crypto';
|
|
40
|
+
import { WAS, envNames, nameSlug, readEnv } from '../../shared/product-name.mjs';
|
|
41
|
+
|
|
42
|
+
const isDir = (dir) => { try { return fs.statSync(dir).isDirectory(); } catch { return false; } };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* THE STORE ROOT AS ENVIRONMENT, FOR EVERY PROCESS WE START, UNDER EVERY NAME
|
|
46
|
+
* THIS APP HAS HAD.
|
|
47
|
+
*
|
|
48
|
+
* `appHome` above reads the old names, so anything shipped in this repo finds
|
|
49
|
+
* the root whichever spelling it is given. What is NOT in this repo does not:
|
|
50
|
+
* the store MCP server of an older install, her own launchd jobs, a script she
|
|
51
|
+
* wrote. Those read the one variable they were written against, and a worker
|
|
52
|
+
* whose store root is missing writes its whole session to nowhere, which is the
|
|
53
|
+
* 2026-08-05 failure exactly.
|
|
54
|
+
*
|
|
55
|
+
* So both are exported. It costs one string per spawn and it makes the rename
|
|
56
|
+
* invisible to everything outside this checkout.
|
|
57
|
+
*/
|
|
58
|
+
export function storeRootEnv(storeRoot) {
|
|
59
|
+
const env = {};
|
|
60
|
+
for (const key of envNames('HOME')) env[key] = storeRoot;
|
|
61
|
+
return env;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// One env var, for tests and for anyone who keeps their home somewhere else.
|
|
65
|
+
// Read at call time rather than captured, because a test sets it per case.
|
|
66
|
+
//
|
|
67
|
+
// THE NAME IS READ HERE AND THE OLD NAMES STILL ANSWER. This was a literal
|
|
68
|
+
// `ASTRAL_HOME` and a literal `.astral`, two renames after the app stopped
|
|
69
|
+
// being called that, so both go through the name module now. The fallbacks are
|
|
70
|
+
// not politeness: a launchd job, a login shell and any process started before a
|
|
71
|
+
// rename still export the old variable, and a dot-folder written under an old
|
|
72
|
+
// name holds a real store. The current name wins when both are there;
|
|
73
|
+
// otherwise the newest old name that is actually on disk does, so nobody's
|
|
74
|
+
// store goes missing on the release that renames the app.
|
|
75
|
+
// `homeDir` is injected so a test can ask what this answers on a machine with
|
|
76
|
+
// an old dot-folder on it, without one having to exist on the machine running
|
|
77
|
+
// the test. Production never passes it.
|
|
78
|
+
export function appHome(homeDir = os.homedir()) {
|
|
79
|
+
const override = readEnv('HOME');
|
|
80
|
+
if (override) return override;
|
|
81
|
+
const here = path.join(homeDir, `.${nameSlug}`);
|
|
82
|
+
if (isDir(here)) return here;
|
|
83
|
+
for (const was of WAS) {
|
|
84
|
+
const older = path.join(homeDir, `.${was.toLowerCase()}`);
|
|
85
|
+
if (isDir(older)) return older;
|
|
86
|
+
}
|
|
87
|
+
return here;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Claude Code's own encoding, checked against a real folder on her Mac:
|
|
91
|
+
// `/Users/you/Zero/accounts/b482f.../agentbox` becomes
|
|
92
|
+
// `-Users-you-Zero-accounts-b482f...-agentbox`. Every character that is not
|
|
93
|
+
// a letter or a digit becomes a dash, which is readable at a glance and safe on
|
|
94
|
+
// every filesystem.
|
|
95
|
+
export function encodeProjectPath(dir) {
|
|
96
|
+
return String(dir).replace(/[^A-Za-z0-9]/g, '-');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The one thing that encoding cannot do: `/a/b.c` and `/a/b/c` encode the same.
|
|
100
|
+
// Claude Code lives with that. We do not, because two products sharing one
|
|
101
|
+
// ledger would silently merge two founders' inboxes, so the folder carries the
|
|
102
|
+
// path it belongs to and a mismatch takes a short hash suffix instead.
|
|
103
|
+
const ORIGIN = '.origin';
|
|
104
|
+
|
|
105
|
+
function readOrigin(dir) {
|
|
106
|
+
try { return fs.readFileSync(path.join(dir, ORIGIN), 'utf8').trim(); } catch { return null; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A MACHINERY DIRECTORY IS NOT A PROJECT DIRECTORY, and everything below has to
|
|
111
|
+
* know the difference before it moves a byte.
|
|
112
|
+
*
|
|
113
|
+
* MEASURED on her Mac 2026-09-23 at 15:31:26, because this is not a hypothesis.
|
|
114
|
+
* A session wrote a throwaway script to count stranded rows, listed
|
|
115
|
+
* `<home>/projects`, and handed each of those directories to `readWorkItems` as
|
|
116
|
+
* if it were a product folder. `readWorkItems` asks for the ledger, the ledger
|
|
117
|
+
* asks for the machinery directory, and the machinery directory of a machinery
|
|
118
|
+
* directory is a SECOND one nested inside the home. Then the sweep below did
|
|
119
|
+
* what it is for and carried the ledger, the session traces, the run records,
|
|
120
|
+
* the repeat rules and the parked writes one level down into it. Thirty eight
|
|
121
|
+
* products in two seconds, and her inbox read empty until the folders were
|
|
122
|
+
* carried back at 15:58.
|
|
123
|
+
*
|
|
124
|
+
* Nothing about that was exotic. Any agent with a shell can write that script,
|
|
125
|
+
* and reading is supposed to be the safe thing to do. So the refusal lives
|
|
126
|
+
* HERE, in the one place every caller passes through, rather than in a rule
|
|
127
|
+
* about how to write scripts that nobody reading this will have been told.
|
|
128
|
+
*
|
|
129
|
+
* The test is the path itself: anything inside `<home>/projects` is ours
|
|
130
|
+
* already. A product folder can never be one, because products live under the
|
|
131
|
+
* account root and the home is a separate tree.
|
|
132
|
+
*/
|
|
133
|
+
export function isMachineryDir(dir, home = appHome()) {
|
|
134
|
+
const root = path.resolve(path.join(home, 'projects'));
|
|
135
|
+
return path.resolve(dir).startsWith(root + path.sep);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `<home>/projects/<encoded>` for this project directory. Creates nothing. */
|
|
139
|
+
export function machineryDir(projectDir, home = appHome()) {
|
|
140
|
+
const real = path.resolve(projectDir);
|
|
141
|
+
// Already ours: it is its own answer. Encoding it a second time is the
|
|
142
|
+
// 2026-09-23 fold, and returning it unchanged makes the mistake harmless
|
|
143
|
+
// rather than silent, because a caller that guessed this path still gets the
|
|
144
|
+
// right ledger back.
|
|
145
|
+
if (isMachineryDir(real, home)) return real;
|
|
146
|
+
const base = path.join(home, 'projects', encodeProjectPath(real));
|
|
147
|
+
const claimed = readOrigin(base);
|
|
148
|
+
if (claimed === null || claimed === real) return base;
|
|
149
|
+
const tag = crypto.createHash('sha256').update(real).digest('hex').slice(0, 8);
|
|
150
|
+
return `${base}-${tag}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// EVERY NAME THAT IS OURS, so the move is ONE event rather than a slow leak.
|
|
154
|
+
//
|
|
155
|
+
// A path-by-path migration was the first shape and it is wrong for what this is
|
|
156
|
+
// FOR. It moves a file the first time something asks for it, so the ledger goes
|
|
157
|
+
// the moment the inbox opens and `sessions/` goes one item at a time, whenever a
|
|
158
|
+
// trace happens to be read, which for a row nobody opens again is never.
|
|
159
|
+
// Measured on her folder 2026-08-26: 1,785 of the 1,830 files that leave are
|
|
160
|
+
// session traces. Lazily she would still be looking at nearly all of it. The
|
|
161
|
+
// point is that her folder is clean, so all of it goes at once, the first time
|
|
162
|
+
// this process touches the product.
|
|
163
|
+
export const MACHINERY = ['work-items.jsonl', 'repeats.jsonl', 'sessions', 'runs', 'pending-writes'];
|
|
164
|
+
|
|
165
|
+
// THE TWO OF OUR NAMES THAT ARE APPEND-ONLY LOGS. A stray one of these holds
|
|
166
|
+
// lines the real file never got, so it is folded ONTO THE END of the real file
|
|
167
|
+
// rather than moved over it. Everything else here is a whole file that stands
|
|
168
|
+
// on its own.
|
|
169
|
+
const LOGS = new Set(['work-items.jsonl', 'repeats.jsonl']);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The same directory, existing, with its origin marker written once, and every
|
|
173
|
+
* record of ours carried in.
|
|
174
|
+
*
|
|
175
|
+
* THE SWEEP RUNS ON EVERY CALL, NOT ONCE PER PROCESS. It used to run once,
|
|
176
|
+
* behind a `Set`, and `machineryPath` below only adopted a name when there was
|
|
177
|
+
* NOTHING at the new place. Between them that meant the move happened exactly
|
|
178
|
+
* once, and one of our files that appeared in her folder afterwards was never
|
|
179
|
+
* picked up again: nothing read it, nothing moved it, and it sat there.
|
|
180
|
+
*
|
|
181
|
+
* MEASURED on her real folder the day the move landed, not reasoned about: two
|
|
182
|
+
* release lines for two different cards written into a stray `work-items.jsonl`
|
|
183
|
+
* at 14:10 and never folded in, and two run records stranded under `runs/`. So
|
|
184
|
+
* this is not a theoretical window, it is where her folder actually was.
|
|
185
|
+
*
|
|
186
|
+
* The cost of running it every time is one stat per name against a path that is
|
|
187
|
+
* normally absent. Five syscalls that find nothing is cheaper than being wrong,
|
|
188
|
+
* and being wrong here is silent.
|
|
189
|
+
*/
|
|
190
|
+
export function ensureMachineryDir(projectDir, home = appHome()) {
|
|
191
|
+
const dir = machineryDir(projectDir, home);
|
|
192
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
193
|
+
// THE SWEEP IS WHAT DID THE DAMAGE, so it does not run on a directory that is
|
|
194
|
+
// already ours. `machineryDir` hands back its own argument in that one case,
|
|
195
|
+
// and there is nothing to carry across from a folder into itself. The origin
|
|
196
|
+
// marker is left alone too: it names the product this machinery belongs to,
|
|
197
|
+
// and overwriting it with the machinery's own path is how the folded copies
|
|
198
|
+
// came to claim themselves.
|
|
199
|
+
if (dir === path.resolve(projectDir)) return dir;
|
|
200
|
+
if (readOrigin(dir) === null) {
|
|
201
|
+
try { fs.writeFileSync(path.join(dir, ORIGIN), `${path.resolve(projectDir)}\n`); } catch { /* best effort */ }
|
|
202
|
+
}
|
|
203
|
+
for (const rel of MACHINERY) foldStray(path.join(projectDir, rel), path.join(dir, rel), rel);
|
|
204
|
+
return dir;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* One of our names, wherever it has turned up in her folder, carried across to
|
|
209
|
+
* where it belongs. NOTHING IS EVER DESTROYED HERE.
|
|
210
|
+
*
|
|
211
|
+
* The rules, in the order they apply:
|
|
212
|
+
*
|
|
213
|
+
* - Nothing there: return. This is the ordinary case and it costs one stat.
|
|
214
|
+
* - Nothing at the target yet: rename it across whole, which is what the
|
|
215
|
+
* first move has always done.
|
|
216
|
+
* - Both directories: merge them entry by entry by these same rules, then
|
|
217
|
+
* drop the stray directory once the merge has emptied it.
|
|
218
|
+
* - Both append-only logs: append the stray's bytes onto the real file and
|
|
219
|
+
* delete the stray. Never the other way round; the real file is the one
|
|
220
|
+
* with the history in it.
|
|
221
|
+
* - Both ordinary files: the newer takes the name and the older is kept
|
|
222
|
+
* beside it as `<name>.superseded-<mtime>` rather than deleted. That is the
|
|
223
|
+
* run record case, and it is rare on purpose, because the writer of those
|
|
224
|
+
* now writes straight here (main/code-change.mjs). A conflict only happens
|
|
225
|
+
* while a process started before the move is still running.
|
|
226
|
+
*/
|
|
227
|
+
export function foldStray(from, to, rel = path.basename(to)) {
|
|
228
|
+
let stray;
|
|
229
|
+
try { stray = fs.statSync(from); } catch { return false; }
|
|
230
|
+
|
|
231
|
+
let landed;
|
|
232
|
+
try { landed = fs.statSync(to); } catch { return adoptLegacy(from, to); }
|
|
233
|
+
|
|
234
|
+
if (stray.isDirectory() && landed.isDirectory()) {
|
|
235
|
+
let entries = [];
|
|
236
|
+
try { entries = fs.readdirSync(from); } catch { return false; }
|
|
237
|
+
for (const name of entries) foldStray(path.join(from, name), path.join(to, name), name);
|
|
238
|
+
// Only if the merge emptied it. A directory with anything left in it holds
|
|
239
|
+
// something we did not understand, and it stays where it is.
|
|
240
|
+
try { fs.rmdirSync(from); } catch { /* not empty, or gone already */ }
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// A directory on one side and a file on the other is not a case we have a
|
|
245
|
+
// right answer for, so the stray is left alone and it is said out loud.
|
|
246
|
+
if (stray.isDirectory() !== landed.isDirectory()) {
|
|
247
|
+
console.warn(`${nameSlug}: ${from} and ${to} are not the same kind of thing; leaving the first alone`);
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (LOGS.has(rel)) {
|
|
252
|
+
try {
|
|
253
|
+
const lines = fs.readFileSync(from);
|
|
254
|
+
if (lines.length) fs.appendFileSync(to, lines);
|
|
255
|
+
fs.rmSync(from, { force: true });
|
|
256
|
+
return true;
|
|
257
|
+
} catch (err) {
|
|
258
|
+
console.warn(`${nameSlug}: could not fold ${from} into ${to}: ${err.message}`);
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
if (stray.mtimeMs > landed.mtimeMs) {
|
|
265
|
+
fs.renameSync(to, `${to}.superseded-${Math.round(landed.mtimeMs)}`);
|
|
266
|
+
fs.renameSync(from, to);
|
|
267
|
+
} else {
|
|
268
|
+
fs.renameSync(from, `${to}.superseded-${Math.round(stray.mtimeMs)}`);
|
|
269
|
+
}
|
|
270
|
+
return true;
|
|
271
|
+
} catch (err) {
|
|
272
|
+
console.warn(`${nameSlug}: could not carry ${from} across: ${err.message}`);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Where one of our records lives for this project, and the migration that gets
|
|
279
|
+
* it there.
|
|
280
|
+
*
|
|
281
|
+
* THE MOVE HAPPENS ONCE, and `ensureMachineryDir` above has already done it by
|
|
282
|
+
* the time this returns: anything still sitting in the product folder under one
|
|
283
|
+
* of our names is RENAMED into the home. A rename on the same volume is atomic
|
|
284
|
+
* and costs nothing, whether it is a 6 MB ledger or a directory of 1,785 session
|
|
285
|
+
* traces, and it is undone by moving the file back.
|
|
286
|
+
*
|
|
287
|
+
* It is a move rather than a read-both-write-new because the ledger is an
|
|
288
|
+
* append-only log: two halves of one log in two places is not a store, it is
|
|
289
|
+
* two stores that disagree. Everything that reads it must therefore agree about
|
|
290
|
+
* where it is, which is why the MCP server ships the same file.
|
|
291
|
+
*
|
|
292
|
+
* Races are ordinary here: a dozen processes may call this at once. Every
|
|
293
|
+
* failure mode of the rename is benign and is swallowed — ENOENT means there
|
|
294
|
+
* was nothing to move, EEXIST/ENOTEMPTY means somebody else moved it first.
|
|
295
|
+
*/
|
|
296
|
+
export function machineryPath(projectDir, rel, home = appHome()) {
|
|
297
|
+
if (typeof rel !== 'string' || !rel || path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) {
|
|
298
|
+
throw new Error(`unsafe machinery path: ${rel}`);
|
|
299
|
+
}
|
|
300
|
+
const dir = ensureMachineryDir(projectDir, home);
|
|
301
|
+
const target = path.join(dir, rel);
|
|
302
|
+
if (!fs.existsSync(target)) adoptLegacy(path.join(projectDir, rel), target);
|
|
303
|
+
return target;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* EVERY RECORD OF OURS FOR ONE PROJECT, GONE.
|
|
308
|
+
*
|
|
309
|
+
* This exists for exactly one caller: the practice project, which the founder
|
|
310
|
+
* has now ruled must not survive its own tutorial. Deleting the folder in her
|
|
311
|
+
* store and leaving the ledger, the session traces and the run records behind
|
|
312
|
+
* would be a half removal, and it is the half nobody can see: MEASURED on her
|
|
313
|
+
* real Mac 2026-08-28, the practice folder in her account held two files and
|
|
314
|
+
* the machinery for it held 168 ledger lines and 29 session traces from six
|
|
315
|
+
* walks.
|
|
316
|
+
*
|
|
317
|
+
* IT WILL ONLY EVER DELETE A DIRECTORY THAT SAYS IT BELONGS TO THIS PROJECT.
|
|
318
|
+
* `.origin` is written by `ensureMachineryDir` on first use and holds the
|
|
319
|
+
* resolved path of the project the directory is the machinery for; this refuses
|
|
320
|
+
* unless it is there and it matches. A directory with no `.origin` is one we did
|
|
321
|
+
* not make, or one made by a version that did not write the marker, and either
|
|
322
|
+
* way it is somebody else's to delete. Same instinct as `foldStray` above: when
|
|
323
|
+
* two things are not the same kind of thing, leave the first alone and say so.
|
|
324
|
+
*
|
|
325
|
+
* Never throws. A machinery directory that will not delete is a few kilobytes
|
|
326
|
+
* under the app's own home; failing a launch over it would be the worse bug.
|
|
327
|
+
*
|
|
328
|
+
* Returns { removed, why } so a caller can report what really happened rather
|
|
329
|
+
* than assume.
|
|
330
|
+
*/
|
|
331
|
+
export function removeMachinery(projectDir, home = appHome()) {
|
|
332
|
+
const real = path.resolve(projectDir);
|
|
333
|
+
const dir = machineryDir(real, home);
|
|
334
|
+
let there = false;
|
|
335
|
+
try { there = fs.statSync(dir).isDirectory(); } catch { return { removed: false, why: 'no machinery' }; }
|
|
336
|
+
if (!there) return { removed: false, why: 'not a directory' };
|
|
337
|
+
const claimed = readOrigin(dir);
|
|
338
|
+
if (claimed !== real) {
|
|
339
|
+
console.warn(`${nameSlug}: ${dir} does not claim ${real}; leaving it alone`);
|
|
340
|
+
return { removed: false, why: 'origin does not match' };
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
344
|
+
return { removed: true };
|
|
345
|
+
} catch (err) {
|
|
346
|
+
console.warn(`${nameSlug}: could not remove ${dir}: ${err.message}`);
|
|
347
|
+
return { removed: false, why: err.message };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Move one legacy path in, if it is there. Never throws: a store that cannot be
|
|
352
|
+
// migrated must still open on the new path rather than fail to open at all.
|
|
353
|
+
export function adoptLegacy(from, to) {
|
|
354
|
+
let there = false;
|
|
355
|
+
try { there = fs.existsSync(from); } catch { return false; }
|
|
356
|
+
if (!there) return false;
|
|
357
|
+
try {
|
|
358
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
359
|
+
fs.renameSync(from, to);
|
|
360
|
+
return true;
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if (err.code === 'EXDEV') return copyAcross(from, to);
|
|
363
|
+
// EEXIST / ENOTEMPTY: another process won the race, which is the good case.
|
|
364
|
+
if (err.code !== 'EEXIST' && err.code !== 'ENOTEMPTY' && err.code !== 'ENOENT') {
|
|
365
|
+
console.warn(`${nameSlug}: could not move ${from} into the app's home: ${err.message}`);
|
|
366
|
+
}
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// The store on one volume and the home on another. Rare, and a rename cannot
|
|
372
|
+
// cross it, so copy and then remove — in that order, so a failure halfway
|
|
373
|
+
// leaves the original where it was rather than nothing anywhere.
|
|
374
|
+
function copyAcross(from, to) {
|
|
375
|
+
try {
|
|
376
|
+
fs.cpSync(from, to, { recursive: true, errorOnExist: true, force: false });
|
|
377
|
+
fs.rmSync(from, { recursive: true, force: true });
|
|
378
|
+
return true;
|
|
379
|
+
} catch (err) {
|
|
380
|
+
console.warn(`${nameSlug}: could not copy ${from} into the app's home: ${err.message}`);
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|