claude-code-session-manager 0.35.17 → 0.36.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.
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  const { ipcMain, shell } = require('electron');
17
+ const fs = require('node:fs');
17
18
  const fsp = require('node:fs/promises');
18
19
  const path = require('node:path');
19
20
  const os = require('node:os');
@@ -21,6 +22,7 @@ const os = require('node:os');
21
22
  const { z } = require('zod');
22
23
  const { assertInsideHome } = require('./lib/insideHome.cjs');
23
24
  const { expandHome } = require('./lib/expandHome.cjs');
25
+ const { schemas } = require('./ipcSchemas.cjs');
24
26
 
25
27
  /**
26
28
  * Validates that the path is under the home directory. Returns the realpath
@@ -238,6 +240,51 @@ async function renameEntry(oldPath, newName) {
238
240
  }
239
241
  }
240
242
 
243
+ // Pure name generator for files:duplicate — `<stem>-copy<ext>`, then
244
+ // `<stem>-copy-2<ext>`, `-copy-3`, … `exists(fullPath)` is injected so tests
245
+ // can probe collisions without touching the filesystem.
246
+ const DUPLICATE_MAX_ATTEMPTS = 20;
247
+ function duplicateNameFor(dir, base, exists) {
248
+ const ext = path.extname(base);
249
+ const stem = base.slice(0, base.length - ext.length);
250
+ for (let i = 0; i < DUPLICATE_MAX_ATTEMPTS; i++) {
251
+ const candidate = i === 0 ? `${stem}-copy${ext}` : `${stem}-copy-${i + 1}${ext}`;
252
+ if (!exists(path.join(dir, candidate))) return { ok: true, name: candidate };
253
+ }
254
+ return { ok: false, error: 'Too many copies of this file already exist' };
255
+ }
256
+
257
+ async function duplicateEntry(filePath) {
258
+ let resolved;
259
+ try { resolved = validateHomePath(filePath); }
260
+ catch (e) { return { ok: false, error: e.message }; }
261
+ try { rejectCredentials(resolved); }
262
+ catch (e) { return { ok: false, error: e.message }; }
263
+
264
+ let st;
265
+ try { st = await fsp.stat(resolved); }
266
+ catch (e) { return { ok: false, error: e.message }; }
267
+ if (st.isDirectory()) return { ok: false, error: 'Cannot duplicate a directory' };
268
+
269
+ const dir = path.dirname(resolved);
270
+ const base = path.basename(resolved);
271
+ const nameResult = duplicateNameFor(dir, base, (full) => {
272
+ try { fs.accessSync(full); return true; } catch { return false; }
273
+ });
274
+ if (!nameResult.ok) return nameResult;
275
+
276
+ const target = path.join(dir, nameResult.name);
277
+ try { validateHomePath(target); } catch (e) { return { ok: false, error: e.message }; }
278
+ try { rejectCredentials(target); } catch (e) { return { ok: false, error: e.message }; }
279
+
280
+ try {
281
+ await fsp.copyFile(resolved, target, fs.constants.COPYFILE_EXCL);
282
+ return { ok: true, path: target, error: null };
283
+ } catch (e) {
284
+ return { ok: false, error: e.message };
285
+ }
286
+ }
287
+
241
288
  const CRITICAL_PATHS = new Set([os.homedir(), '/', '/usr', '/bin', '/etc', '/var', '/System', '/Applications']);
242
289
 
243
290
  async function deleteEntry(filePath) {
@@ -330,6 +377,10 @@ function registerFilesHandlers() {
330
377
  const { path: p, newName } = filesRename.parse(payload);
331
378
  return renameEntry(p, newName);
332
379
  });
380
+ ipcMain.handle('files:duplicate', (_e, payload) => {
381
+ const { path: p } = schemas.filesDuplicate.parse(payload);
382
+ return duplicateEntry(p);
383
+ });
333
384
  ipcMain.handle('files:delete', (_e, payload) => {
334
385
  const { path: p } = filesPath.parse(payload);
335
386
  return deleteEntry(p);
@@ -353,4 +404,6 @@ module.exports = {
353
404
  createEntry,
354
405
  renameEntry,
355
406
  deleteEntry,
407
+ duplicateEntry,
408
+ duplicateNameFor,
356
409
  };
@@ -317,7 +317,7 @@ async function aggregate(req) {
317
317
  try {
318
318
  projectDirs = await fsp.readdir(PROJECTS_DIR, { withFileTypes: true });
319
319
  } catch {
320
- return { rows: [], partial: false, truncated: false, scannedMs: Date.now() - t0 };
320
+ return { rows: [], partial: false, truncated: false, scannedMs: Date.now() - t0, cacheSavingsUsd: 0 };
321
321
  }
322
322
 
323
323
  outer:
@@ -48,6 +48,7 @@ const agentMemory = require('./agentMemory.cjs');
48
48
  const git = require('./git.cjs');
49
49
  const superagent = require('./superagent.cjs');
50
50
  const filesIpc = require('./files.cjs');
51
+ const { registerDocEditHandlers } = require('./docEdit.cjs');
51
52
  const searchIpc = require('./search.cjs');
52
53
  const repoAnalyzer = require('./repoAnalyzer.cjs');
53
54
  const hivesIpc = require('./hives.cjs');
@@ -749,6 +750,7 @@ agentMemory.registerAgentMemoryHandlers();
749
750
  git.register(ipcMain);
750
751
  superagent.registerSuperAgentHandlers();
751
752
  filesIpc.registerFilesHandlers();
753
+ registerDocEditHandlers();
752
754
  searchIpc.registerSearchHandlers();
753
755
  repoAnalyzer.register(ipcMain);
754
756
  hivesIpc.registerHiveHandlers();
@@ -351,6 +351,14 @@ const memoryCreate = z.object({
351
351
  description: z.string().max(2048).optional(),
352
352
  }).strict();
353
353
 
354
+ // memory:stale — deterministic staleness scorer (PRD 601). `cwd`, when given,
355
+ // is only used for the dead-ref existence check and is re-validated via
356
+ // config.validatePath in memoryTool.cjs before use.
357
+ const memoryStale = z.object({
358
+ workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
359
+ cwd: z.string().max(4096).optional(),
360
+ }).strict();
361
+
354
362
  // memory:aggregate — Memory Clusters (PRD 356). `workspace` here is already
355
363
  // the encoded cwd slug (memoryAggregate.cjs reads directly from
356
364
  // ~/.claude/projects/<workspace>/memory/), same regex as the other memory:*
@@ -401,6 +409,19 @@ const exchangesList = z.object({
401
409
  offset: z.number().int().min(0).max(100000).optional(),
402
410
  }).strict();
403
411
 
412
+ // ──────────────────────────────────────────── Files (duplicate)
413
+ // files:duplicate — the rest of files:* keeps its schemas local to files.cjs;
414
+ // this one lives here per PRD 638 so it's reusable without importing files.cjs.
415
+ const filesDuplicate = z.object({ path: z.string().min(1).max(4096) });
416
+
417
+ // ──────────────────────────────────────────── Doc Edit (PRD 638 rewrite runner)
418
+ // docedit:run — consumed by docEdit.cjs's registerDocEditHandlers.
419
+ const docEditRun = z.object({
420
+ path: z.string().min(1).max(4096),
421
+ before: z.string().min(1).max(8000),
422
+ instruction: z.string().min(1).max(2000),
423
+ }).strict();
424
+
404
425
  // ──────────────────────────────────────────── Chat runner (PRD 319)
405
426
  // Prompt cap: 100 KiB. Matches a generous interactive message budget while
406
427
  // preventing accidental megabyte pastes from reaching claude -p.
@@ -705,6 +726,7 @@ module.exports = {
705
726
  memoryDelete,
706
727
  memoryCreate,
707
728
  memoryAggregate,
729
+ memoryStale,
708
730
  agentMemoryList,
709
731
  agentMemoryGet,
710
732
  agentMemorySet,
@@ -713,6 +735,8 @@ module.exports = {
713
735
  watchersList,
714
736
  watchersRemove,
715
737
  watchersKillTab,
738
+ filesDuplicate,
739
+ docEditRun,
716
740
  chatRun,
717
741
  chatCancel,
718
742
  chatProbeContext,
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Pure, deterministic staleness scorer for workspace memories. No fs/electron
5
+ * access — all IO is injected via `entries` (pre-read) and `existsPath`
6
+ * (predicate), so this module is unit-testable with zero mocking.
7
+ */
8
+
9
+ const DAY_MS = 86_400_000;
10
+ const AGE_STALE_DAYS = 90;
11
+ const MAX_DEAD_REF_CANDIDATES = 20;
12
+
13
+ const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
14
+ // Backtick-quoted repo-path-shaped tokens, optionally suffixed with a :line.
15
+ const PATH_TOKEN_RE = /`([A-Za-z0-9_./-]+\.[A-Za-z0-9]{1,5}(?::\d+)?)`/g;
16
+
17
+ const KNOWN_SOURCE_EXTENSIONS = new Set([
18
+ 'js', 'jsx', 'ts', 'tsx', 'cjs', 'mjs', 'json', 'md', 'py', 'rb', 'go', 'rs',
19
+ 'java', 'c', 'h', 'cpp', 'hpp', 'css', 'scss', 'html', 'yml', 'yaml', 'sh',
20
+ 'txt', 'toml', 'lock', 'sql', 'php', 'swift', 'kt', 'vue',
21
+ ]);
22
+
23
+ function stripName(name) {
24
+ return name.replace(/\.md$/, '');
25
+ }
26
+
27
+ /**
28
+ * Extract dead-ref candidate paths from a memory body.
29
+ * Complexity: O(body length) — one regex pass, then O(candidates) filtering.
30
+ */
31
+ function extractCandidates(body) {
32
+ const seen = new Set();
33
+ const out = [];
34
+ let m;
35
+ PATH_TOKEN_RE.lastIndex = 0;
36
+ while ((m = PATH_TOKEN_RE.exec(body)) !== null) {
37
+ if (out.length >= MAX_DEAD_REF_CANDIDATES) break;
38
+ let token = m[1].replace(/:\d+$/, '');
39
+ if (token.startsWith('/') || token.startsWith('~')) continue; // not repo-relative
40
+ const ext = token.includes('.') ? token.split('.').pop().toLowerCase() : '';
41
+ const hasSlash = token.includes('/');
42
+ if (!hasSlash && !KNOWN_SOURCE_EXTENSIONS.has(ext)) continue;
43
+ if (seen.has(token)) continue;
44
+ seen.add(token);
45
+ out.push(token);
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * scoreMemories({ entries, now, existsPath })
52
+ *
53
+ * entries: Array<{ name, mtimeMs, body }>
54
+ * now: ms epoch
55
+ * existsPath: (relPath: string) => boolean
56
+ *
57
+ * Complexity: O(total body bytes) — a single pass per entry to extract
58
+ * wikilink targets and dead-ref candidates; the inbound-link fold is
59
+ * O(entries + total links), NOT O(n^2) substring scans.
60
+ */
61
+ function scoreMemories({ entries, now, existsPath }) {
62
+ // Signal 2 (pass 1): per-entry set of wikilink targets, deduped so repeated
63
+ // links to the same slug within one body count once toward that slug's
64
+ // inbound count.
65
+ const perEntryTargets = entries.map((e) => {
66
+ const targets = new Set();
67
+ let m;
68
+ WIKILINK_RE.lastIndex = 0;
69
+ while ((m = WIKILINK_RE.exec(e.body)) !== null) {
70
+ targets.add(m[1].trim());
71
+ }
72
+ return targets;
73
+ });
74
+
75
+ const inboundCounts = new Map();
76
+ entries.forEach((e, i) => {
77
+ const selfSlug = stripName(e.name);
78
+ for (const target of perEntryTargets[i]) {
79
+ if (target === selfSlug) continue; // a memory linking itself isn't an inbound reference
80
+ inboundCounts.set(target, (inboundCounts.get(target) || 0) + 1);
81
+ }
82
+ });
83
+
84
+ return entries.map((e) => {
85
+ const ageDays = Math.floor((now - e.mtimeMs) / DAY_MS);
86
+ const slug = stripName(e.name);
87
+ const inboundLinks = inboundCounts.get(slug) || 0;
88
+
89
+ const candidates = extractCandidates(e.body);
90
+ const deadRefs = candidates.filter((c) => !existsPath(c));
91
+
92
+ const reasons = [];
93
+ if (deadRefs.length > 0) {
94
+ reasons.push(
95
+ deadRefs.length === 1
96
+ ? 'references 1 path that no longer exists'
97
+ : `references ${deadRefs.length} paths that no longer exist`
98
+ );
99
+ }
100
+ const oldAndUnlinked = ageDays > AGE_STALE_DAYS && inboundLinks === 0;
101
+ if (oldAndUnlinked) {
102
+ reasons.push('90+ days old with no inbound links');
103
+ }
104
+
105
+ return {
106
+ name: e.name,
107
+ ageDays,
108
+ inboundLinks,
109
+ deadRefs,
110
+ stale: deadRefs.length > 0 || oldAndUnlinked,
111
+ reasons,
112
+ };
113
+ });
114
+ }
115
+
116
+ module.exports = { scoreMemories };
@@ -31,6 +31,7 @@ const os = require('node:os');
31
31
  const config = require('./config.cjs');
32
32
 
33
33
  const { MEMORY_SLUG_RE: SLUG_RE } = require('./lib/memorySlug.cjs');
34
+ const { scoreMemories } = require('./lib/memoryStale.cjs');
34
35
 
35
36
  const MAX_FILE_BYTES = 1024 * 1024; // 1 MiB
36
37
  const MAX_ENTRIES = 1000;
@@ -192,6 +193,52 @@ async function create({ workspace, name, description }) {
192
193
  return await write({ workspace: ws, name, content: fm });
193
194
  }
194
195
 
196
+ /**
197
+ * Deterministic, zero-LLM-cost staleness report for a workspace's memories.
198
+ * `cwd`, when supplied, is validated through config.validatePath (same
199
+ * home-boundary contract as every other path in this file) and used only for
200
+ * the dead-ref existence check. Absent/invalid cwd disables dead-ref
201
+ * detection entirely rather than mass-flagging memories.
202
+ */
203
+ async function stale({ workspace, cwd }) {
204
+ const ws = validWorkspaceName(workspace) ? workspace : 'default';
205
+ const dir = workspaceDir(ws);
206
+ const r = await config.listDir(dir, { filesOnly: true });
207
+ if (!r.ok) {
208
+ return { entries: [], workspace: ws, error: r.error };
209
+ }
210
+ const names = r.entries.map((e) => e.name).filter((n) => SLUG_RE.test(n)).sort();
211
+
212
+ let baseDir = null;
213
+ if (typeof cwd === 'string' && cwd) {
214
+ try {
215
+ const validated = config.validatePath(cwd);
216
+ if (fs.existsSync(validated) && fs.statSync(validated).isDirectory()) {
217
+ baseDir = validated;
218
+ }
219
+ } catch {
220
+ baseDir = null;
221
+ }
222
+ }
223
+ const existsPath = baseDir
224
+ ? (relPath) => {
225
+ const full = path.resolve(baseDir, relPath);
226
+ if (full !== baseDir && !full.startsWith(baseDir + path.sep)) return false;
227
+ return fs.existsSync(full);
228
+ }
229
+ : () => true;
230
+
231
+ const entries = [];
232
+ for (const name of names) {
233
+ const abs = path.join(dir, name);
234
+ const r2 = await config.readText(abs);
235
+ if (!r2.exists) continue;
236
+ entries.push({ name, mtimeMs: r2.mtimeMs, body: r2.text });
237
+ }
238
+
239
+ return { entries: scoreMemories({ entries, now: Date.now(), existsPath }), workspace: ws, error: null };
240
+ }
241
+
195
242
  function registerMemoryHandlers() {
196
243
  const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
197
244
  ipcMain.handle('memory:list', v(s.memoryList, list));
@@ -199,6 +246,7 @@ function registerMemoryHandlers() {
199
246
  ipcMain.handle('memory:write', v(s.memoryWrite, write));
200
247
  ipcMain.handle('memory:delete', v(s.memoryDelete, deleteEntry));
201
248
  ipcMain.handle('memory:create', v(s.memoryCreate, create));
249
+ ipcMain.handle('memory:stale', v(s.memoryStale, stale));
202
250
  }
203
251
 
204
252
  module.exports = {
@@ -207,4 +255,5 @@ module.exports = {
207
255
  // exported for tests
208
256
  memoryRoot,
209
257
  workspaceDir,
258
+ stale,
210
259
  };
package/src/main/pty.cjs CHANGED
@@ -188,19 +188,19 @@ class PtyManager {
188
188
  // Tab was removed or never existed — tell the renderer so it can surface
189
189
  // "skipped" feedback rather than silently dropping the write.
190
190
  sendIfAlive(this.window, 'pty:write-error', { tabId, reason: 'no-pty' });
191
- return;
191
+ return { ok: false, reason: 'no-pty' };
192
192
  }
193
193
  try {
194
194
  s.proc.write(data);
195
+ return { ok: true };
195
196
  } catch (err) {
196
197
  // node-pty throws synchronously (or the underlying net.Socket emits an
197
198
  // error that node-pty re-throws) when writing to an exited process.
198
199
  // Catch here so the uncaught-exception handler never sees it, and notify
199
200
  // the renderer to surface "skipped" feedback.
200
- sendIfAlive(this.window, 'pty:write-error', {
201
- tabId,
202
- reason: String(err?.message || 'write-failed'),
203
- });
201
+ const reason = String(err?.message || 'write-failed');
202
+ sendIfAlive(this.window, 'pty:write-error', { tabId, reason });
203
+ return { ok: false, reason };
204
204
  }
205
205
  }
206
206
 
@@ -189,6 +189,11 @@ let _destroyed = false; // set at app shutdown to stop reconnect loops
189
189
  // E2E session state — reset on each new WS connection.
190
190
  // .state: 'idle' | 'pending_sas' | 'authenticated' | 'failed'
191
191
  let _e2e = makeState();
192
+ // Browser SPKI pubkey (base64url) that produced the current _e2e session, and the
193
+ // deviceId it was received on — tracked so a manual SAS confirmation can pin it to
194
+ // that device's `verifiedPeerPubKey` for auto-trust on future reconnects.
195
+ let _e2ePeerPubKey = null;
196
+ let _e2eDeviceId = null;
192
197
 
193
198
  // ─── Config helpers ───────────────────────────────────────────────────────────
194
199
 
@@ -317,6 +322,8 @@ async function getDeviceTicket(deviceToken) {
317
322
 
318
323
  function resetE2e(state = 'idle') {
319
324
  _e2e = makeState(state);
325
+ _e2ePeerPubKey = null;
326
+ _e2eDeviceId = null;
320
327
  }
321
328
 
322
329
  // ─── WebSocket lifecycle ──────────────────────────────────────────────────────
@@ -781,6 +788,19 @@ async function maybeSummarize(w) {
781
788
  });
782
789
  }
783
790
 
791
+ /**
792
+ * Shared finish-up for any path that transitions _e2e into 'authenticated'
793
+ * (manual SAS confirmation or pinned-key auto-trust): log, broadcast the new
794
+ * status, and flush the session list immediately rather than waiting for the
795
+ * next SESSION_LIST_PUSH_MS tick.
796
+ */
797
+ function finalizeE2eAuthenticated(logMessage) {
798
+ logs.writeLine({ scope: 'webRemote', level: 'info', message: logMessage });
799
+ broadcastStatus();
800
+ _lastSessionListJson = null;
801
+ pushSessionList().catch(() => {});
802
+ }
803
+
784
804
  // ─── Message handling & command dispatch ─────────────────────────────────────
785
805
 
786
806
  async function handleMessage(raw, device) {
@@ -883,6 +903,18 @@ async function handleMessage(raw, device) {
883
903
  broadcastStatus();
884
904
  return;
885
905
  }
906
+ _e2ePeerPubKey = browserPubKey;
907
+ _e2eDeviceId = device.deviceId;
908
+ // TOFU pinning: if this exact (deviceId, browserPubKey) pair was already
909
+ // manually SAS-confirmed once, skip pending_sas and auto-authenticate — the
910
+ // user already verified this browser. A different/first-seen pubKey always
911
+ // falls through to the manual pending_sas flow below, unchanged.
912
+ if (device.verifiedPeerPubKey && device.verifiedPeerPubKey === browserPubKey) {
913
+ _e2e = makeState('authenticated', sessionKey, null);
914
+ finalizeE2eAuthenticated('E2E session auto-authenticated — pinned browser key matched');
915
+ respond(id, undefined, 'e2e:ready');
916
+ return;
917
+ }
886
918
  _e2e = makeState('pending_sas', sessionKey, pendingSas);
887
919
  logs.writeLine({ scope: 'webRemote', level: 'info', message: 'E2E session key established — SAS pending confirmation' });
888
920
  broadcastStatus();
@@ -1047,8 +1079,7 @@ function getDispatchMap() {
1047
1079
 
1048
1080
  'cmd:pty:write': async (payload) => {
1049
1081
  const parsed = schemas.ptyWrite.parse(payload);
1050
- ptyManager.write(parsed);
1051
- return { ok: true };
1082
+ return ptyManager.write(parsed);
1052
1083
  },
1053
1084
 
1054
1085
  'cmd:pty:resize': async (payload) => {
@@ -1167,6 +1198,9 @@ async function pair(otp) {
1167
1198
  deviceName: `Device (paired ${new Date().toISOString().slice(0, 10)})`,
1168
1199
  issuedAt: new Date().toISOString(),
1169
1200
  lastConnectedAt: null,
1201
+ // Set once a browser's SAS is manually confirmed; TOFU-pinned for auto-trust
1202
+ // on future reconnects from the same (deviceId, browserPubKey) pair.
1203
+ verifiedPeerPubKey: null,
1170
1204
  }];
1171
1205
 
1172
1206
  await saveConfig({ ...cfg, devices });
@@ -1234,13 +1268,23 @@ function registerRemoteHandlers() {
1234
1268
  return { ok: false, error };
1235
1269
  }
1236
1270
  _e2e = next;
1237
- logs.writeLine({ scope: 'webRemote', level: 'info', message: 'E2E session authenticated SAS confirmed by user' });
1238
- broadcastStatus();
1271
+ // Pin this browser's pubkey to the device so future reconnects with the same
1272
+ // key auto-authenticate (see e2e:hello above) instead of re-prompting forever.
1273
+ if (_e2ePeerPubKey && _e2eDeviceId) {
1274
+ try {
1275
+ const cfg = await loadConfig();
1276
+ const devices = (cfg.devices || []).map((d) =>
1277
+ d.deviceId === _e2eDeviceId ? { ...d, verifiedPeerPubKey: _e2ePeerPubKey } : d
1278
+ );
1279
+ await saveConfig({ ...cfg, devices });
1280
+ } catch (e) {
1281
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'failed to persist pinned peer pubkey', meta: { error: e?.message } });
1282
+ }
1283
+ }
1239
1284
  // Flush the session list immediately — the push loop was suppressed while
1240
1285
  // state !== 'authenticated', so the mobile app would otherwise wait up to
1241
1286
  // SESSION_LIST_PUSH_MS for the first useful data.
1242
- _lastSessionListJson = null;
1243
- pushSessionList().catch(() => {});
1287
+ finalizeE2eAuthenticated('E2E session authenticated — SAS confirmed by user');
1244
1288
  return { ok: true };
1245
1289
  });
