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/config.mjs
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// the app's config: one JSON file, all paths explicit, no discovery magic.
|
|
2
|
+
//
|
|
3
|
+
// The store's modules used to be another system's, loaded out of a private repo
|
|
4
|
+
// by absolute path, and this file was where that coupling was declared. They are
|
|
5
|
+
// Agentbox's own now (main/store/*), so the only outside thing left here is the
|
|
6
|
+
// claude binary.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { resolveClaudeBin, candidatePaths } from './claude-bin.mjs';
|
|
13
|
+
import { resolveCodexBin } from './codex-bin.mjs';
|
|
14
|
+
import { Name } from '../shared/product-name.mjs';
|
|
15
|
+
import { readPlans, slotsForPlans } from './claude-plan.mjs';
|
|
16
|
+
import { machineSlots } from './machine.mjs';
|
|
17
|
+
import { effectiveProfiles } from './account-discovery.mjs';
|
|
18
|
+
import { runningAsAFreshUser } from '../shared/fresh-user-home.mjs';
|
|
19
|
+
import { runningAsADemo } from '../shared/demo-world.mjs';
|
|
20
|
+
|
|
21
|
+
const CONFIG_NAME = 'zero.config.json';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* HOW MANY SESSIONS ONE ACCOUNT RUNS WHEN NOBODY HAS SAID ANYTHING AT ALL.
|
|
25
|
+
*
|
|
26
|
+
* Exported because two places need the same number and a second copy of it is a
|
|
27
|
+
* number that drifts: `DEFAULTS` below, and `Supervisor#_slotsPerAccount`, which
|
|
28
|
+
* reaches for it as CODEX's cap when the number on the config is one a CLAUDE
|
|
29
|
+
* plan lowered. A Claude plan's rate limit says nothing about an OpenAI
|
|
30
|
+
* subscription, and there is no plan tier for Codex anywhere to read, so the
|
|
31
|
+
* honest answer for Codex is the one every engine gets when nobody has said --
|
|
32
|
+
* this, rather than a Codex-shaped number nobody measured.
|
|
33
|
+
*/
|
|
34
|
+
export const DEFAULT_SESSIONS_AT_ONCE = 3;
|
|
35
|
+
|
|
36
|
+
const DEFAULTS = {
|
|
37
|
+
// Where the inbox's own files live. WHICH FOLDER THIS SHOULD BE ON A
|
|
38
|
+
// STRANGER'S MAC IS NOT DECIDED HERE: it is the open question on.
|
|
39
|
+
//
|
|
40
|
+
// IT IS THE APP'S OWN NAME SINCE 2026-09-22, where it used to be an older
|
|
41
|
+
// product's. A real store is always named in the config, so this default is
|
|
42
|
+
// only ever the answer for an install carrying none, and renaming it moved
|
|
43
|
+
// no files: the folder it used to point at is untouched and still on disk.
|
|
44
|
+
storeRoot: path.join(os.homedir(), Name),
|
|
45
|
+
// ONE ACCOUNT FOLDER PER INSTALL, AND NOBODY ELSE'S.
|
|
46
|
+
//
|
|
47
|
+
// This was a literal id for most of the app's life, which meant every copy
|
|
48
|
+
// ever downloaded filed its work under a folder named after the machine the
|
|
49
|
+
// app was written on. `null` is the honest default: `loadConfig` mints a
|
|
50
|
+
// fresh one on first run and writes it into the config, so an id exists from
|
|
51
|
+
// then on and is this install's own. An id already in the config always wins,
|
|
52
|
+
// so nothing that is already running moves.
|
|
53
|
+
accountId: null,
|
|
54
|
+
// The brain. Sessions run headless on the founder's subscription. Left unset
|
|
55
|
+
// on purpose: where Claude Code lives depends on how it was installed, so the
|
|
56
|
+
// answer is searched for at load (main/claude-bin.mjs) rather than assumed.
|
|
57
|
+
// Setting it here in zero.config.json still wins over the search.
|
|
58
|
+
claudeBin: null,
|
|
59
|
+
// How many sessions run at once. The queue absorbs the rest; when the plan
|
|
60
|
+
// window caps out, a longer queue is the visible, benign failure mode.
|
|
61
|
+
//
|
|
62
|
+
// THIS IS THE NUMBER FOR A MAX SUBSCRIPTION, which is the only kind this was
|
|
63
|
+
// ever chosen on. When nobody has set one, loadConfig below reads the plan
|
|
64
|
+
// Claude Code is signed in on and lowers this for a smaller one.
|
|
65
|
+
maxConcurrentSessions: DEFAULT_SESSIONS_AT_ONCE,
|
|
66
|
+
// What spawned sessions are allowed to do. Left unset on purpose, because the
|
|
67
|
+
// right default now depends on whether a store MCP server is present: see
|
|
68
|
+
// Supervisor#sessionArgsFor. Setting it here still wins over that, which is
|
|
69
|
+
// how letting workers build stays the founder's explicit opt-in.
|
|
70
|
+
sessionArgs: null,
|
|
71
|
+
// An OPTIONAL stdio MCP server giving spawned workers tools to read and write
|
|
72
|
+
// the store. Agentbox ships none, so this is null and a worker runs with no store
|
|
73
|
+
// tools at all. Point it at an executable to give workers their hands back.
|
|
74
|
+
storeMcpCommand: null,
|
|
75
|
+
// Products where agent-filed TASK items spawn without waiting for the
|
|
76
|
+
// founder's approval (questions and reviews still hold for them). Empty by
|
|
77
|
+
// default: autonomy here is granted per product, deliberately, in the
|
|
78
|
+
// config file, the same way sessionArgs is.
|
|
79
|
+
autonomousProducts: [],
|
|
80
|
+
// PERSONAL PROJECTS AND THE DRIVE WERE BOTH DECLARED HERE AND BOTH ARE GONE
|
|
81
|
+
// (w-d19d6d387c, 2026-09-22). A personal project ran its sessions on the
|
|
82
|
+
// founder's message and nothing else, with no brief and no store; the drive
|
|
83
|
+
// asked "what next?" by itself on a project that had gone quiet. Hers, on
|
|
84
|
+
// the same day: "I would get rid of 'personal' as well. I think there is no
|
|
85
|
+
// case where we should ever have that", and "we should get rid of the code
|
|
86
|
+
// that self-drives the product entirely. That should never have existed
|
|
87
|
+
// anyway, especially because we're open sourcing it soon."
|
|
88
|
+
//
|
|
89
|
+
// A config still carrying `personalProducts` or `driveEnabled` is not an
|
|
90
|
+
// error; nothing reads either key now.
|
|
91
|
+
// Products the app drives: slugs under the account root. Empty means all
|
|
92
|
+
// non-archived products found on disk.
|
|
93
|
+
products: [],
|
|
94
|
+
// WHETHER THE SECOND ENGINE MAY BE CHOSEN AT ALL, AND FROM WHEN. Null, so it
|
|
95
|
+
// may not, and that is the whole of the default: every row runs Claude Code,
|
|
96
|
+
// including one she marked `engine: "codex"` in August, exactly as it did
|
|
97
|
+
// before this key existed. There is no Settings row and no picker. This is a
|
|
98
|
+
// line somebody writes in zero.config.json on purpose, which is the point of
|
|
99
|
+
// it -- it is the config half of the capability gate in shared/engines.mjs,
|
|
100
|
+
// and `main/supervisor.mjs#_engineFor` is the one place that reads it and
|
|
101
|
+
// hands the gate's token over.
|
|
102
|
+
//
|
|
103
|
+
// IT IS THE MOMENT SHE OPENED IT AND NOT A `true`. The reasoning is in
|
|
104
|
+
// shared/engines.mjs, beside the function that parses this key, and is not
|
|
105
|
+
// repeated here; in one sentence, `engine` survived the 2026-08-27 removal on
|
|
106
|
+
// the work-item contract, so her ledger may still carry rows marked `codex`
|
|
107
|
+
// from 08-25 to 08-27, and a boolean would put every one of them onto a
|
|
108
|
+
// second engine the instant it was written, with no picker, no byline naming
|
|
109
|
+
// the engine and no warning. A moment means "Codex may be chosen from now on"
|
|
110
|
+
// instead.
|
|
111
|
+
//
|
|
112
|
+
// Written as an ISO date or date-time: "2026-09-04", or
|
|
113
|
+
// "2026-09-04T09:30:00Z". Anything else -- true, "yes", "1", or a shape
|
|
114
|
+
// `Date.parse` would merely guess at -- is read as off.
|
|
115
|
+
engineChoice: null,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `home` is the machine's home folder and exists so a test can be run against
|
|
120
|
+
* a laid-out one rather than against whoever is logged in. Nothing in the app
|
|
121
|
+
* passes it. */
|
|
122
|
+
export function loadConfig(appDir, { home = os.homedir() } = {}) {
|
|
123
|
+
const file = path.join(appDir, CONFIG_NAME);
|
|
124
|
+
let overrides = {};
|
|
125
|
+
// WHETHER THIS MAC HAS EVER RUN AGENTBOX BEFORE. There is exactly one honest
|
|
126
|
+
// marker of that and this is it: no config file at all. A file that exists
|
|
127
|
+
// but happens not to mention a setting is somebody who has been here for
|
|
128
|
+
// weeks, and treating them as new is how a default silently changes under
|
|
129
|
+
// an install that was working.
|
|
130
|
+
//
|
|
131
|
+
// NOTHING DECIDES A DEFAULT ON THIS ANY MORE. It used to pick the
|
|
132
|
+
// `outsideAgents` default, and that split was the bug: the same machine got
|
|
133
|
+
// two different answers about the same setting depending on whether a file
|
|
134
|
+
// happened to exist. There is one default now, in main/settings.mjs. This
|
|
135
|
+
// stays as a plain fact about the machine, read by nobody today, because the
|
|
136
|
+
// next thing that wants it will want it honest.
|
|
137
|
+
let fresh = false;
|
|
138
|
+
try {
|
|
139
|
+
overrides = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
140
|
+
} catch (err) {
|
|
141
|
+
if (err.code === 'ENOENT') fresh = true;
|
|
142
|
+
else console.warn(`zero: could not read ${file}: ${err.message}`);
|
|
143
|
+
}
|
|
144
|
+
const config = { ...DEFAULTS, ...overrides, freshInstall: fresh };
|
|
145
|
+
// An older alias for `storeRoot` was read here until 2026-09-22. A config
|
|
146
|
+
// still carrying only the old key falls through to the default now, which is
|
|
147
|
+
// why the key was renamed in zero.config.json in the same step and not after
|
|
148
|
+
// it: the two are one move, and doing them apart points the app at an empty
|
|
149
|
+
// folder.
|
|
150
|
+
// The account id, minted once per install. Written back immediately, because
|
|
151
|
+
// a generated id that is not saved is a different id next launch and the
|
|
152
|
+
// store would move out from under everything.
|
|
153
|
+
if (typeof config.accountId !== 'string' || !config.accountId) {
|
|
154
|
+
config.accountId = crypto.randomUUID();
|
|
155
|
+
try {
|
|
156
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
157
|
+
fs.writeFileSync(file, `${JSON.stringify({ ...overrides, accountId: config.accountId }, null, 2)}\n`);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.warn(`zero: could not write the new account id to ${file}: ${err.message}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
config.accountRoot = path.join(config.storeRoot, 'accounts', config.accountId);
|
|
163
|
+
// Where Claude Code is on THIS machine. Her setting wins; otherwise we look
|
|
164
|
+
// (main/claude-bin.mjs). What she SET is kept separately from what we FOUND,
|
|
165
|
+
// because `claudeBin` ends up filled in either way, and a later search that
|
|
166
|
+
// reads its own last answer back as a setting would never search again.
|
|
167
|
+
//
|
|
168
|
+
// When nothing is found at all, `claudeBin` falls back to the installer's
|
|
169
|
+
// path, so anything that tries to spawn it fails the way it always did, with
|
|
170
|
+
// a real path in the message, rather than on a null. `claudeFound` is the
|
|
171
|
+
// honest answer to whether it is there, and it is carried here so no screen
|
|
172
|
+
// has to re-derive it.
|
|
173
|
+
config.claudeBinConfigured = typeof overrides.claudeBin === 'string' && overrides.claudeBin
|
|
174
|
+
? overrides.claudeBin
|
|
175
|
+
: null;
|
|
176
|
+
const claude = resolveClaudeBin(config.claudeBinConfigured);
|
|
177
|
+
config.claudeBin = claude.path ?? candidatePaths()[0];
|
|
178
|
+
config.claudeFound = claude.found;
|
|
179
|
+
config.claudeCertain = claude.certain !== false;
|
|
180
|
+
config.claudeFrom = claude.from;
|
|
181
|
+
// AND WHERE CODEX IS, THROUGH THE SAME DOOR AND FOR A SHARPER REASON.
|
|
182
|
+
//
|
|
183
|
+
// `codexBin` used to arrive on `config` by the unknown-key spread above: this
|
|
184
|
+
// file keeps whatever is in `zero.config.json` so a setting nobody has taught
|
|
185
|
+
// the app about survives a round trip, which is right for a setting and wrong
|
|
186
|
+
// for A PATH THAT GETS SPAWNED. `this.config.codexBin` reaches `spawn` in the
|
|
187
|
+
// supervisor raw, the file is gitignored and sits in the app directory, and
|
|
188
|
+
// every worker on this machine has a broad Bash allow. So the one path in the
|
|
189
|
+
// second engine that becomes a process was the one with no finder, no
|
|
190
|
+
// existence check and no `configured` split -- while `claudeBin`, three lines
|
|
191
|
+
// up, has had all three since it was written. main/codex-bin.mjs is that
|
|
192
|
+
// finder; until now nothing imported it.
|
|
193
|
+
//
|
|
194
|
+
// THE ONE ASYMMETRY IS DELIBERATE. Claude Code is required, so a miss falls
|
|
195
|
+
// back to the installer's path and anything spawning it fails with a real
|
|
196
|
+
// path in the message. Codex is OPTIONAL, so a miss is null: null is exactly
|
|
197
|
+
// what `engineFor` and `_capacityFor` read as "this Mac cannot run the second
|
|
198
|
+
// engine", and inventing a path there would advertise an engine that is not
|
|
199
|
+
// installed and turn every Codex row into an ENOENT.
|
|
200
|
+
config.codexBinConfigured = typeof overrides.codexBin === 'string' && overrides.codexBin
|
|
201
|
+
? overrides.codexBin
|
|
202
|
+
: null;
|
|
203
|
+
const codex = resolveCodexBin(config.codexBinConfigured);
|
|
204
|
+
config.codexBin = codex.found ? codex.path : null;
|
|
205
|
+
config.codexFound = codex.found;
|
|
206
|
+
config.codexCertain = codex.certain !== false;
|
|
207
|
+
config.codexFrom = codex.from;
|
|
208
|
+
// HOW MANY AGENTS AT ONCE, WHEN NOBODY HAS SAID, IS THE PLAN AND THE MACHINE
|
|
209
|
+
// TOGETHER. Somebody on a smaller plan gets several sessions started for them,
|
|
210
|
+
// they all spend the same allowance, and what they see is agents that are slow
|
|
211
|
+
// and rows that fail. The founder's own bullet off a tester's onboarding was
|
|
212
|
+
// "not on max plan?". So a plan that is not Max answers 1 and that stands
|
|
213
|
+
// whatever the hardware is.
|
|
214
|
+
//
|
|
215
|
+
// HER OWN NUMBER ALWAYS WINS, including when it is the same as the default:
|
|
216
|
+
// this only fills in a number nobody set. And a Mac whose plan cannot be read
|
|
217
|
+
// keeps whatever the hardware suggests, so this can make an install smaller
|
|
218
|
+
// but never breaks one. `planSlotsFrom` is the account it came from, or null,
|
|
219
|
+
// so the Agents page can say why the number is what it is instead of leaving
|
|
220
|
+
// somebody to wonder why theirs says one.
|
|
221
|
+
// AND WHAT THIS MAC SUGGESTS IS WHAT THE PLAN FALLS BACK TO. The arithmetic
|
|
222
|
+
// and the measurement behind it are in main/machine.mjs; the short version is
|
|
223
|
+
// one agent per 4 GB of memory, which is the founder's own two anchors.
|
|
224
|
+
//
|
|
225
|
+
// THE MACHINE IS THE FALLBACK RATHER THAN A SECOND CAP, and the difference is
|
|
226
|
+
// the whole reason this reads the way it does. `slotsForPlans` answers 1 for a
|
|
227
|
+
// small subscription and its fallback for everything else, so handing it the
|
|
228
|
+
// machine number composes the two correctly in one expression: a Pro plan gets
|
|
229
|
+
// one at a time whatever the hardware, and everybody else gets what the
|
|
230
|
+
// hardware suggests. Taking `Math.min` of a flat 3 and the machine instead,
|
|
231
|
+
// which is what shipped first, held every Mac at 3 or below and so could never
|
|
232
|
+
// do the thing she asked for, which is scale up: "For 24 GB, have it scale
|
|
233
|
+
// accordingly."
|
|
234
|
+
//
|
|
235
|
+
// IT IS NOT A CAP. Nothing downstream clamps to this; the stepper and the
|
|
236
|
+
// writer both use the control's own range, the same on every machine. A number
|
|
237
|
+
// already in zero.config.json is untouched, for the same reason the plan cap
|
|
238
|
+
// leaves it alone.
|
|
239
|
+
config.machineSlots = machineSlots().slots;
|
|
240
|
+
if (typeof overrides.maxConcurrentSessions !== 'number') {
|
|
241
|
+
const plans = readPlans(effectiveProfiles(config.authProfiles, { home }), { home });
|
|
242
|
+
const slots = slotsForPlans(plans, config.machineSlots);
|
|
243
|
+
config.maxConcurrentSessions = slots;
|
|
244
|
+
config.planSlotsFrom = slots === config.machineSlots
|
|
245
|
+
? null
|
|
246
|
+
: (plans.find((p) => p.known && !p.max)?.label ?? null);
|
|
247
|
+
} else {
|
|
248
|
+
config.planSlotsFrom = null;
|
|
249
|
+
}
|
|
250
|
+
// WHOSE MACHINE THIS IS, carried so nothing downstream has to ask the OS a
|
|
251
|
+
// second time and so a test can lay out a home of its own. The accounts the
|
|
252
|
+
// fleet runs on are read from it (main/account-discovery.mjs).
|
|
253
|
+
config.home = home;
|
|
254
|
+
// Where the file is, so anything that edits a setting can write it back
|
|
255
|
+
// without being handed appDir a second time.
|
|
256
|
+
config.appDir = appDir;
|
|
257
|
+
return config;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function configFile(appDir) {
|
|
261
|
+
return path.join(appDir, CONFIG_NAME);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* WHICH FOLDER `zero.config.json` IS READ FROM, and the whole of it is one
|
|
266
|
+
* rule: A COPY PRETENDING TO BE SOMEBODY ELSE MAY NOT READ THE CHECKOUT'S
|
|
267
|
+
* CONFIG.
|
|
268
|
+
*
|
|
269
|
+
* Running from source the config is the checkout's, which is what every session
|
|
270
|
+
* and every test already assumes. Packaged it cannot be, because the bundle is
|
|
271
|
+
* signed and a write into it breaks the signature notarisation just bought, so
|
|
272
|
+
* it is the user data folder.
|
|
273
|
+
*
|
|
274
|
+
* THE THIRD CASE IS THE ONE THAT WAS MISSING, AND IT IS WHAT SHE SAW. She
|
|
275
|
+
* pressed the ⌘K new user row on 2026-09-27 and the second window opened on HER
|
|
276
|
+
* INBOX, the personal rows among them, with no first run in front of it. A
|
|
277
|
+
* throwaway home moves the user data folder and it does NOT move the checkout,
|
|
278
|
+
* so on a source-run app the copy that was meant to be a stranger read
|
|
279
|
+
* `<checkout>/zero.config.json` and took her store root, her account id, her
|
|
280
|
+
* PostHog key and her session arguments off it. `shared/fresh-user-home.mjs`
|
|
281
|
+
* strips those names out of the child's ENVIRONMENT, which was the other half
|
|
282
|
+
* of the same leak; this is the half a file on disk causes, and no environment
|
|
283
|
+
* variable can fix it.
|
|
284
|
+
*
|
|
285
|
+
* The demo copy goes the same way, for the same reason rather than a second
|
|
286
|
+
* one: it seeds its own small store into the throwaway home and was then
|
|
287
|
+
* pointed straight past it at hers.
|
|
288
|
+
*
|
|
289
|
+
* Both marks are read off the environment the copy was launched with, so this
|
|
290
|
+
* answers correctly inside the child while staying a plain function a test can
|
|
291
|
+
* call with no Electron and no launch.
|
|
292
|
+
*/
|
|
293
|
+
export function configDir({ appDir, userData, packaged = false, env = process.env } = {}) {
|
|
294
|
+
if (packaged) return userData;
|
|
295
|
+
return runningAsAFreshUser(env) || runningAsADemo(env) ? userData : appDir;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Change settings from the app, on the founder's own file.
|
|
299
|
+
//
|
|
300
|
+
// Two halves, and both are required. The FILE is rewritten so the choice
|
|
301
|
+
// survives a restart, merging into whatever is on disk right now rather than
|
|
302
|
+
// into what this process read at boot: several sessions edit this repo, and a
|
|
303
|
+
// whole-object overwrite from stale memory is how an unrelated key silently
|
|
304
|
+
// reverts. The LIVE object is then mutated in place, because every consumer
|
|
305
|
+
// (the supervisor's tick, spawnPlan's grants, the digest) reads
|
|
306
|
+
// `config.*` per call, so a setting she changes takes effect on the next tick
|
|
307
|
+
// rather than at the next restart. A running app that ignores its own settings
|
|
308
|
+
// screen until relaunch is the same failure as a fix that is not running.
|
|
309
|
+
//
|
|
310
|
+
// Written through a rename, like the standing instructions: a half-written
|
|
311
|
+
// config is a fleet that boots with no grants at all.
|
|
312
|
+
export function saveConfig(config, patch) {
|
|
313
|
+
const appDir = config.appDir;
|
|
314
|
+
if (!appDir) throw new Error('config has no appDir; cannot save');
|
|
315
|
+
const file = configFile(appDir);
|
|
316
|
+
let onDisk = {};
|
|
317
|
+
try {
|
|
318
|
+
onDisk = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (err.code !== 'ENOENT') throw err;
|
|
321
|
+
}
|
|
322
|
+
const next = { ...onDisk };
|
|
323
|
+
// NOTHING IS PINNED HERE ANY MORE, and the deletion is the point. This used
|
|
324
|
+
// to write `outsideAgents` into a new install's first save, because the
|
|
325
|
+
// default flipped from "only the ones asking" to "every session" the moment a
|
|
326
|
+
// config file existed, and a stranger who had never opened the setting would
|
|
327
|
+
// have been flipped by it. There is one default now for every install, `off`
|
|
328
|
+
// in main/settings.mjs, so there is no flip left to guard against and no
|
|
329
|
+
// setting to write on somebody's behalf.
|
|
330
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
331
|
+
// An explicit undefined REMOVES the key, which is how a per-project
|
|
332
|
+
// override goes back to meaning "same as the workspace" rather than
|
|
333
|
+
// hardening into an empty list that means something else entirely.
|
|
334
|
+
if (value === undefined) delete next[key];
|
|
335
|
+
else next[key] = value;
|
|
336
|
+
}
|
|
337
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
338
|
+
fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
339
|
+
fs.renameSync(tmp, file);
|
|
340
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
341
|
+
if (value === undefined) delete config[key];
|
|
342
|
+
else config[key] = value;
|
|
343
|
+
}
|
|
344
|
+
return next;
|
|
345
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// "COPY" IN THE RIGHT-CLICK MENU ALWAYS PUTS SOMETHING ON THE CLIPBOARD.
|
|
2
|
+
//
|
|
3
|
+
// The founder, w-f7d2841075 (2026-09-26): right-click a link, press Copy, paste,
|
|
4
|
+
// and what comes out is whatever was on the clipboard before. Third report of
|
|
5
|
+
// the same thing (w-71f790a6eb, w-b108b1d596), and each earlier fix was
|
|
6
|
+
// measured on a bare test page where the copy did land.
|
|
7
|
+
//
|
|
8
|
+
// MEASURED this time on the real app: a seeded message, a real right-click on a
|
|
9
|
+
// link (on a Mac that selects the link's words, so "Copy" is the top item), and
|
|
10
|
+
// the real native menu item pressed through the accessibility API. The click
|
|
11
|
+
// handler ran, `contents.copy()` was called, and the clipboard still held the
|
|
12
|
+
// sentinel written before the click. "Copy Link Address", which writes the
|
|
13
|
+
// clipboard directly from here, landed every time.
|
|
14
|
+
//
|
|
15
|
+
// `contents.copy()` asks the page to run its own copy command, and whether that
|
|
16
|
+
// lands depends on focus and selection state inside the page at the moment the
|
|
17
|
+
// menu closes, which this process cannot see. The words she right-clicked on
|
|
18
|
+
// are already in hand: Chromium hands them over as `params.selectionText` at the
|
|
19
|
+
// moment of the click. So the page still gets the first go, because its copy
|
|
20
|
+
// carries the rich version (pictures inlined, our file links unwrapped, see
|
|
21
|
+
// renderer/src/copy-out.ts), and if nothing has landed shortly after, the words
|
|
22
|
+
// are written directly. Either way the old clipboard is gone.
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Copy what a context menu was raised on.
|
|
26
|
+
*
|
|
27
|
+
* @param {{ copy: () => void }} contents the web contents the menu was raised on
|
|
28
|
+
* @param {string} selectionText `params.selectionText` from the context-menu event
|
|
29
|
+
* @param {{ clear: () => void, availableFormats: () => string[], writeText: (t: string) => void }} clipboard
|
|
30
|
+
* @param {{ waitMs?: number, stepMs?: number, sleep?: (ms: number) => Promise<void> }} [opts]
|
|
31
|
+
* @returns {Promise<'page' | 'direct'>} which of the two put it there
|
|
32
|
+
*/
|
|
33
|
+
export async function copySelection(contents, selectionText, clipboard, opts = {}) {
|
|
34
|
+
const waitMs = opts.waitMs ?? 600;
|
|
35
|
+
const stepMs = opts.stepMs ?? 25;
|
|
36
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
37
|
+
// Nothing selected (a Copy in an empty text box) has no words to fall back
|
|
38
|
+
// on, and clearing would throw away her clipboard for nothing.
|
|
39
|
+
if (!selectionText) {
|
|
40
|
+
try { contents.copy(); } catch { /* nothing to copy anyway */ }
|
|
41
|
+
return 'page';
|
|
42
|
+
}
|
|
43
|
+
// Cleared first, so "something is on the clipboard" can only mean this copy
|
|
44
|
+
// put it there. The words are written below if the page does not, so a
|
|
45
|
+
// cleared clipboard never outlives this function.
|
|
46
|
+
clipboard.clear();
|
|
47
|
+
try { contents.copy(); } catch { /* a destroyed contents; the words still go */ }
|
|
48
|
+
for (let waited = 0; waited < waitMs; waited += stepMs) {
|
|
49
|
+
if (clipboard.availableFormats().length) return 'page';
|
|
50
|
+
await sleep(stepMs);
|
|
51
|
+
}
|
|
52
|
+
if (clipboard.availableFormats().length) return 'page';
|
|
53
|
+
clipboard.writeText(selectionText);
|
|
54
|
+
return 'direct';
|
|
55
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// When Agentbox breaks on a stranger's Mac, somebody has to find out. Today
|
|
2
|
+
// nothing catches anything: the window closes, they stop opening it, and the
|
|
3
|
+
// only trace is one more download. Her yes, 2026-08-19.
|
|
4
|
+
//
|
|
5
|
+
// What this file is: the four ways the app can die, one scrubbed report each,
|
|
6
|
+
// and a queue on disk. What it is NOT, deliberately: a network client. Where
|
|
7
|
+
// reports go is her open pick on (PostHog from the main process, or Aptabase),
|
|
8
|
+
// and picking it here would be an agent choosing the third party that receives
|
|
9
|
+
// a stranger's crashes. So `setTransport` is the seam and until something is
|
|
10
|
+
// passed to it, a report is written to disk and nothing leaves the machine.
|
|
11
|
+
// That is also the honest default for a build we have not shipped yet.
|
|
12
|
+
//
|
|
13
|
+
// Every payload is built by `shared/crash-scrub.mjs`'s allowlist and then
|
|
14
|
+
// checked again by `findPrivate` before it is written. A report that fails the
|
|
15
|
+
// check is not sent minus the bad field; the whole report is dropped and a
|
|
16
|
+
// counter is written instead, because a scrub that quietly half-works is how a
|
|
17
|
+
// promise on the page turns into a lie.
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { findPrivate, scrubError, scrubText } from '../shared/crash-scrub.mjs';
|
|
23
|
+
import { readInstallId } from '../shared/install-id.mjs';
|
|
24
|
+
import { NAME, nameSlug } from '../shared/product-name.mjs';
|
|
25
|
+
|
|
26
|
+
const MAX_KEPT = 50;
|
|
27
|
+
|
|
28
|
+
let ctx = null; // { home, username, appDir, dir, version, startedAt }
|
|
29
|
+
let transport = null; // set once she picks where reports go
|
|
30
|
+
let installId = null;
|
|
31
|
+
let fired = false; // one hard crash is one report, never a loop
|
|
32
|
+
|
|
33
|
+
// The same random per-install id the approved counts use, and it now lives in
|
|
34
|
+
// ONE file (`shared/install-id.mjs`) because two copies of "it is not a
|
|
35
|
+
// fingerprint" is one copy too many. No account, no email, no machine id.
|
|
36
|
+
|
|
37
|
+
export function report(kind, err, extra = {}) {
|
|
38
|
+
if (!ctx) return null;
|
|
39
|
+
const payload = {
|
|
40
|
+
v: 1,
|
|
41
|
+
kind, // where it died, not what was on screen
|
|
42
|
+
ts: new Date().toISOString(),
|
|
43
|
+
installId,
|
|
44
|
+
app: { name: NAME, version: ctx.version },
|
|
45
|
+
os: { platform: process.platform, release: os.release(), arch: process.arch },
|
|
46
|
+
// How long it had been open. Useful (a crash at 3 seconds is a boot bug and
|
|
47
|
+
// a crash at 3 hours is a leak) and carries nothing about anyone.
|
|
48
|
+
upMs: Date.now() - ctx.startedAt,
|
|
49
|
+
error: scrubError(err, ctx),
|
|
50
|
+
};
|
|
51
|
+
// Extras are scrubbed exactly like the error, and only strings and numbers
|
|
52
|
+
// survive at all, so no future caller can widen this by passing an object.
|
|
53
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
54
|
+
if (typeof v === 'number') payload[k] = v;
|
|
55
|
+
else if (typeof v === 'string') payload[k] = scrubText(v, ctx);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const leaks = findPrivate(payload, ctx);
|
|
59
|
+
if (leaks.length) {
|
|
60
|
+
// The scrub failed. Say that we crashed and say nothing else.
|
|
61
|
+
return write({ v: 1, kind: 'redacted', ts: payload.ts, installId, app: payload.app, os: payload.os, reason: 'scrub check failed', fields: leaks.length });
|
|
62
|
+
}
|
|
63
|
+
return write(payload);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function write(payload) {
|
|
67
|
+
try {
|
|
68
|
+
fs.mkdirSync(ctx.dir, { recursive: true });
|
|
69
|
+
const file = path.join(ctx.dir, `crash-${payload.ts.replace(/[:.]/g, '-')}-${Math.floor(Math.random() * 1e6)}.json`);
|
|
70
|
+
fs.writeFileSync(file, JSON.stringify(payload, null, 2));
|
|
71
|
+
prune();
|
|
72
|
+
// No await, no retry, no queue drain here: this runs while the process is
|
|
73
|
+
// dying. Delivery is the transport's problem, on the next boot if it has to be.
|
|
74
|
+
if (transport) { try { transport(payload); } catch {} }
|
|
75
|
+
return file;
|
|
76
|
+
} catch { return null; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function prune() {
|
|
80
|
+
try {
|
|
81
|
+
const files = fs.readdirSync(ctx.dir).filter((f) => f.startsWith('crash-')).sort();
|
|
82
|
+
for (const f of files.slice(0, Math.max(0, files.length - MAX_KEPT))) {
|
|
83
|
+
fs.unlinkSync(path.join(ctx.dir, f));
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Her pick on lands here and nowhere else.
|
|
89
|
+
export function setTransport(fn) { transport = typeof fn === 'function' ? fn : null; }
|
|
90
|
+
|
|
91
|
+
export function pending() {
|
|
92
|
+
try {
|
|
93
|
+
return fs.readdirSync(ctx.dir).filter((f) => f.startsWith('crash-')).map((f) => path.join(ctx.dir, f));
|
|
94
|
+
} catch { return []; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function installCrashReports({ app, appDir, dir, version, crashReporter } = {}) {
|
|
98
|
+
ctx = {
|
|
99
|
+
home: os.homedir(),
|
|
100
|
+
username: (() => { try { return os.userInfo().username; } catch { return null; } })(),
|
|
101
|
+
// realpath, because macOS hands `/tmp` back as `/private/tmp` and a stack
|
|
102
|
+
// frame written the other way then reads as somebody else's code and gets
|
|
103
|
+
// dropped. The frames inside our bundle are the entire point of the report.
|
|
104
|
+
appDir: (() => { const d = appDir || process.cwd(); try { return fs.realpathSync(d); } catch { return d; } })(),
|
|
105
|
+
dir: dir || path.join(os.tmpdir(), `${nameSlug}-crash-reports`),
|
|
106
|
+
version: version || '0.0.0',
|
|
107
|
+
startedAt: Date.now(),
|
|
108
|
+
};
|
|
109
|
+
installId = readInstallId(ctx.dir);
|
|
110
|
+
|
|
111
|
+
// Native crashes (the renderer's own process dying, a GPU fault) leave a
|
|
112
|
+
// minidump rather than a JS stack. `uploadToServer: false` is not a setting
|
|
113
|
+
// that could be flipped by accident: with no destination decided there is no
|
|
114
|
+
// submit URL to flip it to, so the dumps sit in the app's own folder.
|
|
115
|
+
if (crashReporter) {
|
|
116
|
+
try { crashReporter.start({ uploadToServer: false, compress: true, ignoreSystemCrashHandler: false }); } catch {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const onUncaught = (err) => {
|
|
120
|
+
if (fired) return;
|
|
121
|
+
fired = true;
|
|
122
|
+
report('main-uncaught', err);
|
|
123
|
+
// Put the default back rather than living on in a state Electron never
|
|
124
|
+
// designed for. A reporter that silently swallows a fatal error turns "it
|
|
125
|
+
// crashed" into "it froze", which is worse for the person holding the Mac.
|
|
126
|
+
process.removeListener('uncaughtException', onUncaught);
|
|
127
|
+
throw err;
|
|
128
|
+
};
|
|
129
|
+
process.on('uncaughtException', onUncaught);
|
|
130
|
+
|
|
131
|
+
const onRejection = (reason) => {
|
|
132
|
+
report('main-rejection', reason instanceof Error ? reason : new Error(String(reason)));
|
|
133
|
+
};
|
|
134
|
+
process.on('unhandledRejection', onRejection);
|
|
135
|
+
|
|
136
|
+
if (app?.on) {
|
|
137
|
+
// The window's process died. `details.reason` is Electron's own vocabulary
|
|
138
|
+
// (`crashed`, `oom`, `killed`) and carries nothing of theirs.
|
|
139
|
+
app.on('render-process-gone', (_e, _wc, details = {}) => {
|
|
140
|
+
report('renderer-gone', new Error('render process gone'), { reason: String(details.reason || 'unknown'), exitCode: Number(details.exitCode ?? -1) });
|
|
141
|
+
});
|
|
142
|
+
app.on('child-process-gone', (_e, details = {}) => {
|
|
143
|
+
report('child-gone', new Error('child process gone'), { reason: String(details.reason || 'unknown'), processType: String(details.type || 'unknown'), exitCode: Number(details.exitCode ?? -1) });
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return { report, pending, dir: ctx.dir };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// The renderer's uncaught errors arrive here over IPC. They are the ones most
|
|
150
|
+
// likely to carry a task title inside the message, which is why they go through
|
|
151
|
+
// exactly the same scrub as everything else and get no shortcut of their own.
|
|
152
|
+
export function reportFromRenderer(payload = {}) {
|
|
153
|
+
return report('renderer-error', {
|
|
154
|
+
name: typeof payload.name === 'string' ? payload.name : 'Error',
|
|
155
|
+
message: typeof payload.message === 'string' ? payload.message : 'renderer error',
|
|
156
|
+
stack: typeof payload.stack === 'string' ? payload.stack : '',
|
|
157
|
+
});
|
|
158
|
+
}
|