claude-code-session-manager 0.33.0 → 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.
Files changed (41) hide show
  1. package/README.md +5 -1
  2. package/dist/assets/{TiptapBody-DHzFmRr2.js → TiptapBody-DWeWI8gw.js} +51 -51
  3. package/dist/assets/index-C2m4dco8.css +32 -0
  4. package/dist/assets/index-CFT773vM.js +3491 -0
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/plugins/session-manager-dev/skills/discover-features/SKILL.md +95 -0
  8. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +9 -9
  9. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +77 -62
  10. package/plugins/session-manager-dev/skills/project-status/SKILL.md +13 -0
  11. package/src/main/__tests__/chat-queue.test.cjs +65 -0
  12. package/src/main/__tests__/chat-stop-signal.test.cjs +52 -0
  13. package/src/main/__tests__/exchanges.test.cjs +177 -0
  14. package/src/main/__tests__/files-reject-credentials.test.cjs +40 -0
  15. package/src/main/__tests__/kg-augment.test.cjs +195 -0
  16. package/src/main/__tests__/memoryAggregate.test.cjs +70 -0
  17. package/src/main/agentMemory.cjs +0 -21
  18. package/src/main/chatRunner.cjs +393 -0
  19. package/src/main/exchanges.cjs +125 -0
  20. package/src/main/files.cjs +20 -8
  21. package/src/main/historyAggregator.cjs +0 -162
  22. package/src/main/index.cjs +76 -57
  23. package/src/main/ipcSchemas.cjs +70 -30
  24. package/src/main/lib/kgExchangePairing.cjs +75 -0
  25. package/src/main/lib/reaperHelpers.cjs +7 -1
  26. package/src/main/lib/summarize.cjs +114 -0
  27. package/src/main/memoryAggregate.cjs +250 -0
  28. package/src/main/pluginInstall.cjs +4 -2
  29. package/src/main/scheduler.cjs +66 -20
  30. package/src/main/supervisor.cjs +16 -0
  31. package/src/main/transcripts.cjs +22 -2
  32. package/src/main/usage.cjs +58 -11
  33. package/src/main/voiceHotkey.cjs +0 -2
  34. package/src/main/webRemote.cjs +11 -78
  35. package/src/preload/api.d.ts +131 -125
  36. package/src/preload/index.cjs +45 -29
  37. package/dist/assets/index-AKeGl-VM.css +0 -32
  38. package/dist/assets/index-Bwwbc2mS.js +0 -3535
  39. package/src/main/kg.cjs +0 -792
  40. package/src/main/lib/kgLite.cjs +0 -195
  41. 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 };
@@ -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
- ipcMain.handle('files:open-external', (_e, payload) => {
326
- const { path: p } = filesPath.parse(payload);
327
- return openExternal(p);
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,