claude-code-session-manager 0.108.0 → 0.110.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 (79) hide show
  1. package/dist/assets/AgentLibrary-Cm6MrOnF.js +2 -0
  2. package/dist/assets/{Button-DC6VzovQ.js → Button-CGVncJ6a.js} +1 -1
  3. package/dist/assets/DataModel-C3hlMoa8.js +1 -0
  4. package/dist/assets/History-CTyfGNKn.js +2 -0
  5. package/dist/assets/Hooks-BXxCBibx.js +3 -0
  6. package/dist/assets/{Library-DLmnlfQt.js → Library-aCs7zxAg.js} +24 -24
  7. package/dist/assets/{ListDetail-YylbvUj_.js → ListDetail-B9Hqk1_f.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DMPKWHTF.js → MarkdownEditor-Djfgp32k.js} +1 -1
  9. package/dist/assets/{McpServers-Da-Ty5nc.js → McpServers-8HQC6QgR.js} +2 -2
  10. package/dist/assets/{Memory-C3rUQ_cg.js → Memory-c4SVE3l2.js} +5 -5
  11. package/dist/assets/{Panel-CC3JTnOI.js → Panel-BEQTwE4z.js} +1 -1
  12. package/dist/assets/Permissions-BaYzgZtc.js +3 -0
  13. package/dist/assets/Plugins-DnXsVau7.js +2 -0
  14. package/dist/assets/ProvenanceBadge-CT2vWps1.js +1 -0
  15. package/dist/assets/RemoteMachines-DHJZfdJC.js +3 -0
  16. package/dist/assets/{SaveBar-C06rFT34.js → SaveBar-DwcHt3iR.js} +1 -1
  17. package/dist/assets/Scheduler-D6Gcuo0M.js +16 -0
  18. package/dist/assets/{ScopeSwitcher-Cp5mxblN.js → ScopeSwitcher-CgvWDc9Y.js} +1 -1
  19. package/dist/assets/Settings-CJoogVAB.js +4 -0
  20. package/dist/assets/{SkillReferenceGraph-DuWRmT_f.js → SkillReferenceGraph-4fq01kxq.js} +14 -14
  21. package/dist/assets/Skills-BU4ZX4h4.js +2 -0
  22. package/dist/assets/SystemPrompt-BCEyMSEq.js +1 -0
  23. package/dist/assets/TagLibrary-m3It8Oal.js +1 -0
  24. package/dist/assets/{TiptapBody-1r4ODLJ7.js → TiptapBody-DVyBsfsT.js} +1 -1
  25. package/dist/assets/{Toggle-BGT68XHP.js → Toggle-In_AExPV.js} +1 -1
  26. package/dist/assets/{ViewTabs-T0NBc8uv.js → ViewTabs-BLHjKUv-.js} +1 -1
  27. package/dist/assets/historyTotals-BXDd3EzP.js +1 -0
  28. package/dist/assets/index-CuiU3I-8.js +3077 -0
  29. package/dist/assets/{index-tr1w3hHB.css → index-Dutcsly-.css} +1 -1
  30. package/dist/assets/listSkills-C1aWKf0T.js +1 -0
  31. package/dist/assets/{settingsSchema-CvoNk3Ye.js → settingsSchema-DD4bc5cT.js} +1 -1
  32. package/dist/assets/useAgentsDirWatch-DMQ_XZtr.js +1 -0
  33. package/dist/assets/useRemotePeers-C9NfteSD.js +1 -0
  34. package/dist/index.html +2 -2
  35. package/package.json +6 -2
  36. package/plugins/session-manager-dev/skills/develop/SKILL.md +3 -1
  37. package/plugins/session-manager-dev/skills/develop/standards.md +5 -0
  38. package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +48 -15
  39. package/scripts/README.md +7 -0
  40. package/scripts/scheduler-mcp-server.cjs +29 -2
  41. package/src/main/agentLibrary.cjs +20 -9
  42. package/src/main/build-info.json +4 -4
  43. package/src/main/chatRunner.cjs +7 -0
  44. package/src/main/config.cjs +20 -10
  45. package/src/main/historyAggregator.cjs +46 -3
  46. package/src/main/index.cjs +8 -0
  47. package/src/main/ipcSchemas.cjs +30 -0
  48. package/src/main/lib/autoUpdater.cjs +190 -0
  49. package/src/main/lib/crossProjectFeedback.cjs +143 -6
  50. package/src/main/lib/mcpToolCatalog.cjs +12 -3
  51. package/src/main/lib/reaperHelpers.cjs +13 -1
  52. package/src/main/lib/relayClientAuth.cjs +133 -0
  53. package/src/main/lib/remoteClientSessions.cjs +84 -0
  54. package/src/main/lib/remoteE2eCrypto.cjs +109 -0
  55. package/src/main/lib/updaterSettings.cjs +64 -0
  56. package/src/main/pty.cjs +53 -0
  57. package/src/main/remoteClient.cjs +334 -0
  58. package/src/main/remoteClientIpc.cjs +133 -0
  59. package/src/main/scheduler.cjs +16 -0
  60. package/src/main/updaterIpc.cjs +110 -0
  61. package/src/main/webRemote.cjs +341 -215
  62. package/src/preload/api.d.ts +97 -7
  63. package/src/preload/index.cjs +51 -3
  64. package/dist/assets/AgentLibrary-Co54GThg.js +0 -2
  65. package/dist/assets/DataModel-DZ0vyJau.js +0 -1
  66. package/dist/assets/History-Df3YwpPe.js +0 -2
  67. package/dist/assets/Hooks-DplG6roH.js +0 -3
  68. package/dist/assets/Permissions-XvBiMj1-.js +0 -3
  69. package/dist/assets/Plugins-CzHY1mgS.js +0 -2
  70. package/dist/assets/ProvenanceBadge-DaJIWVcg.js +0 -1
  71. package/dist/assets/Scheduler-BehadU-0.js +0 -16
  72. package/dist/assets/Settings-Cg9LIni0.js +0 -4
  73. package/dist/assets/Skills-CMmCZfIv.js +0 -3
  74. package/dist/assets/SystemPrompt-D_-MV025.js +0 -1
  75. package/dist/assets/TagLibrary-D9Tv3yOJ.js +0 -1
  76. package/dist/assets/index-3_AtCAyd.js +0 -3077
  77. package/dist/assets/listSkills-QORduPIk.js +0 -1
  78. package/dist/assets/useAgentsDirWatch-1C2CBBya.js +0 -1
  79. package/dist/assets/useConfigDirWatch-Cd5PnTkB.js +0 -1
