claude-code-session-manager 0.35.4 → 0.35.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,16 +5,24 @@
5
5
  * the terminal chat UI (PRD 319). Each call spawns a fresh process that exits
6
6
  * when done; no idle process lives between commands.
7
7
  *
8
- * v0.34: runs are serialized through a FIFO queue ONE loop at a time by
9
- * default (SM_CHAT_CONCURRENCY overrides, clamped to [1,3]). Extra submits
10
- * queue instead of erroring; modeled on the scheduler's serialized queue so a
11
- * burst of commands across tabs can't fan out into the parallel-`claude -p`
12
- * OOM that SIGKILLed a job on 2026-06-26.
8
+ * v0.34: silent (automated) runs are serialized through a FIFO queue capped at
9
+ * CONCURRENCY_CAP (SM_CHAT_CONCURRENCY overrides, clamped to [1,3]); modeled
10
+ * on the scheduler's serialized queue so a burst of probes can't fan out into
11
+ * the parallel-`claude -p` OOM that SIGKILLed a job on 2026-06-26.
12
+ * PRD 493: manual, user-initiated runs bypass that lane entirely they always
13
+ * execute immediately, uncapped, so a foreground send never queues behind
14
+ * another tab.
13
15
  *
14
16
  * Public surface:
15
- * run({ tabId, sessionId, prompt, cwd, resume }): void — fire-and-forget enqueue
17
+ * run({ tabId, sessionId, prompt, cwd, resume, silent }): void — fire-and-forget enqueue.
18
+ * `silent` (PRD 470) runs still go through the CONCURRENCY_CAP FIFO lane and suppress the
19
+ * six turn-affecting broadcasts + recordExchange (see probeContextUsage below). Non-silent
20
+ * (manual) runs (PRD 493) execute immediately, uncapped, never entering the FIFO lane.
16
21
  * cancel(tabId): void
17
22
  * parseStopSignal(finalText): { questions: string[] } | null — exported for reuse
23
+ * parseContextUsageMarkdown(text): { usedTokens, totalTokens, usedPct, categories } | null
24
+ * — pure parser for `/context`
25
+ * probeContextUsage({ tabId, sessionId, cwd }): void — fire-and-forget silent `/context` probe
18
26
  * STOP_SENTINEL: string — exported for tests
19
27
  * __setExecutor(fn): void — test seam
20
28
  *
@@ -27,6 +35,8 @@
27
35
  * chat:run:needs-input { tabId, sessionId, questions, raw }
28
36
  * chat:run:error { tabId, sessionId, message }
29
37
  * chat:run:notice { tabId, sessionId, message } — informational, not terminal
38
+ * chat:context-usage { tabId, sessionId, usedTokens, totalTokens, usedPct, categories }
39
+ * — result of a silent `/context` probe
30
40
  */
31
41
 
32
42
  const { spawn } = require('node:child_process');
@@ -72,6 +82,119 @@ function parseStopSignal(finalText) {
72
82
  }
73
83
  }
74
84
 
