claude-code-session-manager 0.106.0 → 0.108.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 (50) hide show
  1. package/dist/assets/{AgentLibrary-CKOZzc2W.js → AgentLibrary-Co54GThg.js} +1 -1
  2. package/dist/assets/{Button-yuqFpQsK.js → Button-DC6VzovQ.js} +1 -1
  3. package/dist/assets/{DataModel-Bwdc_gaJ.js → DataModel-DZ0vyJau.js} +1 -1
  4. package/dist/assets/{History-D1Z0lydo.js → History-Df3YwpPe.js} +1 -1
  5. package/dist/assets/{Hooks-CyD6l4Is.js → Hooks-DplG6roH.js} +1 -1
  6. package/dist/assets/{Library-fnod5cPe.js → Library-DLmnlfQt.js} +1 -1
  7. package/dist/assets/{ListDetail-BKO68SJ4.js → ListDetail-YylbvUj_.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DrYMnMBy.js → MarkdownEditor-DMPKWHTF.js} +1 -1
  9. package/dist/assets/{McpServers-CgfItWhF.js → McpServers-Da-Ty5nc.js} +1 -1
  10. package/dist/assets/{Memory-4M3gO3_r.js → Memory-C3rUQ_cg.js} +1 -1
  11. package/dist/assets/{Panel-CKbXo-LT.js → Panel-CC3JTnOI.js} +1 -1
  12. package/dist/assets/{Permissions-Bc9EpjXL.js → Permissions-XvBiMj1-.js} +1 -1
  13. package/dist/assets/{Plugins-B_-PlEIx.js → Plugins-CzHY1mgS.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-Cxev7XSA.js → ProvenanceBadge-DaJIWVcg.js} +1 -1
  15. package/dist/assets/{SaveBar-DiqscG6q.js → SaveBar-C06rFT34.js} +1 -1
  16. package/dist/assets/Scheduler-BehadU-0.js +16 -0
  17. package/dist/assets/{ScopeSwitcher-BsM4-jHQ.js → ScopeSwitcher-Cp5mxblN.js} +1 -1
  18. package/dist/assets/Settings-Cg9LIni0.js +4 -0
  19. package/dist/assets/{SkillReferenceGraph-B9YlOuPo.js → SkillReferenceGraph-DuWRmT_f.js} +1 -1
  20. package/dist/assets/{Skills-Dtf-z_zN.js → Skills-CMmCZfIv.js} +1 -1
  21. package/dist/assets/{SystemPrompt-HJDn0E8C.js → SystemPrompt-D_-MV025.js} +1 -1
  22. package/dist/assets/{TagLibrary-DLGOfauU.js → TagLibrary-D9Tv3yOJ.js} +1 -1
  23. package/dist/assets/{TiptapBody-Cdmv1CuM.js → TiptapBody-1r4ODLJ7.js} +1 -1
  24. package/dist/assets/{Toggle-CQFhOo1F.js → Toggle-BGT68XHP.js} +1 -1
  25. package/dist/assets/{ViewTabs-CxjDCHjH.js → ViewTabs-T0NBc8uv.js} +1 -1
  26. package/dist/assets/{index-Blo38vfX.js → index-3_AtCAyd.js} +3 -3
  27. package/dist/assets/{index-DkJv4l6L.css → index-tr1w3hHB.css} +1 -1
  28. package/dist/assets/{settingsSchema-B0uoL_1R.js → settingsSchema-CvoNk3Ye.js} +1 -1
  29. package/dist/assets/useAgentsDirWatch-1C2CBBya.js +1 -0
  30. package/dist/assets/{useConfigDirWatch-Dxv7JbCU.js → useConfigDirWatch-Cd5PnTkB.js} +1 -1
  31. package/dist/index.html +2 -2
  32. package/package.json +2 -1
  33. package/scripts/scheduler-mcp-server.cjs +52 -7
  34. package/src/main/build-info.json +4 -4
  35. package/src/main/index.cjs +8 -0
  36. package/src/main/ipcSchemas.cjs +93 -0
  37. package/src/main/lib/builtinMacros.cjs +2 -2
  38. package/src/main/lib/e2eStateMachine.cjs +39 -0
  39. package/src/main/lib/mcpToolCatalog.cjs +8 -6
  40. package/src/main/lib/shippedPersonaSeeds.cjs +11 -0
  41. package/src/main/lib/usageCircuit.cjs +15 -0
  42. package/src/main/scheduler.cjs +57 -11
  43. package/src/main/usage.cjs +10 -2
  44. package/src/main/webRemote.cjs +1364 -0
  45. package/src/preload/api.d.ts +64 -1
  46. package/src/preload/index.cjs +39 -0
  47. package/src/seed/agents/demo-video-builder.md +69 -48
  48. package/dist/assets/Scheduler-CCcJzuu8.js +0 -16
  49. package/dist/assets/Settings-C3-VL-T8.js +0 -3
  50. package/dist/assets/useAgentsDirWatch-e3TJUFPP.js +0 -1