@@ -83,6 +83,14 @@ const DEFAULT_FEEDBACK_AGENT = 'architect';
83
83
  const MAX_TITLE_CHARS = 200;
84
84
  const MAX_BODY_CHARS = 20000;
85
85
  const MAX_REFERENCES = 20;
86
+ const MAX_MOTIVATION_CHARS = 2000;
87
+ const MAX_LIST_ITEMS = 20;
88
+ const MAX_LIST_ITEM_CHARS = 500;
89
+ const MAX_SERIES_KEY_CHARS = 60;
90
+ const MAX_SERIES_TITLE_CHARS = 120;
91
+ const MAX_SERIES_TOTAL = 50;
92
+ const MAX_ORIGIN_ASK_CHARS = 300;
93
+ const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
86
94
 
87
95
  /** Bound on how many `~/.claude/projects/` folders listFeedbackTargets will
88
96
  * resolve. That directory accumulates one folder per path the CLI was ever
@@ -127,6 +135,29 @@ function isSessionManagerProject(cwd) {
127
135
  return isDirectory(path.join(cwd, OPS_ROOT_DIR));
128
136
  }
129
137
 
138
+ /**
139
+ * resolveOriginAsk(fromCwd, fromEpicId) → string | null
140
+ *
141
+ * The sending Epic's own human goal (single line, ≤ 300 chars), read from the
142
+ * sender project's active-index.json. Never throws: a missing Epic or an
143
+ * unreadable file is just "no origin known".
144
+ */
145
+ function resolveOriginAsk(fromCwd, fromEpicId) {
146
+ if (!fromCwd || !fromEpicId) return null;
147
+ try {
148
+ const sessions = readActiveIndex(fromCwd).sessions;
149
+ if (!Object.prototype.hasOwnProperty.call(sessions, fromEpicId)) return null;
150
+ const goal = singleLine(sessions[fromEpicId] && sessions[fromEpicId].goalText);
151
+ return goal ? goal.slice(0, MAX_ORIGIN_ASK_CHARS) : null;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ function seriesLine(series) {
158
+ return series ? `Part ${series.index} of ${series.total} — ${singleLine(series.title)}` : '';
159
+ }
160
+
130
161
  /**
131
162
  * composeFeedbackIntake({ fromCwd, fromEpicId, title, body, referencePaths })
132
163
  * → { goalText, openingPrompt, sections }
@@ -160,6 +191,12 @@ function composeFeedbackIntake({
160
191
  agentName = DEFAULT_FEEDBACK_AGENT,
161
192
  agentDescription = null,
162
193
  referencePaths = [],
194
+ originAsk = null,
195
+ motivation = '',
196
+ plan = [],
197
+ acceptanceCriteria = [],
198
+ outOfScope = [],
199
+ series = null,
163
200
  } = {}) {
164
201
  const trimmedTitle = singleLine(title);
165
202
  const trimmedBody = String(body ?? '').trim();
@@ -174,21 +211,36 @@ function composeFeedbackIntake({
174
211
  });
175
212
  }
176
213
 
214
+ const fromPath = singleLine(fromCwd);
215
+ const fromName = path.basename(fromPath) || fromPath;
177
216
  const origin = fromEpicId
178
- ? `${singleLine(fromCwd)} (session ${singleLine(fromEpicId)})`
179
- : singleLine(fromCwd);
217
+ ? `${fromName} (${fromPath}, session ${singleLine(fromEpicId)})`
218
+ : `${fromName} (${fromPath})`;
219
+ const askClause = originAsk ? ` It was raised while working on: ${singleLine(originAsk)}.` : '';
180
220
  sections.push({
181
221
  kind: 'input',
182
222
  label: 'Input',
183
223
  text:
184
- `This session is INBOUND FEEDBACK from another project: ${origin}. `
224
+ `This session is INBOUND FEEDBACK from another project: ${origin}.${askClause} `
185
225
  + 'Nobody in this project wrote the report below — an agent working in that project did, and it '
186
226
  + 'may be wrong about this codebase. Verify the claim against the code here before acting on it, '
187
227
  + 'and if it does not hold, say so and close the session rather than building against it.',
188
228
  source: fromCwd,
189
229
  });
190
230
 
191
- const goalBody = trimmedTitle ? `Goal: ${trimmedTitle}\n\n${trimmedBody}` : trimmedBody;
231
+ const trimmedMotivation = String(motivation ?? '').trim();
232
+ const blocks = [];
233
+ if (series) blocks.push(seriesLine(series));
234
+ if (trimmedMotivation) blocks.push(`## Why\n${trimmedMotivation}`);
235
+ if (plan.length) blocks.push(`## Proposed plan\n${plan.map((x, i) => `${i + 1}. ${singleLine(x)}`).join('\n')}`);
236
+ if (acceptanceCriteria.length) {
237
+ blocks.push(`## Acceptance criteria\n${acceptanceCriteria.map((x) => `- [ ] ${singleLine(x)}`).join('\n')}`);
238
+ }
239
+ if (outOfScope.length) blocks.push(`## Out of scope\n${outOfScope.map((x) => `- ${singleLine(x)}`).join('\n')}`);
240
+ blocks.push(`## Details\n${trimmedBody}`);
241
+ const structured = blocks.join('\n\n');
242
+
243
+ const goalBody = trimmedTitle ? `Goal: ${trimmedTitle}\n\n${structured}` : structured;
192
244
  sections.push({ kind: 'goal', label: 'Goal', text: goalBody });
193
245
 
194
246
  for (const p of referencePaths) {
@@ -203,7 +255,7 @@ function composeFeedbackIntake({
203
255
  return out + sep + s.text;
204
256
  }, '');
205
257
 
206
- const bodyText = trimmedTitle ? `${trimmedTitle}\n\n${trimmedBody}` : trimmedBody;
258
+ const bodyText = trimmedTitle ? `${trimmedTitle}\n\n${structured}` : structured;
207
259
  const refLines = referencePaths.map((p) => `Reference: ${singleLine(p)}`);
208
260
  const goalText = refLines.length ? `${bodyText}\n\n${refLines.join('\n')}` : bodyText;
209
261
 
@@ -237,6 +289,66 @@ function validateFeedbackInput(input = {}) {
237
289
  return bad(400, `tag must be one of ${FEEDBACK_TAGS.join(' | ')} (got ${JSON.stringify(tag)})`);
238
290
  }
239
291
 
292
+ const motivation = typeof input.motivation === 'string' ? input.motivation.trim() : '';
293
+ if (!motivation) {
294
+ return bad(400, 'motivation is required — WHY this is needed, in the sending human\'s terms, so the receiving human can judge it');
295
+ }
296
+ if (motivation.length > MAX_MOTIVATION_CHARS) {
297
+ return bad(400, `motivation is ${motivation.length} chars; cap is ${MAX_MOTIVATION_CHARS}`);
298
+ }
299
+
300
+ const listPurpose = {
301
+ plan: 'the ordered steps you propose the receiving project take',
302
+ acceptanceCriteria: 'the checkable conditions that mean this is done',
303
+ outOfScope: 'what this proposal deliberately does not cover',
304
+ };
305
+ const lists = {};
306
+ for (const field of ['plan', 'acceptanceCriteria', 'outOfScope']) {
307
+ const raw = input[field];
308
+ if (raw === undefined || raw === null) { lists[field] = []; continue; }
309
+ if (!Array.isArray(raw)) return bad(400, `${field} must be an array of strings (${listPurpose[field]})`);
310
+ if (raw.length > MAX_LIST_ITEMS) {
311
+ return bad(400, `${field} has ${raw.length} items; cap is ${MAX_LIST_ITEMS} (${listPurpose[field]})`);
312
+ }
313
+ const items = [];
314
+ for (const item of raw) {
315
+ const t = typeof item === 'string' ? item.trim() : '';
316
+ if (!t) return bad(400, `${field} items must be non-blank strings (${listPurpose[field]})`);
317
+ if (t.length > MAX_LIST_ITEM_CHARS) {
318
+ return bad(400, `a ${field} item is ${t.length} chars; cap is ${MAX_LIST_ITEM_CHARS}`);
319
+ }
320
+ items.push(t);
321
+ }
322
+ lists[field] = items;
323
+ }
324
+ if (lists.acceptanceCriteria.length === 0) {
325
+ return bad(400, `acceptanceCriteria is required with at least one item — ${listPurpose.acceptanceCriteria}`);
326
+ }
327
+ if (tag === 'feature' && lists.plan.length === 0) {
328
+ return bad(400, `plan is required with at least one item when tag is 'feature' — ${listPurpose.plan}`);
329
+ }
330
+
331
+ let series = null;
332
+ if (input.series !== undefined && input.series !== null) {
333
+ const sr = input.series;
334
+ const sbad = (why) => bad(400, `series ${why} (series groups one request split across several proposals: { key, title, index, total })`);
335
+ if (typeof sr !== 'object' || Array.isArray(sr)) return sbad('must be an object');
336
+ const key = typeof sr.key === 'string' ? sr.key : '';
337
+ if (!key || key.length > MAX_SERIES_KEY_CHARS || !KEBAB_RE.test(key)) {
338
+ return sbad(`key must be kebab-case, ≤ ${MAX_SERIES_KEY_CHARS} chars`);
339
+ }
340
+ const stitle = typeof sr.title === 'string' ? sr.title.trim() : '';
341
+ if (!stitle || stitle.length > MAX_SERIES_TITLE_CHARS) {
342
+ return sbad(`title must be non-blank, ≤ ${MAX_SERIES_TITLE_CHARS} chars`);
343
+ }
344
+ if (!Number.isInteger(sr.index) || sr.index < 1) return sbad('index must be an integer ≥ 1');
345
+ if (!Number.isInteger(sr.total) || sr.total < 2 || sr.total > MAX_SERIES_TOTAL) {
346
+ return sbad(`total must be an integer 2..${MAX_SERIES_TOTAL}`);
347
+ }
348
+ if (sr.index > sr.total) return sbad('index must be ≤ total');
349
+ series = { key, title: stitle, index: sr.index, total: sr.total };
350
+ }
351
+
240
352
  const referencePaths = Array.isArray(input.referencePaths) ? input.referencePaths : [];
241
353
  if (referencePaths.length > MAX_REFERENCES) {
242
354
  return bad(400, `too many referencePaths (${referencePaths.length}); cap is ${MAX_REFERENCES}`);
@@ -293,6 +405,11 @@ function validateFeedbackInput(input = {}) {
293
405
  title,
294
406
  body,
295
407
  tag,
408
+ motivation,
409
+ plan: lists.plan,
410
+ acceptanceCriteria: lists.acceptanceCriteria,
411
+ outOfScope: lists.outOfScope,
412
+ series,
296
413
  referencePaths: referencePaths.map((p) => String(p)),
297
414
  fromEpicId: input.fromEpicId ? String(input.fromEpicId) : null,
298
415
  agentType: input.agentType ? String(input.agentType) : DEFAULT_FEEDBACK_AGENT,
@@ -337,6 +454,8 @@ async function openFeedbackSession(input, deps = {}) {
337
454
  } catch { /* unresolvable — the proposal still delivers, just without a receipt */ }
338
455
  }