85
+ // ─── `/context` probe (PRD 470) ────────────────────────────────────────────
86
+ // `claude -p "/context"` is answered entirely locally by the CLI (zero-cost,
87
+ // no real API call) with markdown containing a summary line and a per-category
88
+ // breakdown table. Parsed here so the app can mirror the CLI's own context-
89
+ // window accounting without re-implementing the estimate itself.
90
+
91
+ // "9k" -> 9000, "15.1k" -> 15100; bare "207" -> 207. Never throws — returns NaN
92
+ // for genuinely malformed input, which callers treat as a parse failure.
93
+ function parseTokenAmount(str) {
94
+ const suffixed = /^(\d+(?:\.\d+)?)k$/i.exec(str);
95
+ if (suffixed) return Math.round(parseFloat(suffixed[1]) * 1000);
96
+ return Number(str);
97
+ }
98
+
99
+ /**
100
+ * Parse the markdown produced by the CLI's own `/context` slash command into
101
+ * a structured summary. Pure, single line-by-line pass — O(n) over the lines
102
+ * of a small, bounded response, no nested scans.
103
+ *
104
+ * @param {string} text
105
+ * @returns {{ usedTokens: number, totalTokens: number, usedPct: number, categories: Array<{ category: string, tokens: number, pct: number }> } | null}
106
+ */
107
+ function parseContextUsageMarkdown(text) {
108
+ if (typeof text !== 'string' || !text) return null;
109
+ const lines = text.split('\n');
110
+
111
+ let usedTokens = null;
112
+ let totalTokens = null;
113
+ let usedPct = null;
114
+ const tokensLineRe = /\*\*Tokens:\*\*\s*(\S+)\s*\/\s*(\S+)\s*\(([\d.]+)%\)/;
115
+
116
+ let inTable = false;
117
+ let foundHeading = false;
118
+ const categories = [];
119
+
120
+ for (const line of lines) {
121
+ if (usedTokens === null) {
122
+ const m = tokensLineRe.exec(line);
123
+ if (m) {
124
+ usedTokens = parseTokenAmount(m[1]);
125
+ totalTokens = parseTokenAmount(m[2]);
126
+ usedPct = Number(m[3]);
127
+ }
128
+ }
129
+
130
+ if (!inTable) {
131
+ if (line.trim() === '### Estimated usage by category') {
132
+ foundHeading = true;
133
+ inTable = true;
134
+ }
135
+ continue;
136
+ }
137
+ const trimmed = line.trim();
138
+ if (trimmed.startsWith('###')) { inTable = false; continue; }
139
+ if (!trimmed.startsWith('|')) continue;
140
+
141
+ const cells = trimmed.split('|').map((c) => c.trim()).filter((c) => c.length > 0);
142
+ if (cells.length !== 3) continue;
143
+ if (cells[0].toLowerCase() === 'category') continue; // header row
144
+ if (/^:?-+:?$/.test(cells[0])) continue; // separator row
145
+
146
+ const tokens = parseTokenAmount(cells[1]);
147
+ const pctMatch = /^([\d.]+)%$/.exec(cells[2]);
148
+ if (!pctMatch || Number.isNaN(tokens)) continue;
149
+ categories.push({ category: cells[0], tokens, pct: Number(pctMatch[1]) });
150
+ }
151
+
152
+ if (
153
+ usedTokens === null || totalTokens === null || usedPct === null ||
154
+ Number.isNaN(usedTokens) || Number.isNaN(totalTokens) || Number.isNaN(usedPct) ||
155
+ !foundHeading || categories.length === 0
156
+ ) {
157
+ return null;
158
+ }
159
+
160
+ return { usedTokens, totalTokens, usedPct, categories };
161
+ }
162
+
163
+ /**
164
+ * Fire-and-forget silent probe of the resumed session's context usage. Reuses
165
+ * the normal `run()` queue/lane (resume: true, silent: true) so it can never
166
+ * race a real chat run against the same `--resume sessionId`. Broadcasts
167
+ * `chat:context-usage` on success; logs (never throws/broadcasts) on a parse
168
+ * failure.
169
+ *
170
+ * @param {{ tabId: string, sessionId: string, cwd: string }} params
171
+ */
172
+ function probeContextUsage({ tabId, sessionId, cwd }) {
173
+ run({
174
+ tabId,
175
+ sessionId,
176
+ prompt: '/context',
177
+ cwd,
178
+ resume: true,
179
+ silent: true,
180
+ onSilentResult: (text) => {
181
+ const parsed = parseContextUsageMarkdown(text);
182
+ if (!parsed) {
183
+ console.error('[chatRunner] parseContextUsageMarkdown failed to parse /context probe output');
184
+ return;
185
+ }
186
+ broadcast('chat:context-usage', {
187
+ tabId,
188
+ sessionId,
189
+ usedTokens: parsed.usedTokens,
190
+ totalTokens: parsed.totalTokens,
191
+ usedPct: parsed.usedPct,
192
+ categories: parsed.categories,
193
+ });
194
+ },
195
+ });
196
+ }
197
+
75
198
  // ─── MCP consent-denial detection ──────────────────────────────────────────
