@adhdev/mesh-shared 1.0.82-rc.4 → 1.0.82-rc.40

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 (65) hide show
  1. package/dist/assistant-protocol.d.ts +161 -6
  2. package/dist/decision-push-relay.d.ts +20 -0
  3. package/dist/index.d.ts +11 -0
  4. package/dist/index.js +1197 -118
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +1107 -118
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/launch-defaults.d.ts +196 -0
  9. package/dist/mesh-tool-names.d.ts +2 -2
  10. package/dist/mesh-vocabulary.d.ts +13 -4
  11. package/dist/mission-git-state.d.ts +83 -0
  12. package/dist/mission-items.d.ts +202 -0
  13. package/dist/node-facts.d.ts +75 -1
  14. package/dist/outbound-message.d.ts +9 -2
  15. package/dist/path-ownership.d.ts +1 -1
  16. package/dist/protocol/daemon-server.d.ts +42 -2
  17. package/dist/protocol/dashboard-daemon-p2p.d.ts +1 -1
  18. package/dist/protocol/dashboard-server.d.ts +20 -2
  19. package/dist/provider-approval.d.ts +36 -0
  20. package/dist/provider-question.d.ts +19 -0
  21. package/dist/quota-freshness.d.ts +65 -0
  22. package/dist/relay-reprobe.d.ts +52 -0
  23. package/dist/release.d.ts +90 -0
  24. package/dist/role-launch.d.ts +11 -0
  25. package/dist/session-launch.d.ts +10 -22
  26. package/dist/slot-proposal.d.ts +14 -0
  27. package/dist/task-source-phase.d.ts +75 -0
  28. package/dist/turn-evidence.d.ts +29 -13
  29. package/dist/turn-ipc-mission.d.ts +68 -1
  30. package/dist/turn-ipc-queue.d.ts +19 -0
  31. package/dist/turn-ipc.d.ts +2 -2
  32. package/dist/types.d.ts +1 -0
  33. package/dist/worker-protocol.d.ts +1 -1
  34. package/package.json +1 -1
  35. package/src/assistant-protocol.ts +302 -6
  36. package/src/daemon-normalize.ts +7 -5
  37. package/src/decision-push-relay.ts +23 -0
  38. package/src/index.ts +12 -0
  39. package/src/keyed-doc-delta.ts +9 -1
  40. package/src/launch-defaults.ts +170 -0
  41. package/src/mesh-tool-names.ts +16 -0
  42. package/src/mesh-vocabulary.ts +12 -2
  43. package/src/mission-git-state.ts +112 -0
  44. package/src/mission-items.ts +421 -0
  45. package/src/node-facts.ts +70 -1
  46. package/src/outbound-message.ts +19 -0
  47. package/src/path-ownership.ts +4 -6
  48. package/src/protocol/daemon-server.ts +37 -2
  49. package/src/protocol/dashboard-server.ts +13 -2
  50. package/src/provider-approval.ts +59 -0
  51. package/src/provider-question.ts +31 -0
  52. package/src/quota-freshness.ts +139 -0
  53. package/src/relay-reprobe.ts +117 -0
  54. package/src/release.ts +121 -0
  55. package/src/role-launch.ts +51 -0
  56. package/src/session-launch.ts +17 -1
  57. package/src/slot-proposal.ts +61 -2
  58. package/src/task-source-phase.ts +61 -0
  59. package/src/turn-evidence.ts +27 -20
  60. package/src/turn-ipc-mission.ts +121 -11
  61. package/src/turn-ipc-queue.ts +29 -1
  62. package/src/turn-ipc-turn.ts +1 -1
  63. package/src/turn-ipc.ts +1 -0
  64. package/src/types.ts +1 -0
  65. package/src/worker-protocol.ts +15 -0
