claude-code-session-manager 0.35.4 → 0.35.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -7,10 +7,10 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
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/index-DASEOqMP.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-DIVOtBGO.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
- <link rel="stylesheet" crossorigin href="./assets/index-DZgKJkf3.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-BHyeZfve.css">
14
14
  </head>
15
15
  <body class="bg-bg text-fg font-sans antialiased">
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.35.4",
3
+ "version": "0.35.5",
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",
@@ -38,10 +38,19 @@ the only step that runs on the cheaper executor.
38
38
  ## Standards (single source of truth)
39
39
 
40
40
  The engineering standards (Performance, Debugging, API reuse / single source of truth, TDD,
41
- and the executor-facing Execution discipline) live in **`standards.md`** beside this file:
42
- `~/.claude/skills/develop/standards.md`. Read it, hold it while planning, and inline it
43
- verbatim into every PRD you emit (Phase 1 step 4). Never restate or fork its content — one
44
- concept, one implementation.
41
+ and the executor-facing Execution discipline) live in **`standards.md`** beside this file, in
42
+ the same skill directory (`.../skills/develop/standards.md` NOT `~/.claude/skills/develop/`,
43
+ which is a different, non-existent path; resolve it relative to wherever this SKILL.md itself
44
+ was loaded from). **Re-read that file fresh with the Read tool immediately before pasting it
45
+ into each PRD (Phase 1 step 4) — never reuse a copy cached earlier in the same conversation.**
46
+ A long authoring session can span an edit to `standards.md` (including one autonomously applied
47
+ by a prior incident's fix-plan) without the model noticing; pasting a stale in-context copy
48
+ silently ships PRDs missing the latest execution-discipline rules. This is exactly the
49
+ single-source-of-truth violation the standards themselves warn against — don't let it happen to
50
+ the standards block itself. (Incident: PRDs 467/468 authored late in a long session carried a
51
+ standards.md snapshot from before the "You ARE the executor" guard was added earlier that same
52
+ session, so PRD 467's headless run repeated the exact anti-pattern the guard exists to prevent.)
53
+ Never restate or fork its content — one concept, one implementation.
45
54
 
46
55
  For interactive dev work, also apply the `test-driven-development` and `systematic-debugging`
47
56
  skills; the headless PRDs get the distilled core from `standards.md` instead, since they
@@ -195,7 +204,7 @@ single definition of "tracked to done" for both entry paths.
195
204
  ## References (reuse, don't duplicate)
196
205
 
197
206
  - `~/.claude/session-manager/scheduled-plans/PRD_AUTHORING.md` — the §1–§10 safety rules.
198
- - `~/.claude/skills/develop/standards.md` — the engineering + execution-discipline rules inlined into every PRD.
207
+ - `standards.md` beside this file — the engineering + execution-discipline rules inlined into every PRD. Re-read fresh each time (see "Standards" above) — don't reuse a cached copy.
199
208
  - `test-driven-development`, `systematic-debugging` — interactive dev sessions.
200
209
  - `requesting-code-review` — the Phase-2 review gate.
201
210
 
@@ -0,0 +1,51 @@
1
+ /**
2
+ * scheduler-investigation-prompt.test.cjs — unit tests for buildInvestigationPrompt.
3
+ *
4
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-investigation-prompt.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const { buildInvestigationPrompt } = require('../scheduler.cjs');
12
+
13
+ function makeArgs(overrides = {}) {
14
+ return {
15
+ failedJob: { slug: '05-my-feature', title: 'My feature', exitCode: 1 },
16
+ cwd: '/home/bilko/Projects/session-manager',
17
+ failedLogPath: '/tmp/05-my-feature.log',
18
+ originalBody: 'Original PRD body.',
19
+ logTail: 'some log tail output',
20
+ fixPath: '/tmp/05-fix-my-feature.md',
21
+ group: 5,
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ test('prompt references the canonical standards.md file', () => {
27
+ const prompt = buildInvestigationPrompt(makeArgs());
28
+ assert.match(prompt, /standards\.md/);
29
+ assert.match(prompt, /plugins\/session-manager-dev\/skills\/develop\/standards\.md/);
30
+ });
31
+
32
+ test('prompt instructs inlining the Execution discipline section under Engineering standards', () => {
33
+ const prompt = buildInvestigationPrompt(makeArgs());
34
+ assert.match(prompt, /Engineering standards/);
35
+ assert.match(prompt, /Execution discipline \(headless runs\)/);
36
+ });
37
+
38
+ test('prompt contains the named "delegated instead of executed" detection check', () => {
39
+ const prompt = buildInvestigationPrompt(makeArgs());
40
+ assert.match(prompt, /ScheduleWakeup/);
41
+ assert.match(prompt, /session-manager-dev:develop/);
42
+ assert.match(prompt, /never re-queue or self-schedule|delegated instead of executed/);
43
+ });
44
+
45
+ test('prompt includes the resolved job fields', () => {
46
+ const prompt = buildInvestigationPrompt(makeArgs());
47
+ assert.match(prompt, /05-my-feature/);
48
+ assert.match(prompt, /Original PRD body\./);
49
+ assert.match(prompt, /some log tail output/);
50
+ assert.match(prompt, /05-fix-my-feature\.md/);
51
+ });
@@ -18,7 +18,7 @@ const path = require('path');
18
18
  const os = require('os');
19
19
  const fs = require('fs');
20
20
  const crypto = require('crypto');
21
- const { WebContentsView, shell } = require('electron');
21
+ const { WebContentsView } = require('electron');
22
22
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
23
23
  const { readJson, writeJson } = require('./config.cjs');
24
24
 
@@ -181,7 +181,7 @@ function wireNavEvents(viewId, view) {
181
181
 
182
182
  wc.setWindowOpenHandler(({ url }) => {
183
183
  if (/^https?:\/\//i.test(url)) {
184
- shell.openExternal(url).catch(() => {});
184
+ sendIfAlive(win, 'browser:open-tab-request', { url });
185
185
  }
186
186
  return { action: 'deny' };
187
187
  });
@@ -12,9 +12,14 @@
12
12
  * OOM that SIGKILLed a job on 2026-06-26.
13
13
  *
14
14
  * Public surface:
15
- * run({ tabId, sessionId, prompt, cwd, resume }): void — fire-and-forget enqueue
15
+ * run({ tabId, sessionId, prompt, cwd, resume, silent }): void — fire-and-forget enqueue.
16
+ * `silent` (PRD 470) still goes through the same per-tab FIFO lane, but suppresses the
17
+ * six turn-affecting broadcasts + recordExchange (see probeContextUsage below).
16
18
  * cancel(tabId): void
17
19
  * parseStopSignal(finalText): { questions: string[] } | null — exported for reuse
20
+ * parseContextUsageMarkdown(text): { usedTokens, totalTokens, usedPct, categories } | null
21
+ * — pure parser for `/context`
22
+ * probeContextUsage({ tabId, sessionId, cwd }): void — fire-and-forget silent `/context` probe
18
23
  * STOP_SENTINEL: string — exported for tests
19
24
  * __setExecutor(fn): void — test seam
20
25
  *
@@ -27,6 +32,8 @@
27
32
  * chat:run:needs-input { tabId, sessionId, questions, raw }
28
33
  * chat:run:error { tabId, sessionId, message }
29
34
  * chat:run:notice { tabId, sessionId, message } — informational, not terminal
35
+ * chat:context-usage { tabId, sessionId, usedTokens, totalTokens, usedPct, categories }
36
+ * — result of a silent `/context` probe
30
37
  */
31
38
 
32
39
  const { spawn } = require('node:child_process');
@@ -72,6 +79,119 @@ function parseStopSignal(finalText) {
72
79
  }
73
80
  }
74
81
 
82
+ // ─── `/context` probe (PRD 470) ────────────────────────────────────────────
83
+ // `claude -p "/context"` is answered entirely locally by the CLI (zero-cost,
84
+ // no real API call) with markdown containing a summary line and a per-category
85
+ // breakdown table. Parsed here so the app can mirror the CLI's own context-
86
+ // window accounting without re-implementing the estimate itself.
87
+
88
+ // "9k" -> 9000, "15.1k" -> 15100; bare "207" -> 207. Never throws — returns NaN
89
+ // for genuinely malformed input, which callers treat as a parse failure.
90
+ function parseTokenAmount(str) {
91
+ const suffixed = /^(\d+(?:\.\d+)?)k$/i.exec(str);
92
+ if (suffixed) return Math.round(parseFloat(suffixed[1]) * 1000);
93
+ return Number(str);
94
+ }
95
+
96
+ /**
97
+ * Parse the markdown produced by the CLI's own `/context` slash command into
98
+ * a structured summary. Pure, single line-by-line pass — O(n) over the lines
99
+ * of a small, bounded response, no nested scans.
100
+ *
101
+ * @param {string} text
102
+ * @returns {{ usedTokens: number, totalTokens: number, usedPct: number, categories: Array<{ category: string, tokens: number, pct: number }> } | null}
103
+ */
104
+ function parseContextUsageMarkdown(text) {
105
+ if (typeof text !== 'string' || !text) return null;
106
+ const lines = text.split('\n');
107
+
108
+ let usedTokens = null;
109
+ let totalTokens = null;
110
+ let usedPct = null;
111
+ const tokensLineRe = /\*\*Tokens:\*\*\s*(\S+)\s*\/\s*(\S+)\s*\(([\d.]+)%\)/;
112
+
113
+ let inTable = false;
114
+ let foundHeading = false;
115
+ const categories = [];
116
+
117
+ for (const line of lines) {
118
+ if (usedTokens === null) {
119
+ const m = tokensLineRe.exec(line);
120
+ if (m) {
121
+ usedTokens = parseTokenAmount(m[1]);
122
+ totalTokens = parseTokenAmount(m[2]);
123
+ usedPct = Number(m[3]);
124
+ }
125
+ }
126
+
127
+ if (!inTable) {
128
+ if (line.trim() === '### Estimated usage by category') {
129
+ foundHeading = true;
130
+ inTable = true;
131
+ }
132
+ continue;
133
+ }
134
+ const trimmed = line.trim();
135
+ if (trimmed.startsWith('###')) { inTable = false; continue; }
136
+ if (!trimmed.startsWith('|')) continue;
137
+
138
+ const cells = trimmed.split('|').map((c) => c.trim()).filter((c) => c.length > 0);
139
+ if (cells.length !== 3) continue;
140
+ if (cells[0].toLowerCase() === 'category') continue; // header row
141
+ if (/^:?-+:?$/.test(cells[0])) continue; // separator row
142
+
143
+ const tokens = parseTokenAmount(cells[1]);
144
+ const pctMatch = /^([\d.]+)%$/.exec(cells[2]);
145
+ if (!pctMatch || Number.isNaN(tokens)) continue;
146
+ categories.push({ category: cells[0], tokens, pct: Number(pctMatch[1]) });
147
+ }
148
+
149
+ if (
150
+ usedTokens === null || totalTokens === null || usedPct === null ||
151
+ Number.isNaN(usedTokens) || Number.isNaN(totalTokens) || Number.isNaN(usedPct) ||
152
+ !foundHeading || categories.length === 0
153
+ ) {
154
+ return null;
155
+ }
156
+
157
+ return { usedTokens, totalTokens, usedPct, categories };
158
+ }
159
+
160
+ /**
161
+ * Fire-and-forget silent probe of the resumed session's context usage. Reuses
162
+ * the normal `run()` queue/lane (resume: true, silent: true) so it can never
163
+ * race a real chat run against the same `--resume sessionId`. Broadcasts
164
+ * `chat:context-usage` on success; logs (never throws/broadcasts) on a parse
165
+ * failure.
166
+ *
167
+ * @param {{ tabId: string, sessionId: string, cwd: string }} params
168
+ */
169
+ function probeContextUsage({ tabId, sessionId, cwd }) {
170
+ run({
171
+ tabId,
172
+ sessionId,
173
+ prompt: '/context',
174
+ cwd,
175
+ resume: true,
176
+ silent: true,
177
+ onSilentResult: (text) => {
178
+ const parsed = parseContextUsageMarkdown(text);
179
+ if (!parsed) {
180
+ console.error('[chatRunner] parseContextUsageMarkdown failed to parse /context probe output');
181
+ return;
182
+ }
183
+ broadcast('chat:context-usage', {
184
+ tabId,
185
+ sessionId,
186
+ usedTokens: parsed.usedTokens,
187
+ totalTokens: parsed.totalTokens,
188
+ usedPct: parsed.usedPct,
189
+ categories: parsed.categories,
190
+ });
191
+ },
192
+ });
193
+ }
194
+
75
195
  // ─── MCP consent-denial detection ──────────────────────────────────────────
76
196
  // Best-effort substring heuristic over known CLI phrasing — NOT a documented
77
197
  // stream-json schema field. Confirmed by extracting strings from the compiled
@@ -121,7 +241,7 @@ const CONCURRENCY_CAP = Math.min(
121
241
 
122
242
  // tabId → cancel() for every ACTIVE run; FIFO list of WAITING runs; live count.
123
243
  const inFlight = new Map();
124
- const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume }]
244
+ const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult }]
125
245
  let activeCount = 0;
