@adhdev/mesh-shared 1.0.82-rc.34 → 1.0.82-rc.36

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.
@@ -0,0 +1,421 @@
1
+ /**
2
+ * mission-items — structured mission records: the item kinds a mission carries
3
+ * (remaining work, live checks, owner decisions, evidence), their normalization
4
+ * and limits, dedupe-key derivation and the close outcome enum
5
+ * (docs/design/2026-10-28-structured-missions-and-owner-decisions.md §4, §5).
6
+ *
7
+ * Philosophy (owner 2026-10-28, design §0): NEVER reject for size or shape —
8
+ * over-long text is truncated, extra refs are dropped, unusable items are
9
+ * reported per-item. The only hard rule lives elsewhere (the daemon): a
10
+ * coordinator or worker can never ANSWER a decision, so `answer`/`delivery`
11
+ * are stripped from every caller-supplied `decision` here.
12
+ *
13
+ * Dependency-free leaf: pure functions over plain data, no storage.
14
+ */
15
+
16
+ export const MISSION_ITEM_KINDS = ['remaining', 'live_check', 'decision', 'evidence'] as const
17
+ export type MissionItemKind = typeof MISSION_ITEM_KINDS[number]
18
+
19
+ export const MISSION_ITEM_STATES = ['open', 'done', 'dropped', 'moved', 'answered', 'expired', 'withdrawn', 'superseded'] as const
20
+ export type MissionItemState = typeof MISSION_ITEM_STATES[number]
21
+
22
+ /** States a coordinator/worker may SET through items_update, per kind. `answered`/`expired` are daemon-owned. */
23
+ export const COORDINATOR_SETTABLE_ITEM_STATES: Readonly<Record<MissionItemKind, readonly MissionItemState[]>> = {
24
+ remaining: ['open', 'done', 'dropped'],
25
+ live_check: ['open', 'done', 'dropped'],
26
+ evidence: ['open', 'done', 'dropped'],
27
+ decision: ['open', 'withdrawn', 'superseded'],
28
+ }
29
+
30
+ export const MISSION_OUTCOMES = ['merged', 'verified_live', 'superseded', 'not_needed', 'owner_abandoned', 'unspecified'] as const
31
+ export type MissionOutcome = typeof MISSION_OUTCOMES[number]
32
+ export function isMissionOutcome(value: unknown): value is MissionOutcome {
33
+ return typeof value === 'string' && (MISSION_OUTCOMES as readonly string[]).includes(value)
34
+ }
35
+
36
+ export const MISSION_REF_KINDS = ['commit', 'branch', 'pr', 'task', 'artifact', 'mission', 'url'] as const
37
+ export type MissionRefKind = typeof MISSION_REF_KINDS[number]
38
+
39
+ export const MISSION_LIMITS = {
40
+ summary: 300,
41
+ outcomeReason: 500,
42
+ itemText: 300,
43
+ machine: 80,
44
+ refs: 8,
45
+ refValue: 300,
46
+ refRepo: 120,
47
+ refNote: 200,
48
+ dedupeKey: 400,
49
+ question: 300,
50
+ context: 600,
51
+ options: 5,
52
+ optionLabel: 60,
53
+ optionDetail: 200,
54
+ /** Open items per mission before the oldest `remaining` fold into a "N more" summary item. */
55
+ openItemsPerMission: 200,
56
+ /** One event payload (serialized). */
57
+ eventPayloadBytes: 16 * 1024,
58
+ /** items_add / items_update entries honoured per call (extra entries are reported, not applied). */
59
+ itemsPerCall: 50,
60
+ } as const
61
+
62
+ export const MISSION_DECISION_OPTION_IDS = ['a', 'b', 'c', 'd', 'e'] as const
63
+ export const MISSION_DECISION_URGENCIES = ['normal', 'blocking'] as const
64
+ export type MissionDecisionUrgency = typeof MISSION_DECISION_URGENCIES[number]
65
+ export const MISSION_DECISION_ON_DUE = ['remind', 'apply_recommended'] as const
66
+ export type MissionDecisionOnDue = typeof MISSION_DECISION_ON_DUE[number]
67
+
68
+ export interface MissionItemRef {
69
+ kind: MissionRefKind
70
+ repo?: string
71
+ value: string
72
+ note?: string
73
+ }
74
+
75
+ export interface MissionDecisionOption {
76
+ id: string
77
+ label: string
78
+ detail?: string
79
+ }
80
+
81
+ /** Who/where an answer came from. Written only by the daemon's (owner-side) answer path — never from tool input. */
82
+ export interface MissionDecisionAnswer {
83
+ optionId?: string
84
+ text?: string
85
+ by: 'owner' | 'auto_recommended'
86
+ via: 'dashboard' | 'assistant' | 'push' | 'cli' | 'auto'
87
+ interpretedFrom?: string
88
+ at: string
89
+ ingress?: string
90
+ }
91
+
92
+ export interface MissionDecisionDelivery {
93
+ messageId: string
94
+ /**
95
+ * `queued`: the coordinator's input queue accepted it but has not typed it yet — the daemon
96
+ * confirms it against the send receipt (a daemon restart drops that queue). Daemon-written.
97
+ */
98
+ state: 'pending' | 'queued' | 'sent' | 'acked'
99
+ lastAttemptAt?: string
100
+ /** Coordinator session + funnel messageId of a `queued` delivery (receipt lookup). Daemon-written. */
101
+ sessionId?: string
102
+ wireMessageId?: string
103
+ /** Failed send attempts of this revision (retry backoff). Daemon-written. */
104
+ attempts?: number
105
+ /** What the coordinator is told: a first answer, a changed answer, or an undone one. Daemon-written. */
106
+ kind?: 'answered' | 'changed' | 'undone'
107
+ }
108
+
109
+ export interface MissionDecision {
110
+ question: string
111
+ context?: string
112
+ options: MissionDecisionOption[]
113
+ recommended?: string
114
+ allowFreeText: boolean
115
+ dueAt?: string
116
+ onDue: MissionDecisionOnDue
117
+ urgency: MissionDecisionUrgency
118
+ answer?: MissionDecisionAnswer
119
+ delivery?: MissionDecisionDelivery
120
+ /**
121
+ * Coordinator-prompt watermark (bundle B): ISO time the coordinator last "saw" this item's
122
+ * answer. An answered decision is shown in the coordinator prompt while this is absent or
123
+ * older than `answer.at` (a changed answer re-surfaces). Written only by the daemon.
124
+ */
125
+ promptAckedAt?: string
126
+ /** Counts answer writes (answer / change / undo); delivery messageIds are `decision:<itemId>:r<n>`. Daemon-written. */
127
+ answerRevision?: number
128
+ /** ISO time an `onDue: remind` crossing was recorded (once per dueAt). Daemon-written. */
129
+ remindedAt?: string
130
+ }
131
+
132
+ export interface NormalizedMissionItem {
133
+ kind: MissionItemKind
134
+ text: string
135
+ machine?: string
136
+ refs: MissionItemRef[]
137
+ decision?: MissionDecision
138
+ dedupeKey: string
139
+ /** Fields that were truncated/dropped — informational, never an error. */
140
+ truncated: string[]
141
+ }
142
+
143
+ export function truncateMissionText(value: string, max: number): string {
144
+ if (value.length <= max) return value
145
+ return `${value.slice(0, max - 1).trimEnd()}…`
146
+ }
147
+
148
+ function str(value: unknown): string {
149
+ return typeof value === 'string' ? value.trim() : ''
150
+ }
151
+
152
+ function collapse(value: string): string {
153
+ return value.replace(/\s+/g, ' ').trim()
154
+ }
155
+
156
+ export function normalizeMissionSummary(value: unknown): { value: string; truncated: boolean } | null {
157
+ if (typeof value !== 'string') return null
158
+ const text = collapse(value)
159
+ return { value: truncateMissionText(text, MISSION_LIMITS.summary), truncated: text.length > MISSION_LIMITS.summary }
160
+ }
161
+
162
+ export function normalizeOutcomeReason(value: unknown): { value: string; truncated: boolean } | null {
163
+ if (typeof value !== 'string') return null
164
+ const text = value.trim()
165
+ return { value: truncateMissionText(text, MISSION_LIMITS.outcomeReason), truncated: text.length > MISSION_LIMITS.outcomeReason }
166
+ }
167
+
168
+ /** Refs: ≤8, `{kind, repo?, value, note?}`. Unusable entries are dropped; the 9th and later are dropped. */
169
+ export function normalizeMissionRefs(value: unknown): { refs: MissionItemRef[]; truncated: boolean } {
170
+ if (!Array.isArray(value)) return { refs: [], truncated: false }
171
+ const refs: MissionItemRef[] = []
172
+ let truncated = false
173
+ for (const raw of value) {
174
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
175
+ const r = raw as Record<string, unknown>
176
+ const kind = str(r.kind)
177
+ const refValue = str(r.value)
178
+ if (!(MISSION_REF_KINDS as readonly string[]).includes(kind) || !refValue) continue
179
+ if (refs.length >= MISSION_LIMITS.refs) { truncated = true; break }
180
+ const repo = str(r.repo)
181
+ const note = str(r.note)
182
+ refs.push({
183
+ kind: kind as MissionRefKind,
184
+ ...(repo ? { repo: truncateMissionText(repo, MISSION_LIMITS.refRepo) } : {}),
185
+ value: truncateMissionText(refValue, MISSION_LIMITS.refValue),
186
+ ...(note ? { note: truncateMissionText(note, MISSION_LIMITS.refNote) } : {}),
187
+ })
188
+ }
189
+ return { refs, truncated }
190
+ }
191
+
192
+ function normalizeOptions(value: unknown, truncated: string[]): MissionDecisionOption[] {
193
+ if (!Array.isArray(value)) return []
194
+ const out: MissionDecisionOption[] = []
195
+ for (const raw of value) {
196
+ if (typeof raw === 'string' && raw.trim()) {
197
+ if (out.length >= MISSION_LIMITS.options) { truncated.push('options'); break }
198
+ out.push({ id: MISSION_DECISION_OPTION_IDS[out.length], label: truncateMissionText(raw.trim(), MISSION_LIMITS.optionLabel) })
199
+ continue
200
+ }
201
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
202
+ const o = raw as Record<string, unknown>
203
+ const label = collapse(str(o.label))
204
+ if (!label) continue
205
+ if (out.length >= MISSION_LIMITS.options) { truncated.push('options'); break }
206
+ const detail = str(o.detail)
207
+ // Ids are positional ('a'..'e') so a caller cannot collide or invent ids.
208
+ out.push({
209
+ id: MISSION_DECISION_OPTION_IDS[out.length],
210
+ label: truncateMissionText(label, MISSION_LIMITS.optionLabel),
211
+ ...(detail ? { detail: truncateMissionText(detail, MISSION_LIMITS.optionDetail) } : {}),
212
+ })
213
+ }
214
+ return out
215
+ }
216
+
217
+ function normalizeDueAt(value: unknown): string | undefined {
218
+ if (typeof value !== 'string' || !value.trim()) return undefined
219
+ const ms = Date.parse(value)
220
+ return Number.isNaN(ms) ? undefined : new Date(ms).toISOString()
221
+ }
222
+
223
+ /**
224
+ * Caller-supplied decision → stored shape. `answer`/`delivery` (and any other
225
+ * unknown key) are DROPPED: a coordinator/worker cannot pre-answer its own
226
+ * question. `recommended` must name one of the options, else it is dropped.
227
+ */
228
+ export function normalizeDecisionInput(raw: Record<string, unknown>, truncated: string[]): MissionDecision | null {
229
+ const questionSource = collapse(str(raw.question) || str(raw.text))
230
+ if (!questionSource) return null
231
+ if (questionSource.length > MISSION_LIMITS.question) truncated.push('question')
232
+ const question = truncateMissionText(questionSource, MISSION_LIMITS.question)
233
+ const contextSource = str(raw.context)
234
+ if (contextSource.length > MISSION_LIMITS.context) truncated.push('context')
235
+ const options = normalizeOptions(raw.options, truncated)
236
+ const recommendedRaw = str(raw.recommended)
237
+ const recommended = recommendedRaw && options.some(o => o.id === recommendedRaw)
238
+ ? recommendedRaw
239
+ // Also accept the option label (coordinators naturally write "recommended: Option A").
240
+ : options.find(o => recommendedRaw && o.label.toLowerCase() === recommendedRaw.toLowerCase())?.id
241
+ const dueAt = normalizeDueAt(raw.dueAt ?? raw.due_at)
242
+ const onDueRaw = str(raw.onDue ?? raw.on_due)
243
+ const urgencyRaw = str(raw.urgency)
244
+ const allowFreeTextRaw = raw.allowFreeText ?? raw.allow_free_text
245
+ return {
246
+ question,
247
+ ...(contextSource ? { context: truncateMissionText(contextSource, MISSION_LIMITS.context) } : {}),
248
+ options,
249
+ ...(recommended ? { recommended } : {}),
250
+ allowFreeText: typeof allowFreeTextRaw === 'boolean' ? allowFreeTextRaw : true,
251
+ ...(dueAt ? { dueAt } : {}),
252
+ // apply_recommended without a recommended option has nothing to apply — fall back to remind.
253
+ onDue: onDueRaw === 'apply_recommended' && recommended ? 'apply_recommended' : 'remind',
254
+ urgency: urgencyRaw === 'blocking' ? 'blocking' : 'normal',
255
+ }
256
+ }
257
+
258
+ /** Normalized text for dedupe: lower-case, whitespace-collapsed, trailing punctuation trimmed. */
259
+ export function normalizeForDedupe(text: string): string {
260
+ return collapse(text).toLowerCase().replace(/[\s.!?。]+$/u, '')
261
+ }
262
+
263
+ export function deriveItemDedupeKey(kind: MissionItemKind, parts: { text?: string; machine?: string; refs?: readonly MissionItemRef[]; question?: string; explicit?: string }): string {
264
+ const explicit = str(parts.explicit)
265
+ if (explicit) return truncateMissionText(`k:${explicit}`, MISSION_LIMITS.dedupeKey)
266
+ if (kind === 'decision') return truncateMissionText(`decision:${normalizeForDedupe(parts.question ?? parts.text ?? '')}`, MISSION_LIMITS.dedupeKey)
267
+ if (kind === 'evidence') {
268
+ const ref = parts.refs?.[0]
269
+ if (ref) return truncateMissionText(`evidence:${ref.kind}:${ref.repo ?? ''}:${ref.value}`, MISSION_LIMITS.dedupeKey)
270
+ }
271
+ const machine = parts.machine ? `@${normalizeForDedupe(parts.machine)}` : ''
272
+ return truncateMissionText(`${kind}${machine}:${normalizeForDedupe(parts.text ?? '')}`, MISSION_LIMITS.dedupeKey)
273
+ }
274
+
275
+ /**
276
+ * Normalize one caller-supplied `items_add[]` entry. Returns `null` + `error`
277
+ * only when nothing usable remains (no text/question) — everything else is
278
+ * clamped, never rejected.
279
+ */
280
+ export function normalizeMissionItemInput(raw: unknown): { item: NormalizedMissionItem } | { error: string } {
281
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'item must be an object' }
282
+ const r = raw as Record<string, unknown>
283
+ const kind = str(r.kind) || 'remaining'
284
+ if (!(MISSION_ITEM_KINDS as readonly string[]).includes(kind)) {
285
+ return { error: `invalid kind '${kind}' (valid: ${MISSION_ITEM_KINDS.join(', ')})` }
286
+ }
287
+ const truncated: string[] = []
288
+ const { refs, truncated: refsTruncated } = normalizeMissionRefs(r.refs)
289
+ if (refsTruncated) truncated.push('refs')
290
+ const machineRaw = collapse(str(r.machine))
291
+ const machine = machineRaw ? truncateMissionText(machineRaw, MISSION_LIMITS.machine) : undefined
292
+ const explicitKey = str(r.dedupe_key ?? r.dedupeKey)
293
+ if (kind === 'decision') {
294
+ const decisionRaw = (r.decision && typeof r.decision === 'object' && !Array.isArray(r.decision) ? r.decision : r) as Record<string, unknown>
295
+ const decision = normalizeDecisionInput({ ...decisionRaw, question: decisionRaw.question ?? r.question ?? r.text }, truncated)
296
+ if (!decision) return { error: 'decision item needs a question' }
297
+ return {
298
+ item: {
299
+ kind,
300
+ text: decision.question,
301
+ ...(machine ? { machine } : {}),
302
+ refs,
303
+ decision,
304
+ dedupeKey: deriveItemDedupeKey('decision', { question: decision.question, explicit: explicitKey }),
305
+ truncated,
306
+ },
307
+ }
308
+ }
309
+ const textSource = collapse(str(r.text))
310
+ if (!textSource && !(kind === 'evidence' && refs.length > 0)) return { error: `${kind} item needs text` }
311
+ if (textSource.length > MISSION_LIMITS.itemText) truncated.push('text')
312
+ const text = truncateMissionText(textSource || `${refs[0].kind}: ${refs[0].value}`, MISSION_LIMITS.itemText)
313
+ return {
314
+ item: {
315
+ kind: kind as MissionItemKind,
316
+ text,
317
+ ...(machine ? { machine } : {}),
318
+ refs,
319
+ dedupeKey: deriveItemDedupeKey(kind as MissionItemKind, { text, machine, refs, explicit: explicitKey }),
320
+ truncated,
321
+ },
322
+ }
323
+ }
324
+
325
+ export interface MissionItemUpdateInput {
326
+ id: string
327
+ state?: MissionItemState
328
+ text?: string
329
+ machine?: string | null
330
+ refs?: MissionItemRef[]
331
+ }
332
+
333
+ /** Normalize one `items_update[]` entry (id required). The kind-specific state rule is applied by the daemon, which knows the stored kind. */
334
+ export function normalizeMissionItemUpdate(raw: unknown): { update: MissionItemUpdateInput; truncated: string[] } | { error: string } {
335
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { error: 'update must be an object' }
336
+ const r = raw as Record<string, unknown>
337
+ const id = str(r.id ?? r.item_id ?? r.itemId)
338
+ if (!id) return { error: 'update needs an item id' }
339
+ const truncated: string[] = []
340
+ const update: MissionItemUpdateInput = { id }
341
+ if (r.state !== undefined) {
342
+ const state = str(r.state)
343
+ if (!(MISSION_ITEM_STATES as readonly string[]).includes(state)) return { error: `invalid state '${state}'` }
344
+ update.state = state as MissionItemState
345
+ }
346
+ if (typeof r.text === 'string') {
347
+ const text = collapse(r.text)
348
+ if (text.length > MISSION_LIMITS.itemText) truncated.push('text')
349
+ if (text) update.text = truncateMissionText(text, MISSION_LIMITS.itemText)
350
+ }
351
+ if (r.machine === null) update.machine = null
352
+ else if (typeof r.machine === 'string') {
353
+ const machine = collapse(r.machine)
354
+ update.machine = machine ? truncateMissionText(machine, MISSION_LIMITS.machine) : null
355
+ }
356
+ if (r.refs !== undefined) {
357
+ const { refs, truncated: t } = normalizeMissionRefs(r.refs)
358
+ if (t) truncated.push('refs')
359
+ update.refs = refs
360
+ }
361
+ return { update, truncated }
362
+ }
363
+
364
+ /** Cap a serialized event payload at {@link MISSION_LIMITS.eventPayloadBytes}; over-size payloads become a stub that says so. */
365
+ export function boundEventPayload(payload: Record<string, unknown>): string {
366
+ let json = JSON.stringify(payload)
367
+ if (json.length <= MISSION_LIMITS.eventPayloadBytes) return json
368
+ // Shrink the largest string fields first (the previous goal text is the usual offender).
369
+ const shrunk: Record<string, unknown> = {}
370
+ for (const [k, v] of Object.entries(payload)) {
371
+ shrunk[k] = typeof v === 'string' && v.length > 4000 ? `${v.slice(0, 4000)}…` : v
372
+ }
373
+ shrunk.payloadTruncated = true
374
+ json = JSON.stringify(shrunk)
375
+ if (json.length <= MISSION_LIMITS.eventPayloadBytes) return json
376
+ return JSON.stringify({ payloadTruncated: true, keys: Object.keys(payload).slice(0, 20) })
377
+ }
378
+
379
+ /** The carry-over mission's fixed title (design §5.1). */
380
+ export const CARRY_OVER_MISSION_TITLE = 'Carry-over — open items from closed missions'
381
+ /** The title earlier builds stored; existing DBs still hold it, so it keeps matching (no rename). */
382
+ export const LEGACY_CARRY_OVER_MISSION_TITLE = '이월 — 닫힌 미션에서 넘어온 항목'
383
+ /** Canonical goal text of a newly created carry-over mission. */
384
+ export const CARRY_OVER_MISSION_GOAL = 'Open items carried over from closed missions, and decisions requested without a mission. Managed automatically — do not close.'
385
+ /**
386
+ * THE one predicate for "is this the auto carry-over mission". The daemon's `is_carry_over`
387
+ * column (`isCarryOver`) is the source of truth; wire views that cannot carry the flag (older
388
+ * strict decoders reject unknown keys) fall back to the fixed title. Use this everywhere.
389
+ */
390
+ export function isCarryOverMissionRecord(m: { isCarryOver?: boolean; title?: unknown } | null | undefined): boolean {
391
+ return !!m && (m.isCarryOver === true || isCarryOverMissionTitle(m.title))
392
+ }
393
+ /** The carry-over mission's title is fixed (the daemon ignores renames), so a title match identifies it on wire views that carry no flag. */
394
+ export function isCarryOverMissionTitle(title: unknown): boolean {
395
+ if (typeof title !== 'string') return false
396
+ const t = title.trim()
397
+ return t === CARRY_OVER_MISSION_TITLE || t === LEGACY_CARRY_OVER_MISSION_TITLE
398
+ }
399
+
400
+ /** Prefix of the owner-confirmation decision a coordinator's `owner_abandoned` close creates. */
401
+ export const OWNER_ABANDON_CONFIRM_KEY_PREFIX = 'owner-abandon-confirm:'
402
+
403
+ export const OWNER_ABANDON_CONFIRM_QUESTION = "The coordinator closed this as 'owner abandoned' — correct?"
404
+ export const OWNER_ABANDON_CONFIRM_OPTIONS = ['Correct', 'Reopen'] as const
405
+
406
+ /** worker `report_completion.decisions_needed` cap. */
407
+ export const WORKER_DECISIONS_NEEDED_MAX = 3
408
+ /** worker `report_completion.commits` cap and shape. */
409
+ export const WORKER_COMMITS_MAX = 20
410
+ const SHA_RE = /^[0-9a-f]{7,64}$/i
411
+ export function normalizeReportCommits(value: unknown): string[] | null {
412
+ if (!Array.isArray(value)) return null
413
+ const out: string[] = []
414
+ for (const v of value) {
415
+ if (typeof v !== 'string') return null
416
+ const sha = v.trim()
417
+ if (!SHA_RE.test(sha)) return null
418
+ if (!out.includes(sha.toLowerCase())) out.push(sha.toLowerCase())
419
+ }
420
+ return out
421
+ }
@@ -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
@@ -78,6 +102,8 @@ export type DaemonStatusEventWirePayload = {
78
102
  waitKind?: 'external'
79
103
  surfaceHidden?: boolean
80
104
  muted?: boolean
105
+ /** Present only on `agent:decision_needed`. */
106
+ decision?: DecisionPushWire
81
107
  }
