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.
Files changed (40) hide show
  1. package/README.md +5 -1
  2. package/dist/assets/{TiptapBody-BqQFXHkk.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/voiceHotkey.cjs +0 -2
  33. package/src/main/webRemote.cjs +11 -78
  34. package/src/preload/api.d.ts +131 -125
  35. package/src/preload/index.cjs +45 -29
  36. package/dist/assets/index-1PpZBVUr.js +0 -3535
  37. package/dist/assets/index-AKeGl-VM.css +0 -32
  38. package/src/main/kg.cjs +0 -792
  39. package/src/main/lib/kgLite.cjs +0 -195
  40. package/src/main/lib/kgPrune.cjs +0 -87
@@ -42,12 +42,6 @@ class LRUCache {
42
42
  */
43
43
  const aggrCache = new LRUCache(CACHE_MAX);
44
44
 
45
- /**
46
- * Cache for parseConversationMeta results.
47
- * Entry shape: { mtimeMs: number, size: number, readOffset: number, inode: number, result: MetaResult }
48
- */
49
- const metaCache = new LRUCache(CACHE_MAX);
50
-
51
45
  // ── date helpers ──────────────────────────────────────────────────────────────
52
46
 
53
47
  function decodeCwd(encoded) {
@@ -143,31 +137,6 @@ function scanAggrLines(lines, acc, captureFirst) {
143
137
  return firstTs;
144
138
  }
145
139
 
146
- /**
147
- * Scan JSONL lines into a conversation-meta accumulator (mutates meta).
148
- * captureFirst=true: record the first timestamp seen as meta.firstTs.
149
- */
150
- function scanMetaLines(lines, meta, captureFirst) {
151
- for (const raw of lines) {
152
- const line = raw.trim();
153
- if (!line) continue;
154
- let obj;
155
- try { obj = JSON.parse(line); } catch { continue; }
156
- const ts = obj.ts ?? obj.timestamp;
157
- if (ts) {
158
- if (captureFirst && meta.firstTs === null) meta.firstTs = ts;
159
- meta.lastTs = ts;
160
- }
161
- const usage = obj.usage ?? obj.message?.usage;
162
- if (usage && typeof usage === 'object') {
163
- const inT = usage.input_tokens ?? usage.inputTokens;
164
- const outT = usage.output_tokens ?? usage.outputTokens;
165
- if (typeof inT === 'number') meta.inputTokens += inT;
166
- if (typeof outT === 'number') meta.outputTokens += outT;
167
- }
168
- }
169
- }
170
-
171
140
  // ── cached file parsers ───────────────────────────────────────────────────────
172
141
 
173
142
  /**
@@ -271,71 +240,6 @@ async function parseJSONL(filePath, stat) {
271
240
  return { result: acc, cacheHit: false };
272
241
  }
273
242
 
274
- /**
275
- * Parse a JSONL transcript for per-conversation metadata.
276
- * Returns { result, cacheHit } — same caching strategy as parseJSONL.
277
- */
278
- async function parseConversationMeta(filePath, stat) {
279
- const emptyMeta = () => ({
280
- firstTs: null,
281
- lastTs: null,
282
- inputTokens: 0,
283
- outputTokens: 0,
284
- skipped: false,
285
- });
286
-
287
- if (stat.size > MAX_FILE_BYTES) {
288
- return { result: { ...emptyMeta(), skipped: true }, cacheHit: false };
289
- }
290
-
291
- const cached = metaCache.get(filePath);
292
- if (cached) {
293
- if (cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
294
- return { result: cached.result, cacheHit: true };
295
- }
296
-
297
- if (stat.size > cached.size) {
298
- if (cached.inode !== undefined && cached.inode !== stat.ino) {
299
- // inode changed → file replaced; fall through to full parse
300
- } else {
301
- try {
302
- const readFrom = cached.readOffset ?? cached.size;
303
- const tail = await readSlice(filePath, readFrom, stat.size);
304
- const delta = emptyMeta();
305
- scanMetaLines(tail.split('\n'), delta, false);
306
- const prev = cached.result;
307
- const merged = {
308
- firstTs: prev.firstTs, // first timestamp never changes on appends
309
- lastTs: delta.lastTs ?? prev.lastTs,
310
- inputTokens: prev.inputTokens + delta.inputTokens,
311
- outputTokens: prev.outputTokens + delta.outputTokens,
312
- skipped: false,
313
- };
314
- const lastNl = tail.lastIndexOf('\n');
315
- const readOffset = lastNl >= 0 ? readFrom + lastNl + 1 : readFrom;
316
- metaCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, readOffset, inode: stat.ino, result: merged });
317
- return { result: merged, cacheHit: false };
318
- } catch {
319
- // fall through to full parse
320
- }
321
- }
322
- }
323
- }
324
-
325
- // Full parse
326
- let text;
327
- try { text = await fsp.readFile(filePath, 'utf8'); } catch {
328
- return { result: emptyMeta(), cacheHit: false };
329
- }
330
-
331
- const meta = emptyMeta();
332
- scanMetaLines(text.split('\n'), meta, true);
333
- const lastNlMeta = text.lastIndexOf('\n');
334
- const readOffsetMeta = lastNlMeta >= 0 ? lastNlMeta + 1 : 0;
335
- metaCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, readOffset: readOffsetMeta, inode: stat.ino, result: meta });
336
- return { result: meta, cacheHit: false };
337
- }
338
-
339
243
  // ── aggregate ─────────────────────────────────────────────────────────────────