@@ -30,6 +30,17 @@ const ptyTabId = z.object({ tabId: z.string().min(1).max(128) });
30
30
  // have a live PTY? Bounded so the renderer can't ask about unbounded lists.
31
31
  const ptyAlive = z.object({ tabIds: z.array(z.string().min(1).max(128)).max(500) });
32
32
 
33
+ // v2 mobile: subscribe to a session's live state + summary. cwd is needed to
34
+ // locate the transcript JSONL (transcriptPath); validated against home-dir boundary
35
+ // in webRemote before any fs access.
36
+ const sessionSubscribe = z.object({
37
+ // tabId becomes a transcript FILENAME (`<tabId>.jsonl`) — restrict to a
38
+ // session-id charset (no '/', no '.', so it can't traverse out of the project
39
+ // transcript dir). sessionId is a UUID, which satisfies this.
40
+ tabId: z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9_-]*$/),
41
+ cwd: z.string().min(1).max(4096),
42
+ });
43
+
33
44
  // 64 KiB cap per pty:write — typewriter input is bounded; a renderer firing
34
45
  // megabytes per call is either a bug or an attack. Block it at the boundary.
35
46
  const PTY_WRITE_MAX_BYTES = 64 * 1024;
@@ -932,6 +943,23 @@ const chatExternalSend = z.object({
932
943
  ),
933
944
  });
934
945
 
946
+ // ──────────────────────────────────────────── Web Remote
947
+ // OTP is 8 uppercase alphanumeric chars (case-insensitive entry, normalised to upper in handler).
948
+ const WEB_REMOTE_OTP_RE = /^[A-Z0-9]{8}$/i;
949
+ const DEVICE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
950
+
951
+ const webRemotePair = z.object({
952
+ otp: z.string().regex(WEB_REMOTE_OTP_RE),
953
+ }).strict();
954
+
955
+ const webRemoteRevokeDevice = z.object({
956
+ deviceId: z.string().regex(DEVICE_ID_RE),
957
+ }).strict();
958
+
959
+ const webRemoteAuditTail = z.object({
960
+ lines: z.number().int().min(1).max(500).optional(),
961
+ }).strict();
962
+
935
963
  // ──────────────────────────────────────────── History
936
964
  const DATE_YYYY_MM_DD = /^\d{4}-\d{2}-\d{2}$/;
937
965
 
@@ -1093,13 +1121,78 @@ function validated(schema, handler) {
1093
1121
  };
1094
1122
  }
1095
1123
 