76
199
  // Best-effort substring heuristic over known CLI phrasing — NOT a documented
77
200
  // stream-json schema field. Confirmed by extracting strings from the compiled
@@ -108,12 +231,13 @@ const STOP_SIGNAL_INSTRUCTION =
108
231
  `Otherwise complete the task and end with a concise summary of what you did.\n\n`;
109
232
 
110
233
  // ─── Serial run queue (v0.34) ───────────────────────────────────────────────
111
- // CONCURRENCY_CAP=1 (default) one loop at a time. The cap is the machine-wide
112
- // "≤3 concurrent claude -p" ceiling from CLAUDE.md; default 1 is the v0.34
113
- // guarantee. The waiting list FIFO-queues anything that can't start yet.
234
+ // CONCURRENCY_CAP=2 (default) governs SILENT (automated probe) runs only
235
+ // two probes can run at once before a 3rd queues. SM_CHAT_CONCURRENCY still
236
+ // overrides, clamped to [1, 3]. PRD 493: manual runs never touch this cap or
237
+ // the `waiting` FIFO — see run() below.
114
238
 
115
- const DEFAULT_CAP = 1;
116
- // Clamp to [1, 3]; default 1 = "one loop at a time".
239
+ const DEFAULT_CAP = 2;
240
+ // Clamp to [1, 3]; default 2 = "two loops at a time".
117
241
  const CONCURRENCY_CAP = Math.min(
118
242
  3,
119
243
  Math.max(1, parseInt(process.env.SM_CHAT_CONCURRENCY || String(DEFAULT_CAP), 10) || DEFAULT_CAP),
@@ -121,7 +245,7 @@ const CONCURRENCY_CAP = Math.min(
121
245
 
122
246
  // tabId → cancel() for every ACTIVE run; FIFO list of WAITING runs; live count.
123
247
  const inFlight = new Map();
124
- const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume }]
248
+ const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult }]
125
249
  let activeCount = 0;
126
250
 
127
251
  // Indirection so tests can stub the spawn without launching claude.
@@ -148,17 +272,35 @@ function broadcast(channel, payload) {
148
272
  // ─── Public queue entry ─────────────────────────────────────────────────────
149
273
 
150
274
  /**
151
- * Enqueue a chat run for a tab. Fire-and-forget — results arrive via IPC. With
152
- * CONCURRENCY_CAP=1 (default) runs execute one at a time; extra submits FIFO-
153
- * queue and are announced via chat:run:queued. De-dupes a tab already in the
154
- * pipeline (the UI disables input while running, but guard anyway).
275
+ * Enqueue a chat run for a tab. Fire-and-forget — results arrive via IPC.
276
+ * Silent (automated probe) runs go through the CONCURRENCY_CAP=2 (default)
277
+ * FIFO lane — extra submits queue and are announced via chat:run:queued.
278
+ * Manual (user-initiated) runs (PRD 493) execute immediately and never enter
279
+ * that lane. De-dupes a tab already in the pipeline (the UI disables input
280
+ * while running, but guard anyway).
155
281
  *
156
- * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean }} opts
282
+ * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void }} opts
157
283
  */
158
284
  function run(opts) {
285
+ // Per-tab exclusivity guard — unrelated to the cross-tab cap; must hold for
286
+ // BOTH manual and silent runs so a manual send can't race a /context probe
287
+ // for the same tab against the same --resume sessionId.
159
288
  if (inFlight.has(opts.tabId) || waiting.some((w) => w.tabId === opts.tabId)) return;
160
- waiting.push(opts);
161
- pump();
289
+
290
+ if (opts.silent === true) {
291
+ // Automated probe: keep the CONCURRENCY_CAP FIFO lane machinery (protects
292
+ // against the 2026-06-10 parallel-claude-p OOM).
293
+ waiting.push(opts);
294
+ pump();
295
+ return;
296
+ }
297
+
298
+ // PRD 493: manual/user-initiated sends are intentionally UNCAPPED — never
299
+ // queue behind another tab. This means the ≤3-concurrent-claude-p ceiling in
300
+ // CLAUDE.md no longer strictly holds for foreground sessions (accepted
301
+ // tradeoff). executeRun still registers in inFlight synchronously, so the
302
+ // per-tab guard above and cancel() keep working; no activeCount, no pump.
303
+ executor(opts).catch(() => { /* executeRun never rejects; defensive */ });
162
304
  }
163
305
 
164
306
  // Fill open lanes FIFO up to CONCURRENCY_CAP, then announce queue positions for
@@ -186,10 +328,10 @@ function pump() {
186
328
  * for the run's lifetime so cancel() can reach it. Never rejects — the queue
187
329
  * pump relies on the returned promise always settling so the lane frees.
188
330
  *
189
- * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean }} opts
331
+ * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void }} opts
190
332
  * @returns {Promise<void>}
