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/ipc.mjs
ADDED
|
@@ -0,0 +1,1558 @@
|
|
|
1
|
+
import { openSourceFile } from './open-source-file.mjs';
|
|
2
|
+
import {submitReply} from './live-replies.mjs';
|
|
3
|
+
// IPC: a thin, typed-by-convention surface. The renderer asks; the main
|
|
4
|
+
// process derives from files and answers. Pushes go one way: "state changed,
|
|
5
|
+
// refetch." No state is cached on either side of the bridge.
|
|
6
|
+
|
|
7
|
+
// NOTHING IN THIS FILE IMPORTS ELECTRON, AND THAT IS WHAT THE `host` ARGUMENT
|
|
8
|
+
// BELOW IS FOR.
|
|
9
|
+
//
|
|
10
|
+
// This is the contract between the screen and the half of the app that does
|
|
11
|
+
// things: 78 channels, nine lines of code each, nearly all of them wrappers
|
|
12
|
+
// over modules that are ordinary Node. Only three Electron things were ever
|
|
13
|
+
// reached for in here, `ipcMain` to register a channel, `dialog` for the
|
|
14
|
+
// folder picker, and `app` for two flags and the quit hook. They arrive as
|
|
15
|
+
// arguments now.
|
|
16
|
+
//
|
|
17
|
+
// What that buys is a second front door. `main/main.mjs` hands over the real
|
|
18
|
+
// Electron three and nothing about the desktop app changes. A plain Node
|
|
19
|
+
// process can hand over its own, and the same 78 channels then answer a
|
|
20
|
+
// browser over http without one line of this file being written twice.
|
|
21
|
+
import {terminalSenderAllowed} from './terminal-access.mjs';
|
|
22
|
+
import {TaskTerminals,terminalPlace} from './task-terminals.mjs';
|
|
23
|
+
import {SETTINGS_TERMINAL} from '../shared/settings-terminal.mjs';
|
|
24
|
+
import {readInstruction,writeInstruction,listVersions,readVersion} from './instruction-settings.mjs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import fsWatch from 'node:fs';
|
|
28
|
+
import { listFolders } from './folders.mjs';
|
|
29
|
+
import * as approvals from './approvals.mjs';
|
|
30
|
+
import { restartNeeded } from './staleness.mjs';
|
|
31
|
+
import { artifactRoots, resolveArtifact } from './artifact-path.mjs';
|
|
32
|
+
import { docUrl } from './doc-scheme.mjs';
|
|
33
|
+
import { readDoc, writeDoc } from './doc-file.mjs';
|
|
34
|
+
import { readChange } from './code-change.mjs';
|
|
35
|
+
import { findFile, readCodeFile, writeCodeFile } from './code-file.mjs';
|
|
36
|
+
import { readNote, writeNote } from './rail-note.mjs';
|
|
37
|
+
import { addClaudeAccount, addCodexAccount, outsideAgentsMode, permissionMode, readSettings, recheckClaude, recheckCodex, setProjectSetting, setWorkspaceSetting } from './settings.mjs';
|
|
38
|
+
import { ICON_KINDS, clearProjectIcon, setProjectIcon, setProjectName } from './project-identity.mjs';
|
|
39
|
+
import { ClaudeUsage } from './claude-usage.mjs';
|
|
40
|
+
import { usageEngine } from '../shared/usage.mjs';
|
|
41
|
+
import { DEFAULT_ENGINE } from '../shared/engines.mjs';
|
|
42
|
+
import * as agents from './agents.mjs';
|
|
43
|
+
import { findAgentFolders, readAgentFiles, readFolderAgents } from './agent-files.mjs';
|
|
44
|
+
import { readSessionThreads } from './agent-sessions.mjs';
|
|
45
|
+
import { readCodexThreads } from './codex-threads.mjs';
|
|
46
|
+
import { codexIdOf, importChoice, isCodexImportRow, isNotImportedRow } from '../shared/codex-import.mjs';
|
|
47
|
+
import { checkProjectFolder } from '../shared/project-folder-check.mjs';
|
|
48
|
+
import { openFreshUser } from './fresh-user.mjs';
|
|
49
|
+
import { openDemo } from './demo.mjs';
|
|
50
|
+
import { AgentSchedule } from './agent-schedule.mjs';
|
|
51
|
+
import { agentKey } from '../shared/agents.mjs';
|
|
52
|
+
import { machineryPath } from './store/home.mjs';
|
|
53
|
+
import { EVENT_NAMES } from '../shared/analytics-events.mjs';
|
|
54
|
+
import { Name } from '../shared/product-name.mjs';
|
|
55
|
+
|
|
56
|
+
// WHAT A WORKER IS TOLD WHEN HER DECISION COULD NOT BE RECORDED.
|
|
57
|
+
const SPOOL_REFUSED = `${Name} could not record the founder's decision, so nothing was allowed. Finish what your grants cover and file the rest as a review.`;
|
|
58
|
+
|
|
59
|
+
// AND WHAT A CODEX WORKER IS TOLD WHEN THE CARD SHE READ IS NOT THE QUESTION IT
|
|
60
|
+
// IS PARKED ON. The request file lives in the spool a Claude Code worker is
|
|
61
|
+
// granted, so a card can be rewritten under its own id between being drawn and
|
|
62
|
+
// being answered; her Allow was for the other one. Same reading as above, for
|
|
63
|
+
// the same reason: this is a no.
|
|
64
|
+
const CARD_CHANGED = 'The approval request changed between being drawn and being answered, so the founder\'s decision was for a different action and nothing was allowed. Finish what your grants cover and file the rest as a review with the exact commands.';
|
|
65
|
+
|
|
66
|
+
// WHAT COUNTS AS SENT WHEN NOTHING IS. Analytics is optional here on purpose:
|
|
67
|
+
// every test and every copy without a key gets an object that answers no to
|
|
68
|
+
// everything, so no call site has to ask whether it exists.
|
|
69
|
+
const NO_ANALYTICS = { enabled: false, track: () => false };
|
|
70
|
+
|
|
71
|
+
// A copy with no updater wired in: the tests, and any host that constructs the
|
|
72
|
+
// IPC surface by hand. It reports the state a build from source really is in
|
|
73
|
+
// rather than a cheerful "up to date" nobody checked.
|
|
74
|
+
const NO_UPDATER = {
|
|
75
|
+
state: () => ({
|
|
76
|
+
phase: 'unsupported',
|
|
77
|
+
currentVersion: null,
|
|
78
|
+
newVersion: null,
|
|
79
|
+
percent: null,
|
|
80
|
+
error: `${Name} only updates itself when it is installed as an app.`,
|
|
81
|
+
checkedAt: null,
|
|
82
|
+
ready: false,
|
|
83
|
+
}),
|
|
84
|
+
check: async () => NO_UPDATER.state(),
|
|
85
|
+
install: () => false,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export function registerIpc({ store, supervisor, config, window, analytics = NO_ANALYTICS, docGrants = null, updater = NO_UPDATER, host }) {
|
|
89
|
+
// The three Electron things, or a plain Node stand-in for them. There is no
|
|
90
|
+
// default: a caller that forgets says so here rather than throwing eighty
|
|
91
|
+
// lines further down on `ipcMain.handle` of undefined.
|
|
92
|
+
if (!host?.ipcMain || !host?.app || !host?.dialog) {
|
|
93
|
+
throw new Error('registerIpc needs host: { ipcMain, app, dialog }');
|
|
94
|
+
}
|
|
95
|
+
const { ipcMain, app, dialog } = host;
|
|
96
|
+
// Coalesced: the supervisor fires onChange per worker stream line, which is
|
|
97
|
+
// every second or two per running session. Each push makes the renderer
|
|
98
|
+
// refetch the whole snapshot, so unthrottled pushes had the UI visibly
|
|
99
|
+
// re-rendering (flashing images, shifting layout) the entire time any
|
|
100
|
+
// worker was streaming. One push per window is plenty for a live tail.
|
|
101
|
+
let pushTimer = null;
|
|
102
|
+
const push = () => {
|
|
103
|
+
if (pushTimer) return;
|
|
104
|
+
pushTimer = setTimeout(() => {
|
|
105
|
+
pushTimer = null;
|
|
106
|
+
if (!window.isDestroyed()) window.webContents.send('zero:changed');
|
|
107
|
+
}, 400);
|
|
108
|
+
};
|
|
109
|
+
store.onChange = push;
|
|
110
|
+
supervisor.onChange = push;
|
|
111
|
+
|
|
112
|
+
// A worker mid-approval is frozen until the founder answers, so the spool
|
|
113
|
+
// pushes like the store does.
|
|
114
|
+
try {
|
|
115
|
+
const spool = approvals.spoolDir(config.storeRoot);
|
|
116
|
+
fsWatch.mkdirSync(spool, { recursive: true });
|
|
117
|
+
fsWatch.watch(spool, () => push());
|
|
118
|
+
} catch {}
|
|
119
|
+
|
|
120
|
+
// THE ONE PLACE AN APPROVAL IS ANSWERED, and there are three ways to reach
|
|
121
|
+
// it: the buttons on the card, the ⌘Y/⌘N chord (which main catches itself,
|
|
122
|
+
// ahead of the page, so it outranks anything holding the keyboard) and
|
|
123
|
+
// Allow/Deny Pending Approval in the Agents menu. All three go through here
|
|
124
|
+
// so all three tell the renderer what was just decided: without that, the
|
|
125
|
+
// card only ever "disappeared" on the next store push and nothing on screen
|
|
126
|
+
// acknowledged the press (the founder, 2026-08-06). The card's exit is drawn
|
|
127
|
+
// from this.
|
|
128
|
+
//
|
|
129
|
+
// THE MENU WAS NOT ONE OF THE THREE UNTIL NOW, AND THIS PARAGRAPH SAID IT
|
|
130
|
+
// WAS. It wrote `<id>.answer.json` itself, which is a real answer to a Claude
|
|
131
|
+
// Code worker in another process and NO answer at all to a Codex worker
|
|
132
|
+
// waiting on a promise in this one -- so her own Allow appeared on disk for a
|
|
133
|
+
// request still waiting, which is the definition of a forgery on that path,
|
|
134
|
+
// and `sweepForgery` deleted it two seconds later while logging that the
|
|
135
|
+
// founder had not written it. `installMenu` runs before this function exists,
|
|
136
|
+
// which is why it did that; main/main.mjs late-binds the door instead, and
|
|
137
|
+
// tests/the-menu-answers-a-card-through-the-one-door.test.mjs holds it.
|
|
138
|
+
//
|
|
139
|
+
// AND IT ANSWERS BOTH ENGINES, in two ways rather than one. A Claude Code
|
|
140
|
+
// worker waits on the answer FILE, in another process, so the file is the
|
|
141
|
+
// whole of its answer. A Codex worker waits on a promise inside THIS process
|
|
142
|
+
// and is told directly: a worker that can write into the store root could
|
|
143
|
+
// otherwise write itself an approval, and on this engine there is a channel
|
|
144
|
+
// that cannot be forged, so that is the one its decision travels down
|
|
145
|
+
// (main/codex-approvals.mjs). The file is still written either way, because
|
|
146
|
+
// it is what takes the card out of `listPending` and what the audit line is
|
|
147
|
+
// made of.
|
|
148
|
+
//
|
|
149
|
+
// AND AN ANSWER THAT COULD NOT BE WRITTEN IS A DENY, NOT A SILENCE
|
|
150
|
+
// (2026-09-04). `approvals.answer` is hardened now -- it will not write
|
|
151
|
+
// through a link a worker planted at `<id>.answer.json`, and it removes
|
|
152
|
+
// whatever is squatting the name instead -- so the only way it can still come
|
|
153
|
+
// back false is a request that is no longer there (she answered it already)
|
|
154
|
+
// or a spool that will not take a write at all. That second case used to
|
|
155
|
+
// THROW, out of an ipc handler and out of the ⌘Y `before-input-event`
|
|
156
|
+
// listener, which is worse than either outcome here. A Codex worker is parked
|
|
157
|
+
// on a promise in this process, so it is told the refusal directly and
|
|
158
|
+
// unblocks; a Claude Code worker has only the file, so it denies on its own
|
|
159
|
+
// deadline, which is the same answer fifteen minutes later. Nothing on this
|
|
160
|
+
// path can turn a failure into an allow, and the deny falls out of the SAME
|
|
161
|
+
// two calls the yes does rather than a second door beside them: `allow` and
|
|
162
|
+
// `note` stop meaning what she pressed the moment the write fails, and start
|
|
163
|
+
// meaning what was actually recorded, which is what everything downstream of
|
|
164
|
+
// here has to be told.
|
|
165
|
+
//
|
|
166
|
+
// AND HER PRESS IS BOUND TO THE CARD SHE READ, NOT TO ITS ID (2026-09-05).
|
|
167
|
+
// The request file is worker-writable and a rewrite keeps the name and the id
|
|
168
|
+
// while replacing the body, so "yes to <uuid>" was an allow for whatever the
|
|
169
|
+
// engine happened to be holding. `approvals.cardShown` is the digest of what
|
|
170
|
+
// was last drawn -- `answer` signs it, so the Claude Code worker's own server
|
|
171
|
+
// can check it against the request IT is holding -- and `codexCardChanged` is
|
|
172
|
+
// the same question asked of the engine holding its request in THIS process,
|
|
173
|
+
// which is the only one that can be asked here. A yes on a card that moved
|
|
174
|
+
// becomes a no, by the same rule and in the same place as a yes the spool
|
|
175
|
+
// would not take: `allow` and `note` stop meaning what she pressed and start
|
|
176
|
+
// meaning what was actually recorded.
|
|
177
|
+
const answerApproval = (id, allow, note) => {
|
|
178
|
+
const shown = approvals.cardShown(config.storeRoot, id);
|
|
179
|
+
if (allow && supervisor.codexCardChanged?.(id, shown)) {
|
|
180
|
+
console.warn(`zero: approval ${id} was drawn from a request that has since been rewritten; the founder's Allow was for a different card and nothing has been allowed`);
|
|
181
|
+
allow = false;
|
|
182
|
+
note = CARD_CHANGED;
|
|
183
|
+
}
|
|
184
|
+
const ok = approvals.answer(config.storeRoot, id, allow, note);
|
|
185
|
+
if (!ok) { allow = false; note = SPOOL_REFUSED; }
|
|
186
|
+
const settled = supervisor.settleCodexApproval?.(id, allow, note) ?? false;
|
|
187
|
+
if (!ok && settled) console.warn(`zero: approval ${id} could not be recorded in the spool; the worker waiting on it has been denied`);
|
|
188
|
+
if ((ok || settled) && !window.isDestroyed()) {
|
|
189
|
+
window.webContents.send('zero:approval-answered', { id, allow: !!allow });
|
|
190
|
+
}
|
|
191
|
+
return ok || settled;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// THE RENDERER'S ONLY WAY TO COUNT ANYTHING, and it cannot send: it hands a
|
|
195
|
+
// name to the main process, which checks the name against her approved list
|
|
196
|
+
// and attaches the properties itself. This is the reason there is no
|
|
197
|
+
// analytics SDK in the renderer at all (a test fails the build if one
|
|
198
|
+
// appears): a browser SDK autocaptures the text of what was clicked, and in
|
|
199
|
+
// Agentbox that text is task titles.
|
|
200
|
+
ipcMain.handle('zero:track', (_e, { name } = {}) => {
|
|
201
|
+
if (!EVENT_NAMES.includes(name)) return false;
|
|
202
|
+
return analytics.track(name);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
ipcMain.handle('zero:approve', (_e, { id, allow, note }) => {
|
|
206
|
+
return { ok: answerApproval(id, allow, note) };
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// AN AGENT ROW IS NOT A WORK ITEM. Rows for her Claude Code agents are drawn
|
|
210
|
+
// in the same list as her work and carry the same shape, so every keystroke
|
|
211
|
+
// the list already understands is aimed at them too. They belong to no
|
|
212
|
+
// ledger, and a write against `agent:41234` would append a line to a real
|
|
213
|
+
// product's work-items.jsonl for an item that does not exist. The renderer
|
|
214
|
+
// does not offer those actions; this is the second lock, here because the
|
|
215
|
+
// first one is a UI and UIs get edited.
|
|
216
|
+
const isAgentRow = (id) => typeof id === 'string' && id.startsWith('agent:');
|
|
217
|
+
|
|
218
|
+
// EVERY CLAUDE CODE AGENT ON THE MACHINE, minus the ones the app started itself.
|
|
219
|
+
// It rides the snapshot because it is exactly the same kind of fact as the
|
|
220
|
+
// rest of it — what is happening right now — and because a second poll would
|
|
221
|
+
// be a second clock the two lists could disagree about. This call NEVER
|
|
222
|
+
// blocks: it hands back the last measurement and asks for a fresh one in the
|
|
223
|
+
// background (main/agents.mjs), because measuring costs a quarter of a second
|
|
224
|
+
// of subprocess and this is the thread the window is drawn on.
|
|
225
|
+
// A newly-stuck agent should reach her without waiting for the renderer's
|
|
226
|
+
// ten-second poll, and the reader only fires this when the list actually
|
|
227
|
+
// changed rather than on every measurement.
|
|
228
|
+
agents.setOnChange(push);
|
|
229
|
+
|
|
230
|
+
// The moments she set on agents she is not ready for. Deferring one is about
|
|
231
|
+
// her inbox and nothing else: no message is sent and no process is touched,
|
|
232
|
+
// so this is a moment kept beside the reading rather than a write into
|
|
233
|
+
// somebody else's terminal. See main/agent-schedule.mjs.
|
|
234
|
+
const agentSchedule = new AgentSchedule(config.storeRoot);
|
|
235
|
+
|
|
236
|
+
// WHAT IS LEFT OF HER LIMIT. It rides the snapshot for the same reason
|
|
237
|
+
// `restartNeeded` does: it changes WHILE the app is open, and the corner that
|
|
238
|
+
// draws it is on every screen. Reading it costs twenty-five seconds, so
|
|
239
|
+
// nothing here ever waits for it: the object answers instantly out of its
|
|
240
|
+
// last measurement and refreshes itself behind the window
|
|
241
|
+
// (main/claude-usage.mjs).
|
|
242
|
+
const usage = new ClaudeUsage({
|
|
243
|
+
// Asked each time rather than captured, because Claude Code can be installed
|
|
244
|
+
// while the app is running and the settings screen already promises that
|
|
245
|
+
// works without a restart.
|
|
246
|
+
bin: () => config.claudeBin,
|
|
247
|
+
onChange: push,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Agents this run has already counted. In memory on purpose: a fresh launch
|
|
251
|
+
// seeing the same agent again is a fresh fact about the app being used.
|
|
252
|
+
const seenAgents = new Set();
|
|
253
|
+
|
|
254
|
+
const liveAgents = () => {
|
|
255
|
+
try {
|
|
256
|
+
const live = agentSchedule.decorate(agents.listAgents({
|
|
257
|
+
zeroPid: process.pid,
|
|
258
|
+
sessionIds: Object.values(supervisor._liveSessions ?? {}).map((s) => s?.sessionId),
|
|
259
|
+
products: store.listProducts(),
|
|
260
|
+
claudeBin: config.claudeBin,
|
|
261
|
+
}));
|
|
262
|
+
// "An agent was seen", ONCE per agent per run of the app. This list is
|
|
263
|
+
// rebuilt every few seconds, so counting it here without the set would
|
|
264
|
+
// send one event per agent per tick, all day. The keys are only ever
|
|
265
|
+
// compared in this process; nothing about an agent is sent with the
|
|
266
|
+
// count, which carries no name, no path and no title.
|
|
267
|
+
for (const agent of live) {
|
|
268
|
+
const key = agentKey(agent);
|
|
269
|
+
if (!key || seenAgents.has(key)) continue;
|
|
270
|
+
seenAgents.add(key);
|
|
271
|
+
analytics.track('agent_seen');
|
|
272
|
+
}
|
|
273
|
+
return live;
|
|
274
|
+
} catch {
|
|
275
|
+
// Her inbox is not allowed to fail because a `ps` did. An empty list here
|
|
276
|
+
// reads as "no outside agents", which is the state of most machines.
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/*
|
|
282
|
+
* THE FOLDER THE NEXT SIGN IN BELONGS TO, for either coding agent.
|
|
283
|
+
Remembered in main, not passed in from the window, because a terminal's
|
|
284
|
+
working directory is the one thing the renderer must never get to choose:
|
|
285
|
+
`resolve` is the only gate between a key and a shell, and every other key
|
|
286
|
+
goes through a work item that has to exist. w-dc88147919.
|
|
287
|
+
|
|
288
|
+
IT IS ONE VARIABLE FOR BOTH ENGINES because there is one settings terminal,
|
|
289
|
+
so there is only ever one sign in going on. Adding a Claude account after a
|
|
290
|
+
Codex one replaces the folder, and the shell the old one was holding is
|
|
291
|
+
closed in the same breath. */
|
|
292
|
+
let pendingLogin=null;
|
|
293
|
+
|
|
294
|
+
const terminals=new TaskTerminals({resolve:key=>{
|
|
295
|
+
const {product,id}=JSON.parse(key);
|
|
296
|
+
/*
|
|
297
|
+
* THE SETTINGS TERMINAL, which belongs to no task. There is exactly one of
|
|
298
|
+
it and it opens in the folder main just made for the login; if nothing is
|
|
299
|
+
pending it opens at home, which is where a shell with nothing to do
|
|
300
|
+
belongs. */
|
|
301
|
+
if(product===SETTINGS_TERMINAL.product&&id===SETTINGS_TERMINAL.id){
|
|
302
|
+
return pendingLogin ?? config.home ?? os.homedir();
|
|
303
|
+
}
|
|
304
|
+
const agent=id.startsWith('agent:')?liveAgents().find(a=>'agent:'+a.pid===id && (a.product??'')===product):null;
|
|
305
|
+
return terminalPlace({store,supervisor,product,id,agent});
|
|
306
|
+
}});
|
|
307
|
+
let terminalQuitPending=false,terminalQuitReady=false;
|
|
308
|
+
app.on('before-quit',event=>{
|
|
309
|
+
if(terminalQuitReady||(!terminalQuitPending&&!terminals.sessions.size))return;
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
if(!terminalQuitPending){terminalQuitPending=true;void terminals.shutdown().finally(()=>{terminalQuitReady=true;app.quit();});}
|
|
312
|
+
});
|
|
313
|
+
ipcMain.handle('zero:terminal',(_event,payload={})=>{
|
|
314
|
+
if(_event.senderFrame?.parent || (_event.sender && _event.sender!==window.webContents)) throw Error('Terminal is only available in the main app window.');
|
|
315
|
+
const {product,id,action,data,cols,rows,offset}=payload;
|
|
316
|
+
if(typeof product!=='string'||typeof id!=='string'||!id||id.length>300)throw Error('Invalid task.');
|
|
317
|
+
const key=JSON.stringify({product,id});
|
|
318
|
+
switch(action){
|
|
319
|
+
case 'open':return terminals.open(key);
|
|
320
|
+
case 'read':return terminals.read(key,offset);
|
|
321
|
+
case 'write':terminals.write(key,data);return true;
|
|
322
|
+
case 'resize':terminals.resize(key,cols,rows);return true;
|
|
323
|
+
case 'close':terminals.close(key);return true;
|
|
324
|
+
default:throw Error('Unknown terminal action.');
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
ipcMain.handle('zero:snapshot', () => {
|
|
329
|
+
const now = Date.now();
|
|
330
|
+
const items = store.listItems(now);
|
|
331
|
+
const running = supervisor.status();
|
|
332
|
+
const engines = supervisor.engineFacts(items);
|
|
333
|
+
// WHICH CODING AGENT THE CORNER IS ABOUT, ANSWERED ONCE, HERE.
|
|
334
|
+
// `usageEngine` is the rule (shared/usage.mjs) and it is the byline's own
|
|
335
|
+
// -- what is running beats what would run -- asked about the app instead of
|
|
336
|
+
// about a row.
|
|
337
|
+
const usageFor = usageEngine({ workspace: engines.workspace, running: running.running });
|
|
338
|
+
// ASKING IS THE BRANCH, WHICH IS WHY THIS IS NOT TWO READS AND A CHOICE.
|
|
339
|
+
// `usage.read` starts a `claude -p /usage` child whenever its figure has
|
|
340
|
+
// gone stale, so calling it on a snapshot whose corner is about Codex
|
|
341
|
+
// spends twenty-five seconds of somebody's machine on a number nothing will
|
|
342
|
+
// draw -- which is what a Codex-only workflow paid every five minutes until
|
|
343
|
+
// now. The Codex side never starts anything at all (main/codex-usage.mjs).
|
|
344
|
+
const reading = usageFor === DEFAULT_ENGINE ? usage.read() : supervisor.codexUsage();
|
|
345
|
+
return {
|
|
346
|
+
products: store.listProducts(),
|
|
347
|
+
items,
|
|
348
|
+
agents: liveAgents(),
|
|
349
|
+
approvals: approvals.listPending(config.storeRoot),
|
|
350
|
+
supervisor: running,
|
|
351
|
+
// WHETHER THERE ARE TWO CODING AGENTS TO CHOOSE BETWEEN, AND WHICH ONE
|
|
352
|
+
// EACH ROW RUNS ON. Answered whole by the supervisor, because the two
|
|
353
|
+
// rules underneath it -- the capability gate and "an August choice is not
|
|
354
|
+
// a choice she is making now" -- are pinned to that one file, and a screen
|
|
355
|
+
// that worked either of them out for itself could draw "Codex" over a row
|
|
356
|
+
// about to run on Claude Code.
|
|
357
|
+
//
|
|
358
|
+
// It rides the snapshot rather than the settings model for the reason
|
|
359
|
+
// `outsideAgents` does: the INBOX reads it, on every row, and the settings
|
|
360
|
+
// model is only fetched while that screen is open. On a Mac with one
|
|
361
|
+
// coding agent this is one entry and an empty map, and nothing anywhere
|
|
362
|
+
// draws anything new.
|
|
363
|
+
engines,
|
|
364
|
+
// Whether this process is still the app on disk. It rides the snapshot
|
|
365
|
+
// rather than boot-info because the staleness DEVELOPS while the app
|
|
366
|
+
// runs: the case that cost her three times was a process left up for days
|
|
367
|
+
// while fixes landed underneath it, and a boot-time check cannot see
|
|
368
|
+
// that. See main/staleness.mjs.
|
|
369
|
+
restartNeeded: restartNeeded(supervisor.appDir),
|
|
370
|
+
// HER RATE LIMIT, WHICH IS WHAT ACTUALLY CAPS THE FLEET. Null until the
|
|
371
|
+
// first reading lands, and the corner draws nothing at all until then
|
|
372
|
+
// rather than a pill full of dashes.
|
|
373
|
+
//
|
|
374
|
+
// AND IT IS ONE AGENT'S LIMIT, NAMED (2026-09-05). One corner and one
|
|
375
|
+
// dropdown, as before; what is new is that the reading says whose
|
|
376
|
+
// subscription it is about, so the screen can say so too. The name is
|
|
377
|
+
// still only DRAWN where this Mac has two agents to tell apart -- that
|
|
378
|
+
// rule is `engineWordFor`'s, in renderer/src/byline.ts.
|
|
379
|
+
usage: reading && { engine: usageFor, ...reading },
|
|
380
|
+
usageByEngine: engines.choices.map(({ id }) => {
|
|
381
|
+
const value = id === usageFor ? reading : id === DEFAULT_ENGINE ? usage.peek() : supervisor.codexUsage();
|
|
382
|
+
return value ? { engine: id, ...value } : null;
|
|
383
|
+
}).filter(Boolean),
|
|
384
|
+
// WHETHER A NEWER AGENTBOX IS ALREADY DOWNLOADED AND WAITING. It rides the
|
|
385
|
+
// snapshot for the same reason `restartNeeded` above does: it becomes
|
|
386
|
+
// true WHILE the app is open, hours after boot, and the screens that
|
|
387
|
+
// offer the restart are ones she may already be looking at. Settings
|
|
388
|
+
// reads the whole of it; ⌘K reads `ready` alone.
|
|
389
|
+
update: updater.state(),
|
|
390
|
+
config: {
|
|
391
|
+
// How many of her own Claude Code sessions the inbox takes. It rides
|
|
392
|
+
// the snapshot rather than the settings model because the INBOX reads
|
|
393
|
+
// it, and the settings model is only fetched while that screen is
|
|
394
|
+
// open.
|
|
395
|
+
outsideAgents: outsideAgentsMode(config),
|
|
396
|
+
// WHAT AGENTS ARE ALLOWED TO DO, ON THE SNAPSHOT, so the reply box can
|
|
397
|
+
// print it without opening the settings screen.
|
|
398
|
+
//
|
|
399
|
+
// It genuinely was not there: the only way in was a character you had
|
|
400
|
+
// to already know to type. Claude Code's own answer to the same problem
|
|
401
|
+
// is that the mode is printed in the status bar the entire time it is
|
|
402
|
+
// on, so ours is printed on the reply footer the entire time, and this
|
|
403
|
+
// is the value it prints when the message carries no opinion of its
|
|
404
|
+
// own. Per PROJECT, because the mode a reply will run in depends on
|
|
405
|
+
// which project the row belongs to: a project override wins over the
|
|
406
|
+
// workspace, and a personal project runs its own list entirely. The
|
|
407
|
+
// workspace answer is under the empty-string key, for a surface that
|
|
408
|
+
// has no project in hand.
|
|
409
|
+
permission: Object.fromEntries([
|
|
410
|
+
['', permissionMode(supervisor.defaultSessionArgs())],
|
|
411
|
+
...store.listProducts().map((p) => [p.slug, supervisor.effectivePermission(p.slug)]),
|
|
412
|
+
]),
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// HER WORDS, INTO A LIVE AGENT OF HERS. The only thing in the app that writes
|
|
418
|
+
// anywhere outside its own store, and it writes into a socket rather than to
|
|
419
|
+
// disk. The reader refuses a send to an agent frozen on a permission box, and
|
|
420
|
+
// reports back whether the agent actually moved rather than assuming it did.
|
|
421
|
+
ipcMain.handle('zero:agent-reply', async (_e, { pid, text }) => {
|
|
422
|
+
try {
|
|
423
|
+
const out = await agents.reply(Number(pid), text);
|
|
424
|
+
// AND THE ROW REMEMBERS THAT SHE SPOKE. Only on a delivered message: a
|
|
425
|
+
// mark written for a send that failed would move the row into In progress
|
|
426
|
+
// over words that never arrived, which is the failure this codebase cares
|
|
427
|
+
// about most.It does now, until the session stops. AND WHAT SHE TYPED,
|
|
428
|
+
// not only that she typed. Claude Code marks a message arriving over its
|
|
429
|
+
// socket as machine chatter (`isMeta`), so the transcript reader drops it
|
|
430
|
+
// and her own sentence never reached the card: she sent session-67 two
|
|
431
|
+
// messages on 2026-08-17 and the row went on saying only what she had
|
|
432
|
+
// started it with four days before. This is the only process that knows
|
|
433
|
+
// she said it, so this is where it is written.
|
|
434
|
+
if (out?.ok && out.key) agentSchedule.replied(out.key, out.at ?? Date.now(), Date.now(), String(text ?? ''));
|
|
435
|
+
push();
|
|
436
|
+
return out;
|
|
437
|
+
} catch {
|
|
438
|
+
return { ok: false, reason: 'The message could not be handed over.' };
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// AND UNDOING ONE. Withdrawing a reply cannot un-send the message — it is in
|
|
443
|
+
// somebody else's terminal — so this is honest about what it does: it takes
|
|
444
|
+
// the row out of In progress and puts it back where it was. The session is
|
|
445
|
+
// not touched, exactly as with deferring or closing one.
|
|
446
|
+
ipcMain.handle('zero:unreply-agent', (_e, { key }) => {
|
|
447
|
+
const mark = agentSchedule.replied(String(key ?? ''), 0);
|
|
448
|
+
push();
|
|
449
|
+
return { ok: true, repliedAt: mark };
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// For the agent a reply cannot reach: bring the app it is running inside to
|
|
453
|
+
// the front. Named honestly, because it is the app and not the pane.
|
|
454
|
+
ipcMain.handle('zero:agent-reveal', async (_e, { pid }) => {
|
|
455
|
+
try {
|
|
456
|
+
return await agents.reveal(Number(pid));
|
|
457
|
+
} catch {
|
|
458
|
+
return { ok: false, reason: `${Name} cannot tell which window it is running in.` };
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// HER BUTTON: what the two of them actually said, read off the transcript she
|
|
463
|
+
// already has, only when she presses it. No model, nothing sent, nothing
|
|
464
|
+
// written. It is streamed inside the reader because the biggest transcript on
|
|
465
|
+
// this machine is 160MB.
|
|
466
|
+
ipcMain.handle('zero:agent-conversation', async (_e, payload) => {
|
|
467
|
+
try {
|
|
468
|
+
// Her replies from Agentbox ride in beside the transcript for the same
|
|
469
|
+
// reason the card needs them: they are not IN the transcript as hers.
|
|
470
|
+
// The key is DERIVED here rather than sent, because what identifies an
|
|
471
|
+
// agent is a rule with one copy of it (shared/agents.mjs) and the
|
|
472
|
+
// renderer already hands over both halves it needs.
|
|
473
|
+
const key = agentKey({ sessionId: payload?.sessionId, pid: Number(payload?.pid), startedAt: payload?.startedAt });
|
|
474
|
+
return await agents.conversation({ ...(payload ?? {}), spoke: agentSchedule.spoke(key) });
|
|
475
|
+
} catch {
|
|
476
|
+
return { ok: false, reason: 'That conversation could not be read.' };
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
ipcMain.handle('zero:dashboard', (_e, slug) => {
|
|
481
|
+
try {
|
|
482
|
+
return store.readDashboard(slug);
|
|
483
|
+
} catch {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
ipcMain.handle('zero:command-catalog', (_e, { product, id }) => supervisor.commandCatalog(product, id));
|
|
489
|
+
ipcMain.handle('zero:remote-control', (_e, {product,id,action}) => supervisor.remoteControl(product,id,action));
|
|
490
|
+
ipcMain.handle('zero:command', (_e, { product, id, text }) => supervisor.commandItem(product, id, text));
|
|
491
|
+
ipcMain.handle('zero:compact', (_e, { product, id }) => supervisor.compactItem(product, id));
|
|
492
|
+
ipcMain.handle('zero:compaction-status', (_e, { product, id }) => supervisor.compactionStatus(product, id));
|
|
493
|
+
|
|
494
|
+
ipcMain.handle('zero:answer', async (_e, { product, id, answer, status, priority, permissionMode, model, effort }) => {
|
|
495
|
+
if (isAgentRow(id)) return { ok: false };
|
|
496
|
+
// A CODEX CONVERSATION'S YES OR NO NEVER REACHES A WORKER. The row asking
|
|
497
|
+
// whether to import one is answered by the app itself: yes makes it a row
|
|
498
|
+
// she can read, no parks it in Closed under Not imported, and Import on a
|
|
499
|
+
// parked one is the same yes. Any other reply on such a row falls through
|
|
500
|
+
// to the ordinary path, exactly as before.
|
|
501
|
+
if (product && id) {
|
|
502
|
+
let row = null;
|
|
503
|
+
try { row = store.readItem(product, id); } catch { row = null; }
|
|
504
|
+
if (row && (isCodexImportRow(row) || isNotImportedRow(row))) {
|
|
505
|
+
const choice = importChoice(answer);
|
|
506
|
+
if (choice) {
|
|
507
|
+
let thread = null;
|
|
508
|
+
if (choice === 'yes') {
|
|
509
|
+
try { thread = readCodexThreads({}).threads.find((t) => t.id === codexIdOf(row)) ?? null; } catch { thread = null; }
|
|
510
|
+
}
|
|
511
|
+
const out = store.answerCodexImport(product, id, choice, { thread });
|
|
512
|
+
push();
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// ARCHIVING ENDS THE WORK, not just the row. Her archive now outranks
|
|
518
|
+
// anything a straggler writes (shared/work-items.mjs, authorityOf), but a
|
|
519
|
+
// worker left running on work she has ended is still burning her plan on
|
|
520
|
+
// it, so it stops here rather than being merely overruled.
|
|
521
|
+
if (status === 'done') supervisor.stopSession(id);
|
|
522
|
+
// THE MODEL IS WRITTEN BEFORE THE REPLY IS SUBMITTED, deliberately. A reply
|
|
523
|
+
// on an idle row starts a run, and that run reads the model off the item;
|
|
524
|
+
// handing them over in one call and writing the model afterwards would be a
|
|
525
|
+
// race the run usually wins, so she would pick a model and watch the old one
|
|
526
|
+
// take the task. `answerItem` writes both, and it writes the model first.
|
|
527
|
+
const out = await submitReply(supervisor,{product,id,answer,status,permissionMode},()=>store.answerItem(product, id, { answer, status, priority, permissionMode, model, effort }));
|
|
528
|
+
// THAT she replied and THAT something finished. Never a word of either:
|
|
529
|
+
// in Agentbox the answer IS the prompt (privacy page, section 4).
|
|
530
|
+
if (typeof answer === 'string' && answer.trim()) analytics.track('reply_sent');
|
|
531
|
+
if (status === 'done') analytics.track('task_finished');
|
|
532
|
+
// HER REPLY IS THE OTHER HALF OF THE SAME WAIT, and it is the one she pays
|
|
533
|
+
// on every round trip rather than once: measured 2026-08-24, median 9.1
|
|
534
|
+
// seconds between pressing the key and the continuation being spawned.
|
|
535
|
+
// Archiving is the one answer that starts nothing, and its session was
|
|
536
|
+
// just killed above, so there is nothing to wake for.
|
|
537
|
+
if (status !== 'done') { supervisor.liftBrakeForHer?.(); supervisor.wake(); }
|
|
538
|
+
return out;
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// The product order: the founder's standing running order over her
|
|
542
|
+
// companies, most important first. Persisted in the supervisor's state;
|
|
543
|
+
// agents never set this. The renderer sends the WHOLE order rather than one
|
|
544
|
+
// move, because a drag already knows the finished arrangement, and a move
|
|
545
|
+
// would need both sides to agree about indices only one of them can see.
|
|
546
|
+
ipcMain.handle('zero:set-product-order', (_e, { order }) => {
|
|
547
|
+
supervisor.setProductOrder(order);
|
|
548
|
+
return supervisor.status();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// Hidden from the composer's picker and nothing else: a hidden product still
|
|
552
|
+
// ranks, still spawns workers, and its work still reaches the inbox.
|
|
553
|
+
ipcMain.handle('zero:set-product-hidden', (_e, { product, hidden }) => {
|
|
554
|
+
supervisor.setProductHidden(product, hidden);
|
|
555
|
+
return supervisor.status();
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
ipcMain.handle('zero:compose', (_e, { product, title, body, kind, priority, runAt, labels, engine, model, effort }) => {
|
|
559
|
+
const out = store.composeItem(product, {
|
|
560
|
+
// HOW HARD IT THINKS rides through unjudged: the store keeps any word
|
|
561
|
+
// shaped like a level, and the spawn is the gate that knows each
|
|
562
|
+
// engine's real list (`Supervisor#spawnPlan` for Claude Code,
|
|
563
|
+
// `codexEffortRefusal` for Codex).
|
|
564
|
+
title, body, kind, priority, runAt, labels, effort,
|
|
565
|
+
// WHICH CODING AGENT SHE CHOSE, AND ONLY IF SHE COULD HAVE. `engineOffered`
|
|
566
|
+
// is the supervisor's own test and the door makes no judgement of its own:
|
|
567
|
+
// it answers null on any Mac where the picker is not drawn, so a renderer
|
|
568
|
+
// left open across a config change cannot write a choice she was never
|
|
569
|
+
// shown. This argument used to be forwarded straight through and
|
|
570
|
+
// `composeItem` dropped it in silence.
|
|
571
|
+
engine: supervisor.engineOffered(engine),
|
|
572
|
+
// AND THE MODEL GOES WITH IT, which it did not: `model` was forwarded raw
|
|
573
|
+
// beside a refused engine, so the row was filed carrying the other
|
|
574
|
+
// harness's slug and no engine to explain it -- a row the cross-harness
|
|
575
|
+
// guard downstream cannot even see. `modelOffered` says why the two
|
|
576
|
+
// fields cannot be corrected separately.
|
|
577
|
+
model: supervisor.modelOffered(engine, model),
|
|
578
|
+
});
|
|
579
|
+
// AND LOOK AT IT NOW. Composing used to write the line and stop, so the
|
|
580
|
+
// task sat in the store until the next fifteen second tick: measured
|
|
581
|
+
// 2026-08-24, median 7.0 seconds and up to 15.0 before the spawn was even
|
|
582
|
+
// attempted. A row scheduled for later is not woken for, because it is not
|
|
583
|
+
// due; the timer is what serves those.
|
|
584
|
+
if (!runAt || runAt <= Date.now()) supervisor.wake();
|
|
585
|
+
return out;
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
/* ----------------------------- the first run ---------------------------- */
|
|
589
|
+
// THE WALK'S EXAMPLE TASK, ANSWERED BY THE APP RATHER THAN BY A SESSION.
|
|
590
|
+
//
|
|
591
|
+
// The whole of the work is reading the readme in the folder chosen two
|
|
592
|
+
// screens earlier, so this returns in milliseconds. The two second pause
|
|
593
|
+
// before it is called is in the renderer, where the row is being watched.
|
|
594
|
+
ipcMain.handle('zero:first-run-answer', (_e, { product, id }) => {
|
|
595
|
+
return store.finishFirstRunTask(product, id);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// THE PRACTICE PROJECT.Made when the hand-off screen is pressed, filled with
|
|
599
|
+
// the three rows in the same call, and archived by the handler below it when
|
|
600
|
+
// the walk ends. It is a real project and it is never a project anybody works
|
|
601
|
+
// in: main/store.mjs and main/supervisor.mjs both say what that means.
|
|
602
|
+
ipcMain.handle('zero:first-run-practice', () => {
|
|
603
|
+
const made = store.createPractice();
|
|
604
|
+
let rows = { ids: [] };
|
|
605
|
+
// A store that will not take the three rows is not a reason to strand
|
|
606
|
+
// somebody mid-walk. They land in an empty practice inbox and the beat that
|
|
607
|
+
// clears them is skipped, exactly as beat thirteen already behaves.
|
|
608
|
+
try { rows = store.stagePracticeRows(made.slug); } catch { rows = { ids: [] }; }
|
|
609
|
+
return { ...made, examples: rows.ids };
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// AND TAKING IT BACK OFF THE SCREEN. `sweepPractice` rather than the one
|
|
613
|
+
// slug, so a practice project left behind by a walk somebody quit halfway is
|
|
614
|
+
// cleared by the next one that finishes.
|
|
615
|
+
ipcMain.handle('zero:first-run-practice-end', () => store.sweepPractice());
|
|
616
|
+
|
|
617
|
+
// BEAT EIGHT'S THREE EXAMPLE ROWS, written into the project she just
|
|
618
|
+
// made. They are real rows, because the beat teaches clearing an inbox and
|
|
619
|
+
// there is no way to teach that with a picture of one.
|
|
620
|
+
ipcMain.handle('zero:first-run-examples', (_e, { product }) => {
|
|
621
|
+
return store.stageFirstRunExamples(product);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// AND NOTHING ELSE STARTS WHILE SHE IS BEING SHOWN AROUND.The hold is in
|
|
625
|
+
// memory and it expires; supervisor.firstRunWalking says why both of those
|
|
626
|
+
// matter.
|
|
627
|
+
ipcMain.handle('zero:first-run-walking', (_e, { walking }) => {
|
|
628
|
+
supervisor.firstRunWalking(!!walking);
|
|
629
|
+
return supervisor.status();
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// BEING A BRAND NEW USER, ON THE APP SHE DOWNLOADED.The row above this one
|
|
633
|
+
// is the "at LEAST" half. This is the whole of it: a SECOND Agentbox, with no
|
|
634
|
+
// projects, no settings and no store, standing beside hers rather than
|
|
635
|
+
// replacing it.
|
|
636
|
+
//
|
|
637
|
+
// NOTHING OF HERS IS DELETED HERE, and there is no version of this handler
|
|
638
|
+
// that may delete anything. main/fresh-user.mjs says how it works and why
|
|
639
|
+
// her own window and every agent in it keep running.
|
|
640
|
+
ipcMain.handle('zero:open-fresh-user', (_e, payload) => {
|
|
641
|
+
return openFreshUser({
|
|
642
|
+
withAgents: payload?.withAgents !== false,
|
|
643
|
+
packaged: app.isPackaged,
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
// AN INBOX TO STAND IN FRONT OF A ROOM WITH.
|
|
648
|
+
//
|
|
649
|
+
// The same second Agentbox in a throwaway home as the row above, with three
|
|
650
|
+
// invented products and a day of invented work seeded into it first, so it
|
|
651
|
+
// opens on a populated inbox rather than on the welcome screen. It reads none
|
|
652
|
+
// of her folders and it deletes nothing (main/demo.mjs).
|
|
653
|
+
ipcMain.handle('zero:open-demo', () => openDemo({ packaged: app.isPackaged }));
|
|
654
|
+
|
|
655
|
+
// "Run this later", the durable half of what snooze used to pretend to be.
|
|
656
|
+
// A worker already running on the item is stopped: deferring work that is
|
|
657
|
+
// happening right now and leaving it running is not deferring it.
|
|
658
|
+
ipcMain.handle('zero:schedule', (_e, { product, id, runAt }) => {
|
|
659
|
+
if (isAgentRow(id)) return { ok: false };
|
|
660
|
+
if (runAt > Date.now()) supervisor.stopSession(id);
|
|
661
|
+
return store.scheduleItem(product, id, runAt);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// said about an agent instead of about a task. The row it moves stands for
|
|
665
|
+
// somebody's terminal, so this writes to no ledger, sends no message and
|
|
666
|
+
// stops no process: it records the moment her inbox should raise the agent
|
|
667
|
+
// again, and the reader tests it on every measurement. 0 brings it straight
|
|
668
|
+
// back.
|
|
669
|
+
//
|
|
670
|
+
// The KEY comes from the agent, never from the row id: `agent:<pid>` is what
|
|
671
|
+
// the list draws with and a pid is not an identity (shared/agents.mjs).
|
|
672
|
+
ipcMain.handle('zero:schedule-agent', (_e, { key, runAt }) => {
|
|
673
|
+
const set = agentSchedule.set(String(key ?? ''), runAt);
|
|
674
|
+
push();
|
|
675
|
+
return { ok: true, runAt: set };
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// `through` is the agent's own last activity when she closed it, so a session
|
|
679
|
+
// that then does something newer is news again rather than the thing she
|
|
680
|
+
// dismissed; 0 puts the row straight back.
|
|
681
|
+
ipcMain.handle('zero:close-agent', (_e, { key, through }) => {
|
|
682
|
+
const mark = agentSchedule.close(String(key ?? ''), through);
|
|
683
|
+
push();
|
|
684
|
+
return { ok: true, doneThrough: mark };
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
/* ---------------------------- repeating tasks --------------------------- */
|
|
688
|
+
// A repeating task is a RULE, so composing one writes no work item at all.
|
|
689
|
+
// That ordering is the point: composeItem appends an open founder item before
|
|
690
|
+
// it applies her content, and another copy of the app can spawn that intermediate state,
|
|
691
|
+
// so a rule that turned out to be unkeepable would leave a one-shot running.
|
|
692
|
+
// The first run arrives on the next tick instead.
|
|
693
|
+
ipcMain.handle('zero:repeats', () => store.listRepeats());
|
|
694
|
+
|
|
695
|
+
// THE SAME TWO GATES `zero:compose` USES, because a rule is a standing task
|
|
696
|
+
// and every one of its runs is an ordinary row. `engineOffered` answers null
|
|
697
|
+
// on any Mac where the picker is not drawn, so a rule cannot be marked for an
|
|
698
|
+
// engine she was never shown, and `modelOffered` refuses the model of an
|
|
699
|
+
// engine that was just refused. Both were absent here because neither field
|
|
700
|
+
// reached this handler at all: the whole pair was dropped four times over on
|
|
701
|
+
// the way from her card (main/repeats.mjs says where).
|
|
702
|
+
ipcMain.handle('zero:compose-repeat', (_e, { product, title, body, priority, rule, engine, model }) => {
|
|
703
|
+
return store.composeRepeat(product, {
|
|
704
|
+
title,
|
|
705
|
+
body,
|
|
706
|
+
priority,
|
|
707
|
+
...rule,
|
|
708
|
+
engine: supervisor.engineOffered(engine),
|
|
709
|
+
model: supervisor.modelOffered(engine, model),
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
// THE ONE HANDLER THAT TAKES A FREE-FORM PATCH, so it is the one that has to
|
|
714
|
+
// gate the two new fields by hand. `patchRule` appends whatever it is given
|
|
715
|
+
// and the fold now carries `engine` and `model` (shared/repeats.mjs), so
|
|
716
|
+
// without this the renderer could mark a rule for Codex without ever passing
|
|
717
|
+
// `engineOffered` -- the capability gate, bypassed by the edit screen. A patch
|
|
718
|
+
// that names neither is forwarded exactly as it arrived.
|
|
719
|
+
ipcMain.handle('zero:set-repeat', (_e, { product, id, rule }) => {
|
|
720
|
+
const { engine, model, ...rest } = rule ?? {};
|
|
721
|
+
return store.setRepeat(product, id, {
|
|
722
|
+
...rest,
|
|
723
|
+
...(engine === undefined ? {} : { engine: supervisor.engineOffered(engine) }),
|
|
724
|
+
...(model === undefined ? {} : { model: supervisor.modelOffered(engine, model) }),
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// Ending one is how she stops it, and it is a small durable fact rather than
|
|
729
|
+
// an archive, so it does not collide with what done means on a work item.
|
|
730
|
+
ipcMain.handle('zero:end-repeat', (_e, { product, id }) => store.endRepeat(product, id));
|
|
731
|
+
|
|
732
|
+
ipcMain.handle('zero:create-product', (_e, { name, repoPath }) => {
|
|
733
|
+
const out = store.createProduct({ name, repoPath });
|
|
734
|
+
// (privacy page 5.1). The count is the whole payload; the path it was
|
|
735
|
+
// connected to never leaves.
|
|
736
|
+
if (typeof repoPath === 'string' && repoPath.trim()) analytics.track('repo_connected');
|
|
737
|
+
return out;
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// Returns a bare path or null. Cancelling is not an error and never clears
|
|
741
|
+
// what the card already had.
|
|
742
|
+
//
|
|
743
|
+
// AND IT REFUSES THE FOLDERS THAT ARE NOT PROJECTS.The picker opens on the
|
|
744
|
+
// home folder, so pressing Open without navigating anywhere makes the home
|
|
745
|
+
// folder the project, and the worker that starts there lists it within its
|
|
746
|
+
// first few tool calls. macOS then asks about Downloads, Music, Pictures and
|
|
747
|
+
// Application Support one panel at a time, with our name on every one of
|
|
748
|
+
// them.
|
|
749
|
+
//
|
|
750
|
+
// The refusal is HERE rather than on the screens, so the path never reaches
|
|
751
|
+
// the renderer at all and no screen can take it by accident. What comes back
|
|
752
|
+
// is the sentence, which the screens show as it stands.
|
|
753
|
+
ipcMain.handle('zero:choose-folder', async (_e, { startIn } = {}) => {
|
|
754
|
+
const r = await dialog.showOpenDialog(window, {
|
|
755
|
+
title: 'Where is its code?',
|
|
756
|
+
buttonLabel: 'Use this folder',
|
|
757
|
+
properties: ['openDirectory', 'createDirectory'],
|
|
758
|
+
...(startIn ? { defaultPath: startIn } : {}),
|
|
759
|
+
});
|
|
760
|
+
// NO MAC DIALOG TO OPEN, WHICH IS THE BROWSER AND NOT AN ERROR. The screen
|
|
761
|
+
// draws its own picker instead, on `zero:list-folders` below, and comes
|
|
762
|
+
// back here with nothing further to ask. Saying so with a flag rather than
|
|
763
|
+
// a sentence is what lets one screen serve both doors.
|
|
764
|
+
if (r.noDialog) return { path: null, browse: true };
|
|
765
|
+
if (r.canceled || !r.filePaths?.length) return { path: null };
|
|
766
|
+
const picked = r.filePaths[0];
|
|
767
|
+
const verdict = checkProjectFolder(picked, { home: os.homedir() });
|
|
768
|
+
if (!verdict.ok) return { path: null, refused: verdict.say };
|
|
769
|
+
return { path: picked };
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// THE PICKER THE APP DRAWS ITSELF, for a tab that has no Mac dialog.
|
|
773
|
+
//
|
|
774
|
+
// The tab cannot be given a path by the operating system. This process can
|
|
775
|
+
// read the disk perfectly well, so the screen walks it a folder at a time
|
|
776
|
+
// through here and sends back the one she stopped on. See main/folders.mjs
|
|
777
|
+
// for why that is the whole of the problem.
|
|
778
|
+
//
|
|
779
|
+
// The verdict rides along so the picker can say, on the folder she is
|
|
780
|
+
// standing in, whether choosing it would be turned down. The Mac's dialog
|
|
781
|
+
// cannot do that and this can, which makes the browser's picker the better
|
|
782
|
+
// of the two on the one thing that actually costs somebody an afternoon.
|
|
783
|
+
ipcMain.handle('zero:list-folders', (_e, { at = null, showHidden = false } = {}) => {
|
|
784
|
+
const home = os.homedir();
|
|
785
|
+
const listed = listFolders({ at, home, showHidden });
|
|
786
|
+
const verdict = checkProjectFolder(listed.at, { home });
|
|
787
|
+
return { ...listed, refused: verdict.ok ? null : verdict.say };
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// Whether the card's sentence may say "a new folder". The card proposes
|
|
791
|
+
// ~/Desktop/dev/<name> from the name typed, and that folder sometimes already
|
|
792
|
+
// exists with somebody's code in it; calling that one new would be a lie in
|
|
793
|
+
// the one sentence on the card.
|
|
794
|
+
ipcMain.handle('zero:folder-exists', (_e, { path: p } = {}) => {
|
|
795
|
+
if (typeof p !== 'string' || !p.trim()) return { exists: false };
|
|
796
|
+
const full = p.trim().startsWith('~')
|
|
797
|
+
? path.join(os.homedir(), p.trim().slice(1))
|
|
798
|
+
: p.trim();
|
|
799
|
+
try { return { exists: fsWatch.statSync(full).isDirectory() }; } catch { return { exists: false }; }
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// HER CLAUDE CODE AGENTS, for the last screen of the first run. Both of
|
|
803
|
+
// Claude Code's own scopes, because both of them are in her sentence: the
|
|
804
|
+
// home folder set is every project on this Mac, and the one inside the
|
|
805
|
+
// project folder is that project alone.
|
|
806
|
+
//
|
|
807
|
+
// `chosen` comes back with them so the screen can open on what she already
|
|
808
|
+
// took rather than on nothing, which is what makes walking it a second time
|
|
809
|
+
// show her the truth instead of an empty page.
|
|
810
|
+
ipcMain.handle('zero:agent-files', (_e, { folder = null, product = null } = {}) => {
|
|
811
|
+
const found = readAgentFiles({ folder: typeof folder === 'string' && folder.trim() ? folder.trim() : null });
|
|
812
|
+
const map = config.projectAgents ?? {};
|
|
813
|
+
const chosen = product && Array.isArray(map[product]) ? map[product] : null;
|
|
814
|
+
return { ...found, chosen };
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// THE FOLDERS THE CARD CANNOT REACH.
|
|
818
|
+
//
|
|
819
|
+
// So a folder's agents may only ever land in the project that points at that
|
|
820
|
+
// folder, which `zero:agent-files` above already guarantees by reading the
|
|
821
|
+
// chosen project's own `repoPath` and nothing else. The gap her answer leaves
|
|
822
|
+
// open is the folder no project points at: on her Mac that is
|
|
823
|
+
// `~/Desktop/dev/astral-desktop`, four agents that no choice on the card could
|
|
824
|
+
// reach. This says which folders those are so the card can offer to make the
|
|
825
|
+
// project rather than quietly filing them somewhere they do not work.
|
|
826
|
+
//
|
|
827
|
+
// the parent of every project folder she has already connected. A folder a
|
|
828
|
+
// project already owns is dropped, because the card reaches those by name in
|
|
829
|
+
// its own project list.
|
|
830
|
+
ipcMain.handle('zero:agent-folders', () => {
|
|
831
|
+
const products = store.listProducts();
|
|
832
|
+
const owned = new Set();
|
|
833
|
+
const roots = new Set();
|
|
834
|
+
for (const p of products) {
|
|
835
|
+
const repo = typeof p.repoPath === 'string' && p.repoPath.trim() ? path.resolve(p.repoPath.trim()) : null;
|
|
836
|
+
if (!repo) continue;
|
|
837
|
+
owned.add(repo);
|
|
838
|
+
roots.add(path.dirname(repo));
|
|
839
|
+
}
|
|
840
|
+
let folders = [];
|
|
841
|
+
try { folders = findAgentFolders({ roots: [...roots] }); } catch { folders = []; }
|
|
842
|
+
// AND THE AGENTS THEMSELVES, not only how many.A count can only be a clause
|
|
843
|
+
// in a sentence; the card now draws each folder as a heading with its own
|
|
844
|
+
// agents ticked underneath, which is the hierarchy, and that needs the
|
|
845
|
+
// files. They are a handful of small markdown files per folder and the same
|
|
846
|
+
// reader the project side already uses.
|
|
847
|
+
const out = [];
|
|
848
|
+
for (const f of folders) {
|
|
849
|
+
if (owned.has(f.folder)) continue;
|
|
850
|
+
let agents = [];
|
|
851
|
+
try { agents = readFolderAgents(f.folder); } catch { agents = []; }
|
|
852
|
+
out.push({ ...f, agents });
|
|
853
|
+
}
|
|
854
|
+
return { folders: out };
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// AND THE THREADS, WHICH ARE THE AGENTS SHE MEANT.
|
|
858
|
+
//
|
|
859
|
+
// The card was reading `.md` agent definitions, four of them, last edited in
|
|
860
|
+
// March; these are the conversations.
|
|
861
|
+
//
|
|
862
|
+
// The reading itself is main/agent-sessions.mjs and every judgment lives
|
|
863
|
+
// there. This is the window onto it, plus one thing: WHAT IT LAST HANDED OUT
|
|
864
|
+
// IS KEPT. The import below needs the thread objects and it may not trust the
|
|
865
|
+
// renderer's copy of them, and the alternative is walking her whole
|
|
866
|
+
// ~/.claude/projects again per press. Measured on her Mac 2026-08-29: one
|
|
867
|
+
// walk is 1.7 seconds over 2,445 transcripts, and one press can file into
|
|
868
|
+
// several projects, so re-walking would cost seconds of a button that should
|
|
869
|
+
// feel instant. The card read them a moment before pressing, so the last read
|
|
870
|
+
// is the right answer and it is ours rather than the renderer's.
|
|
871
|
+
let lastThreads = [];
|
|
872
|
+
// HER CODEX CONVERSATIONS ARE ON THE SAME CARD. They carry `source:
|
|
873
|
+
// 'codex'`, land under the project whose folder they ran in exactly as the
|
|
874
|
+
// Claude Code ones do, and the ones she said not now to are here too, which
|
|
875
|
+
// is the way back she asked for.
|
|
876
|
+
const readAllThreads = () => {
|
|
877
|
+
let claude = [];
|
|
878
|
+
let codex = [];
|
|
879
|
+
try { ({ threads: claude } = readSessionThreads({})); } catch { claude = []; }
|
|
880
|
+
try { ({ threads: codex } = readCodexThreads({})); } catch { codex = []; }
|
|
881
|
+
return [...claude, ...codex].sort((a, b) => (b.when ?? 0) - (a.when ?? 0));
|
|
882
|
+
};
|
|
883
|
+
ipcMain.handle('zero:agent-threads', () => {
|
|
884
|
+
const threads = readAllThreads();
|
|
885
|
+
lastThreads = threads;
|
|
886
|
+
return { threads };
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// So this is `zero:import-agents` with the agent taken out of it. No ticks are
|
|
890
|
+
// saved, because there is nothing for the card to remember: an imported thread
|
|
891
|
+
// is a row, and a row is what stops it being imported twice
|
|
892
|
+
// (`threadsNeedingRows`). It does NOT resume the conversation; she was offered
|
|
893
|
+
// that and picked this instead.
|
|
894
|
+
ipcMain.handle('zero:import-threads', (_e, { product, threads } = {}) => {
|
|
895
|
+
if (!product) return { ok: false, added: 0, already: 0, ids: [] };
|
|
896
|
+
const want = new Set(
|
|
897
|
+
(Array.isArray(threads) ? threads : [])
|
|
898
|
+
.filter((id) => typeof id === 'string' && id.trim())
|
|
899
|
+
.map((id) => id.trim()),
|
|
900
|
+
);
|
|
901
|
+
if (!want.size) { return { ok: true, added: 0, already: 0, ids: [] }; }
|
|
902
|
+
// The read is taken again only if nothing has been handed out yet, which is
|
|
903
|
+
// the case a test or a fresh window can reach and a person cannot.
|
|
904
|
+
if (!lastThreads.length) lastThreads = readAllThreads();
|
|
905
|
+
// Two kinds, two rows.
|
|
906
|
+
const chosen = lastThreads.filter((t) => want.has(String(t?.id ?? '')));
|
|
907
|
+
let out = { ids: [], added: 0, already: 0 };
|
|
908
|
+
try {
|
|
909
|
+
// Each store method takes its own kind off the list and leaves the other.
|
|
910
|
+
const claude = store.importThreadRows(product, chosen);
|
|
911
|
+
const codex = store.importCodexThreads(product, chosen);
|
|
912
|
+
out = { ids: [...claude.ids, ...codex.ids], added: claude.added + codex.added, already: claude.already + codex.already };
|
|
913
|
+
} catch (err) {
|
|
914
|
+
push();
|
|
915
|
+
return { ok: false, error: String(err.message), added: 0, already: 0, ids: [] };
|
|
916
|
+
}
|
|
917
|
+
push();
|
|
918
|
+
return { ok: true, ...out };
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
// TAKING THEM, which is the half that used to do nothing.
|
|
922
|
+
//
|
|
923
|
+
// Before this, the finish card's button wrote the ticked names into
|
|
924
|
+
// `projectAgents` and stopped. Three occurrences of that key in the whole
|
|
925
|
+
// shipped app, all of them this file and settings.mjs, and none in the
|
|
926
|
+
// renderer: the list was read back only so the card could remember its own
|
|
927
|
+
// ticks the next time somebody walked the setup. Measured on the build David
|
|
928
|
+
// used, the store was byte for byte identical before and after pressing it.
|
|
929
|
+
//
|
|
930
|
+
// The names are still saved, because the card still opens on what was taken
|
|
931
|
+
// last time. What is new is the second line: one inbox row per agent, asking
|
|
932
|
+
// for its first job.
|
|
933
|
+
//
|
|
934
|
+
// THE FILES ARE READ AGAIN HERE RATHER THAN SENT IN. The renderer has the
|
|
935
|
+
// whole list already, so it could hand the paths over, but a row that names a
|
|
936
|
+
// file on somebody's disk should name a file that was on the disk a moment
|
|
937
|
+
// ago and not one a screen was holding from earlier in the walk.
|
|
938
|
+
ipcMain.handle('zero:import-agents', (_e, { product, agents } = {}) => {
|
|
939
|
+
if (!product) return { ok: false, added: 0, ids: [] };
|
|
940
|
+
const names = (Array.isArray(agents) ? agents : [])
|
|
941
|
+
.filter((n) => typeof n === 'string' && n.trim())
|
|
942
|
+
.map((n) => n.trim());
|
|
943
|
+
try {
|
|
944
|
+
setProjectSetting({ config, supervisor }, { product, key: 'agents', value: names });
|
|
945
|
+
} catch { /* the ticks are a convenience; the rows are the point */ }
|
|
946
|
+
if (!names.length) { push(); return { ok: true, added: 0, ids: [] }; }
|
|
947
|
+
const keep = new Set(names);
|
|
948
|
+
const repo = store.listProducts().find((p) => p.slug === product)?.repoPath ?? null;
|
|
949
|
+
const found = readAgentFiles({ folder: repo });
|
|
950
|
+
const chosen = [...found.user, ...found.project].filter((a) => keep.has(a.name));
|
|
951
|
+
let out = { ids: [], added: 0, already: 0 };
|
|
952
|
+
try {
|
|
953
|
+
out = store.importAgentRows(product, chosen);
|
|
954
|
+
} catch (err) {
|
|
955
|
+
push();
|
|
956
|
+
return { ok: false, error: String(err.message), added: 0, ids: [] };
|
|
957
|
+
}
|
|
958
|
+
push();
|
|
959
|
+
return { ok: true, ...out };
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
ipcMain.handle('zero:supervisor-pause', (_e, paused) => {
|
|
963
|
+
supervisor.paused = !!paused;
|
|
964
|
+
// Taking the pause off is a thing she does expecting work to start, so it
|
|
965
|
+
// starts, rather than after up to another fifteen seconds of a paused-
|
|
966
|
+
// looking app.
|
|
967
|
+
if (!supervisor.paused) supervisor.wake();
|
|
968
|
+
return supervisor.status();
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// Stop a running agent: kill its session and set the item blocked, so it
|
|
972
|
+
// is not instantly re-pulled and sits in the inbox awaiting redirection.
|
|
973
|
+
ipcMain.handle('zero:stop-session', (_e, { product, id }) => {
|
|
974
|
+
if (isAgentRow(id)) return { ok: false };
|
|
975
|
+
supervisor.stopSession(id);
|
|
976
|
+
return store.answerItem(product, id, { status: 'blocked' });
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
// Send a stopped/blocked item back to the queue; the fresh worker's brief
|
|
980
|
+
// carries whatever the founder wrote on the item meanwhile.
|
|
981
|
+
ipcMain.handle('zero:reopen', (_e, { product, id }) => {
|
|
982
|
+
if (isAgentRow(id)) return { ok: false };
|
|
983
|
+
const out = store.answerItem(product, id, { status: 'open' });
|
|
984
|
+
supervisor.liftBrakeForHer?.();
|
|
985
|
+
supervisor.wake();
|
|
986
|
+
return out;
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
/* -------------------------- standing instructions ------------------------ */
|
|
990
|
+
// Rules the founder writes once that every session reads before its own
|
|
991
|
+
// brief. The supervisor owns the file and the injection (standingFile,
|
|
992
|
+
// standingInstructions, spawnPlan); this is only the window onto it.
|
|
993
|
+
//
|
|
994
|
+
// WHAT SHE TYPES LANDS IN userDir, NEVER IN THE CHECKOUT (w-3dc46f3a67).
|
|
995
|
+
// These boxes wrote to `dataDir/briefs`, which running from source is the git
|
|
996
|
+
// checkout the fleet works in, so a save could be undone by the next
|
|
997
|
+
// worker's `git checkout` with nothing to show it had happened. The shipped
|
|
998
|
+
// defaults still come from appDir; only her own copy moved.
|
|
999
|
+
ipcMain.handle('zero:instruction-read', (_e, id) => {
|
|
1000
|
+
try { return readInstruction(supervisor.userDir, id, supervisor.appDir); }
|
|
1001
|
+
catch (e) { return {text:'',defaultText:'',error:e.message}; }
|
|
1002
|
+
});
|
|
1003
|
+
ipcMain.handle('zero:instruction-write', (_e, {id,text}) => {
|
|
1004
|
+
try { return writeInstruction(supervisor.userDir, id, text); }
|
|
1005
|
+
catch (e) { return {ok:false,error:e.message}; }
|
|
1006
|
+
});
|
|
1007
|
+
// The restore points behind the Restore link: her own earlier versions of one
|
|
1008
|
+
// box. The list carries times and sizes only, because the whole of twenty
|
|
1009
|
+
// versions is most of a megabyte and the page needs one of them at a time.
|
|
1010
|
+
ipcMain.handle('zero:instruction-history', (_e, id) => {
|
|
1011
|
+
try { return {versions: listVersions(supervisor.userDir, id)}; }
|
|
1012
|
+
catch (e) { return {versions:[], error:e.message}; }
|
|
1013
|
+
});
|
|
1014
|
+
ipcMain.handle('zero:instruction-version', (_e, {id,at}) => {
|
|
1015
|
+
try { return readVersion(supervisor.userDir, id, at); }
|
|
1016
|
+
catch (e) { return {text:'', error:e.message}; }
|
|
1017
|
+
});
|
|
1018
|
+
ipcMain.handle('zero:standing-read', () => {
|
|
1019
|
+
try {
|
|
1020
|
+
return { text: supervisor.readStanding() };
|
|
1021
|
+
} catch (err) {
|
|
1022
|
+
return { text: '', error: err.message };
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
ipcMain.handle('zero:standing-write', (_e, { text }) => {
|
|
1027
|
+
try {
|
|
1028
|
+
supervisor.writeStanding(text);
|
|
1029
|
+
return { ok: true };
|
|
1030
|
+
} catch (err) {
|
|
1031
|
+
return { ok: false, error: err.message };
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
/* ---------------------------- the message rules -------------------------- */
|
|
1036
|
+
// How agents write to her: the titles and openings during a task and the last
|
|
1037
|
+
// message a run sends, in one document since w-3dc46f3a67. Shipped filled in,
|
|
1038
|
+
// hers to change or empty. The supervisor owns the file and the injection
|
|
1039
|
+
// (messageRulesFile, messageRules, spawnPlan); this is only the window. The
|
|
1040
|
+
// two channels it replaces were zero:writing-rules-* and zero:finishing-*.
|
|
1041
|
+
ipcMain.handle('zero:message-rules-read', () => {
|
|
1042
|
+
try {
|
|
1043
|
+
return { text: supervisor.readMessageRules() };
|
|
1044
|
+
} catch (err) {
|
|
1045
|
+
return { text: '', error: err.message };
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
ipcMain.handle('zero:message-rules-write', (_e, { text }) => {
|
|
1050
|
+
try {
|
|
1051
|
+
supervisor.writeMessageRules(text);
|
|
1052
|
+
return { ok: true };
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
return { ok: false, error: err.message };
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
/* -------------------------------- settings ------------------------------ */
|
|
1059
|
+
// The settings screen. Reads are derived on every open (nothing is cached on
|
|
1060
|
+
// either side of the bridge, same as the snapshot); writes land in whichever
|
|
1061
|
+
// system owns the value and push, so the inbox behind the screen updates with
|
|
1062
|
+
// it. Every handler answers `{ ok, error }` rather than throwing across the
|
|
1063
|
+
// bridge, because the one thing this screen must never do is leave her
|
|
1064
|
+
// believing a setting took when it did not.
|
|
1065
|
+
ipcMain.handle('zero:settings-read', () => {
|
|
1066
|
+
try {
|
|
1067
|
+
return { ok: true, ...readSettings({ config, supervisor, store }) };
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
return { ok: false, error: String(err.message) };
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
// CHECK AGAIN, from the last card of the walk. The card will not open the
|
|
1074
|
+
// inbox on a Mac with no Claude Code on it, so the way through it is to
|
|
1075
|
+
// install Claude Code and press this. It throws the remembered shell answer
|
|
1076
|
+
// away first, because a button that says it looked again and reads a thirty
|
|
1077
|
+
// second old memory is the same broken promise as a key that does nothing.
|
|
1078
|
+
ipcMain.handle('zero:claude-recheck', () => {
|
|
1079
|
+
try {
|
|
1080
|
+
return { ok: true, workspace: recheckClaude(config) };
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
return { ok: false, error: String(err.message) };
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
// AND THE SAME BUTTON FOR THE SECOND CODING AGENT, from the Codex card on
|
|
1087
|
+
// Settings. It earns its place for a sharper reason than the walk's: what the
|
|
1088
|
+
// search finds lands on `config.codexBin`, which is the fact `engineChoices`
|
|
1089
|
+
// reads, so a press that finds Codex is also what makes the Coding agent row
|
|
1090
|
+
// appear -- without the restart a main-process fact usually costs.
|
|
1091
|
+
ipcMain.handle('zero:codex-recheck', () => {
|
|
1092
|
+
try {
|
|
1093
|
+
return { ok: true, workspace: recheckCodex(config) };
|
|
1094
|
+
} catch (err) {
|
|
1095
|
+
return { ok: false, error: String(err.message) };
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
/* * ADD A CODEX ACCOUNT: make the folder, register the login, hand back the one line the
|
|
1100
|
+
terminal has to run.
|
|
1101
|
+
*/
|
|
1102
|
+
ipcMain.handle('zero:codex-add-account', () => {
|
|
1103
|
+
try {
|
|
1104
|
+
const made = addCodexAccount({ config, supervisor });
|
|
1105
|
+
pendingLogin = made.home;
|
|
1106
|
+
// The shell from a previous add is holding the previous folder, so it is
|
|
1107
|
+
// ended: a terminal that says one thing in its prompt and signs another
|
|
1108
|
+
// account in is the defect this whole row has been about.
|
|
1109
|
+
try { terminals.close(JSON.stringify(SETTINGS_TERMINAL)); } catch { /* none open */ }
|
|
1110
|
+
return { ok: true, ...made, workspace: readSettings({ config, supervisor, store }).workspace };
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
return { ok: false, error: String(err.message) };
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
/* * ADD A CLAUDE ACCOUNT, which is the same three steps as its Codex twin above:
|
|
1117
|
+
make the folder, register the login, hand back the one line the terminal has to
|
|
1118
|
+
run. It exists from 2026-09-22 (w-3498e0cad2), when she withdrew the rule that
|
|
1119
|
+
kept the Claude card from offering one.
|
|
1120
|
+
*/
|
|
1121
|
+
ipcMain.handle('zero:claude-add-account', () => {
|
|
1122
|
+
try {
|
|
1123
|
+
const made = addClaudeAccount({ config, supervisor });
|
|
1124
|
+
pendingLogin = made.home;
|
|
1125
|
+
try { terminals.close(JSON.stringify(SETTINGS_TERMINAL)); } catch { /* none open */ }
|
|
1126
|
+
return { ok: true, ...made, workspace: readSettings({ config, supervisor, store }).workspace };
|
|
1127
|
+
} catch (err) {
|
|
1128
|
+
return { ok: false, error: String(err.message) };
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
ipcMain.handle('zero:settings-set-project', (_e, payload) => {
|
|
1133
|
+
try {
|
|
1134
|
+
setProjectSetting({ config, supervisor }, payload ?? {});
|
|
1135
|
+
push();
|
|
1136
|
+
return { ok: true, ...readSettings({ config, supervisor, store }) };
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
return { ok: false, error: String(err.message) };
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
ipcMain.handle('zero:settings-set-workspace', (_e, payload) => {
|
|
1143
|
+
try {
|
|
1144
|
+
setWorkspaceSetting({ config, supervisor }, payload ?? {});
|
|
1145
|
+
push();
|
|
1146
|
+
return { ok: true, ...readSettings({ config, supervisor, store }) };
|
|
1147
|
+
} catch (err) {
|
|
1148
|
+
return { ok: false, error: String(err.message) };
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
|
|
1152
|
+
/* --------------------- what a project is called, and its mark ------------ */
|
|
1153
|
+
// Both of these write into the PROJECT'S OWN project.json rather than into her
|
|
1154
|
+
// config, and main/project-identity.mjs says at length why. They are separate
|
|
1155
|
+
// handlers rather than two more keys on settings-set-project because that one
|
|
1156
|
+
// is handed `{ config, supervisor }`, and neither of these is a preference
|
|
1157
|
+
// about a folder: they need the folder itself, which only the store resolves.
|
|
1158
|
+
//
|
|
1159
|
+
// THE FOLDER IS LOOKED UP HERE AND NOWHERE ELSE. A slug naming no product is
|
|
1160
|
+
// refused before anything touches the disk, so a stale sidebar row cannot aim
|
|
1161
|
+
// a write at a folder that has been deleted underneath it.
|
|
1162
|
+
const productDirOf = (slug) => store.listProducts().find((p) => p.slug === slug)?.dir ?? null;
|
|
1163
|
+
|
|
1164
|
+
ipcMain.handle('zero:project-rename', (_e, { product, name } = {}) => {
|
|
1165
|
+
try {
|
|
1166
|
+
const dir = productDirOf(product);
|
|
1167
|
+
if (!dir) return { ok: false, error: 'That project could not be found.' };
|
|
1168
|
+
const saved = setProjectName(dir, name);
|
|
1169
|
+
// PUSH, because the name is on far more than the screen she typed it on:
|
|
1170
|
+
// the rail, every row's byline, the composer's picker and ⌘K all print it,
|
|
1171
|
+
// and a rename that only redrew Settings would leave the rest of the app
|
|
1172
|
+
// calling it the old thing until something else happened to refresh.
|
|
1173
|
+
push();
|
|
1174
|
+
return { ok: true, name: saved, ...readSettings({ config, supervisor, store }) };
|
|
1175
|
+
} catch (err) {
|
|
1176
|
+
return { ok: false, error: String(err.message) };
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
// Opens the picker AND does the copy, in one round trip. Two handlers would
|
|
1181
|
+
// mean the renderer briefly holds a path into her Downloads folder and hands
|
|
1182
|
+
// it back to be trusted; this way the only path that crosses the bridge is one
|
|
1183
|
+
// the open panel itself produced inside this same call.
|
|
1184
|
+
ipcMain.handle('zero:project-icon', async (_e, { product } = {}) => {
|
|
1185
|
+
try {
|
|
1186
|
+
const dir = productDirOf(product);
|
|
1187
|
+
if (!dir) return { ok: false, error: 'That project could not be found.' };
|
|
1188
|
+
const { dialog } = await import('electron');
|
|
1189
|
+
const r = await dialog.showOpenDialog(window, {
|
|
1190
|
+
title: 'Pick a picture for this project',
|
|
1191
|
+
buttonLabel: 'Use this picture',
|
|
1192
|
+
properties: ['openFile'],
|
|
1193
|
+
filters: [{ name: 'Pictures', extensions: ICON_KINDS.map((e) => e.slice(1)) }],
|
|
1194
|
+
});
|
|
1195
|
+
// Cancelling is not a failure and must not draw an error. `ok` with nothing
|
|
1196
|
+
// changed is the honest answer: she opened the panel and closed it again.
|
|
1197
|
+
if (r.canceled || !r.filePaths?.length) return { ok: true, canceled: true };
|
|
1198
|
+
setProjectIcon(dir, r.filePaths[0]);
|
|
1199
|
+
push();
|
|
1200
|
+
return { ok: true, ...readSettings({ config, supervisor, store }) };
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
return { ok: false, error: String(err.message) };
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
ipcMain.handle('zero:project-icon-clear', (_e, { product } = {}) => {
|
|
1207
|
+
try {
|
|
1208
|
+
const dir = productDirOf(product);
|
|
1209
|
+
if (!dir) return { ok: false, error: 'That project could not be found.' };
|
|
1210
|
+
clearProjectIcon(dir);
|
|
1211
|
+
push();
|
|
1212
|
+
return { ok: true, ...readSettings({ config, supervisor, store }) };
|
|
1213
|
+
} catch (err) {
|
|
1214
|
+
return { ok: false, error: String(err.message) };
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
// Her rules for ONE project, the layer that did not exist. The supervisor
|
|
1219
|
+
// owns the file and the injection (projectInstructionsFile, spawnPlan); this
|
|
1220
|
+
// is only the window onto it, exactly as standing-read/write is.
|
|
1221
|
+
ipcMain.handle('zero:project-instructions-read', (_e, { product }) => {
|
|
1222
|
+
try {
|
|
1223
|
+
return { ok: true, text: supervisor.readProjectInstructions(product) };
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
return { ok: false, text: '', error: String(err.message) };
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
ipcMain.handle('zero:project-instructions-write', (_e, { product, text }) => {
|
|
1230
|
+
try {
|
|
1231
|
+
supervisor.writeProjectInstructions(product, text);
|
|
1232
|
+
return { ok: true };
|
|
1233
|
+
} catch (err) {
|
|
1234
|
+
return { ok: false, error: String(err.message) };
|
|
1235
|
+
}
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
// Attachments: pasted images (base64) or dropped files (source paths) land
|
|
1239
|
+
// in the product's attachments/ dir and come back as relative paths the
|
|
1240
|
+
// caller embeds in the body as markdown. Relative paths keep them readable
|
|
1241
|
+
// by workers through the MCP and renderable in the message view.
|
|
1242
|
+
ipcMain.handle('zero:save-attachments', async (_e, { product, files }) => {
|
|
1243
|
+
const fs = await import('node:fs');
|
|
1244
|
+
const dir = store.productDir(product);
|
|
1245
|
+
const outDir = path.join(dir, 'attachments');
|
|
1246
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
1247
|
+
const saved = [];
|
|
1248
|
+
for (const file of files ?? []) {
|
|
1249
|
+
const safe = String(file.name ?? 'file').replace(/[^\w.-]+/g, '-').slice(0, 80);
|
|
1250
|
+
// A RANDOM SUFFIX, THE SAME ONE THE STAGING PATH ALREADY USES. The name
|
|
1251
|
+
// was the millisecond plus her filename, so two files saved in the same
|
|
1252
|
+
// millisecond with the same name overwrote each other and the message
|
|
1253
|
+
// ended up with two links to one picture. She sends screenshots in pairs,
|
|
1254
|
+
// which is exactly the shape that collides. It has never been observed
|
|
1255
|
+
// happening; it is one line to make impossible.
|
|
1256
|
+
const rel = `attachments/${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${safe}`;
|
|
1257
|
+
const abs = path.join(dir, rel);
|
|
1258
|
+
if (file.dataBase64) fs.writeFileSync(abs, Buffer.from(file.dataBase64, 'base64'));
|
|
1259
|
+
else if (file.srcPath) fs.copyFileSync(file.srcPath, abs);
|
|
1260
|
+
else continue;
|
|
1261
|
+
saved.push({ rel, name: safe, image: /\.(png|jpe?g|gif|webp)$/i.test(safe) });
|
|
1262
|
+
}
|
|
1263
|
+
return saved;
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
// A PASTED SCREENSHOT GOES TO DISK THE MOMENT IT IS PASTED.
|
|
1267
|
+
//
|
|
1268
|
+
// She was right and the constraint was ours. Pasted bytes used to ride in the
|
|
1269
|
+
// draft as base64, and a draft lives in localStorage, whose quota MEASURED on
|
|
1270
|
+
// this machine is 5,241,856 characters for the WHOLE app. Seven of the
|
|
1271
|
+
// ninety-one screenshots she has actually pasted are over that; the largest
|
|
1272
|
+
// is 6.08 MB.
|
|
1273
|
+
//
|
|
1274
|
+
// Raising the number cannot reach her bar: even the whole quota is under 4 MB
|
|
1275
|
+
// of image, and Claude's own limit is 10 MB base64 per image (about 7.5 MB of
|
|
1276
|
+
// file) at up to 8000x8000. localStorage is simply the wrong place for image
|
|
1277
|
+
// bytes. So they go here instead, and the draft keeps a path — which the draft
|
|
1278
|
+
// format ALREADY stores for free, because `srcPath` attachments cost nothing
|
|
1279
|
+
// and are never the ones dropped. There is no image budget left to hit.
|
|
1280
|
+
//
|
|
1281
|
+
// Staged, not saved: this is not the product's attachments dir. A pasted image
|
|
1282
|
+
// only becomes an attachment when she sends, and `zero:save-attachments`
|
|
1283
|
+
// copies it there. What she pastes and abandons is swept below.
|
|
1284
|
+
//
|
|
1285
|
+
// A PICTURE SHE DRAGS IN IS STAGED TOO, AND THAT IS `srcPath`.
|
|
1286
|
+
//
|
|
1287
|
+
// with a shot of the reply box holding a broken thumbnail labelled
|
|
1288
|
+
// `fun-mode.png`. A paste was already fine; a DRAG was not, and the two take
|
|
1289
|
+
// different routes. A dropped file arrives with a real path from
|
|
1290
|
+
// `webUtils.getPathForFile`, so nothing was ever copied and the thumbnail
|
|
1291
|
+
// asked for `astral-img://file/Users/you/…` somewhere out on the disk.
|
|
1292
|
+
// `servable` in main/img-scheme.mjs answers that with a 403, on purpose: the
|
|
1293
|
+
// window may only draw pictures inside the account root or a product's repo,
|
|
1294
|
+
// and widening that to the whole disk is the trade that scheme exists to
|
|
1295
|
+
// refuse. Measured against the real module, her account root as the only
|
|
1296
|
+
// root:
|
|
1297
|
+
//
|
|
1298
|
+
// /…/00000000-/.staging/…-pasted-33035.png servable true
|
|
1299
|
+
// /Users/you/Desktop/fun-mode.png servable false
|
|
1300
|
+
//
|
|
1301
|
+
// So the picture comes inside instead. A copy into the same `.staging` folder
|
|
1302
|
+
// a paste lands in makes the path servable, gets swept on the same week, and
|
|
1303
|
+
// costs nothing at send time because `zero:save-attachments` above already
|
|
1304
|
+
// copies from `srcPath`. Bytes are copied on disk rather than carried through
|
|
1305
|
+
// this bridge as base64, which a file picked off disk should never need.
|
|
1306
|
+
ipcMain.handle('zero:stage-attachment', async (_e, { name, dataBase64, srcPath }) => {
|
|
1307
|
+
const fs = await import('node:fs');
|
|
1308
|
+
const dir = path.join(config.accountRoot, '.staging');
|
|
1309
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1310
|
+
// Sweep on the way in rather than on a timer: an abandoned paste is only
|
|
1311
|
+
// ever discovered by the next one, and a week is longer than any draft she
|
|
1312
|
+
// has ever left open. Failure to sweep must never fail the paste.
|
|
1313
|
+
try {
|
|
1314
|
+
const week = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
1315
|
+
for (const f of fs.readdirSync(dir)) {
|
|
1316
|
+
const p = path.join(dir, f);
|
|
1317
|
+
try { if (fs.statSync(p).mtimeMs < week) fs.unlinkSync(p); } catch { /* leave it */ }
|
|
1318
|
+
}
|
|
1319
|
+
} catch { /* a sweep that cannot run is not a paste that failed */ }
|
|
1320
|
+
const safe = String(name ?? 'pasted.png').replace(/[^\w.-]+/g, '-').slice(0, 80);
|
|
1321
|
+
const abs = path.join(dir, `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-${safe}`);
|
|
1322
|
+
// A path is a drag, bytes are a paste. Already inside the folder we serve
|
|
1323
|
+
// from is left alone: copying it would only make a second file to sweep.
|
|
1324
|
+
if (typeof srcPath === 'string' && srcPath) {
|
|
1325
|
+
if (path.resolve(srcPath) === path.resolve(path.join(dir, path.basename(srcPath)))) return { path: srcPath };
|
|
1326
|
+
fs.copyFileSync(srcPath, abs);
|
|
1327
|
+
return { path: abs };
|
|
1328
|
+
}
|
|
1329
|
+
fs.writeFileSync(abs, Buffer.from(String(dataBase64 ?? ''), 'base64'));
|
|
1330
|
+
return { path: abs };
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
// Unstick ONE stalled item: forget its answer's delivery mark and tick, so
|
|
1334
|
+
// a fresh worker picks the answer up now.
|
|
1335
|
+
ipcMain.handle('zero:redeliver', (_e, { product, id }) => {
|
|
1336
|
+
if (isAgentRow(id)) return { ok: false };
|
|
1337
|
+
const item = store.listItems().find((i) => i.product === product && i.id === id);
|
|
1338
|
+
if (!item?.answer || item.answer === '(withdrawn)') return { ok: false, error: 'no live answer to redeliver' };
|
|
1339
|
+
supervisor.liftBrakeForHer?.();
|
|
1340
|
+
supervisor.redeliverAnswer(item, item.answer);
|
|
1341
|
+
supervisor.tick?.().catch(() => {});
|
|
1342
|
+
return { ok: true };
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
// Respawn what a mass die-off (usage limit, crash) left stranded. With `ids`,
|
|
1346
|
+
// only those: a selection in the list is her saying which of the stopped rows
|
|
1347
|
+
// are still worth her plan.
|
|
1348
|
+
ipcMain.handle('zero:resume-agents', (_e, arg) => {
|
|
1349
|
+
const ids = Array.isArray(arg?.ids) ? arg.ids : null;
|
|
1350
|
+
const cleared = supervisor.resumeStopped(ids);
|
|
1351
|
+
return { ok: true, cleared, ...supervisor.status() };
|
|
1352
|
+
});
|
|
1353
|
+
|
|
1354
|
+
// Put a worker back on named rows whatever state they are in, including the
|
|
1355
|
+
// finished ones no other path can reach. Ids only: this is her pointing.
|
|
1356
|
+
ipcMain.handle('zero:resume-items', (_e, arg) => {
|
|
1357
|
+
const ids = Array.isArray(arg?.ids) ? arg.ids : [];
|
|
1358
|
+
return { ok: true, ...supervisor.resumeItems(ids), ...supervisor.status() };
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
// `zero:pause-product` stopped one project and is gone with it
|
|
1362
|
+
// (w-d19d6d387c, 2026-09-22). `zero:pause` still holds the whole fleet.
|
|
1363
|
+
|
|
1364
|
+
// Open a worker-written artifact path. Workers write paths relative to
|
|
1365
|
+
// whichever root they were thinking about (docs dir, designs/, attachments/,
|
|
1366
|
+
// the repo), so a single-root file:// href fails silently on the rest.
|
|
1367
|
+
// Images already resolve across these roots to render; links open the same
|
|
1368
|
+
// way. Absolute and file:// paths open as-is if they exist.
|
|
1369
|
+
// mode 'resolve' returns the found path without opening it, so the renderer
|
|
1370
|
+
// can render hrefs that are true (Copy Link must hand out a URL that works).
|
|
1371
|
+
//
|
|
1372
|
+
// EVERY GUESS STAYS INSIDE THE CARD'S OWN PRODUCT (main/artifact-path.mjs).
|
|
1373
|
+
// It used to fall through to every other product's folders, which is how nine
|
|
1374
|
+
// of eleven chips on one card opened two other products' files (her answer,
|
|
1375
|
+
// 2026-08-19). When it finds nothing it now hands back a sentence she can
|
|
1376
|
+
// read, and the renderer shows it.
|
|
1377
|
+
ipcMain.handle('zero:open-artifact', async (_e, { product, src, mode }) => {
|
|
1378
|
+
const { shell } = await import('electron');
|
|
1379
|
+
if (typeof src !== 'string' || !src) return { ok: false, error: 'no path' };
|
|
1380
|
+
if (/^https?:/.test(src)) { if (mode !== 'resolve') shell.openExternal(src); return { ok: true, opened: src } }
|
|
1381
|
+
const found = resolveArtifact({
|
|
1382
|
+
src,
|
|
1383
|
+
product,
|
|
1384
|
+
products: store.listProducts(),
|
|
1385
|
+
accountRoot: config.accountRoot,
|
|
1386
|
+
storeRoot: config.storeRoot,
|
|
1387
|
+
});
|
|
1388
|
+
if (!found.ok) return { ok: false, error: found.error };
|
|
1389
|
+
if (mode === 'resolve') {
|
|
1390
|
+
// RESOLVING FOR THE PANE IS WHAT OPENS THE DOOR. An html page is drawn
|
|
1391
|
+
// in a frame of its own and its pictures sit beside it on disk, so the
|
|
1392
|
+
// file's folder and this product's roots become readable under the
|
|
1393
|
+
// document scheme, and the url the frame should point at comes back with
|
|
1394
|
+
// the path. Nothing else on disk is reachable from that page. A markdown
|
|
1395
|
+
// file is read through zero:read-doc and never needs this.
|
|
1396
|
+
if (docGrants && /\.html?$/i.test(found.path)) {
|
|
1397
|
+
const prod = store.listProducts().find((p) => p.slug === product) ?? null;
|
|
1398
|
+
docGrants.grant(found.path, artifactRoots(prod));
|
|
1399
|
+
return { ok: true, opened: found.path, url: docUrl(found.path) };
|
|
1400
|
+
}
|
|
1401
|
+
return { ok: true, opened: found.path };
|
|
1402
|
+
}
|
|
1403
|
+
if (mode === 'source') {
|
|
1404
|
+
try { await openSourceFile(found.path); return { ok: true, opened: found.path }; }
|
|
1405
|
+
catch (error) { return { ok: false, error: error.message || 'The text editor could not be opened.' }; }
|
|
1406
|
+
}
|
|
1407
|
+
const err = await shell.openPath(found.path);
|
|
1408
|
+
return err ? { ok: false, error: err } : { ok: true, opened: found.path };
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
// THE DOCUMENT PANE READS AND WRITES THE FILE ITSELF. A path goes through
|
|
1412
|
+
// the very same finder the links and the chips use, so the pane opens
|
|
1413
|
+
// exactly the file a click would have opened, and then main hands back the
|
|
1414
|
+
// text rather than the path. What may be written is decided in
|
|
1415
|
+
// main/doc-file.mjs: markdown only, inside this product's own folders only.
|
|
1416
|
+
ipcMain.handle('zero:read-doc', async (_e, { product, src }) => {
|
|
1417
|
+
const found = resolveArtifact({
|
|
1418
|
+
src,
|
|
1419
|
+
product,
|
|
1420
|
+
products: store.listProducts(),
|
|
1421
|
+
accountRoot: config.accountRoot,
|
|
1422
|
+
storeRoot: config.storeRoot,
|
|
1423
|
+
});
|
|
1424
|
+
if (!found.ok) return { ok: false, error: found.error };
|
|
1425
|
+
return readDoc({ file: found.path });
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
// THE CHANGE A RUN MADE. Same finder as every other artifact, so a click on
|
|
1429
|
+
// the chip and a click on a path in her message open the same file.
|
|
1430
|
+
ipcMain.handle('zero:code-change', async (_e, { product, src }) => {
|
|
1431
|
+
const found = resolveArtifact({
|
|
1432
|
+
src,
|
|
1433
|
+
product,
|
|
1434
|
+
products: store.listProducts(),
|
|
1435
|
+
accountRoot: config.accountRoot,
|
|
1436
|
+
storeRoot: config.storeRoot,
|
|
1437
|
+
});
|
|
1438
|
+
if (!found.ok) return { ok: false, error: found.error };
|
|
1439
|
+
try {
|
|
1440
|
+
return { ok: true, change: readChange(found.path) };
|
|
1441
|
+
} catch (e) {
|
|
1442
|
+
return { ok: false, error: `that change could not be read (${e.message})` };
|
|
1443
|
+
}
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
// ONE FILE INSIDE A CHANGE, READ AND WRITTEN.
|
|
1447
|
+
//
|
|
1448
|
+
// `src` is the change artifact and `path` is one file inside it, which is the
|
|
1449
|
+
// same pair the pane already has on screen. The change is re-read here rather
|
|
1450
|
+
// than trusted from the renderer, because it is what says which folders a
|
|
1451
|
+
// write may land in and that decision may not come from the window.
|
|
1452
|
+
const openChange = (product, src) => {
|
|
1453
|
+
const found = resolveArtifact({
|
|
1454
|
+
src,
|
|
1455
|
+
product,
|
|
1456
|
+
products: store.listProducts(),
|
|
1457
|
+
accountRoot: config.accountRoot,
|
|
1458
|
+
storeRoot: config.storeRoot,
|
|
1459
|
+
});
|
|
1460
|
+
if (!found.ok) return { ok: false, error: found.error };
|
|
1461
|
+
try {
|
|
1462
|
+
return { ok: true, change: readChange(found.path), prod: store.listProducts().find((p) => p.slug === product) ?? null };
|
|
1463
|
+
} catch (e) {
|
|
1464
|
+
return { ok: false, error: `that change could not be read (${e.message})` };
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
|
|
1468
|
+
ipcMain.handle('zero:code-file', async (_e, { product, src, path: filePath }) => {
|
|
1469
|
+
const opened = openChange(product, src);
|
|
1470
|
+
if (!opened.ok) return opened;
|
|
1471
|
+
const found = findFile({ change: opened.change, product: opened.prod, filePath });
|
|
1472
|
+
if (!found.ok) return found;
|
|
1473
|
+
return readCodeFile({ file: found.path });
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
ipcMain.handle('zero:save-code-file', async (_e, { product, src, path: filePath, text, mtime }) => {
|
|
1477
|
+
const opened = openChange(product, src);
|
|
1478
|
+
if (!opened.ok) return opened;
|
|
1479
|
+
const found = findFile({ change: opened.change, product: opened.prod, filePath });
|
|
1480
|
+
if (!found.ok) return found;
|
|
1481
|
+
return writeCodeFile({ file: found.path, change: opened.change, product: opened.prod, text, mtime });
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
ipcMain.handle('zero:write-doc', async (_e, { product, src, text, mtime }) => {
|
|
1485
|
+
const prod = store.listProducts().find((p) => p.slug === product) ?? null;
|
|
1486
|
+
const found = resolveArtifact({
|
|
1487
|
+
src,
|
|
1488
|
+
product,
|
|
1489
|
+
products: store.listProducts(),
|
|
1490
|
+
accountRoot: config.accountRoot,
|
|
1491
|
+
storeRoot: config.storeRoot,
|
|
1492
|
+
});
|
|
1493
|
+
if (!found.ok) return { ok: false, error: found.error };
|
|
1494
|
+
return writeDoc({ file: found.path, product: prod, text, mtime });
|
|
1495
|
+
});
|
|
1496
|
+
|
|
1497
|
+
// The work behind a review, viewable: commits and diff of a branch against
|
|
1498
|
+
// main in the product's repo. Read-only, size-capped. THE SIDEBAR NOTE. It
|
|
1499
|
+
// is a file in the product's own folder and nothing else, so unlike the
|
|
1500
|
+
// document pane there is no path to resolve and no path to check: the slug
|
|
1501
|
+
// picks the product, the product owns the path. See main/rail-note.mjs for
|
|
1502
|
+
// why it is a file at all.
|
|
1503
|
+
ipcMain.handle('zero:rail-note', (_e, { product }) => {
|
|
1504
|
+
const prod = store.listProducts().find((p) => p.slug === product) ?? null;
|
|
1505
|
+
if (!prod) return { ok: false, error: 'That project could not be found.' };
|
|
1506
|
+
return readNote({ product: prod });
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
ipcMain.handle('zero:save-rail-note', (_e, { product, text }) => {
|
|
1510
|
+
const prod = store.listProducts().find((p) => p.slug === product) ?? null;
|
|
1511
|
+
if (!prod) return { ok: false, error: 'That project could not be found.' };
|
|
1512
|
+
return writeNote({ product: prod, text });
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
// The trace: what each session on this item actually did, from the logs the
|
|
1516
|
+
// supervisor persists under <product>/sessions/<item-id>/. Read-only.
|
|
1517
|
+
ipcMain.handle('zero:session-trace', async (_e, { product, id }) => {
|
|
1518
|
+
const fs = await import('node:fs');
|
|
1519
|
+
const path = await import('node:path');
|
|
1520
|
+
const dir = store.listProducts().find((p) => p.slug === product)?.dir;
|
|
1521
|
+
if (!dir || !/^[\w-]+$/.test(String(id ?? ''))) return { ok: false, sessions: [] };
|
|
1522
|
+
const traceDir = machineryPath(dir, path.join('sessions', id));
|
|
1523
|
+
let files = [];
|
|
1524
|
+
try {
|
|
1525
|
+
files = fs.readdirSync(traceDir).filter((f) => f.endsWith('.log')).sort();
|
|
1526
|
+
} catch { return { ok: true, sessions: [] }; }
|
|
1527
|
+
const sessions = files.slice(-20).map((f) => {
|
|
1528
|
+
let text = '';
|
|
1529
|
+
try { text = fs.readFileSync(path.join(traceDir, f), 'utf8'); } catch {}
|
|
1530
|
+
if (text.length > 120_000) text = `… truncated (${text.length} bytes total)\n\n${text.slice(-120_000)}`;
|
|
1531
|
+
return { startedAt: Number(f.replace('.log', '')) || 0, text };
|
|
1532
|
+
});
|
|
1533
|
+
return { ok: true, sessions };
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
// The thread history behind the time under a task's title. Raw ledger lines,
|
|
1537
|
+
// read fresh: the renderer owns what they mean, this owns getting them across.
|
|
1538
|
+
ipcMain.handle('zero:item-history', async (_e, { product, id }) => {
|
|
1539
|
+
try {
|
|
1540
|
+
return { ok: true, lines: store.readHistory(product, id) };
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
return { ok: false, error: err.message, lines: [] };
|
|
1543
|
+
}
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
// UPDATES (main/updater.mjs). Two doors and no more: look now, and install
|
|
1547
|
+
// what has been downloaded. There is deliberately no "download" door, because
|
|
1548
|
+
// the download already happened on its own; the only thing left for a person
|
|
1549
|
+
// to decide is when the app is allowed to disappear and come back.
|
|
1550
|
+
ipcMain.handle('zero:update-check', async () => updater.check({ manual: true }));
|
|
1551
|
+
ipcMain.handle('zero:update-install', () => ({ started: updater.install() }));
|
|
1552
|
+
|
|
1553
|
+
// Handed back so the Cmd+Y/Cmd+N chord answers through the same door the
|
|
1554
|
+
// buttons do, rather than reaching past it into approvals.answer.
|
|
1555
|
+
// `push` goes with it so the updater can make the window refetch when a
|
|
1556
|
+
// download finishes, without holding the window itself.
|
|
1557
|
+
return { answerApproval, push };
|
|
1558
|
+
}
|