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
@@ -26,11 +26,9 @@
26
26
  * lock (epicMint.cjs exports withPathLock; requiring it here shares the
27
27
  * same module-level Map via Node's require cache, not a second one).
28
28
  *
29
- * Tombstones are never auto-cleared: resumeArchived() (state/promptSessions.ts)
30
- * always mints a BRAND NEW Epic id + claudeSessionId — it never reuses the
31
- * archived id — so a tombstoned id is never legitimately reborn under the
32
- * current domain model. If that ever changes, this module would need an
33
- * explicit "un-tombstone" path; today one would be dead code.
29
+ * Tombstones are cleared in exactly one place: reopenEpic (lib/epic/epicReopen.cjs),
30
+ * a human pressing Resume/Reopen on a completed Epic, under the same withPathLock.
31
+ * Nothing else un-tombstones an id.
34
32
  */
35
33
 
36
34
  const path = require('path');
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * epicReopen.cjs — flip a completed Epic back to 'active' IN PLACE (same Epic id, same
5
+ * claudeSessionId, same event chain) so the next turn continues via `claude --resume`.
6
+ * Clears the three disk guards deliberately: the removal tombstone, the archive-shaped
7
+ * `<id>.json` (rewritten as a live status mirror), and the merged-away worktree.
8
+ * Electron-free at load; electron/config/ipcSchemas are required lazily in the register fn.
9
+ */
10
+
11
+ const fs = require('node:fs');
12
+ const { activeIndexPath, readActiveIndex, withPathLock } = require('./epicMint.cjs');
13
+ const { epicMirrorPath } = require('./epicStatusMirror.cjs');
14
+ const { assertOpsWrite } = require('../opsOwnership.cjs');
15
+ const atomicFs = require('../util/atomicFs.cjs');
16
+ const { hasTranscriptSource, ensureEpicTranscriptReachable } = require('./epicTranscriptRestore.cjs');
17
+ const { restoreEpicWorktree } = require('./epicSpawnCwd.cjs');
18
+
19
+ const inFlight = new Map();
20
+
21
+ const fail = (code, message) => ({ ok: false, code, message });
22
+
23
+ function isDir(dir, deps) {
24
+ try {
25
+ return (deps.statSync || fs.statSync)(dir).isDirectory();
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ async function provisionWorktree({ cwd, epicId, archived, deps }) {
32
+ const wt = archived && typeof archived === 'object' ? archived : null;
33
+ const st = wt && wt.status;
34
+ if (!wt || !wt.dir || st === 'merged' || st === 'disabled') {
35
+ const createWorktree = deps.createWorktree || ((c, id) => require('./epicWorktreeMint.cjs').createEpicWorktreeViaIpc(c, id));
36
+ let created = null;
37
+ try {
38
+ created = await createWorktree(cwd, epicId);
39
+ } catch {
40
+ created = null;
41
+ }
42
+ return created ? { worktree: created, created: true } : { worktree: wt, created: false };
43
+ }
44
+ if (st === 'active' || st === 'needs_merge_resolution') {
45
+ if (isDir(wt.dir, deps)) return { worktree: { ...wt, status: 'active' }, created: false };
46
+ const restore = deps.restoreWorktree || restoreEpicWorktree;
47
+ let ok = false;
48
+ try {
49
+ ok = await restore({ dir: wt.dir, branch: wt.branch, baseCwd: cwd });
50
+ } catch {
51
+ ok = false;
52
+ }
53
+ return { worktree: ok ? { ...wt, status: 'active' } : wt, created: false };
54
+ }
55
+ return { worktree: wt, created: false };
56
+ }
57
+
58
+ function usableDir(worktree, deps) {
59
+ return worktree && worktree.dir && worktree.status === 'active' && isDir(worktree.dir, deps) ? worktree.dir : null;
60
+ }
61
+
62
+ async function doReopen({ cwd, epicId, deps }) {
63
+ const current = readActiveIndex(cwd);
64
+ const row = Object.prototype.hasOwnProperty.call(current.sessions, epicId) ? current.sessions[epicId] : null;
65
+ if (row && (row.status === 'active' || row.status === 'proposed')) {
66
+ return { ok: true, session: row, events: current.events[epicId] || [], alreadyOpen: true };
67
+ }
68
+
69
+ const mirrorFile = epicMirrorPath(cwd, epicId);
70
+ let archive = null;
71
+ try {
72
+ archive = JSON.parse(fs.readFileSync(mirrorFile, 'utf8'));
73
+ } catch {
74
+ archive = null;
75
+ }
76
+ const arch = archive && archive.session;
77
+ if (
78
+ !arch || typeof arch !== 'object' || arch.id !== epicId ||
79
+ (arch.cwd && arch.cwd !== cwd) ||
80
+ (archive.status || arch.status) !== 'completed'
81
+ ) {
82
+ return fail('not_found', `No completed Epic ${epicId} to reopen.`);
83
+ }
84
+
85
+ const claudeSessionId = arch.claudeSessionId;
86
+ const tDeps = deps.homeDir || deps.statSync ? { homeDir: deps.homeDir, statSync: deps.statSync } : {};
87
+ if (!hasTranscriptSource({ cwd, epicId, claudeSessionId, deps: tDeps })) {
88
+ // No source anywhere, so this never writes; it only names the failure.
89
+ const r = ensureEpicTranscriptReachable({ cwd, epicId, claudeSessionId, deps: tDeps });
90
+ return r.ok ? fail('transcript_missing', 'No saved transcript.') : r;
91
+ }
92
+
93
+ const { worktree, created } = await provisionWorktree({ cwd, epicId, archived: arch.worktree, deps });
94
+ const spawnCwd = usableDir(worktree, deps) || cwd;
95
+ const reach = ensureEpicTranscriptReachable({ cwd, epicId, claudeSessionId, spawnCwd, deps: tDeps });
96
+ if (!reach.ok) {
97
+ if (created && worktree && worktree.dir) {
98
+ try {
99
+ const cleanup = deps.cleanupWorktree || require('../git/gitWorktree.cjs').cleanupEpicWorktree;
100
+ await cleanup({ cwd, dir: worktree.dir, branch: worktree.branch, keepBranch: false });
101
+ } catch { /* best-effort */ }
102
+ }
103
+ return reach;
104
+ }
105
+
106
+ const indexFile = activeIndexPath(cwd);
107
+ let reopened;
108
+ let events;
109
+ try {
110
+ await withPathLock(indexFile, async () => {
111
+ const index = readActiveIndex(cwd);
112
+ delete index.tombstones[epicId];
113
+ reopened = { ...arch, status: 'active', completedAt: null };
114
+ if (worktree) reopened.worktree = worktree;
115
+ events = Array.isArray(archive.events) ? archive.events : [];
116
+ index.sessions[epicId] = reopened;
117
+ index.events[epicId] = events;
118
+ assertOpsWrite(indexFile, 'epics');
119
+ atomicFs.writeJsonAtomicSync(indexFile, index, { newline: false });
120
+ });
121
+ assertOpsWrite(mirrorFile, 'epics');
122
+ atomicFs.writeJsonAtomicSync(mirrorFile, {
123
+ ...reopened,
124
+ id: epicId,
125
+ cwd,
126
+ status: 'active',
127
+ archivedAt: null,
128
+ indexedAt: new Date().toISOString(),
129
+ });
130
+ } catch (err) {
131
+ return fail('write_failed', `Could not reopen Epic ${epicId}: ${err && err.message ? err.message : err}`);
132
+ }
133
+ return { ok: true, session: reopened, events, alreadyOpen: false };
134
+ }
135
+
136
+ /**
137
+ * @param {{ cwd: string, epicId: string, deps?: object }} opts
138
+ */
139
+ function reopenEpic({ cwd, epicId, deps = {} } = {}) {
140
+ const key = `${cwd}\0${epicId}`;
141
+ const existing = inFlight.get(key);
142
+ if (existing) return existing;
143
+ const p = doReopen({ cwd, epicId, deps }).finally(() => inFlight.delete(key));
144
+ inFlight.set(key, p);
145
+ return p;
146
+ }
147
+
148
+ function registerEpicReopenHandlers() {
149
+ const { ipcMain } = require('electron');
150
+ const { validatePath } = require('../../config.cjs');
151
+ const { schemas: s, validated: v } = require('../../ipcSchemas.cjs');
152
+ ipcMain.handle(
153
+ 'promptSessions:reopen',
154
+ v(s.promptSessionsReopen, ({ cwd, epicId }) => {
155
+ validatePath(cwd);
156
+ return reopenEpic({ cwd, epicId });
157
+ }),
158
+ );
159
+ }
160
+
161
+ module.exports = { reopenEpic, registerEpicReopenHandlers };
@@ -16,6 +16,9 @@
16
16
  * the real cause, never claim a flag that cannot work. Reachable = "some existing transcript lives
17
17
  * under encodeCwd(candidate cwd)" and the candidate is a real directory.
18
18
  *
19
+ * A turn-1 `worktreeDirHint` lets a brand-new Epic's first spawn land in its freshly minted worktree;
20
+ * once any transcript exists (or the hint is not a directory) the hint is ignored.
21
+ *
19
22
  * Also refuses requests routed to a closed (`completed`) Epic, and keeps a per-session circuit
20
23
  * breaker: once a session is found unreachable, later requests are refused after ONE stat (is the
21
24
  * worktree dir still missing?) without re-running the restore git commands.
@@ -74,7 +77,7 @@ function findEpic(cwd, claudeSessionId, deps) {
74
77
  * @returns {{ ok: true, execCwd: string, useResume: boolean, transcriptPath: string|null }
75
78
  * | { ok: false, code: 'epic_closed'|'session_unreachable'|'spawn_cwd_missing', message: string }}
76
79
  */
77
- function planEpicSpawn({ cwd, claudeSessionId, fallbackResume = false, deps = {} } = {}) {
80
+ function planEpicSpawn({ cwd, claudeSessionId, fallbackResume = false, worktreeDirHint, deps = {} } = {}) {
78
81
  const statSync = deps.statSync || fs.statSync;
79
82
  const epic = findEpic(cwd, claudeSessionId, deps);
80
83
 
@@ -98,6 +101,11 @@ function planEpicSpawn({ cwd, claudeSessionId, fallbackResume = false, deps = {}
98
101
  useResume = !!resolved.existsAnywhere;
99
102
  } catch { /* keep the caller-supplied flag */ }
100
103
 
104
+ // Turn-1 hint: no transcript anywhere yet and the freshly minted worktree exists → spawn there.
105
+ if ((!resolved || !resolved.existsAnywhere) && typeof worktreeDirHint === 'string' && worktreeDirHint && isDir(worktreeDirHint, statSync)) {
106
+ return { ok: true, execCwd: worktreeDirHint, useResume: false, transcriptPath: null };
107
+ }
108
+
101
109
  let execCwd = (deps.resolveSpawnCwd || resolveEpicSpawnCwd)({ cwd, claudeSessionId, deps: { restore: true, readActiveIndex: deps.readActiveIndex, statSync: deps.statSync, restoreWorktree: deps.restoreWorktree } });
102
110
 
103
111
  if (resolved && resolved.existsAnywhere) {
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * epicTranscriptRestore.cjs — guarantee the Claude CLI can find a completed Epic's transcript
5
+ * from the cwd it will be resumed in.
6
+ *
7
+ * `claude --resume <id>` only finds `~/.claude/projects/<encodeCwd(spawn cwd)>/<id>.jsonl`.
8
+ * Reopening a completed Epic must never fall back to a fresh `--session-id`: that silently
9
+ * starts a context-less conversation (the reported "Resume starts a new session" bug). A
10
+ * completed Epic's JSONL can sit under an old worktree encoding, or be gone entirely
11
+ * (Claude Code's cleanupPeriodDays) while our gzip sidecar still holds it. This module copies
12
+ * or restores it to the spawn cwd's encoding. It never overwrites a non-empty target and never
13
+ * moves or deletes a source.
14
+ */
15
+
16
+ const fs = require('node:fs');
17
+ const os = require('node:os');
18
+ const path = require('node:path');
19
+ const { encodeCwd } = require('../util/encodeCwd.cjs');
20
+ const atomicFs = require('../util/atomicFs.cjs');
21
+ const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
22
+ const { readSidecar } = require('./archivedTranscript.cjs');
23
+
24
+ const SID_RE = /^[A-Za-z0-9_-]{1,128}$/;
25
+
26
+ const isValidSid = (sid) => typeof sid === 'string' && SID_RE.test(sid);
27
+
28
+ function isNonEmptyFile(p, deps) {
29
+ try {
30
+ const st = (deps.statSync || fs.statSync)(p);
31
+ return st.isFile() && st.size > 0;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ function fileFor(dir, sid, deps) {
38
+ return path.join(deps.homeDir || os.homedir(), '.claude', 'projects', encodeCwd(dir), `${sid}.jsonl`);
39
+ }
40
+
41
+ function sidecarText(cwd, epicId) {
42
+ try {
43
+ const t = readSidecar(cwd, epicId);
44
+ return typeof t === 'string' && t.length > 0 ? t : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Find the first source: reachable file, other JSONL, or sidecar. No writes. */
51
+ function locate({ cwd, epicId, sid, spawnCwd, deps }) {
52
+ for (const dir of [...new Set([spawnCwd, cwd].filter(Boolean))]) {
53
+ const p = fileFor(dir, sid, deps);
54
+ if (isNonEmptyFile(p, deps)) return { kind: 'none', path: p };
55
+ }
56
+ let resolved = null;
57
+ try {
58
+ resolved = resolveEpicTranscriptPath({ cwd, claudeSessionId: sid, deps });
59
+ } catch {
60
+ resolved = null;
61
+ }
62
+ if (resolved && resolved.existsAnywhere && resolved.path && isNonEmptyFile(resolved.path, deps)) {
63
+ return { kind: 'copied', path: resolved.path };
64
+ }
65
+ const text = sidecarText(cwd, epicId);
66
+ if (text) return { kind: 'sidecar', text };
67
+ return null;
68
+ }
69
+
70
+ /**
71
+ * @param {{ cwd: string, epicId: string, claudeSessionId: string, deps?: object }} opts
72
+ * @returns {boolean}
73
+ */
74
+ function hasTranscriptSource({ cwd, epicId, claudeSessionId, spawnCwd, deps = {} } = {}) {
75
+ if (!isValidSid(claudeSessionId) || !cwd) return false;
76
+ return locate({ cwd, epicId, sid: claudeSessionId, spawnCwd: spawnCwd || cwd, deps }) !== null;
77
+ }
78
+
79
+ /**
80
+ * @param {{ cwd: string, epicId: string, claudeSessionId: string, spawnCwd?: string, deps?: object }} opts
81
+ * @returns {{ ok: true, path: string, restored: 'none'|'copied'|'sidecar' } | { ok: false, code: 'transcript_missing'|'invalid_session_id'|'restore_failed', message: string }}
82
+ */
83
+ function ensureEpicTranscriptReachable({ cwd, epicId, claudeSessionId, spawnCwd, deps = {} } = {}) {
84
+ if (!isValidSid(claudeSessionId)) {
85
+ return { ok: false, code: 'invalid_session_id', message: 'Invalid Claude session id.' };
86
+ }
87
+ const sid = claudeSessionId;
88
+ const target = fileFor(spawnCwd || cwd, sid, deps);
89
+ const found = locate({ cwd, epicId, sid, spawnCwd, deps });
90
+ if (!found) {
91
+ return {
92
+ ok: false,
93
+ code: 'transcript_missing',
94
+ message: `No saved transcript for session ${sid} — it cannot be resumed. Use "Duplicate as new session" to start a fresh one.`,
95
+ };
96
+ }
97
+ if (found.kind === 'none') return { ok: true, path: found.path, restored: 'none' };
98
+ try {
99
+ const text = found.kind === 'copied' ? fs.readFileSync(found.path, 'utf8') : found.text;
100
+ // Re-check right before writing: never overwrite a non-empty target.
101
+ if (isNonEmptyFile(target, deps)) return { ok: true, path: target, restored: 'none' };
102
+ atomicFs.writeTextAtomicSync(target, text, { mode: 0o600 });
103
+ return { ok: true, path: target, restored: found.kind };
104
+ } catch (err) {
105
+ return { ok: false, code: 'restore_failed', message: `Could not restore transcript for session ${sid}: ${err && err.message ? err.message : err}` };
106
+ }
107
+ }
108
+
109
+ module.exports = { hasTranscriptSource, ensureEpicTranscriptReachable };
@@ -173,7 +173,7 @@ async function runEpicSettle({ cwd, epicId } = {}, deps = {}) {
173
173
  }
174
174
 
175
175
  const prompt = buildSettledDigest(digestSettle, { epicId, notValidatedReason });
176
- sendPrompt(epicId, prompt);
176
+ sendPrompt(epicId, prompt, { origin: 'scheduler' });
177
177
  await appendResponseEvent(
178
178
  cwd, epicId,
179
179
  `Plan digest sent: ${settle.verified.length} verified, ${settle.refuted.length} refuted, ${settle.uncovered.length} not validated`,
@@ -24,6 +24,7 @@
24
24
  * since this is likewise reachable from the renderer over IPC.
25
25
  */
26
26
 
27
+ const path = require('node:path');
27
28
  const { createEpicWorktree } = require('../git/gitWorktree.cjs');
28
29
  const { validatePath } = require('../../config.cjs');
29
30
 
@@ -35,8 +36,54 @@ const { validatePath } = require('../../config.cjs');
35
36
  * `null` on any failure — createEpicWorktree/createWorktree never throw, so
36
37
  * this never does either.
37
38
  */
38
- async function createEpicWorktreeViaIpc(cwd, epicId) {
39
- validatePath(cwd);
39
+ /** In-flight mints, keyed by resolved cwd + epicId (turn 1 awaits these — awaitPendingEpicWorktree). */
40
+ const pendingMints = new Map();
41
+ const mintKey = (cwd, epicId) => `${path.resolve(String(cwd))}\0${epicId}`;
42
+
43
+ function createEpicWorktreeViaIpc(cwd, epicId) {
44
+ try { validatePath(cwd); } catch (err) { return Promise.reject(err); }
45
+ const key = mintKey(cwd, epicId);
46
+ const p = mintEpicWorktree(cwd, epicId);
47
+ pendingMints.set(key, p);
48
+ p.finally(() => { if (pendingMints.get(key) === p) pendingMints.delete(key); }).catch(() => {});
49
+ return p;
50
+ }
51
+
52
+ /**
53
+ * awaitPendingEpicWorktree(cwd, epicId, { timeoutMs }) → Promise<EpicWorktree | null>
54
+ * Resolves the in-flight mint's result, null when nothing is pending or after timeoutMs. Never rejects.
55
+ */
56
+ async function awaitPendingEpicWorktree(cwd, epicId, { timeoutMs = 90000 } = {}) {
57
+ let p;
58
+ try { p = pendingMints.get(mintKey(cwd, epicId)); } catch { return null; }
59
+ if (!p) return null;
60
+ let timer;
61
+ const timeout = new Promise((resolve) => {
62
+ timer = setTimeout(() => {
63
+ console.log(`[epicWorktreeMint] ${epicId}: pending mint timed out after ${timeoutMs}ms`);
64
+ resolve(null);
65
+ }, timeoutMs);
66
+ if (timer && typeof timer.unref === 'function') timer.unref();
67
+ });
68
+ try {
69
+ return await Promise.race([p.catch(() => null), timeout]);
70
+ } catch {
71
+ return null;
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+
77
+ /** True exactly when a mint is in flight for this Epic. Synchronous, never throws. */
78
+ function hasPendingEpicWorktree(cwd, epicId) {
79
+ try { return pendingMints.has(mintKey(cwd, epicId)); } catch { return false; }
80
+ }
81
+
82
+ function __resetPendingForTests() {
83
+ pendingMints.clear();
84
+ }
85
+
86
+ async function mintEpicWorktree(cwd, epicId) {
40
87
  const result = await createEpicWorktree({ cwd, epicId });
41
88
  if (!result.ok) {
42
89
  console.log(`[epicWorktreeMint] ${epicId}: not isolated (${result.reason})`);
@@ -66,4 +113,4 @@ function registerEpicWorktreeMintHandlers() {
66
113
  );
67
114
  }
68
115
 
69
- module.exports = { createEpicWorktreeViaIpc, registerEpicWorktreeMintHandlers };
116
+ module.exports = { createEpicWorktreeViaIpc, awaitPendingEpicWorktree, hasPendingEpicWorktree, __resetPendingForTests, registerEpicWorktreeMintHandlers };
@@ -67,6 +67,7 @@
67
67
  const fs = require('node:fs');
68
68
  const fsp = require('node:fs/promises');
69
69
  const os = require('node:os');
70
+ const { sendIfAlive } = require('../sendToRenderer.cjs');
70
71
  const path = require('node:path');
71
72
 
72
73
  const config = require('../../config.cjs');
@@ -453,7 +454,7 @@ function defaultNotifyRenderer(payload) {
453
454
  const { BrowserWindow } = require('electron');
454
455
  for (const win of BrowserWindow.getAllWindows()) {
455
456
  try {
456
- win.webContents.send('promptSessions:inbound-auto-approve', payload);
457
+ sendIfAlive(win, 'promptSessions:inbound-auto-approve', payload);
457
458
  } catch { /* window going away */ }
458
459
  }
459
460
  } catch { /* not running under Electron */ }
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * remoteAssets.cjs — serves this desktop's own `<app root>/dist-web/` build to an
5
+ * E2E-authenticated browser, so the browser runs exactly the UI of the desktop it talks to.
6
+ * Names are basename-only and realpath-confined; nothing outside dist-web is readable.
7
+ */
8
+
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const crypto = require('node:crypto');
12
+
13
+ const MAX_ASSET_BYTES = 25 * 1024 * 1024;
14
+
15
+ const TYPES = {
16
+ '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css',
17
+ '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg',
18
+ '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon',
19
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.wasm': 'application/wasm',
20
+ '.map': 'application/json', '.txt': 'text/plain', '.onnx': 'application/octet-stream',
21
+ };
22
+
23
+ let _rootOverride = null;
24
+
25
+ /** Same layout rule as index.cjs's dist/index.html lookup: <src/main>/../../dist-web. */
26
+ function webRoot() {
27
+ return _rootOverride || path.join(__dirname, '..', '..', '..', '..', 'dist-web');
28
+ }
29
+
30
+ function typeOf(name) {
31
+ return TYPES[path.extname(name).toLowerCase()] || 'application/octet-stream';
32
+ }
33
+
34
+ function realRoot() {
35
+ try {
36
+ const r = fs.realpathSync(webRoot());
37
+ return fs.statSync(r).isDirectory() ? r : null;
38
+ } catch { return null; }
39
+ }
40
+
41
+ /** Resolve a requested name to a confined regular file, or null. */
42
+ function resolveAsset(name) {
43
+ if (typeof name !== 'string' || name.length === 0 || name.length > 255) return null;
44
+ if (name !== path.basename(name) || name.includes('\\') || name.includes('/') || name.includes('\0') ||
45
+ name === '.' || name === '..') return null;
46
+ const root = realRoot();
47
+ if (!root) return null;
48
+ try {
49
+ const real = fs.realpathSync(path.join(root, name));
50
+ if (path.dirname(real) !== root) return null;
51
+ const st = fs.statSync(real);
52
+ return st.isFile() ? { real, size: st.size } : null;
53
+ } catch { return null; }
54
+ }
55
+
56
+ function packageVersion() {
57
+ try {
58
+ return JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', '..', '..', 'package.json'), 'utf8')).version || null;
59
+ } catch { return null; }
60
+ }
61
+
62
+ /** -> { available, version, files:[{ name, size, sha256, type }] } */
63
+ function assetManifest() {
64
+ const version = packageVersion();
65
+ const root = realRoot();
66
+ if (!root) return { available: false, version, files: [] };
67
+ const files = [];
68
+ for (const e of fs.readdirSync(root, { withFileTypes: true })) {
69
+ if (!e.isFile()) continue;
70
+ const a = resolveAsset(e.name);
71
+ if (!a || a.size > MAX_ASSET_BYTES) continue;
72
+ files.push({
73
+ name: e.name, size: a.size,
74
+ sha256: crypto.createHash('sha256').update(fs.readFileSync(a.real)).digest('hex'),
75
+ type: typeOf(e.name),
76
+ });
77
+ }
78
+ files.sort((x, y) => (x.name < y.name ? -1 : 1));
79
+ return { available: true, version, files };
80
+ }
81
+
82
+ /** -> { ok:true, name, size, type, sha256, data(base64) } | { ok:false, error } */
83
+ function readAsset(name) {
84
+ const a = resolveAsset(name);
85
+ if (!a) return { ok: false, error: 'not_found' };
86
+ if (a.size > MAX_ASSET_BYTES) return { ok: false, error: 'too_large' };
87
+ const buf = fs.readFileSync(a.real);
88
+ return {
89
+ ok: true, name, size: buf.length, type: typeOf(name),
90
+ sha256: crypto.createHash('sha256').update(buf).digest('hex'),
91
+ data: buf.toString('base64'),
92
+ };
93
+ }
94
+
95
+ module.exports = {
96
+ assetManifest, readAsset, MAX_ASSET_BYTES,
97
+ __test: { setRoot: (p) => { _rootOverride = p; } },
98
+ };