339
456
 
457
+ const originAsk = resolveOriginAsk(v.fromCwd, v.fromEpicId);
458
+
340
459
  const { goalText, openingPrompt, sections } = composeFeedbackIntake({
341
460
  fromCwd: v.fromCwd,
342
461
  fromEpicId: v.fromEpicId,
@@ -345,6 +464,12 @@ async function openFeedbackSession(input, deps = {}) {
345
464
  agentName: v.agentType,
346
465
  agentDescription: v.agentDescription,
347
466
  referencePaths: v.referencePaths,
467
+ originAsk,
468
+ motivation: v.motivation,
469
+ plan: v.plan,
470
+ acceptanceCriteria: v.acceptanceCriteria,
471
+ outOfScope: v.outOfScope,
472
+ series: v.series,
348
473
  });
349
474
 
350
475
  // The Epic's stored identity is the one-line title, not the whole report —
@@ -363,6 +488,14 @@ async function openFeedbackSession(input, deps = {}) {
363
488
  producer: 'cross-project-feedback',
364
489
  fromCwd: v.fromCwd,
365
490
  ...(v.fromEpicId ? { fromEpicId: v.fromEpicId } : {}),
491
+ proposal: {
492
+ motivation: v.motivation,
493
+ originAsk,
494
+ plan: v.plan,
495
+ acceptanceCriteria: v.acceptanceCriteria,
496
+ outOfScope: v.outOfScope,
497
+ series: v.series,
498
+ },
366
499
  },
367
500
  mintAuthority: MINT_AUTHORITY_CROSS_PROJECT_FEEDBACK,
368
501
  });
