claude-code-session-manager 0.8.6 → 0.10.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 (54) hide show
  1. package/README.md +95 -65
  2. package/dist/assets/{cssMode-DBg6nxUL.js → cssMode-DWlBzlpW.js} +1 -1
  3. package/dist/assets/{freemarker2-CyjUGY3f.js → freemarker2-Cgg83m-Z.js} +1 -1
  4. package/dist/assets/{handlebars-lhtCWqlB.js → handlebars-C4r4LOI9.js} +1 -1
  5. package/dist/assets/{html-egptHwbZ.js → html-DaxRI5sW.js} +1 -1
  6. package/dist/assets/htmlMode-Bu_8jtXo.js +1 -0
  7. package/dist/assets/{index-DjeqNwqn.js → index-C_tgFedf.js} +1115 -1081
  8. package/dist/assets/{index-DnLtSCQS.css → index-Dj3Db4OA.css} +1 -1
  9. package/dist/assets/{javascript-tZbiID3O.js → javascript-D5Ztx-Ej.js} +1 -1
  10. package/dist/assets/{jsonMode-BGtPN-L-.js → jsonMode-tfsgezVc.js} +1 -1
  11. package/dist/assets/{liquid-DvTeXhev.js → liquid-F2cD9OL0.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-D9xoxVlV.js → lspLanguageFeatures-Bz_Eih8F.js} +2 -2
  13. package/dist/assets/{mdx-BQ3Ja4wM.js → mdx-BPlD1clX.js} +1 -1
  14. package/dist/assets/{ort-wasm-simd-threaded.asyncify-CtKKja6V.wasm → ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm} +0 -0
  15. package/dist/assets/{python-C71RWXaP.js → python-B4gUOWNI.js} +1 -1
  16. package/dist/assets/{razor-w__Mkyns.js → razor-B6pMxVp1.js} +1 -1
  17. package/dist/assets/{tsMode-DOQLQDB3.js → tsMode-C9nq6cHi.js} +1 -1
  18. package/dist/assets/{typescript-DEiub2Jt.js → typescript-Do5Vtwxu.js} +1 -1
  19. package/dist/assets/{whisperWorker-QfIS0sPF.js → whisperWorker-CcsPqZUS.js} +19 -19
  20. package/dist/assets/{xml-RXkLQscS.js → xml-C0mTbVRp.js} +1 -1
  21. package/dist/assets/{yaml-C8HIpJku.js → yaml-D3sePJfA.js} +1 -1
  22. package/dist/index.html +2 -2
  23. package/package.json +18 -10
  24. package/screenshots/.gitkeep +0 -0
  25. package/screenshots/README-screenshots.md +13 -0
  26. package/src/main/config.cjs +47 -9
  27. package/src/main/historyAggregator.cjs +10 -5
  28. package/src/main/index.cjs +85 -14
  29. package/src/main/ipcSchemas.cjs +165 -3
  30. package/src/main/lib/claudeBin.cjs +39 -0
  31. package/src/main/lib/encodeCwd.cjs +19 -0
  32. package/src/main/lib/fileTail.cjs +35 -0
  33. package/src/main/lib/insideHome.cjs +38 -0
  34. package/src/main/lib/prdFrontmatter.cjs +51 -0
  35. package/src/main/lib/sendToRenderer.cjs +21 -0
  36. package/src/main/memoryTool.cjs +203 -0
  37. package/src/main/otelSettings.cjs +2 -7
  38. package/src/main/pluginInstall.cjs +129 -0
  39. package/src/main/pty.cjs +13 -29
  40. package/src/main/queueOps.cjs +404 -0
  41. package/src/main/scheduler/prdParser.cjs +135 -0
  42. package/src/main/scheduler.cjs +291 -250
  43. package/src/main/sessionsStore.cjs +2 -6
  44. package/src/main/supervisor.cjs +3 -35
  45. package/src/main/teams.cjs +95 -0
  46. package/src/main/transcripts.cjs +5 -7
  47. package/src/main/usage.cjs +8 -0
  48. package/src/main/voiceHotkey.cjs +13 -9
  49. package/src/main/voiceSettings.cjs +2 -9
  50. package/src/main/voiceWizard.cjs +4 -11
  51. package/src/main/watchers.cjs +18 -42
  52. package/src/preload/api.d.ts +153 -1
  53. package/src/preload/index.cjs +29 -0
  54. package/dist/assets/htmlMode-tPDeHGOB.js +0 -1