191
333
  */
192
- function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
334
+ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult }) {
193
335
  return new Promise((resolve) => {
194
336
  let settled = false;
195
337
  // Frees the lane exactly once: drops the cancel fn and resolves the promise
@@ -212,6 +354,9 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
212
354
  const emitTerminal = (channel, payload) => {
213
355
  if (terminalSent) return;
214
356
  terminalSent = true;
357
+ // Silent (probe) runs still need this bookkeeping so the lane frees
358
+ // correctly, but must never surface on the six turn-affecting channels.
359
+ if (silent) return;
215
360
  broadcast(channel, payload);
216
361
  };
217
362
 
@@ -254,7 +399,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
254
399
  args.push('--session-id', sessionId);
255
400
  }
256
401
 
257
- broadcast('chat:run:started', { tabId, sessionId });
402
+ if (!silent) broadcast('chat:run:started', { tabId, sessionId });
258
403
 
259
404
  // Spawn with stdin closed (mirrors scheduler's 'ignore' — prevents the
260
405
  // "claude -p stdin must be closed" gotcha from kg.cjs). stdout is piped for
@@ -324,10 +469,10 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
324
469
  for (const block of content) {
325
470
  if (block.type === 'text' && typeof block.text === 'string') {
326
471
  finalAssistantText += block.text;
327
- broadcast('chat:run:output', { tabId, delta: block.text });
472
+ if (!silent) broadcast('chat:run:output', { tabId, delta: block.text });
328
473
  } else if (block.type === 'tool_use' && typeof block.name === 'string') {
329
474
  const classified = classifyToolUse(block);
330
- broadcast('chat:run:tool-use', { tabId, id: block.id, ...classified });
475
+ if (!silent) broadcast('chat:run:tool-use', { tabId, id: block.id, ...classified });
331
476
  }
332
477
  }
333
478
  } else if (event.type === 'user') {
@@ -351,20 +496,27 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
351
496
  const text = typeof event.result === 'string' ? event.result : finalAssistantText;
352
497
 
353
498
  if (event.subtype === 'success') {
354
- const signal = parseStopSignal(text);
355
- if (signal) {
356
- emitTerminal('chat:run:needs-input', {
357
- tabId,
358
- sessionId,
359
- questions: signal.questions,
360
- raw: text,
361
- });
362
- } else {
499
+ if (silent) {
500
+ // Silent probe: no turn broadcast, no recordExchange — just hand
501
+ // the final text to whoever enqueued the probe (e.g. probeContextUsage).
363
502
  emitTerminal('chat:run:complete', { tabId, sessionId, finalMessage: text });
364
- // Record durable exchange off the hot path — UI must not wait on Haiku
365
- recordExchange({ sessionId, cwd, prompt, result: text }).catch((err) => {
366
- console.error('[chatRunner] recordExchange failed:', err?.message ?? err);
367
- });
503
+ if (typeof onSilentResult === 'function') onSilentResult(text);
504
+ } else {
505
+ const signal = parseStopSignal(text);
506
+ if (signal) {
507
+ emitTerminal('chat:run:needs-input', {
508
+ tabId,
509
+ sessionId,
510
+ questions: signal.questions,
511
+ raw: text,
512
+ });
513
+ } else {
514
+ emitTerminal('chat:run:complete', { tabId, sessionId, finalMessage: text });
515
+ // Record durable exchange off the hot path — UI must not wait on Haiku
516
+ recordExchange({ sessionId, cwd, prompt, result: text }).catch((err) => {
517
+ console.error('[chatRunner] recordExchange failed:', err?.message ?? err);
518
+ });
519
+ }
368
520
  }
369
521
  } else {
370
522
  emitTerminal('chat:run:error', {
@@ -470,6 +622,11 @@ function registerChatHandlers() {
470
622
  try { tabId = schemas.chatCancel.parse(payload).tabId; } catch { return; }
471
623
  cancel(tabId);
472
624
  });
625
+
626
+ ipcMain.handle('chat:probe-context', validated(schemas.chatProbeContext, async ({ tabId, sessionId, cwd }) => {
627
+ probeContextUsage({ tabId, sessionId, cwd });
628
+ return { ok: true };
629
+ }));
473
630
  }
474
631
 
475
632
  module.exports = {
@@ -478,6 +635,8 @@ module.exports = {
478
635
  attachWindow,
479
636
  registerChatHandlers,
480
637
  parseStopSignal,
638
+ parseContextUsageMarkdown,
639
+ probeContextUsage,
481
640
  STOP_SENTINEL,
482
641
  __setExecutor,
483
642
  };
@@ -127,6 +127,13 @@ function validateWrite(realAbs) {
127
127
  if (realAbs === e2eSub || realAbs.startsWith(e2eSub + path.sep)) {
128
128
  return;
129
129
  }
130
+ // Browser tab scratch saves (DOM captures, screenshots, recorded
131
+ // flows): narrowly scoped to session-manager-operations/browser/,
132
+ // this repo's existing per-project artifact-store convention.
133
+ const browserSub = path.join(realRoot, 'session-manager-operations', 'browser');
134
+ if (realAbs === browserSub || realAbs.startsWith(browserSub + path.sep)) {
135
+ return;
136
+ }
130
137
  }
131
138
  }
132
139
  throw new Error(`Write outside allowed write boundaries: ${realAbs}`);
@@ -9,7 +9,12 @@ const { schemas } = require('./ipcSchemas.cjs');
9
9
  const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
10
10
  const PARSE_BUDGET_MS = 2_000;
11
11
  const MAX_FILE_BYTES = 20 * 1024 * 1024;
12
- const CACHE_MAX = 500;
12
+ // Cache entries are small scalar aggregates (token counts, per-model buckets, a
13
+ // tool-name histogram) — NOT raw transcript content — so a much larger cap costs
14
+ // little memory. 50k is safely above the observed real-workspace file count
15
+ // (~26,604 .jsonl files on the dev machine), so repeat scans over the same date
16
+ // range hit cache instead of self-evicting mid-scan and spuriously truncating.
17
+ const CACHE_MAX = 50_000;
13
18
 
14
19
  // Dollars per million tokens (input / output / cache-read). Cache-read
15
20
  // tokens are priced far below input because they're served from
@@ -484,4 +489,6 @@ module.exports = {
484
489
  scanAggrLines,
485
490
  parseJSONL,
486
491
  resolvePricingKey,
492
+ CACHE_MAX,
493
+ LRUCache,
487
494
  };
@@ -455,6 +455,20 @@ ipcMain.handle('clipboard:paste-image', async () => {
455
455
  }
456
456
  });
457
457
 
458
+ // Text paste — same rationale as clipboard:paste-image above: the renderer's
459
+ // navigator.clipboard.readText() requires the 'clipboard-read' permission,
460
+ // which setPermissionRequestHandler denies (MEDIA_PERMS only), so it always
461
+ // rejects. Reading via Electron's main-process clipboard.readText() sidesteps
462
+ // that permission gate entirely — no permission-handler changes needed.
463
+ ipcMain.handle('clipboard:paste-text', async () => {
464
+ try {
465
+ const text = clipboard.readText();
466
+ return { ok: true, text: text || '' };
467
+ } catch (e) {
468
+ return { ok: false, error: e && e.message ? e.message : String(e) };
469
+ }
470
+ });
471
+
458
472
  // PRD 407 Capture panel — write side of paste-image's read. Writes a
459
473
  // screenshot capture to the OS clipboard as an image.
460
474
  ipcMain.handle('browser:copy-image', validated(schemas.browserCopyImage, ({ dataUrl }) => {
@@ -382,6 +382,12 @@ const chatCancel = z.object({
382
382
  tabId: z.string().min(1).max(128),
383
383
  });
384
384
 
385
+ const chatProbeContext = z.object({
386
+ tabId: z.string().min(1).max(128),
387
+ sessionId: z.string().min(1).max(128),
388
+ cwd: z.string().min(1).max(4096),
389
+ });
390
+
385
391
  // ──────────────────────────────────────────── Web Remote
386
392
  // OTP is 8 uppercase alphanumeric chars (case-insensitive entry, normalised to upper in handler).
387
393
  const WEB_REMOTE_OTP_RE = /^[A-Z0-9]{8}$/i;
@@ -665,6 +671,7 @@ module.exports = {
665
671
  watchersKillTab,
666
672
  chatRun,
667
673
  chatCancel,
674
+ chatProbeContext,
668
675
  exchangesList,
669
676
  },
670
677
  validated,
@@ -1164,6 +1164,73 @@ function healTargetForFix(fixSlug, jobs) {
1164
1164
  return jobs.find((x) => x.slug === originalSlug && isPromotableOriginal(x.status)) || null;
1165
1165
  }
1166
1166
 
1167
+ /**
1168
+ * Build the Opus investigation prompt. Pure/hermetic so its content can be
1169
+ * unit-tested (no spawn, no fs). Inputs are the already-resolved values that
1170
+ * spawnInvestigation computes.
1171
+ */
1172
+ function buildInvestigationPrompt({ failedJob, cwd, failedLogPath, originalBody, logTail, fixPath, group }) {
1173
+ return `You are investigating a failed scheduled job in the session-manager queue. Your ONLY job is to write a fix-plan PRD file. Do NOT attempt the fix yourself.
1174
+
1175
+ # Failed job
1176
+ - Slug: ${failedJob.slug}
1177
+ - Title: ${failedJob.title}
1178
+ - cwd: ${cwd}
1179
+ - Exit code: ${failedJob.exitCode}
1180
+ - Full failure log: ${failedLogPath}
1181
+
1182
+ # Original PRD body (this is what the job was trying to do)
1183
+ \`\`\`
1184
+ ${originalBody}
1185
+ \`\`\`
1186
+
1187
+ # Last ~16KB of the failure log (stream-json format from \`claude -p\`)
1188
+ \`\`\`
1189
+ ${logTail}
1190
+ \`\`\`
1191
+
1192
+ # Your task
1193
+ 1. Read the full failure log at ${failedLogPath} if the tail above isn't sufficient.
1194
+ 2. Read source files in ${cwd} as needed to understand the context.
1195
+ 3. Identify the root cause of the failure.
1196
+ 4. Write a NEW fix-plan PRD file at exactly this path:
1197
+
1198
+ ${fixPath}
1199
+
1200
+ 5. The frontmatter MUST be exactly this format (no extra keys):
1201
+ \`\`\`
1202
+ ---
1203
+ title: Fix: <short summary of the fix>
1204
+ cwd: ${cwd}
1205
+ parallelGroup: ${group}
1206
+ estimateMinutes: <your time estimate>
1207
+ ---
1208
+ \`\`\`
1209
+ 6. The PRD body MUST be self-contained — \`claude -p\` runs it on a fresh Sonnet session with NO conversation context. Include:
1210
+ - Root-cause analysis (what went wrong and why)
1211
+ - Concrete fix steps (specific files / commands / edits)
1212
+ - Verification command(s) the next agent should run to confirm the fix
1213
+ - Acceptance criteria
1214
+ - Before writing the fix-plan PRD body, read
1215
+ \`plugins/session-manager-dev/skills/develop/standards.md\` (resolve it relative to the failed
1216
+ job's repo root — for session-manager-authored PRDs that is this cwd). Inline its
1217
+ \`## Execution discipline (headless runs)\` section VERBATIM into the new fix-plan PRD under an
1218
+ \`## Engineering standards\` heading, exactly as the \`/develop\` skill's Phase 1 does for normal
1219
+ PRDs. Reuse that convention — do not invent a second, paraphrased set of warnings. The headless
1220
+ Sonnet executor that runs your fix-plan sees no skills and no conversation, so inlining these
1221
+ rules verbatim is the only way they reach it.
1222
+ - Known failure class — "delegated instead of executed": if the failure log tail above shows the
1223
+ failed run invoked the \`Skill\` tool with a \`session-manager-dev:develop\` or
1224
+ \`session-manager-dev:process-feedback\` argument, and/or called \`ScheduleWakeup\`, and then exited
1225
+ 0 without producing the diff/tests its PRD demanded, recognize this as a self-delegation failure.
1226
+ In that case LEAD the fix-plan PRD by quoting VERBATIM the canonical rule from standards.md's
1227
+ Execution discipline section — "You ARE the executor — never re-queue or self-schedule" — rather
1228
+ than authoring new prose. Instruct the fix-plan's executor plainly that a queued PRD is the task,
1229
+ not evidence of completion, and that the deliverable is the code diff.
1230
+
1231
+ DO NOT attempt the fix. ONLY write the file. When the file exists, exit immediately.`;
1232
+ }
1233
+
1167
1234
  /**
1168
1235
  * Spawn an Opus investigation session for a failed job. The investigator's job
1169
1236
  * is to read the failure log + original PRD, identify the root cause, and write
@@ -1236,49 +1303,7 @@ async function spawnInvestigation(failedJob, runDir) {
1236
1303
  console.warn(`[scheduler] investigation cwd missing (${cwd}); falling back to ${DEFAULT_PROJECT_CWD}`);
1237
1304
  cwd = DEFAULT_PROJECT_CWD;
1238
1305
  }
1239
- const prompt = `You are investigating a failed scheduled job in the session-manager queue. Your ONLY job is to write a fix-plan PRD file. Do NOT attempt the fix yourself.
1240
-
1241
- # Failed job
1242
- - Slug: ${failedJob.slug}
1243
- - Title: ${failedJob.title}
1244
- - cwd: ${cwd}
1245
- - Exit code: ${failedJob.exitCode}
1246
- - Full failure log: ${failedLogPath}
1247
-
1248
- # Original PRD body (this is what the job was trying to do)
1249
- \`\`\`
1250
- ${originalBody}
1251
- \`\`\`
1252
-
1253
- # Last ~16KB of the failure log (stream-json format from \`claude -p\`)
1254
- \`\`\`
1255
- ${logTail}
1256
- \`\`\`
1257
-
1258
- # Your task
1259
- 1. Read the full failure log at ${failedLogPath} if the tail above isn't sufficient.
1260
- 2. Read source files in ${cwd} as needed to understand the context.
1261
- 3. Identify the root cause of the failure.
1262
- 4. Write a NEW fix-plan PRD file at exactly this path:
1263
-
1264
- ${fixPath}
1265
-
1266
- 5. The frontmatter MUST be exactly this format (no extra keys):
1267
- \`\`\`
1268
- ---
1269
- title: Fix: <short summary of the fix>
1270
- cwd: ${cwd}
1271
- parallelGroup: ${group}
1272
- estimateMinutes: <your time estimate>
1273
- ---
1274
- \`\`\`
1275
- 6. The PRD body MUST be self-contained — \`claude -p\` runs it on a fresh Sonnet session with NO conversation context. Include:
1276
- - Root-cause analysis (what went wrong and why)
1277
- - Concrete fix steps (specific files / commands / edits)
1278
- - Verification command(s) the next agent should run to confirm the fix
1279
- - Acceptance criteria
1280
-
1281
- DO NOT attempt the fix. ONLY write the file. When the file exists, exit immediately.`;
1306
+ const prompt = buildInvestigationPrompt({ failedJob, cwd, failedLogPath, originalBody, logTail, fixPath, group });
1282
1307
 
1283
1308
  // Phase 1: open log fd for pre-spawn diagnostics.
1284
1309
  const { fd, safeLog, closeFd } = openLog(investigationLogPath);
@@ -2633,4 +2658,4 @@ const remote = {
2633
2658
  },
2634
2659
  };
2635
2660
 
2636
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, resolveRunId, isUnresolvableNeedsReview, healTargetForFix };
2661
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, resolveRunId, isUnresolvableNeedsReview, healTargetForFix, buildInvestigationPrompt };
@@ -1079,6 +1079,8 @@ export interface SessionManagerAPI {
1079
1079
  | { ok: true; mode: string; text: string; meta: { chunks?: number; tokens?: number } }
1080
1080
  | { ok: false; error: string }
1081
1081
  >;
1082
+ /** Fired when the embedded page tries to open a new window (target="_blank", window.open, ctrl/cmd-click) — the main process denies the native popup and forwards the URL here. */
1083
+ onOpenTabRequest: (handler: (payload: { url: string }) => void) => () => void;
1082
1084
  };
1083
1085
  transcripts: {
1084
1086
  subscribe: (payload: { tabId: string; cwd: string; sessionUuid: string }) => Promise<SubscribeResult>;
@@ -1250,6 +1252,13 @@ export interface SessionManagerAPI {
1250
1252
  | { ok: true; path: string; bytes: number }
1251
1253
  | { ok: false; empty?: true; error?: string }
1252
1254
  >;
1255
+ /** Ctrl+V text paste — reads OS clipboard text via Electron's native API
1256
+ * (renderer's navigator.clipboard.readText() is denied by the
1257
+ * permission-request handler, which only allows media permissions). */
1258
+ pasteText: () => Promise<
1259
+ | { ok: true; text: string }
1260
+ | { ok: false; error?: string }
1261
+ >;
1253
1262
  /** Write side — copies a Capture-panel screenshot data URL to the OS clipboard. */
1254
1263
  copyImage: (dataUrl: string) => Promise<{ ok: boolean; error?: string }>;
1255
1264
  };
@@ -107,6 +107,11 @@ contextBridge.exposeInMainWorld('api', {
107
107
  return () => ipcRenderer.removeListener(channel, listener);
108
108
  },
109
109
  capture: (payload) => ipcRenderer.invoke('browser:capture', payload),
110
+ onOpenTabRequest: (handler) => {
111
+ const listener = (_e, payload) => handler(payload);
112
+ ipcRenderer.on('browser:open-tab-request', listener);
113
+ return () => ipcRenderer.removeListener('browser:open-tab-request', listener);
114
+ },
110
115
  },
111
116
  transcripts: {
112
117
  subscribe: (payload) => ipcRenderer.invoke('transcript:subscribe', payload),
@@ -282,6 +287,7 @@ contextBridge.exposeInMainWorld('api', {
282
287
  },
283
288
  clipboard: {
284
289
  pasteImage: () => ipcRenderer.invoke('clipboard:paste-image'),
290
+ pasteText: () => ipcRenderer.invoke('clipboard:paste-text'),
285
291
  copyImage: (dataUrl) => ipcRenderer.invoke('browser:copy-image', { dataUrl }),
286
292
  },
287
293
  memory: {