claude-code-session-manager 0.112.0 → 0.114.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-DZFM7Lto.js} +2 -2
- package/dist/assets/{Button-36j3PzYv.js → Button-BJVFKdGH.js} +1 -1
- package/dist/assets/{DataModel-DUhANo4l.js → DataModel-CebvcP_y.js} +1 -1
- package/dist/assets/{History-BeZzNjmv.js → History-BwISEUhR.js} +2 -2
- package/dist/assets/{Hooks-iQ8845Hp.js → Hooks-Dac3smYP.js} +3 -3
- package/dist/assets/{Library-DPE1h-1L.js → Library-DTjsUr9W.js} +7 -7
- package/dist/assets/{ListDetail-PStOuQAo.js → ListDetail-B1L5TbjS.js} +1 -1
- package/dist/assets/{MarkdownEditor-DPw-N2-m.js → MarkdownEditor-BFRhWZD4.js} +1 -1
- package/dist/assets/{McpServers-CUyRTNep.js → McpServers-CBjGCMg0.js} +2 -2
- package/dist/assets/Memory-DdLi5fqe.js +8 -0
- package/dist/assets/{Panel-_VZ_uXNg.js → Panel-BQkkkjex.js} +1 -1
- package/dist/assets/{Permissions-PPRmfNbK.js → Permissions-CaACwY0B.js} +3 -3
- package/dist/assets/{Plugins-BFcvfvlx.js → Plugins-DJeBwVoh.js} +2 -2
- package/dist/assets/ProvenanceBadge--N5j8xiK.js +1 -0
- package/dist/assets/{RemoteMachines-D5V0lzgW.js → RemoteMachines-WjWaDHEA.js} +2 -2
- package/dist/assets/{SaveBar-Dw64FVdM.js → SaveBar-DFmOjb4_.js} +1 -1
- package/dist/assets/{Scheduler-C8s_pJ3I.js → Scheduler-Miap6_aY.js} +8 -8
- package/dist/assets/{ScopeSwitcher-D-nKDMAE.js → ScopeSwitcher-CiEujp4L.js} +1 -1
- package/dist/assets/Settings-UGpW4P0j.js +4 -0
- package/dist/assets/{SkillReferenceGraph-BbZFMHim.js → SkillReferenceGraph-BgyAnxGW.js} +1 -1
- package/dist/assets/Skills-BQUpnNfx.js +2 -0
- package/dist/assets/SystemPrompt-CQjOgHGT.js +1 -0
- package/dist/assets/TagLibrary-CTVpoLl0.js +1 -0
- package/dist/assets/{TiptapBody-Cr-hxX8L.js → TiptapBody-BtOcrdRR.js} +1 -1
- package/dist/assets/{Toggle-7WpPzcZH.js → Toggle-Chri4G19.js} +1 -1
- package/dist/assets/{ViewTabs-9lQCRwWJ.js → ViewTabs-BIenDGVv.js} +1 -1
- package/dist/assets/{historyTotals-BrFxw5J1.js → historyTotals-CX8mUxJY.js} +1 -1
- package/dist/assets/{index-CTBpf-26.js → index-AVrhGmUO.js} +163 -163
- package/dist/assets/{index-Dutcsly-.css → index-HpEbvDiE.css} +1 -1
- package/dist/assets/{settingsSchema-Dh5Kl7VO.js → settingsSchema-uqwlznbH.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-CMpx0d04.js → useAgentsDirWatch-BaezRtWw.js} +1 -1
- package/dist/assets/{useRemotePeers-CI2axd-L.js → useRemotePeers-BuFbBqDt.js} +1 -1
- package/dist/index.html +2 -2
- 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 -2
- package/src/main/ipcSchemas.cjs +27 -34
- 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/memoryDirResolve.cjs +139 -0
- package/src/main/lib/memoryIndex.cjs +98 -0
- 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/memoryAggregate.cjs +15 -8
- package/src/main/memoryTool.cjs +151 -22
- 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 +47 -52
- package/src/preload/index.cjs +25 -21
- package/dist/assets/Memory-bZ2xjBGy.js +0 -8
- 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
- package/src/main/agentMemory.cjs +0 -246
package/src/main/config.cjs
CHANGED
|
@@ -392,8 +392,21 @@ function watchKeyOf(key, depth) {
|
|
|
392
392
|
*/
|
|
393
393
|
function watch(paths, senderId, opts) {
|
|
394
394
|
const depth = opts?.depth;
|
|
395
|
+
const watched = [];
|
|
396
|
+
const rejected = [];
|
|
395
397
|
for (const rawPath of paths) {
|
|
396
|
-
|
|
398
|
+
try {
|
|
399
|
+
validatePath(expandHome(rawPath)); // security check — throws if out of bounds
|
|
400
|
+
} catch (err) {
|
|
401
|
+
// Per-path: one bad path must not abort the rest of the batch.
|
|
402
|
+
rejected.push(rawPath);
|
|
403
|
+
if (!warnedRejectedPaths.has(rawPath)) {
|
|
404
|
+
warnedRejectedPaths.add(rawPath);
|
|
405
|
+
logs.writeLine({ level: 'warn', scope: 'config', message: 'config:watch path reject', meta: { path: rawPath, error: err?.message } });
|
|
406
|
+
}
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
watched.push(rawPath);
|
|
397
410
|
const key = normalizePathKey(rawPath);
|
|
398
411
|
const mapKey = watchKeyOf(key, depth);
|
|
399
412
|
if (senderId != null) {
|
|
@@ -430,8 +443,12 @@ function watch(paths, senderId, opts) {
|
|
|
430
443
|
});
|
|
431
444
|
watchers.set(mapKey, { watcher: w, refCount: 1 });
|
|
432
445
|
}
|
|
446
|
+
return { watched, rejected };
|
|
433
447
|
}
|
|
434
448
|
|
|
449
|
+
// Distinct rejected watch paths already warned about (once per process).
|
|
450
|
+
const warnedRejectedPaths = new Set();
|
|
451
|
+
|
|
435
452
|
function unwatch(paths, senderId, opts) {
|
|
436
453
|
for (const rawPath of paths) {
|
|
437
454
|
const key = watchKeyOf(normalizePathKey(rawPath), opts?.depth);
|
package/src/main/index.cjs
CHANGED
|
@@ -115,7 +115,6 @@ const { registerProjectBriefIpc } = require('./projectBrief.cjs');
|
|
|
115
115
|
const { registerProjectPagesIpc, attachWindow: attachProjectPagesWindow, closeAllOutputWatchers: closeAllProjectPagesWatchers } = require('./projectPages.cjs');
|
|
116
116
|
const promptSessionTranscript = require('./promptSessionTranscript.cjs');
|
|
117
117
|
const { computeEpicDelegationStats } = require('./lib/epic/epicDelegationStats.cjs');
|
|
118
|
-
const agentMemory = require('./agentMemory.cjs');
|
|
119
118
|
const git = require('./git.cjs');
|
|
120
119
|
const heapSnapshot = require('./heapSnapshot.cjs');
|
|
121
120
|
const filesIpc = require('./files.cjs');
|
|
@@ -1034,7 +1033,6 @@ ipcMain.handle('auditLog:append', validated(schemas.auditLogAppend, async ({ kin
|
|
|
1034
1033
|
appendAuditEvent(kind, { cwd, epicId, source });
|
|
1035
1034
|
return { ok: true };
|
|
1036
1035
|
}));
|
|
1037
|
-
agentMemory.registerAgentMemoryHandlers();
|
|
1038
1036
|
git.register(ipcMain);
|
|
1039
1037
|
filesIpc.registerFilesHandlers();
|
|
1040
1038
|
registerDocEditHandlers();
|
|
@@ -1402,6 +1400,12 @@ app.whenReady().then(async () => {
|
|
|
1402
1400
|
console.log(`[main] scheduler ownership held by pid ${ownership.holderPid} — running scheduler-passive (no reconciliation, no admin server)`);
|
|
1403
1401
|
logs.writeLine({ scope: 'scheduler', level: 'info', message: 'scheduler-passive: ownership held by another instance', meta: { holderPid: ownership.holderPid } });
|
|
1404
1402
|
}
|
|
1403
|
+
// Sweep orphaned `<file>.tmp-<pid>-…` files from crashed atomic writers in the
|
|
1404
|
+
// machine-global state dir (per-project dirs belong to reconcile).
|
|
1405
|
+
try {
|
|
1406
|
+
const { removed } = require('./lib/util/atomicFs.cjs').sweepStaleTmpFiles(path.join(os.homedir(), '.claude', 'session-manager'));
|
|
1407
|
+
logs.writeLine({ scope: 'main', level: 'info', message: 'stale tmp sweep', meta: { removed: removed.length } });
|
|
1408
|
+
} catch { /* sweep is best-effort */ }
|
|
1405
1409
|
// First-boot default: install the bundled session-manager-dev plugin (its 3
|
|
1406
1410
|
// dev skills) from the app's own marketplace. One-shot + idempotent; never
|
|
1407
1411
|
// throws. SM_SEED_DEV_PLUGIN_DISABLE=1 to opt out.
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -634,30 +634,48 @@ const { MEMORY_SLUG_RE } = require('./lib/memorySlug.cjs');
|
|
|
634
634
|
// 1 MiB hard cap — matches MAX_FILE_BYTES in memoryTool.cjs.
|
|
635
635
|
const MEMORY_MAX_BYTES = 1024 * 1024;
|
|
636
636
|
|
|
637
|
+
// Optional native-dir addressing: `cwd` selects Claude Code's own workspace dir
|
|
638
|
+
// (git root + autoMemoryDirectory); `agent` selects native subagent memory.
|
|
639
|
+
const MEMORY_CWD = z.string().max(4096).optional();
|
|
640
|
+
const MEMORY_AGENT = z.object({
|
|
641
|
+
name: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
|
|
642
|
+
scope: z.enum(['user', 'project', 'local']),
|
|
643
|
+
}).strict().optional();
|
|
644
|
+
|
|
637
645
|
const memoryList = z.object({
|
|
638
646
|
workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
|
|
647
|
+
cwd: MEMORY_CWD,
|
|
648
|
+
agent: MEMORY_AGENT,
|
|
639
649
|
}).strict();
|
|
640
650
|
|
|
641
651
|
const memoryRead = z.object({
|
|
642
652
|
workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
|
|
643
653
|
name: z.string().regex(MEMORY_SLUG_RE),
|
|
654
|
+
cwd: MEMORY_CWD,
|
|
655
|
+
agent: MEMORY_AGENT,
|
|
644
656
|
}).strict();
|
|
645
657
|
|
|
646
658
|
const memoryWrite = z.object({
|
|
647
659
|
workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
|
|
648
660
|
name: z.string().regex(MEMORY_SLUG_RE),
|
|
649
661
|
content: z.string().max(MEMORY_MAX_BYTES),
|
|
662
|
+
cwd: MEMORY_CWD,
|
|
663
|
+
agent: MEMORY_AGENT,
|
|
650
664
|
}).strict();
|
|
651
665
|
|
|
652
666
|
const memoryDelete = z.object({
|
|
653
667
|
workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
|
|
654
668
|
name: z.string().regex(MEMORY_SLUG_RE),
|
|
669
|
+
cwd: MEMORY_CWD,
|
|
670
|
+
agent: MEMORY_AGENT,
|
|
655
671
|
}).strict();
|
|
656
672
|
|
|
657
673
|
const memoryCreate = z.object({
|
|
658
674
|
workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
|
|
659
675
|
name: z.string().regex(MEMORY_SLUG_RE),
|
|
660
676
|
description: z.string().max(2048).optional(),
|
|
677
|
+
cwd: MEMORY_CWD,
|
|
678
|
+
agent: MEMORY_AGENT,
|
|
661
679
|
}).strict();
|
|
662
680
|
|
|
663
681
|
// memory:stale — deterministic staleness scorer (PRD 601). `cwd`, when given,
|
|
@@ -666,6 +684,11 @@ const memoryCreate = z.object({
|
|
|
666
684
|
const memoryStale = z.object({
|
|
667
685
|
workspace: z.string().regex(MEMORY_WORKSPACE_RE).optional(),
|
|
668
686
|
cwd: z.string().max(4096).optional(),
|
|
687
|
+
agent: MEMORY_AGENT,
|
|
688
|
+
}).strict();
|
|
689
|
+
|
|
690
|
+
const memoryAgents = z.object({
|
|
691
|
+
cwd: MEMORY_CWD,
|
|
669
692
|
}).strict();
|
|
670
693
|
|
|
671
694
|
// memory:aggregate — Memory Clusters (PRD 356). `workspace` here is already
|
|
@@ -826,36 +849,6 @@ const auditLogAppend = z.object({
|
|
|
826
849
|
source: z.string().min(1).max(256),
|
|
827
850
|
}).strict();
|
|
828
851
|
|
|
829
|
-
// ──────────────────────────────────────────── Per-subagent memory
|
|
830
|
-
// Distinct from the workspace-scoped Memory tool: agentMemory is keyed by
|
|
831
|
-
// subagent name (the .md filename in ~/.claude/agents/, e.g. "code-reviewer"),
|
|
832
|
-
// not by cwd. Storage lives at ~/.claude/session-manager/agent-memory/<agentId>.json.
|
|
833
|
-
// Regex caps must stay in lockstep with agentMemory.cjs AGENT_ID_RE / ENTRY_ID_RE.
|
|
834
|
-
const AGENT_MEMORY_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
835
|
-
const AGENT_MEMORY_CATEGORY = z.enum(['command', 'preference', 'pattern', 'failure', 'workflow']);
|
|
836
|
-
const AGENT_MEMORY_MAX_BODY = 1024 * 1024; // 1 MiB — must match MAX_BODY_BYTES in agentMemory.cjs
|
|
837
|
-
|
|
838
|
-
const agentMemoryList = z.object({
|
|
839
|
-
agentId: z.string().regex(AGENT_MEMORY_ID_RE),
|
|
840
|
-
}).strict();
|
|
841
|
-
|
|
842
|
-
const agentMemoryGet = z.object({
|
|
843
|
-
agentId: z.string().regex(AGENT_MEMORY_ID_RE),
|
|
844
|
-
entryId: z.string().regex(AGENT_MEMORY_ID_RE),
|
|
845
|
-
}).strict();
|
|
846
|
-
|
|
847
|
-
const agentMemorySet = z.object({
|
|
848
|
-
agentId: z.string().regex(AGENT_MEMORY_ID_RE),
|
|
849
|
-
entryId: z.string().regex(AGENT_MEMORY_ID_RE),
|
|
850
|
-
body: z.string().max(AGENT_MEMORY_MAX_BODY),
|
|
851
|
-
category: AGENT_MEMORY_CATEGORY.optional(),
|
|
852
|
-
}).strict();
|
|
853
|
-
|
|
854
|
-
const agentMemoryDelete = z.object({
|
|
855
|
-
agentId: z.string().regex(AGENT_MEMORY_ID_RE),
|
|
856
|
-
entryId: z.string().regex(AGENT_MEMORY_ID_RE),
|
|
857
|
-
}).strict();
|
|
858
|
-
|
|
859
852
|
// ──────────────────────────────────────────── Exchanges (PRD 324 read path)
|
|
860
853
|
const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
861
854
|
|
|
@@ -919,6 +912,9 @@ const chatRun = z.object({
|
|
|
919
912
|
// Originating PromptTicket.id (PRD 748) when this run was dequeued from a
|
|
920
913
|
// tab's prompt queue — absent for a fresh manual send with no ticket.
|
|
921
914
|
promptId: z.string().min(1).max(128).optional(),
|
|
915
|
+
// Who authored this send. Only 'human' is auto-approve evidence for
|
|
916
|
+
// crossProjectFeedback; absent or any other value is treated as not-human.
|
|
917
|
+
origin: z.enum(['human', 'external', 'inbound-proposal']).optional(),
|
|
922
918
|
});
|
|
923
919
|
|
|
924
920
|
const chatCancel = z.object({
|
|
@@ -1279,6 +1275,7 @@ module.exports = {
|
|
|
1279
1275
|
memoryCreate,
|
|
1280
1276
|
memoryAggregate,
|
|
1281
1277
|
memoryStale,
|
|
1278
|
+
memoryAgents,
|
|
1282
1279
|
projectBriefCwd,
|
|
1283
1280
|
projectPagesCwd,
|
|
1284
1281
|
projectHomeAdminWriteBody,
|
|
@@ -1295,10 +1292,6 @@ module.exports = {
|
|
|
1295
1292
|
promptSessionsGetWorktreeDisabled,
|
|
1296
1293
|
promptSessionsSetWorktreeDisabled,
|
|
1297
1294
|
auditLogAppend,
|
|
1298
|
-
agentMemoryList,
|
|
1299
|
-
agentMemoryGet,
|
|
1300
|
-
agentMemorySet,
|
|
1301
|
-
agentMemoryDelete,
|
|
1302
1295
|
watchersAdd,
|
|
1303
1296
|
watchersList,
|
|
1304
1297
|
watchersRemove,
|
|
@@ -37,6 +37,17 @@ const TMP_DROP_ROOTS = {
|
|
|
37
37
|
get prefixDropRoots() { return [WORKTREE_KIND_CONFIG.job.root, WORKTREE_KIND_CONFIG.epic.root]; },
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
// Test-harness project cwds live at <tmpdir>/sst-*/…. They are never real
|
|
41
|
+
// projects, and leaking them into the active list made the renderer's batched
|
|
42
|
+
// config:watch hit 618 "Path outside allowed boundaries" rejects. Only the
|
|
43
|
+
// `sst-` first segment is matched, so test-stubbed HOMEs that are not sst-*
|
|
44
|
+
// dirs (see the exactDropCwds note above) are still kept.
|
|
45
|
+
function isSstHarnessCwd(cwd) {
|
|
46
|
+
const rel = path.relative(TMPDIR, cwd);
|
|
47
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
|
48
|
+
return rel.split(path.sep)[0].startsWith('sst-');
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
// Transcript reads only need the last line with a `cwd` field — a few dozen
|
|
41
52
|
// lines is always enough. 64 KB keeps peak RSS proportionate.
|
|
42
53
|
const TAIL_BYTES = 64 * 1024;
|
|
@@ -389,6 +400,7 @@ function deriveCwds(records, maxAgeMin, maxCwds, tmpDropRoots) {
|
|
|
389
400
|
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
390
401
|
});
|
|
391
402
|
if (isUnderPrefixDropRoot) return;
|
|
403
|
+
if (isSstHarnessCwd(cwd)) return;
|
|
392
404
|
if (seen.has(cwd) || result.length >= maxCwds) return;
|
|
393
405
|
// Must exist AND be a directory — a transcript naming a since-deleted
|
|
394
406
|
// path, or a file, is not a project.
|
|
@@ -456,6 +468,7 @@ module.exports = {
|
|
|
456
468
|
allProjectCwds,
|
|
457
469
|
projectRootOf,
|
|
458
470
|
worktreeMainRootOf,
|
|
471
|
+
isSstHarnessCwd,
|
|
459
472
|
bustProjectCwdCache,
|
|
460
473
|
__waitForPendingRefresh,
|
|
461
474
|
};
|
|
@@ -13,20 +13,39 @@
|
|
|
13
13
|
|
|
14
14
|
const { readTail } = require('../util/fileTail.cjs');
|
|
15
15
|
|
|
16
|
-
/** Scan the tail of a job's log for
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
/** Scan the tail of a job's log for a GENUINE rate-limit rejection. Parses the
|
|
17
|
+
* last 16 KB line by line as JSON (non-JSON / partial lines are skipped) and
|
|
18
|
+
* returns true only for: (a) a rate_limit_event whose rate_limit_info.status
|
|
19
|
+
* is 'rejected'; (b) a result event with api_error_status 429; (c) an
|
|
20
|
+
* is_error result event whose `result` text is the CLI's limit message
|
|
21
|
+
* ("You've hit your ... limit" / "You've reached your ... limit").
|
|
22
|
+
*
|
|
23
|
+
* Never substring-match the raw text: informational rate_limit_event lines
|
|
24
|
+
* (status 'allowed' / 'allowed_warning') carry rateLimitType on every run, so
|
|
25
|
+
* the old substring match turned any non-zero exit (e.g. a watchdog SIGTERM,
|
|
26
|
+
* exit 143) into a machine-wide rate_limit pause (2026-10-11 incident: 4-day
|
|
27
|
+
* pause at 58% weekly). Tool output may also mention 429 or "rate limit". */
|
|
21
28
|
function detectRateLimitInLog(logPath) {
|
|
22
29
|
try {
|
|
23
30
|
const text = readTail(logPath, 16384);
|
|
24
31
|
if (!text) return false;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
||
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
for (const line of text.split('\n')) {
|
|
33
|
+
const t = line.trim();
|
|
34
|
+
if (!t || t[0] !== '{') continue;
|
|
35
|
+
let ev;
|
|
36
|
+
try { ev = JSON.parse(t); } catch { continue; }
|
|
37
|
+
if (!ev || typeof ev !== 'object') continue;
|
|
38
|
+
if (ev.type === 'rate_limit_event') {
|
|
39
|
+
if (ev.rate_limit_info && ev.rate_limit_info.status === 'rejected') return true;
|
|
40
|
+
} else if (ev.type === 'result') {
|
|
41
|
+
if (ev.api_error_status === 429) return true;
|
|
42
|
+
if (ev.is_error === true && typeof ev.result === 'string'
|
|
43
|
+
&& (/You'?ve hit your .*limit/.test(ev.result) || /You'?ve reached your .* limit/.test(ev.result))) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
30
49
|
} catch {
|
|
31
50
|
return false;
|
|
32
51
|
}
|
|
@@ -21,17 +21,21 @@ const { readTail } = require('../util/fileTail.cjs');
|
|
|
21
21
|
const TAIL_BYTES = 64 * 1024;
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Scans a run log's tail for rate_limit_event entries
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* Scans a run log's tail for rate_limit_event entries. The REJECTED event is
|
|
25
|
+
* authoritative (2026-10-11 incident: an allowed-only log with a seven_day
|
|
26
|
+
* window pause landed on the seven_day reset 4 days out): if any event has
|
|
27
|
+
* status 'rejected', the LAST one's reset wins — rate_limit_info.resetsAt, else
|
|
28
|
+
* unifiedWindows[rateLimitType].resetsAt. Otherwise, a last event carrying a
|
|
29
|
+
* status (allowed / allowed_warning) yields null, even if a window shows
|
|
30
|
+
* utilization >= 1.0 (overage is not a rejection). Legacy: a last event with
|
|
31
|
+
* no status falls back to the latest resetsAt among windows at utilization >= 1.0.
|
|
29
32
|
*/
|
|
30
33
|
function resolveBindingRateLimitReset(logPath) {
|
|
31
34
|
const text = readTail(logPath, TAIL_BYTES);
|
|
32
35
|
if (!text) return null;
|
|
33
36
|
|
|
34
|
-
let
|
|
37
|
+
let lastRejected = null;
|
|
38
|
+
let lastEvent = null;
|
|
35
39
|
for (const line of text.split('\n')) {
|
|
36
40
|
const trimmed = line.trim();
|
|
37
41
|
if (!trimmed || trimmed[0] !== '{') continue;
|
|
@@ -41,12 +45,20 @@ function resolveBindingRateLimitReset(logPath) {
|
|
|
41
45
|
} catch {
|
|
42
46
|
continue;
|
|
43
47
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
if (event?.type !== 'rate_limit_event' || !event.rate_limit_info) continue;
|
|
49
|
+
lastEvent = event.rate_limit_info;
|
|
50
|
+
if (lastEvent.status === 'rejected') lastRejected = lastEvent;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (lastRejected) {
|
|
54
|
+
if (typeof lastRejected.resetsAt === 'number') return lastRejected.resetsAt;
|
|
55
|
+
const typed = lastRejected.unifiedWindows?.[lastRejected.rateLimitType]?.resetsAt;
|
|
56
|
+
return typeof typed === 'number' ? typed : null;
|
|
48
57
|
}
|
|
49
|
-
if (!
|
|
58
|
+
if (!lastEvent || lastEvent.status !== undefined) return null;
|
|
59
|
+
|
|
60
|
+
const lastWindows = lastEvent.unifiedWindows;
|
|
61
|
+
if (!lastWindows || typeof lastWindows !== 'object') return null;
|
|
50
62
|
|
|
51
63
|
let latestBoundReset = null;
|
|
52
64
|
for (const window of Object.values(lastWindows)) {
|
|
@@ -6,7 +6,6 @@ const path = require('node:path');
|
|
|
6
6
|
const os = require('node:os');
|
|
7
7
|
const { spawn, execFileSync } = require('node:child_process');
|
|
8
8
|
const { cleanChildEnv, pathWithUserBins } = require('./process/cleanEnv.cjs');
|
|
9
|
-
const atomicFs = require('./util/atomicFs.cjs');
|
|
10
9
|
const { resolveClaudeBin, claudeSpawnTarget } = require('./claude/claudeBin.cjs');
|
|
11
10
|
|
|
12
11
|
const CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json');
|
|
@@ -47,22 +46,9 @@ function keychainAccount() {
|
|
|
47
46
|
return os.userInfo().username;
|
|
48
47
|
}
|
|
49
48
|
|
|
50
|
-
/** Write the credential JSON back into the Keychain (-U upserts in place). */
|
|
51
|
-
function writeKeychainRaw(value) {
|
|
52
|
-
execFileSync(
|
|
53
|
-
'security',
|
|
54
|
-
['add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', keychainAccount(), '-w', value],
|
|
55
|
-
{ timeout: 10_000, stdio: 'ignore' },
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
49
|
const REFRESH_LOG_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'credential-refresh.log');
|
|
59
50
|
const REFRESH_LOG_MAX_BYTES = 100 * 1024;
|
|
60
51
|
|
|
61
|
-
// Standard OAuth 2.0 refresh grant — endpoint discovered from Claude Code CLI behavior.
|
|
62
|
-
// Returns { kind: 'unsupported' } if the endpoint returns 404 or cannot be reached,
|
|
63
|
-
// allowing the caller to fall back gracefully.
|
|
64
|
-
const OAUTH_TOKEN_URL = 'https://claude.ai/api/auth/oauth/token';
|
|
65
|
-
|
|
66
52
|
/** Parse a raw credential JSON blob (from file or Keychain) into a result. */
|
|
67
53
|
function parseCredsRaw(raw, source) {
|
|
68
54
|
let data;
|
|
@@ -125,14 +111,13 @@ function isExpiringSoon(creds, withinMs = 5 * 60_000) {
|
|
|
125
111
|
* isRefreshFutile(creds) → boolean
|
|
126
112
|
*
|
|
127
113
|
* True when the token is already expired AND there is no refresh token to
|
|
128
|
-
* present. In that state
|
|
129
|
-
* short-circuits to 'unsupported' (it needs `creds.refreshToken`), and the
|
|
114
|
+
* present. In that state the refresh avenue is a dead end: the
|
|
130
115
|
* `claude --version` CLI fallback performs its own silent OAuth refresh from
|
|
131
116
|
* that SAME missing refresh token, so it cannot help either. The only recovery
|
|
132
117
|
* is an out-of-band `claude login` — which refreshIfNeeded picks up on its very
|
|
133
118
|
* next call via the isExpiringSoon early-return, since fresh creds never reach
|
|
134
119
|
* this path. Left ungated, this state made pollLoop spawn a `claude --version`
|
|
135
|
-
* process and write the
|
|
120
|
+
* process and write the cli_fallback_ok →
|
|
136
121
|
* auth_failed_expired triple on EVERY 15 s poll, forever (observed 2026-09-23:
|
|
137
122
|
* expiredAtMs 1775277875159, identical triple every ~15 min across the log).
|
|
138
123
|
*/
|
|
@@ -153,17 +138,6 @@ function __resetFutileRefreshThrottle() {
|
|
|
153
138
|
lastFutileRefreshLoggedAt = null;
|
|
154
139
|
}
|
|
155
140
|
|
|
156
|
-
async function writeCredentials(rawData, freshOauth, source = 'file') {
|
|
157
|
-
const next = { ...rawData, claudeAiOauth: { ...rawData.claudeAiOauth, ...freshOauth } };
|
|
158
|
-
if (source === 'keychain') {
|
|
159
|
-
// macOS: upsert back into the Keychain. Sync + may throw — caller catches
|
|
160
|
-
// and falls back to the `claude --version` CLI refresh path.
|
|
161
|
-
writeKeychainRaw(JSON.stringify(next));
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
await atomicFs.writeJsonAtomic(CREDS_PATH, next, { mode: 0o600, newline: false });
|
|
165
|
-
}
|
|
166
|
-
|
|
167
141
|
function appendRefreshLog(entry) {
|
|
168
142
|
try {
|
|
169
143
|
const line = JSON.stringify({ ...entry, ts: new Date().toISOString() }) + '\n';
|
|
@@ -179,36 +153,7 @@ function appendRefreshLog(entry) {
|
|
|
179
153
|
} catch { /* non-fatal; telemetry must not break the main flow */ }
|
|
180
154
|
}
|
|
181
155
|
|
|
182
|
-
//
|
|
183
|
-
async function tryOAuthRefresh(creds) {
|
|
184
|
-
if (!creds.refreshToken) return { kind: 'unsupported', message: 'no refresh token in credentials' };
|
|
185
|
-
try {
|
|
186
|
-
const r = await fetch(OAUTH_TOKEN_URL, {
|
|
187
|
-
method: 'POST',
|
|
188
|
-
headers: { 'Content-Type': 'application/json' },
|
|
189
|
-
body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: creds.refreshToken }),
|
|
190
|
-
signal: AbortSignal.timeout(10_000),
|
|
191
|
-
});
|
|
192
|
-
if (r.status === 404) return { kind: 'unsupported', message: 'refresh endpoint not found (HTTP 404)' };
|
|
193
|
-
if (r.status === 401 || r.status === 403) return { kind: 'auth', message: `refresh rejected: HTTP ${r.status}` };
|
|
194
|
-
if (!r.ok) return { kind: 'transient', message: `HTTP ${r.status}` };
|
|
195
|
-
const j = await r.json();
|
|
196
|
-
if (!j.access_token) return { kind: 'auth', message: 'no access_token in refresh response' };
|
|
197
|
-
return {
|
|
198
|
-
kind: 'ok',
|
|
199
|
-
fresh: {
|
|
200
|
-
accessToken: j.access_token,
|
|
201
|
-
refreshToken: j.refresh_token ?? creds.refreshToken,
|
|
202
|
-
expiresAt: j.expires_at ?? (Date.now() + (j.expires_in ?? 3600) * 1000),
|
|
203
|
-
},
|
|
204
|
-
};
|
|
205
|
-
} catch (e) {
|
|
206
|
-
if (e?.name === 'TimeoutError') return { kind: 'transient', message: 'refresh request timed out' };
|
|
207
|
-
return { kind: 'unsupported', message: `refresh error: ${e?.message}` };
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Stretch fallback: spawning `claude --version` triggers silent token refresh in the CLI binary.
|
|
156
|
+
// Sole refresh avenue: spawning `claude --version` triggers silent token refresh in the CLI binary.
|
|
212
157
|
function tryCliFallback() {
|
|
213
158
|
return new Promise((resolve) => {
|
|
214
159
|
let settled = false;
|
|
@@ -244,7 +189,7 @@ function tryCliFallback() {
|
|
|
244
189
|
async function refreshIfNeeded(forceRefresh = false) {
|
|
245
190
|
const cr = await readCredentials();
|
|
246
191
|
if (cr.kind !== 'ok') return cr;
|
|
247
|
-
const { creds
|
|
192
|
+
const { creds } = cr;
|
|
248
193
|
|
|
249
194
|
if (!forceRefresh && !isExpiringSoon(creds)) {
|
|
250
195
|
return { kind: 'ok', creds };
|
|
@@ -253,12 +198,12 @@ async function refreshIfNeeded(forceRefresh = false) {
|
|
|
253
198
|
const alreadyExpired = isExpired(creds);
|
|
254
199
|
|
|
255
200
|
// Futile-refresh short-circuit: token already expired AND no refresh token
|
|
256
|
-
// to present.
|
|
257
|
-
//
|
|
258
|
-
//
|
|
201
|
+
// to present. The `claude --version` CLI fallback is a guaranteed dead end
|
|
202
|
+
// from the same missing token, so running it on every 15 s poll only spawns
|
|
203
|
+
// a doomed child process and re-logs the
|
|
259
204
|
// same triple forever (2026-09-23 storm). Re-read once — cheap, and the ONE
|
|
260
205
|
// real recovery (`claude login`) lands here — then, if still futile, return
|
|
261
|
-
// the auth verdict WITHOUT the
|
|
206
|
+
// the auth verdict WITHOUT the CLI-fallback spawn, logging
|
|
262
207
|
// at most once per FUTILE_REFRESH_COOLDOWN_MS instead of every poll.
|
|
263
208
|
if (isRefreshFutile(creds)) {
|
|
264
209
|
const recheckCr = await readCredentials();
|
|
@@ -279,38 +224,19 @@ async function refreshIfNeeded(forceRefresh = false) {
|
|
|
279
224
|
};
|
|
280
225
|
}
|
|
281
226
|
|
|
282
|
-
//
|
|
283
|
-
const
|
|
284
|
-
appendRefreshLog({ event:
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const freshCr = await readCredentials();
|
|
290
|
-
if (freshCr.kind === 'ok') {
|
|
291
|
-
appendRefreshLog({ event: 'oauth_refresh_written_ok' });
|
|
292
|
-
return { kind: 'ok', creds: freshCr.creds };
|
|
293
|
-
}
|
|
294
|
-
} catch (e) {
|
|
295
|
-
appendRefreshLog({ event: 'oauth_refresh_write_failed', error: e?.message });
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Stretch: if OAuth didn't explicitly reject the token, try `claude --version` fallback.
|
|
300
|
-
if (oauthResult.kind !== 'auth') {
|
|
301
|
-
const cliResult = await tryCliFallback();
|
|
302
|
-
appendRefreshLog({ event: cliResult.ok ? 'cli_fallback_ok' : 'cli_fallback_failed', reason: cliResult.reason ?? null });
|
|
303
|
-
if (cliResult.ok) {
|
|
304
|
-
const freshCr = await readCredentials();
|
|
305
|
-
if (freshCr.kind === 'ok' && !isExpired(freshCr.creds)) {
|
|
306
|
-
return { kind: 'ok', creds: freshCr.creds };
|
|
307
|
-
}
|
|
227
|
+
// The CLI owns the real refresh contract: `claude --version` refreshes silently.
|
|
228
|
+
const cliResult = await tryCliFallback();
|
|
229
|
+
appendRefreshLog({ event: cliResult.ok ? 'cli_fallback_ok' : 'cli_fallback_failed', reason: cliResult.reason ?? null });
|
|
230
|
+
if (cliResult.ok) {
|
|
231
|
+
const freshCr = await readCredentials();
|
|
232
|
+
if (freshCr.kind === 'ok' && !isExpired(freshCr.creds)) {
|
|
233
|
+
return { kind: 'ok', creds: freshCr.creds };
|
|
308
234
|
}
|
|
309
235
|
}
|
|
310
236
|
|
|
311
237
|
if (alreadyExpired) {
|
|
312
238
|
// Re-read from disk in case credentials were externally refreshed (e.g. via
|
|
313
|
-
// `claude login`) between our initial read and the failed
|
|
239
|
+
// `claude login`) between our initial read and the failed CLI fallback.
|
|
314
240
|
const recheckCr = await readCredentials();
|
|
315
241
|
if (recheckCr.kind === 'ok' && !isExpired(recheckCr.creds)) {
|
|
316
242
|
appendRefreshLog({ event: 'externally_refreshed_ok', recheckExpiresAt: recheckCr.creds.expiresAt ?? null });
|