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.
Files changed (75) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/dist/assets/{AgentLibrary-RwPvEgqm.js → AgentLibrary-DZFM7Lto.js} +2 -2
  3. package/dist/assets/{Button-36j3PzYv.js → Button-BJVFKdGH.js} +1 -1
  4. package/dist/assets/{DataModel-DUhANo4l.js → DataModel-CebvcP_y.js} +1 -1
  5. package/dist/assets/{History-BeZzNjmv.js → History-BwISEUhR.js} +2 -2
  6. package/dist/assets/{Hooks-iQ8845Hp.js → Hooks-Dac3smYP.js} +3 -3
  7. package/dist/assets/{Library-DPE1h-1L.js → Library-DTjsUr9W.js} +7 -7
  8. package/dist/assets/{ListDetail-PStOuQAo.js → ListDetail-B1L5TbjS.js} +1 -1
  9. package/dist/assets/{MarkdownEditor-DPw-N2-m.js → MarkdownEditor-BFRhWZD4.js} +1 -1
  10. package/dist/assets/{McpServers-CUyRTNep.js → McpServers-CBjGCMg0.js} +2 -2
  11. package/dist/assets/Memory-DdLi5fqe.js +8 -0
  12. package/dist/assets/{Panel-_VZ_uXNg.js → Panel-BQkkkjex.js} +1 -1
  13. package/dist/assets/{Permissions-PPRmfNbK.js → Permissions-CaACwY0B.js} +3 -3
  14. package/dist/assets/{Plugins-BFcvfvlx.js → Plugins-DJeBwVoh.js} +2 -2
  15. package/dist/assets/ProvenanceBadge--N5j8xiK.js +1 -0
  16. package/dist/assets/{RemoteMachines-D5V0lzgW.js → RemoteMachines-WjWaDHEA.js} +2 -2
  17. package/dist/assets/{SaveBar-Dw64FVdM.js → SaveBar-DFmOjb4_.js} +1 -1
  18. package/dist/assets/{Scheduler-C8s_pJ3I.js → Scheduler-Miap6_aY.js} +8 -8
  19. package/dist/assets/{ScopeSwitcher-D-nKDMAE.js → ScopeSwitcher-CiEujp4L.js} +1 -1
  20. package/dist/assets/Settings-UGpW4P0j.js +4 -0
  21. package/dist/assets/{SkillReferenceGraph-BbZFMHim.js → SkillReferenceGraph-BgyAnxGW.js} +1 -1
  22. package/dist/assets/Skills-BQUpnNfx.js +2 -0
  23. package/dist/assets/SystemPrompt-CQjOgHGT.js +1 -0
  24. package/dist/assets/TagLibrary-CTVpoLl0.js +1 -0
  25. package/dist/assets/{TiptapBody-Cr-hxX8L.js → TiptapBody-BtOcrdRR.js} +1 -1
  26. package/dist/assets/{Toggle-7WpPzcZH.js → Toggle-Chri4G19.js} +1 -1
  27. package/dist/assets/{ViewTabs-9lQCRwWJ.js → ViewTabs-BIenDGVv.js} +1 -1
  28. package/dist/assets/{historyTotals-BrFxw5J1.js → historyTotals-CX8mUxJY.js} +1 -1
  29. package/dist/assets/{index-CTBpf-26.js → index-AVrhGmUO.js} +163 -163
  30. package/dist/assets/{index-Dutcsly-.css → index-HpEbvDiE.css} +1 -1
  31. package/dist/assets/{settingsSchema-Dh5Kl7VO.js → settingsSchema-uqwlznbH.js} +1 -1
  32. package/dist/assets/{useAgentsDirWatch-CMpx0d04.js → useAgentsDirWatch-BaezRtWw.js} +1 -1
  33. package/dist/assets/{useRemotePeers-CI2axd-L.js → useRemotePeers-BuFbBqDt.js} +1 -1
  34. package/dist/index.html +2 -2
  35. package/package.json +1 -1
  36. package/plugins/session-manager-dev/.claude-plugin/plugin.json +1 -1
  37. package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +13 -0
  38. package/scripts/scheduler-mcp-server.cjs +23 -5
  39. package/src/main/build-info.json +4 -4
  40. package/src/main/chatRunner.cjs +18 -1
  41. package/src/main/config.cjs +18 -1
  42. package/src/main/index.cjs +6 -2
  43. package/src/main/ipcSchemas.cjs +27 -34
  44. package/src/main/lib/activeSessions.cjs +13 -0
  45. package/src/main/lib/claude/rateLimitDetect.cjs +29 -10
  46. package/src/main/lib/claude/rateLimitWindow.cjs +23 -11
  47. package/src/main/lib/credentials.cjs +16 -90
  48. package/src/main/lib/feedback-remote/crossProjectFeedback.cjs +107 -9
  49. package/src/main/lib/feedback-remote/humanPromptLedger.cjs +93 -0
  50. package/src/main/lib/mcpToolCatalog.cjs +9 -3
  51. package/src/main/lib/memoryDirResolve.cjs +139 -0
  52. package/src/main/lib/memoryIndex.cjs +98 -0
  53. package/src/main/lib/prd/depSlugResolve.cjs +16 -1
  54. package/src/main/lib/scheduler/finalizeReason.cjs +30 -0
  55. package/src/main/lib/scheduler/flatSweepThrottle.cjs +17 -0
  56. package/src/main/lib/scheduler/runLogRetention.cjs +34 -13
  57. package/src/main/lib/scheduler/schedulerBatch.cjs +8 -12
  58. package/src/main/lib/telemetry/telemetryClient.cjs +8 -4
  59. package/src/main/lib/util/atomicFs.cjs +42 -0
  60. package/src/main/lib/util/loopDelay.cjs +1 -1
  61. package/src/main/memoryAggregate.cjs +15 -8
  62. package/src/main/memoryTool.cjs +151 -22
  63. package/src/main/runVerify.cjs +5 -2
  64. package/src/main/scheduler.cjs +43 -10
  65. package/src/main/supervisor.cjs +45 -22
  66. package/src/main/webRemote.cjs +15 -0
  67. package/src/preload/api.d.ts +47 -52
  68. package/src/preload/index.cjs +25 -21
  69. package/dist/assets/Memory-bZ2xjBGy.js +0 -8
  70. package/dist/assets/ProvenanceBadge-B1WUknak.js +0 -1
  71. package/dist/assets/Settings-CQSrR4mO.js +0 -4
  72. package/dist/assets/Skills-Bzyrh6EQ.js +0 -2
  73. package/dist/assets/SystemPrompt-DP6i3Dx0.js +0 -1
  74. package/dist/assets/TagLibrary-iip0C6Bl.js +0 -1
  75. package/src/main/agentMemory.cjs +0 -246
