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.
- package/dist/assets/{TiptapBody-CCmz3PdX.js → TiptapBody-BwN4pNC0.js} +42 -42
- package/dist/assets/{index-CPMP2XZ_.css → index-CVCCMw5o.css} +1 -1
- package/dist/assets/index-ZjTDk5b7.js +3197 -0
- package/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/main/__tests__/chat-preamble-anchors.test.cjs +100 -0
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
- package/src/main/chatRunner.cjs +4 -0
- package/src/main/config.cjs +30 -23
- package/src/main/files.cjs +20 -0
- package/src/main/index.cjs +1 -63
- package/src/main/ipcSchemas.cjs +11 -209
- package/src/main/lib/__tests__/coldBootPromptSessionsWrite.test.cjs +87 -0
- package/src/main/lib/activeIndexMerge.cjs +9 -0
- package/src/main/lib/opsOwnership.cjs +0 -2
- package/src/main/lib/summarize.cjs +0 -1
- package/src/main/runVerify.cjs +55 -6
- package/src/main/scheduler.cjs +46 -7
- package/src/preload/api.d.ts +2 -197
- package/src/preload/index.cjs +1 -100
- package/dist/assets/index-B5994QOJ.js +0 -3222
- package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
- package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
- package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
- package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
- package/src/main/browserAgentServer.cjs +0 -224
- package/src/main/browserCapture.cjs +0 -728
- package/src/main/browserView.cjs +0 -916
- package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
- package/src/main/lib/browserAgentActions.cjs +0 -114
- package/src/main/lib/e2eStateMachine.cjs +0 -39
- package/src/main/webRemote.cjs +0 -1367
- package/src/preload/browserViewPreload.cjs +0 -118
package/src/main/index.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut, shell, clipboard,
|
|
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(() => {});
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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
|
|
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' }
|