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/main.mjs
ADDED
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
// The app: the agent inbox. Electron shell around a keyboard-first renderer, a
|
|
2
|
+
// file-derived store, and a supervisor of headless sessions.
|
|
3
|
+
|
|
4
|
+
import { app, BrowserWindow, Menu, Notification, clipboard, crashReporter, dialog, ipcMain, nativeImage, net, powerMonitor, protocol, screen as electronScreen, shell, session } from 'electron';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import { configDir, loadConfig } from './config.mjs';
|
|
9
|
+
import { installMenu, applyZoom, requestFind } from './menu.mjs';
|
|
10
|
+
import { zoomDeltaFor } from './zoom-keys.mjs';
|
|
11
|
+
import { copySelection } from './copy-selection.mjs';
|
|
12
|
+
import { screenDetailFor } from './screen-detail.mjs';
|
|
13
|
+
import { Store } from './store.mjs';
|
|
14
|
+
import { storeRootEnv } from './store/home.mjs';
|
|
15
|
+
import { Supervisor } from './supervisor.mjs';
|
|
16
|
+
import { startCodexWatch } from './codex-watch.mjs';
|
|
17
|
+
import { registerIpc } from './ipc.mjs';
|
|
18
|
+
import { carryHerBriefsAcross, joinMessageRules } from './instruction-settings.mjs';
|
|
19
|
+
import * as approvals from './approvals.mjs';
|
|
20
|
+
import { recoveryToast } from '../shared/recovery.mjs';
|
|
21
|
+
import { dataFolderName, isNewUserBuild } from '../shared/side-build.mjs';
|
|
22
|
+
import { runningAsAFreshUser } from '../shared/fresh-user-home.mjs';
|
|
23
|
+
import { openFreshUser } from './fresh-user.mjs';
|
|
24
|
+
import { installCrashReports, reportFromRenderer, pending as pendingCrashes, setTransport } from './crash-report.mjs';
|
|
25
|
+
import { createAnalytics } from './analytics.mjs';
|
|
26
|
+
import { createUpdater } from './updater.mjs';
|
|
27
|
+
import { installNotifier } from './notify.mjs';
|
|
28
|
+
import { DOC_SCHEMES, DocGrants, docPath } from './doc-scheme.mjs';
|
|
29
|
+
import { IMG_SCHEMES, imgPath, servable } from './img-scheme.mjs';
|
|
30
|
+
import { hotWindowVerdict, storeHasWork } from './dev-window.mjs';
|
|
31
|
+
import { IS_SHE_TYPING_IN_THE_PAGE, theAppKeepsThisInput, theAppKeepsThisLetterInAPage } from '../shared/artifact-keys.mjs';
|
|
32
|
+
import { NAME, Name, WAS } from '../shared/product-name.mjs';
|
|
33
|
+
|
|
34
|
+
// HOW LONG TO WAIT AFTER THE LID OPENS, and the wait is for the network rather
|
|
35
|
+
// than for the wake. `powerMonitor`'s resume event fires while wifi is still
|
|
36
|
+
// coming back, and a session resumed into a dead network dies in about a
|
|
37
|
+
// second, which the supervisor reads as a fast exit: three of those strike the
|
|
38
|
+
// subscription and arm a fleet-wide spawn cooldown of up to thirty minutes. So
|
|
39
|
+
// the repair would take the fleet down with it.
|
|
40
|
+
const WAKE_SETTLE_MS = Number(process.env.ZERO_WAKE_SETTLE_MS ?? 8000);
|
|
41
|
+
|
|
42
|
+
// THE DOCUMENT PANE'S OWN ORIGIN. This has to be said before the app is ready
|
|
43
|
+
// or the scheme is not a standard one, and a page served under a non-standard
|
|
44
|
+
// scheme has no origin for its own pictures to be same-origin with. `secure`
|
|
45
|
+
// keeps it out of the mixed-content rules; the grants in doc-scheme.mjs are
|
|
46
|
+
// what decide the pane may read a given file.
|
|
47
|
+
//
|
|
48
|
+
// THE SECOND ONE IS WHERE THE APP'S OWN PICTURES COME FROM: a pasted thumbnail
|
|
49
|
+
// and every screenshot in a message. They used to be file:// urls, which a
|
|
50
|
+
// dev-served window may not load, so the app she uses all day drew a broken
|
|
51
|
+
// image for every one of them.
|
|
52
|
+
//
|
|
53
|
+
// EVERY SPELLING THIS APP HAS USED IS REGISTERED, not only today's. A url on
|
|
54
|
+
// either scheme is stored: a product's logo is kept in the store as one, and
|
|
55
|
+
// every picture in a message she was sent months ago is one. An unregistered
|
|
56
|
+
// scheme does not degrade, it fails to load, so the old names stay served.
|
|
57
|
+
protocol.registerSchemesAsPrivileged([...DOC_SCHEMES.map((scheme) => ({
|
|
58
|
+
scheme,
|
|
59
|
+
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true },
|
|
60
|
+
})), ...IMG_SCHEMES.map((scheme) => ({
|
|
61
|
+
// `corsEnabled` IS WHAT LETS A COPIED MESSAGE CARRY THE PICTURE ITSELF.: a
|
|
62
|
+
// picture on this scheme taints a canvas like any other cross-origin image,
|
|
63
|
+
// so the copy handler in the window could not read one back to put on the
|
|
64
|
+
// clipboard, and a message copied into an email arrived with a dead url
|
|
65
|
+
// where the screenshot was. With this flag, the header the handler sends and
|
|
66
|
+
// `crossOrigin` on the <img>, a canvas reads it cleanly. MEASURED, all three
|
|
67
|
+
// needed: without the flag the same <img> never loads at all, and without
|
|
68
|
+
// `crossOrigin` the canvas throws SecurityError.
|
|
69
|
+
scheme,
|
|
70
|
+
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true },
|
|
71
|
+
}))]);
|
|
72
|
+
|
|
73
|
+
// A local file answered so the window asks again next time instead of reusing
|
|
74
|
+
// what it drew before. Why, measured, is at the page scheme's handler below.
|
|
75
|
+
function noStore(res) {
|
|
76
|
+
const headers = new Headers(res.headers);
|
|
77
|
+
headers.set('cache-control', 'no-store');
|
|
78
|
+
return new Response(res.body, { status: res.status, headers });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Which folders that scheme may read from. Filled in when the pane resolves a
|
|
82
|
+
// document (main/ipc.mjs) and empty until then, so nothing is readable until
|
|
83
|
+
// she opens something.
|
|
84
|
+
const docGrants = new DocGrants();
|
|
85
|
+
|
|
86
|
+
// The picture scheme's roots, as a function so it reads the live store. Nothing
|
|
87
|
+
// is servable until the window has been built, which is the same shape as the
|
|
88
|
+
// grants above: an empty list is a closed door, never an open one.
|
|
89
|
+
let imageRoots = () => [];
|
|
90
|
+
|
|
91
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
92
|
+
const appDir = path.join(__dirname, '..');
|
|
93
|
+
|
|
94
|
+
// A SIDE BUILD IS STILL AGENTBOX, AND IT HAS TO BE, OR TWO SUPERVISORS RUN.
|
|
95
|
+
// `scripts/pack-side.mjs` packs this branch as its own bundle, "the app Rest",
|
|
96
|
+
// so a build she is trying out for a few days sits beside the real one in
|
|
97
|
+
// Finder instead of overwriting it. The bundle is separate; the DATA FOLDER
|
|
98
|
+
// must not be, and shared/side-build.mjs is where that rule and its reasons
|
|
99
|
+
// live. This has to happen before ANYTHING reads a path, because everything
|
|
100
|
+
// downstream keys off app.getName, the single-instance lock at the foot of
|
|
101
|
+
// this file included.
|
|
102
|
+
if (dataFolderName(app.getName()) !== app.getName()) app.setName(dataFolderName(app.getName()));
|
|
103
|
+
|
|
104
|
+
// "ASTRAL NEW USER" IS A STRANGER EVERY TIME IT OPENS.
|
|
105
|
+
//
|
|
106
|
+
// So this build does to itself what the ⌘K row does to the real one: it opens a
|
|
107
|
+
// second copy inside a brand new throwaway home, with Claude Code and its login
|
|
108
|
+
// linked through and nothing else of hers, then gets out of the way. Every
|
|
109
|
+
// launch is a person who has never opened Agentbox, on a bundle whose
|
|
110
|
+
// permission history is its own (`ac.agentbox.newuser`), which is what makes
|
|
111
|
+
// the first-run permission panels testable at all. Quitting it and opening it
|
|
112
|
+
// again starts over from zero on purpose.
|
|
113
|
+
//
|
|
114
|
+
// BEFORE ANY PATH IS READ, like the rename above: the copy that hands over must
|
|
115
|
+
// never take a lock, write a crash folder or read a config in her real home.
|
|
116
|
+
if (isNewUserBuild(app.getName()) && !runningAsAFreshUser()) {
|
|
117
|
+
const opened = openFreshUser({ withAgents: true, packaged: app.isPackaged });
|
|
118
|
+
if (!opened.ok) dialog.showErrorBox(`${Name} new user could not start`, opened.error ?? 'No reason was given.');
|
|
119
|
+
app.exit(0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// THE APP IS CALLED POWERUP NOW, AND CHROMIUM NAMES ITS DATA FOLDER AFTER THE
|
|
123
|
+
// APP. So the rename alone would have pointed this machine at an empty
|
|
124
|
+
// ~/Library/Application Support/Powerup and lost every renderer setting she has
|
|
125
|
+
// ever chosen: her look (theme and skin), her zoom, her project order. Nothing
|
|
126
|
+
// is moved here, because moving 1.3 GB of somebody's folder is a way to lose
|
|
127
|
+
// it. A copy that already has an older folder and no the app one keeps reading
|
|
128
|
+
// the older one, forever; a fresh install has neither and gets Powerup.
|
|
129
|
+
//
|
|
130
|
+
// THE LIST IS NEWEST FIRST AND IT GREW BY ONE ON 2026-09-04. This was written
|
|
131
|
+
// for the app -> the app and would have silently failed the second rename: every
|
|
132
|
+
// existing user's folder is called the app, not the app, so a bare the app check would
|
|
133
|
+
// have found nothing and handed them an empty Powerup. Whoever renames this app
|
|
134
|
+
// a third time adds one entry here and nothing else.
|
|
135
|
+
try {
|
|
136
|
+
const named = path.join(app.getPath('appData'), app.getName());
|
|
137
|
+
if (!fs.existsSync(named)) {
|
|
138
|
+
// THESE ARE HISTORY AND NOT COPY. They are the folder names this app has
|
|
139
|
+
// had, and a rename sweep that "helpfully" moves one of them to the current
|
|
140
|
+
// name deletes the only reason this loop exists. scripts/rename-to-powerup
|
|
141
|
+
// protects this line by name for exactly that reason; it rewrote it once.
|
|
142
|
+
for (const was of WAS) {
|
|
143
|
+
const legacy = path.join(app.getPath('appData'), was);
|
|
144
|
+
if (legacy !== named && fs.existsSync(legacy)) { app.setPath('userData', legacy); break; }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch {}
|
|
148
|
+
|
|
149
|
+
// WHERE THE APP'S OWN WRITABLE STATE LIVES. Running from source it is the
|
|
150
|
+
// checkout, which is what every session and every test already assumes. Inside
|
|
151
|
+
// a packaged .app it cannot be: the bundle is signed, read-only in practice,
|
|
152
|
+
// and a write into it breaks the signature that notarisation just bought. So
|
|
153
|
+
// zero.config.json moves to the user's data folder when packaged, and nothing
|
|
154
|
+
// else does — the briefs we ship, the worker permissions and the renderer are
|
|
155
|
+
// read-only and stay in the bundle.
|
|
156
|
+
//
|
|
157
|
+
// AND A THROWAWAY COPY GETS THE USER DATA FOLDER WHATEVER IT WAS STARTED FROM,
|
|
158
|
+
// because a throwaway home moves that folder and does not move the checkout.
|
|
159
|
+
// `configDir` in main/config.mjs is the whole rule and says what she saw when
|
|
160
|
+
// it was missing.
|
|
161
|
+
const dataDir = configDir({ appDir, userData: app.getPath('userData'), packaged: app.isPackaged });
|
|
162
|
+
|
|
163
|
+
// AND WHERE WHAT SHE TYPES LIVES, WHICH IS NEVER THE CHECKOUT (w-3dc46f3a67,
|
|
164
|
+
// 2026-09-21). Her standing instructions and her writing rules used to be
|
|
165
|
+
// `dataDir/briefs`, so on her own machine, running from source, they were files
|
|
166
|
+
// in the git repository the whole fleet works in. Her report: ⌘K could not find
|
|
167
|
+
// the box by the word she types, and when we went looking the file's entire
|
|
168
|
+
// history was four agent commits with nothing of hers in any of them. A rules
|
|
169
|
+
// file inside the folder every worker may `git checkout` is a rules file an
|
|
170
|
+
// agent can undo by accident, silently, and she has no way to see it happen.
|
|
171
|
+
//
|
|
172
|
+
// This is the same folder a packaged build already used, so there is now ONE
|
|
173
|
+
// place her instructions live whichever way the app was started, and the copy
|
|
174
|
+
// below carries the text there once. What we SHIP still comes from appDir: the
|
|
175
|
+
// worker brief and the finishing rules are read from her folder first and the
|
|
176
|
+
// bundle second, so a release's new default still reaches her unless she has
|
|
177
|
+
// written over it herself.
|
|
178
|
+
const userDir = app.getPath('userData');
|
|
179
|
+
try {
|
|
180
|
+
const carried = carryHerBriefsAcross(appDir, userDir);
|
|
181
|
+
if (carried.length) console.log(`zero: carried ${carried.join(', ')} into ${userDir}`);
|
|
182
|
+
// AND THE TWO OLD MESSAGE FILES BECOME ONE, ONCE (w-3dc46f3a67). It runs
|
|
183
|
+
// after the carry above, so it finds whatever that just moved, and before the
|
|
184
|
+
// supervisor exists, so nothing has read the new name yet.
|
|
185
|
+
const joined = joinMessageRules(appDir, userDir);
|
|
186
|
+
if (joined) console.log(`zero: joined the message rules into one file, ${joined.total} characters`);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.warn(`zero: could not carry her instructions into ${userDir}: ${err.message}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Before anything else can throw. A crash during boot is the one a stranger
|
|
192
|
+
// actually meets, and a reporter installed after the store and the supervisor
|
|
193
|
+
// have loaded is a reporter that cannot see it. It writes to disk here and
|
|
194
|
+
// NOTHING leaves yet: the transport is handed to it inside createWindow, once
|
|
195
|
+
// her config has been read and only if diagnostics are on and a key exists.
|
|
196
|
+
const crashDir = path.join(app.getPath('userData'), 'crash-reports');
|
|
197
|
+
installCrashReports({
|
|
198
|
+
app,
|
|
199
|
+
appDir,
|
|
200
|
+
crashReporter,
|
|
201
|
+
version: app.getVersion(),
|
|
202
|
+
dir: crashDir,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
let window = null;
|
|
206
|
+
// Set by ⌘R and read once by the renderer as it comes back up, so a reload can
|
|
207
|
+
// say so. A reload that lands on an identical screen and a chord that did
|
|
208
|
+
// nothing at all look exactly alike, and telling them apart is the whole
|
|
209
|
+
// complaint (2026-08-06). The build time comes with it: if it has not moved,
|
|
210
|
+
// nobody rebuilt the renderer, which is the other way ⌘R "does nothing".
|
|
211
|
+
let reloadRequested = false;
|
|
212
|
+
|
|
213
|
+
function builtAt() {
|
|
214
|
+
try {
|
|
215
|
+
return fs.statSync(path.join(appDir, 'renderer', 'dist', 'index.html')).mtimeMs;
|
|
216
|
+
} catch {
|
|
217
|
+
return 0;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let lastReloadAt = 0;
|
|
222
|
+
// The wake sweep's one line, and whether there is a page to say it to yet.
|
|
223
|
+
let pendingRecovery = null;
|
|
224
|
+
let pageReady = false;
|
|
225
|
+
|
|
226
|
+
function reloadRenderer() {
|
|
227
|
+
// The chord can now arrive twice, from the menu accelerator and from
|
|
228
|
+
// before-input-event, depending on what holds focus. Reloading twice is
|
|
229
|
+
// harmless but reads as a flicker, so the second one is dropped.
|
|
230
|
+
const now = Date.now();
|
|
231
|
+
if (now - lastReloadAt < 500) return;
|
|
232
|
+
lastReloadAt = now;
|
|
233
|
+
// Logged because "⌘R does nothing" has now been reported twice, and a chord
|
|
234
|
+
// that never arrived is indistinguishable from one that reloaded an
|
|
235
|
+
// identical build unless something records that it arrived at all.
|
|
236
|
+
console.log(`zero: reload requested, renderer built ${new Date(builtAt()).toLocaleTimeString()}`);
|
|
237
|
+
if (!window || window.isDestroyed()) return;
|
|
238
|
+
reloadRequested = true;
|
|
239
|
+
window.webContents.reloadIgnoringCache();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function createWindow() {
|
|
243
|
+
const config = loadConfig(dataDir);
|
|
244
|
+
// The store modules read the app's own home variable at call time; setting it
|
|
245
|
+
// here points auth session files, sync, and every store path at its root.
|
|
246
|
+
// Written under every name this app has had, because a store server or a
|
|
247
|
+
// script from before a rename is still out there reading the old one.
|
|
248
|
+
Object.assign(process.env, storeRootEnv(config.storeRoot));
|
|
249
|
+
|
|
250
|
+
// Menu needs the config (approval hotkeys answer the oldest pending card),
|
|
251
|
+
// so it installs here rather than at whenReady. The spool watcher in ipc
|
|
252
|
+
// pushes the change to the renderer; no direct renderer wiring needed.
|
|
253
|
+
//
|
|
254
|
+
// AND ITS ANSWER GOES THROUGH THE IPC LAYER'S ONE DOOR, LATE-BOUND.
|
|
255
|
+
//
|
|
256
|
+
// `installMenu` needs only the config, so it runs here; `registerIpc` needs
|
|
257
|
+
// the window, the store and the supervisor, so it runs two hundred lines
|
|
258
|
+
// below. That ordering is why these menu items used to write the answer FILE
|
|
259
|
+
// themselves with `approvals.answer`, and on a Codex card a file is not an
|
|
260
|
+
// answer at all: the thing waiting is Agentbox itself, in this process, so
|
|
261
|
+
// nothing on that path ever reads one. Her click on Allow landed as a
|
|
262
|
+
// FORGERY -- the file appeared for a request still waiting, `sweepForgery`
|
|
263
|
+
// deleted it inside two seconds, the log said "an answer file appeared for
|
|
264
|
+
// approval <id> that the founder did not write", her card came back and the
|
|
265
|
+
// worker was denied fifteen minutes later by the deadline. It also spent the
|
|
266
|
+
// one warning that is supposed to mean a worker attacked the spool.
|
|
267
|
+
//
|
|
268
|
+
// The variable is filled in the moment `registerIpc` returns, a few hundred
|
|
269
|
+
// lines down. Until then there is nothing to answer with and nothing that
|
|
270
|
+
// could have been pressed, and the stub says so rather than pretending.
|
|
271
|
+
let answerApproval = (id, allow) => {
|
|
272
|
+
console.warn(`zero: an approval (${id}) was answered ${allow ? 'yes' : 'no'} before the app had finished starting; nothing was decided`);
|
|
273
|
+
return false;
|
|
274
|
+
};
|
|
275
|
+
installMenu({
|
|
276
|
+
onApproval: (allow) => {
|
|
277
|
+
const oldest = approvals.listPending(config.storeRoot)[0];
|
|
278
|
+
if (oldest) answerApproval(oldest.id, allow);
|
|
279
|
+
},
|
|
280
|
+
onReload: reloadRenderer,
|
|
281
|
+
// A click in the View menu names the window it was chosen from, so it zooms
|
|
282
|
+
// the inbox rather than whatever happens to hold focus. If the click ever
|
|
283
|
+
// arrives without one, this window is the answer, never nothing.
|
|
284
|
+
onZoom: (delta, target) => applyZoom(delta, target ?? window?.webContents ?? null),
|
|
285
|
+
});
|
|
286
|
+
// ANALYTICS, AND EVERYTHING IT NEEDS TO BE HONEST. Off unless her switch is
|
|
287
|
+
// on AND a key is configured, so a copy built from source sends nothing. The
|
|
288
|
+
// crash reporter has been writing to disk since the top of this file; this is
|
|
289
|
+
// where it is given a way out, and where the reports from the launch that
|
|
290
|
+
// died are finally sent, because a report is written while the process is
|
|
291
|
+
// dying and delivery cannot be synchronous.
|
|
292
|
+
const analytics = createAnalytics({ config, version: app.getVersion(), dir: crashDir });
|
|
293
|
+
// The seam is filled whenever there is somewhere to send to. Whether anything
|
|
294
|
+
// actually goes is asked again inside, at every send, so her switch stops it
|
|
295
|
+
// from the moment she moves it rather than at the next launch.
|
|
296
|
+
if (analytics.hasDestination) setTransport((payload) => analytics.crashTransport(payload));
|
|
297
|
+
if (analytics.enabled) {
|
|
298
|
+
const drained = analytics.drainCrashes(pendingCrashes());
|
|
299
|
+
if (drained) console.log(`zero: sent ${drained} crash report(s) from a previous launch`);
|
|
300
|
+
analytics.track('app_opened');
|
|
301
|
+
} else {
|
|
302
|
+
console.log(`zero: diagnostics is ${analytics.reason}, nothing is sent`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const store = await new Store(config).init();
|
|
306
|
+
// Where the window's own pictures may come from (main/img-scheme.mjs). The
|
|
307
|
+
// account root holds every product's docs, attachments, designs and the
|
|
308
|
+
// `.staging` folder a pasted screenshot lands in; a product's repo is where a
|
|
309
|
+
// worker writes `shots/…` and then names it on a card. Read fresh on every
|
|
310
|
+
// request so a project made after launch is not a folder of broken images.
|
|
311
|
+
imageRoots = () => [config.accountRoot, ...store.listProducts().map((p) => p.repoPath).filter(Boolean)];
|
|
312
|
+
const supervisor = new Supervisor(config, store, appDir, dataDir, userDir);
|
|
313
|
+
app.on('before-quit', () => supervisor.killAll());
|
|
314
|
+
// HER CODEX CONVERSATIONS ASK TO COME IN. Once a minute: a new one in a
|
|
315
|
+
// folder a project points at gets a row asking yes or no, and the ones she
|
|
316
|
+
// said yes to follow Codex's latest answer.
|
|
317
|
+
const codexWatch = startCodexWatch({ store });
|
|
318
|
+
app.on('before-quit', () => codexWatch.stop());
|
|
319
|
+
// Whatever is still queued goes with the app rather than dying in memory.
|
|
320
|
+
app.on('will-quit', () => { analytics.flush(); });
|
|
321
|
+
|
|
322
|
+
// Right-click. Electron ships no context menu at all, so links could not be
|
|
323
|
+
// copied or opened by hand anywhere in the app. Built per click from what is
|
|
324
|
+
// actually under the cursor. MUST register before the window exists:
|
|
325
|
+
// web-contents-created only fires for contents created after it (the first
|
|
326
|
+
// fix missed the main window for exactly that reason, 2026-08-04).
|
|
327
|
+
const openLink = (url) => {
|
|
328
|
+
if (url.startsWith('file://')) shell.openPath(decodeURIComponent(url.replace('file://', '')));
|
|
329
|
+
else shell.openExternal(url);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// A PICTURE GOES ON THE CLIPBOARD AS A PICTURE AND AS NOTHING ELSE.
|
|
333
|
+
//
|
|
334
|
+
// What her email showed was a raw `file:///Users/you/Zero/accounts/...` line
|
|
335
|
+
// where the picture should be.
|
|
336
|
+
//
|
|
337
|
+
// MEASURED, scripts/measure-copy-and-link-colour.mjs. `copyImageAt` alone
|
|
338
|
+
// leaves TWO flavours on the clipboard:
|
|
339
|
+
//
|
|
340
|
+
// formats ["text/html", "image/png"]
|
|
341
|
+
// text/html <img src="astral-img://file/tmp/one.png" alt="pasted-48629.png"/>
|
|
342
|
+
//
|
|
343
|
+
// Agentbox pastes the html and resolves that scheme, so inside the app it looks
|
|
344
|
+
// perfect. Gmail also prefers the html, and `astral-img://` means nothing
|
|
345
|
+
// outside this window, so it lands as a dead url. One copy, two answers, and
|
|
346
|
+
// that is the whole of the inconsistency she is describing.
|
|
347
|
+
//
|
|
348
|
+
// So the picture is read off disk and written as the only thing on the
|
|
349
|
+
// clipboard. `writeImage` clears what was there, which is what drops the html
|
|
350
|
+
// flavour and leaves every app one unambiguous thing to paste. Reading the
|
|
351
|
+
// file also skips `copyImageAt`'s round trip to the renderer, so there is no
|
|
352
|
+
// window in which the clipboard holds half an answer.
|
|
353
|
+
const copyPicture = (contents, params) => {
|
|
354
|
+
// The same two rules the scheme itself enforces (main/img-scheme.mjs): it is
|
|
355
|
+
// a picture, and it is inside a root the app owns. A context menu is not a
|
|
356
|
+
// reason to widen them.
|
|
357
|
+
const file = imgPath(params.srcURL);
|
|
358
|
+
if (file && servable(file, imageRoots())) {
|
|
359
|
+
const picture = nativeImage.createFromPath(file);
|
|
360
|
+
if (!picture.isEmpty()) { clipboard.writeImage(picture); return; }
|
|
361
|
+
}
|
|
362
|
+
// Anything else — an http picture, a data: uri, a canvas — is
|
|
363
|
+
// not ours to read off disk, so it goes the way it always did.
|
|
364
|
+
contents.copyImageAt(params.x, params.y);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// AND A FILE GOES ON THE CLIPBOARD AS THE FILE.
|
|
368
|
+
//
|
|
369
|
+
// She was right, and the picture fix above does not touch it. Until now the
|
|
370
|
+
// only file-shaped thing this menu could give her was "Copy Link Address" on
|
|
371
|
+
// a worker's `file://` link, which hands over
|
|
372
|
+
// `file:///Users/you/Zero/accounts/00000000-…/personas/one.md` — thirteen
|
|
373
|
+
// folders of her home directory, and the exact line her Gmail showed in the
|
|
374
|
+
// screenshot on this row. The chips under a message could not be copied at
|
|
375
|
+
// all: they are buttons, so the menu had nothing to offer.
|
|
376
|
+
//
|
|
377
|
+
// MEASURED on this Mac, scripts/measure-copy-and-link-colour.mjs: writing
|
|
378
|
+
// `public.file-url` puts a real file reference on the pasteboard —
|
|
379
|
+
// `osascript -e 'the clipboard as record'` reads back «class furl»:file
|
|
380
|
+
// Macintosh HD:… — which is what Finder, Mail and a browser's paste read as
|
|
381
|
+
// a file rather than as words. `writeBuffer` clears everything else, and
|
|
382
|
+
// that is wanted: the same rule the picture follows, one unambiguous thing
|
|
383
|
+
// to paste, because one copy leaving two answers is the whole of the bug on
|
|
384
|
+
// this row.
|
|
385
|
+
const copyFile = (file) => {
|
|
386
|
+
// The same rule the picture and the two schemes enforce: inside a root the
|
|
387
|
+
// app owns. A right click is not a reason to widen it.
|
|
388
|
+
if (!file || !servable(file, imageRoots())) return false;
|
|
389
|
+
try {
|
|
390
|
+
if (!fs.statSync(file).isFile()) return false;
|
|
391
|
+
clipboard.writeBuffer('public.file-url', Buffer.from(pathToFileURL(file).toString(), 'utf8'));
|
|
392
|
+
return true;
|
|
393
|
+
} catch { return false; }
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
// The path under her pointer, when what is under it is a chip rather than a
|
|
397
|
+
// link. `params` carries an href and an image src and nothing else, so the
|
|
398
|
+
// window is asked. Tiny, guarded, and answered before the menu is drawn.
|
|
399
|
+
const fileUnderPointer = async (contents, params) => {
|
|
400
|
+
if (params.linkURL.startsWith('file://')) {
|
|
401
|
+
try { return fileURLToPath(params.linkURL.split('#')[0]); } catch { return null; }
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
const found = await contents.executeJavaScript(
|
|
405
|
+
`(() => { const el = document.elementFromPoint(${Number(params.x)}, ${Number(params.y)}); `
|
|
406
|
+
+ `const hit = el && el.closest && el.closest('[data-copy-file]'); `
|
|
407
|
+
+ `return hit ? hit.getAttribute('data-copy-file') : null; })()`, true);
|
|
408
|
+
return typeof found === 'string' && found.startsWith('/') ? found : null;
|
|
409
|
+
} catch { return null; }
|
|
410
|
+
};
|
|
411
|
+
app.on('web-contents-created', (_event, contents) => {
|
|
412
|
+
contents.on('context-menu', async (_e, params) => {
|
|
413
|
+
const items = [];
|
|
414
|
+
const file = await fileUnderPointer(contents, params);
|
|
415
|
+
// WHAT SHE SELECTED COMES FIRST, ABOVE ANYTHING ABOUT A LINK.
|
|
416
|
+
//
|
|
417
|
+
// — and what it had pasted was the single line
|
|
418
|
+
// `mailto:hello@example.com`.
|
|
419
|
+
//
|
|
420
|
+
// That is this menu's own doing. `params.linkURL` is set whenever the
|
|
421
|
+
// POINTER is over a link, whatever is selected, and the draft she was
|
|
422
|
+
// copying has `hello@example.com` in the middle of it. So the menu she
|
|
423
|
+
// got read "Open Link / Copy Link / --- / Copy": the first copy-shaped
|
|
424
|
+
// word in it copied the href and threw her selection away. She took it,
|
|
425
|
+
// and got the mailto.
|
|
426
|
+
//
|
|
427
|
+
// A selection is the more specific thing to have asked for and it now
|
|
428
|
+
// sits at the top. The link keeps its items, under it, and "Copy Link
|
|
429
|
+
// Address" says which of the two it is rather than leaving her to guess.
|
|
430
|
+
//
|
|
431
|
+
// AND IT COPIES FROM THE CONTENTS THIS MENU WAS RAISED ON, BY NAME.
|
|
432
|
+
//
|
|
433
|
+
// The founder, 2026-09-22 (w-b108b1d596): "Clicking copy doesnt seem to
|
|
434
|
+
// actually copy it (links)", with a picture of this menu open over a
|
|
435
|
+
// `mailto:` in a draft — "Copy / --- / Open Link / Copy Link Address",
|
|
436
|
+
// so both branches below were taken and she took the top item.
|
|
437
|
+
//
|
|
438
|
+
// This item used to be `{ role: 'copy' }`, and a role is not a call. Read
|
|
439
|
+
// out of the shipped Electron framework this session, lib/browser/api/
|
|
440
|
+
// menu-item-roles.ts:
|
|
441
|
+
//
|
|
442
|
+
// execute(role, window, contents) {
|
|
443
|
+
// if (!(hasRole(role) && (!isDarwin || roleList[role].nonNativeMacOSRole))) return false;
|
|
444
|
+
// ... if (webContentsMethod && contents != null) { webContentsMethod(contents); return true }
|
|
445
|
+
// }
|
|
446
|
+
//
|
|
447
|
+
// and `copy` carries no `nonNativeMacOSRole`, so on this Mac that guard
|
|
448
|
+
// fails on the first line and `webContents.copy()` is NEVER REACHED.
|
|
449
|
+
// What ships instead is a bare Cocoa `copy:` with no target, sent up the
|
|
450
|
+
// responder chain, holding no reference to the window she right-clicked
|
|
451
|
+
// in. Every other item in this menu carries its own closure over
|
|
452
|
+
// `params` and works whatever the responder chain is doing; "Copy" was
|
|
453
|
+
// the only one that did not, and "Copy" is the one she says does
|
|
454
|
+
// nothing. When that selector finds nobody, nothing is written and the
|
|
455
|
+
// clipboard keeps whatever it held, which is exactly what she describes.
|
|
456
|
+
//
|
|
457
|
+
// MEASURED, scripts/measure-native-copy-role.mjs: on the same window and
|
|
458
|
+
// the same selection, `Menu.sendActionToFirstResponder('copy:')` left a
|
|
459
|
+
// sentinel untouched on the clipboard while `contents.copy()` put the
|
|
460
|
+
// whole selection on it, both with a picture and with a `mailto:` under
|
|
461
|
+
// the pointer. The accelerator is kept so the item still reads "⌘C" the
|
|
462
|
+
// way her picture shows it, and `registerAccelerator: false` so drawing
|
|
463
|
+
// that word does not also claim the key.
|
|
464
|
+
if (!params.isEditable && params.selectionText.trim()) {
|
|
465
|
+
items.push(
|
|
466
|
+
// Through copySelection, because `contents.copy()` alone was measured
|
|
467
|
+
// landing nothing in the real app (main/copy-selection.mjs).
|
|
468
|
+
{ label: 'Copy', accelerator: 'CommandOrControl+C', registerAccelerator: false, click: () => copySelection(contents, params.selectionText, clipboard) },
|
|
469
|
+
{ type: 'separator' },
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
// A FILE COMES BEFORE ANYTHING ABOUT ITS ADDRESS, for the same reason the
|
|
473
|
+
// selection does: it is what she meant by "copy" and the address is not.
|
|
474
|
+
if (file && servable(file, imageRoots())) {
|
|
475
|
+
items.push({ label: 'Copy File', click: () => copyFile(file) }, { type: 'separator' });
|
|
476
|
+
}
|
|
477
|
+
if (params.linkURL) {
|
|
478
|
+
items.push(
|
|
479
|
+
{ label: 'Open Link', click: () => openLink(params.linkURL) },
|
|
480
|
+
{ label: 'Copy Link Address', click: () => clipboard.writeText(params.linkURL) },
|
|
481
|
+
{ type: 'separator' },
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (params.hasImageContents) {
|
|
485
|
+
items.push(
|
|
486
|
+
{ label: 'Copy Image', click: () => copyPicture(contents, params) },
|
|
487
|
+
{ type: 'separator' },
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
// The reply composer, and the same rule for the same reason: cut, copy,
|
|
491
|
+
// paste and select all are all native macOS roles, so all four were
|
|
492
|
+
// bare selectors up the responder chain. They are named calls on the
|
|
493
|
+
// contents she right-clicked in now, like everything else in this menu.
|
|
494
|
+
if (params.isEditable) {
|
|
495
|
+
items.push(
|
|
496
|
+
{ label: 'Cut', accelerator: 'CommandOrControl+X', registerAccelerator: false, click: () => contents.cut() },
|
|
497
|
+
{ label: 'Copy', accelerator: 'CommandOrControl+C', registerAccelerator: false, click: () => copySelection(contents, params.selectionText, clipboard) },
|
|
498
|
+
{ label: 'Paste', accelerator: 'CommandOrControl+V', registerAccelerator: false, click: () => contents.paste() },
|
|
499
|
+
{ label: 'Select All', accelerator: 'CommandOrControl+A', registerAccelerator: false, click: () => contents.selectAll() },
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
while (items.length && items[items.length - 1].type === 'separator') items.pop();
|
|
503
|
+
if (!items.length) return;
|
|
504
|
+
Menu.buildFromTemplate(items).popup({ window: BrowserWindow.fromWebContents(contents) ?? window });
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
window = new BrowserWindow({
|
|
509
|
+
width: 1440,
|
|
510
|
+
height: 900,
|
|
511
|
+
minWidth: 980,
|
|
512
|
+
minHeight: 600,
|
|
513
|
+
title: NAME,
|
|
514
|
+
backgroundColor: '#1a1a1c',
|
|
515
|
+
titleBarStyle: 'hiddenInset',
|
|
516
|
+
webPreferences: {
|
|
517
|
+
preload: path.join(appDir, 'preload.cjs'),
|
|
518
|
+
contextIsolation: true,
|
|
519
|
+
nodeIntegration: false,
|
|
520
|
+
webviewTag: true, // the inbox-zero browser
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// WHICH PICTURE SET THIS WINDOW IS ON, and a shout when it changes.
|
|
525
|
+
// main/screen-detail.mjs has the rule and the reasoning; this is the wiring.
|
|
526
|
+
// The initial answer rides bootInfo rather than being pushed, for the same
|
|
527
|
+
// reason everything else there does: a push races the renderer's first
|
|
528
|
+
// effects and would be dropped by the page it is for. After that, the two
|
|
529
|
+
// things that can change the answer are the window moving to another screen
|
|
530
|
+
// and a screen changing under it, and both are watched here. Nothing is sent
|
|
531
|
+
// unless the answer actually flips, so dragging a window around does not
|
|
532
|
+
// spray IPC at the page.
|
|
533
|
+
const currentScreenDetail = () => {
|
|
534
|
+
try { return screenDetailFor(electronScreen.getDisplayMatching(window.getBounds())); }
|
|
535
|
+
catch { return 'sharp'; }
|
|
536
|
+
};
|
|
537
|
+
let lastScreenDetail = currentScreenDetail();
|
|
538
|
+
const tellScreenDetail = () => {
|
|
539
|
+
if (!window || window.isDestroyed()) return;
|
|
540
|
+
const next = currentScreenDetail();
|
|
541
|
+
if (next === lastScreenDetail) return;
|
|
542
|
+
lastScreenDetail = next;
|
|
543
|
+
window.webContents.send('zero:screen-detail', { detail: next });
|
|
544
|
+
};
|
|
545
|
+
window.on('move', tellScreenDetail);
|
|
546
|
+
window.on('moved', tellScreenDetail);
|
|
547
|
+
electronScreen.on('display-metrics-changed', tellScreenDetail);
|
|
548
|
+
electronScreen.on('display-added', tellScreenDetail);
|
|
549
|
+
electronScreen.on('display-removed', tellScreenDetail);
|
|
550
|
+
window.on('closed', () => {
|
|
551
|
+
electronScreen.removeListener('display-metrics-changed', tellScreenDetail);
|
|
552
|
+
electronScreen.removeListener('display-added', tellScreenDetail);
|
|
553
|
+
electronScreen.removeListener('display-removed', tellScreenDetail);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// The junk browser's session presents as plain Chrome. Electron's default
|
|
557
|
+
// UA carries "Electron/x Zero/x", which makes Google-family logins refuse
|
|
558
|
+
// even though a human is typing. This is identity cleanup for an embedded
|
|
559
|
+
// browser a human drives, not automation.
|
|
560
|
+
const junk = session.fromPartition('persist:junk');
|
|
561
|
+
junk.setUserAgent(junk.getUserAgent().replace(/\s?(zero|Electron)\/\S+/gi, ''));
|
|
562
|
+
|
|
563
|
+
// THE APP KEEPS ITSELF CURRENT (main/updater.mjs). It checks a few seconds
|
|
564
|
+
// from now, downloads anything newer in the background, and then waits: the
|
|
565
|
+
// restart is a button she presses, never something that happens under her.
|
|
566
|
+
// `pushUpdate` is filled in on the next line, because the updater has to
|
|
567
|
+
// exist before the IPC surface that reports it and the IPC surface is what
|
|
568
|
+
// knows how to reach the window.
|
|
569
|
+
let pushUpdate = () => {};
|
|
570
|
+
const updater = createUpdater({
|
|
571
|
+
app,
|
|
572
|
+
onChanged: () => pushUpdate(),
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// The desktop door. `main/serve.mjs` is the other one and hands over plain
|
|
576
|
+
// Node stand-ins for these three; everything else about the call is the same.
|
|
577
|
+
const ipc = registerIpc({
|
|
578
|
+
store, supervisor, config, window, analytics, docGrants, updater,
|
|
579
|
+
host: { ipcMain, app, dialog },
|
|
580
|
+
});
|
|
581
|
+
pushUpdate = ipc.push;
|
|
582
|
+
// The Agents menu's Allow/Deny, bound to the real door now that there is one.
|
|
583
|
+
// See the `installMenu` call above for what writing the file instead did to a
|
|
584
|
+
// Codex card.
|
|
585
|
+
answerApproval = ipc.answerApproval;
|
|
586
|
+
updater.start();
|
|
587
|
+
app.on('before-quit', () => updater.stop());
|
|
588
|
+
|
|
589
|
+
// Cmd+Y / Cmd+N answer the oldest approval card from ANYWHERE, including
|
|
590
|
+
// while a child web contents owns the keyboard (menu accelerators turned out
|
|
591
|
+
// not to fire there, 2026-08-06). before-input-event runs in the main
|
|
592
|
+
// process ahead of any page dispatch, so nothing on the page can swallow
|
|
593
|
+
// it. These two chords, and nothing else, outrank whatever is in front (her
|
|
594
|
+
// rule).
|
|
595
|
+
window.webContents.on('before-input-event', (event, input) => {
|
|
596
|
+
if (input.type !== 'keyDown') return;
|
|
597
|
+
// ESCAPE HAS TO REACH THE APP FROM INSIDE AN OPEN FILE.
|
|
598
|
+
//
|
|
599
|
+
// She was inside the document pane, and escape is the app's one way back
|
|
600
|
+
// out of it.
|
|
601
|
+
//
|
|
602
|
+
// A keydown does not cross a document boundary. The page in the pane is its
|
|
603
|
+
// own document under its own origin (main/doc-scheme.mjs), so the moment her
|
|
604
|
+
// click lands inside it the app's own window listener never hears the key
|
|
605
|
+
// again. This runs below the page and hears it either way.
|
|
606
|
+
//
|
|
607
|
+
// IT ONLY FORWARDS FROM A FRAME, so the app's own escape is untouched: with
|
|
608
|
+
// the keyboard on the app's page the renderer's own handler is the only one
|
|
609
|
+
// that answers, exactly as before. Measured on Electron 43, 2026-08-21:
|
|
610
|
+
// `focusedFrame` reads the document's frame while the file has the keyboard
|
|
611
|
+
// and the main frame the moment the app takes it back. The renderer checks
|
|
612
|
+
// the same fact again from its own side (focusIsInTheFile, doc-pane.ts).
|
|
613
|
+
//
|
|
614
|
+
// ⌘K COMES UP THE SAME WAY, and for the same reason. Measured on the real
|
|
615
|
+
// renderer that day: with the keyboard inside a page in the pane, ⌘K was
|
|
616
|
+
// dead. Escape was the only key this branch had ever forwarded, so it was
|
|
617
|
+
// the only one that worked. The rule for which keys are the app's is
|
|
618
|
+
// written once, in shared/artifact-keys.mjs, and read here and by the pane.
|
|
619
|
+
//
|
|
620
|
+
// HER LETTERS COME UP TOO, FROM A PAGE ONLY.E is not ⌘K: measured that day,
|
|
621
|
+
// it archives the card perfectly well while her keyboard is on the message,
|
|
622
|
+
// with a file open beside it or without one. It dies only inside the
|
|
623
|
+
// artifact, and only in a PAGE is that wrong — in a markdown file and in a
|
|
624
|
+
// line of a change she is typing, and there a letter has to stay a letter.
|
|
625
|
+
// A page has nothing to type in, so nothing in it is text, and the single
|
|
626
|
+
// reason E was dead is that this list was Escape and chords.
|
|
627
|
+
//
|
|
628
|
+
// EXCEPT WHEN THE PAGE ITSELF IS TAKING TEXT. Her pages are her own html
|
|
629
|
+
// and some of them carry a search box or a field in a mockup (15 of the 406
|
|
630
|
+
// under this product, counted 08-24). Only the frame can see its own focus,
|
|
631
|
+
// so the frame is asked, and the answer comes back a few milliseconds later
|
|
632
|
+
// — which is fine, because nothing here ever preventDefaults and the page
|
|
633
|
+
// has already had the key regardless.
|
|
634
|
+
const aLetterFromAPage = !theAppKeepsThisInput(input) && theAppKeepsThisLetterInAPage(input);
|
|
635
|
+
if (theAppKeepsThisInput(input) || aLetterFromAPage) {
|
|
636
|
+
let theFrame = null;
|
|
637
|
+
try {
|
|
638
|
+
const frame = window.webContents.focusedFrame;
|
|
639
|
+
if (frame && frame !== window.webContents.mainFrame) theFrame = frame;
|
|
640
|
+
} catch { theFrame = null; /* an older Electron cannot say; leave it alone */ }
|
|
641
|
+
const sendItUp = () => window.webContents.send('zero:key-in-the-file', {
|
|
642
|
+
key: input.key, meta: !!input.meta, ctrl: !!input.control, alt: !!input.alt, shift: !!input.shift,
|
|
643
|
+
});
|
|
644
|
+
// Never preventDefault: the page under it may want the key too, and the
|
|
645
|
+
// renderer decides what escape MEANS, here and everywhere else.
|
|
646
|
+
if (theFrame && !aLetterFromAPage) sendItUp();
|
|
647
|
+
else if (theFrame) {
|
|
648
|
+
theFrame.executeJavaScript(IS_SHE_TYPING_IN_THE_PAGE, false)
|
|
649
|
+
.then((typing) => { if (!typing) sendItUp(); })
|
|
650
|
+
.catch(() => { /* the frame went away mid-press; the key is simply the page's */ });
|
|
651
|
+
}
|
|
652
|
+
// Escape stops here whatever happens; it is not one of the chords below.
|
|
653
|
+
// A chord falls THROUGH, because ⌘R, ⌘=, ⌘0, ⌘Y and ⌘N are answered
|
|
654
|
+
// further down this same handler and forwarding one must not take that
|
|
655
|
+
// away. The renderer answers only Escape, ⌘K and her letters
|
|
656
|
+
// (whatTheFileSentUp).
|
|
657
|
+
if (input.key === 'Escape') return;
|
|
658
|
+
}
|
|
659
|
+
if (!input.meta) return;
|
|
660
|
+
// ⌘=, ⌘- AND ⌘0 BELONG HERE FOR THE SAME REASON ⌘R DOES.They were menu
|
|
661
|
+
// accelerators and nothing else, and this handler exists because menu
|
|
662
|
+
// accelerators in this app have twice turned out not to arrive. Zoom is the
|
|
663
|
+
// worst chord to leave on that route: Chromium stores the level against the
|
|
664
|
+
// page's URL and restores it on every load, so a window that is zoomed
|
|
665
|
+
// stays zoomed through ⌘R, through a rebuild, and through a relaunch, with
|
|
666
|
+
// no way back.
|
|
667
|
+
const zoomDelta = zoomDeltaFor(input);
|
|
668
|
+
if (zoomDelta !== null) {
|
|
669
|
+
applyZoom(zoomDelta, window.webContents);
|
|
670
|
+
event.preventDefault();
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const key = String(input.key).toLowerCase();
|
|
674
|
+
// ⌘R BELONGS HERE FOR THE SAME REASON ⌘Y AND ⌘N DO. It was a menu
|
|
675
|
+
// accelerator and nothing else, and the note above records what this file
|
|
676
|
+
// already learned about those: they do not fire while a child web contents
|
|
677
|
+
// owns the keyboard, which an open document does the whole time it is up.
|
|
678
|
+
// She has reported ⌘R never working twice now, five days apart, and the
|
|
679
|
+
// first fix (2026-08-06) changed WHAT the menu item does without moving it
|
|
680
|
+
// off the accelerator that was not arriving. before-input-event runs in the
|
|
681
|
+
// main process ahead of any page dispatch, so nothing on the page can
|
|
682
|
+
// swallow it. Menu item stays, for discoverability and for the label.
|
|
683
|
+
if (key === 'r') {
|
|
684
|
+
reloadRenderer();
|
|
685
|
+
event.preventDefault();
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// ⌘F AND ⌘G, HERE FOR THE SAME REASON. Nothing in the app answered ⌘F at
|
|
689
|
+
// all until w-cc5bc203d0 (2026-09-26), and a menu accelerator alone would
|
|
690
|
+
// go dead the moment her click lands in an open file. The page decides what
|
|
691
|
+
// the press means; this only says which one it was.
|
|
692
|
+
if (key === 'f') {
|
|
693
|
+
requestFind(0);
|
|
694
|
+
event.preventDefault();
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (key === 'g') {
|
|
698
|
+
requestFind(input.shift ? -1 : 1);
|
|
699
|
+
event.preventDefault();
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
if (key !== 'y' && key !== 'n') return;
|
|
703
|
+
const oldest = approvals.listPending(config.storeRoot)[0];
|
|
704
|
+
if (!oldest) return;
|
|
705
|
+
// Through the ipc layer's own door, so the renderer is told what was
|
|
706
|
+
// decided and can acknowledge it. Answering the file directly from here is
|
|
707
|
+
// what made the chord feel like nothing had happened (2026-08-06).
|
|
708
|
+
ipc.answerApproval(oldest.id, key === 'y');
|
|
709
|
+
event.preventDefault(); // the page never sees the chord; no double-answer
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// ---- END OF THE CHORD WIRING ----
|
|
713
|
+
//
|
|
714
|
+
// THIS LINE IS LOAD-BEARING: two tests slice main.mjs from the input handler
|
|
715
|
+
// above to these words to read the chord wiring on its own, so the words at
|
|
716
|
+
// the start of it are not free to change. It used to read "// The arcade.",
|
|
717
|
+
// because what stood here was the emulator, its save vault and the shelf that
|
|
718
|
+
// reached them. All of that is deleted; see decisions.md.
|
|
719
|
+
|
|
720
|
+
store.watch();
|
|
721
|
+
// ZERO_NO_SUPERVISOR: run the inbox without spawning any sessions (UI work,
|
|
722
|
+
// screenshots, cautious first runs).
|
|
723
|
+
if (!process.env.ZERO_NO_SUPERVISOR) {
|
|
724
|
+
// COMING BACK AFTER THE LID (her pick A on, her option 1 on). The app had no
|
|
725
|
+
// idea the machine could sleep: `powerMonitor` appeared nowhere in this
|
|
726
|
+
// process, and startup looked only for fresh work, never at the wreckage
|
|
727
|
+
// the last run left. Measured: 34 mass die-offs in 12 days, 134 sessions
|
|
728
|
+
// the app went away without reaping.
|
|
729
|
+
//
|
|
730
|
+
// The startup half lives inside supervisor.start, ahead of its first tick,
|
|
731
|
+
// because the tick is what would otherwise put a stranger on those rows.
|
|
732
|
+
// This is the other half: the lid.
|
|
733
|
+
supervisor.onRecovered = (r) => {
|
|
734
|
+
const line = recoveryToast(r);
|
|
735
|
+
console.log(`zero: ${r.reason} recovery — resumed ${r.resumed}, waiting on her ${r.waiting}, no slot yet ${r.queued}`);
|
|
736
|
+
if (!line) return;
|
|
737
|
+
// The startup sweep runs BEFORE the page exists, so its line is held
|
|
738
|
+
// rather than shouted at nobody: the renderer collects it on mount with
|
|
739
|
+
// the rest of its boot info. A wake sweep goes straight out.
|
|
740
|
+
pendingRecovery = line;
|
|
741
|
+
if (pageReady && window && !window.isDestroyed()) {
|
|
742
|
+
window.webContents.send('zero:recovered', { text: line });
|
|
743
|
+
pendingRecovery = null;
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
supervisor.start();
|
|
747
|
+
powerMonitor.on('resume', () => {
|
|
748
|
+
setTimeout(() => {
|
|
749
|
+
try { supervisor.recoverInterrupted('wake'); } catch (e) { console.warn('zero: wake recovery:', e.message); }
|
|
750
|
+
}, WAKE_SETTLE_MS);
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Unread badge + notification on new inbox arrivals are driven by the
|
|
755
|
+
// renderer (it owns the definition of "inbox"), through the badge API.
|
|
756
|
+
ipcMain.handle('zero:badge', (_e, count) => {
|
|
757
|
+
if (process.platform === 'darwin') app.dock.setBadge(count > 0 ? String(count) : '');
|
|
758
|
+
});
|
|
759
|
+
// WHETHER to speak, and what to say, is not the renderer's call: only this
|
|
760
|
+
// process can see whether the window has focus, and a page cannot be trusted
|
|
761
|
+
// to know she is looking at it (a webview owning the keyboard, another Space,
|
|
762
|
+
// a locked screen all read the same from in there). The renderer says what
|
|
763
|
+
// arrived; main/notify.mjs decides. tell me when I am in another app, never
|
|
764
|
+
// while I am in Agentbox.
|
|
765
|
+
installNotifier({ app, window, Notification, powerMonitor, ipcMain });
|
|
766
|
+
// Asked for once by the renderer as it mounts, rather than pushed on
|
|
767
|
+
// did-finish-load, which races the first effects and would drop the very
|
|
768
|
+
// message it exists to deliver.
|
|
769
|
+
ipcMain.handle('zero:boot-info', () => {
|
|
770
|
+
const info = {
|
|
771
|
+
reloaded: reloadRequested, builtAt: builtAt(), recovered: pendingRecovery,
|
|
772
|
+
screenDetail: currentScreenDetail(),
|
|
773
|
+
};
|
|
774
|
+
reloadRequested = false;
|
|
775
|
+
pendingRecovery = null;
|
|
776
|
+
return info;
|
|
777
|
+
});
|
|
778
|
+
// The window's own uncaught errors. The renderer cannot write a report
|
|
779
|
+
// itself (it has no disk and should not have one), and 'render-process-gone'
|
|
780
|
+
// only fires when the process DIES, which a thrown React error does not.
|
|
781
|
+
ipcMain.handle('zero:crash', (_e, payload) => { reportFromRenderer(payload ?? {}); });
|
|
782
|
+
// External links from markdown open in the real browser; local artifacts
|
|
783
|
+
// (a demo page, a screenshot a worker saved) open in their default app, so
|
|
784
|
+
// "see it working" is one click from the message.
|
|
785
|
+
window.webContents.setWindowOpenHandler(({ url }) => {
|
|
786
|
+
if (/^https?:/.test(url)) shell.openExternal(url);
|
|
787
|
+
else if (url.startsWith('file://')) shell.openPath(decodeURIComponent(url.replace('file://', '')));
|
|
788
|
+
return { action: 'deny' };
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// NO PAGE IN AN EMBEDDED BROWSER STARTS SOUND BEFORE SHE HAS CLICKED IN IT.
|
|
792
|
+
//
|
|
793
|
+
// Electron's default is `no-user-gesture-required`, so any page may start
|
|
794
|
+
// media the moment it loads, and the rest browser reloads her last tab at
|
|
795
|
+
// every launch, out of sight. Measured 2026-09-10 on Electron 43 in a hidden
|
|
796
|
+
// webview on a YouTube watch page: default, the video ran from 3s to 15s;
|
|
797
|
+
// with this rule it sat paused at 0 for all 16s. A click anywhere in the page
|
|
798
|
+
// is activation, so pressing play, or picking the next video inside YouTube,
|
|
799
|
+
// works as before. Every webview this window attaches gets it, the junk
|
|
800
|
+
// browser included, which is what "never" asks for.
|
|
801
|
+
window.webContents.on('will-attach-webview', (_e, webPreferences) => {
|
|
802
|
+
webPreferences.autoplayPolicy = 'document-user-activation-required';
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// Escape must ALWAYS leave the junk browser, even when the guest page has
|
|
806
|
+
// focus and would otherwise swallow the key. Intercept it below the page.
|
|
807
|
+
app.on('web-contents-created', (_event, contents) => {
|
|
808
|
+
if (contents.getType() !== 'webview') return;
|
|
809
|
+
contents.on('before-input-event', (e, input) => {
|
|
810
|
+
if (input.type === 'keyDown' && input.key === 'Escape' && window && !window.isDestroyed()) {
|
|
811
|
+
window.webContents.send('zero:escape-browser');
|
|
812
|
+
window.webContents.focus();
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
// The guest page owns the keyboard while she is in the junk browser, so
|
|
816
|
+
// the window's handler never hears the zoom chord. Zoom the guest, not
|
|
817
|
+
// the inbox: a webview carries its own level.
|
|
818
|
+
const delta = zoomDeltaFor(input);
|
|
819
|
+
if (delta !== null) {
|
|
820
|
+
applyZoom(delta, contents);
|
|
821
|
+
e.preventDefault();
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
// ZERO_DEBUG_CONSOLE: surface renderer and iframe console output in the
|
|
827
|
+
// terminal, which is the only way to see inside a child frame on an
|
|
828
|
+
// unattended run.
|
|
829
|
+
if (process.env.ZERO_DEBUG_CONSOLE) {
|
|
830
|
+
app.on('web-contents-created', (_e, contents) => {
|
|
831
|
+
contents.on('console-message', (_ev, level, message, line, source) => {
|
|
832
|
+
console.log(`[renderer:${level}] ${message} (${String(source).split('/').pop()}:${line})`);
|
|
833
|
+
});
|
|
834
|
+
});
|
|
835
|
+
window.webContents.on('console-message', (_ev, level, message, line, source) => {
|
|
836
|
+
console.log(`[renderer:${level}] ${message} (${String(source).split('/').pop()}:${line})`);
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const devUrl = process.env.ZERO_DEV_URL;
|
|
841
|
+
const parts = [];
|
|
842
|
+
if (process.env.ZERO_FIXTURES) parts.push(`fixtures=${process.env.ZERO_FIXTURES === 'empty' ? 'empty' : '1'}`);
|
|
843
|
+
if (process.env.ZERO_FOCUS) parts.push('focus=1');
|
|
844
|
+
const search = parts.length ? parts.join('&') : undefined;
|
|
845
|
+
// A HOT-RELOADING WINDOW MUST NOT BE THE ONE SHE WORKS IN. Every dev window
|
|
846
|
+
// that can ever exist comes through here, which is why the guard is here and
|
|
847
|
+
// not in the npm script: the one that cost her an afternoon was started by
|
|
848
|
+
// hand. See main/dev-window.mjs for what it allows and why.
|
|
849
|
+
const verdict = hotWindowVerdict({
|
|
850
|
+
devUrl,
|
|
851
|
+
fixtures: !!process.env.ZERO_FIXTURES,
|
|
852
|
+
forced: process.env.ZERO_DEV_ON_REAL_STORE === '1',
|
|
853
|
+
storeRoot: config.storeRoot,
|
|
854
|
+
storeHasWork: storeHasWork(fs, path, config.storeRoot),
|
|
855
|
+
});
|
|
856
|
+
const built = path.join(appDir, 'renderer', 'dist', 'index.html');
|
|
857
|
+
if (devUrl && !verdict.allow) {
|
|
858
|
+
console.error(verdict.message);
|
|
859
|
+
// AND IT STILL OPENS A WINDOW. Refusing the dev server and then showing
|
|
860
|
+
// nothing would trade her bug for a dead app. The built renderer is the
|
|
861
|
+
// right answer when there is one; when there is not, the dev server is
|
|
862
|
+
// better than a blank screen and the line above already said so.
|
|
863
|
+
if (fs.existsSync(built)) await window.loadFile(built, search ? { search } : undefined);
|
|
864
|
+
else {
|
|
865
|
+
console.error(' There is no built renderer to fall back to, so the dev server it is. Run `npm run build`.\n');
|
|
866
|
+
await window.loadURL(search ? `${devUrl}?${search}` : devUrl);
|
|
867
|
+
}
|
|
868
|
+
} else if (devUrl) {
|
|
869
|
+
await window.loadURL(search ? `${devUrl}?${search}` : devUrl);
|
|
870
|
+
} else {
|
|
871
|
+
await window.loadFile(built, search ? { search } : undefined);
|
|
872
|
+
}
|
|
873
|
+
pageReady = true;
|
|
874
|
+
|
|
875
|
+
// Dev aid: ZERO_SCREENSHOT=path captures the window after load and quits,
|
|
876
|
+
// so the UI can be verified without a human at the screen.
|
|
877
|
+
const shotPath = process.env.ZERO_SCREENSHOT;
|
|
878
|
+
if (shotPath) {
|
|
879
|
+
console.log(`loaded: ${window.webContents.getURL()}`);
|
|
880
|
+
setTimeout(async () => {
|
|
881
|
+
try {
|
|
882
|
+
if (process.env.ZERO_DEBUG_CONSOLE) {
|
|
883
|
+
const dom = await window.webContents.executeJavaScript(
|
|
884
|
+
'JSON.stringify({ text: document.body.innerText.slice(0, 240), classes: [...document.querySelectorAll("[class]")].slice(0, 12).map(e => e.className) })',
|
|
885
|
+
);
|
|
886
|
+
console.log(`dom: ${dom}`);
|
|
887
|
+
}
|
|
888
|
+
const image = await window.webContents.capturePage();
|
|
889
|
+
const fs = await import('node:fs');
|
|
890
|
+
fs.writeFileSync(shotPath, image.toPNG());
|
|
891
|
+
console.log(`screenshot: ${shotPath}`);
|
|
892
|
+
} catch (err) {
|
|
893
|
+
console.error('screenshot failed:', err.message);
|
|
894
|
+
}
|
|
895
|
+
app.quit();
|
|
896
|
+
}, Number(process.env.ZERO_SCREENSHOT_DELAY_MS ?? 2500));
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
window.on('closed', () => {
|
|
900
|
+
supervisor.stop();
|
|
901
|
+
store.unwatch();
|
|
902
|
+
window = null;
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ONE the app, ever. Two instances mean two supervisors, and two supervisors
|
|
907
|
+
// spawn twin workers for every answered item (observed 2026-08-04: nine items
|
|
908
|
+
// double-spawned one second apart, four agents told the founder the same
|
|
909
|
+
// thing). A second launch just focuses the first.
|
|
910
|
+
if (!app.requestSingleInstanceLock()) {
|
|
911
|
+
app.quit();
|
|
912
|
+
} else {
|
|
913
|
+
app.on('second-instance', () => {
|
|
914
|
+
if (window) {
|
|
915
|
+
if (window.isMinimized()) window.restore();
|
|
916
|
+
window.focus();
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
app.whenReady().then(() => {
|
|
920
|
+
// Serve a granted file, and nothing else. A page under this scheme is one
|
|
921
|
+
// of ours, but it is written by an agent and read by her, so the check is
|
|
922
|
+
// on every request rather than only on the one the pane asked for.
|
|
923
|
+
// On every spelling this app has used, because a stored url carries the
|
|
924
|
+
// name the app had the day it was written.
|
|
925
|
+
for (const scheme of DOC_SCHEMES) protocol.handle(scheme, async (request) => {
|
|
926
|
+
const file = docPath(request.url);
|
|
927
|
+
if (!file || !docGrants.allows(file)) return new Response('Not available.', { status: 403 });
|
|
928
|
+
try {
|
|
929
|
+
// The file is streamed exactly as it is on disk, pictures and all.
|
|
930
|
+
// Nothing is injected into a page any more; see doc-scheme.mjs for the
|
|
931
|
+
// pinch zoom that briefly lived here and why she turned it down.
|
|
932
|
+
//
|
|
933
|
+
// NO-STORE, OR A REDRAWN PICTURE NEVER REACHES HER. Hers, w-3fc39983be,
|
|
934
|
+
// 2026-09-28: "the font in the images is STILL not changing. Seems like
|
|
935
|
+
// a bug". It was. An agent redrew six pictures under the same names;
|
|
936
|
+
// the page's html came back new and every <img> in it came back from
|
|
937
|
+
// the window's memory, round one's pictures under round two's captions,
|
|
938
|
+
// until a restart. Measured in a hidden probe of this exact setup
|
|
939
|
+
// (file:// window, sandboxed frame, this scheme, net.fetch): picture
|
|
940
|
+
// replaced on disk 2250 -> 3118 wide, page reopened, frame drew 2250.
|
|
941
|
+
// With this header it drew 3118. These are local files; there is no
|
|
942
|
+
// download to save by caching them.
|
|
943
|
+
return noStore(await net.fetch(pathToFileURL(file).toString()));
|
|
944
|
+
} catch {
|
|
945
|
+
return new Response('Not there.', { status: 404 });
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
// And her own pictures, on their own scheme, so the window can draw them
|
|
949
|
+
// whichever origin it was loaded from (main/img-scheme.mjs). Two rules and
|
|
950
|
+
// both have to pass: it is a picture, and it is inside a root the app owns.
|
|
951
|
+
for (const scheme of IMG_SCHEMES) protocol.handle(scheme, async (request) => {
|
|
952
|
+
const file = imgPath(request.url);
|
|
953
|
+
if (!file || !servable(file, imageRoots())) return new Response('Not available.', { status: 403 });
|
|
954
|
+
try {
|
|
955
|
+
const res = await net.fetch(pathToFileURL(file).toString());
|
|
956
|
+
// The window is allowed to read its own pictures back out, which is
|
|
957
|
+
// what a copy needs. This widens nothing: the two rules above already
|
|
958
|
+
// decided this file may be seen at all, and only this window can ask on
|
|
959
|
+
// this scheme.
|
|
960
|
+
// No-store here too, for the same reason as the page scheme above: a
|
|
961
|
+
// screenshot an agent retakes under the same name is still a new picture.
|
|
962
|
+
const out = noStore(res);
|
|
963
|
+
out.headers.set('access-control-allow-origin', '*');
|
|
964
|
+
return out;
|
|
965
|
+
} catch {
|
|
966
|
+
return new Response('Not there.', { status: 404 });
|
|
967
|
+
}
|
|
968
|
+
});
|
|
969
|
+
createWindow();
|
|
970
|
+
});
|
|
971
|
+
app.on('window-all-closed', () => app.quit());
|
|
972
|
+
}
|