claude-code-session-manager 0.61.0 → 0.62.1

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.
Files changed (38) hide show
  1. package/dist/assets/{TiptapBody-CCmz3PdX.js → TiptapBody-BwN4pNC0.js} +42 -42
  2. package/dist/assets/{index-CPMP2XZ_.css → index-CVCCMw5o.css} +1 -1
  3. package/dist/assets/index-ZjTDk5b7.js +3197 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -2
  6. package/src/main/__tests__/chat-preamble-anchors.test.cjs +100 -0
  7. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
  8. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
  9. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
  10. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
  11. package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
  12. package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
  13. package/src/main/chatRunner.cjs +4 -0
  14. package/src/main/config.cjs +30 -23
  15. package/src/main/files.cjs +20 -0
  16. package/src/main/index.cjs +1 -63
  17. package/src/main/ipcSchemas.cjs +11 -209
  18. package/src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs +87 -0
  19. package/src/main/lib/activeIndexMerge.cjs +9 -0
  20. package/src/main/lib/opsOwnership.cjs +0 -2
  21. package/src/main/lib/summarize.cjs +0 -1
  22. package/src/main/runVerify.cjs +55 -6
  23. package/src/main/scheduler.cjs +46 -7
  24. package/src/preload/api.d.ts +2 -197
  25. package/src/preload/index.cjs +1 -100
  26. package/dist/assets/index-B5994QOJ.js +0 -3222
  27. package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
  28. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
  29. package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
  30. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
  31. package/src/main/browserAgentServer.cjs +0 -224
  32. package/src/main/browserCapture.cjs +0 -728
  33. package/src/main/browserView.cjs +0 -916
  34. package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
  35. package/src/main/lib/browserAgentActions.cjs +0 -114
  36. package/src/main/lib/e2eStateMachine.cjs +0 -39
  37. package/src/main/webRemote.cjs +0 -1367
  38. package/src/preload/browserViewPreload.cjs +0 -118
@@ -1,4 +1,4 @@
1
- const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut, shell, clipboard, nativeImage, powerSaveBlocker, protocol } = require('electron');
1
+ const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut, shell, clipboard, powerSaveBlocker, protocol } = require('electron');
2
2
  const { spawn, execFile, execFileSync } = require('node:child_process');
3
3
  const path = require('node:path');
4
4
  const fs = require('node:fs');
@@ -9,8 +9,6 @@ const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
9
9
  const { terminateLosingInstance } = require('./lib/singleInstanceGuard.cjs');
10
10
  const { acquireSchedulerOwnership, releaseSchedulerOwnership } = require('./lib/instanceLock.cjs');
11
11
  const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
12
- const browserView = require('./browserView.cjs');
13
- const browserCapture = require('./browserCapture.cjs');
14
12
  const configMgr = require('./config.cjs');
15
13
  const transcripts = require('./transcripts.cjs');
16
14
  const sessionsStore = require('./sessionsStore.cjs');
@@ -40,13 +38,6 @@ const adminHttp = createAdminHttp();
40
38
  scheduler.registerAdminRoutes(adminHttp);
41
39
  prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
42
40
  chatRunner.registerAdminRoute(adminHttp);
43
- const { createBrowserAgentServer } = require('./browserAgentServer.cjs');
44
- const browserAgentServer = createBrowserAgentServer({
45
- listTabs: () => browserView.listViews(),
46
- screenshot: ({ viewId }) => browserView.captureShotForAgent({ viewId }),
47
- action: (params) => browserView.dispatchViewAction(params),
48
- dom: ({ viewId }) => browserView.captureAgentDom({ viewId }),
49
- });
50
41
  const supervisor = require('./supervisor.cjs');
51
42
  const watchers = require('./watchers.cjs');
52
43
  const teams = require('./teams.cjs');
@@ -69,7 +60,6 @@ const agentMemory = require('./agentMemory.cjs');
69
60
  const git = require('./git.cjs');
70
61
  const filesIpc = require('./files.cjs');
71
62
  const { registerDocEditHandlers, attachWindow: attachDocEditWindow } = require('./docEdit.cjs');