1124
+ // ──────────────────────────────────────────── Web Remote command allowlist
1125
+ // Commands are split into three tiers:
1126
+ // READ_COMMANDS — return data; allowed when remoteEnabled=true.
1127
+ // SAS_GATED_READS — return sensitive user data (sessions, PRDs, logs,
1128
+ // transcript summaries); additionally require
1129
+ // _e2eAuthenticated=true (SAS confirmed by user).
1130
+ // A compromised relay cannot exfiltrate this data from
1131
+ // a session that has not been SAS-confirmed.
1132
+ // MUTATE_COMMANDS — write files, spawn processes, or mutate persisted
1133
+ // state; gated behind remoteControlEnabled=true AND
1134
+ // _e2eAuthenticated=true.
1135
+ // ALLOWED_COMMANDS is the union, kept for existing import compatibility.
1136
+ //
1137
+ // Ungated READ_COMMANDS (justify each):
1138
+ // cmd:app:version — exposes only the app semver string; no user data.
1139
+ // cmd:session:unsubscribe — teardown lifecycle; returns nothing sensitive.
1140
+ const READ_COMMANDS = new Set([
1141
+ 'cmd:app:version',
1142
+ // v2 mobile: unsubscribe is a teardown lifecycle call with no data payload.
1143
+ 'cmd:session:unsubscribe',
1144
+ ]);
1145
+
1146
+ // Sensitive reads — return user data; require SAS confirmation same as MUTATE.
1147
+ const SAS_GATED_READS = new Set([
1148
+ 'cmd:sessions:load',
1149
+ 'cmd:history:aggregate',
1150
+ // subscribe initiates a live stream of session state/summary — sensitive.
1151
+ 'cmd:session:subscribe',
1152
+ // NOTE: cmd:exchanges:list is intentionally NOT allowlisted — webRemote.cjs has
1153
+ // no dispatch handler for it, so an allowlist entry would only fail closed with
1154
+ // an opaque reject. Re-add here together with the handler when remote exchanges
1155
+ // are wired, so the allowlist always mirrors an actual capability.
1156
+ //
1157
+ // Scheduler/Epics commands (cmd:schedule:*) were removed here deliberately
1158
+ // (core scheduler/Epics redesign) — Remote no longer reaches into scheduler
1159
+ // internals at all; a future Remote rebuild will talk to Scheduler/Epics
1160
+ // through whatever higher-level surface that redesign lands on, not this
1161
+ // per-command allowlist.
1162
+ ]);
1163
+
1164
+ const MUTATE_COMMANDS = new Set([
1165
+ 'cmd:sessions:save',
1166
+ 'cmd:pty:spawn',
1167
+ 'cmd:pty:write',
1168
+ // pty:kill terminates a live session; pty:resize drives the geometry of the
1169
+ // user's interactive PTY — both write live process state, so they are gated
1170
+ // behind remoteControlEnabled + SAS like every other mutation. A read-only
1171
+ // mobile mirror has no business killing or resizing the desktop's session.
1172
+ 'cmd:pty:kill',
1173
+ 'cmd:pty:resize',
1174
+ // Pushes a prompt into an open tab's chat queue — a real mutation of that
1175
+ // tab's session, same tier as pty:write.
1176
+ 'cmd:chat:send',
1177
+ ]);
1178
+
1179
+ const ALLOWED_COMMANDS = new Set([...READ_COMMANDS, ...SAS_GATED_READS, ...MUTATE_COMMANDS]);
1180
+
1096
1181
  module.exports = {
1097
1182
  // Centralized slug regex — used by scheduler.cjs and queueOps.cjs for
1098
1183
  // direct test()/match() containment checks alongside the zod parses.
1099
1184
  SCHEDULE_SLUG_RE,
1100
1185
  SCHEDULE_RUN_ID_RE,
1101
1186
  PRD_CREATE_SLUG_RE,
1187
+ READ_COMMANDS,
1188
+ SAS_GATED_READS,
1189
+ MUTATE_COMMANDS,
1190
+ ALLOWED_COMMANDS,
1102
1191
  schemas: {
1192
+ webRemotePair,
1193
+ webRemoteRevokeDevice,
1194
+ webRemoteAuditTail,
1195
+ sessionSubscribe,
1103
1196
  ptySpawn,
1104
1197
  ptyTabId,
1105
1198
  ptyAlive,
@@ -30,9 +30,9 @@ const BUILTIN_MACROS = Object.freeze([
30
30
  tag: 'project-home-builder',
31
31
  surface: 'project-home',
32
32
  projects: ['*'],
33
- builtinVersion: 2,
33
+ builtinVersion: 3,
34
34
  prompt:
35
- 'Generate a 30-second demo video for this project as one self-contained HTML/JavaScript animation that presents its main goals and key features, grounded only in what you read in the repository, with a matching on-device narration track per the audio policy in the persona. Save it with a single project_demo_video_write call.',
35
+ 'Generate a 30-second demo video for this project as one self-contained HTML/JavaScript animation that presents its main goals and key features, grounded only in what you read in the repository, with a matching on-device narration track: narrate with Kokoro (kokoro-onnx) using a natural female (af_heart) or male (am_michael) voice per the audio policy in the persona. Save it with a single project_demo_video_write call passing htmlPath.',
36
36
  }),
37
37
  ]);
