claude-code-session-manager 0.112.0 → 0.113.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/.claude-plugin/marketplace.json +1 -1
- package/dist/assets/{AgentLibrary-RwPvEgqm.js → AgentLibrary-wvrcLGGS.js} +2 -2
- package/dist/assets/{Button-36j3PzYv.js → Button-B6h0F0XT.js} +1 -1
- package/dist/assets/{DataModel-DUhANo4l.js → DataModel-C5pW9E3u.js} +1 -1
- package/dist/assets/{History-BeZzNjmv.js → History-BV4e8YOM.js} +2 -2
- package/dist/assets/{Hooks-iQ8845Hp.js → Hooks-CrFSv4x1.js} +3 -3
- package/dist/assets/{Library-DPE1h-1L.js → Library-D-GIt3EY.js} +7 -7
- package/dist/assets/{ListDetail-PStOuQAo.js → ListDetail-Bco_wfs6.js} +1 -1
- package/dist/assets/{MarkdownEditor-DPw-N2-m.js → MarkdownEditor-C-yHSXNV.js} +1 -1
- package/dist/assets/{McpServers-CUyRTNep.js → McpServers-CfDekadG.js} +2 -2
- package/dist/assets/{Memory-bZ2xjBGy.js → Memory-CGGLexkR.js} +6 -6
- package/dist/assets/{Panel-_VZ_uXNg.js → Panel-DGMEuYYG.js} +1 -1
- package/dist/assets/{Permissions-PPRmfNbK.js → Permissions-Cx2zjtYZ.js} +3 -3
- package/dist/assets/{Plugins-BFcvfvlx.js → Plugins-Bc_W3IyR.js} +2 -2
- package/dist/assets/ProvenanceBadge-DiKAUR1u.js +1 -0
- package/dist/assets/{RemoteMachines-D5V0lzgW.js → RemoteMachines-8_77-WPD.js} +2 -2
- package/dist/assets/{SaveBar-Dw64FVdM.js → SaveBar-Blpzdq1R.js} +1 -1
- package/dist/assets/{Scheduler-C8s_pJ3I.js → Scheduler-Bd70AM4X.js} +8 -8
- package/dist/assets/{ScopeSwitcher-D-nKDMAE.js → ScopeSwitcher-wxN0h4YW.js} +1 -1
- package/dist/assets/Settings-CQ7DA3zW.js +4 -0
- package/dist/assets/{SkillReferenceGraph-BbZFMHim.js → SkillReferenceGraph-DH1NLOiS.js} +1 -1
- package/dist/assets/Skills-CmyRyu4_.js +2 -0
- package/dist/assets/SystemPrompt-KAc3UMCd.js +1 -0
- package/dist/assets/TagLibrary-C8EvHnpg.js +1 -0
- package/dist/assets/{TiptapBody-Cr-hxX8L.js → TiptapBody-d423mH6D.js} +1 -1
- package/dist/assets/{Toggle-7WpPzcZH.js → Toggle-D1wsGE9S.js} +1 -1
- package/dist/assets/{ViewTabs-9lQCRwWJ.js → ViewTabs-CjHw3kGY.js} +1 -1
- package/dist/assets/{historyTotals-BrFxw5J1.js → historyTotals-DFHzqLt7.js} +1 -1
- package/dist/assets/{index-CTBpf-26.js → index-Ci1u018F.js} +143 -143
- package/dist/assets/{settingsSchema-Dh5Kl7VO.js → settingsSchema-Ctf3pZVI.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-CMpx0d04.js → useAgentsDirWatch-B1NBmOAe.js} +1 -1
- package/dist/assets/{useRemotePeers-CI2axd-L.js → useRemotePeers-BHjSJs9D.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +13 -0
- package/scripts/scheduler-mcp-server.cjs +23 -5
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +18 -1
- package/src/main/config.cjs +18 -1
- package/src/main/index.cjs +6 -0
- package/src/main/ipcSchemas.cjs +3 -0
- package/src/main/lib/activeSessions.cjs +13 -0
- package/src/main/lib/claude/rateLimitDetect.cjs +29 -10
- package/src/main/lib/claude/rateLimitWindow.cjs +23 -11
- package/src/main/lib/credentials.cjs +16 -90
- package/src/main/lib/feedback-remote/crossProjectFeedback.cjs +107 -9
- package/src/main/lib/feedback-remote/humanPromptLedger.cjs +93 -0
- package/src/main/lib/mcpToolCatalog.cjs +9 -3
- package/src/main/lib/prd/depSlugResolve.cjs +16 -1
- package/src/main/lib/scheduler/finalizeReason.cjs +30 -0
- package/src/main/lib/scheduler/flatSweepThrottle.cjs +17 -0
- package/src/main/lib/scheduler/runLogRetention.cjs +34 -13
- package/src/main/lib/scheduler/schedulerBatch.cjs +8 -12
- package/src/main/lib/telemetry/telemetryClient.cjs +8 -4
- package/src/main/lib/util/atomicFs.cjs +42 -0
- package/src/main/lib/util/loopDelay.cjs +1 -1
- package/src/main/runVerify.cjs +5 -2
- package/src/main/scheduler.cjs +43 -10
- package/src/main/supervisor.cjs +45 -22
- package/src/main/webRemote.cjs +15 -0
- package/src/preload/api.d.ts +6 -0
- package/src/preload/index.cjs +6 -0
- package/dist/assets/ProvenanceBadge-B1WUknak.js +0 -1
- package/dist/assets/Settings-CQSrR4mO.js +0 -4
- package/dist/assets/Skills-Bzyrh6EQ.js +0 -2
- package/dist/assets/SystemPrompt-DP6i3Dx0.js +0 -1
- package/dist/assets/TagLibrary-iip0C6Bl.js +0 -1
|
@@ -33,7 +33,19 @@
|
|
|
33
33
|
* feedback Epic is born 'proposed' like every other Epic (epicMint.cjs's
|
|
34
34
|
* BORN-PROPOSED check applies to this mint authority verbatim), so the human
|
|
35
35
|
* in the RECEIVING project is still the one who decides whether a single token
|
|
36
|
-
* gets spent. An agent can ask; it cannot start.
|
|
36
|
+
* gets spent. An agent can ask; it cannot start on its own say-so.
|
|
37
|
+
*
|
|
38
|
+
* The one exception is a VERIFIED HUMAN REQUEST. When the human themselves
|
|
39
|
+
* asked, in chat, for work in another project, making them re-confirm in the
|
|
40
|
+
* receiving project is pure friction. So `autoStart: true` plus a
|
|
41
|
+
* `humanRequestQuote` is honored only when main can check the quote against
|
|
42
|
+
* humanPromptLedger (prompts chat:run recorded with human origin) for the
|
|
43
|
+
* originating chat session — `input.originClaudeSessionId`, taken from the MCP
|
|
44
|
+
* server's env, set only for interactive chat runs, never for scheduler jobs —
|
|
45
|
+
* AND that human prompt names the target project. Even then the Epic is still
|
|
46
|
+
* minted 'proposed'; it is only stamped `source.autoApprove` and the renderer
|
|
47
|
+
* is told to approve-and-start it through its normal approveProposed path.
|
|
48
|
+
* Anything unverifiable degrades to the ordinary proposal.
|
|
37
49
|
*
|
|
38
50
|
* Guards, all fail-closed:
|
|
39
51
|
* - both cwds go through config.cjs's validatePath (allowedRoots = home)
|
|
@@ -90,6 +102,7 @@ const MAX_SERIES_KEY_CHARS = 60;
|
|
|
90
102
|
const MAX_SERIES_TITLE_CHARS = 120;
|
|
91
103
|
const MAX_SERIES_TOTAL = 50;
|
|
92
104
|
const MAX_ORIGIN_ASK_CHARS = 300;
|
|
105
|
+
const MAX_HUMAN_QUOTE_CHARS = 2000;
|
|
93
106
|
const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
94
107
|
|
|
95
108
|
/** Bound on how many `~/.claude/projects/` folders listFeedbackTargets will
|
|
@@ -349,6 +362,18 @@ function validateFeedbackInput(input = {}) {
|
|
|
349
362
|
series = { key, title: stitle, index: sr.index, total: sr.total };
|
|
350
363
|
}
|
|
351
364
|
|
|
365
|
+
if (input.autoStart !== undefined && input.autoStart !== null && typeof input.autoStart !== 'boolean') {
|
|
366
|
+
return bad(400, 'autoStart must be a boolean');
|
|
367
|
+
}
|
|
368
|
+
let humanRequestQuote = '';
|
|
369
|
+
if (input.humanRequestQuote !== undefined && input.humanRequestQuote !== null) {
|
|
370
|
+
if (typeof input.humanRequestQuote !== 'string') return bad(400, 'humanRequestQuote must be a string');
|
|
371
|
+
humanRequestQuote = input.humanRequestQuote.trim();
|
|
372
|
+
if (humanRequestQuote.length > MAX_HUMAN_QUOTE_CHARS) {
|
|
373
|
+
return bad(400, `humanRequestQuote is ${humanRequestQuote.length} chars; cap is ${MAX_HUMAN_QUOTE_CHARS}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
352
377
|
const referencePaths = Array.isArray(input.referencePaths) ? input.referencePaths : [];
|
|
353
378
|
if (referencePaths.length > MAX_REFERENCES) {
|
|
354
379
|
return bad(400, `too many referencePaths (${referencePaths.length}); cap is ${MAX_REFERENCES}`);
|
|
@@ -410,6 +435,8 @@ function validateFeedbackInput(input = {}) {
|
|
|
410
435
|
acceptanceCriteria: lists.acceptanceCriteria,
|
|
411
436
|
outOfScope: lists.outOfScope,
|
|
412
437
|
series,
|
|
438
|
+
autoStart: input.autoStart === true,
|
|
439
|
+
humanRequestQuote,
|
|
413
440
|
referencePaths: referencePaths.map((p) => String(p)),
|
|
414
441
|
fromEpicId: input.fromEpicId ? String(input.fromEpicId) : null,
|
|
415
442
|
agentType: input.agentType ? String(input.agentType) : DEFAULT_FEEDBACK_AGENT,
|
|
@@ -418,6 +445,44 @@ function validateFeedbackInput(input = {}) {
|
|
|
418
445
|
};
|
|
419
446
|
}
|
|
420
447
|
|
|
448
|
+
/** Tell every renderer window to approve-and-start a granted auto-start Epic.
|
|
449
|
+
* Electron is required lazily so unit tests never need it. */
|
|
450
|
+
function defaultNotifyRenderer(payload) {
|
|
451
|
+
try {
|
|
452
|
+
const { BrowserWindow } = require('electron');
|
|
453
|
+
for (const win of BrowserWindow.getAllWindows()) {
|
|
454
|
+
try {
|
|
455
|
+
win.webContents.send('promptSessions:inbound-auto-approve', payload);
|
|
456
|
+
} catch { /* window going away */ }
|
|
457
|
+
}
|
|
458
|
+
} catch { /* not running under Electron */ }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* verifyAutoStart(v, originClaudeSessionId) → { granted: true, quote } | { granted: false, reason }
|
|
463
|
+
*
|
|
464
|
+
* Every check is fail-closed. The session identity is ONLY the caller-supplied
|
|
465
|
+
* originClaudeSessionId (MCP env), never a field an agent can pick freely.
|
|
466
|
+
*/
|
|
467
|
+
function verifyAutoStart(v, originClaudeSessionId) {
|
|
468
|
+
if (!v.autoStart) return { granted: false, reason: 'autoStart was not requested' };
|
|
469
|
+
if (!v.humanRequestQuote) return { granted: false, reason: 'no humanRequestQuote was given' };
|
|
470
|
+
if (!originClaudeSessionId) {
|
|
471
|
+
return { granted: false, reason: 'no originClaudeSessionId — only interactive chat sessions can auto-start' };
|
|
472
|
+
}
|
|
473
|
+
const { findHumanPromptContaining } = require('./humanPromptLedger.cjs');
|
|
474
|
+
const found = findHumanPromptContaining(originClaudeSessionId, v.humanRequestQuote);
|
|
475
|
+
if (!found) {
|
|
476
|
+
return { granted: false, reason: 'humanRequestQuote was not found in a prompt the human typed in this chat session' };
|
|
477
|
+
}
|
|
478
|
+
const prompt = found.text.toLowerCase();
|
|
479
|
+
const base = path.basename(v.toCwd).toLowerCase();
|
|
480
|
+
if (!(base && prompt.includes(base)) && !prompt.includes(v.toCwd.toLowerCase())) {
|
|
481
|
+
return { granted: false, reason: `the human's prompt does not name the receiving project (${path.basename(v.toCwd)})` };
|
|
482
|
+
}
|
|
483
|
+
return { granted: true, quote: v.humanRequestQuote };
|
|
484
|
+
}
|
|
485
|
+
|
|
421
486
|
/**
|
|
422
487
|
* openFeedbackSession(input, deps?) → { ok, status?, error?, epicId, toCwd, ... }
|
|
423
488
|
*
|
|
@@ -427,8 +492,8 @@ function validateFeedbackInput(input = {}) {
|
|
|
427
492
|
* best-effort: a failure there never invalidates a delivered proposal, it just
|
|
428
493
|
* reports `receiptOnOriginEpic: false`.
|
|
429
494
|
*
|
|
430
|
-
* `deps` exists so tests can inject the
|
|
431
|
-
* without an Electron/main-process boot.
|
|
495
|
+
* `deps` exists so tests can inject the side-effecting collaborators
|
|
496
|
+
* (ensureEpic, appendResponseEventIfKnown, notifyRenderer) without an Electron/main-process boot.
|
|
432
497
|
*/
|
|
433
498
|
async function openFeedbackSession(input, deps = {}) {
|
|
434
499
|
const mint = deps.ensureEpic || ensureEpic;
|
|
@@ -439,10 +504,19 @@ async function openFeedbackSession(input, deps = {}) {
|
|
|
439
504
|
// boot the main process.
|
|
440
505
|
|| ((...args) => require('../../promptSessionEvents.cjs').appendResponseEventIfKnown(...args));
|
|
441
506
|
|
|
507
|
+
const notify = deps.notifyRenderer || defaultNotifyRenderer;
|
|
508
|
+
|
|
442
509
|
const checked = validateFeedbackInput(input);
|
|
443
510
|
if (!checked.ok) return checked;
|
|
444
511
|
const v = checked.value;
|
|
445
512
|
|
|
513
|
+
const originClaudeSessionId =
|
|
514
|
+
typeof input.originClaudeSessionId === 'string' && input.originClaudeSessionId
|
|
515
|
+
? input.originClaudeSessionId
|
|
516
|
+
: null;
|
|
517
|
+
const auto = verifyAutoStart(v, originClaudeSessionId);
|
|
518
|
+
const verifiedAt = new Date().toISOString();
|
|
519
|
+
|
|
446
520
|
// The MCP client can't know its own Epic id — it only inherits the claude
|
|
447
521
|
// session id chatRunner.cjs stamps on the `claude -p` child. Resolve it
|
|
448
522
|
// through prdCreate.cjs's existing resolver rather than a second copy of
|
|
@@ -488,6 +562,16 @@ async function openFeedbackSession(input, deps = {}) {
|
|
|
488
562
|
producer: 'cross-project-feedback',
|
|
489
563
|
fromCwd: v.fromCwd,
|
|
490
564
|
...(v.fromEpicId ? { fromEpicId: v.fromEpicId } : {}),
|
|
565
|
+
...(auto.granted
|
|
566
|
+
? {
|
|
567
|
+
autoApprove: {
|
|
568
|
+
requestedBy: 'human',
|
|
569
|
+
quote: auto.quote,
|
|
570
|
+
verifiedAt,
|
|
571
|
+
fromClaudeSessionId: originClaudeSessionId,
|
|
572
|
+
},
|
|
573
|
+
}
|
|
574
|
+
: {}),
|
|
491
575
|
proposal: {
|
|
492
576
|
motivation: v.motivation,
|
|
493
577
|
originAsk,
|
|
@@ -511,8 +595,16 @@ async function openFeedbackSession(input, deps = {}) {
|
|
|
511
595
|
tag: v.tag,
|
|
512
596
|
title: v.title,
|
|
513
597
|
series: v.series,
|
|
598
|
+
autoStartGranted: auto.granted,
|
|
599
|
+
autoStartReason: auto.granted ? 'verified human request' : auto.reason,
|
|
514
600
|
});
|
|
515
601
|
|
|
602
|
+
if (auto.granted) {
|
|
603
|
+
try {
|
|
604
|
+
notify({ cwd: v.toCwd, epicId: minted.epicId });
|
|
605
|
+
} catch { /* the Epic is delivered either way; a human can still approve it */ }
|
|
606
|
+
}
|
|
607
|
+
|
|
516
608
|
let receiptOnOriginEpic = false;
|
|
517
609
|
if (v.fromEpicId) {
|
|
518
610
|
try {
|
|
@@ -522,7 +614,9 @@ async function openFeedbackSession(input, deps = {}) {
|
|
|
522
614
|
`Feedback delivered to ${v.toCwd} as a proposed session (${minted.epicId}): "${v.title}"`
|
|
523
615
|
+ (v.series ? ` [${seriesLine(v.series)}]` : '')
|
|
524
616
|
+ '. '
|
|
525
|
-
+
|
|
617
|
+
+ (auto.granted
|
|
618
|
+
? 'It was auto-approved at the human\'s explicit request and is starting in that project.'
|
|
619
|
+
: 'It will not run until a human in that project approves it.'),
|
|
526
620
|
);
|
|
527
621
|
} catch {
|
|
528
622
|
receiptOnOriginEpic = false;
|
|
@@ -535,14 +629,18 @@ async function openFeedbackSession(input, deps = {}) {
|
|
|
535
629
|
toCwd: v.toCwd,
|
|
536
630
|
fromCwd: v.fromCwd,
|
|
537
631
|
tag: v.tag,
|
|
538
|
-
status: 'proposed',
|
|
632
|
+
status: auto.granted ? 'auto-approved' : 'proposed',
|
|
633
|
+
...(v.autoStart ? { autoStart: auto.granted ? { granted: true } : { granted: false, reason: auto.reason } } : {}),
|
|
539
634
|
receiptOnOriginEpic,
|
|
540
635
|
// Say plainly what did NOT happen, same posture as create-prd's
|
|
541
636
|
// `enqueued: false` — never let a caller report this as "work started".
|
|
542
|
-
note:
|
|
543
|
-
'
|
|
544
|
-
|
|
545
|
-
|
|
637
|
+
note: auto.granted
|
|
638
|
+
? 'Auto-approved at the human\'s explicit request (verified against their chat prompt): the session '
|
|
639
|
+
+ 'is starting in the receiving project. There is no callback: if you need an answer, ask the human '
|
|
640
|
+
+ 'to follow up.'
|
|
641
|
+
: 'Delivered as a PROPOSED session in the receiving project\'s Sessions queue. Nothing runs and no '
|
|
642
|
+
+ 'tokens are spent until a human in that project presses Approve & start. There is no callback: '
|
|
643
|
+
+ 'if you need an answer, ask the human to follow up.',
|
|
546
644
|
goalText,
|
|
547
645
|
};
|
|
548
646
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* humanPromptLedger.cjs — in-memory record of prompts a HUMAN typed into each
|
|
5
|
+
* chat claude session, keyed by claudeSessionId.
|
|
6
|
+
*
|
|
7
|
+
* Why it exists. The cross-project feedback conduit (crossProjectFeedback.cjs)
|
|
8
|
+
* may auto-approve a receiving-project session only when the human explicitly
|
|
9
|
+
* asked for it. An agent's say-so is not evidence of that. This ledger is the
|
|
10
|
+
* ONLY evidence that a human — not an agent, not an external/Web Remote
|
|
11
|
+
* injection, not an inbound proposal's agent-written opening prompt — typed a
|
|
12
|
+
* given prompt. Callers record only prompts that arrived from the human's own
|
|
13
|
+
* chat input, then ask `findHumanPromptContaining` whether a quoted request
|
|
14
|
+
* really appears in one.
|
|
15
|
+
*
|
|
16
|
+
* In-memory by design. Losing the ledger on restart only degrades auto-approve
|
|
17
|
+
* back to the ordinary 'proposed' path — it fails closed, so nothing is
|
|
18
|
+
* persisted to disk.
|
|
19
|
+
*
|
|
20
|
+
* Bounds: 10 prompts per session, 24h TTL, 200 sessions (least-recently-
|
|
21
|
+
* recorded evicted), 20000 chars per stored prompt. Pure Node, no Electron.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const MIN_QUOTE_CHARS = 20;
|
|
25
|
+
const MAX_PER_SESSION = 10;
|
|
26
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
const MAX_SESSIONS = 200;
|
|
28
|
+
const MAX_TEXT_CHARS = 20000;
|
|
29
|
+
|
|
30
|
+
/** @type {Map<string, Array<{ text: string, norm: string, at: number }>>} */
|
|
31
|
+
const ledger = new Map();
|
|
32
|
+
|
|
33
|
+
/** @param {string} s */
|
|
34
|
+
function normalize(s) {
|
|
35
|
+
return s.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {unknown} claudeSessionId
|
|
40
|
+
* @param {unknown} text
|
|
41
|
+
* @param {number} [now]
|
|
42
|
+
*/
|
|
43
|
+
function recordHumanPrompt(claudeSessionId, text, now = Date.now()) {
|
|
44
|
+
try {
|
|
45
|
+
if (typeof claudeSessionId !== 'string' || !claudeSessionId) return;
|
|
46
|
+
if (typeof text !== 'string' || !text.trim()) return;
|
|
47
|
+
const stored = text.slice(0, MAX_TEXT_CHARS);
|
|
48
|
+
const prior = (ledger.get(claudeSessionId) || []).filter((e) => now - e.at <= TTL_MS);
|
|
49
|
+
prior.push({ text: stored, norm: normalize(stored), at: now });
|
|
50
|
+
while (prior.length > MAX_PER_SESSION) prior.shift();
|
|
51
|
+
// Re-insert so Map order is least-recently-recorded first.
|
|
52
|
+
ledger.delete(claudeSessionId);
|
|
53
|
+
ledger.set(claudeSessionId, prior);
|
|
54
|
+
while (ledger.size > MAX_SESSIONS) {
|
|
55
|
+
const oldest = ledger.keys().next().value;
|
|
56
|
+
if (oldest === undefined) break;
|
|
57
|
+
ledger.delete(oldest);
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
/* never throws */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {unknown} claudeSessionId
|
|
66
|
+
* @param {unknown} quote
|
|
67
|
+
* @param {number} [now]
|
|
68
|
+
* @returns {{ text: string, at: number } | null}
|
|
69
|
+
*/
|
|
70
|
+
function findHumanPromptContaining(claudeSessionId, quote, now = Date.now()) {
|
|
71
|
+
try {
|
|
72
|
+
if (typeof claudeSessionId !== 'string' || typeof quote !== 'string') return null;
|
|
73
|
+
const q = normalize(quote);
|
|
74
|
+
if (q.length < MIN_QUOTE_CHARS) return null;
|
|
75
|
+
const entries = ledger.get(claudeSessionId);
|
|
76
|
+
if (!entries) return null;
|
|
77
|
+
const live = entries.filter((e) => now - e.at <= TTL_MS);
|
|
78
|
+
if (live.length === 0) ledger.delete(claudeSessionId);
|
|
79
|
+
else if (live.length !== entries.length) ledger.set(claudeSessionId, live);
|
|
80
|
+
for (let i = live.length - 1; i >= 0; i--) {
|
|
81
|
+
if (live[i].norm.includes(q)) return { text: live[i].text, at: live[i].at };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function _resetForTests() {
|
|
90
|
+
ledger.clear();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { recordHumanPrompt, findHumanPromptContaining, MIN_QUOTE_CHARS, _resetForTests };
|
|
@@ -243,7 +243,11 @@ const MCP_TOOL_CATALOG = [
|
|
|
243
243
|
+ '`acceptanceCriteria`; put evidence, symptom and references in `body`. Name the file in THEIR repo '
|
|
244
244
|
+ 'that looks responsible if you know it. When one request is split into several proposals, send '
|
|
245
245
|
+ 'them ALL with one shared `series` {key, title, total} (plus each one\'s `index`) and summarise '
|
|
246
|
-
+ 'the overall plan in each motivation.'
|
|
246
|
+
+ 'the overall plan in each motivation. TWO MODES: (1) agent-initiated (your own idea) = PROPOSED, a '
|
|
247
|
+
+ 'human in the receiving project must approve it; pass neither autoStart nor humanRequestQuote. '
|
|
248
|
+
+ '(2) human-requested = the human in THIS conversation explicitly asked for the work in that other '
|
|
249
|
+
+ 'project: pass `autoStart: true` plus `humanRequestQuote`, copied VERBATIM from their own message, '
|
|
250
|
+
+ 'which must name the target project. An unverified request silently falls back to PROPOSED.',
|
|
247
251
|
whenNotToUse: 'WHEN NOT TO USE IT: for work in the project you are ALREADY in, run /develop inside the Epic you '
|
|
248
252
|
+ 'are already in — this tool refuses toCwd === fromCwd outright.',
|
|
249
253
|
exampleArgs: {
|
|
@@ -258,11 +262,13 @@ const MCP_TOOL_CATALOG = [
|
|
|
258
262
|
series: { key: 'shared-admin-api-f', title: 'Shared admin API field F', index: 1, total: 2 },
|
|
259
263
|
body: 'Symptom: X. Observed while doing Y in session-manager. Expected: Z. Suspected file: <path in their repo>.',
|
|
260
264
|
},
|
|
261
|
-
notes: 'WHAT THIS DOES NOT DO: it
|
|
265
|
+
notes: 'WHAT THIS DOES NOT DO: it never writes code or queues a PRD itself. Agent-initiated, the session '
|
|
262
266
|
+ 'lands as `proposed` and runs only if a human in the RECEIVING project presses "Approve & start". '
|
|
267
|
+
+ 'With a verified autoStart + humanRequestQuote the result status is `auto-approved` and the session '
|
|
268
|
+
+ 'starts on its own; otherwise it stays proposed (check the note for the reason). '
|
|
263
269
|
+ 'There is no callback and no reply channel — do not wait for an answer, and do not tell the user '
|
|
264
270
|
+ 'the other project has "been fixed" or "is working on it". Report only that the proposal was '
|
|
265
|
-
+ 'delivered.',
|
|
271
|
+
+ 'delivered, or that the session was auto-approved and is starting.',
|
|
266
272
|
},
|
|
267
273
|
{
|
|
268
274
|
name: 'project_home_write',
|
|
@@ -30,6 +30,21 @@ function resolveDepSlug(depSlug, candidateSlugs) {
|
|
|
30
30
|
return candidateSlugs.filter((slug) => bareSlug(slug) === bare);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Is this dep ROW satisfied? `completed`, or a `skipped` row stamped
|
|
35
|
+
* `needsReviewAutoResolvedSkip` — the bounded needs_review auto-resolve's
|
|
36
|
+
* terminal decision (scheduler.cjs applyNeedsReviewAutoResolve), deliberately
|
|
37
|
+
* not a permanent block. Shared by schedulerBatch.findBlockingDep (dispatch)
|
|
38
|
+
* and runVerify.checkDeps (post-run): when they disagreed, the picker fired a
|
|
39
|
+
* dependent of an auto-skipped row and the verifier downgraded every finished
|
|
40
|
+
* run back to pending on "is skipped (need completed)" — an unbounded billed
|
|
41
|
+
* re-run loop (Bilko-Launch-Kit validator, ~20 runs, 2026-10-11).
|
|
42
|
+
*/
|
|
43
|
+
function isSatisfiedDepRow(row) {
|
|
44
|
+
return row?.status === 'completed'
|
|
45
|
+
|| (row?.status === 'skipped' && row.needsReviewAutoResolvedSkip === true);
|
|
46
|
+
}
|
|
47
|
+
|
|
33
48
|
/**
|
|
34
49
|
* Levenshtein edit distance. O(n*m) in the two strings' lengths — fine here
|
|
35
50
|
* since it only ever runs over short slug strings (<= 60 chars, PRD_CREATE_SLUG_RE's
|
|
@@ -69,4 +84,4 @@ function findNearMatches(depSlug, candidateSlugs, limit = 3) {
|
|
|
69
84
|
.map((entry) => entry.slug);
|
|
70
85
|
}
|
|
71
86
|
|
|
72
|
-
module.exports = { bareSlug, resolveDepSlug, findNearMatches };
|
|
87
|
+
module.exports = { bareSlug, resolveDepSlug, findNearMatches, isSatisfiedDepRow };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* finalizeReason.cjs — the statusHistory reason for a job's running→terminal
|
|
5
|
+
* finalize transition. Pure; no I/O.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {{
|
|
10
|
+
* effectiveStatus: string,
|
|
11
|
+
* verifyResult?: { verdict?: string, reason?: string } | null,
|
|
12
|
+
* sigtermOverrideReason?: string | null,
|
|
13
|
+
* exitCode?: number | null,
|
|
14
|
+
* }} input
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function finalizeReasonFor({ effectiveStatus, verifyResult, sigtermOverrideReason, exitCode }) {
|
|
18
|
+
if (sigtermOverrideReason) return sigtermOverrideReason;
|
|
19
|
+
if (effectiveStatus === 'completed' && verifyResult?.verdict === 'already_satisfied_on_main') {
|
|
20
|
+
return String(verifyResult.reason);
|
|
21
|
+
}
|
|
22
|
+
if (effectiveStatus === 'needs_review' && verifyResult?.verdict) {
|
|
23
|
+
return verifyResult.reason
|
|
24
|
+
? `needs_review: ${verifyResult.verdict} — ${verifyResult.reason}`
|
|
25
|
+
: `needs_review: ${verifyResult.verdict}`;
|
|
26
|
+
}
|
|
27
|
+
return `run finished with exit ${exitCode}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { finalizeReasonFor };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** Retired flat `scheduler/prds/` sweep runs at most once per hour per process. */
|
|
4
|
+
const FLAT_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {number} nowMs
|
|
8
|
+
* @param {number | null} lastRunMs null = never ran this process (always run)
|
|
9
|
+
* @param {number} [intervalMs]
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
function shouldRunFlatSweep(nowMs, lastRunMs, intervalMs = FLAT_SWEEP_INTERVAL_MS) {
|
|
13
|
+
if (lastRunMs === null || lastRunMs === undefined) return true;
|
|
14
|
+
return nowMs - lastRunMs >= intervalMs;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { shouldRunFlatSweep, FLAT_SWEEP_INTERVAL_MS };
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* runLogRetention.cjs — computes
|
|
5
|
-
* applies) a retention policy over `scheduled-plans/runs/`.
|
|
4
|
+
* runLogRetention.cjs — computes and applies a retention policy over `scheduled-plans/runs/`.
|
|
6
5
|
*
|
|
7
6
|
* That directory is a machine-level Session-Manager runtime artifact store
|
|
8
7
|
* (execution logs of the scheduler's own `claude -p` runs), not anything
|
|
@@ -26,10 +25,10 @@
|
|
|
26
25
|
* slug's own files.
|
|
27
26
|
*
|
|
28
27
|
* SAFETY MODEL (the point of this module — see the PRD that added it):
|
|
29
|
-
* -
|
|
30
|
-
* `schedulerRunLogRetention
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* - Retention is ON by default (DEFAULT_RUN_LOG_RETENTION) when settings
|
|
29
|
+
* carry no `schedulerRunLogRetention`. An explicit `enabled: false` (or a
|
|
30
|
+
* config with no non-empty policy) opts out: computeReport, and
|
|
31
|
+
* applyRetention when opted out, only READ the filesystem.
|
|
33
32
|
* - An entry belonging to a job that is currently live — status `pending`
|
|
34
33
|
* (queued), `running`, `needs_review`, or `investigating` (the exact
|
|
35
34
|
* literals scheduler.cjs uses; see LIVE_STATUSES) — is never eligible,
|
|
@@ -51,6 +50,16 @@ const schedulerPaths = require('./schedulerPaths.cjs');
|
|
|
51
50
|
// checks) — 'completed' and 'failed' are the only terminal ones.
|
|
52
51
|
const LIVE_STATUSES = new Set(['pending', 'running', 'needs_review', 'investigating']);
|
|
53
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Built-in policy applied when settings carry no `schedulerRunLogRetention`:
|
|
55
|
+
* drop runs older than 14 days but always keep each slug's newest 3.
|
|
56
|
+
* (Policy keys follow the module's own names: maxAgeDays / keepPerSlug.)
|
|
57
|
+
*/
|
|
58
|
+
const DEFAULT_RUN_LOG_RETENTION = Object.freeze({
|
|
59
|
+
enabled: true,
|
|
60
|
+
policy: Object.freeze({ maxAgeDays: 14, keepPerSlug: 3 }),
|
|
61
|
+
});
|
|
62
|
+
|
|
54
63
|
const META_SUFFIX = '.meta.json';
|
|
55
64
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
56
65
|
|
|
@@ -259,11 +268,21 @@ function computeReport(runsDir, policy, opts) {
|
|
|
259
268
|
}
|
|
260
269
|
|
|
261
270
|
/**
|
|
262
|
-
*
|
|
263
|
-
*
|
|
271
|
+
* The effective retention config: the caller's `schedulerRunLogRetention`
|
|
272
|
+
* when present, else DEFAULT_RUN_LOG_RETENTION.
|
|
264
273
|
*/
|
|
265
|
-
function
|
|
274
|
+
function resolveRetentionConfig(settings) {
|
|
266
275
|
const cfg = settings && settings.schedulerRunLogRetention;
|
|
276
|
+
return cfg == null ? DEFAULT_RUN_LOG_RETENTION : cfg;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* True when the effective config (see resolveRetentionConfig) has
|
|
281
|
+
* `enabled === true` plus a non-empty policy. An absent setting resolves to
|
|
282
|
+
* the default (enabled); an explicit `enabled: false` opts out.
|
|
283
|
+
*/
|
|
284
|
+
function isRetentionEnabled(settings) {
|
|
285
|
+
const cfg = resolveRetentionConfig(settings);
|
|
267
286
|
if (!cfg || cfg.enabled !== true) return false;
|
|
268
287
|
const policy = cfg.policy;
|
|
269
288
|
return !!policy && (policy.maxAgeDays != null || policy.keepPerSlug != null);
|
|
@@ -372,7 +391,7 @@ function sweepOrphanDirs(runsDir, runningRunIds, errors) {
|
|
|
372
391
|
* nothing removed.
|
|
373
392
|
*/
|
|
374
393
|
function applyRetention(runsDir, settings, opts) {
|
|
375
|
-
const cfg = (settings
|
|
394
|
+
const cfg = resolveRetentionConfig(settings);
|
|
376
395
|
const policy = (cfg && cfg.policy) || {};
|
|
377
396
|
const enabled = isRetentionEnabled(settings);
|
|
378
397
|
const liveKeys = resolveLiveKeysForApply(runsDir, opts, enabled);
|
|
@@ -381,7 +400,7 @@ function applyRetention(runsDir, settings, opts) {
|
|
|
381
400
|
if (!enabled) {
|
|
382
401
|
return {
|
|
383
402
|
deleted: false,
|
|
384
|
-
reason: 'dry-run: schedulerRunLogRetention
|
|
403
|
+
reason: 'dry-run: schedulerRunLogRetention is disabled (enabled !== true, or has no policy)',
|
|
385
404
|
report,
|
|
386
405
|
};
|
|
387
406
|
}
|
|
@@ -424,8 +443,8 @@ function applyRetention(runsDir, settings, opts) {
|
|
|
424
443
|
* applyRetention's own internal queueStore fallback (resolveLiveKeysForApply
|
|
425
444
|
* above), which exists only as a defensive net for callers who forget to
|
|
426
445
|
* thread live-job info through, not as this module's primary path. Deletion
|
|
427
|
-
*
|
|
428
|
-
* schedulerRunLogRetention.enabled ===
|
|
446
|
+
* happens unless the read config opts out with
|
|
447
|
+
* schedulerRunLogRetention.enabled === false (see isRetentionEnabled).
|
|
429
448
|
*
|
|
430
449
|
* Intended to be called once per app boot (see index.cjs's deferred 30s
|
|
431
450
|
* startup timer, alongside finalizeClosedDays) — never on its own timer.
|
|
@@ -438,6 +457,8 @@ function runBootSweep(opts) {
|
|
|
438
457
|
}
|
|
439
458
|
|
|
440
459
|
module.exports = {
|
|
460
|
+
DEFAULT_RUN_LOG_RETENTION,
|
|
461
|
+
resolveRetentionConfig,
|
|
441
462
|
LIVE_STATUSES,
|
|
442
463
|
isLiveJob,
|
|
443
464
|
liveKeysFromJobs,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
const path = require('node:path');
|
|
16
16
|
const os = require('node:os');
|
|
17
17
|
const { projectJobCap, quietMachineWaitMs } = require('./schedulerConfig.cjs');
|
|
18
|
-
const { bareSlug } = require('../prd/depSlugResolve.cjs');
|
|
18
|
+
const { bareSlug, isSatisfiedDepRow } = require('../prd/depSlugResolve.cjs');
|
|
19
19
|
|
|
20
20
|
const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager');
|
|
21
21
|
|
|
@@ -127,20 +127,16 @@ function findUnresolvableDepRoots(projectJobs, satisfiedSlugs = new Set()) {
|
|
|
127
127
|
|
|
128
128
|
function findBlockingDep(job, projectJobs, satisfiedSlugs = new Set()) {
|
|
129
129
|
const { rowsForDep, isKnownSatisfied } = makeDepResolver(projectJobs, satisfiedSlugs);
|
|
130
|
-
// A dep row is blocking unless
|
|
131
|
-
//
|
|
132
|
-
// auto-resolve terminal decision (scheduler.cjs's
|
|
133
|
-
// applyNeedsReviewAutoResolve). That skip is deliberately NOT the
|
|
130
|
+
// A dep row is blocking unless isSatisfiedDepRow — 'completed', or a
|
|
131
|
+
// `needsReviewAutoResolvedSkip` skip. That skip is deliberately NOT the
|
|
134
132
|
// "PRD source vanished, a human must author a fresh PRD" skip the
|
|
135
|
-
// heldBySkippedDep messaging
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
const isSatisfiedRow = (dep) => dep.status === 'completed'
|
|
140
|
-
|| (dep.status === 'skipped' && dep.needsReviewAutoResolvedSkip === true);
|
|
133
|
+
// heldBySkippedDep messaging describes — it already got every bounded
|
|
134
|
+
// chance to resolve itself, so treating it as a permanent dependsOn block
|
|
135
|
+
// would defeat the auto-resolve pass. runVerify.checkDeps MUST use the same
|
|
136
|
+
// predicate, or every run of the dependent is downgraded and re-dispatched.
|
|
141
137
|
return (job.dependsOn ?? []).find((slug) => {
|
|
142
138
|
const rows = rowsForDep(slug);
|
|
143
|
-
if (rows.length > 0) return rows.some((dep) => !
|
|
139
|
+
if (rows.length > 0) return rows.some((dep) => !isSatisfiedDepRow(dep));
|
|
144
140
|
return !isKnownSatisfied(slug);
|
|
145
141
|
});
|
|
146
142
|
}
|
|
@@ -647,18 +647,20 @@ function describeFailureStatus(status) {
|
|
|
647
647
|
return status === 0 ? 'network error' : `HTTP ${status}`;
|
|
648
648
|
}
|
|
649
649
|
|
|
650
|
-
function applyFailureBackoff(status) {
|
|
650
|
+
function applyFailureBackoff(status, channel) {
|
|
651
651
|
if (status === 429 || status === 0 || status >= 500) {
|
|
652
652
|
S.consecutiveFailures += 1;
|
|
653
653
|
const ms = Math.min(BACKOFF_BASE_MS * (2 ** (S.consecutiveFailures - 1)), BACKOFF_MAX_MS);
|
|
654
654
|
S.backoffUntil = Date.now() + ms;
|
|
655
655
|
S.lastError = { status, message: describeFailureStatus(status), at: Date.now() };
|
|
656
|
+
logWarn('telemetry send failed', { channel, status, consecutiveFailures: S.consecutiveFailures, backoffMs: ms });
|
|
656
657
|
return;
|
|
657
658
|
}
|
|
658
659
|
if (status >= 400 && status < 500) {
|
|
659
660
|
// Malformed-contract circuit breaker (includes 401) — not retried.
|
|
660
661
|
S.disabledForProcess = true;
|
|
661
662
|
S.lastError = { status, message: describeFailureStatus(status), at: Date.now() };
|
|
663
|
+
logWarn('telemetry disabled for process', { status });
|
|
662
664
|
}
|
|
663
665
|
}
|
|
664
666
|
|
|
@@ -705,7 +707,7 @@ async function flushImpl(reason) {
|
|
|
705
707
|
result.sent.push(r.recordId);
|
|
706
708
|
} else {
|
|
707
709
|
result.failed.push(r.recordId);
|
|
708
|
-
applyFailureBackoff(res.status);
|
|
710
|
+
applyFailureBackoff(res.status, channel);
|
|
709
711
|
sawFailure = true;
|
|
710
712
|
break;
|
|
711
713
|
}
|
|
@@ -726,7 +728,7 @@ async function flushImpl(reason) {
|
|
|
726
728
|
}
|
|
727
729
|
} else {
|
|
728
730
|
for (const r of batch) result.failed.push(r.recordId);
|
|
729
|
-
applyFailureBackoff(res.status);
|
|
731
|
+
applyFailureBackoff(res.status, channel);
|
|
730
732
|
sawFailure = true;
|
|
731
733
|
break;
|
|
732
734
|
}
|
|
@@ -739,7 +741,9 @@ async function flushImpl(reason) {
|
|
|
739
741
|
if (safeReason === 'daily' && result.failed.length === 0) {
|
|
740
742
|
S.settings = await telemetrySettings.save({ lastDailyFlushAt: new Date().toISOString() });
|
|
741
743
|
}
|
|
742
|
-
} catch {
|
|
744
|
+
} catch (e) {
|
|
745
|
+
logWarn('telemetry flush threw', { error: e && e.message ? e.message : String(e) });
|
|
746
|
+
}
|
|
743
747
|
S.lastFlushAt = Date.now();
|
|
744
748
|
S.lastFlushReason = safeReason;
|
|
745
749
|
return result;
|
|
@@ -105,7 +105,49 @@ function readJsonOrSync(file, fallback) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
function defaultIsPidAlive(pid) {
|
|
109
|
+
try {
|
|
110
|
+
process.kill(pid, 0);
|
|
111
|
+
return true;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
return e?.code === 'EPERM'; // exists but owned by someone else
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Remove orphaned `<file>.tmp-<pid>-<ts>-<rand>` files left by crashed
|
|
119
|
+
* writers. Direct children of `dir` only (no recursion); a file goes only when
|
|
120
|
+
* its mtime is older than `olderThanMs` AND its embedded pid is not alive.
|
|
121
|
+
* `.corrupt-*` / `.bak*` forensics never match. Never throws.
|
|
122
|
+
*/
|
|
123
|
+
function sweepStaleTmpFiles(dir, { olderThanMs = 3600000, isPidAlive = defaultIsPidAlive } = {}) {
|
|
124
|
+
const removed = [];
|
|
125
|
+
let names;
|
|
126
|
+
try {
|
|
127
|
+
names = fs.readdirSync(dir);
|
|
128
|
+
} catch {
|
|
129
|
+
return { removed };
|
|
130
|
+
}
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
for (const name of names) {
|
|
133
|
+
try {
|
|
134
|
+
const m = /\.tmp-(\d+)-/.exec(name);
|
|
135
|
+
if (!m) continue;
|
|
136
|
+
if (/\.(corrupt|bak)/.test(name)) continue;
|
|
137
|
+
const full = path.join(dir, name);
|
|
138
|
+
const st = fs.lstatSync(full);
|
|
139
|
+
if (!st.isFile()) continue;
|
|
140
|
+
if (now - st.mtimeMs <= olderThanMs) continue;
|
|
141
|
+
if (isPidAlive(Number(m[1]))) continue;
|
|
142
|
+
fs.unlinkSync(full);
|
|
143
|
+
removed.push(full);
|
|
144
|
+
} catch { /* per-file errors are skipped */ }
|
|
145
|
+
}
|
|
146
|
+
return { removed };
|
|
147
|
+
}
|
|
148
|
+
|
|
108
149
|
module.exports = {
|
|
150
|
+
sweepStaleTmpFiles,
|
|
109
151
|
tmpPathFor,
|
|
110
152
|
writeTextAtomic,
|
|
111
153
|
writeTextAtomicSync,
|
|
@@ -11,7 +11,7 @@ const { monitorEventLoopDelay } = require('node:perf_hooks');
|
|
|
11
11
|
|
|
12
12
|
const LOOP_DELAY_RESOLUTION_MS = 20;
|
|
13
13
|
// A single beat whose worst loop delay exceeds this is a visible main-thread stall.
|
|
14
|
-
const LOOP_STALL_WARN_MS =
|
|
14
|
+
const LOOP_STALL_WARN_MS = 1000;
|
|
15
15
|
|
|
16
16
|
const NS_PER_MS = 1e6;
|
|
17
17
|
const toMs = (ns) => Math.round(ns / NS_PER_MS * 10) / 10;
|