claude-code-session-manager 0.123.1 → 0.124.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 (80) hide show
  1. package/dist/assets/{AgentLibrary-T14aM8ie.js → AgentLibrary-qmAgadiS.js} +1 -1
  2. package/dist/assets/{Button-Dz1kV8Z_.js → Button-CkuaU6cA.js} +1 -1
  3. package/dist/assets/{DataModel-DdYRn2Ec.js → DataModel-DEVHxW5I.js} +1 -1
  4. package/dist/assets/{History-zQPvqwE7.js → History-C-qw4Gx4.js} +1 -1
  5. package/dist/assets/{Hooks-BTrbT742.js → Hooks-DzAnCyjJ.js} +1 -1
  6. package/dist/assets/{Library-BwQ6GRHR.js → Library-DExZ29wM.js} +1 -1
  7. package/dist/assets/{ListDetail-CunxAhRC.js → ListDetail-DiQk-Hf9.js} +1 -1
  8. package/dist/assets/{McpServers-DpoyxXaO.js → McpServers-C0nqX36_.js} +1 -1
  9. package/dist/assets/{Memory-KDtGYMc3.js → Memory-D4KAWPAC.js} +1 -1
  10. package/dist/assets/{Panel-xXmUBo8l.js → Panel-xv9ToSjl.js} +1 -1
  11. package/dist/assets/{Permissions-lIzTUrW2.js → Permissions-0yEyqwZU.js} +1 -1
  12. package/dist/assets/{Plugins-BA1Zqk69.js → Plugins-CB28SGGJ.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-CmeJG2Kw.js → ProvenanceBadge-9i4gXDvI.js} +1 -1
  14. package/dist/assets/{SaveBar-BTJg_9Uc.js → SaveBar-mSVRXpml.js} +1 -1
  15. package/dist/assets/{Scheduler-CISTDNMU.js → Scheduler-BoUetIC0.js} +1 -1
  16. package/dist/assets/{ScopeSwitcher-C6NWvzoj.js → ScopeSwitcher-B8tdQIKs.js} +1 -1
  17. package/dist/assets/{Settings-BBoGJ34v.js → Settings-D3jfKwaK.js} +1 -1
  18. package/dist/assets/{SkillReferenceGraph-IJv60QAJ.js → SkillReferenceGraph-DJ4JJRt3.js} +1 -1
  19. package/dist/assets/{Skills-L6nGcNF1.js → Skills-P9HHxDCR.js} +1 -1
  20. package/dist/assets/{SystemPrompt-Cm7I2gJz.js → SystemPrompt-B_tnbBZ2.js} +1 -1
  21. package/dist/assets/{TagLibrary-BxPwoPE6.js → TagLibrary-CI8NHRKi.js} +1 -1
  22. package/dist/assets/{TiptapBody-CR-Zr3Pz.js → TiptapBody-9LktXPiD.js} +1 -1
  23. package/dist/assets/{Toggle-BFhlpZ-n.js → Toggle-BwsUsAA7.js} +1 -1
  24. package/dist/assets/{ViewTabs-CuGlZ9Ed.js → ViewTabs-CY5gMZ_N.js} +1 -1
  25. package/dist/assets/{historyTotals-Dxv9Fzrs.js → historyTotals-_dIaxmPX.js} +1 -1
  26. package/dist/assets/{index-Dx8owDeQ.js → index-d4i3X1x3.js} +419 -419
  27. package/dist/assets/{settingsSchema-DaqFSAQH.js → settingsSchema-Dg0Xa9RR.js} +1 -1
  28. package/dist/assets/{skillFrontmatter-BIJddxVB.js → skillFrontmatter-CpoFkSyj.js} +1 -1
  29. package/dist/assets/{useAgentsDirWatch-1hjZ4knk.js → useAgentsDirWatch-CFPprfWG.js} +1 -1
  30. package/dist/index.html +1 -1
  31. package/dist-web/assets/app.css +32 -0
  32. package/dist-web/assets/app.js +1479 -0
  33. package/dist-web/index.html +16 -0
  34. package/package.json +6 -4
  35. package/plugins/session-manager-dev/skills/builder/3-publish/SKILL.md +28 -10
  36. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -6
  37. package/src/main/build-info.json +4 -4
  38. package/src/main/chatRunner.cjs +68 -9
  39. package/src/main/config.cjs +23 -29
  40. package/src/main/docEdit.cjs +2 -5
  41. package/src/main/files.cjs +16 -4
  42. package/src/main/index.cjs +26 -4
  43. package/src/main/ipcSchemas.cjs +6 -2
  44. package/src/main/lib/epic/activeIndexMerge.cjs +3 -5
  45. package/src/main/lib/epic/epicReopen.cjs +161 -0
  46. package/src/main/lib/epic/epicSpawnPlan.cjs +9 -1
  47. package/src/main/lib/epic/epicTranscriptRestore.cjs +109 -0
  48. package/src/main/lib/epic/epicValidationHook.cjs +1 -1
  49. package/src/main/lib/epic/epicWorktreeMint.cjs +50 -3
  50. package/src/main/lib/feedback-remote/crossProjectFeedback.cjs +2 -1
  51. package/src/main/lib/feedback-remote/remoteAssets.cjs +98 -0
  52. package/src/main/lib/feedback-remote/remoteIpcDispatch.cjs +216 -0
  53. package/src/main/lib/insideHome.cjs +102 -23
  54. package/src/main/lib/persona-macro/shippedPersonaSeeds.cjs +27 -0
  55. package/src/main/lib/prd/prdWorktreeReach.cjs +9 -1
  56. package/src/main/lib/scheduler/rcaReport.cjs +1 -13
  57. package/src/main/lib/scheduler/reaperHelpers.cjs +82 -77
  58. package/src/main/lib/scheduler/reviewNotice.cjs +0 -2
  59. package/src/main/lib/scheduler/scheduleJobTransitions.cjs +1 -1
  60. package/src/main/lib/scheduler/schedulerBatch.cjs +1 -1
  61. package/src/main/lib/sendToRenderer.cjs +22 -2
  62. package/src/main/lib/smfileHeaders.cjs +17 -1
  63. package/src/main/pluginInstall.cjs +1 -1
  64. package/src/main/promptSessionEvents.cjs +2 -5
  65. package/src/main/pty.cjs +5 -4
  66. package/src/main/runVerify.cjs +6 -41
  67. package/src/main/scheduler/budget.cjs +4 -4
  68. package/src/main/scheduler/prdParser.cjs +2 -3
  69. package/src/main/scheduler/reviewLadder.cjs +0 -9
  70. package/src/main/scheduler.cjs +72 -25
  71. package/src/main/seedAgentPersonas.cjs +1 -1
  72. package/src/main/templates/PRD_AUTHORING.md +2 -2
  73. package/src/main/voiceHotkey.cjs +3 -2
  74. package/src/main/webRemote.cjs +161 -1
  75. package/src/preload/api.d.ts +8 -1
  76. package/src/preload/apiSurface.cjs +19 -0
  77. package/src/preload/apiSurface.json +1689 -0
  78. package/src/preload/index.cjs +2 -0
  79. package/src/seed/agents/architect.md +2 -2
  80. package/src/seed/agents/blender-artist.md +76 -0
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Claude Session Manager</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
+ <script type="module" crossorigin src="./assets/app.js"></script>
11
+ <link rel="stylesheet" crossorigin href="./assets/app.css">
12
+ </head>
13
+ <body class="bg-bg text-fg font-sans antialiased">
14
+ <div id="root"></div>
15
+ </body>
16
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.123.1",
3
+ "version": "0.124.1",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -29,6 +29,7 @@
29
29
  "dist/index.html",
