@adhdev/mesh-shared 1.0.82-rc.2 → 1.0.82-rc.21

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.
@@ -18,15 +18,21 @@
18
18
 
19
19
  export const ASSISTANT_TOOLS = [
20
20
  'projects',
21
+ 'environment',
22
+ 'assistant_action',
23
+ 'project_watch',
21
24
  'project_status',
22
25
  'project_send',
23
26
  'project_read',
24
27
  'project_add',
28
+ 'project_attach_remote',
25
29
  'discover_repos',
26
30
  'memory',
27
31
  'skill_view',
28
32
  'skill_manage',
29
33
  'project_note',
34
+ 'launch_defaults',
35
+ 'launch_defaults_set',
30
36
  ] as const
31
37
 
32
38
  export type AssistantTool = typeof ASSISTANT_TOOLS[number]
@@ -41,15 +47,23 @@ export function isAssistantTool(value: unknown): value is AssistantTool {
41
47
  export const ASSISTANT_VERB = {
42
48
  // tool verbs
43
49
  projects: 'assistant_projects',
50
+ environment: 'assistant_environment',
51
+ action: 'assistant_action',
52
+ watch: 'assistant_project_watch',
53
+ executionState: 'assistant_execution_state',
54
+ executionSettings: 'assistant_execution_settings',
44
55
  projectStatus: 'assistant_project_status',
45
56
  projectSend: 'assistant_project_send',
46
57
  projectRead: 'assistant_project_read',
47
58
  projectAdd: 'assistant_project_add',
59
+ projectAttachRemote: 'assistant_project_attach_remote',
48
60
  discoverRepos: 'assistant_discover_repos',
49
61
  memory: 'assistant_memory',
50
62
  skillView: 'assistant_skill_view',
51
63
  skillManage: 'assistant_skill_manage',
52
64
  projectNote: 'assistant_project_note',
65
+ launchDefaults: 'assistant_launch_defaults',
66
+ launchDefaultsSet: 'assistant_launch_defaults_set',
53
67
  // assistant-side daemon verbs without a tool of the same name
54
68
  launch: 'launch_assistant',
55
69
  pendingRelays: 'assistant_pending_relays',
@@ -57,6 +71,9 @@ export const ASSISTANT_VERB = {
57
71
  stagedResolve: 'assistant_staged_resolve',
58
72
  storeAdmin: 'assistant_store_admin',
59
73
  importSkills: 'assistant_import_skills',
74
+ /** Projects the assistant is following (open relay threads) — list / stop following. */
75
+ threads: 'assistant_threads',
76
+ threadClose: 'assistant_thread_close',
60
77
  } as const
61
78
 
62
79
  export type AssistantVerb = typeof ASSISTANT_VERB[keyof typeof ASSISTANT_VERB]
@@ -64,15 +81,21 @@ export type AssistantVerb = typeof ASSISTANT_VERB[keyof typeof ASSISTANT_VERB]
64
81
  /** The daemon verb each assistant tool calls. */
65
82
  export const ASSISTANT_TOOL_VERBS: Readonly<Record<AssistantTool, AssistantVerb>> = {
66
83
  projects: ASSISTANT_VERB.projects,
84
+ environment: ASSISTANT_VERB.environment,
85
+ assistant_action: ASSISTANT_VERB.action,
86
+ project_watch: ASSISTANT_VERB.watch,
67
87
  project_status: ASSISTANT_VERB.projectStatus,
68
88
  project_send: ASSISTANT_VERB.projectSend,
69
89
  project_read: ASSISTANT_VERB.projectRead,
70
90
  project_add: ASSISTANT_VERB.projectAdd,
91
+ project_attach_remote: ASSISTANT_VERB.projectAttachRemote,
71
92
  discover_repos: ASSISTANT_VERB.discoverRepos,
72
93
  memory: ASSISTANT_VERB.memory,
73
94
  skill_view: ASSISTANT_VERB.skillView,
74
95
  skill_manage: ASSISTANT_VERB.skillManage,
75
96
  project_note: ASSISTANT_VERB.projectNote,
97
+ launch_defaults: ASSISTANT_VERB.launchDefaults,
98
+ launch_defaults_set: ASSISTANT_VERB.launchDefaultsSet,
76
99
  }
77
100
 
78
101
  /**
@@ -81,13 +104,15 @@ export const ASSISTANT_TOOL_VERBS: Readonly<Record<AssistantTool, AssistantVerb>
81
104
  * because they cause effects (a coordinator message or launch, a new
82
105
  * project), and MCP clients use readOnlyHint to skip confirmation.
83
106
  */
84
- export const ASSISTANT_WRITE_TOOLS: readonly AssistantTool[] = ['memory', 'skill_manage', 'project_note', 'project_send', 'project_add']
107
+ export const ASSISTANT_WRITE_TOOLS: readonly AssistantTool[] = ['project_watch', 'assistant_action', 'memory', 'skill_manage', 'project_note', 'project_send', 'project_add', 'project_attach_remote', 'launch_defaults_set']
85
108
 
86
109
  /** Owner-only verbs: never reachable from the assistant's MCP server (no `ipc`). */
87
110
  export const ASSISTANT_OWNER_VERBS: readonly AssistantVerb[] = [
111
+ ASSISTANT_VERB.executionSettings,
88
112
  ASSISTANT_VERB.stagedResolve,
89
113
  ASSISTANT_VERB.storeAdmin,
90
114
  ASSISTANT_VERB.importSkills,
115
+ ASSISTANT_VERB.threadClose,
91
116
  ]
92
117
 
93
118
  /** The only assistant verbs accepted while an idle review turn is open (§4.10.7). */
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export * from './git-normalize'
16
16
  export * from './session-normalize'
17
17
  export * from './node-normalize'
18
18
  export * from './node-facts'
19
+ export * from './quota-freshness'
19
20
  export * from './workspace-normalize'
20
21
  export * from './daemon-normalize'
21
22
  export * from './git-summarize'
@@ -41,3 +42,6 @@ export * from './turn-ipc'
41
42
  export * from './path-ownership'
42
43
  export * from './mission-brief'
43
44
  export * from './keyed-doc-delta'
45
+ export * from './provider-approval'
46
+ export * from './provider-question'
47
+ export * from './launch-defaults'
@@ -326,7 +326,15 @@ export function foldKeyedDoc<T extends Record<string, unknown>>(held: T, delta:
326
326
  const heldList = heldCollections.get(path)
327
327
  if (change?.absent) {
328
328
  const [head, tail] = splitPath(path)
329
- if (!tail) delete next[head]
329
+ if (!tail) {
330
+ delete next[head]
331
+ continue
332
+ }
333
+ const parent = next[head]
334
+ if (!isRecord(parent) || !(tail in parent)) continue
335
+ const copy = { ...parent }
336
+ delete copy[tail]
337
+ next[head] = copy
330
338
  continue
331
339
  }
332
340
  if (!change && heldList === undefined) continue
@@ -0,0 +1,168 @@
1
+ /** Daemon-local launch intent and command wire contract. P2P/local only;
2
+ * never include this document or its history in cloud status_report.
3
+ * Publication through mesh-shared's barrel is a later integration step. */
4
+ export const LAUNCH_DEFAULT_ROLES = ['assistant', 'coordinator'] as const;
5
+ export type LaunchDefaultRole = typeof LAUNCH_DEFAULT_ROLES[number];
6
+ export const LAUNCH_DEFAULT_SOURCES = ['user', 'stored_default', 'project_default', 'provider_default'] as const;
7
+ export type LaunchDefaultSource = typeof LAUNCH_DEFAULT_SOURCES[number];
8
+ export const LAUNCH_DEFAULTS_NEXT_LAUNCH_NOTE = 'Applies to the next launch; running sessions are unchanged.';
9
+ export function isLaunchDefaultRole(value: unknown): value is LaunchDefaultRole {
10
+ return typeof value === 'string' && (LAUNCH_DEFAULT_ROLES as readonly string[]).includes(value);
11
+ }
12
+ export function isLaunchDefaultSource(value: unknown): value is LaunchDefaultSource {
13
+ return typeof value === 'string' && (LAUNCH_DEFAULT_SOURCES as readonly string[]).includes(value);
14
+ }
15
+ export interface LaunchDefaultSelection {
16
+ provider: string;
17
+ /** Omission means pass no model/thinking argument, not inherit another provider. */
18
+ model?: string;
19
+ thinkingLevel?: string;
20
+ }
21
+ export type LaunchDefaultToolRestriction = 'enforced' | 'prompt_only';
22
+ export type LaunchDefaultOrigin = 'owner' | 'human' | 'relay' | 'review' | 'review_tainted';
23
+ export interface LaunchDefaultEntry extends LaunchDefaultSelection {
24
+ revision: number;
25
+ setBy: 'owner' | 'assistant';
26
+ setAt: number;
27
+ }
28
+ export interface LaunchDefaultChange {
29
+ at: number;
30
+ role: LaunchDefaultRole;
31
+ by: 'owner' | 'assistant';
32
+ origin: LaunchDefaultOrigin;
33
+ from: LaunchDefaultEntry | null;
34
+ to: LaunchDefaultEntry | null;
35
+ }
36
+ export interface LaunchDefaultsDocument {
37
+ version: 1;
38
+ /** Global document token, including clears; distinct from entry revisions. */
39
+ revision: number;
40
+ assistant?: LaunchDefaultEntry;
41
+ coordinator?: LaunchDefaultEntry;
42
+ history: LaunchDefaultChange[];
43
+ }
44
+ export interface LaunchDefaultRef {
45
+ role: LaunchDefaultRole;
46
+ scope: 'global' | 'project';
47
+ /** Entry revision for global defaults; host project token for project defaults. */
48
+ revision: number;
49
+ projectId?: string;
50
+ }
51
+ export type LaunchDefaultField = keyof LaunchDefaultSelection;
52
+ export interface LaunchDefaultAxis {
53
+ value: string | null;
54
+ source: LaunchDefaultSource;
55
+ defaultRef?: LaunchDefaultRef;
56
+ }
57
+ export type LaunchDefaultWarning = 'catalog_unverified' | 'manual_mcp_setup_required';
58
+ export type LaunchDefaultValidationReason =
59
+ | 'invalid_schema' | 'provider_unknown' | 'provider_not_cli' | 'provider_disabled'
60
+ | 'provider_not_detected' | 'provider_not_eligible' | 'model_unsupported'
61
+ | 'model_provider_mismatch' | 'model_not_offered' | 'thinking_unsupported'
62
+ | 'thinking_not_offered' | 'catalog_unavailable';
63
+ export type LaunchDefaultResolution =
64
+ | { ok: true; selection: LaunchDefaultSelection; applied: Record<LaunchDefaultField, LaunchDefaultAxis>;
65
+ defaultRef?: LaunchDefaultRef; documentRevision: number; warnings: LaunchDefaultWarning[];
66
+ toolRestriction: LaunchDefaultToolRestriction }
67
+ | { ok: false; code: 'launch_default_required' | 'launch_default_unavailable' | 'launch_selection_invalid';
68
+ role: LaunchDefaultRole; field: LaunchDefaultField; reason: string; source?: LaunchDefaultSource;
69
+ value?: string | null; defaultRef?: LaunchDefaultRef; candidates?: readonly string[]; hint: string };
70
+ export interface LaunchDefaultsRoleView {
71
+ stored: LaunchDefaultEntry | null;
72
+ /** The existing daemon resolver's result, including loud failures. */
73
+ effective: LaunchDefaultResolution;
74
+ }
75
+ type Target = { role: 'assistant'; project?: never } | { role: 'coordinator'; project?: string };
76
+ export type LaunchDefaultsReadRequest =
77
+ | ({ action?: 'get' } & (Target | { role?: never; project?: never }))
78
+ | ({ action: 'check' } & Target & LaunchDefaultSelection);
79
+ /** Mandatory CAS: global document token, or host project token for project writes.
80
+ * 0 is explicit first setup; clears advance the token and never restore fallback. */
81
+ export type LaunchDefaultsSetRequest = Target & { expectedRevision: number } & (
82
+ | ({ action: 'set' } & LaunchDefaultSelection)
83
+ | { action: 'clear'; provider?: never; model?: never; thinkingLevel?: never }
84
+ );
85
+ /** Existing assistant routing envelope; session identity never supplies origin. */
86
+ export type AssistantLaunchDefaultsReadRequest = LaunchDefaultsReadRequest & { assistantSessionId?: string };
87
+ export type AssistantLaunchDefaultsSetRequest = LaunchDefaultsSetRequest & { assistantSessionId?: string };
88
+ export const LAUNCH_DEFAULT_ERROR_CODES = [
89
+ 'invalid_args', 'launch_default_required', 'launch_default_unavailable', 'launch_selection_invalid',
90
+ 'launch_default_invalid', 'revision_conflict', 'store_unavailable', 'store_busy', 'persistence_failed',
91
+ 'project_not_found', 'project_ambiguous', 'project_not_hosted', 'project_storage_unavailable',
92
+ 'staging_unavailable', 'staging_failed', 'review_turn_tool_denied',
93
+ ] as const;
94
+ export type LaunchDefaultsErrorCode = typeof LAUNCH_DEFAULT_ERROR_CODES[number];
95
+ export interface LaunchDefaultsError {
96
+ success: false;
97
+ code: LaunchDefaultsErrorCode;
98
+ error: string;
99
+ reason?: string;
100
+ role?: LaunchDefaultRole;
101
+ field?: LaunchDefaultField;
102
+ source?: LaunchDefaultSource;
103
+ value?: string | null;
104
+ defaultRef?: LaunchDefaultRef;
105
+ candidates?: readonly string[];
106
+ actualRevision?: number;
107
+ /** 0 for an absent global document; absent when it cannot be read. */
108
+ documentRevision?: number;
109
+ /** A persistence failure can replace intent without confirming durability.
110
+ * Re-read before retrying; never report applied on committed:true alone. */
111
+ committed?: boolean;
112
+ }
113
+ export interface LaunchDefaultsReadSuccess {
114
+ success: true;
115
+ result: 'get';
116
+ documentRevision: number;
117
+ roles: Partial<Record<LaunchDefaultRole, LaunchDefaultsRoleView>>;
118
+ project?: { projectId: string; revision: number; entry: LaunchDefaultEntry | null };
119
+ }
120
+ export interface LaunchDefaultsCheckSuccess {
121
+ success: true;
122
+ result: 'check';
123
+ role: LaunchDefaultRole;
124
+ selection: LaunchDefaultSelection;
125
+ warnings: LaunchDefaultWarning[];
126
+ toolRestriction: LaunchDefaultToolRestriction;
127
+ lowersToolLock: boolean;
128
+ }
129
+ export interface LaunchDefaultsApplied {
130
+ success: true;
131
+ result: 'applied';
132
+ role: LaunchDefaultRole;
133
+ scope: 'global' | 'project';
134
+ projectId?: string;
135
+ entry: LaunchDefaultEntry | null;
136
+ previous: LaunchDefaultEntry | null;
137
+ /** CAS token returned by the authority that performed the write. */
138
+ revision: number;
139
+ warnings: LaunchDefaultWarning[];
140
+ durable: true;
141
+ note: string;
142
+ }
143
+ export interface LaunchDefaultsStaged {
144
+ success: true;
145
+ result: 'staged';
146
+ role: LaunchDefaultRole;
147
+ stagedId: string;
148
+ previous: LaunchDefaultEntry | null;
149
+ selection: LaunchDefaultSelection | null;
150
+ expectedRevision: number;
151
+ warnings: LaunchDefaultWarning[];
152
+ note: string;
153
+ }
154
+ /** Existing owner Inbox adapter stores this proposal; it is not another default.
155
+ * Applying it must revalidate on the host and CAS via the same set handler. */
156
+ export interface LaunchDefaultsProposal {
157
+ kind: 'launch_defaults';
158
+ role: LaunchDefaultRole;
159
+ project?: string;
160
+ selection: LaunchDefaultSelection | null;
161
+ previous: LaunchDefaultEntry | null;
162
+ expectedRevision: number;
163
+ origin: LaunchDefaultOrigin;
164
+ callerSessionId?: string;
165
+ }
166
+ export type LaunchDefaultsReadResponse = LaunchDefaultsReadSuccess | LaunchDefaultsCheckSuccess | LaunchDefaultsError;
167
+ export type LaunchDefaultsSetResponse = LaunchDefaultsApplied | LaunchDefaultsStaged | LaunchDefaultsError;
168
+ export type LaunchDefaultsResponse = LaunchDefaultsReadResponse | LaunchDefaultsSetResponse;
@@ -44,6 +44,7 @@ export const CANONICAL_MESH_TOOL_NAMES = [
44
44
  'mesh_list_pending_approvals',
45
45
  'mesh_create',
46
46
  'mesh_add_node',
47
+ 'mesh_attach_remote',
47
48
  'mesh_clone_node',
48
49
  'mesh_remove_node',
49
50
  'mesh_cleanup_worktree_nodes',
@@ -56,6 +56,14 @@ export type MeshSessionCleanupMode = typeof MESH_SESSION_CLEANUP_MODES[number]
56
56
  export const WORKER_REPORT_OUTCOMES = ['completed', 'blocked', 'failed'] as const
57
57
  export type WorkerReportOutcome = typeof WORKER_REPORT_OUTCOMES[number]
58
58
 
59
+ /**
60
+ * Hard cap on a `report_completion` summary (characters, after trim). Over-limit
61
+ * summaries are REJECTED, never truncated. Declared once so the daemon validator
62
+ * and the mcp-server's pre-send check (which keeps an over-limit report out of the
63
+ * durable outbox) cannot drift apart.
64
+ */
65
+ export const WORKER_REPORT_SUMMARY_MAX_CHARS = 8_000
66
+
59
67
  /** Final branch state a worker reports (see coordinator convergence rules). */
60
68
  export const WORKER_BRANCH_STATES = [
61
69
  'merged_to_main',
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,25 @@ 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
115
148
  [extra: string]: unknown
116
149
  }
117
150
  [extra: string]: unknown
@@ -137,6 +170,8 @@ export interface MeshNodeFactsProviderEnablement {
137
170
 
138
171
  export interface MeshNodeFacts {
139
172
  schemaVersion: number
173
+ /** Cached CLI detection only; no paths or account data. */
174
+ providerInventory?: { observedAt: number; providers: Record<string, { installed: boolean }> }
140
175
  reportedAt: number
141
176
  daemonBuild?: MeshNodeFactsDaemonBuild
142
177
  providerVersions?: Record<string, string>
@@ -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. */
@@ -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,136 @@
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
+ || (kind === 'expired-token' && ['grok-cli', 'kimi', 'antigravity-cli'].includes(quota.provider))
107
+ // Order matters: the aged-out Claude shape and the retained antigravity
108
+ // shape both ALSO mark lastGoodWindows (mesh routing trusts retained numbers
109
+ // until their reset), so they must be classified before the generic branch.
110
+ if (userMustAct) return hasReading(quota) ? { cue: 'stale', userMustAct: true } : { userMustAct: true }
111
+ if (meta?.lastGoodWindows !== true) {
112
+ // A successful fetch is not a perpetual live reading. Use the DATA
113
+ // clock; a recent failed attempt must never rejuvenate old numbers.
114
+ const measuredAt = finiteMs(quota.updatedAt)
115
+ if (hasReading(quota) && (measuredAt === undefined || now - measuredAt >= QUOTA_BACKFILL_HORIZON_MS)) {
116
+ return { cue: 'stale', userMustAct }
117
+ }
118
+ return { userMustAct }
119
+ }
120
+
121
+ const retryAtMs = finiteMs(meta?.retryAtMs)
122
+ const retryStopped = meta?.retryExhausted === true
123
+ || (retryAtMs !== undefined && retryAtMs + QUOTA_RETRY_INFLIGHT_GRACE_MS < now)
124
+ // A `no-data` retained reading (codex) has no retry at all — it is a
125
+ // snapshot the daemon will simply look at again, never an in-flight refresh.
126
+ if (kind !== 'no-data' && !retryStopped) return { cue: 'refreshing', userMustAct: false }
127
+ const nextCheckAt = backfillCheckAt(quota, now)
128
+ return nextCheckAt === undefined
129
+ ? { cue: 'stale', userMustAct: false }
130
+ : { cue: 'stale', userMustAct: false, nextCheckAt }
131
+ }
132
+
133
+ /** Whole minutes (min 1) until `nextCheckAt`, for the "next check ~Nm" wording. */
134
+ export function minutesUntilQuotaCheck(nextCheckAt: number, now: number = Date.now()): number {
135
+ return Math.max(1, Math.round((nextCheckAt - now) / 60_000))
136
+ }