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
|
@@ -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 };
|
package/src/main/index.cjs
CHANGED
|
@@ -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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -141,29 +141,22 @@ const scheduleRetagPrd = z.object({
|
|
|
141
141
|
// ──────────────────────────────────────────── Projects
|
|
142
142
|
const ENCODED_SLUG_RE = /^[A-Za-z0-9._-]+$/;
|
|
143
143
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|