72
- const webRemote = require('./webRemote.cjs');
73
63
  const { listExchanges } = require('./exchanges.cjs');
74
64
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
75
65
  const { checkInsideHome, assertInsideHome } = require('./lib/insideHome.cjs');
@@ -306,8 +296,6 @@ async function rebootApp() {
306
296
  ptyManager.attachWindow(mainWindow);
307
297
  configMgr.attachWindow(mainWindow);
308
298
  transcripts.attachWindow(mainWindow);
309
- browserView.attachWindow(mainWindow);
310
- browserCapture.attachWindow(mainWindow);
311
299
  voiceHotkey.init(mainWindow).catch((e) => {
312
300
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'reinit failed', meta: { error: e?.message } });
313
301
  });
@@ -506,25 +494,6 @@ ipcMain.handle('app:pick-directory', async () => {
506
494
 
507
495
  ipcMain.on('app:reboot-app', () => rebootApp());
508
496
 
509
- // Recorder export "Save to file…" (PRD 412) — native Save As dialog, written
510
- // directly since the path is user-chosen, not renderer input.
511
- ipcMain.handle('browser:save-recording', validated(schemas.browserSaveRecording, async ({ defaultName, text }) => {
512
- try {
513
- const result = await dialog.showSaveDialog(mainWindow, {
514
- defaultPath: defaultName,
515
- filters: [
516
- { name: 'Markdown', extensions: ['md'] },
517
- { name: 'All Files', extensions: ['*'] },
518
- ],
519
- });
520
- if (result.canceled || !result.filePath) return { ok: false, canceled: true };
521
- await fsp.writeFile(result.filePath, text, 'utf8');
522
- return { ok: true, path: result.filePath };
523
- } catch (e) {
524
- return { ok: false, error: e && e.message ? e.message : String(e) };
525
- }
526
- }));
527
-
528
497
  // Image paste — Ctrl+V in the Terminal pane. Reads the OS clipboard via
529
498
  // Electron's native API (renderer's navigator.clipboard.read() doesn't expose
530
499
  // raw image MIME types under contextIsolation), saves the bitmap to a temp
@@ -570,19 +539,6 @@ ipcMain.handle('clipboard:write-text', validated(schemas.clipboardWriteText, ({
570
539
  }
571
540
  }));
572
541
 
573
- // PRD 407 Capture panel — write side of paste-image's read. Writes a
574
- // screenshot capture to the OS clipboard as an image.
575
- ipcMain.handle('browser:copy-image', validated(schemas.browserCopyImage, ({ dataUrl }) => {
576
- try {
577
- const img = nativeImage.createFromDataURL(dataUrl);
578
- if (!img || img.isEmpty()) return { ok: false, error: 'empty image' };
579
- clipboard.writeImage(img);
580
- return { ok: true };
581
- } catch (e) {
582
- return { ok: false, error: e && e.message ? e.message : String(e) };
583
- }
584
- }));
585
-
586
542
  // Hooks tab "Test fire": run a hook command with a fake event payload piped
587
543
  // to stdin. shell:true is intentional — Claude Code's hook field is a shell
588
544
  // string. Timeout is enforced via SIGKILL on a timer because spawn's built-in
@@ -841,7 +797,6 @@ agentMemory.registerAgentMemoryHandlers();
841
797
  git.register(ipcMain);
842
798
  filesIpc.registerFilesHandlers();
843
799
  registerDocEditHandlers();
844
- webRemote.registerRemoteHandlers();
845
800
  chatRunner.registerChatHandlers();
846
801
 
847
802
  // Direct in-process PRD authoring for a queued PromptTicket classified
@@ -943,11 +898,6 @@ app.on('web-contents-created', (_e, wc) => {
943
898
  });
944
899
 
945
900
  wc.on('will-navigate', (event, url) => {
946
- // The embedded Browser tab's WebContentsView is a real browser — it must
947
- // be able to navigate anywhere. Exempt ONLY webContents registered by
948
- // browserView.cjs; the main window's lock below is unchanged.
949
- if (browserView.isBrowserViewContents(wc.id)) return;
950
-
951
901
  const allowed = useDevServer
952
902
  ? ['http://localhost:5173', 'http://127.0.0.1:5173']
953
903
  : [];
@@ -1144,16 +1094,12 @@ app.whenReady().then(async () => {
1144
1094
  ptyManager.attachWindow(mainWindow);
1145
1095
  configMgr.attachWindow(mainWindow);
1146
1096
  transcripts.attachWindow(mainWindow);
1147
- browserView.registerBrowserView({ mainWindow, ipcMain });
1148
- browserCapture.attachWindow(mainWindow);
1149
- browserCapture.registerBrowserCapture({ ipcMain, getView: browserView.getView });
1150
1097
  voiceHotkey.init(mainWindow).catch((e) => {
1151
1098
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'init failed', meta: { error: e?.message } });
1152
1099
  });
1153
1100
  scheduler.attachWindow(mainWindow);
1154
1101
  watchers.attachWindow(mainWindow);
1155
1102
  pluginInstall.attachWindow(mainWindow);
1156
- webRemote.attachWindow(mainWindow);
1157
1103
  chatRunner.attachWindow(mainWindow);
1158
1104
  promptSessionEvents.attachWindow(mainWindow);
1159
1105
  attachDocEditWindow(mainWindow);
@@ -1177,18 +1123,12 @@ app.whenReady().then(async () => {
1177
1123
  console.log(`[main] scheduler ownership held by pid ${ownership.holderPid} — running scheduler-passive (no reconciliation, no admin server)`);
1178
1124
  logs.writeLine({ scope: 'scheduler', level: 'info', message: 'scheduler-passive: ownership held by another instance', meta: { holderPid: ownership.holderPid } });
1179
1125
  }
1180
- browserAgentServer.start().catch((e) => {
1181
- logs.writeLine({ scope: 'browser-agent-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
1182
- });
1183
1126
  // First-boot default: install the bundled session-manager-dev plugin (its 10
1184
1127
  // dev skills) from the app's own marketplace. One-shot + idempotent; never
1185
1128
  // throws. SM_SEED_DEV_PLUGIN_DISABLE=1 to opt out.
1186
1129
  seedDevPlugin({ logger: console }).catch((e) => {
1187
1130
  logs.writeLine({ scope: 'seed-dev-plugin', level: 'error', message: 'seed failed', meta: { error: e?.message } });
1188
1131
  });
1189
- webRemote.init().catch((e) => {
1190
- logs.writeLine({ scope: 'webRemote', level: 'error', message: 'init failed', meta: { error: e?.message } });
1191
- });
1192
1132
  // History rollup finalize pass: deferred 30s past boot so it never competes
1193
1133
  // with first-paint, fire-and-forget (cron/offline refresh is PRD 651 — this
1194
1134
  // is just the one-shot in-app pass).
@@ -1249,7 +1189,6 @@ let teardownDone = false;
1249
1189
  function runShutdownCleanup() {
1250
1190
  if (teardownDone) return; // idempotent — will-quit may still fire after an app.exit path
1251
1191
  teardownDone = true;
1252
- webRemote.destroy();
1253
1192
  // Mark a clean exit so the next boot can distinguish a graceful quit from an
1254
1193
  // OOM-kill / native crash (which leaves the sentinel `open`).
1255
1194
  crashDiagnostics.markCleanShutdown();
@@ -1287,7 +1226,6 @@ app.on('before-quit', () => {
1287
1226
  transcripts.closeAll();
1288
1227
  watchers.manager.killAll();
1289
1228
  adminHttp.stop().catch(() => {});
1290
- browserAgentServer.stop().catch(() => {});
1291
1229
  // Best-effort flush of any pending OTEL spans. shutdown() has its own 2s
1292
1230
  // ceiling so a wedged exporter can't hold quit.
1293
1231
  otel.shutdown().catch(() => {});
@@ -25,17 +25,6 @@ const ptyTabId = z.object({ tabId: z.string().min(1).max(128) });
25
25
  // have a live PTY? Bounded so the renderer can't ask about unbounded lists.
26
26
  const ptyAlive = z.object({ tabIds: z.array(z.string().min(1).max(128)).max(500) });
27
27
 
28
- // v2 mobile: subscribe to a session's live state + summary. cwd is needed to
29
- // locate the transcript JSONL (transcriptPath); validated against home-dir boundary
30
- // in webRemote before any fs access.
31
- const sessionSubscribe = z.object({
32
- // tabId becomes a transcript FILENAME (`<tabId>.jsonl`) — restrict to a
33
- // session-id charset (no '/', no '.', so it can't traverse out of the project
34
- // transcript dir). sessionId is a UUID, which satisfies this.
35
- tabId: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/),
36
- cwd: z.string().min(1).max(4096),
37
- });
38
-
39
28
  // 64 KiB cap per pty:write — typewriter input is bounded; a renderer firing
40
29
  // megabytes per call is either a bug or an attack. Block it at the boundary.
41
30
  const PTY_WRITE_MAX_BYTES = 64 * 1024;
@@ -50,115 +39,12 @@ const ptyResize = z.object({
50
39
  rows: z.number().int().min(3).max(1000),
51
40
  });
52
41
 
53
- // ──────────────────────────────────────────── Browser (WebContentsView embed)
54
- // viewId is a renderer-generated identifier; restrict to a safe charset (no
55
- // '/', no '.') since it keys the in-process Map<viewId, WebContentsView> —
56
- // not used as a filesystem path, but kept consistent with tabId conventions.
57
- const BROWSER_VIEW_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
58
- const browserViewId = z.object({
59
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
60
- });
61
-
62
- const browserCreate = z.object({
63
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
64
- // Non-persistent partition string (PRD 400 run-mode isolation). No leading
65
- // 'persist:' enforced here — callers choose persistence explicitly.
66
- partition: z.string().min(1).max(256),
67
- });
68
-
69
- const BOUNDS_INT = z.number().int().min(0).max(100000);
70
- const browserSetBounds = z.object({
71
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
72
- x: BOUNDS_INT,
73
- y: BOUNDS_INT,
74
- width: BOUNDS_INT,
75
- height: BOUNDS_INT,
76
- });
77
-
78
- const browserNavigate = z.object({
79
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
80
- url: z.string().min(1).max(8192),
81
- });
82
-
83
- // PRD 407: DOM/text capture from the active browser sub-tab.
84
- const browserCaptureDom = z.object({
85
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
86
- kind: z.enum(['text', 'html']),
87
- });
88
-
89
- // PRD 404: filter -> prune -> summarize -> chunk capture of a picked
90
- // selection (browser:capture). selectors comes from the PRD 403 picker.
91
- const browserCaptureSelection = z.object({
92
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
93
- selectors: z.array(z.string().min(1).max(2048)).min(1).max(50),
94
- mode: z.enum(['agent', 'html', 'a11y', 'selector']),
95
- });
96
-
97
- // PRD 407: clipboard image write (browser:copy-image). dataUrl is a PNG data
98
- // URL from webContents.capturePage() — capped well above any realistic
99
- // screenshot so a malformed/huge payload can't wedge the IPC channel.
100
- const browserCopyImage = z.object({
101
- dataUrl: z.string().min(1).max(50_000_000),
102
- });
103
-
104
42
  // Recorder export (PRD 412): write arbitrary recorded-flow text to the OS
105
- // clipboard, separate from the image-only browserCopyImage above.
43
+ // clipboard.
106
44
  const clipboardWriteText = z.object({
107
45
  text: z.string().max(1_000_000),
108
46
  });
109
47
 
110
- // Recorder export (PRD 412): native "Save As" dialog write, bypassing the
111
- // config.cjs write-boundary since the path is user-chosen via OS dialog.
112
- const browserSaveRecording = z.object({
113
- defaultName: z.string().min(1).max(255),
114
- text: z.string().max(1_000_000),
115
- });
116
-
117
- // PRD 407: binary-safe atomic write (browser:save-binary) for screenshot
118
- // captures — config:write-text is utf8-only.
119
- const browserSaveBinary = z.object({
120
- path: z.string().min(1).max(4096),
121
- base64: z.string().min(1).max(50_000_000),
122
- // Single-writer law (lib/opsOwnership.cjs) — required for ops-root paths.
123
- writer: z.string().min(1).max(64).optional(),
124
- });
125
-
126
- // PRD 410: replay a recorded step list against a live view. The renderer
127
- // owns the step list (main never persists recorded steps), so every call is
128
- // self-contained. `select` is accepted for forward-compat even though the
129
- // live recorder engine doesn't emit it yet.
130
- const browserReplayStep = z.object({
131
- n: z.number().int().min(1),
132
- verb: z.enum(['navigate', 'click', 'type', 'select', 'wait-for']),
133
- target: z.string().max(2000),
134
- value: z.string().max(2000).optional(),
135
- variable: z.string().max(64).nullable().optional(),
136
- kind: z.enum(['nav', 'assert']).optional(),
137
- masked: z.boolean().optional(),
138
- variableSuggestion: z.string().max(64).optional(),
139
- });
140
-
141
- const browserReplay = z.object({
142
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
143
- steps: z.array(browserReplayStep).max(500),
144
- values: z.record(z.string().max(64), z.string().max(2000)).optional(),
145
- continueOnError: z.boolean().optional(),
146
- });
147
-
148
- // PRD 402: address-bar zoom control. factor is clamped again in
149
- // browserView.cjs's setZoom — this just bounds the wire payload.
150
- const browserSetZoom = z.object({
151
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
152
- factor: z.number().min(0.1).max(10),
153
- });
154
-
155
- // PRD 402: Cmd/Ctrl+F find bar.
156
- const browserFind = z.object({
157
- viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
158
- text: z.string().max(2000),
159
- forward: z.boolean().optional(),
160
- });
161
-
162
48
  // ──────────────────────────────────────────── Transcripts
163
49
  const SESSION_UUID_RE = /^[a-zA-Z0-9-]{1,64}$/;
164
50
 
@@ -680,6 +566,15 @@ const exchangesList = z.object({
680
566
  // this one lives here per PRD 638 so it's reusable without importing files.cjs.
681
567
  const filesDuplicate = z.object({ path: z.string().min(1).max(4096) });
682
568
 
569
+ // files:save-binary — binary-safe atomic write (formerly browser:save-binary,
570
+ // re-homed off the Browser tab). Mirrors the retired browserSaveBinary shape.
571
+ const filesSaveBinary = z.object({
572
+ path: z.string().min(1).max(4096),
573
+ base64: z.string().min(1).max(50_000_000),
574
+ // Single-writer law (lib/opsOwnership.cjs) — required for ops-root paths.
575
+ writer: z.string().min(1).max(64).optional(),
576
+ });
577
+
683
578
  // ──────────────────────────────────────────── Doc Edit (PRD 638 rewrite runner)
684
579
  // docedit:run — consumed by docEdit.cjs's registerDocEditHandlers.
685
580
  const docEditRun = z.object({
@@ -744,23 +639,6 @@ const chatExternalSend = z.object({
744
639
  ),
745
640
  });
746
641
 
747
- // ──────────────────────────────────────────── Web Remote
748
- // OTP is 8 uppercase alphanumeric chars (case-insensitive entry, normalised to upper in handler).
749
- const WEB_REMOTE_OTP_RE = /^[A-Z0-9]{8}$/i;
750
- const DEVICE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
751
-
752
- const webRemotePair = z.object({
753
- otp: z.string().regex(WEB_REMOTE_OTP_RE),
754
- }).strict();
755
-
756
- const webRemoteRevokeDevice = z.object({
757
- deviceId: z.string().regex(DEVICE_ID_RE),
758
- }).strict();
759
-
760
- const webRemoteAuditTail = z.object({
761
- lines: z.number().int().min(1).max(500).optional(),
762
- }).strict();
763
-
764
642
  // ──────────────────────────────────────────── History
765
643
  const DATE_YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
766
644
 
@@ -892,96 +770,19 @@ function validated(schema, handler) {
892
770
  };
893
771
  }
894
772
 
895
- // ──────────────────────────────────────────── Web Remote command allowlist
896
- // Commands are split into three tiers:
897
- // READ_COMMANDS — return data; allowed when remoteEnabled=true.
898
- // SAS_GATED_READS — return sensitive user data (sessions, PRDs, logs,
899
- // transcript summaries); additionally require
900
- // _e2eAuthenticated=true (SAS confirmed by user).
901
- // A compromised relay cannot exfiltrate this data from
902
- // a session that has not been SAS-confirmed.
903
- // MUTATE_COMMANDS — write files, spawn processes, or mutate persisted
904
- // state; gated behind remoteControlEnabled=true AND
905
- // _e2eAuthenticated=true.
906
- // ALLOWED_COMMANDS is the union, kept for existing import compatibility.
907
- //
908
- // Ungated READ_COMMANDS (justify each):
909
- // cmd:app:version — exposes only the app semver string; no user data.
910
- // cmd:session:unsubscribe — teardown lifecycle; returns nothing sensitive.
911
- const READ_COMMANDS = new Set([
912
- 'cmd:app:version',
913
- // v2 mobile: unsubscribe is a teardown lifecycle call with no data payload.
914
- 'cmd:session:unsubscribe',
915
- ]);
916
-
917
- // Sensitive reads — return user data; require SAS confirmation same as MUTATE.
918
- const SAS_GATED_READS = new Set([
919
- 'cmd:sessions:load',
920
- 'cmd:history:aggregate',
921
- // subscribe initiates a live stream of session state/summary — sensitive.
922
- 'cmd:session:subscribe',
923
- // NOTE: cmd:exchanges:list is intentionally NOT allowlisted — webRemote.cjs has
924
- // no dispatch handler for it, so an allowlist entry would only fail closed with
925
- // an opaque reject. Re-add here together with the handler when remote exchanges
926
- // are wired, so the allowlist always mirrors an actual capability.
927
- //
928
- // Scheduler/Epics commands (cmd:schedule:*) were removed here deliberately
929
- // (core scheduler/Epics redesign) — Remote no longer reaches into scheduler
930
- // internals at all; a future Remote rebuild will talk to Scheduler/Epics
931
- // through whatever higher-level surface that redesign lands on, not this
932
- // per-command allowlist.
933
- ]);
934
-
935
- const MUTATE_COMMANDS = new Set([
936
- 'cmd:sessions:save',
937
- 'cmd:pty:spawn',
938
- 'cmd:pty:write',
939
- // pty:kill terminates a live session; pty:resize drives the geometry of the
940
- // user's interactive PTY — both write live process state, so they are gated
941
- // behind remoteControlEnabled + SAS like every other mutation. A read-only
942
- // mobile mirror has no business killing or resizing the desktop's session.
943
- 'cmd:pty:kill',
944
- 'cmd:pty:resize',
945
- // Pushes a prompt into an open tab's chat queue — a real mutation of that
946
- // tab's session, same tier as pty:write.
947
- 'cmd:chat:send',
948
- ]);
949
-
950
- const ALLOWED_COMMANDS = new Set([...READ_COMMANDS, ...SAS_GATED_READS, ...MUTATE_COMMANDS]);
951
-
952
773
  module.exports = {
953
774
  // Centralized slug regex — used by scheduler.cjs and queueOps.cjs for
954
775
  // direct test()/match() containment checks alongside the zod parses.
955
776
  SCHEDULE_SLUG_RE,
956
777
  SCHEDULE_RUN_ID_RE,
957
778
  PRD_CREATE_SLUG_RE,
958
- READ_COMMANDS,
959
- SAS_GATED_READS,
960
- MUTATE_COMMANDS,
961
- ALLOWED_COMMANDS,
962
779
  schemas: {
963
- webRemotePair,
964
- webRemoteRevokeDevice,
965
- webRemoteAuditTail,
966
780
  ptySpawn,
967
781
  ptyTabId,
968
782
  ptyAlive,
969
783
  ptyWrite,
970
784
  ptyResize,
971
- sessionSubscribe,
972
- browserViewId,
973
- browserCreate,
974
- browserSetBounds,
975
- browserNavigate,
976
- browserCaptureDom,
977
- browserCaptureSelection,
978
- browserCopyImage,
979
- browserSaveBinary,
980
785
  clipboardWriteText,
981
- browserSaveRecording,
982
- browserReplay,
983
- browserSetZoom,
984
- browserFind,
985
786
  transcriptSubscribe,
986
787
  transcriptTabId,
987
788
  transcriptPath,
@@ -1049,6 +850,7 @@ module.exports = {
1049
850
  watchersRemove,
1050
851
  watchersKillTab,
1051
852
  filesDuplicate,
853
+ filesSaveBinary,
1052
854
  docEditRun,
1053
855
  docEditRunInSession,
1054
856
  chatRun,
@@ -0,0 +1,87 @@
1
+ /**
2
+ * coldBootPromptSessionsWrite.test.cjs — regression coverage for the
3
+ * "Epics marked Completed come back Open" bug.
4
+ *
5
+ * config.cjs's allowedRoots starts as just {os.homedir()}, and
6
+ * validateWrite() explicitly skips the homedir root (a project cwd must be
7
+ * registered via addAllowedRoot() before writeJson()/writeTextAtomic() will
8
+ * accept a write under it). addAllowedRoot's only callers used to be pty.cjs
9
+ * (Terminal spawn) and lib/prdCreate.cjs — never the Epic persistence path —
10
+ * so on a cold boot, before any Terminal/PRD activity for a project, both
11
+ * mergeActiveIndex's own write and the renderer's Epic-archive write
12
+ * (config:write-json of promptSessionArchivePath) were silently rejected.
13
+ *
14
+ * Both cases below use a tmp dir INSIDE the homedir so validatePath (the
15
+ * READ boundary, satisfied by the homedir root alone) succeeds while
16
+ * validateWrite (which skips the homedir root, per config.cjs:113) still
17
+ * requires an explicit per-project root registration — reproducing the
18
+ * exact cold-boot gap without needing an out-of-home tmp root.
19
+ *
20
+ * Run: timeout 300 npx vitest run src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ import { test, expect, afterEach } from 'vitest';
26
+ const fsp = require('node:fs/promises');
27
+ const os = require('node:os');
28
+ const path = require('node:path');
29
+ const { mergeActiveIndex } = require('../activeIndexMerge.cjs');
30
+ const { readActiveIndex } = require('../epicMint.cjs');
31
+ const config = require('../../config.cjs');
32
+
33
+ const tmpDirs = [];
34
+ afterEach(async () => {
35
+ while (tmpDirs.length) {
36
+ const d = tmpDirs.pop();
37
+ await fsp.rm(d, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ async function mkColdBootCwd(prefix) {
42
+ // Deliberately under homedir (so validatePath's read boundary passes via
43
+ // the default homedir root) and deliberately NOT registered via
44
+ // config.addAllowedRoot() — simulates the exact state of allowedRoots
45
+ // right after app boot, before any Terminal/PRD activity for this project.
46
+ const cwd = await fsp.mkdtemp(path.join(os.homedir(), prefix));
47
+ tmpDirs.push(cwd);
48
+ return cwd;
49
+ }
50
+
51
+ function session(id, overrides = {}) {
52
+ return {
53
+ id,
54
+ cwd: overrides.cwd || '/proj',
55
+ goalText: overrides.goalText || `goal for ${id}`,
56
+ claudeSessionId: overrides.claudeSessionId || `claude-${id}`,
57
+ status: overrides.status || 'active',
58
+ createdAt: overrides.createdAt || '2026-08-06T13:48:00.000Z',
59
+ completedAt: overrides.completedAt ?? null,
60
+ };
61
+ }
62
+
63
+ test('mergeActiveIndex writes active-index.json on a cold boot with only the homedir registered', async () => {
64
+ const cwd = await mkColdBootCwd('.sm-test-activeindex-coldboot-');
65
+ const s = session('psess-coldboot-1');
66
+
67
+ await mergeActiveIndex(cwd, { sessions: { [s.id]: s }, events: {} });
68
+
69
+ const index = readActiveIndex(cwd);
70
+ expect(index.sessions[s.id]).toEqual(s);
71
+ });
72
+
73
+ // Mirrors what the 'config:write-json' IPC handler now does before calling
74
+ // writeJson (config.cjs's registerPromptSessionsRoot) — the main-process fix
75
+ // for state/promptSessions.ts's markCompleted -> writeJson(promptSessionArchivePath).
76
+ test('an Epic archive write succeeds on a cold boot with only the homedir registered', async () => {
77
+ const cwd = await mkColdBootCwd('.sm-test-archive-coldboot-');
78
+ const archivePath = path.join(cwd, 'session-manager-operations', 'prompt-sessions', 'psess-archived-1.json');
79
+ const archive = { ...session('psess-archived-1', { status: 'completed' }), completedAt: '2026-08-06T14:00:00.000Z' };
80
+
81
+ config.registerPromptSessionsRoot(archivePath);
82
+ await config.writeJson(archivePath, archive, { writer: 'epics' });
83
+
84
+ const result = await config.readJson(archivePath);
85
+ expect(result.exists).toBe(true);
86
+ expect(result.data).toEqual(archive);
87
+ });
@@ -33,6 +33,7 @@
33
33
  * explicit "un-tombstone" path; today one would be dead code.
34
34
  */
35
35
 
36
+ const path = require('path');
36
37
  const { activeIndexPath, withPathLock } = require('./epicMint.cjs');
37
38
  const config = require('../config.cjs');
38
39
 
@@ -69,6 +70,14 @@ function compareEventsChainAware(a, b) {
69
70
  */
70
71
  async function mergeActiveIndex(cwd, { sessions: rawMemorySessions = {}, events: rawMemoryEvents = {}, removedIds = [] } = {}) {
71
72
  if (!cwd || typeof cwd !== 'string') throw new Error('mergeActiveIndex: cwd is required');
73
+ // Register this project's cwd as a write-allowed root (config.cjs's
74
+ // validateWrite gate) — mirrors the precedent + comment in
75
+ // lib/prdCreate.cjs:152-155. A chat-only Epic (headless claude -p, no
76
+ // Terminal PTY ever spawned for this cwd) reaches this merge on a cold
77
+ // boot before pty.cjs's addAllowedRoot call has ever run for it — without
78
+ // this, every active-index.json write for such a project fails with
79
+ // "Write outside allowed write boundaries".
80
+ config.addAllowedRoot(path.resolve(cwd));
72
81
  const filePath = activeIndexPath(cwd);
73
82
  // Null-prototype copies of the CALLER'S OWN payload too: an Epic id equal
74
83
  // to "__proto__" is a plain string key from the caller's perspective, but
@@ -48,8 +48,6 @@ const OWNERS = Object.freeze({
48
48
  'scheduler': 'scheduler',
49
49
  // Project Home owns the synthesized Brief (generate + hand-edit).
50
50
  'project-brief': 'project-home',
51
- // Browser tab scratch saves (DOM captures, screenshots, recorded flows).
52
- 'browser': 'browser',
53
51
  // Structured per-tab error log lines (JSONL), tagged for tracing/analysis.
54
52
  'logs': 'logs',
55
53
  // Host on Bilko.run tab's deterministic bundle prep (dist/index.html +
@@ -5,7 +5,6 @@
5
5
  *
6
6
  * Single source of truth for the Anthropic summarization call. Used by:
7
7
  * - exchanges.cjs (records durable per-exchange summaries)
8
- * - webRemote.cjs (mobile summary push — refactored to import from here)
9
8
  *
10
9
  * Degrade contracts (never throw — always return a record):
11
10
  * no API key → { summary: text.slice(0,600), model:'raw', degraded:'no_api_key' }