82
108
 
83
109
  // ─── Status report (mirror of daemon-core `CloudStatusReportPayload`) ──────
@@ -101,6 +101,13 @@ export interface ModelSelection {
101
101
  /** Last value the provider itself reported (statusline, native history). */
102
102
  observed?: string
103
103
  observedAt?: number
104
+ /**
105
+ * The provider SILENTLY substituted another model for the requested one
106
+ * (antigravity: `model X is not recognized … Using "Y" instead`). `observed`
107
+ * then holds the model actually running. P2P/launch-record only — the
108
+ * server path never carries it (see the content boundary above).
109
+ */
110
+ fallback?: boolean
104
111
  /** Every value change in order. The last entry is the value in force. */
105
112
  history: ModelSelectionHistoryEntry[]
106
113
  }
@@ -206,6 +213,7 @@ function parseModelSelection(raw: unknown): ModelSelection | undefined {
206
213
  const current = readString(record.current)
207
214
  const observed = readString(record.observed)
208
215
  const observedAt = readFiniteNumber(record.observedAt)
216
+ const fallback = record.fallback === true
209
217
  return {
210
218
  source: isModelAxisSource(record.source) ? record.source : 'unspecified',
211
219
  ...(requested ? { requested } : {}),
@@ -214,6 +222,7 @@ function parseModelSelection(raw: unknown): ModelSelection | undefined {
214
222
  ...(current ? { current } : {}),
215
223
  ...(observed ? { observed } : {}),
216
224
  ...(observedAt !== undefined ? { observedAt } : {}),
225
+ ...(fallback ? { fallback } : {}),
217
226
  history: history.slice(-MODEL_SELECTION_HISTORY_LIMIT),
218
227
  }
219
228
  }