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.
- package/dist/assets/AgentLibrary-Cm6MrOnF.js +2 -0
- package/dist/assets/{Button-DC6VzovQ.js → Button-CGVncJ6a.js} +1 -1
- package/dist/assets/DataModel-C3hlMoa8.js +1 -0
- package/dist/assets/History-CTyfGNKn.js +2 -0
- package/dist/assets/Hooks-BXxCBibx.js +3 -0
- package/dist/assets/{Library-DLmnlfQt.js → Library-aCs7zxAg.js} +24 -24
- package/dist/assets/{ListDetail-YylbvUj_.js → ListDetail-B9Hqk1_f.js} +1 -1
- package/dist/assets/{MarkdownEditor-DMPKWHTF.js → MarkdownEditor-Djfgp32k.js} +1 -1
- package/dist/assets/{McpServers-Da-Ty5nc.js → McpServers-8HQC6QgR.js} +2 -2
- package/dist/assets/{Memory-C3rUQ_cg.js → Memory-c4SVE3l2.js} +5 -5
- package/dist/assets/{Panel-CC3JTnOI.js → Panel-BEQTwE4z.js} +1 -1
- package/dist/assets/Permissions-BaYzgZtc.js +3 -0
- package/dist/assets/Plugins-DnXsVau7.js +2 -0
- package/dist/assets/ProvenanceBadge-CT2vWps1.js +1 -0
- package/dist/assets/RemoteMachines-DHJZfdJC.js +3 -0
- package/dist/assets/{SaveBar-C06rFT34.js → SaveBar-DwcHt3iR.js} +1 -1
- package/dist/assets/Scheduler-D6Gcuo0M.js +16 -0
- package/dist/assets/{ScopeSwitcher-Cp5mxblN.js → ScopeSwitcher-CgvWDc9Y.js} +1 -1
- package/dist/assets/Settings-CJoogVAB.js +4 -0
- package/dist/assets/{SkillReferenceGraph-DuWRmT_f.js → SkillReferenceGraph-4fq01kxq.js} +14 -14
- package/dist/assets/Skills-BU4ZX4h4.js +2 -0
- package/dist/assets/SystemPrompt-BCEyMSEq.js +1 -0
- package/dist/assets/TagLibrary-m3It8Oal.js +1 -0
- package/dist/assets/{TiptapBody-1r4ODLJ7.js → TiptapBody-DVyBsfsT.js} +1 -1
- package/dist/assets/{Toggle-BGT68XHP.js → Toggle-In_AExPV.js} +1 -1
- package/dist/assets/{ViewTabs-T0NBc8uv.js → ViewTabs-BLHjKUv-.js} +1 -1
- package/dist/assets/historyTotals-BXDd3EzP.js +1 -0
- package/dist/assets/index-CuiU3I-8.js +3077 -0
- package/dist/assets/{index-tr1w3hHB.css → index-Dutcsly-.css} +1 -1
- package/dist/assets/listSkills-C1aWKf0T.js +1 -0
- package/dist/assets/{settingsSchema-CvoNk3Ye.js → settingsSchema-DD4bc5cT.js} +1 -1
- package/dist/assets/useAgentsDirWatch-DMQ_XZtr.js +1 -0
- package/dist/assets/useRemotePeers-C9NfteSD.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +6 -2
- package/plugins/session-manager-dev/skills/develop/SKILL.md +3 -1
- package/plugins/session-manager-dev/skills/develop/standards.md +5 -0
- package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +48 -15
- package/scripts/README.md +7 -0
- package/scripts/scheduler-mcp-server.cjs +29 -2
- package/src/main/agentLibrary.cjs +20 -9
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +7 -0
- package/src/main/config.cjs +20 -10
- package/src/main/historyAggregator.cjs +46 -3
- package/src/main/index.cjs +8 -0
- package/src/main/ipcSchemas.cjs +30 -0
- package/src/main/lib/autoUpdater.cjs +190 -0
- package/src/main/lib/crossProjectFeedback.cjs +143 -6
- package/src/main/lib/mcpToolCatalog.cjs +12 -3
- package/src/main/lib/reaperHelpers.cjs +13 -1
- package/src/main/lib/relayClientAuth.cjs +133 -0
- package/src/main/lib/remoteClientSessions.cjs +84 -0
- package/src/main/lib/remoteE2eCrypto.cjs +109 -0
- package/src/main/lib/updaterSettings.cjs +64 -0
- package/src/main/pty.cjs +53 -0
- package/src/main/remoteClient.cjs +334 -0
- package/src/main/remoteClientIpc.cjs +133 -0
- package/src/main/scheduler.cjs +16 -0
- package/src/main/updaterIpc.cjs +110 -0
- package/src/main/webRemote.cjs +341 -215
- package/src/preload/api.d.ts +97 -7
- package/src/preload/index.cjs +51 -3
- package/dist/assets/AgentLibrary-Co54GThg.js +0 -2
- package/dist/assets/DataModel-DZ0vyJau.js +0 -1
- package/dist/assets/History-Df3YwpPe.js +0 -2
- package/dist/assets/Hooks-DplG6roH.js +0 -3
- package/dist/assets/Permissions-XvBiMj1-.js +0 -3
- package/dist/assets/Plugins-CzHY1mgS.js +0 -2
- package/dist/assets/ProvenanceBadge-DaJIWVcg.js +0 -1
- package/dist/assets/Scheduler-BehadU-0.js +0 -16
- package/dist/assets/Settings-Cg9LIni0.js +0 -4
- package/dist/assets/Skills-CMmCZfIv.js +0 -3
- package/dist/assets/SystemPrompt-D_-MV025.js +0 -1
- package/dist/assets/TagLibrary-D9Tv3yOJ.js +0 -1
- package/dist/assets/index-3_AtCAyd.js +0 -3077
- package/dist/assets/listSkills-QORduPIk.js +0 -1
- package/dist/assets/useAgentsDirWatch-1C2CBBya.js +0 -1
- 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
|
-
? `${
|
|
179
|
-
:
|
|
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
|
|
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${
|
|
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
|
|
242
|
-
+ '
|
|
243
|
-
+ '
|
|
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 };
|