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
package/main/store.mjs
ADDED
|
@@ -0,0 +1,1269 @@
|
|
|
1
|
+
// The app's read/write surface over its own store. The inbox IS the state
|
|
2
|
+
// layer: everything rendered is derived fresh from files, and everything the
|
|
3
|
+
// founder does becomes an appended line through the store's own write path.
|
|
4
|
+
// The app holds no state an app restart would lose.
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { loadStore } from './store-modules.mjs';
|
|
10
|
+
import { Repeats } from './repeats.mjs';
|
|
11
|
+
import { answerFor, describeFor } from './first-run.mjs';
|
|
12
|
+
import { checkProjectFolder } from '../shared/project-folder-check.mjs';
|
|
13
|
+
import { EXAMPLES, stampsFor } from '../shared/first-run-examples.mjs';
|
|
14
|
+
import {
|
|
15
|
+
PRACTICE_ANSWER, PRACTICE_FLAG, PRACTICE_NAME, PRACTICE_NOTE, PRACTICE_ROWS, PRACTICE_SLUG,
|
|
16
|
+
PRACTICE_TASK_TRACE,
|
|
17
|
+
} from '../shared/first-run-practice.mjs';
|
|
18
|
+
import { NOTE_NAME } from './rail-note.mjs';
|
|
19
|
+
import { iconPathFor } from './project-identity.mjs';
|
|
20
|
+
import { imgUrl } from './img-scheme.mjs';
|
|
21
|
+
import {
|
|
22
|
+
agentImportRow, agentsNeedingRows, threadImportRow, threadsNeedingRows,
|
|
23
|
+
} from '../shared/agent-import.mjs';
|
|
24
|
+
import {
|
|
25
|
+
CODEX_IMPORT_LABEL, NOT_IMPORTED_LABEL, codexIdOf, codexImportRow, codexMirrorPatch, isCodexImportRow, isCodexMirrorRow,
|
|
26
|
+
productsForThread, threadsNeedingRows as codexThreadsNeedingRows,
|
|
27
|
+
} from '../shared/codex-import.mjs';
|
|
28
|
+
import { buildClaimLine, buildReleaseLine } from '../shared/work-items.mjs';
|
|
29
|
+
import { isEffortWord } from '../shared/effort-levels.mjs';
|
|
30
|
+
import { machineryPath, removeMachinery } from './store/home.mjs';
|
|
31
|
+
import {
|
|
32
|
+
answerMode as readAnswerMode, answerModes, clearAnswerMode as clearAnswerModeFile, setAnswerMode,
|
|
33
|
+
touchAnswerMode as touchAnswerModeFile,
|
|
34
|
+
} from './answer-modes.mjs';
|
|
35
|
+
|
|
36
|
+
export class Store {
|
|
37
|
+
constructor(config) {
|
|
38
|
+
this.config = config;
|
|
39
|
+
this.modules = null;
|
|
40
|
+
this.repeats = null;
|
|
41
|
+
this.watchers = [];
|
|
42
|
+
this.onChange = null; // set by ipc layer; called on any store file change
|
|
43
|
+
this._debounce = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async init() {
|
|
47
|
+
this.modules = await loadStore();
|
|
48
|
+
this.repeats = new Repeats(this.modules);
|
|
49
|
+
// AND A PRACTICE PROJECT LEFT OVER FROM LAST TIME GOES NOW.A walk that was
|
|
50
|
+
// quit, or a Mac that went to sleep in the middle of one, leaves the
|
|
51
|
+
// practice project sitting in the account root, and everything downstream
|
|
52
|
+
// then treats it as a real project she owns: it is in the rail, it is in
|
|
53
|
+
// the compose card, and it is the project the card opens on. Nothing
|
|
54
|
+
// resumes a walk across a restart (`firstRunNeeded` reads the project
|
|
55
|
+
// COUNT, and a leftover practice project is itself one of them), so a
|
|
56
|
+
// practice project present at launch is by definition a leftover and never
|
|
57
|
+
// one somebody is standing in.
|
|
58
|
+
//
|
|
59
|
+
// In `init` rather than in a launch hook because this is the one place every
|
|
60
|
+
// the app opens its store, and a tidy-up that has to be remembered at a new
|
|
61
|
+
// call site is one that eventually is not.
|
|
62
|
+
try { this.sweepPractice(); } catch { /* a leftover folder is not a reason to fail to open */ }
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/* ------------------------------- products ------------------------------ */
|
|
67
|
+
// A product is a project directory under the account root carrying a
|
|
68
|
+
// project.json. Archived products (or the reserved analytics id) don't ride.
|
|
69
|
+
listProducts() {
|
|
70
|
+
const root = this.config.accountRoot;
|
|
71
|
+
let entries = [];
|
|
72
|
+
try {
|
|
73
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
74
|
+
} catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const wanted = this.config.products;
|
|
78
|
+
const products = [];
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (entry.name.startsWith('_')) continue;
|
|
81
|
+
if (wanted.length && !wanted.includes(entry.name)) continue;
|
|
82
|
+
const dir = path.join(root, entry.name);
|
|
83
|
+
// statSync, not the Dirent: a product may be a symlink into another
|
|
84
|
+
// store root (the combined-workspace pattern), and a Dirent reports a
|
|
85
|
+
// symlink as not-a-directory, which made a symlinked product silently
|
|
86
|
+
// invisible to the inbox and the supervisor.
|
|
87
|
+
let st = null;
|
|
88
|
+
try { st = fs.statSync(dir); } catch { continue; }
|
|
89
|
+
if (!st.isDirectory()) continue;
|
|
90
|
+
let project = null;
|
|
91
|
+
try {
|
|
92
|
+
project = JSON.parse(fs.readFileSync(path.join(dir, 'project.json'), 'utf8'));
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (project.archived) continue;
|
|
97
|
+
products.push({
|
|
98
|
+
slug: entry.name,
|
|
99
|
+
dir,
|
|
100
|
+
name: project.name || entry.name,
|
|
101
|
+
oneLiner: project.oneLiner || '',
|
|
102
|
+
repoPath: project.repoPath || null,
|
|
103
|
+
// THE MARK SHE CHOSE FOR THIS PROJECT, already a url the window can
|
|
104
|
+
// draw, or null on every project that has never been given one, which is
|
|
105
|
+
// what makes the rail draw its burst instead (main/project-identity.mjs).
|
|
106
|
+
//
|
|
107
|
+
// RESOLVED HERE rather than passed on raw, because the rail, the
|
|
108
|
+
// composer's picker, ⌘K and the settings nav all draw a product off this
|
|
109
|
+
// one list, and every one of them would otherwise have to know that
|
|
110
|
+
// `logo` is a bare file name needing `dir` joined to it and a scheme put
|
|
111
|
+
// in front. It costs one stat per product that HAS a mark, on a list
|
|
112
|
+
// that already does a stat and a read per product.
|
|
113
|
+
logo: (() => { const f = iconPathFor({ dir, logo: project.logo }); return f ? imgUrl(f) : null; })(),
|
|
114
|
+
// THE PRACTICE PROJECT SAYS SO ABOUT ITSELF. Read off project.json
|
|
115
|
+
// rather than matched on a name, because "Practice" is a name somebody
|
|
116
|
+
// could reasonably give a real project of their own, and the things
|
|
117
|
+
// that turn on this flag — never spawning a worker in it, drawing a
|
|
118
|
+
// band over it — must never happen to real work.
|
|
119
|
+
practice: project[PRACTICE_FLAG] === true,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return products.sort((a, b) => a.name.localeCompare(b.name));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* ------------------------------ work items ----------------------------- */
|
|
126
|
+
// The cross-product fold. Every item carries its product slug so the
|
|
127
|
+
// renderer can group, filter, and rail without a second lookup.
|
|
128
|
+
listItems(now = Date.now()) {
|
|
129
|
+
const { workItemsDisk } = this.modules;
|
|
130
|
+
const all = [];
|
|
131
|
+
// WHAT ONE REPLY MAY DO IS NOT ON THE LEDGER, so it is joined on here, once
|
|
132
|
+
// for the whole fold rather than a file read per row (main/answer-modes.mjs).
|
|
133
|
+
const modes = answerModes({ now });
|
|
134
|
+
for (const product of this.listProducts()) {
|
|
135
|
+
let items = [];
|
|
136
|
+
try {
|
|
137
|
+
items = workItemsDisk.readWorkItems(product.dir, now);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.warn(`zero: unreadable work items for ${product.slug}: ${err.message}`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
for (const item of items) {
|
|
143
|
+
const row = { ...item, product: product.slug, productName: product.name };
|
|
144
|
+
const key = `${product.slug}/${item.id}`;
|
|
145
|
+
if (key in modes) row.answerMode = modes[key];
|
|
146
|
+
all.push(row);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return all;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// One item, folded fresh off disk. The supervisor holds a SPAWN-TIME snapshot
|
|
153
|
+
// for the whole life of a session, so it cannot ask what the session did to
|
|
154
|
+
// the row without reading it again; that question is the difference between a
|
|
155
|
+
// delivery and a no-op (settleDelivery, main/supervisor.mjs).
|
|
156
|
+
readItem(slug, id, now = Date.now()) {
|
|
157
|
+
const { workItemsDisk } = this.modules;
|
|
158
|
+
const item = workItemsDisk.readWorkItem(this.productDir(slug), id, now);
|
|
159
|
+
if (!item) return null;
|
|
160
|
+
const row = { ...item, product: slug };
|
|
161
|
+
const mode = readAnswerMode(slug, id, { now });
|
|
162
|
+
if (mode !== undefined) row.answerMode = mode;
|
|
163
|
+
return row;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// EVERYTHING THAT HAPPENED ON ONE ITEM, as the lines it actually happened as.
|
|
167
|
+
//
|
|
168
|
+
// The fold keeps the latest value per field, so it cannot answer "what
|
|
169
|
+
// happened here" at all: the row's first title, the reply she sent before the
|
|
170
|
+
// one on screen, the moment an agent picked it up are all in the ledger and
|
|
171
|
+
// nowhere else. Her thread history reads them (renderer/src/thread-history.ts
|
|
172
|
+
// turns them into sentences; this end takes no view on what they mean).
|
|
173
|
+
//
|
|
174
|
+
// The lease lines go here rather than there because they are the bulk of the
|
|
175
|
+
// file and none of them are news: a worker holding a task for an hour writes
|
|
176
|
+
// a heartbeat a minute, and's 39 lines are 15 events.
|
|
177
|
+
readHistory(slug, id, { max = 400 } = {}) {
|
|
178
|
+
const { workItemsDisk } = this.modules;
|
|
179
|
+
if (!/^w-[0-9a-f]{6,}$/.test(String(id ?? ''))) return [];
|
|
180
|
+
const file = workItemsDisk._internals.ledgerPath(this.productDir(slug));
|
|
181
|
+
const lines = [];
|
|
182
|
+
for (const raw of workItemsDisk._internals.readLines(file)) {
|
|
183
|
+
let line = null;
|
|
184
|
+
try { line = JSON.parse(raw); } catch { continue; }
|
|
185
|
+
if (!line || line.id !== id) continue;
|
|
186
|
+
if (line.heartbeat || line.release) continue;
|
|
187
|
+
lines.push(line);
|
|
188
|
+
}
|
|
189
|
+
return lines.slice(-max);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// THE CONVERSATION ON ONE ROW, oldest first, for the brief a spawning worker
|
|
193
|
+
// is handed (main/supervisor.mjs, buildBrief). readHistory above is the same
|
|
194
|
+
// ledger read for her thread view and stops at the lines; this one asks
|
|
195
|
+
// shared/work-items.mjs which of them was somebody SPEAKING.
|
|
196
|
+
//
|
|
197
|
+
// Uncapped on purpose. The brief does the trimming, because only the brief
|
|
198
|
+
// knows what else is competing for the same session's attention, and a cap
|
|
199
|
+
// here would silently decide it twice.
|
|
200
|
+
readThread(slug, id) {
|
|
201
|
+
const { workItemsDisk, workItemsCore } = this.modules;
|
|
202
|
+
if (!/^w-[0-9a-f]{6,}$/.test(String(id ?? ''))) return [];
|
|
203
|
+
const file = workItemsDisk._internals.ledgerPath(this.productDir(slug));
|
|
204
|
+
return workItemsCore.threadOf(workItemsDisk._internals.readLines(file), id);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The scheduling rule, delegated rather than copied. A second "is this due
|
|
208
|
+
// yet" is exactly how the spawner and the inbox would come to disagree about
|
|
209
|
+
// the same item, which is the drift this file exists to prevent.
|
|
210
|
+
isDue(item, now = Date.now()) {
|
|
211
|
+
return this.modules.workItemsCore.isDue(item, now);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
productDir(slug) {
|
|
215
|
+
const dir = path.join(this.config.accountRoot, slug);
|
|
216
|
+
if (!fs.existsSync(path.join(dir, 'project.json'))) throw new Error(`no such product: ${slug}`);
|
|
217
|
+
return dir;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// WHERE ONE OF OUR OWN RECORDS FOR A PRODUCT LIVES, and it is not in the
|
|
221
|
+
// product folder. The ledger, session traces and repeat rules are the app's
|
|
222
|
+
// bookkeeping, so they sit in the app's home the way Claude Code's do
|
|
223
|
+
// (main/store/home.mjs). The founder's own documents, and the two files that
|
|
224
|
+
// describe them, stay where she can see them.
|
|
225
|
+
machineryFile(slug, rel) {
|
|
226
|
+
return machineryPath(this.productDir(slug), rel);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Founder writes. Content and lifecycle alike go in as HERS: what she says
|
|
230
|
+
// and what she decides an item's state is both outrank any agent line.
|
|
231
|
+
//
|
|
232
|
+
// Status used to go in as 'system', on the reasoning that a founder-authored
|
|
233
|
+
// status would freeze the item's lifecycle because a worker could never move
|
|
234
|
+
// it again. The reasoning was right abo writing her archive with machinery's
|
|
235
|
+
// authority let the next thing a worker wrote hand the item straight back to
|
|
236
|
+
// her inbox, three times in fourteen minutes.
|
|
237
|
+
//
|
|
238
|
+
// The fold now draws that line itself: her 'open' is a HANDBACK and claims no
|
|
239
|
+
// authority, so the claim, the blocked and the done that follow all land,
|
|
240
|
+
// while her terminal words hold (shared/work-items.mjs, authorityOf). Pinned
|
|
241
|
+
// by tests/v0-loop.test.mjs here and archive-is-final.test.mjs there.
|
|
242
|
+
// `permissionMode` is WHAT THE ANSWER ITSELF MAY DO, and it belongs to this
|
|
243
|
+
// one reply rather than to the project.It is written as a founder field
|
|
244
|
+
// beside the answer, read once by the run that answer starts
|
|
245
|
+
// (supervisor.spawnPlan) and cleared by that run, so it never leaks into the
|
|
246
|
+
// next one. `model` and `effort` are WHICH MODEL PICKS THE NEXT RUN UP, set
|
|
247
|
+
// from the drawer in the reply box.
|
|
248
|
+
//
|
|
249
|
+
// They go on the ITEM as founder fields, exactly where the new-task card
|
|
250
|
+
// writes them, because that is where the spawn reads them back
|
|
251
|
+
// (`modelForEngine`, shared/engines.mjs). Unlike `permissionMode` they are NOT
|
|
252
|
+
// cleared by the run that uses them: she changed the model of the
|
|
253
|
+
// conversation, and the conversation keeps it until she says otherwise.
|
|
254
|
+
//
|
|
255
|
+
// Undefined means she never opened the drawer and the row keeps what it had.
|
|
256
|
+
// Null means she cleared it, and the engine chooses again.
|
|
257
|
+
answerItem(slug, id, { answer, status, priority, permissionMode, model, effort }) {
|
|
258
|
+
const { workItemsDisk } = this.modules;
|
|
259
|
+
const dir = this.productDir(slug);
|
|
260
|
+
let item = null;
|
|
261
|
+
// THE MODEL GOES ON FIRST, BEFORE THE ANSWER. A reply on an idle row is
|
|
262
|
+
// what starts a run, and the run reads the model off the item; written
|
|
263
|
+
// after the answer it is a race the run can win, and she would pick a model
|
|
264
|
+
// and watch the old one take the task.
|
|
265
|
+
//
|
|
266
|
+
// Written as '' rather than null to clear, because that is how "no model"
|
|
267
|
+
// is said everywhere else here: `modelForEngine` (shared/engines.mjs) reads
|
|
268
|
+
// the empty string as nothing chosen, where a JSON null would have to be
|
|
269
|
+
// special-cased in both engines' argument builders.
|
|
270
|
+
if (model !== undefined) item = workItemsDisk.updateWorkItem(dir, id, { model: model ?? '' }, { source: 'founder' });
|
|
271
|
+
if (effort !== undefined) item = workItemsDisk.updateWorkItem(dir, id, { effort: effort ?? '' }, { source: 'founder' });
|
|
272
|
+
if (answer) item = workItemsDisk.updateWorkItem(dir, id, { answer }, { source: 'founder' });
|
|
273
|
+
// The pane may still show a running row after Stop or a provider result.
|
|
274
|
+
// Resolve omitted reply status against the ledger that was just written.
|
|
275
|
+
if (status == null && typeof answer === 'string' && answer.trim() && answer !== '(withdrawn)'
|
|
276
|
+
&& (item?.status === 'blocked' || item?.status === 'done')) status = 'open';
|
|
277
|
+
// WHAT THE ANSWER ITSELF MAY DO does not go on the ledger. It is a grant,
|
|
278
|
+
// the ledger is an append-only file every worker writes, and every line on
|
|
279
|
+
// it names its own author with nothing checking the name. It goes in the
|
|
280
|
+
// app's own storage instead (main/answer-modes.mjs).
|
|
281
|
+
if (permissionMode !== undefined) {
|
|
282
|
+
setAnswerMode(slug, id, permissionMode ?? null);
|
|
283
|
+
item = this.readItem(slug, id) ?? item;
|
|
284
|
+
}
|
|
285
|
+
// The founder's priority tag, set at send time; founder source outranks
|
|
286
|
+
// whatever the filing agent chose.
|
|
287
|
+
if (priority != null) item = workItemsDisk.updateWorkItem(dir, id, { priority }, { source: 'founder' });
|
|
288
|
+
if (status) item = workItemsDisk.updateWorkItem(dir, id, { status }, { source: 'founder' });
|
|
289
|
+
return item;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// A personal session's final message, written by the supervisor because
|
|
293
|
+
// personal sessions never learn the store exists. The result is the agent's
|
|
294
|
+
// words (source 'agent'); the status is machinery (source 'system', the
|
|
295
|
+
// same split answerItem makes). 'blocked' means waiting on the founder,
|
|
296
|
+
// which is exactly what puts the thread back in the inbox.
|
|
297
|
+
//
|
|
298
|
+
// `status: null` writes the words and leaves the status alone. That is a
|
|
299
|
+
// worker holding the store tools, which set its own status before it ended
|
|
300
|
+
// (`closingMessageIsTheAnswer` in the supervisor).
|
|
301
|
+
recordSessionResult(slug, id, { result, status = 'blocked' }) {
|
|
302
|
+
const { workItemsDisk } = this.modules;
|
|
303
|
+
const dir = this.productDir(slug);
|
|
304
|
+
if (status === null) {
|
|
305
|
+
return result ? workItemsDisk.updateWorkItem(dir, id, { result }, { source: 'agent' }) : this.readItem(slug, id);
|
|
306
|
+
}
|
|
307
|
+
if (result) workItemsDisk.updateWorkItem(dir, id, { result }, { source: 'agent' });
|
|
308
|
+
return workItemsDisk.updateWorkItem(dir, id, { status }, { source: 'system' });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// THE WRITTEN NAME THE LIST DRAWS. Written by the app rather than by a
|
|
312
|
+
// session, on agent authority, and never onto `title`: the title is what she
|
|
313
|
+
// typed and the fold keeps it hers (main/row-label.mjs says why at length).
|
|
314
|
+
// Empty is a no-op rather than an erase, so a naming call that came back with
|
|
315
|
+
// nothing usable leaves the row exactly as it was.
|
|
316
|
+
nameItem(slug, id, label) {
|
|
317
|
+
const { workItemsDisk } = this.modules;
|
|
318
|
+
const clean = String(label ?? '').trim();
|
|
319
|
+
if (!clean) return null;
|
|
320
|
+
return workItemsDisk.updateWorkItem(this.productDir(slug), id, { label: clean }, { source: 'agent' });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// A one-off permission mode has been spent. Called by the supervisor at the
|
|
324
|
+
// moment it launches the run that mode was set for, so a mode she chose for
|
|
325
|
+
// one message cannot silently govern a respawn three hours later.
|
|
326
|
+
//
|
|
327
|
+
// IT CLEARS THE MODE THAT WAS SPENT, NEVER WHATEVER HAPPENS TO BE THERE NOW.
|
|
328
|
+
// Caught in review, 2026-08-23. The supervisor reads the mode when it builds
|
|
329
|
+
// the launch and clears on the child's own 'spawn' event, so there is a window
|
|
330
|
+
// in between, and a second answer landing inside it must survive. Clearing is
|
|
331
|
+
// the dangerous direction: it does not mean less, it means back to the
|
|
332
|
+
// project's setting, which can be the wider one. So it compares first, in
|
|
333
|
+
// main/answer-modes.mjs, which is where the value lives now.
|
|
334
|
+
clearAnswerMode(slug, id, spent) {
|
|
335
|
+
clearAnswerModeFile(slug, id, spent);
|
|
336
|
+
return this.readItem(slug, id);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// A run just went out under this row's mode. Nothing changes; the stored
|
|
340
|
+
// grant's clock is restamped so the month-long sweep in main/answer-modes.mjs
|
|
341
|
+
// measures from the last run on the thread rather than from the moment she
|
|
342
|
+
// picked the mode. This is what stands where the spawn-time clear used to.
|
|
343
|
+
touchAnswerMode(slug, id, used) {
|
|
344
|
+
touchAnswerModeFile(slug, id, used);
|
|
345
|
+
return this.readItem(slug, id);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// A session finished acting on her answer. Written by the supervisor as the
|
|
349
|
+
// session exits, because the worker cannot be relied on to close a thread
|
|
350
|
+
// that is still alive: the ordinary and correct end of the loop leaves the
|
|
351
|
+
// row open with her answer still on it, and that state used to be
|
|
352
|
+
// indistinguishable from a worker that died (shared/answers.mjs).
|
|
353
|
+
//
|
|
354
|
+
// Source 'system': it is machinery's record of what happened, not a word
|
|
355
|
+
// anyone said, and it must never hold the field against her. She cannot
|
|
356
|
+
// write it at all, and her next answer outdates it by carrying a later ts.
|
|
357
|
+
settleAnswer(slug, id, answerTs) {
|
|
358
|
+
if (!answerTs) return null;
|
|
359
|
+
const { workItemsDisk } = this.modules;
|
|
360
|
+
return workItemsDisk.updateWorkItem(
|
|
361
|
+
this.productDir(slug), id, { answeredThrough: answerTs }, { source: 'system' },
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// "Run this later." The moment goes in the ledger as HERS, so it survives an
|
|
366
|
+
// app restart, a reboot, and a store restored onto another machine, and so no
|
|
367
|
+
// agent line can quietly move a time she set. 0 clears it.
|
|
368
|
+
//
|
|
369
|
+
// This is the one write behind both gestures: deferring an agent's proposal
|
|
370
|
+
// out of the inbox and scheduling her own work to start at six. They were
|
|
371
|
+
// separate mechanisms before, and only the first existed: the old snooze was
|
|
372
|
+
// a renderer localStorage map that hid a row and deferred no work at all.
|
|
373
|
+
scheduleItem(slug, id, runAt) {
|
|
374
|
+
const { workItemsDisk } = this.modules;
|
|
375
|
+
return workItemsDisk.updateWorkItem(
|
|
376
|
+
this.productDir(slug), id, { runAt: Math.max(0, Math.trunc(runAt) || 0) }, { source: 'founder' },
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
composeItem(slug, { title, body, kind = 'directive', priority = 0, labels, runAt, engine, model, effort }) {
|
|
381
|
+
const { workItemsDisk } = this.modules;
|
|
382
|
+
const dir = this.productDir(slug);
|
|
383
|
+
// The 'founder' label is the human-in-the-loop marker: only items the
|
|
384
|
+
// founder composed (or explicitly approved) execute; agent-filed items
|
|
385
|
+
// are proposals until then.
|
|
386
|
+
const allLabels = [...new Set(['founder', ...(labels ?? [])])];
|
|
387
|
+
const created = workItemsDisk.createWorkItem(
|
|
388
|
+
dir,
|
|
389
|
+
{ title, kind, priority, labels: allLabels },
|
|
390
|
+
{ source: 'system' }
|
|
391
|
+
);
|
|
392
|
+
// The founder's words carry founder authority; the lifecycle does not.
|
|
393
|
+
// A runAt sent with the compose is "send later": the item exists now and
|
|
394
|
+
// starts then, which is why it rides in with her content rather than after.
|
|
395
|
+
const contentPatch = {
|
|
396
|
+
title,
|
|
397
|
+
...(body ? { body } : {}),
|
|
398
|
+
...(runAt ? { runAt: Math.max(0, Math.trunc(runAt)) } : {}),
|
|
399
|
+
// WHICH MODEL SHE CHOSE FOR THIS ONE, on founder authority like the rest
|
|
400
|
+
// of her content (2026-08-26).It is a Claude Code alias, and the spawn
|
|
401
|
+
// hands it to `--model`.
|
|
402
|
+
...(model ? { model: String(model) } : {}),
|
|
403
|
+
// AND HOW HARD IT THINKS, the same way. Only a word shaped like a level
|
|
404
|
+
// is written; which words are right depends on the engine and, on Codex,
|
|
405
|
+
// on the model, so that judgement is the spawn's (`Supervisor#spawnPlan`,
|
|
406
|
+
// `codexEffortRefusal`) and not this line's.
|
|
407
|
+
...(isEffortWord(effort) ? { effort } : {}),
|
|
408
|
+
// AND WHICH CODING AGENT, on the same authority and for the same reason.
|
|
409
|
+
// This was dropped here too, for as long as the field has existed:
|
|
410
|
+
// `zero:compose` took an `engine` argument and this signature never named
|
|
411
|
+
// it, so the 08-25 picker's choice never reached the ledger at all.
|
|
412
|
+
//
|
|
413
|
+
// ON FOUNDER AUTHORITY, WRITTEN AS ITS OWN LINE, which is what makes
|
|
414
|
+
// `wrote.engine.ts` the moment she chose. shared/engines.mjs leans on that
|
|
415
|
+
// stamp to tell a choice she is making now from one made in August, so a
|
|
416
|
+
// path that wrote this any other way would quietly re-open the hazard the
|
|
417
|
+
// whole gate exists for. Whether the word may be written at all is decided
|
|
418
|
+
// before it gets here, by `Supervisor#engineOffered`.
|
|
419
|
+
...(engine ? { engine: String(engine) } : {}),
|
|
420
|
+
};
|
|
421
|
+
return workItemsDisk.updateWorkItem(dir, created.id, contentPatch, { source: 'founder' });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* ---------------------------- repeating tasks --------------------------- */
|
|
425
|
+
// Every product's rules, with their product on each, the way listItems does
|
|
426
|
+
// it, so the renderer has one list to draw. Ended rules are gone from her
|
|
427
|
+
// view: ending one is how she stops it.
|
|
428
|
+
listRepeats(now = Date.now()) {
|
|
429
|
+
const all = [];
|
|
430
|
+
for (const product of this.listProducts()) {
|
|
431
|
+
for (const rule of this.repeats.list(product.dir, now)) {
|
|
432
|
+
if (rule.endedAt) continue;
|
|
433
|
+
all.push({ ...rule, product: product.slug, productName: product.name });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return all;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// The rule is written and NO work item is created here. That ordering is the
|
|
440
|
+
// point: composeItem appends an open founder item before it applies her
|
|
441
|
+
// content, and another copy of the app can spawn that intermediate state, so a rule that
|
|
442
|
+
// turned out to be unkeepable would leave a one-shot running behind it. The
|
|
443
|
+
// first run arrives on the next tick instead. `on` travels with `every:
|
|
444
|
+
// 'week'` and is the whole of what makes it a Thursday. The rule is passed
|
|
445
|
+
// WHOLE now; `setRule` is the one place that validates it. `engine` AND
|
|
446
|
+
// `model` TRAVEL WITH THE REST, and they are named here for the same reason
|
|
447
|
+
// `on` had to be: the rule is passed WHOLE to `setRule`, so a field this
|
|
448
|
+
// signature does not name is a field that is dropped in silence. That is what
|
|
449
|
+
// happened to `on` for as long as weekly rules existed, and it is what was
|
|
450
|
+
// happening to both of these: she could compose "every morning", visibly pick
|
|
451
|
+
// Codex and a Codex model, and every occurrence ran on the then-current
|
|
452
|
+
// workspace default.
|
|
453
|
+
composeRepeat(slug, { title, body, priority, every, at, on, engine, model }) {
|
|
454
|
+
return this.repeats.setRule(this.productDir(slug), { title, body, priority, every, at, on, engine, model });
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
setRepeat(slug, id, patch) {
|
|
458
|
+
return this.repeats.patchRule(this.productDir(slug), id, patch);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
endRepeat(slug, id) {
|
|
462
|
+
return this.repeats.endRule(this.productDir(slug), id);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// An ordinary agent-filed item: a PROPOSAL, which is what puts it in her
|
|
466
|
+
// inbox and keeps it there until she answers. Used for the miss alert, which
|
|
467
|
+
// is news about a repeating task rather than work she asked for.
|
|
468
|
+
fileItem(slug, { title, body, kind = 'review', priority = 5, labels = [] }) {
|
|
469
|
+
const { workItemsDisk } = this.modules;
|
|
470
|
+
return workItemsDisk.createWorkItem(
|
|
471
|
+
this.productDir(slug), { title, body, kind, priority, labels }, { source: 'agent' },
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// New product: rare and deliberate. A directory, a project.json, and a first
|
|
476
|
+
// work item that seeds the drive toward launch.
|
|
477
|
+
//
|
|
478
|
+
// Until today this wrote a name and a date and nothing else, so an agent that
|
|
479
|
+
// landed in a new project had nothing to open: `repoPath` is what becomes a
|
|
480
|
+
// worker's cwd (supervisor.mjs), and it was always null here.
|
|
481
|
+
//
|
|
482
|
+
// `repoPath` is optional and stays optional. Cleared on the card, the project
|
|
483
|
+
// is made without a folder, exactly as it was before this change.
|
|
484
|
+
createProduct({ name, repoPath }) {
|
|
485
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
|
486
|
+
if (!slug) throw new Error('a product needs a name');
|
|
487
|
+
const dir = path.join(this.config.accountRoot, slug);
|
|
488
|
+
if (fs.existsSync(path.join(dir, 'project.json'))) throw new Error(`product ${slug} already exists`);
|
|
489
|
+
|
|
490
|
+
let repo = typeof repoPath === 'string' ? repoPath.trim() : '';
|
|
491
|
+
if (repo) {
|
|
492
|
+
repo = path.resolve(repo.startsWith('~') ? path.join(os.homedir(), repo.slice(1)) : repo);
|
|
493
|
+
// AND IT IS NOT THE WHOLE MAC. The picker refuses these already;
|
|
494
|
+
// this is the backstop, because a project is also made from the
|
|
495
|
+
// palette, from a restored draft and from a test, and a repoPath of
|
|
496
|
+
// `~` is a worker whose working directory is somebody's whole home
|
|
497
|
+
// folder. That is where the run of macOS permission panels a tester
|
|
498
|
+
// saw comes from.
|
|
499
|
+
const verdict = checkProjectFolder(repo, { home: os.homedir() });
|
|
500
|
+
if (!verdict.ok) throw new Error(verdict.say);
|
|
501
|
+
fs.mkdirSync(repo, { recursive: true });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
505
|
+
fs.writeFileSync(path.join(dir, 'project.json'), JSON.stringify({
|
|
506
|
+
schemaVersion: 1,
|
|
507
|
+
id: slug,
|
|
508
|
+
name,
|
|
509
|
+
createdAt: new Date().toISOString(),
|
|
510
|
+
...(repo ? { repoPath: repo } : {}),
|
|
511
|
+
}, null, 2));
|
|
512
|
+
// AND IT FILES NOTHING INTO HER INBOX.
|
|
513
|
+
//
|
|
514
|
+
// It began as a `directive` reading "Take X from idea toward launch", which
|
|
515
|
+
// `isFresh` took straight into a real session in the person's repo, and it
|
|
516
|
+
// was turned into a `question` so nothing would spawn. Making it ask
|
|
517
|
+
// instead of order fixed the runaway session and left the other half
|
|
518
|
+
// standing: a new project still put a row in her inbox that she had not
|
|
519
|
+
// asked anybody for, and the inbox is the one place she cannot afford noise
|
|
520
|
+
// in. Making a project is not itself a question.
|
|
521
|
+
//
|
|
522
|
+
// So the project is made and the inbox is left alone. She starts the work by
|
|
523
|
+
// writing a task in it, the same way she does in every project she already
|
|
524
|
+
// has. `idea` went with the row; it had no other reader, and neither call
|
|
525
|
+
// site in App.tsx ever passed one.
|
|
526
|
+
this.watch(); // pick up the new directory
|
|
527
|
+
return { slug };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* THE PRACTICE PROJECT, MADE FOR REAL.
|
|
532
|
+
*
|
|
533
|
+
* So it is a project like any other: a directory, a project.json, real work
|
|
534
|
+
* items, and the whole app scoped to it while the walk is on. Three things
|
|
535
|
+
* make it different and all three are in the file rather than in a special
|
|
536
|
+
* case somewhere:
|
|
537
|
+
*
|
|
538
|
+
* 1. `practice: true`, which is what stops the supervisor ever starting a
|
|
539
|
+
* session in it and what puts the band on the screen.
|
|
540
|
+
* 2. NO REPO PATH. There is nothing on the disk it points at, so nothing
|
|
541
|
+
* can be edited, run or broken by anything that happens in here.
|
|
542
|
+
* 3. NO OPENING ROW. `createProduct` composes one (it asks what to work on
|
|
543
|
+
* first) and that is a real row waiting on a real answer; the practice
|
|
544
|
+
* inbox is written by `stagePracticeRows` instead.
|
|
545
|
+
*
|
|
546
|
+
* A practice project already there is REMADE rather than refused. Walking the
|
|
547
|
+
* onboarding twice is a thing she does on purpose, and a second walk that
|
|
548
|
+
* died on `already exists` would be a walk with no practice project in it at
|
|
549
|
+
* all.
|
|
550
|
+
*
|
|
551
|
+
* IT USED TO BE REUSED, AND REUSE IS WHAT KEPT SIX WALKS IN ONE LEDGER.
|
|
552
|
+
* MEASURED on the founder's own store: the practice project's ledger held 168
|
|
553
|
+
* lines and 29 session traces, four rows and one task per walk, going back to
|
|
554
|
+
* 08-24. Reuse also un-archived it: the old `endPractice` set `archived:
|
|
555
|
+
* true` in project.json and this call rewrote that same file from scratch, so
|
|
556
|
+
* the walk-end tidy-up of walk one was silently undone by the start of walk
|
|
557
|
+
* two. Her practice project.json at the moment this was written carried no
|
|
558
|
+
* `archived` key at all for exactly that reason, five walks after the first
|
|
559
|
+
* one ended.
|
|
560
|
+
*
|
|
561
|
+
* So the walk starts from nothing every time: the folder goes, its machinery
|
|
562
|
+
* goes, and the four rows below are the whole of what is in it. */
|
|
563
|
+
createPractice() {
|
|
564
|
+
const dir = path.join(this.config.accountRoot, PRACTICE_SLUG);
|
|
565
|
+
this.removePractice(PRACTICE_SLUG);
|
|
566
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
567
|
+
fs.writeFileSync(path.join(dir, 'project.json'), JSON.stringify({
|
|
568
|
+
schemaVersion: 1,
|
|
569
|
+
id: PRACTICE_SLUG,
|
|
570
|
+
name: PRACTICE_NAME,
|
|
571
|
+
createdAt: new Date().toISOString(),
|
|
572
|
+
[PRACTICE_FLAG]: true,
|
|
573
|
+
}, null, 2));
|
|
574
|
+
// AND THE SIDEBAR NOTE IS WRITTEN WITH IT.
|
|
575
|
+
//
|
|
576
|
+
// It is `pinned.md` in the project's own folder, which is the real file
|
|
577
|
+
// the panel reads and writes (main/rail-note.mjs). Nothing about this is
|
|
578
|
+
// special-cased in the renderer: the practice project simply has a note
|
|
579
|
+
// already in it, the way a project somebody has been working in for a week
|
|
580
|
+
// would. The third introduction slab draws the same text, so what the
|
|
581
|
+
// introduction promises is what the practice project actually has.
|
|
582
|
+
fs.writeFileSync(path.join(dir, NOTE_NAME), PRACTICE_NOTE, 'utf8');
|
|
583
|
+
this.watch(); // pick up the new directory
|
|
584
|
+
return { slug: PRACTICE_SLUG };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* AND THE PRACTICE PROJECT IS REMOVED AT THE END — the folder, not a flag.
|
|
589
|
+
*
|
|
590
|
+
* THIS USED TO ARCHIVE, AND SHE HAS OVERRULED THAT. The reasoning it replaces
|
|
591
|
+
* is kept here because it was not stupid: `archived` is one key in one file,
|
|
592
|
+
* `listProducts` already skips it, and "rm -rf on a directory in somebody's
|
|
593
|
+
* store is not a thing an onboarding gets to do" is a rule worth having. What
|
|
594
|
+
* it missed is that the folder is in HER store, next to her real projects,
|
|
595
|
+
* and she opens that store.
|
|
596
|
+
*
|
|
597
|
+
* SO THE DELETE IS NARROW ENOUGH THAT IT CANNOT BE POINTED AT REAL WORK, and
|
|
598
|
+
* every one of these is a refusal rather than a best effort:
|
|
599
|
+
*
|
|
600
|
+
* - It is the ONE path `<accountRoot>/practice` and no other. A practice
|
|
601
|
+
* project living under any other slug is archived the old way instead:
|
|
602
|
+
* the flag is the app's, the path is not, and a directory somebody named
|
|
603
|
+
* themselves is not ours to delete.
|
|
604
|
+
* - It is a real directory by `lstat`, so a SYMLINK at that name is refused
|
|
605
|
+
* outright. A symlinked project is a shape this store really has
|
|
606
|
+
* (`listProducts` stats through them on purpose for the combined-workspace
|
|
607
|
+
* pattern), and following one out of the account root is how a delete
|
|
608
|
+
* scoped to a tutorial reaches somebody's actual repository.
|
|
609
|
+
* - Its `project.json` must parse AND carry `practice: true`. The same fact
|
|
610
|
+
* `listProducts` reads, asked again at the moment of the delete rather
|
|
611
|
+
* than trusted from a listing taken earlier.
|
|
612
|
+
*
|
|
613
|
+
* Never throws: the walk does not stop because a practice project could not
|
|
614
|
+
* be tidied away, and a practice project that is not there is the ordinary
|
|
615
|
+
* case for anybody who walked it before this existed. */
|
|
616
|
+
endPractice(slug = PRACTICE_SLUG) {
|
|
617
|
+
const file = path.join(this.config.accountRoot, slug, 'project.json');
|
|
618
|
+
let project = null;
|
|
619
|
+
try { project = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { removed: false, why: 'no practice project' }; }
|
|
620
|
+
if (project[PRACTICE_FLAG] !== true) return { removed: false, why: 'not a practice project' };
|
|
621
|
+
if (slug !== PRACTICE_SLUG) {
|
|
622
|
+
// A practice-flagged project under a name we did not choose. It leaves
|
|
623
|
+
// the app the old way and its folder stays: the delete below is allowed
|
|
624
|
+
// to be this blunt only because it knows the exact path it is for.
|
|
625
|
+
if (project.archived) return { removed: false, why: 'archived, not ours to delete' };
|
|
626
|
+
try {
|
|
627
|
+
fs.writeFileSync(file, JSON.stringify({ ...project, archived: true }, null, 2));
|
|
628
|
+
this.watch();
|
|
629
|
+
} catch { /* it is out of the way or it is not; neither fails the walk */ }
|
|
630
|
+
return { removed: false, why: 'archived, not ours to delete' };
|
|
631
|
+
}
|
|
632
|
+
return this.removePractice(slug);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* THE DELETE ITSELF, and the three questions it asks before it does anything.
|
|
637
|
+
*
|
|
638
|
+
* Split out from `endPractice` because `createPractice` needs the same
|
|
639
|
+
* guarded removal (a second walk starts from nothing) and two copies of a
|
|
640
|
+
* delete is one copy too many. It is the only code in the app that removes a
|
|
641
|
+
* directory from the founder's store, and everything about it is written to
|
|
642
|
+
* be readable at a glance rather than clever.
|
|
643
|
+
*
|
|
644
|
+
* WHAT GOES: the folder at `<accountRoot>/practice` (its `project.json` and
|
|
645
|
+
* its `pinned.md`), and the app's own machinery for it under
|
|
646
|
+
* `$ASTRAL_HOME/projects/<escaped path>/` — the work-item ledger, the session
|
|
647
|
+
* traces, any repeat rules. WHAT DOES NOT: anything else in the account root,
|
|
648
|
+
* ever, and any machinery directory whose `.origin` does not name this exact
|
|
649
|
+
* project (main/store/home.mjs).
|
|
650
|
+
*
|
|
651
|
+
* THE MACHINERY GOES FIRST, and on purpose. It is keyed on the project's
|
|
652
|
+
* path, so removing the folder first would leave the ledger addressable only
|
|
653
|
+
* by a path that no longer exists — which is precisely the state the
|
|
654
|
+
* founder's Mac was in when this was written, six walks deep. */
|
|
655
|
+
removePractice(slug = PRACTICE_SLUG) {
|
|
656
|
+
const root = path.resolve(this.config.accountRoot);
|
|
657
|
+
const dir = path.join(root, PRACTICE_SLUG);
|
|
658
|
+
if (slug !== PRACTICE_SLUG) return { removed: false, why: 'not the practice path' };
|
|
659
|
+
if (path.resolve(dir) !== dir || path.dirname(dir) !== root) return { removed: false, why: 'not the practice path' };
|
|
660
|
+
|
|
661
|
+
// lstat, never stat: a symlink at this name must be refused rather than
|
|
662
|
+
// followed. There is nothing to remove if it is not there at all, which is
|
|
663
|
+
// the ordinary case on every launch after the first.
|
|
664
|
+
let st = null;
|
|
665
|
+
try { st = fs.lstatSync(dir); } catch { return { removed: false, why: 'no practice project' }; }
|
|
666
|
+
if (st.isSymbolicLink()) return { removed: false, why: 'a symlink, not a practice project' };
|
|
667
|
+
if (!st.isDirectory()) return { removed: false, why: 'not a directory' };
|
|
668
|
+
|
|
669
|
+
// AND IT SAYS SO ABOUT ITSELF, asked here rather than taken on trust from a
|
|
670
|
+
// listing. A folder called `practice` that a person made themselves has no
|
|
671
|
+
// flag in it and is not touched.
|
|
672
|
+
let project = null;
|
|
673
|
+
try { project = JSON.parse(fs.readFileSync(path.join(dir, 'project.json'), 'utf8')); } catch {
|
|
674
|
+
return { removed: false, why: 'no readable project.json' };
|
|
675
|
+
}
|
|
676
|
+
if (project[PRACTICE_FLAG] !== true) return { removed: false, why: 'not a practice project' };
|
|
677
|
+
|
|
678
|
+
const machinery = removeMachinery(dir);
|
|
679
|
+
try {
|
|
680
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
681
|
+
} catch (err) {
|
|
682
|
+
// A folder that will not delete must not fail the walk. It is off the
|
|
683
|
+
// screen either way at the end of this, because the next launch sweeps
|
|
684
|
+
// again and because nothing draws a project it cannot read.
|
|
685
|
+
console.warn(`zero: could not remove the practice project: ${err.message}`);
|
|
686
|
+
return { removed: false, why: err.message, machinery: machinery.removed };
|
|
687
|
+
}
|
|
688
|
+
this.watch();
|
|
689
|
+
return { removed: true, machinery: machinery.removed };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* EVERY PRACTICE PROJECT LYING AROUND, removed. A walk that was quit halfway
|
|
694
|
+
* leaves one behind, and the next launch is where that is noticed: `init`
|
|
695
|
+
* calls this, and `firstRunNeeded` counts projects, so a practice project
|
|
696
|
+
* left in the store is also a project that stops a first run ever offering
|
|
697
|
+
* itself again.
|
|
698
|
+
*
|
|
699
|
+
* It answers `{ removed }` where it used to answer `{ archived }`, because
|
|
700
|
+
* the two words mean different things to whoever reads the number and the
|
|
701
|
+
* old one is no longer true of what happened. */
|
|
702
|
+
sweepPractice() {
|
|
703
|
+
let n = 0;
|
|
704
|
+
for (const p of this.listProducts()) {
|
|
705
|
+
if (!p.practice) continue;
|
|
706
|
+
if (this.endPractice(p.slug).removed) n += 1;
|
|
707
|
+
}
|
|
708
|
+
return { removed: n };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* THE THREE ROWS ALREADY WAITING WHEN THE PRACTICE PROJECT OPENS.
|
|
713
|
+
*
|
|
714
|
+
* The copy is in shared/first-run-practice.mjs and the two-writes-per-row
|
|
715
|
+
* trick is the same one `stageFirstRunExamples` uses; see
|
|
716
|
+
* shared/first-run-examples.mjs for why a row needs its agent half stamped
|
|
717
|
+
* later than its founder half before an inbox will draw it. */
|
|
718
|
+
stagePracticeRows(slug, { now = Date.now(), label = 'first-run' } = {}) {
|
|
719
|
+
const { workItemsDisk } = this.modules;
|
|
720
|
+
const dir = this.productDir(slug);
|
|
721
|
+
const ids = [];
|
|
722
|
+
for (const row of PRACTICE_ROWS) {
|
|
723
|
+
const at = stampsFor(row, now);
|
|
724
|
+
const created = workItemsDisk.createWorkItem(
|
|
725
|
+
dir,
|
|
726
|
+
{ title: row.title, kind: row.kind, priority: 5, labels: ['founder', label] },
|
|
727
|
+
{ source: 'system', now: at.hers },
|
|
728
|
+
);
|
|
729
|
+
// HER HALF CARRIES THE ASK. It used to carry the title a second time and
|
|
730
|
+
// nothing else, so the thread opened on `An agent opened this` with no
|
|
731
|
+
// words under it and the row read as having been born out of nothing.
|
|
732
|
+
// `threadEvents` in renderer/src/thread-history.ts takes the first body to
|
|
733
|
+
// arrive as the ask whichever line it lands on, which is this one.
|
|
734
|
+
workItemsDisk.updateWorkItem(
|
|
735
|
+
dir, created.id, { title: row.title, body: row.body }, { source: 'founder', now: at.hers },
|
|
736
|
+
);
|
|
737
|
+
// AN AGENT PICKED IT UP AND HANDED IT BACK, which is the shape a row that
|
|
738
|
+
// has been worked on really has, and it is what makes the run visible.
|
|
739
|
+
// `withRuns` (renderer/src/thread-history.ts) hangs a session's lines off
|
|
740
|
+
// the CLAIM event in the ledger and drops them on the floor if there
|
|
741
|
+
// isn't one, so without these two lines the trace below is written, read
|
|
742
|
+
// and then silently thrown away. Photographed once without them:
|
|
743
|
+
// "2 messages on this task" over an ask and a result, and not one of the
|
|
744
|
+
// eight things the agent did.
|
|
745
|
+
this.appendPracticeRun(slug, created.id, at.hers);
|
|
746
|
+
// THE RUN HAPPENS BETWEEN THE CLAIM AND THE ANSWER, AND THE STAMPS HAVE
|
|
747
|
+
// TO SAY SO. The thread is drawn in stamp order, so a run written from
|
|
748
|
+
// `at.hers` at nine seconds a line ran straight past a result stamped
|
|
749
|
+
// one second after it, and the pane printed the agent's conclusion in
|
|
750
|
+
// the middle of the work that produced it. Its first line also tied with
|
|
751
|
+
// the claim and sorted above "An agent opened this". Photographed both
|
|
752
|
+
// ways on 2026-08-24.
|
|
753
|
+
const ran = at.hers + 1_000;
|
|
754
|
+
const finished = ran + row.trace.length * 9_000 + 1_000;
|
|
755
|
+
// AND THE RUN ITSELF, so opening one of these shows what the agent did
|
|
756
|
+
// rather than only what it concluded.
|
|
757
|
+
this.writePracticeTrace(slug, created.id, row.trace, ran);
|
|
758
|
+
workItemsDisk.updateWorkItem(dir, created.id, { result: row.result }, { source: 'agent', now: finished });
|
|
759
|
+
ids.push(created.id);
|
|
760
|
+
}
|
|
761
|
+
return { ids };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* THE CLAIM AND THE RELEASE, so the row reads as one an agent worked on.
|
|
766
|
+
*
|
|
767
|
+
* Written straight rather than through `claimWorkItem`, which takes the
|
|
768
|
+
* project lock and is async: these rows were created by this same call a
|
|
769
|
+
* microsecond ago, nothing else can be holding them, and `stagePracticeRows`
|
|
770
|
+
* is called from a synchronous ipc handler. The two lines are built by the
|
|
771
|
+
* same builders the real path uses, so the ledger is identical.
|
|
772
|
+
*
|
|
773
|
+
* THE RELEASE IS NOT OPTIONAL. A claim on its own leaves the row `claimed`,
|
|
774
|
+
* which puts it in In progress instead of the inbox, and beat thirteen is
|
|
775
|
+
* about three rows in an inbox. The release hands it back open. */
|
|
776
|
+
appendPracticeRun(slug, id, at) {
|
|
777
|
+
try {
|
|
778
|
+
const file = this.machineryFile(slug, 'work-items.jsonl');
|
|
779
|
+
const epoch = 1;
|
|
780
|
+
fs.appendFileSync(file, `${JSON.stringify(buildClaimLine({
|
|
781
|
+
id, epoch, holder: 'first-run', now: at, leaseMs: 60_000,
|
|
782
|
+
}))}\n${JSON.stringify(buildReleaseLine({ id, epoch, now: at + 500 }))}\n`);
|
|
783
|
+
} catch { /* the run simply does not draw; the ask and the result still do */ }
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* ONE PRACTICE ROW'S RUN, WRITTEN WHERE A REAL RUN'S IS.
|
|
788
|
+
*
|
|
789
|
+
* `zero:session-trace` reads `<product>/sessions/<item-id>/<startedAt>.log`
|
|
790
|
+
* and nothing else, so this writes exactly that file in exactly the shape
|
|
791
|
+
* `traceStreamLine` (main/supervisor.mjs) writes a live one: `HH:MM:SS` then
|
|
792
|
+
* TWO spaces then either `[Tool] argument` or the agent talking.
|
|
793
|
+
* renderer/src/item-thread.ts parses the two apart on that shape, so a
|
|
794
|
+
* single space here turns every tool call into a sentence with a clock
|
|
795
|
+
* printed inside it.
|
|
796
|
+
*
|
|
797
|
+
* THE TIMES ARE THE ROW'S OWN. A trace stamped `now` under a row that
|
|
798
|
+
* landed twenty-two minutes ago is a conversation that happened after it
|
|
799
|
+
* finished, and the thread sorts by these stamps.
|
|
800
|
+
*
|
|
801
|
+
* It is best effort: a practice project whose sessions folder will not take a
|
|
802
|
+
* file is not a reason to fail the beat that stages the rows. The thread
|
|
803
|
+
* simply draws the ledger, which is what it did before this existed. */
|
|
804
|
+
writePracticeTrace(slug, id, lines, startedAt) {
|
|
805
|
+
if (!Array.isArray(lines) || !lines.length) return null;
|
|
806
|
+
try {
|
|
807
|
+
const dir = this.machineryFile(slug, path.join('sessions', id));
|
|
808
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
809
|
+
// ON A WHOLE SECOND, BECAUSE THE STAMP HAS NO MILLISECONDS AND THE
|
|
810
|
+
// READER'S ROLLOVER IS UNFORGIVING. `clockAt` writes HH:MM:SS, so a
|
|
811
|
+
// session that began at …:07.199 has its first line stamped …:07.000,
|
|
812
|
+
// which `momentOf` sees as 199ms BEFORE the session started and pushes a
|
|
813
|
+
// whole day forward. Measured: 86,399,199ms out, on the very first line.
|
|
814
|
+
// Flooring the base is the whole fix, and it has to be the same number in
|
|
815
|
+
// the file name as in the stamps or they disagree by that same fraction.
|
|
816
|
+
const began = Math.floor(startedAt / 1000) * 1000;
|
|
817
|
+
// A few seconds apart, in order, so the run reads as a run. Nine seconds
|
|
818
|
+
// is the measured pace of a real one (renderer/src/components/ItemThread.tsx).
|
|
819
|
+
const text = lines
|
|
820
|
+
.map((line, i) => `${clockAt(began + i * 9_000)} ${line}`)
|
|
821
|
+
.join('\n');
|
|
822
|
+
const file = path.join(dir, `${began}.log`);
|
|
823
|
+
fs.writeFileSync(file, `${text}\n`);
|
|
824
|
+
return file;
|
|
825
|
+
} catch { return null; }
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/* ----------------------------- the first run ---------------------------- */
|
|
829
|
+
// THE WALK'S EXAMPLE TASK IS ANSWERED HERE, WITHOUT A SESSION.
|
|
830
|
+
//
|
|
831
|
+
// The row is real, the reading pane is real and the result is really written
|
|
832
|
+
// to the ledger, so nothing downstream has to know this one was different.
|
|
833
|
+
// What is missing is the worker: the answer is read straight out of the
|
|
834
|
+
// folder she chose one screen earlier, which takes milliseconds and cannot
|
|
835
|
+
// queue, stall or fail. Her own first task took 59.6 seconds under a sentence
|
|
836
|
+
// promising a few, because it was behind the directive the walk itself had
|
|
837
|
+
// just created.
|
|
838
|
+
//
|
|
839
|
+
// AND THE LINE GOES IN THE SIDEBAR, which is the one thing the live version
|
|
840
|
+
// was for. Only when a line was really found: the sentence that says a folder
|
|
841
|
+
// has no readme in it is a true answer and it is not a description, so it is
|
|
842
|
+
// never written under the project's name.
|
|
843
|
+
finishFirstRunTask(slug, id) {
|
|
844
|
+
const { workItemsDisk } = this.modules;
|
|
845
|
+
const product = this.listProducts().find((p) => p.slug === slug) ?? null;
|
|
846
|
+
// AND SINCE 2026-08-23 THE TASK IS SENT IN THE PRACTICE PROJECT, which
|
|
847
|
+
// points at no folder at all. Everything below reads one, so it cannot run
|
|
848
|
+
// here: a project that is not theirs has nothing honest to say about their
|
|
849
|
+
// code. The answer is written down instead, and it is a decision an agent
|
|
850
|
+
// made on its own, which is the shape the next beat teaches them to close.
|
|
851
|
+
// No one-liner is written either; a practice project has no sidebar line to
|
|
852
|
+
// earn.
|
|
853
|
+
if (product?.practice) {
|
|
854
|
+
const item = workItemsDisk.updateWorkItem(
|
|
855
|
+
this.productDir(slug), id, { result: PRACTICE_ANSWER }, { source: 'agent' },
|
|
856
|
+
);
|
|
857
|
+
// AND THE RUN IT DID, so beat eleven opens onto a conversation. What she
|
|
858
|
+
// opened on 2026-08-24 was the words "Nothing has been said here yet."
|
|
859
|
+
// under the title of the first task anybody sends in Agentbox: the row had
|
|
860
|
+
// a result and no thread at all. Backdated a few seconds so the run reads
|
|
861
|
+
// as having happened before the answer rather than after it.
|
|
862
|
+
this.writePracticeTrace(
|
|
863
|
+
slug, id, PRACTICE_TASK_TRACE, Date.now() - PRACTICE_TASK_TRACE.length * 9_000,
|
|
864
|
+
);
|
|
865
|
+
return { item, line: PRACTICE_ANSWER, oneLiner: null };
|
|
866
|
+
}
|
|
867
|
+
const folder = product?.repoPath ?? null;
|
|
868
|
+
const home = os.homedir().replace(/\/+$/, '');
|
|
869
|
+
const shortFolder = folder && folder.startsWith(home + '/') ? '~' + folder.slice(home.length) : folder;
|
|
870
|
+
const name = product?.name ?? slug;
|
|
871
|
+
// THE ANSWER IS THE PRE-WRITTEN SENTENCE, ours, with her folder's numbers in
|
|
872
|
+
// it. The description is a different fact and it is only ever a sidebar
|
|
873
|
+
// line; see main/first-run.mjs and shared/first-run-shape.mjs.
|
|
874
|
+
const line = answerFor({ folder, name });
|
|
875
|
+
const item = workItemsDisk.updateWorkItem(
|
|
876
|
+
this.productDir(slug), id, { result: line }, { source: 'agent' },
|
|
877
|
+
);
|
|
878
|
+
const described = describeFor({ folder, name, shortFolder });
|
|
879
|
+
if (described.found) this.setOneLiner(slug, described.line);
|
|
880
|
+
return { item, line, oneLiner: described.found ? described.line : null };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* BEAT EIGHT'S THREE ROWS.
|
|
885
|
+
*
|
|
886
|
+
* They are real work items in her real store, because the beat teaches
|
|
887
|
+
* clearing an inbox and a picture of an inbox teaches nothing about the keys
|
|
888
|
+
* that clear one. They carry the walk's own label, so the supervisor never
|
|
889
|
+
* spawns anything on them, and they say "Example." in their first word.
|
|
890
|
+
*
|
|
891
|
+
* THE TWO WRITES PER ROW ARE THE WHOLE TRICK, and shared/first-run-examples
|
|
892
|
+
* says why: a row is in her inbox when an agent answered her after her last
|
|
893
|
+
* word on it, so her half is written at the moment the row claims to have
|
|
894
|
+
* landed and the agent's half a second later. Written by hand here rather
|
|
895
|
+
* than through composeItem because that one stamps everything `now`, and
|
|
896
|
+
* three rows all stamped now read as three rows that arrived in the same
|
|
897
|
+
* millisecond. */
|
|
898
|
+
stageFirstRunExamples(slug, { now = Date.now(), label = 'first-run' } = {}) {
|
|
899
|
+
const { workItemsDisk } = this.modules;
|
|
900
|
+
const dir = this.productDir(slug);
|
|
901
|
+
const ids = [];
|
|
902
|
+
for (const example of EXAMPLES) {
|
|
903
|
+
const at = stampsFor(example, now);
|
|
904
|
+
const created = workItemsDisk.createWorkItem(
|
|
905
|
+
dir,
|
|
906
|
+
{ title: example.title, kind: example.kind, priority: 5, labels: ['founder', label] },
|
|
907
|
+
{ source: 'system', now: at.hers },
|
|
908
|
+
);
|
|
909
|
+
workItemsDisk.updateWorkItem(dir, created.id, { title: example.title }, { source: 'founder', now: at.hers });
|
|
910
|
+
workItemsDisk.updateWorkItem(dir, created.id, { result: example.result }, { source: 'agent', now: at.agent });
|
|
911
|
+
ids.push(created.id);
|
|
912
|
+
}
|
|
913
|
+
return { ids };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* THE AGENT IMPORT, ARRIVING.
|
|
918
|
+
*
|
|
919
|
+
* One row per agent ticked on the finish card, in the project the walk just
|
|
920
|
+
* made. They are ordinary work items and they are questions, which is what
|
|
921
|
+
* makes them wait: the supervisor never spawns fresh work on a question
|
|
922
|
+
* (`isFresh`, main/supervisor.mjs), so nothing starts until the person says
|
|
923
|
+
* what the agent should do, and their reply spawns a worker the ordinary way
|
|
924
|
+
* (`isContinuation`). No new mechanism anywhere.
|
|
925
|
+
*
|
|
926
|
+
* THE STAGGER IS THE SAME TRICK AS THE WALK'S EXAMPLES. Rows all stamped
|
|
927
|
+
* the same millisecond read as rows with no order, and these have one: the
|
|
928
|
+
* list the finish card showed. A second apart, oldest first, so the top of
|
|
929
|
+
* the inbox is the first agent on the card.
|
|
930
|
+
*
|
|
931
|
+
* Written by hand rather than through composeItem because that one stamps
|
|
932
|
+
* everything now and marks it the founder's own. These are Agentbox asking,
|
|
933
|
+
* which is what lets a later session reword one.
|
|
934
|
+
*/
|
|
935
|
+
importAgentRows(slug, chosen, { now = Date.now(), home = os.homedir() } = {}) {
|
|
936
|
+
const { workItemsDisk } = this.modules;
|
|
937
|
+
const dir = this.productDir(slug);
|
|
938
|
+
const product = this.listProducts().find((p) => p.slug === slug) ?? null;
|
|
939
|
+
let existing = [];
|
|
940
|
+
try { existing = workItemsDisk.readWorkItems(dir, now); } catch { /* a fresh project has none */ }
|
|
941
|
+
const wanted = agentsNeedingRows(chosen, existing);
|
|
942
|
+
const ids = [];
|
|
943
|
+
wanted.forEach((agent, i) => {
|
|
944
|
+
const at = now - (wanted.length - 1 - i) * 1000;
|
|
945
|
+
const row = agentImportRow(agent, { projectName: product?.name ?? null, home });
|
|
946
|
+
const created = workItemsDisk.createWorkItem(
|
|
947
|
+
dir,
|
|
948
|
+
{ title: row.title, kind: row.kind, priority: row.priority, labels: row.labels },
|
|
949
|
+
{ source: 'agent', now: at },
|
|
950
|
+
);
|
|
951
|
+
workItemsDisk.updateWorkItem(dir, created.id, { body: row.body }, { source: 'agent', now: at });
|
|
952
|
+
ids.push(created.id);
|
|
953
|
+
});
|
|
954
|
+
const names = new Set((chosen ?? []).map((a) => String(a?.name ?? '').trim()).filter(Boolean));
|
|
955
|
+
return { ids, added: ids.length, already: names.size - ids.length };
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* HER OWN THREADS, ARRIVING THE SAME WAY.
|
|
960
|
+
*
|
|
961
|
+
* Deliberately the twin of `importAgentRows` above rather than a mechanism of
|
|
962
|
+
* its own: an ordinary work item, a question so the supervisor waits
|
|
963
|
+
* (`isFresh`, main/supervisor.mjs), and her reply spawns a worker the way any
|
|
964
|
+
* reply does. That is the whole specification and there is nothing under it.
|
|
965
|
+
*
|
|
966
|
+
* Stamped a second apart, oldest last, so the newest thread on the card is
|
|
967
|
+
* the newest row in the inbox. `chosen` arrives newest first from
|
|
968
|
+
* `readSessionThreads`. */
|
|
969
|
+
importThreadRows(slug, chosen, { now = Date.now() } = {}) {
|
|
970
|
+
const { workItemsDisk } = this.modules;
|
|
971
|
+
const dir = this.productDir(slug);
|
|
972
|
+
const product = this.listProducts().find((p) => p.slug === slug) ?? null;
|
|
973
|
+
let existing = [];
|
|
974
|
+
try { existing = workItemsDisk.readWorkItems(dir, now); } catch { /* a fresh project has none */ }
|
|
975
|
+
// Codex conversations on the same card are importCodexThreads's, below.
|
|
976
|
+
const claude = (chosen ?? []).filter((t) => t?.source !== 'codex');
|
|
977
|
+
const wanted = threadsNeedingRows(claude, existing);
|
|
978
|
+
const ids = [];
|
|
979
|
+
wanted.forEach((thread, i) => {
|
|
980
|
+
const at = now - i * 1000;
|
|
981
|
+
const row = threadImportRow(thread, { projectName: product?.name ?? null, now });
|
|
982
|
+
const created = workItemsDisk.createWorkItem(
|
|
983
|
+
dir,
|
|
984
|
+
{ title: row.title, kind: row.kind, priority: row.priority, labels: row.labels },
|
|
985
|
+
{ source: 'agent', now: at },
|
|
986
|
+
);
|
|
987
|
+
workItemsDisk.updateWorkItem(dir, created.id, { body: row.body }, { source: 'agent', now: at });
|
|
988
|
+
ids.push(created.id);
|
|
989
|
+
});
|
|
990
|
+
const seen = new Set(claude.map((t) => String(t?.id ?? '').trim()).filter(Boolean));
|
|
991
|
+
return { ids, added: ids.length, already: seen.size - ids.length };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/* ---------------------------- codex conversations ----------------------- */
|
|
995
|
+
// HER CODEX CONVERSATIONS ASK TO COME IN, AND HER YES OR NO DECIDES. The
|
|
996
|
+
// words and the rules are in shared/codex-import.mjs;
|
|
997
|
+
// main/codex-watch.mjs calls the first two every minute; the bridge calls
|
|
998
|
+
// answerCodexImport when she answers.
|
|
999
|
+
|
|
1000
|
+
/** Every row in every product, each stamped with its product slug. */
|
|
1001
|
+
listAllWorkItems(now = Date.now()) {
|
|
1002
|
+
const { workItemsDisk } = this.modules;
|
|
1003
|
+
const out = [];
|
|
1004
|
+
for (const p of this.listProducts()) {
|
|
1005
|
+
let items = [];
|
|
1006
|
+
try { items = workItemsDisk.readWorkItems(this.productDir(p.slug), now); } catch { items = []; }
|
|
1007
|
+
for (const item of items) out.push({ ...item, product: p.slug });
|
|
1008
|
+
}
|
|
1009
|
+
return out;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* WHICH PROJECT, WHEN SEVERAL POINT AT THE SAME FOLDER. Her store on
|
|
1014
|
+
* 2026-09-15 had seven projects on ~/Desktop/dev/zero, six of them empty test
|
|
1015
|
+
* projects, and the first build filed into whichever the directory listing
|
|
1016
|
+
* returned first ("Test Proj"). So: the one with the most work of its own,
|
|
1017
|
+
* then the one she made first (project.json's createdAt), then the name. */
|
|
1018
|
+
homeForCodexThread(thread, products, existing = []) {
|
|
1019
|
+
const matches = productsForThread(thread, products);
|
|
1020
|
+
if (matches.length <= 1) return matches[0] ?? null;
|
|
1021
|
+
const own = new Map();
|
|
1022
|
+
for (const item of existing) if (!codexIdOf(item)) own.set(item.product, (own.get(item.product) ?? 0) + 1);
|
|
1023
|
+
const made = (p) => {
|
|
1024
|
+
try { return Date.parse(JSON.parse(fs.readFileSync(path.join(p.dir ?? this.productDir(p.slug), 'project.json'), 'utf8')).createdAt ?? '') || Infinity; } catch { return Infinity; }
|
|
1025
|
+
};
|
|
1026
|
+
return [...matches].sort((a, b) => (own.get(b.slug) ?? 0) - (own.get(a.slug) ?? 0) || made(a) - made(b) || String(a.name).localeCompare(String(b.name)))[0];
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* FILE THE ASKING ROW for each conversation that has none yet, in the
|
|
1031
|
+
* project whose folder it ran in. A conversation nobody's project points at
|
|
1032
|
+
* gets nothing here; the ⌘K card offers it under a new project instead. */
|
|
1033
|
+
askAboutCodexThreads(threads, { now = Date.now() } = {}) {
|
|
1034
|
+
const { workItemsDisk } = this.modules;
|
|
1035
|
+
const products = this.listProducts();
|
|
1036
|
+
const existing = this.listAllWorkItems(now);
|
|
1037
|
+
let asked = 0;
|
|
1038
|
+
for (const thread of codexThreadsNeedingRows(threads, existing)) {
|
|
1039
|
+
const product = this.homeForCodexThread(thread, products, existing);
|
|
1040
|
+
if (!product) continue;
|
|
1041
|
+
const row = codexImportRow(thread, { projectName: product.name, now });
|
|
1042
|
+
const dir = this.productDir(product.slug);
|
|
1043
|
+
const created = workItemsDisk.createWorkItem(
|
|
1044
|
+
dir,
|
|
1045
|
+
{ title: row.title, kind: row.kind, priority: row.priority, labels: row.labels },
|
|
1046
|
+
{ source: 'agent', now },
|
|
1047
|
+
);
|
|
1048
|
+
workItemsDisk.updateWorkItem(dir, created.id, { body: row.body }, { source: 'agent', now });
|
|
1049
|
+
asked += 1;
|
|
1050
|
+
}
|
|
1051
|
+
return asked;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
/**
|
|
1055
|
+
* What the asking row already knows about its conversation, for a yes that
|
|
1056
|
+
* arrives when the transcript cannot be read again. */
|
|
1057
|
+
codexThreadFromRow(item) {
|
|
1058
|
+
const body = String(item?.body ?? '');
|
|
1059
|
+
const grab = (lead) => {
|
|
1060
|
+
const at = body.indexOf(lead);
|
|
1061
|
+
if (at < 0) return '';
|
|
1062
|
+
const rest = body.slice(at + lead.length);
|
|
1063
|
+
const end = rest.indexOf('\n\n');
|
|
1064
|
+
return (end < 0 ? rest : rest.slice(0, end)).trim();
|
|
1065
|
+
};
|
|
1066
|
+
return { id: codexIdOf(item), title: item?.title ?? '', prompt: grab('You asked: '), last: grab('Codex last said: ') };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* HER ANSWER. Yes turns the asking row into one she can read: her prompt as
|
|
1071
|
+
* the body, Codex's last message as the result, open, in her inbox. No closes
|
|
1072
|
+
* it under `not-imported`, which is where Closed finds it to offer it back.
|
|
1073
|
+
* Yes on a row she once said no to is the same yes. */
|
|
1074
|
+
answerCodexImport(slug, id, choice, { thread = null, now = Date.now() } = {}) {
|
|
1075
|
+
const { workItemsDisk } = this.modules;
|
|
1076
|
+
const dir = this.productDir(slug);
|
|
1077
|
+
const item = workItemsDisk.readWorkItem(dir, id, now);
|
|
1078
|
+
if (!item) return null;
|
|
1079
|
+
const rest = (item.labels ?? []).filter((l) => l !== CODEX_IMPORT_LABEL && l !== NOT_IMPORTED_LABEL);
|
|
1080
|
+
if (choice === 'no') {
|
|
1081
|
+
workItemsDisk.updateWorkItem(dir, id, { labels: [...rest, NOT_IMPORTED_LABEL] }, { source: 'agent', now });
|
|
1082
|
+
return workItemsDisk.updateWorkItem(dir, id, { status: 'done' }, { source: 'founder', now });
|
|
1083
|
+
}
|
|
1084
|
+
const t = { ...this.codexThreadFromRow(item), ...(thread ?? {}) };
|
|
1085
|
+
const patch = codexMirrorPatch(t);
|
|
1086
|
+
// it draws under You, not under an agent's name.
|
|
1087
|
+
workItemsDisk.updateWorkItem(dir, id, { body: patch.body }, { source: 'founder', now });
|
|
1088
|
+
workItemsDisk.updateWorkItem(dir, id, { kind: patch.kind, labels: patch.labels, result: patch.result }, { source: 'agent', now: now + 1 });
|
|
1089
|
+
return workItemsDisk.updateWorkItem(dir, id, { status: 'open' }, { source: 'founder', now: now + 1 });
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* THE ⌘K CARD'S PRESS on Codex conversations: each becomes a readable row at
|
|
1094
|
+
* once, whether it had an asking row, a declined one, or none. */
|
|
1095
|
+
importCodexThreads(slug, chosen, { now = Date.now() } = {}) {
|
|
1096
|
+
const { workItemsDisk } = this.modules;
|
|
1097
|
+
const dir = this.productDir(slug);
|
|
1098
|
+
const product = this.listProducts().find((p) => p.slug === slug) ?? null;
|
|
1099
|
+
let existing = [];
|
|
1100
|
+
try { existing = workItemsDisk.readWorkItems(dir, now); } catch { existing = []; }
|
|
1101
|
+
const ids = [];
|
|
1102
|
+
let already = 0;
|
|
1103
|
+
for (const thread of chosen ?? []) {
|
|
1104
|
+
if (thread?.source !== 'codex') continue; // a Claude Code thread is importThreadRows's
|
|
1105
|
+
const id = String(thread?.id ?? '').trim();
|
|
1106
|
+
if (!id) continue;
|
|
1107
|
+
const had = existing.find((i) => codexIdOf(i) === id);
|
|
1108
|
+
if (had && isCodexMirrorRow(had) && had.status !== 'done') { already += 1; continue; }
|
|
1109
|
+
let rowId = had?.id ?? null;
|
|
1110
|
+
if (!rowId) {
|
|
1111
|
+
const row = codexImportRow(thread, { projectName: product?.name ?? null, now });
|
|
1112
|
+
const created = workItemsDisk.createWorkItem(
|
|
1113
|
+
dir,
|
|
1114
|
+
{ title: row.title, kind: row.kind, priority: row.priority, labels: row.labels },
|
|
1115
|
+
{ source: 'agent', now },
|
|
1116
|
+
);
|
|
1117
|
+
workItemsDisk.updateWorkItem(dir, created.id, { body: row.body }, { source: 'agent', now });
|
|
1118
|
+
rowId = created.id;
|
|
1119
|
+
}
|
|
1120
|
+
this.answerCodexImport(slug, rowId, 'yes', { thread, now: now + 2 });
|
|
1121
|
+
ids.push(rowId);
|
|
1122
|
+
}
|
|
1123
|
+
return { ids, added: ids.length, already };
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* KEEP THE ROWS SHE SAID YES TO CURRENT: when Codex writes another answer,
|
|
1128
|
+
* the row's result follows it, which is what puts the news in her inbox. */
|
|
1129
|
+
/**
|
|
1130
|
+
* KEEP THE ASKING ROWS TRUE TOO: when the conversation has moved on, or a
|
|
1131
|
+
* better read of the transcript says more than the row does, the body is
|
|
1132
|
+
* rewritten. The body is the app's own words (agent source), so the fold
|
|
1133
|
+
* takes it. This is also what repairs the rows the first build filed with
|
|
1134
|
+
* "Codex has not answered yet." on every one of them (2026-09-15). */
|
|
1135
|
+
refreshCodexAsks(threads, { now = Date.now() } = {}) {
|
|
1136
|
+
const { workItemsDisk } = this.modules;
|
|
1137
|
+
const byId = new Map((threads ?? []).map((t) => [String(t?.id ?? ''), t]));
|
|
1138
|
+
const names = new Map(this.listProducts().map((p) => [p.slug, p.name]));
|
|
1139
|
+
let refreshed = 0;
|
|
1140
|
+
for (const item of this.listAllWorkItems(now)) {
|
|
1141
|
+
if (!isCodexImportRow(item)) continue;
|
|
1142
|
+
const t = byId.get(codexIdOf(item));
|
|
1143
|
+
if (!t) continue;
|
|
1144
|
+
const row = codexImportRow(t, { projectName: names.get(item.product) ?? null, now });
|
|
1145
|
+
if (row.body === item.body) continue;
|
|
1146
|
+
workItemsDisk.updateWorkItem(this.productDir(item.product), item.id, { body: row.body }, { source: 'agent', now });
|
|
1147
|
+
refreshed += 1;
|
|
1148
|
+
}
|
|
1149
|
+
return refreshed;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
refreshCodexMirrors(threads, { now = Date.now() } = {}) {
|
|
1153
|
+
const { workItemsDisk } = this.modules;
|
|
1154
|
+
const byId = new Map((threads ?? []).map((t) => [String(t?.id ?? ''), t]));
|
|
1155
|
+
let refreshed = 0;
|
|
1156
|
+
for (const item of this.listAllWorkItems(now)) {
|
|
1157
|
+
if (!isCodexMirrorRow(item) || item.status === 'done') continue;
|
|
1158
|
+
const t = byId.get(codexIdOf(item));
|
|
1159
|
+
if (!t) continue;
|
|
1160
|
+
const patch = codexMirrorPatch(t);
|
|
1161
|
+
if (patch.result === item.result) continue;
|
|
1162
|
+
workItemsDisk.updateWorkItem(this.productDir(item.product), item.id, { result: patch.result }, { source: 'agent', now });
|
|
1163
|
+
refreshed += 1;
|
|
1164
|
+
}
|
|
1165
|
+
return refreshed;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* The line under the project's name in the rail. Written in place, so a
|
|
1170
|
+
* project.json that has grown other fields keeps them. */
|
|
1171
|
+
setOneLiner(slug, oneLiner) {
|
|
1172
|
+
const file = path.join(this.productDir(slug), 'project.json');
|
|
1173
|
+
try {
|
|
1174
|
+
const project = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1175
|
+
project.oneLiner = oneLiner;
|
|
1176
|
+
fs.writeFileSync(file, JSON.stringify(project, null, 2));
|
|
1177
|
+
return true;
|
|
1178
|
+
} catch {
|
|
1179
|
+
return false; // a sidebar line is not worth failing a first run over
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/* ------------------------------- dashboard ------------------------------ */
|
|
1184
|
+
// The rail's substance: the product's dashboard picture, folded by the store's
|
|
1185
|
+
// own rules so every reader agrees about state.
|
|
1186
|
+
readDashboard(slug, now = Date.now()) {
|
|
1187
|
+
const { dashboard } = this.modules;
|
|
1188
|
+
const file = path.join(this.productDir(slug), 'dashboard.jsonl');
|
|
1189
|
+
let lines = [];
|
|
1190
|
+
try {
|
|
1191
|
+
lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
1192
|
+
} catch {}
|
|
1193
|
+
const picture = lines.length ? dashboard.foldLedger(lines, now) : dashboard.defaultDashboard(now);
|
|
1194
|
+
|
|
1195
|
+
// Flatten that picture into what the rail renders: a headline, a few
|
|
1196
|
+
// rows, and the current intent. Unmeasured stays visibly unmeasured; it
|
|
1197
|
+
// never becomes a plausible figure (dashboard law).
|
|
1198
|
+
const asRow = (m) => ({
|
|
1199
|
+
label: m?.label ?? '',
|
|
1200
|
+
value: m?.value == null ? 'unmeasured' : String(m.value),
|
|
1201
|
+
unit: m?.unit ?? '',
|
|
1202
|
+
unmeasured: m?.value == null,
|
|
1203
|
+
});
|
|
1204
|
+
let headline = null;
|
|
1205
|
+
let rows = [];
|
|
1206
|
+
try {
|
|
1207
|
+
const { key, others } = dashboard.metricRow(picture, { others: 4 });
|
|
1208
|
+
headline = key ? asRow(key) : null;
|
|
1209
|
+
rows = others.map(asRow);
|
|
1210
|
+
} catch {}
|
|
1211
|
+
if (picture.bottleneck) rows.push({ label: 'Bottleneck', value: picture.bottleneck, unit: '', unmeasured: false });
|
|
1212
|
+
return {
|
|
1213
|
+
stage: picture.stage,
|
|
1214
|
+
oneLiner: picture.oneLiner,
|
|
1215
|
+
headline,
|
|
1216
|
+
rows,
|
|
1217
|
+
intent: picture.nextMove ?? null,
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/* ------------------------------- watching ------------------------------ */
|
|
1222
|
+
// Watch every product dir for ledger changes; debounce into one onChange.
|
|
1223
|
+
// fs.watch on dirs is coarse but cheap, and the renderer refetches folds
|
|
1224
|
+
// rather than patching state, so a spurious event costs one re-read.
|
|
1225
|
+
watch() {
|
|
1226
|
+
this.unwatch();
|
|
1227
|
+
const dirs = [this.config.accountRoot, ...this.listProducts().map((p) => p.dir)];
|
|
1228
|
+
for (const dir of dirs) {
|
|
1229
|
+
try {
|
|
1230
|
+
const watcher = fs.watch(dir, (event, file) => {
|
|
1231
|
+
if (file && !/work-items\.jsonl|dashboard\.jsonl|project\.json/.test(file)) return;
|
|
1232
|
+
this._notify();
|
|
1233
|
+
});
|
|
1234
|
+
this.watchers.push(watcher);
|
|
1235
|
+
} catch {}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
_notify() {
|
|
1240
|
+
clearTimeout(this._debounce);
|
|
1241
|
+
this._debounce = setTimeout(() => this.onChange?.(), 150);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
unwatch() {
|
|
1245
|
+
for (const w of this.watchers) try { w.close(); } catch {}
|
|
1246
|
+
this.watchers = [];
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* `HH:MM:SS` in local time, which is the clock a session trace is written in.
|
|
1252
|
+
* IT IS UTC, THE SAME AS `traceStreamLine` IN main/supervisor.mjs, AND THAT
|
|
1253
|
+
* IS NOT A DETAIL. A trace carries a clock and no date, so the READER puts the
|
|
1254
|
+
* date back: `momentOf` (renderer/src/notes.ts) takes the session's own
|
|
1255
|
+
* startedAt, replaces its UTC hours and minutes with the ones on the line, and
|
|
1256
|
+
* adds a whole day if the result lands before the session began.
|
|
1257
|
+
*
|
|
1258
|
+
* So a line stamped in LOCAL time is read as if those digits were UTC, and on
|
|
1259
|
+
* a Mac in California that is wrong twice over. Shot on 2026-08-24 at 22:38
|
|
1260
|
+
* Pacific: the run wrote "22:38:07", the reader read 22:38 UTC, made it 15:38
|
|
1261
|
+
* Pacific, found that was before the session started and added a day, and the
|
|
1262
|
+
* pane printed "Mon, Aug 24 3:38pm" under a result stamped 10:37pm. Seventeen
|
|
1263
|
+
* hours out and the work appeared to happen after the answer.
|
|
1264
|
+
*
|
|
1265
|
+
* Pinned by tests/the-practice-project-is-the-whole-app.test.mjs, which writes
|
|
1266
|
+
* a trace and reads it back through the renderer's own parser. */
|
|
1267
|
+
function clockAt(ms) {
|
|
1268
|
+
return new Date(ms).toISOString().slice(11, 19);
|
|
1269
|
+
}
|