@@ -377,6 +510,7 @@ async function openFeedbackSession(input, deps = {}) {
377
510
  fromEpicId: v.fromEpicId,
378
511
  tag: v.tag,
379
512
  title: v.title,
513
+ series: v.series,
380
514
  });
381
515
 
382
516
  let receiptOnOriginEpic = false;
@@ -385,7 +519,9 @@ async function openFeedbackSession(input, deps = {}) {
385
519
  receiptOnOriginEpic = await appendReceipt(
386
520
  v.fromCwd,
387
521
  v.fromEpicId,
388
- `Feedback delivered to ${v.toCwd} as a proposed session (${minted.epicId}): "${v.title}". `
522
+ `Feedback delivered to ${v.toCwd} as a proposed session (${minted.epicId}): "${v.title}"`
523
+ + (v.series ? ` [${seriesLine(v.series)}]` : '')
524
+ + '. '
389
525
  + 'It will not run until a human in that project approves it.',
390
526
  );
391
527
  } catch {
@@ -545,6 +681,7 @@ module.exports = {
545
681
  resolveProjectsDir,
546
682
  isSessionManagerProject,
547
683
  composeFeedbackIntake,
684
+ resolveOriginAsk,
548
685
  validateFeedbackInput,
549
686
  openFeedbackSession,
550
687
  listFeedbackTargets,
@@ -238,15 +238,24 @@ const MCP_TOOL_CATALOG = [
238
238
  + 'opening prompt and stamped with where it came from. Session Manager performs the cross-folder '
239
239
  + 'write; you never write another project\'s files yourself.',
240
240
  whenToUse: 'Use when work in this project surfaces something that is genuinely another project\'s to fix. '
241
- + 'Write the report for a reader who has never seen your project: state the symptom, where you '
242
- + 'observed it, what you expected, and (if you know) the file in THEIR repo that looks responsible. '
243
- + 'Never assume they can see your code.',
241
+ + 'Write for a human who must approve it and has never seen your project. Lead with WHY: `motivation` '
242
+ + 'in the originating human\'s terms. Then give the full `plan` (required for tag "feature") and '
243
+ + '`acceptanceCriteria`; put evidence, symptom and references in `body`. Name the file in THEIR repo '
244
+ + 'that looks responsible if you know it. When one request is split into several proposals, send '
245
+ + 'them ALL with one shared `series` {key, title, total} (plus each one\'s `index`) and summarise '
246
+ + 'the overall plan in each motivation.',
244
247
  whenNotToUse: 'WHEN NOT TO USE IT: for work in the project you are ALREADY in, run /develop inside the Epic you '
245
248
  + 'are already in — this tool refuses toCwd === fromCwd outright.',
246
249
  exampleArgs: {
247
250
  toCwd: '/home/bilko/Projects/other-project',
248
251
  fromCwd: '/home/bilko/Projects/session-manager',
249
252
  title: 'Cross-project contract mismatch in the shared admin API',
253
+ tag: 'feature',
254
+ motivation: 'The human wants X to work across both projects; today Y breaks because Z, so the shared admin API needs W.',
255
+ plan: ['Add field F to the admin API response', 'Update the validator to accept F', 'Cover F with a contract test'],
256
+ acceptanceCriteria: ['Admin API returns F', 'Contract test for F passes'],
257
+ outOfScope: ['Migrating existing records'],
258
+ series: { key: 'shared-admin-api-f', title: 'Shared admin API field F', index: 1, total: 2 },
250
259
  body: 'Symptom: X. Observed while doing Y in session-manager. Expected: Z. Suspected file: <path in their repo>.',
251
260
  },
252
261
  notes: 'WHAT THIS DOES NOT DO: it does not start anything, queue a PRD, or spend a token. The session '
@@ -339,6 +339,14 @@ const ORPHAN_REQUEUE_CAP = 5;
339
339
  * diverted into `recovered` exactly like a `findLiveProcess` hit.
340
340
  * Both default to a no-op when omitted, so existing callers/tests are
341
341
  * unaffected.
342
+ *
343
+ * `finalizeOwned` (optional, `(job) → boolean`) covers the dead-PID shape:
344
+ * a claude child exits BEFORE its spawnJob() has integrated the worktree
345
+ * branch and written the terminal row, so for that whole window the row is
346
+ * 'running' with a dead pid. Reaping it then races the fast-forward merge and
347
+ * parks landed work as reaped_without_integration (family-frame 137,
348
+ * 2026-10-10: park + merge in the same second). A true return skips the row
349
+ * with a warning; the caller bounds how long it may keep answering true.
342
350
  */
343
351
  /**
344
352
  * Pure, never-throws formatter for the dispatch-phase breadcrumb appended to
@@ -400,7 +408,7 @@ function resolvePidlessFailureOverride(job) {
400
408
  }
401
409
 
402
410
  function selectReapableJobs(jobs, now, {
403
- pidAlive, grace, findLiveProcess, getLogMtimeMs, getLogPid, logFreshWindowMs,
411
+ pidAlive, grace, findLiveProcess, getLogMtimeMs, getLogPid, logFreshWindowMs, finalizeOwned,
404
412
  } = {}) {
405
413
  const reapable = [];
406
414
  const warnings = [];
@@ -410,6 +418,10 @@ function selectReapableJobs(jobs, now, {
410
418
  const pid = j.runtime?.pid;
411
419
  if (pid) {
412
420
  if (pidAlive(pid)) continue;
421
+ if (typeof finalizeOwned === 'function' && finalizeOwned(j)) {
422
+ warnings.push({ slug: j.slug, reason: `pid ${pid} exited but its spawnJob is still finalizing (integration in flight)` });
423
+ continue;
424
+ }
413
425
  reapable.push({ slug: j.slug, pid, pidless: false });
414
426
  continue;
415
427
  }
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ // Desktop credential for acting as a 'browser'-role relay client against
4
+ // bilko.run's sm-relay client endpoints. Pairs with a one-time client code,
5
+ // stores the long-lived client token at 0600, and exchanges it for one-time WS
6
+ // tickets and the owner's device list. No Electron imports.
7
+
8
+ const fs = require('node:fs');
9
+ const https = require('node:https');
10
+ const path = require('node:path');
11
+ const { claudeHome } = require('./schedulerPaths.cjs');
12
+ const { writeJsonAtomicSync, readJsonOrSync } = require('./atomicFs.cjs');
13
+
14
+ const RELAY_CLIENT_API_BASE = 'https://bilko.run/api/sm-relay';
15
+ const REQUEST_TIMEOUT_MS = 15_000;
16
+ const CLIENT_NAME_MAX = 64;
17
+
18
+ /** Default transport: resolves { status, json }; never includes headers in errors. */
19
+ function defaultRequest({ method, url, headers = {}, body }) {
20
+ return new Promise((resolve, reject) => {
21
+ let parsed;
22
+ try { parsed = new URL(url); } catch { reject(new Error('invalid_url')); return; }
23
+ if (parsed.protocol !== 'https:') {
24
+ reject(new Error('Only https:// allowed for relay API calls'));
25
+ return;
26
+ }
27
+ const outHeaders = { ...headers };
28
+ if (body != null) outHeaders['Content-Length'] = Buffer.byteLength(body);
29
+ const req = https.request({
30
+ hostname: parsed.hostname,
31
+ port: parsed.port || 443,
32
+ path: parsed.pathname + (parsed.search || ''),
33
+ method,
34
+ headers: outHeaders,
35
+ rejectUnauthorized: true,
36
+ }, (res) => {
37
+ let data = '';
38
+ res.on('data', (c) => { data += c; });
39
+ res.on('end', () => {
40
+ let json = null;
41
+ try { json = JSON.parse(data); } catch { /* non-JSON body */ }
42
+ resolve({ status: res.statusCode, json });
43
+ });
44
+ });
45
+ req.on('error', (err) => reject(new Error(err.message)));
46
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => { req.destroy(new Error('timeout')); });
47
+ if (body != null) req.write(body);
48
+ req.end();
49
+ });
50
+ }
51
+
52
+ function defaultTokenPath() {
53
+ return path.join(claudeHome(), 'session-manager', 'relay-client.json');
54
+ }
55
+
56
+ function createRelayClientAuth({ tokenPath, request = defaultRequest } = {}) {
57
+ const file = tokenPath || defaultTokenPath();
58
+
59
+ function readStored() {
60
+ const data = readJsonOrSync(file, null);
61
+ return data && typeof data.clientToken === 'string' && data.clientToken ? data : null;
62
+ }
63
+ function clearToken() {
64
+ fs.rmSync(file, { force: true });
65
+ }
66
+ function isPaired() { return readStored() !== null; }
67
+ function getClientId() { return readStored()?.clientId ?? null; }
68
+
69
+ async function pair(code, clientName) {
70
+ const body = { code: String(code ?? '').trim().toUpperCase() };
71
+ const name = typeof clientName === 'string' ? clientName.slice(0, CLIENT_NAME_MAX) : '';
72
+ if (name) body.clientName = name;
73
+ const res = await request({
74
+ method: 'POST',
75
+ url: `${RELAY_CLIENT_API_BASE}/client-pair`,
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify(body),
78
+ });
79
+ const json = res.json || {};
80
+ if (res.status === 404) return { ok: false, error: 'relay_client_auth_unavailable' };
81
+ if (res.status !== 200) return { ok: false, error: json.error || `HTTP ${res.status}` };
82
+ if (!json.clientToken) return { ok: false, error: 'relay_auth_failed' };
83
+ writeJsonAtomicSync(file, {
84
+ clientToken: json.clientToken,
85
+ clientId: json.clientId,
86
+ pairedAt: new Date().toISOString(),
87
+ }, { mode: 0o600 });
88
+ return { ok: true, clientId: json.clientId };
89
+ }
90
+
91
+ /** Authenticated call; maps status codes to the module's error vocabulary. */
92
+ async function authed(method, route, body) {
93
+ const stored = readStored();
94
+ if (!stored) throw new Error('relay_client_unpaired');
95
+ const headers = { Authorization: `Bearer ${stored.clientToken}` };
96
+ if (body != null) headers['Content-Type'] = 'application/json';
97
+ const res = await request({ method, url: `${RELAY_CLIENT_API_BASE}${route}`, headers, body });
98
+ if (res.status === 401) {
99
+ clearToken();
100
+ throw new Error('relay_client_unpaired');
101
+ }
102
+ if (res.status === 404) throw new Error('relay_client_auth_unavailable');
103
+ if (res.status < 200 || res.status >= 300) {
104
+ throw new Error((res.json && res.json.error) || `HTTP ${res.status}`);
105
+ }
106
+ return res.json || {};
107
+ }
108
+
109
+ // Optional peer argument is accepted and ignored (remoteClient passes it).
110
+ async function getBrowserTicket(_peer) {
111
+ const json = await authed('POST', '/client-ticket', '{}');
112
+ if (!json.ticket) throw new Error('relay_auth_failed');
113
+ return json.ticket;
114
+ }
115
+
116
+ async function listDevices() {
117
+ const json = await authed('GET', '/client/devices');
118
+ return Array.isArray(json.devices) ? json.devices : [];
119
+ }
120
+
121
+ async function getDevicePubKey(deviceId) {
122
+ const devices = await listDevices();
123
+ const hit = devices.find((d) => d && d.deviceId === deviceId);
124
+ if (!hit) throw new Error('device_not_found');
125
+ return hit.devicePubKey;
126
+ }
127
+
128
+ return { pair, getBrowserTicket, getDevicePubKey, listDevices, isPaired, getClientId, clearToken };
129
+ }
130
+
131
+ const relayClientAuth = createRelayClientAuth();
132
+
133
+ module.exports = { createRelayClientAuth, relayClientAuth, RELAY_CLIENT_API_BASE };
@@ -0,0 +1,84 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Per-client E2E session registry for the web-remote agent.
5
+ * Pure — no Electron, no I/O — importable in unit tests.
6
+ *
7
+ * One record per connected client, keyed by the relay's `fromConn`. Envelopes
8
+ * without a usable `fromConn` (relay not yet stamping) key to LEGACY_CLIENT_KEY,
9
+ * which preserves single-client behaviour.
10
+ */
11
+
12
+ const { makeState } = require('./e2eStateMachine.cjs')
13
+
14
+ const LEGACY_CLIENT_KEY = 'legacy'
15
+ const MAX_KEY_LEN = 64
16
+
17
+ /**
18
+ * @param {{ fromConn?: unknown } | null | undefined} envelope
19
+ * @returns {string}
20
+ */
21
+ function clientKeyOf(envelope) {
22
+ const k = envelope && envelope.fromConn
23
+ if (typeof k === 'string' && k.length > 0 && k.length <= MAX_KEY_LEN) return k
24
+ return LEGACY_CLIENT_KEY
25
+ }
26
+
27
+ function makeRecord(key, clientId) {
28
+ return {
29
+ key,
30
+ clientId: typeof clientId === 'string' ? clientId : null,
31
+ e2e: makeState(),
32
+ peerPubKey: null,
33
+ deviceId: null,
34
+ ptyStreams: new Map(),
35
+ subscriptions: new Set(),
36
+ lastSessionListJson: null,
37
+ }
38
+ }
39
+
40
+ function createClientSessions() {
41
+ const records = new Map()
42
+
43
+ function teardown(key) {
44
+ const rec = records.get(key)
45
+ if (!rec) return null
46
+ for (const unsub of rec.ptyStreams.values()) {
47
+ try { if (typeof unsub === 'function') unsub() } catch { /* swallow */ }
48
+ }
49
+ rec.ptyStreams.clear()
50
+ rec.subscriptions.clear()
51
+ records.delete(key)
52
+ return rec
53
+ }
54
+
55
+ return {
56
+ clientKeyOf,
57
+ get: (key) => records.get(key) || null,
58
+ getOrCreate(key, { clientId = null } = {}) {
59
+ let rec = records.get(key)
60
+ if (!rec) {
61
+ rec = makeRecord(key, clientId)
62
+ records.set(key, rec)
63
+ } else if (rec.clientId === null && typeof clientId === 'string') {
64
+ rec.clientId = clientId
65
+ }
66
+ return rec
67
+ },
68
+ delete: (key) => records.delete(key),
69
+ keys: () => [...records.keys()],
70
+ values: () => [...records.values()],
71
+ authenticated: () => [...records.values()].filter((r) => r.e2e.state === 'authenticated'),
72
+ teardown,
73
+ teardownAll() {
74
+ let n = 0
75
+ for (const key of [...records.keys()]) {
76
+ if (teardown(key)) n++
77
+ }
78
+ return n
79
+ },
80
+ size: () => records.size,
81
+ }
82
+ }
83
+
84
+ module.exports = { createClientSessions, clientKeyOf, LEGACY_CLIENT_KEY }
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ // E2E encryption helpers (P-256 ECDH + AES-256-GCM, ADR §5.2).
4
+ // Shared by the agent side (webRemote.cjs) and the desktop client role.
5
+
6
+ const crypto = require('node:crypto');
7
+
8
+ /**
9
+ * Generate a P-256 ECDH keypair. Public key is SPKI DER base64url; private key
10
+ * is PKCS8 DER base64url. Both are stored in web-remote.json at 0600.
11
+ */
12
+ function generateE2EKeyPair() {
13
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
14
+ namedCurve: 'P-256',
15
+ publicKeyEncoding: { type: 'spki', format: 'der' },
16
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
17
+ });
18
+ return {
19
+ e2ePrivateKey: privateKey.toString('base64url'),
20
+ e2ePublicKey: publicKey.toString('base64url'),
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Derive an AES-256-GCM session key from two P-256 public keys via ECDH + HKDF.
26
+ * @param myPrivateKeyB64 Agent's PKCS8 private key, base64url DER
27
+ * @param peerPublicKeyB64 Browser's SPKI public key, base64url DER
28
+ * @param deviceId Used as HKDF salt for domain separation
29
+ * @returns 32-byte Buffer (AES-256-GCM key)
30
+ */
31
+ function deriveSessionKey(myPrivateKeyB64, peerPublicKeyB64, deviceId) {
32
+ const myPrivKey = crypto.createPrivateKey({
33
+ key: Buffer.from(myPrivateKeyB64, 'base64url'),
34
+ format: 'der',
35
+ type: 'pkcs8',
36
+ });
37
+ const peerPubKey = crypto.createPublicKey({
38
+ key: Buffer.from(peerPublicKeyB64, 'base64url'),
39
+ format: 'der',
40
+ type: 'spki',
41
+ });
42
+ const sharedSecret = crypto.diffieHellman({ privateKey: myPrivKey, publicKey: peerPubKey });
43
+ // HKDF: salt = deviceId bytes for domain separation, info = fixed protocol label.
44
+ const salt = Buffer.from(deviceId, 'utf8');
45
+ const info = Buffer.from('sm-e2e-v1', 'utf8');
46
+ return Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, 32));
47
+ }
48
+
49
+ /**
50
+ * Derive a 6-digit Short Authentication String from the ECDH shared secret.
51
+ * Uses a separate HKDF info label ('sm-sas-v1') so the SAS is independent of
52
+ * the session key. Both sides compute the same value; user confirms they match.
53
+ */
54
+ function deriveSas(myPrivateKeyB64, peerPublicKeyB64, deviceId) {
55
+ const myPrivKey = crypto.createPrivateKey({
56
+ key: Buffer.from(myPrivateKeyB64, 'base64url'),
57
+ format: 'der',
58
+ type: 'pkcs8',
59
+ });
60
+ const peerPubKey = crypto.createPublicKey({
61
+ key: Buffer.from(peerPublicKeyB64, 'base64url'),
62
+ format: 'der',
63
+ type: 'spki',
64
+ });
65
+ const sharedSecret = crypto.diffieHellman({ privateKey: myPrivKey, publicKey: peerPubKey });
66
+ const salt = Buffer.from(deviceId, 'utf8');
67
+ const info = Buffer.from('sm-sas-v1', 'utf8');
68
+ const sasBytes = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, 3));
69
+ const sasNum = ((sasBytes[0] << 16) | (sasBytes[1] << 8) | sasBytes[2]) % 1_000_000;
70
+ return sasNum.toString().padStart(6, '0');
71
+ }
72
+
73
+ /**
74
+ * Encrypt a plaintext JSON string into an AES-256-GCM box.
75
+ * @returns { nonce: base64url, ciphertext: base64url } — the nonce is 12 random bytes
76
+ */
77
+ function encryptBox(plaintext, sessionKey) {
78
+ const nonce = crypto.randomBytes(12);
79
+ const cipher = crypto.createCipheriv('aes-256-gcm', sessionKey, nonce);
80
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
81
+ const tag = cipher.getAuthTag(); // 16-byte GCM authentication tag
82
+ return {
83
+ nonce: nonce.toString('base64url'),
84
+ // Append the GCM tag to the ciphertext so decryptBox can locate it.
85
+ ciphertext: Buffer.concat([encrypted, tag]).toString('base64url'),
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Decrypt an AES-256-GCM box produced by encryptBox (or the browser's equivalent).
91
+ * @returns Decrypted UTF-8 string, or null if authentication fails.
92
+ */
93
+ function decryptBox(nonceB64, ciphertextB64, sessionKey) {
94
+ try {
95
+ const nonce = Buffer.from(nonceB64, 'base64url');
96
+ const data = Buffer.from(ciphertextB64, 'base64url');
97
+ if (data.length < 16) return null; // too short to contain tag
98
+ const tag = data.subarray(data.length - 16);
99
+ const ciphertext = data.subarray(0, data.length - 16);
100
+ const decipher = crypto.createDecipheriv('aes-256-gcm', sessionKey, nonce);
101
+ decipher.setAuthTag(tag);
102
+ const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
103
+ return plain;
104
+ } catch {
105
+ return null; // authentication failed — drop the message
106
+ }
107
+ }
108
+
109
+ module.exports = { generateE2EKeyPair, deriveSessionKey, deriveSas, encryptBox, decryptBox };