@@ -20,9 +20,12 @@ const ptySpawn = z.object({
20
20
 
21
21
  const ptyTabId = z.object({ tabId: z.string().min(1).max(128) });
22
22
 
23
+ // 64 KiB cap per pty:write — typewriter input is bounded; a renderer firing
24
+ // megabytes per call is either a bug or an attack. Block it at the boundary.
25
+ const PTY_WRITE_MAX_BYTES = 64 * 1024;
23
26
  const ptyWrite = z.object({
24
27
  tabId: z.string().min(1).max(128),
25
- data: z.string(),
28
+ data: z.string().max(PTY_WRITE_MAX_BYTES),
26
29
  });
27
30
 
28
31
  const ptyResize = z.object({
@@ -96,6 +99,34 @@ const scheduleReadLog = z.object({
96
99
  runId: z.string().regex(SCHEDULE_RUN_ID_RE),
97
100
  });
98
101
 
102
+ // PRD write: slug + body (≤256 KiB, matches PRD_WRITE_MAX_BYTES in scheduler.cjs).
103
+ const PRD_WRITE_MAX_BYTES = 256 * 1024;
104
+ const scheduleWritePrd = z.object({
105
+ slug: z.string().regex(SCHEDULE_SLUG_RE),
106
+ body: z.string().refine(
107
+ (s) => Buffer.byteLength(s, 'utf8') <= PRD_WRITE_MAX_BYTES,
108
+ `body must be ≤ ${PRD_WRITE_MAX_BYTES} bytes`,
109
+ ),
110
+ });
111
+
112
+ // Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
113
+ const scheduleArchivePrd = z.object({
114
+ slugs: z.array(z.string().regex(SCHEDULE_SLUG_RE)).min(1).max(500),
115
+ });
116
+
117
+ const scheduleRetagItem = z.object({
118
+ slug: z.string().regex(SCHEDULE_SLUG_RE),
119
+ parallelGroup: z.number().int().min(0).max(999).optional(),
120
+ estimateMinutes: z.number().int().min(1).max(100000).optional(),
121
+ }).refine(
122
+ (it) => it.parallelGroup !== undefined || it.estimateMinutes !== undefined,
123
+ 'at least one of parallelGroup or estimateMinutes is required',
124
+ );
125
+
126
+ const scheduleRetagPrd = z.object({
127
+ items: z.array(scheduleRetagItem).min(1).max(500),
128
+ });
129
+
99
130
  // ──────────────────────────────────────────── Projects
100
131
  const ENCODED_SLUG_RE = /^[A-Za-z0-9._-]+$/;
101
132
 
@@ -135,17 +166,125 @@ const setConfigSchema = z.object({
135
166
  }).optional(),
136
167
  }).strict();
137
168
 
169
+ // ──────────────────────────────────────────── Memory tool (Bundle C, cycle 3)
170
+ // Workspace-scoped markdown store at ~/.claude/session-manager/memories/<ws>/.
171
+ // Slug regex must match memoryTool.cjs SLUG_RE; workspace regex matches its
172
+ // encodeWorkspace() output (alphanumeric + dash) plus 'default'.
173
+ const MEMORY_WORKSPACE_RE = /^[a-zA-Z0-9-_]{1,256}$/;
174
+ const MEMORY_SLUG_RE = /^[a-z0-9-_]+\.md$/;
175
+ // 1 MiB hard cap — matches MAX_FILE_BYTES in memoryTool.cjs.
176
+ const MEMORY_MAX_BYTES = 1024 * 1024;
177
+
178
+ const memoryList = z.object({
179
+ workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
180
+ }).strict();
181
+
182
+ const memoryRead = z.object({
183
+ workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
184
+ name: z.string().regex(MEMORY_SLUG_RE),
185
+ }).strict();
186
+
187
+ const memoryWrite = z.object({
188
+ workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
189
+ name: z.string().regex(MEMORY_SLUG_RE),
190
+ content: z.string().max(MEMORY_MAX_BYTES),
191
+ }).strict();
192
+
193
+ const memoryDelete = z.object({
194
+ workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
195
+ name: z.string().regex(MEMORY_SLUG_RE),
196
+ }).strict();
197
+
198
+ const memoryCreate = z.object({
199
+ workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
200
+ name: z.string().regex(MEMORY_SLUG_RE),
201
+ description: z.string().max(2048).optional(),
202
+ }).strict();
203
+
138
204
  // ──────────────────────────────────────────── History
139
205
  const DATE_YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
140
206
 
141
207
  const historyAggregate = z.object({
142
208
  fromDate: z.string().regex(DATE_YYYY_MM_DD).optional(),
143
209
  toDate: z.string().regex(DATE_YYYY_MM_DD).optional(),
144
- }).optional().nullable();
210
+ }).nullish();
211
+
212
+ // ──────────────────────────────────────────── Voice (F1/F5/F7/F8)
213
+ // Mirrors voiceSettings.cjs isValidConfig / isValidDevicePref / isValid…
214
+ // validators (ad-hoc on disk). Schemas here gate the IPC boundary so a
215
+ // malformed renderer payload can never reach the file writer.
216
+ 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)$/;
217
+
218
+ const voiceSetHotkey = z.object({
219
+ accelerator: z.string().regex(VOICE_ACCELERATOR_RE),
220
+ mode: z.enum(['hold', 'toggle']),
221
+ global: z.boolean(),
222
+ schemaVersion: z.number().int().optional(),
223
+ }).passthrough();
224
+
225
+ const voiceSetDevicePref = z.object({
226
+ selectedDeviceId: z.string().max(256).nullable(),
227
+ selectedLabel: z.string().max(256).nullable(),
228
+ schemaVersion: z.number().int().optional(),
229
+ }).passthrough();
230
+
231
+ const voiceSetTurnDetector = z.object({
232
+ enabled: z.boolean(),
233
+ mode: z.enum(['audio', 'text', 'off']),
234
+ schemaVersion: z.number().int().optional(),
235
+ }).passthrough();
236
+
237
+ const voiceSetRecording = z.boolean();
238
+
239
+ // ──────────────────────────────────────────── Watchers
240
+ // watchers:add runs `spawn(command, { shell: true })` — second-highest blast
241
+ // radius after app:test-fire-hook. The 8 KiB cap on `command` is the same
242
+ // cap watchers.cjs uses internally; centralizing here so the schema is the
243
+ // injection fence rather than relying on the inline schemas in watchers.cjs.
244
+ const watchersAdd = z.object({
245
+ tabId: z.string().min(1).max(128),
246
+ label: z.string().max(256).optional().default(''),
247
+ command: z.string().min(1).max(8192),
248
+ cwd: z.string().max(4096).optional().nullable(),
249
+ });
250
+
251
+ const watchersList = z.object({ tabId: z.string().min(1).max(128) });
252
+ const watchersRemove = z.object({ watcherId: z.string().min(1).max(128) });
253
+ const watchersKillTab = z.object({ tabId: z.string().min(1).max(128) });
254
+
255
+ // ──────────────────────────────────────────── Hooks / git / plugins
256
+ // Free-form env: keys must be safe identifier shape (no '=' / NUL / weird
257
+ // unicode), values must be plain strings, both length-capped. We don't
258
+ // restrict the key set — Hooks can legitimately need any env name — but we
259
+ // do refuse anything that wouldn't survive a child_process env-block round
260
+ // trip.
261
+ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
262
+ const appTestFireHook = z.object({
263
+ command: z.string().min(1).max(16 * 1024),
264
+ env: z.record(z.string().regex(ENV_KEY_RE).max(256), z.string().max(8 * 1024))
265
+ .nullable()
266
+ .optional(),
267
+ payload: z.string().max(1 * 1024 * 1024).optional(),
268
+ timeoutMs: z.number().int().min(0).max(30_000).optional(),
269
+ }).passthrough();
270
+
271
+ const appGitBranch = z.object({
272
+ cwd: z.string().min(1).max(4096),
273
+ }).passthrough();
274
+
275
+ // Plugin install: mirrors pluginInstall.cjs SLUG_RE + length cap. Defense in
276
+ // depth — install() re-checks; the schema rejects earlier.
277
+ const PLUGIN_SLUG_RE = /^[a-z0-9\-/]+$/;
278
+ const pluginsInstall = z.object({
279
+ slug: z.string().regex(PLUGIN_SLUG_RE).min(1).max(128),
280
+ }).passthrough();
145
281
 