package/src/node-facts.ts CHANGED
@@ -97,6 +97,20 @@ export interface MeshNodeFactsProviderQuota {
97
97
  * rather than presenting them as a freshly measured value.
98
98
  */
99
99
  lastGoodWindows?: boolean
100
+ /**
101
+ * Unix ms the reporting node's NEXT scheduled retry of this transient
102
+ * failure is due (HTTP Retry-After wins when later). Re-stamped to the
103
+ * real backoff time on every failure — see daemon-core
104
+ * `updateFailureRetry`. Absent on persistent failures and on daemons
105
+ * predating the stamp.
106
+ */
107
+ retryAtMs?: number
108
+ /**
109
+ * True once the node has spent its bounded transient-retry budget and
110
+ * stopped retrying; the next look is the hourly backfill. Readers must
111
+ * stop calling retained numbers "refreshing" (`assessQuotaFreshness`).
112
+ */
113
+ retryExhausted?: boolean
100
114
  /**
101
115
  * Unix ms when the reporting node last ATTEMPTED a refresh of this
102
116
  * provider — deliberately distinct from `updatedAt`, which dates the
@@ -112,6 +126,41 @@ export interface MeshNodeFactsProviderQuota {
112
126
  * predating the field.
113
127
  */
114
128
  fetchedAt?: number
129
+ /**
130
+ * Provenance of the RETAINED reading when `lastGoodWindows` is true —
131
+ * the source/plan/measurement time of the snapshot that produced the
132
+ * carried numbers, as opposed to the top-level `source`/`planType`,
133
+ * which describe the fresh failed attempt. See daemon-core
134
+ * `QuotaMetadata.lastGood`.
135
+ */
136
+ lastGood?: {
137
+ measuredAt: number
138
+ source?: string
139
+ planType?: string | null
140
+ collectorScope?: string
141
+ }
142
+ /**
143
+ * `'background-collector'` when the snapshot came from a daemon-spawned
144
+ * probe rather than an executing CLI session; a failure with this scope
145
+ * is not evidence about a running session's sign-in.
146
+ */
147
+ collectorScope?: string
148
+ /**
149
+ * DISPLAY-ONLY copy of the last numbers this node measured when it can
150
+ * no longer prove they belong to the CURRENT account (lineage unknown,
151
+ * or the failure left no confirmed match). Never read by routing, gates
152
+ * or ranking: those keep treating the entry as unknown. Dashboards show
153
+ * it marked "account unconfirmed" with its age. Never set across a
154
+ * CONFIRMED account switch (both accounts known and different).
155
+ */
156
+ unconfirmedReading?: {
157
+ /** Unix ms the numbers were measured. */
158
+ measuredAt: number
159
+ session?: MeshNodeFactsQuotaWindow | null
160
+ weekly?: MeshNodeFactsQuotaWindow | null
161
+ monthly?: MeshNodeFactsQuotaWindow | null
162
+ buckets?: MeshNodeFactsProviderQuota['buckets']
163
+ }
115
164
  [extra: string]: unknown
116
165
  }
117
166
  [extra: string]: unknown
