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.
- package/dist/assets/{TiptapBody-CVFG9ufh.js → TiptapBody-C1mvS02k.js} +1 -1
- package/dist/assets/{index-DASEOqMP.js → index-BfDitseh.js} +852 -852
- package/dist/assets/index-C7NyYuXu.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +14 -5
- package/src/main/__tests__/chat-queue.test.cjs +15 -10
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +51 -0
- package/src/main/browserView.cjs +2 -2
- package/src/main/chatRunner.cjs +196 -37
- package/src/main/config.cjs +7 -0
- package/src/main/historyAggregator.cjs +8 -1
- package/src/main/index.cjs +14 -0
- package/src/main/ipcSchemas.cjs +7 -0
- package/src/main/scheduler.cjs +69 -44
- package/src/preload/api.d.ts +9 -0
- package/src/preload/index.cjs +6 -0
- package/dist/assets/index-DZgKJkf3.css +0 -32
package/src/main/chatRunner.cjs
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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=
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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 =
|
|
116
|
-
// Clamp to [1, 3]; default
|
|
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.
|
|
152
|
-
*
|
|
153
|
-
* queue and are announced via chat:run:queued.
|
|
154
|
-
*
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
};
|
package/src/main/config.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
};
|
package/src/main/index.cjs
CHANGED
|
@@ -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 }) => {
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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,
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -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 =
|
|
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 };
|
package/src/preload/api.d.ts
CHANGED
|
@@ -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
|
};
|
package/src/preload/index.cjs
CHANGED
|
@@ -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: {
|