30
30
  "dist/assets/",
31
31
  "dist/vad/",
32
+ "dist-web/",
32
33
  "README.md",
33
34
  "!src/**/__tests__/**"
34
35
  ],
@@ -36,7 +37,8 @@
36
37
  "dev": "concurrently -k -n vite,electron -c magenta,cyan \"npm:dev:renderer\" \"npm:dev:electron\"",
37
38
  "dev:renderer": "vite",
38
39
  "dev:electron": "wait-on http://localhost:5173 && SM_DEV=1 electron .",
39
- "build": "vite build",
40
+ "build": "vite build && vite build --config vite.web.config.ts",
41
+ "build:web": "vite build --config vite.web.config.ts",
40
42
  "dist:stage": "npm run build && node scripts/package/stage.cjs",
41
43
  "dist:mac": "npm run dist:stage && electron-builder --config electron-builder.yml --mac --publish never",
42
44
  "dist:win": "npm run dist:stage && electron-builder --config electron-builder.yml --win --publish never",
@@ -45,7 +47,7 @@
45
47
  "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.main.json",
46
48
  "start": "electron .",
47
49
  "prepack": "node scripts/write-build-info.cjs",
48
- "prepublishOnly": "node scripts/write-build-info.cjs && vite build",
50
+ "prepublishOnly": "node scripts/write-build-info.cjs && npm run build",
49
51
  "test:unit": "vitest run",
50
52
  "test:unit:stability": "bash scripts/test-unit-stability.sh",
51
53
  "bench": "node scripts/bench/main-bench.cjs",
@@ -138,7 +140,7 @@
138
140
  "electron-updater": "^6.8.9",
139
141
  "node-pty": "^1.2.0-beta.12",
140
142
  "ws": "^8.18.0",
141
- "zod": "^3.23.8"
143
+ "zod": "^4.6.7"
142
144
  },