38
38
 
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Pure E2E session state machine for the web-remote relay.
3
+ * No Electron, no I/O — importable in unit tests.
4
+ *
5
+ * State transitions:
6
+ * idle → pending_sas : successful deriveSessionKey + deriveSas
7
+ * idle → failed : crypto derivation error
8
+ * pending_sas → authenticated : user confirms SAS
9
+ * pending_sas → failed : deriveSas threw after sessionKey succeeded
10
+ * any → idle : disconnect / reset
11
+ */
12
+
13
+ /** @returns {{ state: string, sessionKey: Buffer|null, pendingSas: string|null }} */
14
+ function makeState(state = 'idle', sessionKey = null, pendingSas = null) {
15
+ return { state, sessionKey, pendingSas };
16
+ }
17
+
18
+ /**
19
+ * Attempt to confirm the SAS. Pure — does not mutate; returns the next state.
20
+ * @param {{ state: string, sessionKey: Buffer|null, pendingSas: string|null }} e2eState
21
+ * @returns {{ ok: boolean, error?: string, next: { state: string, sessionKey: Buffer|null, pendingSas: string|null } }}
22
+ */
23
+ function confirmSas(e2eState) {
24
+ if (e2eState.state !== 'pending_sas') {
25
+ const errorMap = {
26
+ idle: 'no_e2e_session',
27
+ failed: 'e2e_failed',
28
+ authenticated: 'already_authenticated',
29
+ };
30
+ const error = errorMap[e2eState.state] ?? 'unexpected_state';
31
+ return { ok: false, error, next: e2eState };
32
+ }
33
+ return {
34
+ ok: true,
35
+ next: makeState('authenticated', e2eState.sessionKey, null),
36
+ };
37
+ }
38
+
39
+ module.exports = { makeState, confirmSas };
@@ -50,11 +50,11 @@ const MCP_TOOL_CATALOG = [
50
50
  {
51
51
  name: 'scheduler_pause',
52
52
  group: 'scheduler',
53
- purpose: "Pause the scheduler (user-initiated) via the session-manager app's admin API.",
54
- whenToUse: 'Use to stop NEW job dispatch on request (e.g. before a risky change). Running jobs are never killed.',
55
- whenNotToUse: 'Do not use to cancel a running job (use scheduler_cancel_job) or as a workaround for a stuck queue.',
56
- exampleArgs: {},
57
- notes: 'A manual pause survives app restart and is never auto-cleared by the rate-limit timer or network recovery — only scheduler_resume clears it.',
53
+ purpose: "Pause NEW job dispatch for every project on this machine (machine-wide, not per-repo) via the session-manager app's admin API.",
54
+ whenToUse: 'Use only when the human asks you to stop dispatch everywhere, or for a machine-wide emergency. Pass a `reason`. Running jobs are never killed.',
55
+ whenNotToUse: "NEVER use it to protect one repo's git/branch state or a single Epic's work — it freezes every project's scheduler. Do not use it to cancel a running job (use scheduler_cancel_job) or as a workaround for a stuck queue.",
56
+ exampleArgs: { reason: 'machine-wide emergency: disk full' },
57
+ notes: 'An agent pause auto-expires after 30 minutes, is cleared on app restart, and is audited with the caller (session id + cwd). A human pause outranks it and is only cleared by scheduler_resume.',
58
58
  },
59
59
  {
60
60
  name: 'scheduler_resume',
@@ -279,7 +279,8 @@ const MCP_TOOL_CATALOG = [
279
279
  whenToUse: 'Use when asked to generate or refresh the demo video. Compose the whole document yourself '
280
280
  + '(inline <style>/<script>, data: URIs for assets, CSS/JS-driven animation) from what is really true about '
281
281
  + 'the project, declare its length with <meta name="sm-demo-duration" content="N"> (5-30 seconds), then '
282
- + 'pass the full document as `html`.',
282
+ + 'pass the document as `html` or — preferred when it embeds a base64 audio/image data URI — write it to a '
283
+ + 'local .html file and pass its absolute path as `htmlPath`.',
283
284
  whenNotToUse: 'Never fabricate content — every claim must trace to something concrete in the project. The '
284
285
  + 'document must be self-contained and non-network-capable: the call is rejected for empty input, more than '
285
286
  + '2MB, any <script src>, <link href="http…">, @import, or url(http…) (no remote references); any '
@@ -291,6 +292,7 @@ const MCP_TOOL_CATALOG = [
291
292
  html: '<!DOCTYPE html><html><head><meta name="sm-demo-duration" content="30"></head><body><h1>Example</h1></body></html>',
292
293
  },
293
294
  notes: "cwd is optional — defaults to the calling session's own project root (SM_PROJECT_ROOT or process.cwd()) when omitted. "
295
+ + 'Give exactly one of `html` / `htmlPath` (absolute .html/.htm file, <= 2MB); prefer `htmlPath` for base64 data URIs. '
294
296
  + 'The document is CSP-stamped after it is written, so the viewer sandbox is the real network fence — these checks are defence in depth.',
295
297
  },
296
298
  {
@@ -274,6 +274,17 @@ const SHIPPED_PERSONA_SEEDS = deepFreeze({
274
274
  effort: "medium",
275
275
  },
276
276
  },
277
+ {
278
+ commit: "e477754c",
279
+ bodySha256: "cfb0c7fa8c74dbc731ad352a06cd94717446fe0151bb86e93acc56df45f2c3b1",
280
+ fm: {
281
+ description: "30-second self-contained HTML/JS demo video for Project Home, saved via project_demo_video_write.",
282
+ tools: "Read, Grep, Glob, Bash, Write, Edit",
283
+ title: "Project Home — Demo Video",
284
+ model: "sonnet",
285
+ effort: "medium",
286
+ },
287
+ },
277
288
  ],
278
289
  });