@@ -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
- validatePath(expandHome(rawPath)); // security check — throws if out of bounds
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);
@@ -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.
@@ -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 the canonical rate-limit signal. We look
17
- * at the last 16 KB — final result event always lands at the end. Covers
18
- * both unified-window rate-limit types (five_hour, seven_day), the raw
19
- * 429 status, and the two human-readable limit-message phrasings the CLI
20
- * emits ("You've hit your limit" / "You've reached your <model> limit"). */
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
- return /"rateLimitType":"five_hour"/.test(text)
26
- || /"rateLimitType":"seven_day"/.test(text)
27
- || /"api_error_status":429/.test(text)
28
- || /You'?ve hit your limit/.test(text)
29
- || /You'?ve reached your .* limit/.test(text);
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 and returns the
25
- * UNIX-seconds resetsAt of the window that actually bound (utilization >= 1.0)
26
- * in the LAST such event. When multiple windows bind in that event, the
27
- * latest resetsAt wins. Returns null when no rate_limit_event is found, or
28
- * none of its windows are at/above 1.0 utilization.
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 lastWindows = null;
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
- const windows = event?.rate_limit_info?.unifiedWindows;
45
- if (event?.type === 'rate_limit_event' && windows && typeof windows === 'object') {
46
- lastWindows = windows;
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 (!lastWindows) return null;
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 every refresh avenue is a dead end: tryOAuthRefresh
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 oauth_refresh_unsupported → cli_fallback_ok →
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
- // Stretch: attempt standard OAuth 2.0 refresh token grant.
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, raw, source } = cr;
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. Every downstream avenue (OAuth grant, `claude --version` CLI
257
- // fallback) is a guaranteed dead end from the same missing token, so running
258
- // them on every 15 s poll only spawns a doomed child process and re-logs the
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 OAuth call or the CLI-fallback spawn, logging
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
- // Stretch: try OAuth refresh endpoint first.
283
- const oauthResult = await tryOAuthRefresh(creds);
284
- appendRefreshLog({ event: `oauth_refresh_${oauthResult.kind}`, message: oauthResult.message ?? null });
285
-
286
- if (oauthResult.kind === 'ok') {
287
- try {
288
- await writeCredentials(raw, oauthResult.fresh, source);
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 OAuth attempt.
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 });