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.
- package/dist/assets/{TiptapBody-BM9Kz1Nm.js → TiptapBody-D5b7nejd.js} +1 -1
- package/dist/assets/index-CkiGRskz.css +32 -0
- package/dist/assets/{index-DuoC6oCy.js → index-DrzirIUy.js} +1011 -1008
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/docEdit.test.cjs +82 -0
- package/src/main/__tests__/memoryStale.test.cjs +88 -0
- package/src/main/__tests__/pty-write-result.test.cjs +46 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +4 -5
- package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +181 -0
- package/src/main/chatRunner.cjs +11 -6
- package/src/main/docEdit.cjs +133 -0
- package/src/main/files.cjs +53 -0
- package/src/main/historyAggregator.cjs +1 -1
- package/src/main/index.cjs +2 -0
- package/src/main/ipcSchemas.cjs +24 -0
- package/src/main/lib/memoryStale.cjs +116 -0
- package/src/main/memoryTool.cjs +49 -0
- package/src/main/pty.cjs +5 -5
- package/src/main/webRemote.cjs +57 -6
- package/src/preload/api.d.ts +23 -0
- package/src/preload/index.cjs +6 -0
- package/dist/assets/index-DX2w2YhJ.css +0 -32
package/src/main/files.cjs
CHANGED
|
@@ -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:
|
package/src/main/index.cjs
CHANGED
|
@@ -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();
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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 };
|
package/src/main/memoryTool.cjs
CHANGED
|
@@ -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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
package/src/main/webRemote.cjs
CHANGED
|
@@ -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
|
-
|
|
1238
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/preload/api.d.ts
CHANGED
|
@@ -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. */
|
package/src/preload/index.cjs
CHANGED
|
@@ -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 }),
|