340
244
 
341
245
  async function aggregate(req) {
@@ -490,72 +394,6 @@ function registerHistoryAggregatorHandlers() {
490
394
  sessions.sort((a, b) => b.mtimeMs - a.mtimeMs);
491
395
  return { sessions, scannedMs: Date.now() - t0 };
492
396
  });
493
-
494
- /** Per-conversation metadata: one row per JSONL with derived duration +
495
- * token totals. Used by the Overview detailed-stats panel to compute
496
- * hourly/daily distribution + top-projects. */
497
- ipcMain.handle('history:list-conversations', async () => {
498
- const t0 = Date.now();
499
- const conversations = [];
500
- let truncated = false;
501
- let skippedBudgetFiles = 0;
502
- let parseBudgetSpentMs = 0;
503
-
504
- let projectEntries;
505
- try {
506
- projectEntries = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
507
- } catch {
508
- return { conversations: [], truncated: false, scannedMs: Date.now() - t0 };
509
- }
510
-
511
- outer:
512
- for (const ent of projectEntries) {
513
- if (!ent.isDirectory()) continue;
514
- const projectDir = path.join(PROJECTS_DIR, ent.name);
515
- const projectFolder = '/' + ent.name.replace(/-/g, '/');
516
- let files;
517
- try { files = await fsp.readdir(projectDir, { withFileTypes: true }); } catch { continue; }
518
-
519
- for (const f of files) {
520
- if (!f.isFile() || !f.name.endsWith('.jsonl')) continue;
521
- const filePath = path.join(projectDir, f.name);
522
- let stat;
523
- try { stat = await fsp.stat(filePath); } catch { continue; }
524
-
525
- const t1 = Date.now();
526
- const { result: meta, cacheHit } = await parseConversationMeta(filePath, stat);
527
- if (!cacheHit) parseBudgetSpentMs += Date.now() - t1;
528
-
529
- if (!meta.skipped) {
530
- const firstTs = meta.firstTs || new Date(stat.mtimeMs).toISOString();
531
- const duration =
532
- meta.firstTs && meta.lastTs
533
- ? Math.max(0, Date.parse(meta.lastTs) - Date.parse(meta.firstTs))
534
- : undefined;
535
- conversations.push({
536
- timestamp: firstTs,
537
- projectFolder,
538
- stats: {
539
- ...(duration !== undefined ? { duration } : {}),
540
- estimatedTokens: meta.inputTokens + meta.outputTokens,
541
- },
542
- });
543
- }
544
-
545
- if (!cacheHit && parseBudgetSpentMs > PARSE_BUDGET_MS) {
546
- skippedBudgetFiles++;
547
- truncated = true;
548
- console.warn(
549
- `[historyAggregator] list-conversations: parse budget exhausted after ${parseBudgetSpentMs}ms; ` +
550
- `at least ${skippedBudgetFiles} file(s) skipped`
551
- );
552
- break outer;
553
- }
554
- }
555
- }
556
-
557
- return { conversations, truncated, scannedMs: Date.now() - t0 };
558
- });
559
397
  }