1246
1290
 
@@ -1334,4 +1378,11 @@ module.exports = {
1334
1378
  registerRemoteHandlers,
1335
1379
  init,
1336
1380
  destroy,
1381
+ // Test-only internals — not part of the IPC surface. Lets unit tests drive
1382
+ // e2e:hello / SAS-pinning transitions without a real relay WS connection.
1383
+ _internal: {
1384
+ handleMessage,
1385
+ resetE2e,
1386
+ getE2eState: () => _e2e,
1387
+ },
1337
1388
  };
@@ -583,6 +583,8 @@ export interface FilesWriteResult { ok: boolean; error: string | null }
583
583
  export interface FilesCreateResult { ok: boolean; path?: string; error: string | null }
584
584
  export interface FilesRenameResult { ok: boolean; newPath?: string; error: string | null }
585
585
  export interface FilesDeleteResult { ok: boolean; error: string | null }
586
+ export interface FilesDuplicateResult { ok: boolean; path?: string; error?: string | null }
587
+ export interface DocEditResult { ok: boolean; after?: string; error?: string }
586
588
 
587
589
  export interface SearchFileEntry {
588
590
  name: string;
@@ -796,6 +798,21 @@ export interface MemoryAggregateResult {
796
798
  error?: string;
797
799
  }
798
800
 
801
+ export interface MemoryStaleEntry {
802
+ name: string;
803
+ ageDays: number;
804
+ inboundLinks: number;
805
+ deadRefs: string[];
806
+ stale: boolean;
807
+ reasons: string[];
808
+ }
809
+
810
+ export interface MemoryStaleResult {
811
+ entries: MemoryStaleEntry[];
812
+ workspace: string;
813
+ error: string | null;
814
+ }
815
+
799
816
  // ────────────────────────────────────────────── Per-subagent memory
800
817
  // Stored at ~/.claude/session-manager/agent-memory/<agentId>.json. Keyed by
801
818
  // agent name (the .md filename in ~/.claude/agents/), not by workspace cwd.
@@ -1185,6 +1202,10 @@ export interface SessionManagerAPI {
1185
1202
  create: (parentPath: string, name: string, kind: 'file' | 'folder') => Promise<FilesCreateResult>;
1186
1203
  rename: (path: string, newName: string) => Promise<FilesRenameResult>;
1187
1204
  delete: (path: string) => Promise<FilesDeleteResult>;
1205
+ duplicate: (path: string) => Promise<FilesDuplicateResult>;
1206
+ };
1207
+ docEdit: {
1208
+ run: (payload: { path: string; before: string; instruction: string }) => Promise<DocEditResult>;
1188
1209
  };
1189
1210
  /** Consolidated shell open/reveal. One method, discriminated on `as`, replaces
1190
1211
  * the former app.openIn* / app.openExternal / files.openExternal / files.showInFinder.
@@ -1301,6 +1322,8 @@ export interface SessionManagerAPI {
1301
1322
  create: (name: string, description?: string, workspace?: string) => Promise<MemoryMutationResult>;
1302
1323
  /** Aggregate workspace memories into semantic clusters. `refresh:true` fires a cost-gated `claude -p` pass; otherwise returns the cache only. */
1303
1324
  aggregate: (workspace: string, refresh?: boolean) => Promise<MemoryAggregateResult>;
1325
+ /** Deterministic, zero-LLM-cost staleness report. `cwd` (optional) scopes the dead-repo-ref check. */
1326
+ stale: (workspace?: string, cwd?: string) => Promise<MemoryStaleResult>;
1304
1327
  };