279
290
 
@@ -137,6 +137,20 @@ function degradedBudget(lastGoodPayload, executorEvidence = {}) {
137
137
  return { utilization, concurrencyCap };
138
138
  }
139
139
 
140
+ /**
141
+ * Budget while the METER (not the account) is rate-limited: a 429 from the
142
+ * usage endpoint says nothing about account usage, so a recent-enough cached
143
+ * payload is trusted as-is. Returns `{ utilization, degraded: false }` from
144
+ * the cache, or null (no/old cache) so the caller falls back to degradedBudget().
145
+ */
146
+ function meterFailureBudget({ cachedPayload, staleSinceMs, now = Date.now(), maxStaleMs = 30 * 60 * 1000 } = {}) {
147
+ if (!cachedPayload || !Number.isFinite(staleSinceMs)) return null;
148
+ if (now - staleSinceMs > maxStaleMs) return null;
149
+ const window = bindingWindow(cachedPayload);
150
+ if (!Number.isFinite(window.utilization)) return null;
151
+ return { utilization: window.utilization, degraded: false, name: window.name };
152
+ }
153
+
140
154
  /** Wraps `fn` so concurrent callers awaiting an in-flight call share one promise. */
141
155
  function singleFlight(fn) {
142
156
  let inFlight = null;
@@ -229,6 +243,7 @@ module.exports = {
229
243
  isResetFresh,
230
244
  bindingWindow,
231
245
  degradedBudget,
246
+ meterFailureBudget,
232
247
  degradedConcurrencyCap,
233
248
  singleFlight,
234
249
  };
@@ -78,7 +78,7 @@ const { detectRateLimitInLog } = require('./lib/rateLimitDetect.cjs');
78
78
  const { stripAppOwnedChurn } = require('./lib/jobDirtFilter.cjs');
79
79
  const { GATE_AUTHORITY_VERDICTS, decideGateAuthority } = require('./lib/gateAuthority.cjs');
80
80
  const { resolveBindingRateLimitReset } = require('./lib/rateLimitWindow.cjs');
81
- const { isResetFresh, bindingWindow, degradedBudget } = require('./lib/usageCircuit.cjs');
81
+ const { isResetFresh, bindingWindow, degradedBudget, meterFailureBudget } = require('./lib/usageCircuit.cjs');
82
82
  const { computeQueueHealth } = require('./lib/queueHealth.cjs');
83
83
  const { createLoadGate, topCpuConsumers } = require('./lib/loadGate.cjs');
84
84
  const { openLog, withChildAndLog } = require('./lib/childWithLog.cjs');
@@ -3499,8 +3499,11 @@ function recordObservedReset(resetIso) {
3499
3499
  /** Pure: this poll/executor cycle's conservative budget while the meter is degraded. */
3500
3500
  function computeDegradedBudget() {
3501
3501
  return degradedBudget(lastGoodUsagePayload, {
3502
- observed429: lastFailureKind === 'meter_rate_limited',
3503
- resetsAt: cachedNextReset,
3502
+ // Only an executor-observed rate limit (the paused.reason === 'rate_limit'
3503
+ // path) may pin utilization; a 429 from the METER says nothing about the
3504
+ // account. That pause already holds dispatch itself, so no evidence here.
3505
+ observed429: false,
3506
+ resetsAt: null,
3504
3507
  now: Date.now(),
3505
3508
  configuredCap: sessionSlots.snapshot().total,
3506
3509
  });
@@ -4047,8 +4050,11 @@ function nextRapidRateLimitCount(prevCount, { rateLimited, durationMs }) {
4047
4050
  * either, which used to leave an indefinite pause with no resume timer at
4048
4051
  * all (a queue that never comes back on its own) — this covers both.
4049
4052
  */
4053
+ const AGENT_PAUSE_TTL_MS = 30 * 60_000;
4054
+
4050
4055
  function computeEffectiveResumeAt(reason, resumeAtIso, nowMs = Date.now()) {
4051
4056
  if (resumeAtIso && isResetFresh(resumeAtIso, nowMs)) return resumeAtIso;
4057
+ if (reason === 'agent') return new Date(nowMs + AGENT_PAUSE_TTL_MS).toISOString();
4052
4058
  if (reason === 'network' || reason === 'rate_limit') {
4053
4059
  return new Date(nowMs + 30 * 60_000).toISOString();
4054
4060
  }
@@ -4067,15 +4073,16 @@ function computeResumeDelay(effectiveResumeAtIso, nowMs = Date.now()) {
4067
4073
  }
4068
4074
 
4069
4075
  async function setPaused(reason, resumeAtIso, opts = {}) {
4070
- const { observedAt = null, force = false } = opts;
4076
+ const { observedAt = null, force = false, by = null } = opts;
4071
4077
  const isManual = reason === 'manual';
4078
+ const isAgent = reason === 'agent';
4072
4079
  // Honor manual-override cooldown: if the user cleared a pause within the
4073
4080
  // last 5 minutes, suppress auto-pause re-engagement UNLESS this pause is
4074
4081
  // backed by a fresh observation (a run that started after the clear) or is
4075
4082
  // forced (the rapid-repeat hard pause, which the cooldown cannot suppress).
4076
4083
  // A user-initiated 'manual' pause is never an auto-detection, so the cooldown
4077
4084
  // (which exists to ignore STALE auto-detections) does not apply to it.
4078
- if (!isManual && isCooldownSuppressed({ pauseClearedManuallyAt, now: Date.now(), observedAt, force })) {
4085
+ if (!isManual && !isAgent && isCooldownSuppressed({ pauseClearedManuallyAt, now: Date.now(), observedAt, force })) {
4079
4086
  console.log(`[scheduler] setPaused(${reason}) suppressed by manual override cooldown`);
4080
4087
  return;
4081
4088
  }
@@ -4088,6 +4095,7 @@ async function setPaused(reason, resumeAtIso, opts = {}) {
4088
4095
  // 'manual' never arms a resume timer: only schedule:resume / run-now clears it.
4089
4096
  const effectiveResumeAt = isManual ? null : computeEffectiveResumeAt(reason, resumeAtIso);
4090
4097
 
4098
+ let committed = null;
4091
4099
  const kept = await mutate((s) => {
4092
4100
  // A user pause outranks every auto-pause (rate_limit/auth/network): the
4093
4101
  // auto path must not overwrite it, or its resume timer would auto-clear it.
@@ -4097,12 +4105,15 @@ async function setPaused(reason, resumeAtIso, opts = {}) {
4097
4105
  } else {
4098
4106
  s.paused = { reason, since: new Date().toISOString(), resumeAt: effectiveResumeAt || null };
4099
4107
  }
4108
+ if (by && isAgent) s.paused.by = by;
4109
+ committed = { reason: s.paused.reason, resumeAt: s.paused.resumeAt ?? null, by: s.paused.by ?? null };
4100
4110
  return false;
4101
4111
  });
4102
4112
  if (kept) {
4103
4113
  console.log(`[scheduler] setPaused(${reason}) ignored: a manual pause is in force`);
4104
4114
  return;
4105
4115
  }
4116
+ if (committed) appendAuditEvent('scheduler_pause', committed);
4106
4117
  await broadcast({ flush: true });
4107
4118
  cancelToken.cancelled = true;
4108
4119
  if (resumeTimer) { clearTimeout(resumeTimer); resumeTimer = null; }
@@ -4123,6 +4134,7 @@ async function setPaused(reason, resumeAtIso, opts = {}) {
4123
4134
  async function clearPause(source) {
4124
4135
  if (resumeTimer) { clearTimeout(resumeTimer); resumeTimer = null; }
4125
4136
  const humanOverride = source === 'manual' || source === 'run-now';
4137
+ let clearedReason = null;
4126
4138
  const wasPaused = await mutate((s) => {
4127
4139
  // A human Resume / Run now also re-closes every launch circuit breaker:
4128
4140
  // the operator is asserting the environment is fixed (CLI updated,
@@ -4135,9 +4147,11 @@ async function clearPause(source) {
4135
4147
  }
4136
4148
  if (!s.paused) return false;
4137
4149
  console.log(`[scheduler] clearPause (${source || 'manual'})`);
4150
+ clearedReason = s.paused.reason ?? null;
4138
4151
  s.paused = null;
4139
4152
  return true;
4140
4153
  });
4154
+ if (wasPaused) appendAuditEvent('scheduler_resume', { source: source || 'manual', clearedReason });
4141
4155
  // Un-cancel the tick guard on every recovery path, not just runDueJobs().
4142
4156
  applyPauseCleared(wasPaused, cancelToken);
4143
4157
  if (wasPaused) lastPauseClearedAt = Date.now();
@@ -10377,8 +10391,17 @@ async function pollLoop() {
10377
10391
  // Don't update firstNon429FailureAt — 429s don't count toward the 30-min network-pause threshold.
10378
10392
  backoffMs = nextBackoffMs(backoffMs);
10379
10393
  backoffNextAt = Date.now() + backoffMs;
10380
- applyDegradedBudget();
10381
- console.log(`[scheduler] billing meter rate-limited (HTTP 429) — firing on degraded budget (util=${cachedUtilization}%, cap=${degradedConcurrencyCapValue}) (failure #${consecutiveFailures}); retry in ${backoffMs / 1000}s`);
10394
+ const cachedBudget = meterFailureBudget({ cachedPayload: r.cached, staleSinceMs: r.staleSince, now: Date.now() });
10395
+ if (cachedBudget) {
10396
+ // Meter 429 ≠ account limit: trust the recent cached reading, full slot pool.
10397
+ lastGoodUsagePayload = r.cached;
10398
+ cachedUtilization = cachedBudget.utilization;
10399
+ cachedBindingWindowName = cachedBudget.name;
10400
+ degradedConcurrencyCapValue = null;
10401
+ } else {
10402
+ applyDegradedBudget();
10403
+ }
10404
+ console.log(`[scheduler] billing meter rate-limited (HTTP 429) — firing on ${cachedBudget ? 'cached' : 'degraded'} budget (util=${cachedUtilization}%, cap=${degradedConcurrencyCapValue}) (failure #${consecutiveFailures}); retry in ${backoffMs / 1000}s`);
10382
10405
  warnFailureStreakIfNeeded();
10383
10406
  persistSchedulerState();
10384
10407
  const cur = await readQueue();
@@ -12948,7 +12971,10 @@ async function init() {
12948
12971
  // happens when the app was closed across the reset window.
12949
12972
  const boot = await readQueue();
12950
12973
  await clearStaleDrainAtBoot(boot);
12951
- if (boot.paused && boot.paused.resumeAt && new Date(boot.paused.resumeAt).getTime() <= Date.now()) {
12974
+ if (boot.paused && (boot.paused.reason === 'manual' || boot.paused.reason === 'agent')) {
12975
+ console.log(`[scheduler] boot: clearing persisted '${boot.paused.reason}' pause so dispatch runs`);
12976
+ await clearPause('boot');
12977
+ } else if (boot.paused && boot.paused.resumeAt && new Date(boot.paused.resumeAt).getTime() <= Date.now()) {
12952
12978
  await clearPause('boot-elapsed');
12953
12979
  } else if (boot.paused && boot.paused.resumeAt) {
12954
12980
  // Re-arm the resume timer (lost across restart).
@@ -13293,8 +13319,15 @@ const remote = {
13293
13319
  // User-initiated pause/resume — the admin-route/MCP twins of the
13294
13320
  // schedule:pause / schedule:resume IPC handlers, through the same setPaused /
13295
13321
  // clearPause. Pause stops NEW dispatch only; running jobs are never touched.
13296
- async pause() {
13297
- await setPaused('manual', null);
13322
+ async pause(opts = {}) {
13323
+ const str = (v, max = 300) => (typeof v === 'string' && v ? v.slice(0, max) : null);
13324
+ await setPaused('agent', null, {
13325
+ by: {
13326
+ originClaudeSessionId: str(opts.originClaudeSessionId),
13327
+ cwd: str(opts.cwd, 1000),
13328
+ reason: str(opts.reason),
13329
+ },
13330
+ });
13298
13331
  return { ok: true };
13299
13332
  },
13300
13333
 
@@ -13650,7 +13683,20 @@ function registerAdminRoutes(adminHttp, remoteObj = remote) {
13650
13683
  });
13651
13684
 
13652
13685
  adminHttp.registerRoute('POST', '/admin/scheduler/pause', async (req, res) => {
13653
- sendJson(res, 200, await remoteObj.pause());
13686
+ const raw = await readBody(req);
13687
+ let parsed;
13688
+ try {
13689
+ parsed = raw ? JSON.parse(raw) : {};
13690
+ } catch {
13691
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
13692
+ return;
13693
+ }
13694
+ if (!parsed || typeof parsed !== 'object') parsed = {};
13695
+ sendJson(res, 200, await remoteObj.pause({
13696
+ originClaudeSessionId: parsed.originClaudeSessionId,
13697
+ cwd: parsed.cwd,
13698
+ reason: parsed.reason,
13699
+ }));
13654
13700
  });
13655
13701
 
13656
13702
  adminHttp.registerRoute('POST', '/admin/scheduler/resume', async (req, res) => {
@@ -302,10 +302,18 @@ async function fetchUsage() {
302
302
  }
303
303
 
304
304
  if (lastRateLimitedResult && retryNotBeforeMs && Date.now() < retryNotBeforeMs) {
305
- return { ...lastRateLimitedResult, suppressed: true };
305
+ return withCachedPayload({ ...lastRateLimitedResult, suppressed: true });
306
306
  }
307
307
 
308
- return singleFlightNetworkFetch();
308
+ return withCachedPayload(await singleFlightNetworkFetch());
309
+ }
310
+
311
+ /** Attaches the last good payload (+ its age anchor) to a 429 so callers can keep using it. */
312
+ function withCachedPayload(r) {
313
+ if (r && r.kind === 'meter_rate_limited' && cache && cache.data) {
314
+ return { ...r, cached: cache.data, staleSince: cache.fetchedAt };
315
+ }
316
+ return r;
309
317
  }
310
318
 
311
319
  function registerBillingHandlers() {