143
145
  "devDependencies": {
144
146
  "@huggingface/transformers": "^4.1.0",
@@ -40,24 +40,37 @@ to ask for reconfirmation once `builder:gate` has passed.
40
40
 
41
41
  5. **Create the isolated worktree from that tag:**
42
42
  ```
43
- git worktree add /tmp/sm-publish-v<version> v<version>
43
+ mkdir -p "$HOME/.cache/sm-publish"
44
+ git worktree add "$HOME/.cache/sm-publish/v<version>" v<version>
44
45
  ```
46
+ **Never under `/tmp`** (or anywhere under `os.tmpdir()`): the test suite treats such paths as
47
+ test fixtures, so `src/main/__tests__/opsErrorLog.test.cjs` ("a real project root is
48
+ refused") fails inside a `/tmp` worktree.
45
49
 
46
- 6. **Move into the worktree and build clean:**
50
+ 6. **Move into the worktree, build clean, and run the in-tree gate:**
47
51
  ```
48
- cd /tmp/sm-publish-v<version>
52
+ cd "$HOME/.cache/sm-publish/v<version>"
49
53
  npm ci
50
54
  npm run typecheck
55
+ npm run test:unit
51
56
  ```
52
57
  `npm ci` gives a fully clean `node_modules` from the lockfile — no leftover local state
53
- from the dirty main repo can leak into the published artifact.
58
+ from the dirty main repo can leak into the published artifact. Typecheck and unit tests
59
+ must both pass here; stop before `npm publish` on any failure.
60
+
61
+ After the build step (run the project's build, e.g. `npm run build` plus any web build that
62
+ produces `dist-web/`), check that every `files` entry exists:
63
+ ```
64
+ test -f dist-web/index.html && test -f dist/index.html
65
+ ```
66
+ If either is missing, stop before `npm publish` and report.
54
67
 
55
68
  7. **Publish from inside the worktree:**
56
69
  ```
57
70
  npm publish
58
71
  ```
59
72
  This project's `prepublishOnly` script runs `vite build` — because step 6 already `cd`'d
60
- into `/tmp/sm-publish-v<version>`, that build runs **inside the clean worktree**, never
73
+ into `$HOME/.cache/sm-publish/v<version>`, that build runs **inside the clean worktree**, never
61
74
  touching (or being affected by) the live/dirty working directory back in the main repo.
62
75
  This is the entire reason the worktree exists: it decouples "what's uncommitted in my
63
76
  editor right now" from "what gets built and shipped."
@@ -81,16 +94,21 @@ to ask for reconfirmation once `builder:gate` has passed.
81
94
 
82
95
  9. **Clean up the worktree:**
83
96
  ```
84
- git worktree remove /tmp/sm-publish-v<version> --force
97
+ git worktree remove "$HOME/.cache/sm-publish/v<version>" --force
85
98
  ```
86
99
 
87
- 10. **Verify the publish actually landed on the registry:**
100
+ 10. **Verify the publish actually landed on the registry.** `npm view` is cached and lags for
101
+ minutes after a real publish, so check a cache-busted packument and the tarball first:
88
102
  ```
103
+ curl -s "https://registry.npmjs.org/<packageName>?t=$(date +%s)" # dist-tags.latest + versions
104
+ curl -sI <tarball URL from versions["<version>"].dist.tarball> # expect HTTP 200
89
105
  npm view <packageName> version
90
106
  npm view <packageName> dist-tags
91
107
  ```
92
- Confirm the reported version matches `<version>` and `latest` points to it (unless the
93
- project intentionally publishes to a different dist-tag).
108
+ Confirm `dist-tags.latest` is `<version>`, `<version>` is in `versions`, and the tarball
109
+ returns 200 (unless the project intentionally publishes to a different dist-tag). If the
110
+ packument is current, an unchanged `npm view` for a few minutes is normal lag, not a
111
+ failure — do not republish.
94
112
 
95
113
  11. **Post-publish: verify the installer release.** Pushing `v<version>` (step 8) triggers
96
114
  `.github/workflows/release.yml`. Confirm the tag push succeeded, then poll (bounded: at most
@@ -109,7 +127,7 @@ to ask for reconfirmation once `builder:gate` has passed.
109
127
  Stop at that step. Do not proceed. Report which step failed and its output. If the worktree
110
128
  was already created (step 5+), leave it in place if its state is relevant evidence for
111
129
  diagnosing the failure; otherwise remove it (step 9) before reporting, so a retry doesn't
112
- collide with a stale `/tmp/sm-publish-v<version>` path.
130
+ collide with a stale `$HOME/.cache/sm-publish/v<version>` path.
113
131
 
114
132
  ## Output
115
133
 
@@ -181,9 +181,9 @@ the API rejects that.
181
181
  or a gitignored path. Why: a dirtied tree fails the clean-tree check.
182
182
  12. Never put a step only a human or the passage of time can complete (host cron install,
183
183
  waiting for a live post, a live payment, an account dashboard action) in a gate or
184
- criterion. The executor ends with `SCHEDULER_VERDICT: NEEDS_HUMAN <question>` for those, and
185
- the PRD notes say so. Why: no executor can pass such a gate, so the job parks as
186
- `sentinel_fail`.
184
+ criterion; keep them out of PRDs at authoring time. An executor that meets one ends
185
+ `SCHEDULER_VERDICT: FAIL` naming what is missing. Why: no executor can pass such a gate,
186
+ and queued work runs with no questions asked.
187
187
  Live-run steps that need gitignored state follow PRD_AUTHORING.md §7 (absolute main-checkout
188
188
  paths, artifact-only).
189
189
 
@@ -271,9 +271,8 @@ Rules:
271
271
  `scheduler_archive_prd`. Archiving marks it completed and frees the PRDs that depend on
272
272
  it.
273
273
  A parked validator arrives this way too.
274
- A `needs_human` notice carries a question for the human: relay it verbatim, then act on
275
- the answer with `scheduler_update_prd` + `scheduler_reset_job`. Why: the executor stopped
276
- on a step only the human can answer.
274
+ Never relay a question to the human: resolve every PRD through cases 1-4, making the
275
+ safest reasonable choice, and report it. Why: queued work runs with no questions asked.
277
276
  2. **A PLAN SETTLED digest.** Read the validator records it names. A `REFUTED` PRD, or a
278
277
  Critical or Important finding, means a fix wave: one `behavior` or `wire` PRD per finding
279
278
  plus a new validate PRD. Never fix inline. A Not validated list means validate exactly
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.123.1",
3
- "gitSha": "457770adbb40ab9fb1a79929c1c734198a683ba5",
4
- "gitShortSha": "457770ad",
2
+ "version": "0.124.1",
3
+ "gitSha": "604bae39a0da6417ec4f1ba2f92c81f4661c2dfd",
4
+ "gitShortSha": "604bae39",
5
5
  "gitBranch": "HEAD",
6
- "builtAt": "2026-10-25T22:46:53.152Z",
6
+ "builtAt": "2026-10-26T07:38:06.859Z",
7
7
  "dirty": false
8
8
  }
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
2
3
 
3
4
  /**
4
5
  * chatRunner.cjs — headless `claude -p --output-format stream-json` runner for
@@ -49,6 +50,7 @@ const { killTree, detachedSpawnOpts } = require('./lib/process/killTree.cjs');
49
50
  const agentModelResolve = require('./lib/claude/agentModelResolve.cjs');
50
51
  const { resolveEpicEffort, effortArgs } = require('./lib/claude/agentEffortResolve.cjs');
51
52
  const { planEpicSpawn } = require('./lib/epic/epicSpawnPlan.cjs');
53
+ const epicWorktreeMint = require('./lib/epic/epicWorktreeMint.cjs');
52
54
  const logs = require('./logs.cjs');
53
55
  const { createHeadTailBuffer } = require('./lib/util/headTailBuffer.cjs');
54
56
 
@@ -257,6 +259,33 @@ const STOP_SIGNAL_INSTRUCTION =
257
259
  `guess on what's genuinely blocked, but always answer what you can first. ` +
258
260
  `Otherwise complete the task and end with a concise summary of what you did.\n\n`;
259
261
 
262
+ // Replaces STOP_SIGNAL_INSTRUCTION for turns the scheduler itself starts: no human is
263
+ // watching, so a question would stall the Epic forever.
264
+ const SCHEDULER_TURN_INSTRUCTION =
265
+ `IMPORTANT: This turn was started by the scheduler and nobody is watching. ` +
266
+ `Never ask questions and never print ${STOP_SENTINEL}. Act on the message below with ` +
267
+ `the /develop skill's Phase 2 rules (reset, fix the spec, archive, or queue a fix wave). ` +
268
+ `Make the safest reasonable choice for anything left open, and end with a short report ` +
269
+ `of what you did.\n\n`;
270
+
271
+ // tab+prompt keys of scheduler-originated prompts awaiting their run() (consumed once).
272
+ const schedulerPrompts = new Map();
273
+ const SCHEDULER_PROMPT_TTL_MS = 6 * 60 * 60 * 1000;
274
+ function noteSchedulerPrompt(tabId, prompt) {
275
+ const now = Date.now();
276
+ for (const [k, v] of schedulerPrompts) if (v.expires < now) schedulerPrompts.delete(k);
277
+ const key = `${tabId}\n${prompt}`;
278
+ const cur = schedulerPrompts.get(key);
279
+ schedulerPrompts.set(key, { count: (cur ? cur.count : 0) + 1, expires: now + SCHEDULER_PROMPT_TTL_MS });
280
+ }
281
+ function consumeSchedulerPrompt(tabId, prompt) {
282
+ const key = `${tabId}\n${prompt}`;
283
+ const cur = schedulerPrompts.get(key);
284
+ if (!cur || cur.expires < Date.now()) { schedulerPrompts.delete(key); return false; }
285
+ if (cur.count <= 1) schedulerPrompts.delete(key); else cur.count -= 1;
286
+ return true;
287
+ }
288
+
260
289
  // ─── Hard wall-clock kill ceiling ────────────────────────────────────────
261
290
  // Defined here (ahead of CHAT_MODE_TRUTH_INSTRUCTION) so the prompt's stated
262
291
  // budget is always derived from this single constant — never a hand-written
@@ -311,6 +340,9 @@ const dispatching = new Set();
311
340
  // "Session ID … is already in use", so pump() refuses to start a second run
312
341
  // for a held sessionId (it waits in `waiting` like any other blocked run).
313
342
  const liveSessions = new Set();
343
+ // Submissions parked while a New Epic's fire-and-forget worktree mint is still in
344
+ // flight (tabId → opts). Counted by run()'s tabBusy; cancel() can drop them.
345
+ const pendingStart = new Map();
314
346
 
315
347
  // Indirection so tests can stub the spawn without launching claude.
316
348
  let executor = executeRun;
@@ -327,6 +359,7 @@ function __resetQueueForTests() {
327
359
  dispatching.clear();
328
360
  liveSessions.clear();
329
361
  waiting.length = 0;
362
+ pendingStart.clear();
330
363
  sessionSlots.__resetForTests();
331
364
  }
332
365
 
@@ -337,11 +370,7 @@ let mainWindow = null;
337
370
  function attachWindow(win) { mainWindow = win; }
338
371
 
339
372
  function broadcast(channel, payload) {
340
- try {
341
- if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents && !mainWindow.webContents.isDestroyed()) {
342
- mainWindow.webContents.send(channel, payload);
343
- }
344
- } catch { /* render frame may be gone */ }
373
+ sendIfAlive(mainWindow, channel, payload);
345
374
  }
