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,523 @@
|
|
|
1
|
+
// Project store: <store root>/<product>/ is the truth. Single writer (the main
|
|
2
|
+
// process). All JSON writes are atomic (tmp + rename); index.json spares
|
|
3
|
+
// directory walks; names are sanitized with collision handling.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import { SCHEMA_VERSION, validateCreation, creationText, TEXT_CREATION_KINDS } from '../../shared/contracts.mjs';
|
|
10
|
+
import { assertInside, assertCreationDir, deniesGitOrEnvPath } from './paths.mjs';
|
|
11
|
+
import { migrateIndex } from './migrate.mjs';
|
|
12
|
+
import { withProjectLockSync } from './project-lock.mjs';
|
|
13
|
+
import { Name, readEnv } from '../../shared/product-name.mjs';
|
|
14
|
+
|
|
15
|
+
// The machine-wide root: ASTRAL_HOME, else the folder named below. This is
|
|
16
|
+
// where device-local files live (settings.json, ui-state.json, usage.json,
|
|
17
|
+
// .runtime-pids, ...) and where a founder's PRE-account projects sit until they
|
|
18
|
+
// are migrated into an account space. It is NEVER account-scoped: those files
|
|
19
|
+
// stay global (Phase 1).
|
|
20
|
+
//
|
|
21
|
+
// THE FALLBACK IS THE APP'S OWN NAME NOW, where it used to be an older
|
|
22
|
+
// product's folder.
|
|
23
|
+
//
|
|
24
|
+
// NOTHING WAS DELETED AND NOTHING MOVED. That folder is still on disk exactly
|
|
25
|
+
// as it was. What changed is that this app stops reaching for it by default.
|
|
26
|
+
// Her live store is named in zero.config.json and handed to every worker as
|
|
27
|
+
// the app's own home variable, so this line was never the path she runs on; it
|
|
28
|
+
// was only ever
|
|
29
|
+
// the answer for an install carrying no config at all.
|
|
30
|
+
//
|
|
31
|
+
// An older product's HOME env var used to sit between these two. It is gone:
|
|
32
|
+
// nothing sets it any more now that the store server carries the app's own name.
|
|
33
|
+
export function legacyRoot() {
|
|
34
|
+
return readEnv('HOME') || path.join(os.homedir(), Name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The account currently signed in, if any. Set once at boot and re-resolved on
|
|
38
|
+
// every auth change (main/ipc.mjs), so dataRoot answers per-account without
|
|
39
|
+
// every caller having to thread a userId through. One account is active per
|
|
40
|
+
// running app at a time; switching accounts re-inits (see the account-scoped
|
|
41
|
+
// persistence spec).
|
|
42
|
+
let currentAccountId = null;
|
|
43
|
+
|
|
44
|
+
// A user id sanitized to a safe single path segment (Supabase ids are UUIDs;
|
|
45
|
+
// this is belt-and-braces so a hostile/odd id can never escape accounts/).
|
|
46
|
+
function sanitizeAccountId(userId) {
|
|
47
|
+
return String(userId ?? '').toLowerCase().replace(/[^a-z0-9-]+/g, '').slice(0, 64) || 'account';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function accountRootFor(userId) {
|
|
51
|
+
return path.join(legacyRoot(), 'accounts', sanitizeAccountId(userId));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Enter an account's data space. All later dataRoot answers resolve here.
|
|
55
|
+
export function setAccountScope(userId) {
|
|
56
|
+
currentAccountId = userId ? sanitizeAccountId(userId) : null;
|
|
57
|
+
return dataRoot();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Leave the account space (sign-out). Signed out is DEV-ONLY (packaged builds
|
|
61
|
+
// always gate), and resolves back to the legacy root, exactly the pre-account
|
|
62
|
+
// behavior, so the founder's dev workflow is unchanged.
|
|
63
|
+
export function clearAccountScope() {
|
|
64
|
+
currentAccountId = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function getAccountId() {
|
|
68
|
+
return currentAccountId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The active PROJECT data root. Account-aware: signed in as user X it is
|
|
72
|
+
// <legacyRoot>/accounts/<X>; signed out (dev only) it is the legacy root
|
|
73
|
+
// itself. Machine-wide files deliberately use legacyRoot instead, so they
|
|
74
|
+
// stay global across accounts.
|
|
75
|
+
export function dataRoot() {
|
|
76
|
+
const base = legacyRoot();
|
|
77
|
+
return currentAccountId ? path.join(base, 'accounts', currentAccountId) : base;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function sanitizeName(name) {
|
|
81
|
+
const slug = String(name ?? '').trim().toLowerCase()
|
|
82
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
|
|
83
|
+
return slug || 'untitled';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function atomicWriteJson(file, obj) {
|
|
87
|
+
const tmp = `${file}.tmp-${crypto.randomBytes(4).toString('hex')}`;
|
|
88
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
89
|
+
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
|
|
90
|
+
fs.renameSync(tmp, file);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function readJson(file, fallback = null) {
|
|
94
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function emptyIndex() {
|
|
98
|
+
return { schemaVersion: SCHEMA_VERSION, creations: [], runs: [], reports: [], updatedAt: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Read index.json, migrating a v1 shape to v2 on the way in. The migrated
|
|
102
|
+
// result is written back only when it actually differs from what was on
|
|
103
|
+
// disk, so a project already on v2 never takes a spurious write.
|
|
104
|
+
export function readIndex(projectDir) {
|
|
105
|
+
const file = path.join(projectDir, 'index.json');
|
|
106
|
+
const raw = readJson(file, emptyIndex());
|
|
107
|
+
const idx = migrateIndex(raw);
|
|
108
|
+
if (idx !== raw) atomicWriteJson(file, idx);
|
|
109
|
+
return idx;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createProject(name, root = dataRoot()) {
|
|
113
|
+
fs.mkdirSync(root, { recursive: true });
|
|
114
|
+
let slug = sanitizeName(name);
|
|
115
|
+
let dir = path.join(root, slug);
|
|
116
|
+
for (let n = 2; fs.existsSync(dir); n++) { // collision -> suffix
|
|
117
|
+
slug = `${sanitizeName(name)}-${n}`;
|
|
118
|
+
dir = path.join(root, slug);
|
|
119
|
+
}
|
|
120
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
121
|
+
// `runs` CAME OUT OF THIS LIST. it holds the record of what each run changed,
|
|
122
|
+
// and home.mjs carries it to the app's home. Making it here put an empty
|
|
123
|
+
// folder of our machinery into every brand-new product, which is the thing a
|
|
124
|
+
// new user watched happen and asked us not to do. The rest of this list is
|
|
125
|
+
// hers.
|
|
126
|
+
for (const sub of ['personas', 'transcripts', 'reports', 'designs', 'creations']) {
|
|
127
|
+
fs.mkdirSync(path.join(dir, sub), { recursive: true });
|
|
128
|
+
}
|
|
129
|
+
const project = { schemaVersion: SCHEMA_VERSION, id: slug, name: String(name ?? slug), createdAt: new Date().toISOString() };
|
|
130
|
+
atomicWriteJson(path.join(dir, 'project.json'), project);
|
|
131
|
+
atomicWriteJson(path.join(dir, 'index.json'), emptyIndex());
|
|
132
|
+
return { ...project, dir };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The notes doc a brand-new project opens with. A first-timer reads this, so
|
|
136
|
+
// it must be a friendly, clean starting page: NO scaffolding comments. The
|
|
137
|
+
// agent keeps the index in shape from its skills; the doc itself stays plain
|
|
138
|
+
// prose.
|
|
139
|
+
export function starterNotesDoc(name) {
|
|
140
|
+
return `# ${name}\n\nThis is your product's living map. I keep it short and current as we work: the decisions we make, where things stand, and where the real detail lives. Everything we build together shows up beside this.\n\n## Decisions\n\nNothing decided yet.\n\n## Where we are\n\nJust getting started.\n`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function listProjects(root = dataRoot()) {
|
|
144
|
+
if (!fs.existsSync(root)) return [];
|
|
145
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
146
|
+
// A project may be a symlink into another store root (one physical dir
|
|
147
|
+
// reachable from two roots); a Dirent reports a symlink as
|
|
148
|
+
// not-a-directory, so resolve with stat before filtering.
|
|
149
|
+
.filter((e) => {
|
|
150
|
+
try { return fs.statSync(path.join(root, e.name)).isDirectory(); } catch { return false; }
|
|
151
|
+
})
|
|
152
|
+
.map((e) => {
|
|
153
|
+
const p = readJson(path.join(root, e.name, 'project.json'));
|
|
154
|
+
if (!p) return null;
|
|
155
|
+
const idx = readJson(path.join(root, e.name, 'index.json'), emptyIndex());
|
|
156
|
+
// cloudId and archived ride along because consumers dedupe against them:
|
|
157
|
+
// the app's cloud bootstrap checks `local.cloudId === row.project_id` to
|
|
158
|
+
// decide a registry row is already represented locally. When this list
|
|
159
|
+
// omitted cloudId, that check never matched, and the bootstrap adopted a
|
|
160
|
+
// same-NAME project instead: it stamped a stale duplicate's cloudId onto
|
|
161
|
+
// the real Cascade, and the next sync pull overwrote the real project's
|
|
162
|
+
// identity with the duplicate's (2026-08-04).
|
|
163
|
+
return { id: p.id, name: p.name, createdAt: p.createdAt, updatedAt: idx.updatedAt, dir: path.join(root, e.name), cloudId: p.cloudId ?? null, archived: p.archived === true };
|
|
164
|
+
})
|
|
165
|
+
.filter(Boolean)
|
|
166
|
+
.sort((a, b) => String(b.updatedAt ?? b.createdAt).localeCompare(String(a.updatedAt ?? a.createdAt)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function loadProject(id, root = dataRoot()) {
|
|
170
|
+
const dir = path.join(root, sanitizeName(id));
|
|
171
|
+
const p = readJson(path.join(dir, 'project.json'));
|
|
172
|
+
return p ? { ...p, dir } : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Read a project by the FOLDER a caller already has, not by re-deriving one from
|
|
176
|
+
// an id. loadProject answers "the project in the folder named after this id",
|
|
177
|
+
// which is a different question whenever a product's id and its folder have
|
|
178
|
+
// drifted apart (two products can even carry the same id, so no id-based lookup
|
|
179
|
+
// can tell them apart). Anything that has already resolved a product should keep
|
|
180
|
+
// hold of its directory and come back through here.
|
|
181
|
+
export function loadProjectAt(dir) {
|
|
182
|
+
const p = readJson(path.join(dir, 'project.json'));
|
|
183
|
+
return p ? { ...p, dir } : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function renameProject(id, newName, root = dataRoot()) {
|
|
187
|
+
const name = String(newName ?? '').trim();
|
|
188
|
+
if (!name) return null;
|
|
189
|
+
return patchProject(id, { name }, root);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Delete a product for good: the folder is erased from disk (rm, not Trash).
|
|
193
|
+
// Containment is belt-and-braces: the id resolves through sanitizeName to a
|
|
194
|
+
// DIRECT child of the data root, and that child must actually be a project
|
|
195
|
+
// (project.json present), so a stray id can never point the rm anywhere else.
|
|
196
|
+
export function deleteProject(id, root = dataRoot()) {
|
|
197
|
+
const slug = sanitizeName(id);
|
|
198
|
+
const dir = path.join(root, slug);
|
|
199
|
+
if (path.dirname(path.resolve(dir)) !== path.resolve(root)) throw new Error('refusing: path escapes the data root');
|
|
200
|
+
if (!fs.existsSync(path.join(dir, 'project.json'))) throw new Error(`not a product folder: ${slug}`);
|
|
201
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Async twin of deleteProject. A product that has ever been built is a git repo
|
|
206
|
+
// with a node_modules tree: a synchronous rmSync of it blocks the whole main
|
|
207
|
+
// process for seconds (the frozen "stuck on …" delete). fs.promises.rm yields
|
|
208
|
+
// to the event loop so the app stays responsive while the folder is erased.
|
|
209
|
+
// Same containment guards as the sync version, so a stray id can never point
|
|
210
|
+
// the rm outside the data root.
|
|
211
|
+
export async function deleteProjectAsync(id, root = dataRoot()) {
|
|
212
|
+
const slug = sanitizeName(id);
|
|
213
|
+
const dir = path.join(root, slug);
|
|
214
|
+
if (path.dirname(path.resolve(dir)) !== path.resolve(root)) throw new Error('refusing: path escapes the data root');
|
|
215
|
+
if (!fs.existsSync(path.join(dir, 'project.json'))) throw new Error(`not a product folder: ${slug}`);
|
|
216
|
+
await fs.promises.rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 120 });
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Shallow-merge fields into project.json (e.g. lastSessionId for resume).
|
|
221
|
+
export function patchProject(id, patch, root = dataRoot()) {
|
|
222
|
+
const p = loadProject(id, root);
|
|
223
|
+
if (!p) return null;
|
|
224
|
+
return patchProjectAt(p.dir, patch);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// The same write, aimed at a folder a caller already resolved. This is the safe
|
|
228
|
+
// one: a write that re-derives its folder from an id lands wherever that id
|
|
229
|
+
// happens to name, which is not always the product the caller looked up.
|
|
230
|
+
export function patchProjectAt(dir, patch) {
|
|
231
|
+
const p = loadProjectAt(dir);
|
|
232
|
+
if (!p) return null;
|
|
233
|
+
atomicWriteJson(path.join(dir, 'project.json'), { ...p, dir: undefined, ...patch });
|
|
234
|
+
return { ...p, ...patch };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The ONE place index.json is read, mutated, and written back, which makes it
|
|
238
|
+
// the one place that has to be safe against other PROCESSES.
|
|
239
|
+
//
|
|
240
|
+
// runForProject (main/store/queues.mjs) serializes this within a single process
|
|
241
|
+
// and always did. That was the whole world when the app's main process was the
|
|
242
|
+
// only writer. It is not any more: the MCP server (mcp/) runs as many concurrent
|
|
243
|
+
// children against this same directory, and an in-process promise chain tells
|
|
244
|
+
// them nothing about each other. Two processes both read revision N, both write,
|
|
245
|
+
// and one of them silently loses a document from the catalog.
|
|
246
|
+
//
|
|
247
|
+
// So the read-mutate-write happens under a cross-process file lock. It is taken
|
|
248
|
+
// HERE, precisely, rather than around runForProject: several of that queue's
|
|
249
|
+
// bodies are git pushes and other network work, and holding a lock with a
|
|
250
|
+
// 30-second staleness timer across a slow push would get it broken mid-flight,
|
|
251
|
+
// which is worse than not locking at all. This section is a read, a mutate, and
|
|
252
|
+
// a rename, so it is over in about a millisecond.
|
|
253
|
+
export function updateIndex(projectDir, mutate) {
|
|
254
|
+
return withProjectLockSync(projectDir, () => {
|
|
255
|
+
const file = path.join(projectDir, 'index.json');
|
|
256
|
+
const idx = readIndex(projectDir);
|
|
257
|
+
mutate(idx);
|
|
258
|
+
idx.updatedAt = new Date().toISOString();
|
|
259
|
+
atomicWriteJson(file, idx);
|
|
260
|
+
return idx;
|
|
261
|
+
}, { label: 'updateIndex' });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* --------------------------------- chat --------------------------------- */
|
|
265
|
+
export function appendChat(projectDir, message) {
|
|
266
|
+
fs.appendFileSync(path.join(projectDir, 'chat.jsonl'), JSON.stringify(message) + '\n');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The log is append-only; a message is UPDATED by appending a line with the
|
|
270
|
+
// same id (asks do this when answered). Reading folds duplicates: last line
|
|
271
|
+
// wins, the message keeps its original position in the conversation.
|
|
272
|
+
export function readChat(projectDir) {
|
|
273
|
+
let lines;
|
|
274
|
+
try {
|
|
275
|
+
lines = fs.readFileSync(path.join(projectDir, 'chat.jsonl'), 'utf8').split('\n').filter(Boolean);
|
|
276
|
+
} catch { return []; }
|
|
277
|
+
const order = []; const byId = new Map();
|
|
278
|
+
for (const l of lines) {
|
|
279
|
+
let m; try { m = JSON.parse(l); } catch { continue; } // torn tail line: skip
|
|
280
|
+
if (!m?.id) continue;
|
|
281
|
+
if (!byId.has(m.id)) order.push(m.id);
|
|
282
|
+
byId.set(m.id, { ...(byId.get(m.id) ?? {}), ...m });
|
|
283
|
+
}
|
|
284
|
+
return order.map((id) => byId.get(id));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* ------------------------------- creations ------------------------------- */
|
|
288
|
+
// Envelope in index.json; content in the file at envelope.path (project-relative)
|
|
289
|
+
// for a single-file creation, or under envelope.dir (a git-backed folder,
|
|
290
|
+
// see main/creations/materialize.mjs) for a folder-backed one.
|
|
291
|
+
export function saveCreation(projectDir, envelope, content) {
|
|
292
|
+
// Validate at the sink, not on trust: every catalog writer routes through
|
|
293
|
+
// here, so a malformed envelope (an unregistered kind, path and dir both
|
|
294
|
+
// set) is refused before it can land in index.json. The phantom kind 'doc'
|
|
295
|
+
// lived for a week because nothing enforced the registry at save time.
|
|
296
|
+
const problems = validateCreation(envelope);
|
|
297
|
+
if (problems.length) throw new Error(`saveCreation: invalid envelope: ${problems.join('; ')}`);
|
|
298
|
+
if (envelope.dir) {
|
|
299
|
+
// Folder-backed: the file itself is written by materializeCreation /
|
|
300
|
+
// writeCreationFile, which also commit it. This call only updates the
|
|
301
|
+
// catalog entry; there is no single path to put `content`, so a caller
|
|
302
|
+
// passing real content (instead of null) is a bug in that caller, not
|
|
303
|
+
// silently dropped data.
|
|
304
|
+
if (content != null) {
|
|
305
|
+
throw new Error('saveCreation: content is ignored for a folder-backed creation (dir set); write files via materializeCreation/writeCreationFile instead');
|
|
306
|
+
}
|
|
307
|
+
// A folder-backed envelope's `dir` must be a real subdirectory of
|
|
308
|
+
// creations/ (assertCreationDir, main/store/paths.mjs): plain containment
|
|
309
|
+
// would happily accept "creations" itself, a run directory, or the
|
|
310
|
+
// project root, and every later read/list/write/delete on this envelope
|
|
311
|
+
// trusts `dir` as the creation's root. Reject anything else, and do it
|
|
312
|
+
// BEFORE the catalog write so a bad envelope (e.g. from a compromised
|
|
313
|
+
// sync peer) never lands in index.json at all.
|
|
314
|
+
const absDir = assertCreationDir(projectDir, envelope.dir);
|
|
315
|
+
updateIndex(projectDir, (idx) => {
|
|
316
|
+
idx.creations = idx.creations.filter((c) => c.id !== envelope.id);
|
|
317
|
+
idx.creations.push({ ...envelope, schemaVersion: envelope.schemaVersion ?? SCHEMA_VERSION });
|
|
318
|
+
});
|
|
319
|
+
return absDir;
|
|
320
|
+
}
|
|
321
|
+
const rel = envelope.path;
|
|
322
|
+
if (!rel) throw new Error(`unsafe creation path: ${rel}`);
|
|
323
|
+
// Deny at the SINK, not just in the tool arg branch: a single-file envelope
|
|
324
|
+
// pointing at .git/.env (a compromised sync peer, or a legacy pre-deny
|
|
325
|
+
// envelope) must never write a secret path here, no matter which caller
|
|
326
|
+
// reached us. Folder-backed envelopes (dir set) take the branch above and are
|
|
327
|
+
// denied at their own file IO in materialize.mjs.
|
|
328
|
+
if (deniesGitOrEnvPath(rel)) throw new Error(`refusing to write a .git/.env creation path: ${rel}`);
|
|
329
|
+
const file = assertInside(projectDir, rel);
|
|
330
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
331
|
+
// A TEXT KIND REACHES DISK AS TEXT, whatever the caller handed us. Writing an
|
|
332
|
+
// editor's JSON tree here instead is invisible at the time and shows up as a
|
|
333
|
+
// blank panel beside a chat saying the document is open: the viewer tests for
|
|
334
|
+
// a string, an object fails it, and nothing downstream has a branch for that
|
|
335
|
+
// shape. Coerced rather than refused, because the founder's words matter more
|
|
336
|
+
// than the caller's mistake and throwing here would lose them.
|
|
337
|
+
// ...but only where the file itself is text. A text KIND can still hold a
|
|
338
|
+
// structured document when its path says so (a markdown creation pointed at
|
|
339
|
+
// a .json file is an existing, deliberate path), and the envelope's own
|
|
340
|
+
// extension is the declaration rather than something to infer.
|
|
341
|
+
const wantsText = content != null
|
|
342
|
+
&& TEXT_CREATION_KINDS.includes(envelope.kind)
|
|
343
|
+
&& !/\.json$/i.test(rel);
|
|
344
|
+
const body = wantsText ? creationText(content) : content;
|
|
345
|
+
if (typeof body === 'string') {
|
|
346
|
+
const tmp = `${file}.tmp-${crypto.randomBytes(4).toString('hex')}`;
|
|
347
|
+
fs.writeFileSync(tmp, body);
|
|
348
|
+
fs.renameSync(tmp, file);
|
|
349
|
+
} else if (body != null) {
|
|
350
|
+
atomicWriteJson(file, body);
|
|
351
|
+
}
|
|
352
|
+
updateIndex(projectDir, (idx) => {
|
|
353
|
+
idx.creations = idx.creations.filter((c) => c.id !== envelope.id);
|
|
354
|
+
idx.creations.push({ ...envelope, schemaVersion: envelope.schemaVersion ?? SCHEMA_VERSION });
|
|
355
|
+
});
|
|
356
|
+
return file;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Company V1: mark a creation as history (kept forever, hidden from default
|
|
360
|
+
// reading, one click away for the founder) or bring it back to current. `status`
|
|
361
|
+
// and `replacedBy` are OPTIONAL, ADDITIVE envelope metadata: an envelope without
|
|
362
|
+
// them reads as current, so every existing index.json keeps working untouched
|
|
363
|
+
// and an existing user sees zero behavior change. Both fields spread straight
|
|
364
|
+
// through saveCreation/migrateIndex (whole-envelope copies), so no validator or
|
|
365
|
+
// schemaVersion change is needed.
|
|
366
|
+
//
|
|
367
|
+
// CRITICAL SYNC RULE: the write stamps `updatedAt` (NOT `ts`, which is creation
|
|
368
|
+
// time and must not move). The sync merge (main/store/sync.mjs mergeRegistry)
|
|
369
|
+
// picks the newer whole entry by `stamp = Date.parse(updatedAt) || ts`, so a
|
|
370
|
+
// status change MUST carry a parseable-newer updatedAt or a stale envelope from
|
|
371
|
+
// another machine would silently overwrite it. `stamp` runs Date.parse on
|
|
372
|
+
// updatedAt, so it must be an ISO 8601 STRING (a numeric Date.now parses to NaN
|
|
373
|
+
// and would fall back to the old `ts`, losing the merge) -> new
|
|
374
|
+
// Date.toISOString. Clearing back to current DELETES the keys (rather than
|
|
375
|
+
// storing status:'current'), so a reverted creation is byte-identical to one
|
|
376
|
+
// that never had the fields. The three states a piece of work can be in.
|
|
377
|
+
// `current` is the absence of a status (the default, so nothing needs
|
|
378
|
+
// migrating), and the two named ones are both "do not read this as the live
|
|
379
|
+
// answer", for opposite reasons:
|
|
380
|
+
//
|
|
381
|
+
// history superseded. Something replaced it, and replacedBy says what.
|
|
382
|
+
// exploration never adopted. An afternoon of playing around that fizzled
|
|
383
|
+
// without a verdict. Added 2026-07-25 after a round of throwaway
|
|
384
|
+
// landing-page designs kept their default authority for a day
|
|
385
|
+
// and got promoted to "(live answer)" in the index, which then
|
|
386
|
+
// outranked a live web fetch the agent performed itself and
|
|
387
|
+
// produced a go-to-market plan built on a page that was never
|
|
388
|
+
// real. Binary status was the hole: work that fizzles has no
|
|
389
|
+
// winner to point at, so history is the wrong word for it, and
|
|
390
|
+
// the burden of demoting it fell on a founder who had already
|
|
391
|
+
// moved on and had no reason to think about it again.
|
|
392
|
+
export const CREATION_STATUSES = ['current', 'history', 'exploration'];
|
|
393
|
+
|
|
394
|
+
export function setCreationStatus(projectDir, id, { status, replacedBy } = {}) {
|
|
395
|
+
const idx = readIndex(projectDir);
|
|
396
|
+
const envelope = (idx.creations ?? []).find((c) => c.id === id && !c.deleted);
|
|
397
|
+
if (!envelope) throw new Error(`no such creation: ${id}`);
|
|
398
|
+
const next = { ...envelope, updatedAt: new Date().toISOString() };
|
|
399
|
+
if (status === 'history' || status === 'exploration') {
|
|
400
|
+
next.status = status;
|
|
401
|
+
const note = typeof replacedBy === 'string' ? replacedBy.trim() : '';
|
|
402
|
+
if (note) next.replacedBy = note; else delete next.replacedBy;
|
|
403
|
+
} else {
|
|
404
|
+
delete next.status;
|
|
405
|
+
delete next.replacedBy;
|
|
406
|
+
}
|
|
407
|
+
saveCreation(projectDir, next, null);
|
|
408
|
+
return next;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Tombstone delete: the catalog entry is REPLACED, not removed, with
|
|
412
|
+
// { id, deleted: true, ts } so sync and any open tab can tell "gone" from
|
|
413
|
+
// "never existed". The backing file is unlinked (containment-checked); a
|
|
414
|
+
// folder-backed creation's `dir` is removed recursively. A directory-shaped
|
|
415
|
+
// `path` (e.g. a live run) is left on disk, only the catalog entry tombstones.
|
|
416
|
+
// Returns the removed envelope, or null if there was no such creation.
|
|
417
|
+
export function deleteCreation(projectDir, id) {
|
|
418
|
+
const idx = readIndex(projectDir);
|
|
419
|
+
const envelope = (idx.creations ?? []).find((c) => c.id === id);
|
|
420
|
+
if (!envelope) return null;
|
|
421
|
+
const rel = envelope.path;
|
|
422
|
+
if (rel) {
|
|
423
|
+
try {
|
|
424
|
+
const file = assertInside(projectDir, rel);
|
|
425
|
+
// only unlink a real FILE inside the project (never a run directory)
|
|
426
|
+
if (fs.statSync(file).isFile()) fs.unlinkSync(file);
|
|
427
|
+
} catch { /* already gone, unsafe, or a dir: leave it */ }
|
|
428
|
+
}
|
|
429
|
+
if (envelope.dir) {
|
|
430
|
+
// assertCreationDir (not plain assertInside): a tampered/stale envelope
|
|
431
|
+
// with dir: "." or dir: "runs/x" must never turn this into an rm -rf of
|
|
432
|
+
// the project root or some unrelated subtree. Only an UNSAFE dir is
|
|
433
|
+
// swallowed here (disk left alone, catalog still tombstones below); a
|
|
434
|
+
// real removal failure on a legitimately-scoped dir is a genuine error
|
|
435
|
+
// and must surface, not silently report success.
|
|
436
|
+
let abs = null;
|
|
437
|
+
try { abs = assertCreationDir(projectDir, envelope.dir); } catch { /* unsafe dir: leave disk alone */ }
|
|
438
|
+
if (abs) fs.rmSync(abs, { recursive: true, force: true });
|
|
439
|
+
}
|
|
440
|
+
updateIndex(projectDir, (i) => {
|
|
441
|
+
i.creations = (i.creations ?? []).map((c) => (c.id === id ? { id, deleted: true, ts: Date.now() } : c));
|
|
442
|
+
});
|
|
443
|
+
// A deleted creation must not leave the project's product pointer dangling:
|
|
444
|
+
// build sessions resolve productCreationId to a cwd, and a pointer at a
|
|
445
|
+
// tombstone once sent every product-path build into a nonexistent directory
|
|
446
|
+
// (spawn ENOENT, misreported by the SDK as a libc problem). Clearing it here
|
|
447
|
+
// covers every deletion caller.
|
|
448
|
+
const projFile = path.join(projectDir, 'project.json');
|
|
449
|
+
const proj = readJson(projFile);
|
|
450
|
+
if (proj?.productCreationId === id) {
|
|
451
|
+
atomicWriteJson(projFile, { ...proj, productCreationId: null });
|
|
452
|
+
}
|
|
453
|
+
return envelope;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function readCreationContent(projectDir, envelope) {
|
|
457
|
+
const rel = envelope.path;
|
|
458
|
+
if (!rel) throw new Error(`unsafe creation path: ${rel}`);
|
|
459
|
+
// A kind 'file' creation is raw bytes (video, audio, an arbitrary asset);
|
|
460
|
+
// reading it as utf8 would hand back garbage. Its viewer streams the bytes
|
|
461
|
+
// through creation-file:// instead; content is null by design.
|
|
462
|
+
if (envelope.kind === 'file') return null;
|
|
463
|
+
// Deny at the SINK: this covers ALL callers, including read_creation({id})
|
|
464
|
+
// and the IPC openProject snapshot map, which read the CATALOGED
|
|
465
|
+
// envelope.path and never pass through the tool-level args.path check. A
|
|
466
|
+
// tampered/legacy single-file envelope pointing at .env / .git/config must
|
|
467
|
+
// throw here so it cannot leak; the openProject map try/catches this into a
|
|
468
|
+
// null-content (empty) tab.
|
|
469
|
+
if (deniesGitOrEnvPath(rel)) throw new Error(`refusing to read a .git/.env creation path: ${rel}`);
|
|
470
|
+
const file = assertInside(projectDir, rel);
|
|
471
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
472
|
+
try { return JSON.parse(raw); } catch { return raw; }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// is a claim about the founder's SCREEN, and the tab strip used to be renderer
|
|
476
|
+
// state only: an artifact opened while nobody was listening (the web bridge
|
|
477
|
+
// reconnecting, a background chat finishing after a reload, the founder on
|
|
478
|
+
// another project) reached no client and was never written down, so the
|
|
479
|
+
// sentence was true when the tool ran and false when she read it. Recording the
|
|
480
|
+
// open here makes it durable.
|
|
481
|
+
//
|
|
482
|
+
// Append-only and idempotent on purpose. It never reorders, never closes, and
|
|
483
|
+
// never touches activeTab: an artifact opening while she reads something else
|
|
484
|
+
// must not yank her away. The live creation.opened event still focuses the tab
|
|
485
|
+
// for a founder who IS watching, and her own saveView still wins afterwards
|
|
486
|
+
// (closing a tab is her call, and last write wins). Returns the tab list.
|
|
487
|
+
export function noteOpenedTab(projectDir, creationId) {
|
|
488
|
+
if (!projectDir || typeof creationId !== 'string' || !creationId) return null;
|
|
489
|
+
const file = path.join(projectDir, 'view.json');
|
|
490
|
+
const view = readJson(file, null) ?? { openTabs: [], activeTab: null, pinnedTabs: [], browsers: [] };
|
|
491
|
+
const openTabs = Array.isArray(view.openTabs) ? view.openTabs : [];
|
|
492
|
+
if (openTabs.includes(creationId)) return openTabs;
|
|
493
|
+
const next = [...openTabs, creationId].slice(-200);
|
|
494
|
+
atomicWriteJson(file, { ...view, openTabs: next });
|
|
495
|
+
return next;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// The project:open creations map, with LAZY bodies. Shipping every creation's
|
|
499
|
+
// full content made opening a big project a multi-second stall (one giant
|
|
500
|
+
// frame over the web socket), so content rides along only for tabs that are
|
|
501
|
+
// actually open plus small docs (library excerpts stay populated). The
|
|
502
|
+
// renderer hydrates anything else through IPC.creationContent on tab open.
|
|
503
|
+
export const SNAPSHOT_SMALL_CONTENT_BYTES = 16 * 1024;
|
|
504
|
+
export function snapshotCreations(projectDir, openIds = new Set()) {
|
|
505
|
+
const live = (readIndex(projectDir).creations ?? []).filter((c) => !c.deleted);
|
|
506
|
+
return live.map((envelope) => {
|
|
507
|
+
// a live-run's path is a run DIRECTORY, not a content file: it has no body
|
|
508
|
+
// to read (the tab rebuilds its last still from meta), so skip the read
|
|
509
|
+
// rather than lean on a directory read throwing.
|
|
510
|
+
if (envelope.kind === 'live-run') return { envelope, content: null };
|
|
511
|
+
let content = null;
|
|
512
|
+
try {
|
|
513
|
+
// readCreationContent enforces containment and the .git/.env deny; a
|
|
514
|
+
// tampered index.json yields a null-content tab, never file contents.
|
|
515
|
+
content = readCreationContent(projectDir, envelope) ?? null;
|
|
516
|
+
} catch { /* unsafe path or unreadable; renderer shows the tab empty */ }
|
|
517
|
+
if (content != null && !openIds.has(envelope.id)) {
|
|
518
|
+
const size = typeof content === 'string' ? content.length : JSON.stringify(content).length;
|
|
519
|
+
if (size > SNAPSHOT_SMALL_CONTENT_BYTES) content = null;
|
|
520
|
+
}
|
|
521
|
+
return { envelope, content };
|
|
522
|
+
});
|
|
523
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// main/store/queues.mjs
|
|
2
|
+
//
|
|
3
|
+
// SCOPE, since this is now half of the answer rather than all of it: this queue
|
|
4
|
+
// orders work WITHIN one process. Cross-PROCESS safety (the MCP server in mcp/
|
|
5
|
+
// runs many concurrent children against the same project directory) is handled
|
|
6
|
+
// where the shared mutable state actually is, not here:
|
|
7
|
+
//
|
|
8
|
+
// index.json updateIndex takes a cross-process file lock directly
|
|
9
|
+
// (main/store/project-lock.mjs)
|
|
10
|
+
// work items append-only jsonl, folded newest-wins, so there is no
|
|
11
|
+
// read-modify-write to lose (main/store/work-items.mjs)
|
|
12
|
+
//
|
|
13
|
+
// Deliberately NOT wrapped around this queue: several bodies below are git
|
|
14
|
+
// pushes and other network calls, and a cross-process lock held across a slow
|
|
15
|
+
// push would be broken as stale mid-flight, which is worse than not holding one.
|
|
16
|
+
//
|
|
17
|
+
// One shared per-project write queue for the whole main process. Catalog
|
|
18
|
+
// mutations (index.json read-modify-write) and git-spine ops (commit/restore/
|
|
19
|
+
// push) on the same project must never interleave across concurrent tool
|
|
20
|
+
// calls or IPC handlers, so every caller that touches a project's catalog or
|
|
21
|
+
// a creation's git repo routes through runForProject(project.id, fn) instead
|
|
22
|
+
// of building its own queue. Keyed by project id (makeWriteQueue keys by
|
|
23
|
+
// string, already serializes correctly across unrelated projects).
|
|
24
|
+
|
|
25
|
+
import { makeWriteQueue } from './write-queue.mjs';
|
|
26
|
+
|
|
27
|
+
export const projectQueue = makeWriteQueue();
|
|
28
|
+
|
|
29
|
+
export function runForProject(projectId, fn) {
|
|
30
|
+
return projectQueue.run(projectId, fn);
|
|
31
|
+
}
|