@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.
- package/dist/assistant-protocol.d.ts +161 -6
- package/dist/decision-push-relay.d.ts +20 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1197 -118
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1107 -118
- package/dist/index.mjs.map +1 -1
- package/dist/launch-defaults.d.ts +196 -0
- package/dist/mesh-tool-names.d.ts +2 -2
- package/dist/mesh-vocabulary.d.ts +13 -4
- package/dist/mission-git-state.d.ts +83 -0
- package/dist/mission-items.d.ts +202 -0
- package/dist/node-facts.d.ts +75 -1
- package/dist/outbound-message.d.ts +9 -2
- package/dist/path-ownership.d.ts +1 -1
- package/dist/protocol/daemon-server.d.ts +42 -2
- package/dist/protocol/dashboard-daemon-p2p.d.ts +1 -1
- package/dist/protocol/dashboard-server.d.ts +20 -2
- package/dist/provider-approval.d.ts +36 -0
- package/dist/provider-question.d.ts +19 -0
- package/dist/quota-freshness.d.ts +65 -0
- package/dist/relay-reprobe.d.ts +52 -0
- package/dist/release.d.ts +90 -0
- package/dist/role-launch.d.ts +11 -0
- package/dist/session-launch.d.ts +10 -22
- package/dist/slot-proposal.d.ts +14 -0
- package/dist/task-source-phase.d.ts +75 -0
- package/dist/turn-evidence.d.ts +29 -13
- package/dist/turn-ipc-mission.d.ts +68 -1
- package/dist/turn-ipc-queue.d.ts +19 -0
- package/dist/turn-ipc.d.ts +2 -2
- package/dist/types.d.ts +1 -0
- package/dist/worker-protocol.d.ts +1 -1
- package/package.json +1 -1
- package/src/assistant-protocol.ts +302 -6
- package/src/daemon-normalize.ts +7 -5
- package/src/decision-push-relay.ts +23 -0
- package/src/index.ts +12 -0
- package/src/keyed-doc-delta.ts +9 -1
- package/src/launch-defaults.ts +170 -0
- package/src/mesh-tool-names.ts +16 -0
- package/src/mesh-vocabulary.ts +12 -2
- package/src/mission-git-state.ts +112 -0
- package/src/mission-items.ts +421 -0
- package/src/node-facts.ts +70 -1
- package/src/outbound-message.ts +19 -0
- package/src/path-ownership.ts +4 -6
- package/src/protocol/daemon-server.ts +37 -2
- package/src/protocol/dashboard-server.ts +13 -2
- package/src/provider-approval.ts +59 -0
- package/src/provider-question.ts +31 -0
- package/src/quota-freshness.ts +139 -0
- package/src/relay-reprobe.ts +117 -0
- package/src/release.ts +121 -0
- package/src/role-launch.ts +51 -0
- package/src/session-launch.ts +17 -1
- package/src/slot-proposal.ts +61 -2
- package/src/task-source-phase.ts +61 -0
- package/src/turn-evidence.ts +27 -20
- package/src/turn-ipc-mission.ts +121 -11
- package/src/turn-ipc-queue.ts +29 -1
- package/src/turn-ipc-turn.ts +1 -1
- package/src/turn-ipc.ts +1 -0
- package/src/types.ts +1 -0
- 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
|
|
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
|
*
|
package/src/outbound-message.ts
CHANGED
|
@@ -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
|
+
|
package/src/path-ownership.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
+
}
|