346
375
 
347
376
  // ─── Public queue entry ─────────────────────────────────────────────────────
@@ -358,6 +387,9 @@ function broadcast(channel, payload) {
358
387
  * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void, onSilentError?: (message: string, code?: string) => void, promptId?: string }} opts
359
388
  */
360
389
  function run(opts) {
390
+ // A scheduler-originated prompt (tagged by enqueueExternalPrompt) is re-identified
391
+ // here by tab + exact text, since it travels through the renderer's chat:run.
392
+ if (!opts.silent && consumeSchedulerPrompt(opts.tabId, opts.prompt)) opts = { ...opts, origin: 'scheduler' };
361
393
  // Per-tab exclusivity still holds — two `claude -p --resume` against ONE
362
394
  // sessionId must never overlap — but it is now enforced in pump() (which
363
395
  // refuses to start a run for a tab that already has one live) rather than by
@@ -374,6 +406,7 @@ function run(opts) {
374
406
  const tabBusy = inFlight.has(opts.tabId)
375
407
  || dispatching.has(opts.tabId)
376
408
  || liveSessions.has(opts.sessionId)
409
+ || pendingStart.has(opts.tabId)
377
410
  || waiting.some((w) => w.tabId === opts.tabId);
378
411
  if (tabBusy && opts.silent) {
379
412
  // Silent probes stay best-effort and invisible: a probe that collides is
@@ -381,6 +414,21 @@ function run(opts) {
381
414
  return { accepted: false, reason: 'tab-busy-silent' };
382
415
  }
383
416
 
417
+ // A New Epic's turn 1 races the fire-and-forget worktree mint: only when a mint is
418
+ // actually in flight for this Epic, hold the submission (<=90s, never rejects) so the
419
+ // first spawn lands in the worktree. Every other run is untouched.
420
+ if (epicWorktreeMint.hasPendingEpicWorktree(opts.cwd, opts.tabId)) {
421
+ pendingStart.set(opts.tabId, opts);
422
+ epicWorktreeMint.awaitPendingEpicWorktree(opts.cwd, opts.tabId)
423
+ .then((wt) => wt, () => null)
424
+ .then((wt) => {
425
+ if (pendingStart.get(opts.tabId) !== opts) return; // cancelled meanwhile
426
+ pendingStart.delete(opts.tabId);
427
+ run({ ...opts, worktreeDirHint: wt && wt.dir ? wt.dir : undefined });
428
+ });
429
+ return { accepted: true, queued: tabBusy };
430
+ }
431
+
384
432
  waiting.push(opts);
385
433
  pump();
386
434
  return { accepted: true, queued: tabBusy };
@@ -448,7 +496,7 @@ sessionSlots.subscribe(() => { try { pump(); } catch { /* defensive */ } });
448
496
  * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void, onSilentError?: (message: string, code?: string) => void, promptId?: string }} opts
449
497
  * @returns {Promise<void>}
450
498
  */
451
- function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult, onSilentError, promptId }) {
499
+ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult, onSilentError, promptId, origin, worktreeDirHint }) {
452
500
  return new Promise((resolve) => {
453
501
  const startedAt = Date.now();
454
502
  // Last few tool_use blocks seen on the stream, oldest first — surfaced in
@@ -532,7 +580,8 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
532
580
 
533
581
  // Prepend the stop-signal protocol instruction and the chat-mode truth
534
582
  // instruction to every prompt
535
- const fullPrompt = STOP_SIGNAL_INSTRUCTION + CHAT_MODE_TRUTH_INSTRUCTION + prompt;
583
+ const fullPrompt = (origin === 'scheduler' ? SCHEDULER_TURN_INSTRUCTION : STOP_SIGNAL_INSTRUCTION)
584
+ + CHAT_MODE_TRUTH_INSTRUCTION + prompt;
536
585
 
537
586
  // sessionId is the Epic's claudeSessionId for an Epic-backed tab (see
538
587
  // comment above childEnv) — resolve that Epic's agentType persona model,
@@ -551,7 +600,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
551
600
  // Main is authoritative about --resume vs --session-id AND the spawn cwd, and it decides
552
601
  // them TOGETHER (epicSpawnPlan.cjs): a transcript under an encoding the spawn cwd cannot see
553
602
  // must never yield a claimed flag. Unreachable / closed Epics are refused before any spawn.
554
- const plan = planEpicSpawn({ cwd, claudeSessionId: sessionId, fallbackResume: !!resume });
603
+ const plan = planEpicSpawn({ cwd, claudeSessionId: sessionId, fallbackResume: !!resume, worktreeDirHint });
555
604
  if (!plan.ok) {
556
605
  emitTerminal('chat:run:error', { tabId, sessionId, code: plan.code, message: plan.message });
557
606
  // emitTerminal is a no-op broadcast for silent runs, so hand the refusal to the caller
@@ -919,6 +968,14 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
919
968
  * no-op cancel, since there is nothing further to await in those cases.
920
969
  */
921
970
  function cancel(tabId) {
971
+ const parked = pendingStart.get(tabId);
972
+ if (parked) {
973
+ pendingStart.delete(tabId);
974
+ if (!parked.silent) {
975
+ broadcast('chat:run:error', { tabId: parked.tabId, sessionId: parked.sessionId, message: 'cancelled while queued' });
976
+ }
977
+ return Promise.resolve();
978
+ }
922
979
  const entry = inFlight.get(tabId);
923
980
  if (entry) {
924
981
  entry.cancelFn(); // settle() (on child exit) deletes from inFlight + pumps the next run
@@ -959,8 +1016,9 @@ function hasLiveWindow() {
959
1016
  * { disposition: 'unconfirmed', reason: 'timeout' | 'no-window' }. Never rejects.
960
1017
  */
961
1018
  function enqueueExternalPrompt(tabId, prompt, extra = {}) {
962
- const { sessionId, cwd } = extra ?? {};
1019
+ const { sessionId, cwd, origin } = extra ?? {};
963
1020
  if (!hasLiveWindow()) return Promise.resolve({ disposition: 'unconfirmed', reason: 'no-window' });
1021
+ if (origin === 'scheduler') noteSchedulerPrompt(tabId, prompt);
964
1022
  const requestId = require('node:crypto').randomUUID();
965
1023
  return new Promise((resolve) => {
966
1024
  const timer = setTimeout(() => {
@@ -1088,6 +1146,7 @@ module.exports = {
1088
1146
  // (lib/promptPreamble.ts) can be drift-tested against the REAL text rather
1089
1147
  // than a copy — see chat-preamble-anchors.test.cjs.
1090
1148
  STOP_SIGNAL_INSTRUCTION,
1149
+ SCHEDULER_TURN_INSTRUCTION,
1091
1150
  CHAT_MODE_TRUTH_INSTRUCTION,
1092
1151
  KILL_CEILING_MS,
1093
1152
  KILL_CEILING_MIN,
@@ -26,6 +26,13 @@ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
26
26
  const { expandHome } = require('./lib/util/expandHome.cjs');
27
27
  const { listReferencedFiles } = require('./lib/importReferences.cjs');
28
28
  const atomicFs = require('./lib/util/atomicFs.cjs');
29
+ const insideHome = require('./lib/insideHome.cjs');
30
+
31
+ // Platform + matching path module, read per call so tests can inject win32.
32
+ const platformNow = () => process.platform;
33
+ const pathFor = () => (platformNow() === 'win32' ? path.win32 : path);
34
+ const within = (real, root) => insideHome.isContainedIn(real, root, platformNow());
35
+ const canon = (p) => insideHome.realpath(p, { platform: platformNow() });
29
36
 
30
37
  /** Map<watchKey, {watcher, refCount}> — one chokidar watcher per path (+ depth, see watchKeyOf). */
31
38
  const watchers = new Map();
@@ -55,26 +62,7 @@ function attachWindow(w) {
55
62
  * squarely inside the allowed root.
56
63
  */
57
64
  function realResolve(abs) {
58
- const lex = path.resolve(expandHome(abs));
59
- try {
60
- return fs.realpathSync(lex);
61
- } catch (e) {
62
- if (e.code !== 'ENOENT') throw e;
63
- }
64
- const tail = [path.basename(lex)];
65
- let dir = path.dirname(lex);
66
- for (;;) {
67
- try {
68
- const realDir = fs.realpathSync(dir);
69
- return path.join(realDir, ...tail.reverse());
70
- } catch (e) {
71
- if (e.code !== 'ENOENT') throw e;
72
- const parentDir = path.dirname(dir);
73
- if (parentDir === dir) return lex; // hit the filesystem root; give up safely
74
- tail.push(path.basename(dir));
75
- dir = parentDir;
76
- }
77
- }
65
+ return canon(expandHome(abs));
78
66
  }
79
67
 
80
68
  /**
@@ -87,7 +75,12 @@ function realResolve(abs) {
87
75
  const allowedRoots = new Set([os.homedir()]);
88
76
 
89
77
  function addAllowedRoot(dir) {
90
- if (dir) allowedRoots.add(path.resolve(dir));
78
+ if (dir) allowedRoots.add(pathFor().resolve(dir));
79
+ }
80
+
81
+ /** Snapshot of the registered roots (main-process-registered only). */
82
+ function getAllowedRoots() {
83
+ return [...allowedRoots];
91
84
  }
92
85
 
93
86
  // PromptSession archive writes (state/promptSessions.ts's markCompleted ->
@@ -119,11 +112,11 @@ function validatePath(abs) {
119
112
  for (const root of allowedRoots) {
120
113
  let realRoot;
121
114
  try {
122
- realRoot = fs.realpathSync(root);
115
+ realRoot = canon(root);
123
116
  } catch {
124
117
  realRoot = root;
125
118
  }
126
- if (real === realRoot || real.startsWith(realRoot + path.sep)) return real;
119
+ if (within(real, realRoot)) return real;
127
120
  }
128
121
  throw new Error(`Path outside allowed boundaries: ${real}`);
129
122
  }
@@ -132,7 +125,7 @@ function validatePath(abs) {
132
125
  // home because validateWrite compares against a realpath — on macOS a HOME
133
126
  // under os.tmpdir() (/var → /private/var) otherwise never matches.
134
127
  const REAL_HOME = (() => {
135
- try { return fs.realpathSync(os.homedir()); } catch { return os.homedir(); }
128
+ try { return canon(os.homedir()); } catch { return os.homedir(); }
136
129
  })();
137
130
  const WRITE_PREFIXES = [
138
131
  path.join(REAL_HOME, '.claude'),
@@ -150,7 +143,7 @@ function validateWrite(realAbs) {
150
143
  throw new Error(`Write to .credentials.json denied`);
151
144
  }
152
145
  const inWritePrefix = WRITE_PREFIXES.some(
153
- (p) => realAbs === p || realAbs.startsWith(p + path.sep)
146
+ (p) => within(realAbs, p)
154
147
  );
155
148
  if (inWritePrefix) return;
156
149
  // The app's own userData dir (update-check.json cache).
@@ -162,10 +155,10 @@ function validateWrite(realAbs) {
162
155
  for (const root of allowedRoots) {
163
156
  if (root === os.homedir()) continue;
164
157
  let realRoot;
165
- try { realRoot = fs.realpathSync(root); } catch { realRoot = root; }
166
- if (realAbs === realRoot || realAbs.startsWith(realRoot + path.sep)) {
158
+ try { realRoot = canon(root); } catch { realRoot = root; }
159
+ if (within(realAbs, realRoot)) {
167
160
  const claudeSub = path.join(realRoot, '.claude');
168
- if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
161
+ if (within(realAbs, claudeSub)) {
169
162
  return;
170
163
  }
171
164
  // Writable ops namespaces under a registered project root — every
@@ -189,7 +182,7 @@ function validateWrite(realAbs) {
189
182
  opsSubs = [];
190
183
  }
191
184
  for (const sub of opsSubs) {
192
- if (realAbs === sub || realAbs.startsWith(sub + path.sep)) return;
185
+ if (within(realAbs, sub)) return;
193
186
  }
194
187
  }
195
188
  }
@@ -536,6 +529,7 @@ module.exports = {
536
529
  listDir,
537
530
  exists,
538
531
  addAllowedRoot,
532
+ getAllowedRoots,
539
533
  registerPromptSessionsRoot,
540
534
  validatePath,
541
535
  validateWrite,
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
2
3
 
3
4
  /**
4
5
  * docEdit.cjs — Document Experience rewrite runner (PRD 638).
@@ -154,11 +155,7 @@ let mainWindow = null;
154
155
  function attachWindow(win) { mainWindow = win; }
155
156
 
156
157
  function broadcast(channel, payload) {
157
- try {
158
- if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents && !mainWindow.webContents.isDestroyed()) {
159
- mainWindow.webContents.send(channel, payload);
160
- }
161
- } catch { /* render frame may be gone */ }
158
+ sendIfAlive(mainWindow, channel, payload);
162
159
  }
163
160
 
164
161
  // `chatRunner.run()`'s silent lane invokes `onSilentResult` on its success path and
@@ -20,7 +20,8 @@ const path = require('node:path');
20
20
  const os = require('node:os');
21
21
 
22
22
  const { z } = require('zod');
23
- const { assertInsideHome } = require('./lib/insideHome.cjs');
23
+ const { assertInsideHomeOrRoots } = require('./lib/insideHome.cjs');
24
+ const { getAllowedRoots } = require('./config.cjs');
24
25
  const { expandHome } = require('./lib/util/expandHome.cjs');
25
26
  const atomicFs = require('./lib/util/atomicFs.cjs');
26
27
  const { schemas } = require('./ipcSchemas.cjs');
@@ -32,7 +33,7 @@ const { schemas } = require('./ipcSchemas.cjs');
32
33
  * ~ — but never escapes the home tree.
33
34
  */
34
35
  function validateHomePath(abs) {
35
- return assertInsideHome(expandHome(abs)).realPath;
36
+ return assertInsideHomeOrRoots(expandHome(abs), getAllowedRoots()).realPath;
36
37
  }
37
38
 
38
39
  /** Reject .credentials.json writes regardless of where they sit. */
@@ -282,14 +283,23 @@ async function duplicateEntry(filePath) {
282
283
  }
283
284
  }
284
285
 
285
- const CRITICAL_PATHS = new Set([os.homedir(), '/', '/usr', '/bin', '/etc', '/var', '/System', '/Applications']);
286
+ const CRITICAL_PATHS = ['/', '/usr', '/bin', '/etc', '/var', '/System', '/Applications'];
287
+
288
+ /** Case-insensitive on win32 (`C:\\Users\\Me` == `c:\\users\\me`). */
289
+ function isCriticalPath(resolved, platform = process.platform, home = os.homedir()) {
290
+ const norm = platform === 'win32'
291
+ ? (v) => path.win32.normalize(v).toLowerCase().replace(/[\\/]+$/, '')
292
+ : (v) => v;
293
+ const r = norm(resolved);
294
+ return [home, ...CRITICAL_PATHS].some((c) => norm(c) === r);
295
+ }
286
296
 
287
297
  async function deleteEntry(filePath) {
288
298
  let resolved;
289
299
  try { resolved = validateHomePath(filePath); }
290
300
  catch (e) { return { ok: false, error: e.message }; }
291
301
 
292
- if (CRITICAL_PATHS.has(resolved)) {
302
+ if (isCriticalPath(resolved)) {
293
303
  return { ok: false, error: 'Cannot delete system-critical paths' };
294
304
  }
295
305
  try { rejectCredentials(resolved); }
@@ -414,6 +424,8 @@ module.exports = {
414
424
  showInFinder,
415
425
  // exported for tests
416
426
  rejectCredentials,
427
+ validateHomePath,
428
+ isCriticalPath,
417
429
  listDir,
418
430
  readFile,
419
431
  writeFile,
@@ -16,6 +16,9 @@ const { PROC_NAMES, setProcessTitle, inhibitHolderShell } = require('./lib/proce
16
16
  // `process.argv.includes('--simple')` read further down.
17
17
  setProcessTitle(PROC_NAMES.main);
18
18
  const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut, shell, clipboard, powerSaveBlocker, protocol } = require('electron');
19
+ // Capture every ipcMain.handle registration (before any module registers one) so the
20
+ // web-remote ipc:invoke dispatcher calls the exact handlers the renderer calls.
21
+ const capturedIpcHandlers = require('./lib/feedback-remote/remoteIpcDispatch.cjs').captureIpcHandlers(ipcMain);
19
22
  const { spawn, execFile, execFileSync } = require('node:child_process');
20
23
  const path = require('node:path');
21
24
  const fs = require('node:fs');
@@ -62,7 +65,7 @@ const prdCreate = require('./lib/prd/prdCreate.cjs');
62
65
  const prdAdminRoutes = require('./lib/prd/prdAdminRoutes.cjs');
63
66
  const projectHomeAdminRoutes = require('./lib/feedback-remote/projectHomeAdminRoutes.cjs');
64
67
  const macroAdminRoutes = require('./lib/feedback-remote/macroAdminRoutes.cjs');
65
- const { smfileResponseHeaders, shouldBlockFrameNavigation } = require('./lib/smfileHeaders.cjs');
68
+ const { smfileResponseHeaders, shouldBlockFrameNavigation, smfileUrlToPath } = require('./lib/smfileHeaders.cjs');
66
69
  const { appendAuditEvent } = require('./lib/telemetry/auditLog.cjs');
67
70
  const chatRunner = require('./chatRunner.cjs');
68
71
  const promptSessionEvents = require('./promptSessionEvents.cjs');
@@ -122,6 +125,24 @@ const heapSnapshot = require('./heapSnapshot.cjs');
122
125
  const filesIpc = require('./files.cjs');
123
126
  const { registerDocEditHandlers, attachWindow: attachDocEditWindow } = require('./docEdit.cjs');
124
127
  const webRemote = require('./webRemote.cjs');
128
+ {
129
+ const { createRemoteIpcDispatcher } = require('./lib/feedback-remote/remoteIpcDispatch.cjs');
130
+ webRemote.setIpcDispatcher(createRemoteIpcDispatcher({
131
+ surface: require('./../preload/apiSurface.cjs'),
132
+ handlers: capturedIpcHandlers,
133
+ getConfig: () => webRemote.loadConfig(),
134
+ fenceCtx: {
135
+ homeDir: os.homedir(),
136
+ realpath: (p) => require('./lib/insideHome.cjs').realpath(p),
137
+ projectRoots: () => {
138
+ try {
139
+ const data = JSON.parse(fs.readFileSync(sessionsStore.storePath(), 'utf8'));
140
+ return (data.tabs || []).map((t) => t && t.cwd).filter((c) => typeof c === 'string' && c);
141
+ } catch { return []; }
142
+ },
143
+ },
144
+ }));
145
+ }
125
146
  const { registerAgentsHandlers } = require('./agentsIpc.cjs');
126
147
  const { registerTelemetryHandlers } = require('./telemetryIpc.cjs');
127
148
  const { registerUpdaterIpc } = require('./updaterIpc.cjs');
@@ -1025,6 +1046,7 @@ require('./lib/epic/archivedTranscript.cjs').registerIpc(ipcMain);
1025
1046
  customerFeedbackIpc.registerCustomerFeedbackHandlers();
1026
1047
  epicWorktreeMint.registerEpicWorktreeMintHandlers();
1027
1048
  epicWorktreeMerge.registerEpicWorktreeMergeHandlers();
1049
+ require('./lib/epic/epicReopen.cjs').registerEpicReopenHandlers();
1028
1050
  epicWorktreeProjectConfig.registerEpicWorktreeProjectConfigHandlers();
1029
1051
  registerMemoryAggregateIpc();
1030
1052
  registerProjectBriefIpc();
@@ -1322,7 +1344,7 @@ app.whenReady().then(async () => {
1322
1344
  protocol.handle('smfile', async (request) => {
1323
1345
  try {
1324
1346
  const url = new URL(request.url);
1325
- const abs = decodeURIComponent(url.pathname);
1347
+ const abs = smfileUrlToPath(url.pathname);
1326
1348
  const { realPath } = assertInsideHome(abs); // throws if outside home
1327
1349
  const st = await fs.promises.stat(realPath);
1328
1350
  if (st.isDirectory()) return new Response('Not a file', { status: 404 });
@@ -1365,7 +1387,7 @@ app.whenReady().then(async () => {
1365
1387
  accelerator: 'CmdOrCtrl+N',
1366
1388
  click: () => {
1367
1389
  if (mainWindow && !mainWindow.isDestroyed()) {
1368
- mainWindow.webContents.send('app:new-session');
1390
+ sendIfAlive(mainWindow, 'app:new-session');
1369
1391
  }
1370
1392
  },
1371
1393
  },
@@ -1379,7 +1401,7 @@ app.whenReady().then(async () => {
1379
1401
  accelerator: 'CmdOrCtrl+Shift+S',
1380
1402
  click: () => {
1381
1403
  if (mainWindow && !mainWindow.isDestroyed()) {
1382
- mainWindow.webContents.send('app:reboot-session');
1404
+ sendIfAlive(mainWindow, 'app:reboot-session');
1383
1405
  }
1384
1406
  },
1385
1407
  },
@@ -232,6 +232,9 @@ const promptSessionsCreateWorktree = z.object({
232
232
  epicId: z.string().regex(PROMPT_SESSION_ID_RE),
233
233
  });
234
234
 
235
+ // Renderer entry point for reopening a completed Epic in place (lib/epic/epicReopen.cjs).
236
+ const promptSessionsReopen = z.object({ cwd: z.string().min(1).max(4096), epicId: z.string().regex(PROMPT_SESSION_ID_RE) }).strict();
237
+
235
238
  // Renderer entry point for folding an Epic's isolated git worktree branch
236
239
  // back into its owning project's main tree (lib/epic/epicWorktreeMerge.cjs) — PRD
237
240
  // 1034, the ONLY point where the per-Epic isolation PRD 1033 wires resolves.
@@ -835,8 +838,8 @@ const projectBriefUpdate = z.object({
835
838
  purpose: PROJECT_BRIEF_TEXT.optional(),
836
839
  what: z.array(PROJECT_BRIEF_TEXT).max(50).optional(),
837
840
  conventions: z.array(PROJECT_BRIEF_TEXT).max(100).optional(),
838
- areas: z.array(z.record(z.unknown())).max(100).optional(),
839
- scope: z.array(z.record(z.unknown())).max(200).optional(),
841
+ areas: z.array(z.record(z.string(), z.unknown())).max(100).optional(),
842
+ scope: z.array(z.record(z.string(), z.unknown())).max(200).optional(),
840
843
  }).strict().refine((p) => Object.keys(p).length > 0, { message: 'patch is empty' }),
841
844
  }).strict();
842
845
 
@@ -1357,6 +1360,7 @@ module.exports = {
1357
1360
  promptSessionsMergeActiveIndex,
1358
1361
  promptSessionsCreateEpic,
1359
1362
  promptSessionsCreateWorktree,
1363
+ promptSessionsReopen,
1360
1364
  promptSessionsMergeToMain,
1361
1365
  promptSessionsGetWorktreeDisabled,
1362
1366
  promptSessionsSetWorktreeDisabled,