@@ -137,6 +186,8 @@ export interface MeshNodeFactsProviderEnablement {
137
186
 
138
187
  export interface MeshNodeFacts {
139
188
  schemaVersion: number
189
+ /** Cached CLI detection only; no paths or account data. */
190
+ providerInventory?: { observedAt: number; providers: Record<string, { installed: boolean }> }
140
191
  reportedAt: number
141
192
  daemonBuild?: MeshNodeFactsDaemonBuild
142
193
  providerVersions?: Record<string, string>
@@ -199,6 +250,24 @@ export interface MeshNodeFacts {
199
250
  * Booleans keyed by provider type only — no free text, no credentials.
200
251
  */
201
252
  providerEnablement?: Record<string, MeshNodeFactsProviderEnablement>
253
+ /**
254
+ * The reporting machine's stored quota RESERVE (daemon-core
255
+ * `ADHDevConfig.quotaReserve`, sparse, ≤ 2 KB, migration audit trail
256
+ * stripped): the "how much quota to leave for the person" bar that the
257
+ * coordinator's launch gate applies to THIS machine's snapshots.
258
+ * ★ABSENT means "this node did not tell us" (older daemon) and resolves to
259
+ * the code defaults — the same fail-open direction as before the field
260
+ * existed. Percentages, durations and hashed account keys only; no free
261
+ * text, no emails. Resolve with daemon-core `resolveQuotaReserve`; never
262
+ * read its fields directly.
263
+ */
264
+ quotaReserve?: {
265
+ version: 1
266
+ defaults?: Record<string, number>
267
+ pools?: Record<string, Record<string, number>>
268
+ accounts?: Record<string, Record<string, Record<string, number>>>
269
+ updatedAt?: string
270
+ }
202
271
  /** Future fields ride through opaquely — do not enumerate them in relays. */
203
272
  [extra: string]: unknown
204
273
  }
@@ -253,7 +322,7 @@ export function normalizeMeshNodeFacts(raw: unknown): MeshNodeFacts | undefined
253
322
  * the host `agy` itself uses; the unprefixed `cloudcode-pa` host 429s Google
254
323
  * AI Pro Antigravity accounts), and its credential lives in the OS keyring,
255
324
  * not the stale on-disk token file — see the provenance note in daemon-core
256
- * `quota/fetchers/antigravity.ts`. Supported on macOS and Windows; other
325
+ * `quota/fetchers/antigravity.ts`. Supported on macOS, Windows, and Linux; other
257
326
  * platforms report `unsupported` rather than guess at a keyring backend
258
327
  * nobody has verified.
259
328
  *
@@ -160,6 +160,7 @@ export type SendRefusal =
160
160
  | 'no_target'
161
161
  | 'bootstrap_pending'
162
162
  | 'duplicate_dispatch'
163
+ | 'new_work_held'
163
164
  | 'internal_error'
164
165
 
165
166
  /**
@@ -278,6 +279,7 @@ export const SEND_REFUSAL_REASONS = [
278
279
  'no_target',
279
280
  'bootstrap_pending',
280
281
  'duplicate_dispatch',
282
+ 'new_work_held',
281
283
  'internal_error',
282
284
  ] as const satisfies readonly SendRefusal[]
283
285
 
@@ -321,3 +323,20 @@ export function isOutboundMessage(value: unknown): value is OutboundMessage {
321
323
  && isSendPolicy(candidate.policy)
322
324
  && typeof candidate.createdAt === 'number'
323
325
  }
326
+
327
+ const PASTED_OPEN_RE = /^\s*<pasted_content(?:\s+id="[^"]*")?\s*>\s*$/
328
+ const PASTED_CLOSE_RE = /^\s*<\/pasted_content(?:\s+id="[^"]*")?\s*>\s*$/
329
+
330
+ /**
331
+ * Drop the `<pasted_content>` envelope lines Claude Code wraps around a pasted
332
+ * input. Only a text that OPENS with the envelope is touched; anything else is
333
+ * returned unchanged. Shared across daemon transcript merge and web echo/display
334
+ * normalization so wrapped turns correlate identically.
335
+ */
336
+ export function unwrapPastedContent(text: string): string {
337
+ const lines = text.replace(/\r\n?/g, '\n').split('\n')
338
+ const first = lines.findIndex((line) => line.trim())
339
+ if (first < 0 || !PASTED_OPEN_RE.test(lines[first])) return text
340
+ return lines.filter((line) => !PASTED_OPEN_RE.test(line) && !PASTED_CLOSE_RE.test(line)).join('\n')
341
+ }
342
+
@@ -34,7 +34,7 @@
34
34
 
35
35
  /** One normalized ownership entry: a repo-relative path, optionally a `**` subtree prefix. */
36
36
  export interface OwnedPathEntry {
37
- /** Repo-relative path with forward slashes, no leading `./`, no trailing slash. */
37
+ /** Repo-relative path with forward slashes, no leading `./`, no trailing slash; empty for the whole-repo subtree. */
38
38
  readonly path: string
39
39
  /** True when this entry was declared as `<path>/**` (owns the whole subtree). */
40
40
  readonly subtree: boolean
@@ -159,16 +159,14 @@ export function normalizeOwnedPaths(input: unknown): NormalizeOwnedPathsResult {
159
159
  /** True when `path` is exactly `base`, or (when `base` is a subtree entry) nested under it. */
160
160
  function entryContains(base: OwnedPathEntry, path: string): boolean {
161
161
  if (path === base.path) return true
162
- if (base.subtree && (path === base.path || path.startsWith(`${base.path}/`))) return true
162
+ // An empty subtree prefix is the repo root, so it contains every normalized path.
163
+ if (base.subtree && (base.path === '' || path.startsWith(`${base.path}/`))) return true
163
164
  return false
164
165
  }
165
166
 
166
167
  /** True when two normalized entries' claimed sets intersect (exact match, or one contains the other's directory). */
167
168
  function entriesOverlap(a: OwnedPathEntry, b: OwnedPathEntry): boolean {
168
- if (a.path === b.path) return true
169
- if (a.subtree && (b.path === a.path || b.path.startsWith(`${a.path}/`))) return true
170
- if (b.subtree && (a.path === b.path || a.path.startsWith(`${b.path}/`))) return true
171
- return false
169
+ return entryContains(a, b.path) || entryContains(b, a.path)
172
170
  }
173
171
 
174
172
  /** True when two declarations claim any overlapping path. Pure set comparison — no policy (refuse vs warn) here. */
@@ -52,6 +52,9 @@ export const DAEMON_STATUS_EVENT_NAMES = [
52
52
  'agent:generating_completed',
53
53
  'agent:stopped',
54
54
  'monitor:no_progress',
55
+ // An owner decision (structured missions) needs the owner. Server-bound for the push only;
56
+ // the dashboard learns about decisions over P2P (`mission_decisions_list`).
57
+ 'agent:decision_needed',
55
58
  ] as const
56
59
  export type DaemonStatusEventName = typeof DAEMON_STATUS_EVENT_NAMES[number]
57
60
  export const isDaemonStatusEventName = makeTypeGuard(DAEMON_STATUS_EVENT_NAMES)
@@ -62,6 +65,27 @@ export const isDaemonStatusEventName = makeTypeGuard(DAEMON_STATUS_EVENT_NAMES)
62
65
  * (`modalMessage`/`modalButtons`, CLAUDE.md "Push notification exception").
63
66
  * Mirror of daemon-core `DaemonStatusEventPayload`.
64
67
  */
68
+ /**
69
+ * Owner-decision push payload (design §9, owner decision D1 2026-10-28). The ONLY mission text that
70
+ * reaches the server: the question (<=80 chars) and up to 3 option labels (<=24 chars). Never
71
+ * `context`, free-text answers, mission goal/summary or chat content.
72
+ */
73
+ export type DecisionPushWire = {
74
+ meshId: string
75
+ missionId: string
76
+ itemId: string
77
+ question: string
78
+ options: Array<{ id: string; label: string }>
79
+ recommended?: string
80
+ urgency: 'normal' | 'blocking'
81
+ dueAt?: string
82
+ /** `new` = freshly asked; `remind` = a blocking decision crossed its due time. */
83
+ reason: 'new' | 'remind'
84
+ }
85
+ export const DECISION_PUSH_QUESTION_MAX = 80
86
+ export const DECISION_PUSH_OPTION_LABEL_MAX = 24
87
+ export const DECISION_PUSH_OPTIONS_MAX = 3
88
+
65
89
  export type DaemonStatusEventWirePayload = {
66
90
  event: DaemonStatusEventName
67
91
  timestamp: number
@@ -73,8 +97,13 @@ export type DaemonStatusEventWirePayload = {
73
97
  elapsedSec?: number
74
98
  modalMessage?: string
75
99
  modalButtons?: string[]
100
+ modalKey?: string
101
+ approvalEvidence?: 'live' | 'cached' | 'unavailable'
102
+ waitKind?: 'external'
76
103
  surfaceHidden?: boolean
77
104
  muted?: boolean
105
+ /** Present only on `agent:decision_needed`. */
106
+ decision?: DecisionPushWire
78
107
  }
79
108
 
80
109
  // ─── Status report (mirror of daemon-core `CloudStatusReportPayload`) ──────
@@ -260,8 +289,8 @@ export const DAEMON_P2P_SIGNAL_TYPES = ['p2p_offer', 'p2p_ice'] as const
260
289
  export type DaemonP2PSignalType = typeof DAEMON_P2P_SIGNAL_TYPES[number]
261
290
  export const isDaemonP2PSignalType = makeTypeGuard(DAEMON_P2P_SIGNAL_TYPES)
262
291
 
263
- export type P2POfferWirePayload = { sdp: string; type: string; peerId: string }
264
- export type P2PIceWirePayload = { candidate: string; mid: string; peerId: string }
292
+ export type P2POfferWirePayload = { sdp: string; type: string; peerId: string; attemptId?: number; attemptEpoch?: string; probe?: boolean }
293
+ export type P2PIceWirePayload = { candidate: string; mid: string; peerId: string; attemptId?: number; attemptEpoch?: string; probe?: boolean }
265
294
 
266
295
  /**
267
296
  * A dashboard-originated signal as relayed to the daemon. `peerId` is stamped
@@ -273,6 +302,12 @@ export type P2PIceWirePayload = { candidate: string; mid: string; peerId: string
273
302
  export type RelayedP2PSignalWirePayload = {
274
303
  peerId?: string
275
304
  daemonId?: string
305
+ attemptId?: number
306
+ attemptEpoch?: string
307
+ /** p2p_ready only: the dashboard's stale-connect backoff index (see DashboardP2PReadyWirePayload). */
308
+ backoffStep?: number
309
+ /** Make-before-break relay->direct re-probe of an existing peer (see DashboardP2PReadyWirePayload). */
310
+ probe?: boolean
276
311
  sharePermission?: string
277
312
  shareTargetSessionId?: string
278
313
  sdp?: string
@@ -152,6 +152,9 @@ export type SystemAnnouncementWirePayload = {
152
152
  export type DashboardP2POfferWirePayload = {
153
153
  daemonId: string
154
154
  peerId?: string
155
+ attemptId?: number
156
+ attemptEpoch?: string
157
+ probe?: boolean
155
158
  sdp: string
156
159
  type: string
157
160
  }
@@ -159,6 +162,9 @@ export type DashboardP2POfferWirePayload = {
159
162
  export type DashboardP2PIceWirePayload = {
160
163
  daemonId: string
161
164
  peerId?: string
165
+ attemptId?: number
166
+ attemptEpoch?: string
167
+ probe?: boolean
162
168
  candidate: string
163
169
  mid?: string
164
170
  sdpMid?: string | null
@@ -234,11 +240,16 @@ export const DASHBOARD_P2P_SIGNAL_TYPES = ['p2p_ready', 'p2p_answer', 'p2p_ice']
234
240
  export type DashboardP2PSignalType = typeof DASHBOARD_P2P_SIGNAL_TYPES[number]
235
241
  export const isDashboardP2PSignalType = makeTypeGuard(DASHBOARD_P2P_SIGNAL_TYPES)
236
242
 
237
- export type DashboardP2PReadyWirePayload = { daemonId: string; peerId: string }
238
- export type DashboardP2PAnswerWirePayload = { daemonId: string; peerId: string; sdp: string; type: string }
243
+ /** `backoffStep`: the dashboard's stale-connect backoff index (0 = 15s, 1 = 30s, 2 = 60s), so the daemon holds the connecting slot for the same window. */
244
+ /** `probe`: identifier-only flag — this signal belongs to a make-before-break relay->direct re-probe of the SAME logical peer (never a new peer for the connection cap). */
245
+ export type DashboardP2PReadyWirePayload = { daemonId: string; peerId: string; attemptId?: number; attemptEpoch?: string; backoffStep?: number; probe?: boolean }
246
+ export type DashboardP2PAnswerWirePayload = { daemonId: string; peerId: string; sdp: string; type: string; attemptId?: number; attemptEpoch?: string; probe?: boolean }
239
247
  export type DashboardP2PLocalIceWirePayload = {
240
248
  daemonId: string
241
249
  peerId: string
250
+ attemptId?: number
251
+ attemptEpoch?: string
252
+ probe?: boolean
242
253
  candidate: string
243
254
  sdpMid: string | null
244
255
  sdpMLineIndex: number | null
@@ -0,0 +1,59 @@
1
+ /** Provider permissions are runtime decisions, never owner write ratification. */
2
+ export interface ProviderApproval {
3
+ source: 'provider_modal' | 'legacy_provider' | 'status_only' | 'ledger_approval';
4
+ /** Exact Inbox actions or the existing session handler; never inferred consent. */
5
+ compatibility?: 'exact' | 'session_only' | 'unknown';
6
+ type: 'tool_permission' | 'provider_setup' | 'unknown';
7
+ evidence: 'live' | 'cached' | 'unavailable';
8
+ lifecycle: 'pending' | 'resolved';
9
+ modalKey: string | null;
10
+ createdAt: number | null;
11
+ resolvedAt: number | null;
12
+ description: string;
13
+ runtimeDirectory?: string;
14
+ displayedDirectory?: string | null;
15
+ buttons: string[];
16
+ actionable: boolean;
17
+ providerType?: string;
18
+ sessionId?: string;
19
+ nodeId?: string | null;
20
+ meshId?: string | null;
21
+ taskId?: string | null;
22
+ attemptId?: string | null;
23
+ generation?: number | null;
24
+ }
25
+
26
+ /** Exact resource identity required by the approvals-only action path. */
27
+ export interface ProviderApprovalTarget {
28
+ modalKey: string;
29
+ attemptId: string | null;
30
+ generation: number | null;
31
+ }
32
+
33
+ /** Confirmed authority for the new Inbox path. Legacy handlers retain their own checks. */
34
+ export function isActionableProviderApproval(approval: ProviderApproval | null | undefined): boolean {
35
+ return !!approval && approval.source === 'provider_modal' && approval.type === 'tool_permission'
36
+ && approval.lifecycle === 'pending' && approval.evidence === 'live' && approval.actionable
37
+ && !!approval.modalKey && Array.isArray(approval.buttons)
38
+ && approval.buttons.some(button => typeof button === 'string' && !!button.trim());
39
+ }
40
+
41
+ /** An explicit null suppresses the resource; only missing metadata uses compatibility. */
42
+ export function readProviderDecision(approval: ProviderApproval | null | undefined, waiting: boolean, hasModal = false): ProviderApproval | null {
43
+ if (approval !== undefined) return approval && ['provider_modal', 'legacy_provider', 'status_only', 'ledger_approval'].includes(approval.source) ? approval : null;
44
+ if (!waiting) return null;
45
+ return { source: hasModal ? 'legacy_provider' : 'status_only', type: 'unknown',
46
+ compatibility: hasModal ? 'session_only' : 'unknown', evidence: 'unavailable',
47
+ lifecycle: 'pending', modalKey: null, createdAt: null, resolvedAt: null,
48
+ description: '', buttons: [], actionable: false };
49
+ }
50
+
51
+ /** Host metadata is durable: identity and clocks only, never modal text or directories. */
52
+ export function providerApprovalSnapshot(approval: ProviderApproval | null | undefined): ProviderApproval | null {
53
+ if (!approval) return null;
54
+ const { source, type, lifecycle, modalKey, createdAt, resolvedAt, providerType, sessionId,
55
+ nodeId, meshId, taskId, attemptId, generation } = approval;
56
+ return { source, type, lifecycle, modalKey, createdAt, resolvedAt, providerType, sessionId,
57
+ nodeId, meshId, taskId, attemptId, generation, evidence: 'cached', actionable: false,
58
+ description: '', buttons: [] };
59
+ }
@@ -0,0 +1,31 @@
1
+ /** Ephemeral provenance supplied by the owning daemon, never inferred from prompt text. */
2
+ export interface ProviderQuestionBinding {
3
+ transport: 'codex_rpc' | 'codex_async';
4
+ sessionId: string;
5
+ generation: string;
6
+ threadId: string;
7
+ turnId: string;
8
+ itemId: string;
9
+ requestId?: string | number;
10
+ }
11
+
12
+ export type ProviderQuestionLifecycle = 'pending' | 'submitted' | 'resolved' | 'completed' | 'cancelled' | 'expired' | 'unknown';
13
+
14
+ export interface ProviderQuestionEpisode {
15
+ blocking?: boolean;
16
+ binding?: ProviderQuestionBinding;
17
+ lifecycle?: ProviderQuestionLifecycle;
18
+ /** Async dismiss is product-local; it is not a native tool cancellation. */
19
+ support?: 'request_bound' | 'product_local' | 'uncertain';
20
+ }
21
+
22
+ export function isProviderQuestionBinding(value: unknown): value is ProviderQuestionBinding {
23
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
24
+ const b = value as Record<string, unknown>;
25
+ if (b.transport !== 'codex_rpc' && b.transport !== 'codex_async') return false;
26
+ const keys = ['transport', 'sessionId', 'generation', 'threadId', 'turnId', 'itemId', ...(b.transport === 'codex_rpc' ? ['requestId'] : [])];
27
+ if (Reflect.ownKeys(b).some(key => typeof key !== 'string' || !keys.includes(key))) return false;
28
+ if (['sessionId', 'generation', 'threadId', 'turnId', 'itemId'].some(key => typeof b[key] !== 'string' || !(b[key] as string).trim())) return false;
29
+ return b.transport === 'codex_async' ? b.requestId === undefined
30
+ : (typeof b.requestId === 'string' && !!b.requestId.trim()) || (typeof b.requestId === 'number' && Number.isFinite(b.requestId));
31
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Quota snapshot freshness — the ONE decision every display surface shares.
3
+ *
4
+ * Why this lives in the leaf package: the dashboard chips (web-core
5
+ * `quotaWindowCue`), the `adhdev quota` CLI (daemon-core `quota/cli.ts`) and the
6
+ * coordinator-facing `mesh_status` fold (mcp-server `summarizeNodeQuota`) each
7
+ * carried a hand-copied version of this rule, and a fix to one left the other
8
+ * two telling a different story about the same snapshot. They all call
9
+ * `assessQuotaFreshness` now; only the WORDING stays per-surface.
10
+ *
11
+ * Pure: plain objects in, plain object out, no clock except the `now` argument.
12
+ */
13
+ import type { MeshNodeFactsProviderQuota } from './node-facts'
14
+
15
+ export type QuotaFreshnessCue = 'refreshing' | 'stale'
16
+
17
+ export interface QuotaFreshness {
18
+ /** Undefined = nothing to say (a fresh reading, or no retained numbers). */
19
+ cue?: QuotaFreshnessCue
20
+ /**
21
+ * Only the USER can produce a new reading (aged-out Claude statusline;
22
+ * expired CLI access token — the daemon never redeems it). The surface
23
+ * should say what to run, not promise a self-heal.
24
+ */
25
+ userMustAct: boolean
26
+ /**
27
+ * Unix ms by which the daemon will have looked again, for a `stale` reading
28
+ * the daemon re-checks on its own. Absent when the user must act, when the
29
+ * reading is `refreshing` (a retry is already scheduled), or when the check
30
+ * is already due.
31
+ */
32
+ nextCheckAt?: number
33
+ }
34
+
35
+ /**
36
+ * How long past `metadata.retryAtMs` a retry still reads as "in flight". The
37
+ * retry timer fires at retryAtMs and the probe itself takes a few seconds; a
38
+ * retryAtMs well in the past with no newer attempt means no retry is coming.
39
+ */
40
+ export const QUOTA_RETRY_INFLIGHT_GRACE_MS = 60_000
41
+
42
+ /**
43
+ * The daemon's backfill horizon: a snapshot is re-probed once it is this old
44
+ * even on an idle machine. Mirrors daemon-core `QUOTA_ROUTABLE_MAX_AGE_MS`
45
+ * (quota/refresh.ts); daemon-core's quota-freshness test pins the two equal.
46
+ */
47
+ export const QUOTA_BACKFILL_HORIZON_MS = 60 * 60 * 1000
48
+
49
+ function hasUsableWindow(window: { usedPercent?: unknown } | null | undefined): boolean {
50
+ return !!window && typeof window.usedPercent === 'number' && Number.isFinite(window.usedPercent)
51
+ }
52
+
53
+ /**
54
+ * Does the snapshot carry any renderable number — session/weekly/monthly
55
+ * windows OR per-pool buckets? `monthly` counts on its own: cursor-cli reports
56
+ * ONLY that axis, and leaving it out made a retained cursor reading look
57
+ * numberless. A window without a finite `usedPercent` is unknown, not 0%.
58
+ */
59
+ function hasReading(quota: MeshNodeFactsProviderQuota): boolean {
60
+ if (hasUsableWindow(quota.session) || hasUsableWindow(quota.weekly) || hasUsableWindow(quota.monthly)) return true
61
+ return Array.isArray(quota.buckets) && quota.buckets.some((b) => !!b && typeof b.usedPercent === 'number' && Number.isFinite(b.usedPercent))
62
+ }
63
+
64
+ function finiteMs(value: unknown): number | undefined {
65
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
66
+ }
67
+
68
+ /**
69
+ * When the daemon will next look, from the snapshot alone — the same
70
+ * "both clocks" rule the refresh loop's backfill uses (data age AND attempt
71
+ * age must both pass the horizon). Undefined when that moment has passed.
72
+ */
73
+ function backfillCheckAt(quota: MeshNodeFactsProviderQuota, now: number): number | undefined {
74
+ const dataDue = finiteMs(quota.updatedAt)
75
+ const attempted = finiteMs(quota.metadata?.fetchedAt)
76
+ const dueAt = Math.max(
77
+ dataDue === undefined ? Number.NEGATIVE_INFINITY : dataDue + QUOTA_BACKFILL_HORIZON_MS,
78
+ attempted === undefined ? Number.NEGATIVE_INFINITY : attempted + QUOTA_BACKFILL_HORIZON_MS,
79
+ )
80
+ return Number.isFinite(dueAt) && dueAt > now ? dueAt : undefined
81
+ }
82
+
83
+ /**
84
+ * Which freshness cue a snapshot's retained numbers should carry.
85
+ *
86
+ * - `stale` + `userMustAct` — nothing will refresh it without the user:
87
+ * · `no-data` on any provider EXCEPT codex (Claude's statusline aged out —
88
+ * a session must run). Codex's `no-data` is the daemon re-reading the
89
+ * rollout files itself (≈60s while a CLI is active, ≤60 min idle), so it
90
+ * is `stale` WITHOUT userMustAct;
91
+ * · `expired-token` on Grok/Kimi/antigravity — only the owning CLI
92
+ * renews it on use. An idle CLI has no background renewal cadence.
93
+ * A scheduled quota retry does not itself renew credentials.
94
+ * - `refreshing` — retained numbers after a transient failure with a retry
95
+ * actually pending. ★Honest about the end of the retry budget: once the
96
+ * daemon has stopped retrying (`metadata.retryExhausted`, or `retryAtMs`
97
+ * long past with no newer attempt) the numbers are `stale` with the next
98
+ * scheduled check, not "refreshing" for the up-to-60 minutes until the
99
+ * hourly backfill. A snapshot with NO retry bookkeeping at all (a daemon
100
+ * predating the stamp) keeps the old `refreshing` reading.
101
+ */
102
+ export function assessQuotaFreshness(quota: MeshNodeFactsProviderQuota, now: number = Date.now()): QuotaFreshness {
103
+ const meta = quota.metadata
104
+ const kind = meta?.failureKind
105
+ const userMustAct = kind === 'no-data' && quota.provider !== 'codex-cli'
106
+ const awaitsCliUse = kind === 'expired-token' && ['grok-cli', 'kimi', 'antigravity-cli'].includes(quota.provider)
107
+ // Order matters: the aged-out Claude shape and the retained expired-token
108
+ // shape both ALSO mark lastGoodWindows, so they must be classified before
109
+ // the generic branch. An expired short-lived access token that the CLI
110
+ // renews itself is NOT an auth failure requiring user action (userMustAct:
111
+ // false), while any retained lastGood numbers are marked stale.
112
+ if (userMustAct) return hasReading(quota) ? { cue: 'stale', userMustAct: true } : { userMustAct: true }
113
+ if (awaitsCliUse) return hasReading(quota) ? { cue: 'stale', userMustAct: false } : { userMustAct: false }
114
+ if (meta?.lastGoodWindows !== true) {
115
+ // A successful fetch is not a perpetual live reading. Use the DATA
116
+ // clock; a recent failed attempt must never rejuvenate old numbers.
117
+ const measuredAt = finiteMs(quota.updatedAt)
118
+ if (hasReading(quota) && (measuredAt === undefined || now - measuredAt >= QUOTA_BACKFILL_HORIZON_MS)) {
119
+ return { cue: 'stale', userMustAct }
120
+ }
121
+ return { userMustAct }
122
+ }
123
+
124
+ const retryAtMs = finiteMs(meta?.retryAtMs)
125
+ const retryStopped = meta?.retryExhausted === true
126
+ || (retryAtMs !== undefined && retryAtMs + QUOTA_RETRY_INFLIGHT_GRACE_MS < now)
127
+ // A `no-data` retained reading (codex) has no retry at all — it is a
128
+ // snapshot the daemon will simply look at again, never an in-flight refresh.
129
+ if (kind !== 'no-data' && !retryStopped) return { cue: 'refreshing', userMustAct: false }
130
+ const nextCheckAt = backfillCheckAt(quota, now)
131
+ return nextCheckAt === undefined
132
+ ? { cue: 'stale', userMustAct: false }
133
+ : { cue: 'stale', userMustAct: false, nextCheckAt }
134
+ }
135
+
136
+ /** Whole minutes (min 1) until `nextCheckAt`, for the "next check ~Nm" wording. */
137
+ export function minutesUntilQuotaCheck(nextCheckAt: number, now: number = Date.now()): number {
138
+ return Math.max(1, Math.round((nextCheckAt - now) / 60_000))
139
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Relay -> direct re-probe scheduler (pure, dependency-free).
3
+ *
4
+ * A P2P connection that settled on a TURN relay never looks for a direct path
5
+ * again. The owning transport (dashboard `P2PConnection`, daemon peer manager)
6
+ * uses this scheduler to periodically attempt a make-before-break probe: a
7
+ * SECOND peer connection (STUN-only) is built next to the working relay one and
8
+ * only swapped in once it is verified direct. The scheduler owns nothing but
9
+ * timing: backoff 60s -> 5min (doubling), jitter, and the stop conditions. The
10
+ * transport supplies `shouldProbe` (open + idle + visible) and `probe`.
11
+ */
12
+
13
+ export type RelayReprobeOutcome = 'direct' | 'relay' | 'failed' | 'skipped'
14
+
15
+ export interface RelayReprobeTiming {
16
+ /** First delay and the unit of growth (default 60s). */
17
+ baseDelayMs?: number
18
+ /** Backoff ceiling before jitter (default 5min). */
19
+ maxDelayMs?: number
20
+ /** +/- fraction applied to every delay (default 0.2). */
21
+ jitterRatio?: number
22
+ }
23
+
24
+ export const RELAY_REPROBE_BASE_DELAY_MS = 60_000
25
+ export const RELAY_REPROBE_MAX_DELAY_MS = 5 * 60_000
26
+ export const RELAY_REPROBE_JITTER_RATIO = 0.2
27
+
28
+ /** Delay before the next attempt after `failures` consecutive non-direct outcomes. */
29
+ export function computeRelayReprobeDelay(
30
+ failures: number,
31
+ random: () => number,
32
+ timing: RelayReprobeTiming = {},
33
+ ): number {
34
+ const base = timing.baseDelayMs ?? RELAY_REPROBE_BASE_DELAY_MS
35
+ const max = timing.maxDelayMs ?? RELAY_REPROBE_MAX_DELAY_MS
36
+ const jitter = timing.jitterRatio ?? RELAY_REPROBE_JITTER_RATIO
37
+ const grown = Math.min(max, base * 2 ** Math.max(0, failures))
38
+ const factor = 1 + jitter * (Math.min(1, Math.max(0, random())) * 2 - 1)
39
+ return Math.round(grown * factor)
40
+ }
41
+
42
+ export interface RelayReprobeSchedulerOptions extends RelayReprobeTiming {
43
+ random: () => number
44
+ setTimer: (callback: () => void, ms: number) => unknown
45
+ clearTimer: (handle: unknown) => void
46
+ /** Connected over relay, idle, tab visible, auth epoch unchanged... Re-checked on every tick. */
47
+ shouldProbe: () => boolean
48
+ /** One make-before-break attempt. Must never throw into the scheduler; a rejection counts as 'failed'. */
49
+ probe: () => Promise<RelayReprobeOutcome>
50
+ }
51
+
52
+ export class RelayReprobeScheduler {
53
+ private handle: unknown = null
54
+ private running = false
55
+ private inFlight = false
56
+ private failureCount = 0
57
+ private generation = 0
58
+
59
+ constructor(private readonly options: RelayReprobeSchedulerOptions) {}
60
+
61
+ get isRunning(): boolean { return this.running }
62
+ get failures(): number { return this.failureCount }
63
+ get probing(): boolean { return this.inFlight }
64
+
65
+ /** Begin probing (idempotent). Call when a connection is selected as relay. */
66
+ start(): void {
67
+ if (this.running) return
68
+ this.running = true
69
+ this.failureCount = 0
70
+ this.generation++
71
+ this.schedule()
72
+ }
73
+
74
+ /** Stop for good (direct path found, connection closed, auth epoch changed). */
75
+ stop(): void {
76
+ this.running = false
77
+ this.generation++
78
+ if (this.handle !== null) {
79
+ this.options.clearTimer(this.handle)
80
+ this.handle = null
81
+ }
82
+ }
83
+
84
+ private schedule(): void {
85
+ if (!this.running) return
86
+ const generation = this.generation
87
+ const delay = computeRelayReprobeDelay(this.failureCount, this.options.random, this.options)
88
+ this.handle = this.options.setTimer(() => {
89
+ this.handle = null
90
+ void this.tick(generation)
91
+ }, delay)
92
+ }
93
+
94
+ private async tick(generation: number): Promise<void> {
95
+ if (!this.running || generation !== this.generation) return
96
+ if (this.inFlight || !this.options.shouldProbe()) {
97
+ this.schedule()
98
+ return
99
+ }
100
+ this.inFlight = true
101
+ let outcome: RelayReprobeOutcome
102
+ try {
103
+ outcome = await this.options.probe()
104
+ } catch {
105
+ outcome = 'failed'
106
+ }
107
+ this.inFlight = false
108
+ // stop()/restart while the probe was in flight: the verdict is stale.
109
+ if (!this.running || generation !== this.generation) return
110
+ if (outcome === 'direct') {
111
+ this.stop()
112
+ return
113
+ }
114
+ if (outcome !== 'skipped') this.failureCount++
115
+ this.schedule()
116
+ }
117
+ }