claude-code-session-manager 0.33.1 → 0.34.0
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/README.md +5 -1
- package/dist/assets/{TiptapBody-BqQFXHkk.js → TiptapBody-DWeWI8gw.js} +51 -51
- package/dist/assets/index-C2m4dco8.css +32 -0
- package/dist/assets/index-CFT773vM.js +3491 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/discover-features/SKILL.md +95 -0
- package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +9 -9
- package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +77 -62
- package/plugins/session-manager-dev/skills/project-status/SKILL.md +13 -0
- package/src/main/__tests__/chat-queue.test.cjs +65 -0
- package/src/main/__tests__/chat-stop-signal.test.cjs +52 -0
- package/src/main/__tests__/exchanges.test.cjs +177 -0
- package/src/main/__tests__/files-reject-credentials.test.cjs +40 -0
- package/src/main/__tests__/kg-augment.test.cjs +195 -0
- package/src/main/__tests__/memoryAggregate.test.cjs +70 -0
- package/src/main/agentMemory.cjs +0 -21
- package/src/main/chatRunner.cjs +393 -0
- package/src/main/exchanges.cjs +125 -0
- package/src/main/files.cjs +20 -8
- package/src/main/historyAggregator.cjs +0 -162
- package/src/main/index.cjs +76 -57
- package/src/main/ipcSchemas.cjs +70 -30
- package/src/main/lib/kgExchangePairing.cjs +75 -0
- package/src/main/lib/reaperHelpers.cjs +7 -1
- package/src/main/lib/summarize.cjs +114 -0
- package/src/main/memoryAggregate.cjs +250 -0
- package/src/main/pluginInstall.cjs +4 -2
- package/src/main/scheduler.cjs +66 -20
- package/src/main/supervisor.cjs +16 -0
- package/src/main/transcripts.cjs +22 -2
- package/src/main/voiceHotkey.cjs +0 -2
- package/src/main/webRemote.cjs +11 -78
- package/src/preload/api.d.ts +131 -125
- package/src/preload/index.cjs +45 -29
- package/dist/assets/index-1PpZBVUr.js +0 -3535
- package/dist/assets/index-AKeGl-VM.css +0 -32
- package/src/main/kg.cjs +0 -792
- package/src/main/lib/kgLite.cjs +0 -195
- package/src/main/lib/kgPrune.cjs +0 -87
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* chatRunner.cjs — headless `claude -p --output-format stream-json` runner for
|
|
5
|
+
* the terminal chat UI (PRD 319). Each call spawns a fresh process that exits
|
|
6
|
+
* when done; no idle process lives between commands.
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* Public surface:
|
|
15
|
+
* run({ tabId, sessionId, prompt, cwd, resume }): void — fire-and-forget enqueue
|
|
16
|
+
* cancel(tabId): void
|
|
17
|
+
* parseStopSignal(finalText): { questions: string[] } | null — exported for reuse
|
|
18
|
+
* STOP_SENTINEL: string — exported for tests
|
|
19
|
+
* __setExecutor(fn): void — test seam
|
|
20
|
+
*
|
|
21
|
+
* IPC events broadcast to the renderer:
|
|
22
|
+
* chat:run:queued { tabId, sessionId, position } — waiting behind a busy lane
|
|
23
|
+
* chat:run:started { tabId, sessionId }
|
|
24
|
+
* chat:run:output { tabId, delta }
|
|
25
|
+
* chat:run:complete { tabId, sessionId, finalMessage }
|
|
26
|
+
* chat:run:needs-input { tabId, sessionId, questions, raw }
|
|
27
|
+
* chat:run:error { tabId, sessionId, message }
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { spawn } = require('node:child_process');
|
|
31
|
+
const { ipcMain } = require('electron');
|
|
32
|
+
const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
|
|
33
|
+
const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
|
|
34
|
+
const { recordExchange } = require('./exchanges.cjs');
|
|
35
|
+
|
|
36
|
+
// ─── Stop-signal protocol ──────────────────────────────────────────────────
|
|
37
|
+
// Single source of truth for the sentinel and parser. The renderer (PRD 320)
|
|
38
|
+
// and tests import `parseStopSignal` from here — never re-implement.
|
|
39
|
+
|
|
40
|
+
const STOP_SENTINEL = '<<<SM_NEEDS_INPUT>>>';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse the final assistant text for the stop-signal protocol.
|
|
44
|
+
*
|
|
45
|
+
* Returns `{ questions: string[] }` when the sentinel is present with valid
|
|
46
|
+
* JSON on the next line. Returns `null` when the sentinel is absent OR when
|
|
47
|
+
* the JSON is malformed — both cases are treated as a completed run with no
|
|
48
|
+
* crash.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} finalText
|
|
51
|
+
* @returns {{ questions: string[] } | null}
|
|
52
|
+
*/
|
|
53
|
+
function parseStopSignal(finalText) {
|
|
54
|
+
if (typeof finalText !== 'string') return null;
|
|
55
|
+
const idx = finalText.lastIndexOf(STOP_SENTINEL);
|
|
56
|
+
if (idx === -1) return null;
|
|
57
|
+
// Everything after the sentinel, stripped of leading whitespace
|
|
58
|
+
const after = finalText.slice(idx + STOP_SENTINEL.length).trimStart();
|
|
59
|
+
const firstLine = after.split('\n')[0].trim();
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(firstLine);
|
|
62
|
+
if (parsed && Array.isArray(parsed.questions)) {
|
|
63
|
+
return { questions: parsed.questions };
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
} catch {
|
|
67
|
+
// Malformed JSON — treat as complete, no crash
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Instruction prepended to every prompt. Tells the agent how to signal that
|
|
73
|
+
// it needs clarification vs. having completed the task.
|
|
74
|
+
const STOP_SIGNAL_INSTRUCTION =
|
|
75
|
+
`IMPORTANT: If you need clarification from the user before you can continue, ` +
|
|
76
|
+
`do NOT guess — finish your turn by emitting, as the very last line, exactly:\n` +
|
|
77
|
+
`${STOP_SENTINEL}\n` +
|
|
78
|
+
`followed on the next line by a single-line JSON object {"questions":["..."]}. ` +
|
|
79
|
+
`Otherwise complete the task and end with a concise summary of what you did.\n\n`;
|
|
80
|
+
|
|
81
|
+
// ─── Serial run queue (v0.34) ───────────────────────────────────────────────
|
|
82
|
+
// CONCURRENCY_CAP=1 (default) → one loop at a time. The cap is the machine-wide
|
|
83
|
+
// "≤3 concurrent claude -p" ceiling from CLAUDE.md; default 1 is the v0.34
|
|
84
|
+
// guarantee. The waiting list FIFO-queues anything that can't start yet.
|
|
85
|
+
|
|
86
|
+
const DEFAULT_CAP = 1;
|
|
87
|
+
// Clamp to [1, 3]; default 1 = "one loop at a time".
|
|
88
|
+
const CONCURRENCY_CAP = Math.min(
|
|
89
|
+
3,
|
|
90
|
+
Math.max(1, parseInt(process.env.SM_CHAT_CONCURRENCY || String(DEFAULT_CAP), 10) || DEFAULT_CAP),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// tabId → cancel() for every ACTIVE run; FIFO list of WAITING runs; live count.
|
|
94
|
+
const inFlight = new Map();
|
|
95
|
+
const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume }]
|
|
96
|
+
let activeCount = 0;
|
|
97
|
+
|
|
98
|
+
// Indirection so tests can stub the spawn without launching claude.
|
|
99
|
+
let executor = executeRun;
|
|
100
|
+
function __setExecutor(fn) { executor = fn || executeRun; }
|
|
101
|
+
|
|
102
|
+
// ─── Hard wall-clock kill ceiling ────────────────────────────────────────
|
|
103
|
+
const KILL_CEILING_MS = 30 * 60 * 1000; // 30 minutes
|
|
104
|
+
|
|
105
|
+
// ─── Window reference (set by attachWindow) ────────────────────────────────
|
|
106
|
+
|
|
107
|
+
let mainWindow = null;
|
|
108
|
+
|
|
109
|
+
function attachWindow(win) { mainWindow = win; }
|
|
110
|
+
|
|
111
|
+
function broadcast(channel, payload) {
|
|
112
|
+
try {
|
|
113
|
+
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents && !mainWindow.webContents.isDestroyed()) {
|
|
114
|
+
mainWindow.webContents.send(channel, payload);
|
|
115
|
+
}
|
|
116
|
+
} catch { /* render frame may be gone */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ─── Public queue entry ─────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Enqueue a chat run for a tab. Fire-and-forget — results arrive via IPC. With
|
|
123
|
+
* CONCURRENCY_CAP=1 (default) runs execute one at a time; extra submits FIFO-
|
|
124
|
+
* queue and are announced via chat:run:queued. De-dupes a tab already in the
|
|
125
|
+
* pipeline (the UI disables input while running, but guard anyway).
|
|
126
|
+
*
|
|
127
|
+
* @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean }} opts
|
|
128
|
+
*/
|
|
129
|
+
function run(opts) {
|
|
130
|
+
if (inFlight.has(opts.tabId) || waiting.some((w) => w.tabId === opts.tabId)) return;
|
|
131
|
+
waiting.push(opts);
|
|
132
|
+
pump();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Fill open lanes FIFO up to CONCURRENCY_CAP, then announce queue positions for
|
|
136
|
+
// the remainder. O(n) over the waiting list (bounded by open tabs).
|
|
137
|
+
function pump() {
|
|
138
|
+
while (activeCount < CONCURRENCY_CAP && waiting.length > 0) {
|
|
139
|
+
const job = waiting.shift();
|
|
140
|
+
activeCount += 1;
|
|
141
|
+
Promise.resolve()
|
|
142
|
+
.then(() => executor(job))
|
|
143
|
+
.catch(() => { /* executeRun never rejects; defensive */ })
|
|
144
|
+
.finally(() => { activeCount -= 1; pump(); });
|
|
145
|
+
}
|
|
146
|
+
// Anyone still waiting gets a 1-based position update.
|
|
147
|
+
waiting.forEach((w, i) => {
|
|
148
|
+
broadcast('chat:run:queued', { tabId: w.tabId, sessionId: w.sessionId, position: i + 1 });
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Core runner ──────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Spawn the headless claude -p child for ONE run and resolve when it fully
|
|
156
|
+
* settles (exit / error / spawn failure). Registers a cancel fn in `inFlight`
|
|
157
|
+
* for the run's lifetime so cancel() can reach it. Never rejects — the queue
|
|
158
|
+
* pump relies on the returned promise always settling so the lane frees.
|
|
159
|
+
*
|
|
160
|
+
* @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean }} opts
|
|
161
|
+
* @returns {Promise<void>}
|
|
162
|
+
*/
|
|
163
|
+
function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
let settled = false;
|
|
166
|
+
// Frees the lane exactly once: drops the cancel fn and resolves the promise
|
|
167
|
+
// the pump is awaiting. Both exit and error paths funnel through here.
|
|
168
|
+
const settle = () => {
|
|
169
|
+
if (settled) return;
|
|
170
|
+
settled = true;
|
|
171
|
+
inFlight.delete(tabId);
|
|
172
|
+
resolve();
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const claudeBin = resolveClaudeBin();
|
|
176
|
+
const childEnv = cleanChildEnv({ PATH: pathWithUserBins() });
|
|
177
|
+
|
|
178
|
+
// Prepend the stop-signal protocol instruction to every prompt
|
|
179
|
+
const fullPrompt = STOP_SIGNAL_INSTRUCTION + prompt;
|
|
180
|
+
|
|
181
|
+
// Build argv as an array — no shell: true, no string interpolation
|
|
182
|
+
const args = [
|
|
183
|
+
'-p', fullPrompt,
|
|
184
|
+
'--model', 'sonnet', // pinned per "claude -p model pinning" rule
|
|
185
|
+
'--dangerously-skip-permissions',
|
|
186
|
+
'--output-format', 'stream-json',
|
|
187
|
+
'--verbose',
|
|
188
|
+
];
|
|
189
|
+
if (resume) {
|
|
190
|
+
// --resume carries the session context; no --session-id needed
|
|
191
|
+
args.push('--resume', sessionId);
|
|
192
|
+
} else {
|
|
193
|
+
args.push('--session-id', sessionId);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
broadcast('chat:run:started', { tabId, sessionId });
|
|
197
|
+
|
|
198
|
+
// Spawn with stdin closed (mirrors scheduler's 'ignore' — prevents the
|
|
199
|
+
// "claude -p stdin must be closed" gotcha from kg.cjs). stdout is piped for
|
|
200
|
+
// real-time NDJSON streaming; stderr piped for error-message capture.
|
|
201
|
+
let child;
|
|
202
|
+
try {
|
|
203
|
+
child = spawn(claudeBin, args, {
|
|
204
|
+
cwd,
|
|
205
|
+
env: childEnv,
|
|
206
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
207
|
+
detached: true, // own process group so killTree can SIGTERM descendants
|
|
208
|
+
});
|
|
209
|
+
} catch (err) {
|
|
210
|
+
broadcast('chat:run:error', {
|
|
211
|
+
tabId,
|
|
212
|
+
sessionId,
|
|
213
|
+
message: `spawn failed: ${err?.message ?? String(err)}`,
|
|
214
|
+
});
|
|
215
|
+
settle();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// One-shot kill helper — idempotent via the `killed` flag
|
|
220
|
+
let killed = false;
|
|
221
|
+
const doKill = (sig) => {
|
|
222
|
+
if (killed) return;
|
|
223
|
+
killed = true;
|
|
224
|
+
// Negative pid targets the process group (requires detached: true)
|
|
225
|
+
try { process.kill(-child.pid, sig); }
|
|
226
|
+
catch { try { child.kill(sig); } catch { /* already dead */ } }
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const cancelFn = () => {
|
|
230
|
+
doKill('SIGTERM');
|
|
231
|
+
setTimeout(() => doKill('SIGKILL'), 5000).unref?.();
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
inFlight.set(tabId, cancelFn);
|
|
235
|
+
|
|
236
|
+
// Hard wall-clock ceiling — SIGTERM + SIGKILL on expiry
|
|
237
|
+
const killTimer = setTimeout(() => {
|
|
238
|
+
broadcast('chat:run:error', {
|
|
239
|
+
tabId,
|
|
240
|
+
sessionId,
|
|
241
|
+
message: 'run exceeded 30-minute wall-clock ceiling',
|
|
242
|
+
});
|
|
243
|
+
cancelFn();
|
|
244
|
+
}, KILL_CEILING_MS);
|
|
245
|
+
if (killTimer.unref) killTimer.unref();
|
|
246
|
+
|
|
247
|
+
// ─── Stream parsing ────────────────────────────────────────────────────
|
|
248
|
+
// stdout is newline-delimited JSON (stream-json format). We buffer partial
|
|
249
|
+
// lines across TCP/pipe chunks. O(output-size) in memory per run.
|
|
250
|
+
|
|
251
|
+
let lineBuffer = '';
|
|
252
|
+
let finalAssistantText = '';
|
|
253
|
+
let stderrBuffer = '';
|
|
254
|
+
let gotResult = false;
|
|
255
|
+
|
|
256
|
+
const processLine = (line) => {
|
|
257
|
+
if (!line) return;
|
|
258
|
+
let event;
|
|
259
|
+
try { event = JSON.parse(line); } catch { return; }
|
|
260
|
+
|
|
261
|
+
if (event.type === 'assistant') {
|
|
262
|
+
// Content blocks carry the actual text deltas
|
|
263
|
+
const content = Array.isArray(event.message?.content) ? event.message.content : [];
|
|
264
|
+
for (const block of content) {
|
|
265
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
266
|
+
finalAssistantText += block.text;
|
|
267
|
+
broadcast('chat:run:output', { tabId, delta: block.text });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} else if (event.type === 'result') {
|
|
271
|
+
gotResult = true;
|
|
272
|
+
// Use the authoritative `result` field when available; fall back to
|
|
273
|
+
// accumulated assistant text (same content, different source).
|
|
274
|
+
const text = typeof event.result === 'string' ? event.result : finalAssistantText;
|
|
275
|
+
|
|
276
|
+
if (event.subtype === 'success') {
|
|
277
|
+
const signal = parseStopSignal(text);
|
|
278
|
+
if (signal) {
|
|
279
|
+
broadcast('chat:run:needs-input', {
|
|
280
|
+
tabId,
|
|
281
|
+
sessionId,
|
|
282
|
+
questions: signal.questions,
|
|
283
|
+
raw: text,
|
|
284
|
+
});
|
|
285
|
+
} else {
|
|
286
|
+
broadcast('chat:run:complete', { tabId, sessionId, finalMessage: text });
|
|
287
|
+
// Record durable exchange off the hot path — UI must not wait on Haiku
|
|
288
|
+
recordExchange({ sessionId, cwd, prompt, result: text }).catch((err) => {
|
|
289
|
+
console.error('[chatRunner] recordExchange failed:', err?.message ?? err);
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
} else {
|
|
293
|
+
broadcast('chat:run:error', {
|
|
294
|
+
tabId,
|
|
295
|
+
sessionId,
|
|
296
|
+
message: `run ended with result subtype: ${event.subtype ?? 'unknown'}`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
child.stdout.on('data', (chunk) => {
|
|
303
|
+
lineBuffer += chunk.toString('utf8');
|
|
304
|
+
let nl;
|
|
305
|
+
while ((nl = lineBuffer.indexOf('\n')) !== -1) {
|
|
306
|
+
processLine(lineBuffer.slice(0, nl).trim());
|
|
307
|
+
lineBuffer = lineBuffer.slice(nl + 1);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
child.stderr.on('data', (chunk) => {
|
|
312
|
+
stderrBuffer += chunk.toString('utf8');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
child.on('error', (err) => {
|
|
316
|
+
clearTimeout(killTimer);
|
|
317
|
+
broadcast('chat:run:error', {
|
|
318
|
+
tabId,
|
|
319
|
+
sessionId,
|
|
320
|
+
message: err?.message ?? String(err),
|
|
321
|
+
});
|
|
322
|
+
settle();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
child.on('exit', (code, signal) => {
|
|
326
|
+
clearTimeout(killTimer);
|
|
327
|
+
// Flush any partial line that didn't end with \n
|
|
328
|
+
if (lineBuffer.trim()) processLine(lineBuffer.trim());
|
|
329
|
+
|
|
330
|
+
if (!gotResult && !killed) {
|
|
331
|
+
const errDetail = stderrBuffer.trim()
|
|
332
|
+
? `: ${stderrBuffer.trim().slice(0, 300)}`
|
|
333
|
+
: '';
|
|
334
|
+
broadcast('chat:run:error', {
|
|
335
|
+
tabId,
|
|
336
|
+
sessionId,
|
|
337
|
+
message: `process exited without a result event (code=${code} signal=${signal})${errDetail}`,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
settle();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Cancel a run for the given tabId. An ACTIVE run is SIGTERM→SIGKILL'd (its
|
|
347
|
+
* child exit funnels through settle → pump, freeing the lane). A still-WAITING
|
|
348
|
+
* run is dropped from the queue and announced as cancelled. No-op otherwise.
|
|
349
|
+
*/
|
|
350
|
+
function cancel(tabId) {
|
|
351
|
+
const fn = inFlight.get(tabId);
|
|
352
|
+
if (fn) {
|
|
353
|
+
fn(); // settle() (on child exit) deletes from inFlight + pumps the next run
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const idx = waiting.findIndex((w) => w.tabId === tabId);
|
|
357
|
+
if (idx !== -1) {
|
|
358
|
+
const [w] = waiting.splice(idx, 1);
|
|
359
|
+
broadcast('chat:run:error', {
|
|
360
|
+
tabId: w.tabId,
|
|
361
|
+
sessionId: w.sessionId,
|
|
362
|
+
message: 'cancelled while queued',
|
|
363
|
+
});
|
|
364
|
+
pump(); // refresh remaining queue positions
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ─── IPC handler registration ─────────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
function registerChatHandlers() {
|
|
371
|
+
const { schemas, validated } = require('./ipcSchemas.cjs');
|
|
372
|
+
|
|
373
|
+
ipcMain.handle('chat:run', validated(schemas.chatRun, async ({ tabId, sessionId, prompt, cwd, resume }) => {
|
|
374
|
+
run({ tabId, sessionId, prompt, cwd, resume: !!resume });
|
|
375
|
+
return { ok: true };
|
|
376
|
+
}));
|
|
377
|
+
|
|
378
|
+
ipcMain.on('chat:cancel', (_e, payload) => {
|
|
379
|
+
let tabId;
|
|
380
|
+
try { tabId = schemas.chatCancel.parse(payload).tabId; } catch { return; }
|
|
381
|
+
cancel(tabId);
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = {
|
|
386
|
+
run,
|
|
387
|
+
cancel,
|
|
388
|
+
attachWindow,
|
|
389
|
+
registerChatHandlers,
|
|
390
|
+
parseStopSignal,
|
|
391
|
+
STOP_SENTINEL,
|
|
392
|
+
__setExecutor,
|
|
393
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* exchanges.cjs — durable append-only log of completed terminal-chat exchanges.
|
|
5
|
+
*
|
|
6
|
+
* Appends one NDJSON record per successful chat run to:
|
|
7
|
+
* ~/.claude/knowledge-log/exchanges/<encodeCwd(cwd)>.jsonl
|
|
8
|
+
*
|
|
9
|
+
* Record shape (contract for PRDs 324 + 325):
|
|
10
|
+
* { ts, sessionId, cwd, prompt, result, summary, degraded? }
|
|
11
|
+
*
|
|
12
|
+
* Summary is produced by the shared Haiku summarizer (summarize.cjs). On
|
|
13
|
+
* summarization failure the record is still written with `degraded` set — the
|
|
14
|
+
* exchange is never lost due to an API call failing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fsp = require('node:fs/promises');
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const os = require('node:os');
|
|
21
|
+
const { encodeCwd } = require('./lib/encodeCwd.cjs');
|
|
22
|
+
const { validatePath } = require('./config.cjs');
|
|
23
|
+
const { summarize } = require('./lib/summarize.cjs');
|
|
24
|
+
|
|
25
|
+
const HOME = os.homedir();
|
|
26
|
+
const EXCHANGES_DIR = path.join(HOME, '.claude', 'knowledge-log', 'exchanges');
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record a completed exchange. Creates the exchanges directory if needed.
|
|
30
|
+
* Appends one JSON line; uses O_APPEND so concurrent writes from separate
|
|
31
|
+
* processes are safe (each line is a single write, POSIX O_APPEND atomic for
|
|
32
|
+
* pipe-sized payloads).
|
|
33
|
+
*
|
|
34
|
+
* @param {{ sessionId: string, cwd: string, prompt: string, result: string }} opts
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
async function recordExchange({ sessionId, cwd, prompt, result }) {
|
|
38
|
+
const encoded = encodeCwd(cwd);
|
|
39
|
+
const filePath = path.join(EXCHANGES_DIR, `${encoded}.jsonl`);
|
|
40
|
+
|
|
41
|
+
// Security: validate that the target path stays within home dir
|
|
42
|
+
validatePath(EXCHANGES_DIR);
|
|
43
|
+
|
|
44
|
+
// Summarize — always resolves; never throws
|
|
45
|
+
const { summary, model, degraded } = await summarize(result);
|
|
46
|
+
|
|
47
|
+
const record = {
|
|
48
|
+
ts: new Date().toISOString(),
|
|
49
|
+
sessionId,
|
|
50
|
+
cwd,
|
|
51
|
+
prompt,
|
|
52
|
+
result,
|
|
53
|
+
summary,
|
|
54
|
+
model,
|
|
55
|
+
...(degraded ? { degraded } : {}),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const line = JSON.stringify(record) + '\n';
|
|
59
|
+
|
|
60
|
+
await fsp.mkdir(EXCHANGES_DIR, { recursive: true });
|
|
61
|
+
await fsp.appendFile(filePath, line, { encoding: 'utf8' });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Match kg.cjs MAX_TAIL_BYTES — prevents a huge log from blocking the main thread.
|
|
65
|
+
const MAX_TAIL_BYTES = 8 * 1024 * 1024;
|
|
66
|
+
const DEFAULT_LIMIT = 100;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read exchanges for a project, newest-first. Bounded to MAX_TAIL_BYTES from
|
|
70
|
+
* the end of the file so large logs never block the main thread.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ cwd: string, sessionId?: string, limit?: number, offset?: number }}
|
|
73
|
+
* @returns {Promise<object[]>}
|
|
74
|
+
*/
|
|
75
|
+
async function listExchanges({ cwd, sessionId, limit = DEFAULT_LIMIT, offset = 0 }) {
|
|
76
|
+
const encoded = encodeCwd(cwd);
|
|
77
|
+
const filePath = path.join(EXCHANGES_DIR, `${encoded}.jsonl`);
|
|
78
|
+
|
|
79
|
+
let stat;
|
|
80
|
+
try { stat = await fsp.stat(filePath); } catch { return []; }
|
|
81
|
+
|
|
82
|
+
// Tail up to MAX_TAIL_BYTES from the end of the file.
|
|
83
|
+
const readLen = Math.min(stat.size, MAX_TAIL_BYTES);
|
|
84
|
+
const startPos = stat.size - readLen;
|
|
85
|
+
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
const fd = await fsp.open(filePath, 'r');
|
|
89
|
+
try {
|
|
90
|
+
const buf = Buffer.allocUnsafe(readLen);
|
|
91
|
+
const { bytesRead } = await fd.read(buf, 0, readLen, startPos);
|
|
92
|
+
raw = buf.slice(0, bytesRead).toString('utf8');
|
|
93
|
+
} finally {
|
|
94
|
+
await fd.close();
|
|
95
|
+
}
|
|
96
|
+
} catch { return []; }
|
|
97
|
+
|
|
98
|
+
// If we started mid-file, skip the (possibly incomplete) first line.
|
|
99
|
+
let text = raw;
|
|
100
|
+
if (startPos > 0) {
|
|
101
|
+
const nl = text.indexOf('\n');
|
|
102
|
+
text = nl === -1 ? '' : text.slice(nl + 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const records = [];
|
|
106
|
+
for (const line of text.split('\n')) {
|
|
107
|
+
const trimmed = line.trim();
|
|
108
|
+
if (!trimmed) continue;
|
|
109
|
+
try {
|
|
110
|
+
const rec = JSON.parse(trimmed);
|
|
111
|
+
if (rec && typeof rec.ts === 'string') records.push(rec);
|
|
112
|
+
} catch { /* skip malformed */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Newest-first
|
|
116
|
+
records.reverse();
|
|
117
|
+
|
|
118
|
+
const filtered = sessionId
|
|
119
|
+
? records.filter((r) => r.sessionId === sessionId)
|
|
120
|
+
: records;
|
|
121
|
+
|
|
122
|
+
return filtered.slice(offset, offset + limit);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { recordExchange, listExchanges, EXCHANGES_DIR };
|
package/src/main/files.cjs
CHANGED
|
@@ -37,6 +37,18 @@ function rejectCredentials(real) {
|
|
|
37
37
|
if (path.basename(real) === '.credentials.json') {
|
|
38
38
|
throw new Error('Write to .credentials.json denied');
|
|
39
39
|
}
|
|
40
|
+
// Defense-in-depth: files:* is renderer-only (not web-remote reachable), but a
|
|
41
|
+
// renderer compromise could otherwise write code-execution-persistence or
|
|
42
|
+
// credential material a cockpit file browser has no business modifying. Block
|
|
43
|
+
// WRITES to these (reads are unaffected — they don't call this). Shell rc files
|
|
44
|
+
// are deliberately NOT blocked; developers legitimately edit those.
|
|
45
|
+
const rel = path.relative(os.homedir(), real);
|
|
46
|
+
if (rel === '.ssh' || rel.startsWith('.ssh' + path.sep)) {
|
|
47
|
+
throw new Error('Write inside ~/.ssh denied');
|
|
48
|
+
}
|
|
49
|
+
if (rel.startsWith(path.join('.config', 'autostart') + path.sep)) {
|
|
50
|
+
throw new Error('Write to ~/.config/autostart denied');
|
|
51
|
+
}
|
|
40
52
|
}
|
|
41
53
|
|
|
42
54
|
// Invalid characters for file/folder names (cross-platform).
|
|
@@ -322,19 +334,19 @@ function registerFilesHandlers() {
|
|
|
322
334
|
const { path: p } = filesPath.parse(payload);
|
|
323
335
|
return deleteEntry(p);
|
|
324
336
|
});
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
});
|
|
329
|
-
ipcMain.handle('files:show-in-finder', (_e, payload) => {
|
|
330
|
-
const { path: p } = filesPath.parse(payload);
|
|
331
|
-
return showInFinder(p);
|
|
332
|
-
});
|
|
337
|
+
// files:open-external / files:show-in-finder consolidated into shell:open
|
|
338
|
+
// (index.cjs, as: 'openPath' / 'revealPath'). The openExternal/showInFinder
|
|
339
|
+
// functions are exported for that handler to call.
|
|
333
340
|
}
|
|
334
341
|
|
|
335
342
|
module.exports = {
|
|
336
343
|
registerFilesHandlers,
|
|
344
|
+
// Path-based reveal/open helpers — routed through the consolidated shell:open
|
|
345
|
+
// handler in index.cjs (as: 'openPath' / 'revealPath').
|
|
346
|
+
openExternal,
|
|
347
|
+
showInFinder,
|
|
337
348
|
// exported for tests
|
|
349
|
+
rejectCredentials,
|
|
338
350
|
listDir,
|
|
339
351
|
readFile,
|
|
340
352
|
writeFile,
|