560
398
 
561
399
  const remote = { aggregate };
@@ -31,16 +31,18 @@ const otel = require('./otel.cjs');
31
31
  const otelSettings = require('./otelSettings.cjs');
32
32
  const { registerHistoryAggregatorHandlers } = require('./historyAggregator.cjs');
33
33
  const memoryTool = require('./memoryTool.cjs');
34
+ const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
34
35
  const agentMemory = require('./agentMemory.cjs');
35
36
  const { registerDocEditorHandlers } = require('./docEditor.cjs');
36
37
  const git = require('./git.cjs');
37
38
  const superagent = require('./superagent.cjs');
38
- const kg = require('./kg.cjs');
39
39
  const filesIpc = require('./files.cjs');
40
40
  const searchIpc = require('./search.cjs');
41
41
  const repoAnalyzer = require('./repoAnalyzer.cjs');
42
42
  const hivesIpc = require('./hives.cjs');
43
43
  const webRemote = require('./webRemote.cjs');
44
+ const chatRunner = require('./chatRunner.cjs');
45
+ const { listExchanges } = require('./exchanges.cjs');
44
46
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
45
47
  const { checkInsideHome, assertInsideHome } = require('./lib/insideHome.cjs');
46
48
  const { openInEditor, openFileInEditor, openInFinder, openInTerminal } = require('./lib/openExternalApp.cjs');
@@ -270,6 +272,7 @@ async function rebootApp() {
270
272
  watchers.attachWindow(mainWindow);
271
273
  pluginInstall.attachWindow(mainWindow);
272
274
  superagent.attachWindow(mainWindow);
275
+ chatRunner.attachWindow(mainWindow);
273
276
  rebooting = false;
274
277
  return;
275
278
  }