146
282
  /**
147
283
  * Wrap an IPC handler with schema validation. Returns a new handler that
148
- * parses the payload before calling the original.
284
+ * parses the payload before calling the original. On invalid payload throws
285
+ * a ZodError (caught by Electron's IPC harness → rejected promise). Existing
286
+ * call sites already rely on throw semantics for malformed input, so we keep
287
+ * that behavior for backwards compatibility.
149
288
  */
150
289
  function validated(schema, handler) {
151
290
  return (_event, payload) => {
@@ -155,6 +294,10 @@ function validated(schema, handler) {
155
294
  }
156
295
 
157
296
  module.exports = {
297
+ // Centralized slug regex — used by scheduler.cjs and queueOps.cjs for
298
+ // direct test()/match() containment checks alongside the zod parses.
299
+ SCHEDULE_SLUG_RE,
300
+ SCHEDULE_RUN_ID_RE,
158
301
  schemas: {
159
302
  ptySpawn,
160
303
  ptyTabId,
@@ -171,12 +314,31 @@ module.exports = {
171
314
  sessionsPayload,
172
315
  scheduleSlug,
173
316
  scheduleReadLog,
317
+ scheduleWritePrd,
318
+ scheduleArchivePrd,
319
+ scheduleRetagPrd,
174
320
  setConfigSchema,
175
321
  openInEditor,
176
322
  openInFinder,
177
323
  openInTerminal,
178
324
  archiveProject,
179
325
  historyAggregate,
326
+ voiceSetHotkey,
327
+ voiceSetDevicePref,
328
+ voiceSetTurnDetector,
329
+ voiceSetRecording,
330
+ appTestFireHook,
331
+ appGitBranch,
332
+ pluginsInstall,
333
+ memoryList,
334
+ memoryRead,
335
+ memoryWrite,
336
+ memoryDelete,
337
+ memoryCreate,
338
+ watchersAdd,
339
+ watchersList,
340
+ watchersRemove,
341
+ watchersKillTab,
180
342
  },
181
343
  validated,
182
344
  };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * claudeBin.cjs — shared resolver for the `claude` executable.
3
+ *
4
+ * Both scheduler.cjs and supervisor.cjs need to spawn `claude -p` jobs.
5
+ * Both used to duplicate this list. Single source of truth here.
6
+ *
7
+ * Returns the first executable candidate found, falling back to bare
8
+ * "claude" so spawn() can still try PATH lookup.
9
+ *
10
+ * Cached after first successful resolution. Process-lifetime.
11
+ */
12
+ 'use strict';
13
+
14
+ const fs = require('node:fs');
15
+ const path = require('node:path');
16
+ const os = require('node:os');
17
+
18
+ let cached = null;
19
+
20
+ function resolveClaudeBin() {
21
+ if (cached) return cached;
22
+ // Merged candidate list — was forked in scheduler vs pluginInstall before.
23
+ const home = os.homedir();
24
+ const candidates = [
25
+ path.join(home, '.claude', 'local', 'claude'), // Claude Code bundled install
26
+ path.join(home, '.local', 'bin', 'claude'), // user pip-style install
27
+ path.join(home, '.npm-global', 'bin', 'claude'), // user npm-global
28
+ '/usr/local/bin/claude',
29
+ '/opt/homebrew/bin/claude',
30
+ '/usr/bin/claude',
31
+ ];
32
+ for (const c of candidates) {
33
+ try { fs.accessSync(c, fs.constants.X_OK); cached = c; return c; } catch { /* */ }
34
+ }
35
+ cached = 'claude';
36
+ return cached;
37
+ }
38
+
39
+ module.exports = { resolveClaudeBin };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * encodeCwd.cjs — canonical "cwd to directory-safe slug" encoder.
3
+ *
4
+ * Mirrors Claude Code's transcript directory naming convention: replace every
5
+ * non-alphanumeric character with '-'. Used by:
6
+ * - transcripts.cjs to locate `~/.claude/projects/<encoded>/<sessionUuid>.jsonl`
7
+ * - memoryTool.cjs to derive the workspace label for memory storage
8
+ * - the renderer's Memory tab (purely advisory; main process is authoritative)
9
+ *
10
+ * Falls back to 'default' on null/blank input so callers always get a valid slug.
11
+ */
12
+ 'use strict';
13
+
14
+ function encodeCwd(cwd) {
15
+ if (!cwd || typeof cwd !== 'string' || !cwd.trim()) return 'default';
16
+ return cwd.replace(/[^a-zA-Z0-9]/g, '-');
17
+ }
18
+
19
+ module.exports = { encodeCwd };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * fileTail.cjs — read the last N bytes of a file as UTF-8.
3
+ *
4
+ * Three near-identical copies existed before: scheduler.cjs readTail (1011),
5
+ * supervisor.cjs readTailBytes (104), and an inline copy in scheduler's
6
+ * post-result watchdog (~792). Unified here. Callers MUST be in a synchronous
7
+ * context; intentionally sync because callers are inside polling intervals or
8
+ * exit handlers where async would add an unnecessary event-loop hop.
9
+ *
10
+ * Returns '' on any I/O error (missing file, permission denied, etc.) — the
11
+ * tail readers are best-effort log scanners, not data paths.
12
+ */
13
+ 'use strict';
14
+
15
+ const fs = require('node:fs');
16
+
17
+ function readTail(filePath, bytes) {
18
+ try {
19
+ const stat = fs.statSync(filePath);
20
+ const n = Math.min(stat.size, bytes);
21
+ if (n <= 0) return '';
22
+ const fd = fs.openSync(filePath, 'r');
23
+ try {
24
+ const buf = Buffer.alloc(n);
25
+ fs.readSync(fd, buf, 0, n, stat.size - n);
26
+ return buf.toString('utf8');
27
+ } finally {
28
+ fs.closeSync(fd);
29
+ }
30
+ } catch {
31
+ return '';
32
+ }
33
+ }
34
+
35
+ module.exports = { readTail };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * insideHome.cjs — security invariant: every renderer-controlled cwd must
3
+ * resolve (after realpath, symlink-safe) to a path inside the user's home
4
+ * directory. Four call sites used to reimplement this: pty.cjs spawn,
5
+ * watchers.cjs add, index.cjs checkInsideHome, ipcSchemas.cjs setConfigSchema
6
+ * defaultCwd refine. The check is a single chokepoint here.
7
+ *
8
+ * Returns { ok: true, realCwd } on success or { ok: false, error } on rejection.
9
+ * Realpath of the cwd is returned so callers can use it without recomputing.
10
+ *
11
+ * NOTE: schemas can't easily call this (zod refines are inline); they retain
12
+ * a simpler `startsWith` check that does NOT symlink-resolve. The runtime
13
+ * resolution here is the authoritative one — schemas are belt-and-suspenders.
14
+ */
15
+ 'use strict';
16
+
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+ const os = require('node:os');
20
+
21
+ function assertCwdInsideHome(cwd) {
22
+ if (typeof cwd !== 'string' || !cwd) {
23
+ return { ok: false, error: 'cwd must be a non-empty string' };
24
+ }
25
+ const home = os.homedir();
26
+ let realCwd;
27
+ try {
28
+ realCwd = fs.realpathSync(cwd);
29
+ } catch {
30
+ realCwd = path.resolve(cwd);
31
+ }
32
+ if (realCwd !== home && !realCwd.startsWith(home + path.sep)) {
33
+ return { ok: false, error: `cwd outside home directory: ${realCwd}` };
34
+ }
35
+ return { ok: true, realCwd };
36
+ }
37
+
38
+ module.exports = { assertCwdInsideHome };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * prdFrontmatter.cjs — minimal YAML frontmatter parser for scheduled PRD files.
3
+ *
4
+ * PRDs at `~/.claude/session-manager/scheduled-plans/prds/<NN>-<slug>.md` use
5
+ * a small documented frontmatter subset (title, cwd, estimateMinutes,
6
+ * parallelGroup, …). Two callers used to roll their own parser:
7
+ * - scheduler.cjs::parsePrdRaw — typed extraction of the known keys
8
+ * - queueOps.cjs::splitFrontmatter — linting; needs the raw map plus the
9
+ * line-count of the frontmatter region so warnings point at the right line
10
+ *
11
+ * This module owns the parse. Callers transform the raw map into whatever
12
+ * typed shape they need.
13
+ *
14
+ * Format:
15
+ * ---
16
+ * key: value
17
+ * ---
18
+ * body…
19
+ *
20
+ * - Keys must match /^[A-Za-z][A-Za-z0-9_]*$/ (alpha first; alphanum + _).
21
+ * - Values are read as strings; surrounding ' or " quotes are stripped.
22
+ * - Missing or malformed frontmatter → { fm: {}, body: raw, fmLineCount: 0 }.
23
+ */
24
+ 'use strict';
25
+
26
+ function splitFrontmatter(raw) {
27
+ if (typeof raw !== 'string' || !raw.startsWith('---\n')) {
28
+ return { fm: {}, body: raw, fmLineCount: 0 };
29
+ }
30
+ const end = raw.indexOf('\n---', 4);
31
+ if (end === -1) return { fm: {}, body: raw, fmLineCount: 0 };
32
+ const fmRaw = raw.slice(4, end);
33
+ const fm = {};
34
+ for (const line of fmRaw.split('\n')) {
35
+ const m = line.match(/^([A-Za-z][A-Za-z0-9_]*)\s*:\s*(.*)\s*$/);
36
+ if (!m) continue;
37
+ let v = m[2];
38
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
39
+ v = v.slice(1, -1);
40
+ }
41
+ fm[m[1]] = v;
42
+ }
43
+ return {
44
+ fm,
45
+ body: raw.slice(end + 4).replace(/^\n/, ''),
46
+ // +2 for the two `---` fences themselves
47
+ fmLineCount: fmRaw.split('\n').length + 2,
48
+ };
49
+ }
50
+
51
+ module.exports = { splitFrontmatter };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * sendToRenderer.cjs — `webContents.send` with a destroyed-window guard.
3
+ *
4
+ * Every IPC-broadcasting module was open-coding `if (window && !window.isDestroyed())`
5
+ * before invoking `webContents.send`. Forgetting the guard produces a hard
6
+ * crash when a broadcast lands during teardown. Centralized here so new
7
+ * broadcasters can't reintroduce the footgun.
8
+ */
9
+ 'use strict';
10
+
11
+ /**
12
+ * Send a payload on a channel iff the BrowserWindow is alive.
13
+ * No-ops on null/destroyed windows so callers don't need their own guards.
14
+ */
15
+ function sendIfAlive(window, channel, payload) {
16
+ if (window && !window.isDestroyed()) {
17
+ window.webContents.send(channel, payload);
18
+ }
19
+ }
20
+
21
+ module.exports = { sendIfAlive };
@@ -0,0 +1,203 @@
1
+ /**
2
+ * memoryTool.cjs — Memory tab backend (cycle 3, Bundle C).
3
+ *
4
+ * Workspace-scoped markdown store at
5
+ * ~/.claude/session-manager/memories/<workspace>/
6
+ * where <workspace> is a derived encoding of an active cwd (mirrors the
7
+ * Claude-Code transcript-dir convention). The Memory tab is a list+detail
8
+ * view over these files. Files are plain markdown with optional frontmatter:
9
+ *
10
+ * ---
11
+ * name: short label
12
+ * description: one-line summary
13
+ * ---
14
+ * <body>
15
+ *
16
+ * Hard caps:
17
+ * - 1 MB per file
18
+ * - 1000 entries per workspace
19
+ * - slugs MUST match /^[a-z0-9-_]+\.md$/
20
+ *
21
+ * Every read/write/delete is routed through config.cjs's atomic helpers,
22
+ * which in turn pin every absolute path inside the home directory via
23
+ * validatePath (with validateWrite layered on top for mutations).
24
+ */
25
+
26
+ const { ipcMain } = require('electron');
27
+ const fs = require('node:fs');
28
+ const fsp = require('node:fs/promises');
29
+ const path = require('node:path');
30
+ const os = require('node:os');
31
+ const config = require('./config.cjs');
32
+
33
+ const MAX_FILE_BYTES = 1024 * 1024; // 1 MiB
34
+ const MAX_ENTRIES = 1000;
35
+ const SLUG_RE = /^[a-z0-9-_]+\.md$/;
36
+
37
+ const { encodeCwd: encodeWorkspace } = require('./lib/encodeCwd.cjs');
38
+
39
+ function memoryRoot() {
40
+ return path.join(os.homedir(), '.claude', 'session-manager', 'memories');
41
+ }
42
+
43
+ function workspaceDir(workspace) {
44
+ return path.join(memoryRoot(), workspace);
45
+ }
46
+
47
+ /**
48
+ * Reject NUL chars, '..' segments, absolute paths, or anything that would
49
+ * resolve outside the workspace dir. Returns the resolved absolute path.
50
+ * The downstream config.cjs helpers add a second layer of validation, but
51
+ * this lets us return a clean error message before we get there.
52
+ */
53
+ function resolveEntryPath(workspace, name) {
54
+ if (typeof name !== 'string' || !name) {
55
+ throw new Error('invalid entry name');
56
+ }
57
+ if (name.includes('\0')) throw new Error('NUL in name');
58
+ if (!SLUG_RE.test(name)) {
59
+ throw new Error(`invalid slug (must match ${SLUG_RE.source}): ${name}`);
60
+ }
61
+ const dir = workspaceDir(workspace);
62
+ const full = path.resolve(dir, name);
63
+ // path.resolve normalizes; verify the result is still under dir to guard
64
+ // against bizarre name inputs.
65
+ if (full !== path.join(dir, name)) {
66
+ throw new Error('path escapes workspace');
67
+ }
68
+ const rel = path.relative(dir, full);
69
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
70
+ throw new Error('path escapes workspace');
71
+ }
72
+ return full;
73
+ }
74
+
75
+ function validWorkspaceName(name) {
76
+ return typeof name === 'string' && /^[a-zA-Z0-9-_]{1,256}$/.test(name);
77
+ }
78
+
79
+ async function list({ workspace }) {
80
+ const ws = validWorkspaceName(workspace) ? workspace : 'default';
81
+ const dir = workspaceDir(ws);
82
+ const r = await config.listDir(dir, { filesOnly: true });
83
+ if (!r.ok) {
84
+ return { entries: [], workspace: ws, error: r.error };
85
+ }
86
+ const entries = r.entries
87
+ .filter((e) => e.name.endsWith('.md'))
88
+ .map((e) => ({
89
+ name: e.name,
90
+ path: e.path,
91
+ mtimeMs: e.mtimeMs,
92
+ bytes: e.size,
93
+ }))
94
+ .sort((a, b) => a.name.localeCompare(b.name));
95
+ return { entries, workspace: ws, error: null };
96
+ }
97
+
98
+ async function read({ workspace, name }) {
99
+ const ws = validWorkspaceName(workspace) ? workspace : 'default';
100
+ const abs = resolveEntryPath(ws, name);
101
+ const r = await config.readText(abs);
102
+ if (!r.exists) {
103
+ return { content: '', exists: false, mtimeMs: 0, bytes: 0, error: r.error };
104
+ }
105
+ return {
106
+ content: r.text,
107
+ exists: true,
108
+ mtimeMs: r.mtimeMs,
109
+ bytes: Buffer.byteLength(r.text, 'utf8'),
110
+ error: null,
111
+ };
112
+ }
113
+
114
+ async function write({ workspace, name, content }) {
115
+ const ws = validWorkspaceName(workspace) ? workspace : 'default';
116
+ if (typeof content !== 'string') {
117
+ return { ok: false, error: 'content must be a string' };
118
+ }
119
+ const bytes = Buffer.byteLength(content, 'utf8');
120
+ if (bytes > MAX_FILE_BYTES) {
121
+ return { ok: false, error: `file exceeds 1 MiB cap (${bytes} bytes)` };
122
+ }
123
+ const abs = resolveEntryPath(ws, name);
124
+ // Enforce per-workspace entry cap on creates (writes to existing files are
125
+ // always allowed regardless of count). Cheap fs.stat check rather than a
126
+ // full directory enumeration where possible.
127
+ let exists = true;
128
+ try { await fsp.access(abs, fs.constants.F_OK); }
129
+ catch { exists = false; }
130
+ if (!exists) {
131
+ const l = await list({ workspace: ws });
132
+ if (l.entries.length >= MAX_ENTRIES) {
133
+ return { ok: false, error: `workspace at ${MAX_ENTRIES}-entry cap` };
134
+ }
135
+ }
136
+ try {
137
+ await config.writeTextAtomic(abs, content);
138
+ return { ok: true, error: null };
139
+ } catch (e) {
140
+ return { ok: false, error: e.message };
141
+ }
142
+ }
143
+
144
+ async function deleteEntry({ workspace, name }) {
145
+ const ws = validWorkspaceName(workspace) ? workspace : 'default';
146
+ const abs = resolveEntryPath(ws, name);
147
+ // Route through config.cjs::validatePath (home-boundary pin, symlink-safe)
148
+ // plus validateWrite (write-prefix pin) — same contract every other
149
+ // mutating call in the codebase follows. Complexity: O(1) per call.
150
+ let real;
151
+ try {
152
+ real = config.validatePath(abs);
153
+ config.validateWrite(real);
154
+ } catch (e) {
155
+ return { ok: false, error: e.message };
156
+ }
157
+ try {
158
+ await fsp.unlink(real);
159
+ return { ok: true, error: null };
160
+ } catch (e) {
161
+ if (e.code === 'ENOENT') return { ok: true, error: null };
162
+ return { ok: false, error: e.message };
163
+ }
164
+ }
165
+
166
+ async function create({ workspace, name, description }) {
167
+ const ws = validWorkspaceName(workspace) ? workspace : 'default';
168
+ const abs = resolveEntryPath(ws, name);
169
+ // Refuse to overwrite — create is strictly "new file".
170
+ try {
171
+ await fsp.access(abs, fs.constants.F_OK);
172
+ return { ok: false, error: 'memory already exists' };
173
+ } catch { /* expected */ }
174
+ const stem = name.replace(/\.md$/, '');
175
+ const desc = typeof description === 'string' ? description.trim() : '';
176
+ const fm = [
177
+ '---',
178
+ `name: ${stem}`,
179
+ ...(desc ? [`description: ${desc.replace(/\n/g, ' ')}`] : []),
180
+ '---',
181
+ '',
182
+ desc || `# ${stem}`,
183
+ '',
184
+ ].join('\n');
185
+ return await write({ workspace: ws, name, content: fm });
186
+ }
187
+
188
+ function registerMemoryHandlers() {
189
+ const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
190
+ ipcMain.handle('memory:list', v(s.memoryList, list));
191
+ ipcMain.handle('memory:read', v(s.memoryRead, read));
192
+ ipcMain.handle('memory:write', v(s.memoryWrite, write));
193
+ ipcMain.handle('memory:delete', v(s.memoryDelete, deleteEntry));
194
+ ipcMain.handle('memory:create', v(s.memoryCreate, create));
195
+ }
196
+
197
+ module.exports = {
198
+ registerMemoryHandlers,
199
+ encodeWorkspace,
200
+ // exported for tests
201
+ memoryRoot,
202
+ workspaceDir,
203
+ };
@@ -17,6 +17,7 @@
17
17
  const fsp = require('node:fs/promises');
18
18
  const path = require('node:path');
19
19
  const os = require('node:os');
20
+ const config = require('./config.cjs');
20
21
 
21
22
  const SCHEMA_VERSION = 1;
22
23
 
@@ -74,13 +75,7 @@ async function save(cfg) {
74
75
  if (!isValid(cfg)) throw new Error('Invalid OTEL config');
75
76
  const next = normalize(cfg);
76
77
  const run = async () => {
77
- const p = storePath();
78
- await fsp.mkdir(path.dirname(p), { recursive: true }).catch(() => {});
79
- const body = JSON.stringify(next, null, 2) + '\n';
80
- const tmp = `${p}.tmp-${process.pid}-${Date.now()}`;
81
- await fsp.writeFile(tmp, body, { encoding: 'utf8', mode: 0o600 });
82
- try { await fsp.chmod(tmp, 0o600); } catch { /* */ }
83
- await fsp.rename(tmp, p);
78
+ await config.writeTextAtomic(storePath(), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
84
79
  return next;
85
80
  };
86
81
  const tail = writeQueue.then(run, run);