1305
1328
  agentMemory: {
1306
1329
  /** List all memory entries for one subagent. Sorted newest first. */
@@ -224,6 +224,10 @@ contextBridge.exposeInMainWorld('api', {
224
224
  create: (parentPath, name, kind) => ipcRenderer.invoke('files:create', { parentPath, name, kind }),
225
225
  rename: (path, newName) => ipcRenderer.invoke('files:rename', { path, newName }),
226
226
  delete: (path) => ipcRenderer.invoke('files:delete', { path }),
227
+ duplicate: (path) => ipcRenderer.invoke('files:duplicate', { path }),
228
+ },
229
+ docEdit: {
230
+ run: (payload) => ipcRenderer.invoke('docedit:run', payload),
227
231
  },
228
232
  // Consolidated shell open/reveal — see shell:open in index.cjs.
229
233
  // as: 'editor' | 'fileInEditor' | 'finder' | 'terminal' | 'external' | 'openPath' | 'revealPath'
@@ -307,6 +311,8 @@ contextBridge.exposeInMainWorld('api', {
307
311
  },
308
312
  aggregate: (workspace, refresh) =>
309
313
  ipcRenderer.invoke('memory:aggregate', refresh ? { workspace, refresh: true } : { workspace }),
314
+ stale: (workspace, cwd) =>
315
+ ipcRenderer.invoke('memory:stale', { ...(workspace ? { workspace } : {}), ...(cwd ? { cwd } : {}) }),
310
316
  },
311
317
  agentMemory: {
312
318
  list: (agentId) => ipcRenderer.invoke('agent-memory:list', { agentId }),