@@ -581,57 +584,66 @@ ipcMain.handle('app:git-branch', validated(schemas.appGitBranch, async ({ cwd })
581
584
  // in lib/insideHome.cjs — single chokepoint for the /home/bilkoEVIL prefix-trap.
582
585
  // Editor / finder / terminal logic lives in lib/openExternalApp.cjs.
583
586
 
584
- ipcMain.handle('app:open-in-editor', validated(schemas.openInEditor, async ({ cwd, editor }) => {
585
- const r = checkInsideHome(cwd);
586
- if (!r.ok) throw new Error(r.error);
587
- return openInEditor({ cwd, editor });
588
- }));
589
-
590
- ipcMain.handle('app:open-external', validated(schemas.openExternal, async ({ url }) => {
591
- // URL filter mirrors setWindowOpenHandler at line ~631: without it, the
592
- // renderer could be tricked into asking shell.openExternal to launch
593
- // `file:///etc/passwd`, `javascript:…`, or `mailto:…`. Stick to web URLs.
594
- if (!url.startsWith('http://') && !url.startsWith('https://')) {
595
- return { ok: false, error: 'only http/https URLs are allowed' };
596
- }
597
- await shell.openExternal(url);
598
- return { ok: true };
599
- }));
600
-
601
- ipcMain.handle('app:open-file-in-editor', validated(schemas.openFileInEditor, async ({ path: p, line, col, editor }) => {
602
- const home = os.homedir();
603
- const abs = path.isAbsolute(p) ? p : path.resolve(home, p);
604
- // Allowed roots: $HOME (the usual case) plus our own clipboard temp dir
605
- // (clipboard-paste writes PNGs there; clicks on those paths from the
606
- // terminal must resolve). Resolve symlinks on both sides — on macOS
607
- // /tmp is a symlink to /private/tmp, so a literal prefix check fails.
608
- const clipboardDirRaw = path.join(os.tmpdir(), 'session-manager-clipboard');
609
- let clipboardDirReal = clipboardDirRaw;
610
- try { clipboardDirReal = fs.realpathSync(clipboardDirRaw); } catch { /* not yet created */ }
611
- let absReal = abs;
612
- try { absReal = fs.realpathSync(abs); } catch { /* file may not exist yet — fall through to access() below */ }
613
- const inClipboardTmp =
614
- absReal === clipboardDirReal ||
615
- absReal.startsWith(clipboardDirReal + path.sep) ||
616
- abs === clipboardDirRaw ||
617
- abs.startsWith(clipboardDirRaw + path.sep);
618
- if (!inClipboardTmp) {
619
- const r = checkInsideHome(abs);
620
- if (!r.ok) throw new Error(r.error);
587
+ // Consolidated shell open/reveal endpoint. One channel, discriminated on `as`,
588
+ // replaces five app:open-* handlers plus files:open-external / show-in-finder.
589
+ // Each branch keeps its own boundary guard (home-scope or http(s)-only).
590
+ ipcMain.handle('shell:open', validated(schemas.shellOpen, async (opts) => {
591
+ switch (opts.as) {
592
+ case 'editor': {
593
+ const r = checkInsideHome(opts.cwd);
594
+ if (!r.ok) throw new Error(r.error);
595
+ return openInEditor({ cwd: opts.cwd, editor: opts.editor });
596
+ }
597
+ case 'finder': {
598
+ const r = checkInsideHome(opts.cwd);
599
+ if (!r.ok) throw new Error(r.error);
600
+ return openInFinder({ cwd: opts.cwd });
601
+ }
602
+ case 'terminal': {
603
+ const r = checkInsideHome(opts.cwd);
604
+ if (!r.ok) throw new Error(r.error);
605
+ return openInTerminal({ cwd: opts.cwd });
606
+ }
607
+ case 'external': {
608
+ // Without this, the renderer could ask shell.openExternal to launch
609
+ // `file:///etc/passwd`, `javascript:…`, or `mailto:…`. Web URLs only.
610
+ const url = opts.url;
611
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
612
+ return { ok: false, error: 'only http/https URLs are allowed' };
613
+ }
614
+ await shell.openExternal(url);
615
+ return { ok: true };
616
+ }
617
+ case 'fileInEditor': {
618
+ const home = os.homedir();
619
+ const abs = path.isAbsolute(opts.path) ? opts.path : path.resolve(home, opts.path);
620
+ // Allowed roots: $HOME plus our own clipboard temp dir (clipboard-paste
621
+ // writes PNGs there; terminal clicks on those paths must resolve).
622
+ // Resolve symlinks on both sides — on macOS /tmp → /private/tmp.
623
+ const clipboardDirRaw = path.join(os.tmpdir(), 'session-manager-clipboard');
624
+ let clipboardDirReal = clipboardDirRaw;
625
+ try { clipboardDirReal = fs.realpathSync(clipboardDirRaw); } catch { /* not yet created */ }
626
+ let absReal = abs;
627
+ try { absReal = fs.realpathSync(abs); } catch { /* may not exist yet — access() below */ }
628
+ const inClipboardTmp =
629
+ absReal === clipboardDirReal ||
630
+ absReal.startsWith(clipboardDirReal + path.sep) ||
631
+ abs === clipboardDirRaw ||
632
+ abs.startsWith(clipboardDirRaw + path.sep);
633
+ if (!inClipboardTmp) {
634
+ const r = checkInsideHome(abs);
635
+ if (!r.ok) throw new Error(r.error);
636
+ }
637
+ return openFileInEditor({ path: abs, line: opts.line, col: opts.col, editor: opts.editor });
638
+ }
639
+ case 'openPath':
640
+ // validateHomePath runs inside filesIpc.openExternal.
641
+ return filesIpc.openExternal(opts.path);
642
+ case 'revealPath':
643
+ return filesIpc.showInFinder(opts.path);
644
+ default:
645
+ return { ok: false, error: 'unknown open target' };
621
646
  }
622
- return openFileInEditor({ path: abs, line, col, editor });
623
- }));
624
-
625
- ipcMain.handle('app:open-in-finder', validated(schemas.openInFinder, async ({ cwd }) => {
626
- const r = checkInsideHome(cwd);
627
- if (!r.ok) throw new Error(r.error);
628
- return openInFinder({ cwd });
629
- }));
630
-
631
- ipcMain.handle('app:open-in-terminal', validated(schemas.openInTerminal, async ({ cwd }) => {
632
- const r = checkInsideHome(cwd);
633
- if (!r.ok) throw new Error(r.error);
634
- return openInTerminal({ cwd });
635
647
  }));
636
648
 
637
649
  ipcMain.handle('app:archive-project', validated(schemas.archiveProject, async ({ encoded }) => {
@@ -659,6 +671,7 @@ queueOps.registerQueueOpsHandlers();
659
671
  registerHistoryAggregatorHandlers();
660
672
  pluginInstall.registerPluginInstallHandlers();
661
673
  memoryTool.registerMemoryHandlers();
674
+ registerMemoryAggregateIpc();
662
675
  agentMemory.registerAgentMemoryHandlers();
663
676
  registerDocEditorHandlers();
664
677
  git.register(ipcMain);
@@ -668,6 +681,17 @@ searchIpc.registerSearchHandlers();
668
681
  repoAnalyzer.register(ipcMain);
669
682
  hivesIpc.registerHiveHandlers();
670
683
  webRemote.registerRemoteHandlers();
684
+ chatRunner.registerChatHandlers();
685
+
686
+ // Exchanges: read the durable per-exchange log (written by chatRunner → recordExchange).
687
+ ipcMain.handle('exchanges:list', validated(schemas.exchangesList, async (payload) => {
688
+ try {
689
+ return await listExchanges(payload);
690
+ } catch (e) {
691
+ logs.writeLine({ scope: 'exchanges', level: 'error', message: 'listExchanges failed', meta: { error: e?.message } });
692
+ return [];
693
+ }
694
+ }));
671
695
 
672
696
  // OTEL telemetry export (opt-in via ~/.config/session-manager/otel.json).
673
697
  ipcMain.handle('otel:get-config', async () => otelSettings.load());
@@ -954,8 +978,8 @@ app.whenReady().then(async () => {
954
978
  watchers.attachWindow(mainWindow);
955
979
  pluginInstall.attachWindow(mainWindow);
956
980
  superagent.attachWindow(mainWindow);
957
- kg.attachWindow(mainWindow);
958
981
  webRemote.attachWindow(mainWindow);
982
+ chatRunner.attachWindow(mainWindow);
959
983
  scheduler.init().catch((e) => {
960
984
  logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
961
985
  });
@@ -968,11 +992,6 @@ app.whenReady().then(async () => {
968
992
  webRemote.init().catch((e) => {
969
993
  logs.writeLine({ scope: 'webRemote', level: 'error', message: 'init failed', meta: { error: e?.message } });
970
994
  });
971
- // Knowledge Graph: watch the prompt log + register kg:* IPC. Best-effort.
972
- try { kg.init({ logger: logs }); } catch (e) {
973
- logs.writeLine({ scope: 'kg', level: 'error', message: 'init failed', meta: { error: e?.message } });
974
- }
975
-
976
995
  // Keep the machine awake while the app is open. The scheduler polls billing
977
996
  // usage every 2 min and runs `claude -p` jobs that must survive an idle
978
997
  // laptop — a system suspend (GNOME/Pop!_OS idle or lid timeout) would freeze
@@ -141,29 +141,22 @@ const scheduleRetagPrd = z.object({
141
141
  // ──────────────────────────────────────────── Projects
142
142
  const ENCODED_SLUG_RE = /^[A-Za-z0-9._-]+$/;
143
143
 
144
- const openInEditor = z.object({
145
- cwd: z.string().min(1).max(4096),
146
- editor: z.string().max(256).nullable().optional(),
147
- });
148
-
149
- const openExternal = z.object({
150
- url: z.string().min(1).max(4096),
151
- });
152
-
153
- const openFileInEditor = z.object({
154
- path: z.string().min(1).max(4096),
155
- line: z.number().int().positive().optional(),
156
- col: z.number().int().positive().optional(),
157
- editor: z.string().max(256).nullable().optional(),
158
- });
159
-
160
- const openInFinder = z.object({
161
- cwd: z.string().min(1).max(4096),
162
- });
163
-
164
- const openInTerminal = z.object({
165
- cwd: z.string().min(1).max(4096),
166
- });
144
+ // Consolidated shell "open/reveal" API. One channel (shell:open) replaces the
145
+ // former app:open-in-editor / open-file-in-editor / open-in-finder /
146
+ // open-in-terminal / open-external + files:open-external / show-in-finder.
147
+ // Discriminated on `as`; each variant carries only its own fields. Per-variant
148
+ // path/URL guards still run inside the handler (checkInsideHome / http(s) only).
149
+ const _pathStr = z.string().min(1).max(4096);
150
+ const _editorStr = z.string().max(256).nullable().optional();
151
+ const shellOpen = z.discriminatedUnion('as', [
152
+ z.object({ as: z.literal('editor'), cwd: _pathStr, editor: _editorStr }),
153
+ z.object({ as: z.literal('fileInEditor'), path: _pathStr, line: z.number().int().positive().optional(), col: z.number().int().positive().optional(), editor: _editorStr }),
154
+ z.object({ as: z.literal('finder'), cwd: _pathStr }),
155
+ z.object({ as: z.literal('terminal'), cwd: _pathStr }),
156
+ z.object({ as: z.literal('external'), url: _pathStr }),
157
+ z.object({ as: z.literal('openPath'), path: _pathStr }),
158
+ z.object({ as: z.literal('revealPath'), path: _pathStr }),
159
+ ]);
167
160
 
168
161
  const archiveProject = z.object({
169
162
  encoded: z.string().regex(ENCODED_SLUG_RE).min(1).max(4096),
@@ -223,6 +216,16 @@ const memoryCreate = z.object({
223
216
  description: z.string().max(2048).optional(),
224
217
  }).strict();
225
218
 
219
+ // memory:aggregate — Memory Clusters (PRD 356). `workspace` here is already
220
+ // the encoded cwd slug (memoryAggregate.cjs reads directly from
221
+ // ~/.claude/projects/<workspace>/memory/), same regex as the other memory:*
222
+ // handlers. `refresh: true` is the cost gate that fires the single claude -p
223
+ // clustering pass; falsy returns the cached result.
224
+ const memoryAggregate = z.object({
225
+ workspace: z.string().regex(MEMORY_WORKSPACE_RE),
226
+ refresh: z.boolean().optional(),
227
+ }).strict();
228
+
226
229
  // ──────────────────────────────────────────── Per-subagent memory
227
230
  // Distinct from the workspace-scoped Memory tool: agentMemory is keyed by
228
231
  // subagent name (the .md filename in ~/.claude/agents/, e.g. "code-reviewer"),
@@ -253,6 +256,37 @@ const agentMemoryDelete = z.object({
253
256
  entryId: z.string().regex(AGENT_MEMORY_ID_RE),
254
257
  }).strict();
255
258
 
259
+ // ──────────────────────────────────────────── Exchanges (PRD 324 read path)
260
+ const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
261
+
262
+ const exchangesList = z.object({
263
+ cwd: z.string().min(1).max(4096),
264
+ sessionId: z.string().regex(SESSION_ID_RE).optional(),
265
+ limit: z.number().int().min(1).max(500).optional(),
266
+ offset: z.number().int().min(0).max(100000).optional(),
267
+ }).strict();
268
+
269
+ // ──────────────────────────────────────────── Chat runner (PRD 319)
270
+ // Prompt cap: 100 KiB. Matches a generous interactive message budget while
271
+ // preventing accidental megabyte pastes from reaching claude -p.
272
+ const CHAT_PROMPT_MAX_BYTES = 100 * 1024;
273
+
274
+ const chatRun = z.object({
275
+ tabId: z.string().min(1).max(128),
276
+ sessionId: z.string().min(1).max(128),
277
+ // Non-empty; capped so a malformed renderer can't spawn a 100 MB child argv.
278
+ prompt: z.string().min(1).refine(
279
+ (s) => Buffer.byteLength(s, 'utf8') <= CHAT_PROMPT_MAX_BYTES,
280
+ `prompt must be ≤ ${CHAT_PROMPT_MAX_BYTES} bytes`,
281
+ ),
282
+ cwd: z.string().min(1).max(4096),
283
+ resume: z.boolean().optional().default(false),
284
+ });
285
+
286
+ const chatCancel = z.object({
287
+ tabId: z.string().min(1).max(128),
288
+ });
289
+
256
290
  // ──────────────────────────────────────────── Web Remote
257
291
  // OTP is 8 uppercase alphanumeric chars (case-insensitive entry, normalised to upper in handler).
258
292
  const WEB_REMOTE_OTP_RE = /^[A-Z0-9]{8}$/i;
@@ -285,7 +319,7 @@ const historyAggregate = z.object({
285
319
  const VOICE_ACCELERATOR_RE = /^(CommandOrControl|CmdOrCtrl|Cmd|Command|Ctrl|Control|Alt|Option|Shift|Super|Meta)(\+(CommandOrControl|CmdOrCtrl|Cmd|Command|Ctrl|Control|Alt|Option|Shift|Super|Meta))*\+([A-Z]|[0-9]|F([1-9]|1[0-9]|2[0-4])|Space|Tab|Enter|Backspace|Delete|Escape|Esc)$/;
286
320
 
287
321
  const voiceSetHotkey = z.object({
288
- accelerator: z.string().regex(VOICE_ACCELERATOR_RE),
322
+ accelerator: z.string().max(128).regex(VOICE_ACCELERATOR_RE),
289
323
  mode: z.enum(['hold', 'toggle']),
290
324
  global: z.boolean(),
291
325
  schemaVersion: z.number().int().optional(),
@@ -357,7 +391,9 @@ const repoAnalyze = z.object({
357
391
 
358
392
  // Plugin install: mirrors pluginInstall.cjs SLUG_RE + length cap. Defense in
359
393
  // depth — install() re-checks; the schema rejects earlier.
360
- const PLUGIN_SLUG_RE = /^[a-z0-9\-/]+$/;
394
+ // Leading char must be alphanumeric — a `-` prefix would be parsed as a CLI flag
395
+ // by `claude plugin install` (argv injection). Mirrors pluginInstall.cjs SLUG_RE.
396
+ const PLUGIN_SLUG_RE = /^[a-z0-9][a-z0-9\-/]*$/;
361
397
  const PLUGIN_MKT_ADD_RE = /^[A-Za-z0-9][A-Za-z0-9._\-]*\/[A-Za-z0-9._\-]+$/;
362
398
  const pluginsInstall = z.object({
363
399
  slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
@@ -434,6 +470,10 @@ const SAS_GATED_READS = new Set([
434
470
  'cmd:history:aggregate',
435
471
  // subscribe initiates a live stream of session state/summary — sensitive.
436
472
  'cmd:session:subscribe',
473
+ // NOTE: cmd:exchanges:list is intentionally NOT allowlisted — webRemote.cjs has
474
+ // no dispatch handler for it, so an allowlist entry would only fail closed with
475
+ // an opaque reject. Re-add here together with the handler when remote exchanges
476
+ // are wired, so the allowlist always mirrors an actual capability.
437
477
  ]);
438
478
 
439
479
  const MUTATE_COMMANDS = new Set([
@@ -487,11 +527,7 @@ module.exports = {
487
527
  scheduleArchivePrd,
488
528
  scheduleRetagPrd,
489
529
  setConfigSchema,
490
- openInEditor,
491
- openExternal,
492
- openFileInEditor,
493
- openInFinder,
494
- openInTerminal,
530
+ shellOpen,
495
531
  archiveProject,
496
532
  historyAggregate,
497
533
  voiceSetHotkey,
@@ -512,6 +548,7 @@ module.exports = {
512
548
  memoryWrite,
513
549
  memoryDelete,
514
550
  memoryCreate,
551
+ memoryAggregate,
515
552
  agentMemoryList,
516
553
  agentMemoryGet,
517
554
  agentMemorySet,
@@ -520,6 +557,9 @@ module.exports = {
520
557
  watchersList,
521
558
  watchersRemove,
522
559
  watchersKillTab,
560
+ chatRun,
561
+ chatCancel,
562
+ exchangesList,
523
563
  },
524
564
  validated,
525
565
  };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * kgExchangePairing.cjs — helpers for enriching KG prompt entries with their
5
+ * corresponding exchange results (prompt+outcome pairs from exchanges.cjs).
6
+ *
7
+ * Pure functions + one async file loader. No electron dependency — directly
8
+ * testable with node:test.
9
+ */
10
+
11
+ const fsp = require('node:fs/promises');
12
+ const path = require('node:path');
13
+ const { encodeCwd } = require('./encodeCwd.cjs');
14
+
15
+ /**
16
+ * Stable lookup key for a prompt: lowercased, whitespace-collapsed, first 500
17
+ * chars. Both prompt-log entries and exchange records use this so matching is
18
+ * tolerant of minor whitespace differences (e.g. trailing newline in one source).
19
+ */
20
+ function normalizePromptKey(text) {
21
+ return String(text || '').trim().slice(0, 500).toLowerCase().replace(/\s+/g, ' ');
22
+ }
23
+
24
+ /**
25
+ * Build an in-memory index from an exchanges JSONL file for the given cwd.
26
+ * Returns Map<normalizedPromptKey, exchangeRecord> (last-write wins for
27
+ * duplicate prompts). Returns an empty Map when the file is absent or unreadable
28
+ * so callers always fall back gracefully to prompt-only behavior.
29
+ *
30
+ * @param {string} exchangesDir absolute path to the exchanges directory
31
+ * @param {string} cwd project working directory
32
+ * @returns {Promise<Map<string, object>>}
33
+ */
34
+ async function loadExchangeIndex(exchangesDir, cwd) {
35
+ const filePath = path.join(exchangesDir, `${encodeCwd(cwd)}.jsonl`);
36
+ try {
37
+ const raw = await fsp.readFile(filePath, 'utf8');
38
+ const index = new Map();
39
+ for (const line of raw.split('\n')) {
40
+ const t = line.trim();
41
+ if (!t) continue;
42
+ try {
43
+ const rec = JSON.parse(t);
44
+ if (rec && rec.prompt) {
45
+ index.set(normalizePromptKey(rec.prompt), rec);
46
+ }
47
+ } catch { /* skip malformed lines */ }
48
+ }
49
+ return index;
50
+ } catch {
51
+ // File missing or unreadable — fall back to prompt-only; never block ingest.
52
+ return new Map();
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Enrich prompt-log entries with result/summary from the exchange index.
58
+ * Returns a new array; entries without a match are returned unchanged.
59
+ *
60
+ * @param {Array<{ts: string, prompt: string, [key: string]: any}>} entries
61
+ * @param {Map<string, object>} exchangeIndex from loadExchangeIndex()
62
+ * @returns {Array<object>}
63
+ */
64
+ function enrichEntries(entries, exchangeIndex) {
65
+ return entries.map((entry) => {
66
+ const key = normalizePromptKey(entry.prompt);
67
+ const exchange = exchangeIndex.get(key);
68
+ if (exchange && (exchange.result || exchange.summary)) {
69
+ return { ...entry, result: exchange.result, summary: exchange.summary };
70
+ }
71
+ return entry;
72
+ });
73
+ }
74
+
75
+ module.exports = { normalizePromptKey, loadExchangeIndex, enrichEntries };
@@ -64,4 +64,10 @@ function classifyRunOutcome(logPath) {
64
64
  }
65
65
  }
66
66
 
67
- module.exports = { claudePidAlive, classifyRunOutcome };
67
+ // Max times an orphaned job may be re-queued before giving up (marking failed).
68
+ // Single source of truth: both the in-app reaper (scheduler.cjs) and the external
69
+ // offline watchdog (watchdogHelpers.cjs) import this so their give-up budgets can
70
+ // never drift apart (they increment the SAME j.orphanRetries field).
71
+ const ORPHAN_REQUEUE_CAP = 5;
72
+
73
+ module.exports = { claudePidAlive, classifyRunOutcome, ORPHAN_REQUEUE_CAP };