126
246
 
127
247
  // Indirection so tests can stub the spawn without launching claude.
@@ -153,7 +273,7 @@ function broadcast(channel, payload) {
153
273
  * queue and are announced via chat:run:queued. De-dupes a tab already in the
154
274
  * pipeline (the UI disables input while running, but guard anyway).
155
275
  *
156
- * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean }} opts
276
+ * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void }} opts
157
277
  */
158
278
  function run(opts) {
159
279
  if (inFlight.has(opts.tabId) || waiting.some((w) => w.tabId === opts.tabId)) return;
@@ -186,10 +306,10 @@ function pump() {
186
306
  * for the run's lifetime so cancel() can reach it. Never rejects — the queue
187
307
  * pump relies on the returned promise always settling so the lane frees.
188
308
  *
189
- * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean }} opts
309
+ * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void }} opts
190
310
  * @returns {Promise<void>}
191
311
  */
192
- function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
312
+ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult }) {
193
313
  return new Promise((resolve) => {
194
314
  let settled = false;
195
315
  // Frees the lane exactly once: drops the cancel fn and resolves the promise
@@ -212,6 +332,9 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
212
332
  const emitTerminal = (channel, payload) => {
213
333
  if (terminalSent) return;
214
334
  terminalSent = true;
335
+ // Silent (probe) runs still need this bookkeeping so the lane frees
336
+ // correctly, but must never surface on the six turn-affecting channels.
337
+ if (silent) return;
215
338
  broadcast(channel, payload);
216
339
  };
217
340
 
@@ -254,7 +377,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
254
377
  args.push('--session-id', sessionId);
255
378
  }
256
379
 
257
- broadcast('chat:run:started', { tabId, sessionId });
380
+ if (!silent) broadcast('chat:run:started', { tabId, sessionId });
258
381
 
259
382
  // Spawn with stdin closed (mirrors scheduler's 'ignore' — prevents the
260
383
  // "claude -p stdin must be closed" gotcha from kg.cjs). stdout is piped for
@@ -324,10 +447,10 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
324
447
  for (const block of content) {
325
448
  if (block.type === 'text' && typeof block.text === 'string') {
326
449
  finalAssistantText += block.text;
327
- broadcast('chat:run:output', { tabId, delta: block.text });
450
+ if (!silent) broadcast('chat:run:output', { tabId, delta: block.text });
328
451
  } else if (block.type === 'tool_use' && typeof block.name === 'string') {
329
452
  const classified = classifyToolUse(block);
330
- broadcast('chat:run:tool-use', { tabId, id: block.id, ...classified });
453
+ if (!silent) broadcast('chat:run:tool-use', { tabId, id: block.id, ...classified });
331
454
  }
332
455
  }
333
456
  } else if (event.type === 'user') {
@@ -351,20 +474,27 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
351
474
  const text = typeof event.result === 'string' ? event.result : finalAssistantText;
352
475
 
353
476
  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 {
477
+ if (silent) {
478
+ // Silent probe: no turn broadcast, no recordExchange — just hand
479
+ // the final text to whoever enqueued the probe (e.g. probeContextUsage).
363
480
  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
- });
481
+ if (typeof onSilentResult === 'function') onSilentResult(text);
482
+ } else {
483
+ const signal = parseStopSignal(text);
484
+ if (signal) {
485
+ emitTerminal('chat:run:needs-input', {
486
+ tabId,
487
+ sessionId,
488
+ questions: signal.questions,
489
+ raw: text,
490
+ });
491
+ } else {
492
+ emitTerminal('chat:run:complete', { tabId, sessionId, finalMessage: text });
493
+ // Record durable exchange off the hot path — UI must not wait on Haiku
494
+ recordExchange({ sessionId, cwd, prompt, result: text }).catch((err) => {
495
+ console.error('[chatRunner] recordExchange failed:', err?.message ?? err);
496
+ });
497
+ }
368
498
  }
369
499
  } else {
370
500
  emitTerminal('chat:run:error', {
@@ -470,6 +600,11 @@ function registerChatHandlers() {
470
600
  try { tabId = schemas.chatCancel.parse(payload).tabId; } catch { return; }
471
601
  cancel(tabId);
472
602
  });
603
+
604
+ ipcMain.handle('chat:probe-context', validated(schemas.chatProbeContext, async ({ tabId, sessionId, cwd }) => {
605
+ probeContextUsage({ tabId, sessionId, cwd });
606
+ return { ok: true };
607
+ }));
473
608
  }
474
609
 
475
610
  module.exports = {
@@ -478,6 +613,8 @@ module.exports = {
478
613
  attachWindow,
479
614
  registerChatHandlers,
480
615
  parseStopSignal,
616
+ parseContextUsageMarkdown,
617
+ probeContextUsage,
481
618
  STOP_SENTINEL,
482
619
  __setExecutor,
483
620
  };
@@ -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: {