@adhdev/mesh-shared 1.0.60-rc.8 → 1.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/brain-routing.d.ts +6 -8
  2. package/dist/git-diff.d.ts +21 -0
  3. package/dist/index.d.ts +12 -1
  4. package/dist/index.js +2876 -77
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +2625 -72
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/interpolation.d.ts +0 -9
  9. package/dist/json.d.ts +4 -0
  10. package/dist/keyed-doc-delta.d.ts +105 -0
  11. package/dist/mesh-tool-names.d.ts +31 -2
  12. package/dist/mesh-vocabulary.d.ts +66 -0
  13. package/dist/mission-brief.d.ts +62 -0
  14. package/dist/outbound-message.d.ts +201 -0
  15. package/dist/path-ownership.d.ts +101 -0
  16. package/dist/protocol/daemon-server.d.ts +356 -0
  17. package/dist/protocol/dashboard-daemon-p2p.d.ts +213 -0
  18. package/dist/protocol/dashboard-server.d.ts +240 -0
  19. package/dist/protocol/dashboard-wire-version.d.ts +21 -0
  20. package/dist/protocol/envelope.d.ts +55 -0
  21. package/dist/protocol/index.d.ts +16 -0
  22. package/dist/session-launch.d.ts +105 -0
  23. package/dist/session-status.d.ts +75 -0
  24. package/dist/slot-proposal.d.ts +2 -28
  25. package/dist/turn-evidence.d.ts +434 -0
  26. package/dist/turn-ipc-guards.d.ts +18 -0
  27. package/dist/turn-ipc-mission.d.ts +250 -0
  28. package/dist/turn-ipc-queue.d.ts +196 -0
  29. package/dist/turn-ipc-stats.d.ts +72 -0
  30. package/dist/turn-ipc-turn.d.ts +176 -0
  31. package/dist/turn-ipc.d.ts +90 -0
  32. package/dist/types.d.ts +11 -0
  33. package/dist/worker-protocol.d.ts +69 -0
  34. package/dist/ws-protocol.d.ts +69 -41
  35. package/package.json +1 -1
  36. package/src/brain-routing.ts +9 -11
  37. package/src/git-diff.ts +48 -0
  38. package/src/index.ts +12 -1
  39. package/src/interpolation.ts +0 -18
  40. package/src/json.ts +10 -0
  41. package/src/keyed-doc-delta.ts +413 -0
  42. package/src/mesh-tool-names.ts +66 -25
  43. package/src/mesh-vocabulary.ts +94 -0
  44. package/src/mission-brief.ts +153 -0
  45. package/src/outbound-message.ts +319 -0
  46. package/src/path-ownership.ts +271 -0
  47. package/src/protocol/daemon-server.ts +510 -0
  48. package/src/protocol/dashboard-daemon-p2p.ts +273 -0
  49. package/src/protocol/dashboard-server.ts +340 -0
  50. package/src/protocol/dashboard-wire-version.ts +27 -0
  51. package/src/protocol/envelope.ts +75 -0
  52. package/src/protocol/index.ts +17 -0
  53. package/src/session-launch.ts +245 -0
  54. package/src/session-status.ts +164 -0
  55. package/src/slot-proposal.ts +2 -48
  56. package/src/turn-evidence.ts +596 -0
  57. package/src/turn-ipc-guards.ts +61 -0
  58. package/src/turn-ipc-mission.ts +647 -0
  59. package/src/turn-ipc-queue.ts +493 -0
  60. package/src/turn-ipc-stats.ts +193 -0
  61. package/src/turn-ipc-turn.ts +453 -0
  62. package/src/turn-ipc.ts +137 -0
  63. package/src/types.ts +8 -0
  64. package/src/worker-protocol.ts +164 -0
  65. package/src/ws-protocol.ts +98 -93
  66. package/dist/magi.d.ts +0 -281
  67. package/src/magi.ts +0 -310
@@ -0,0 +1,153 @@
1
+ /**
2
+ * mission-brief — the goal/constraints/done-criteria/handoff a coordinator
3
+ * attaches to a mission at `mesh_mission_upsert` time, and the deterministic
4
+ * rendering of that brief into a dispatched task's worker-protocol footer
5
+ * (wiring-unification Phase H2, `docs/design/2026-09-23-wiring-unification.md`
6
+ * §7c).
7
+ *
8
+ * `mesh_mission_upsert` today carries only a free-text `goal` string — no
9
+ * structured constraints, done-criteria or handoff notes, and nothing from a
10
+ * mission ever reaches a dispatched worker's task body. This module is the
11
+ * pure, dependency-free core: a normalized `MissionBrief` shape with length
12
+ * caps (mirrors `report_completion.summary`'s "prefer specifics" discipline —
13
+ * a brief is meant to be read by a worker mid-task, not skimmed by a human),
14
+ * and a deterministic Markdown renderer capped at 1,200 chars so a mission
15
+ * with many/long fields cannot blow a dispatched task's token budget.
16
+ *
17
+ * This module does NOT decide where the rendered block is inserted (that is
18
+ * `worker-protocol.ts`'s `renderWorkerProtocolFooter`, which places it above
19
+ * the marker line — see that file), and does NOT touch storage (H2's proposal
20
+ * keeps `mesh_missions` as the row source; this is the shape callers upsert
21
+ * into/read out of that row's `goal`/new `brief` columns).
22
+ */
23
+
24
+ /** Per-field caps. Generous enough for real content, small enough that four
25
+ * arrays of them cannot silently balloon a rendered block past the render cap. */
26
+ const GOAL_MAX = 500
27
+ const LIST_ITEM_MAX = 200
28
+ const LIST_MAX_ITEMS = 12
29
+
30
+ /** Total rendered-block cap (design doc H2 "bounded payload" discipline, mirrored from `listMeshMissionsForTool`). */
31
+ export const MISSION_BRIEF_RENDER_MAX_CHARS = 1200
32
+
33
+ export interface MissionBrief {
34
+ /** What this mission is trying to accomplish. Required — an empty brief is not meaningful. */
35
+ goal: string
36
+ /** Hard constraints a worker must respect (e.g. "do not touch daemon-core", "no npm install"). */
37
+ constraints?: string[]
38
+ /** How to know the mission is actually done. */
39
+ doneCriteria?: string[]
40
+ /** Standing notes for whoever picks up mission work next. */
41
+ handoffNotes?: string[]
42
+ /** Paths this mission's tasks collectively own (H1 integration point — surfaced, not enforced here). */
43
+ ownedPaths?: string[]
44
+ }
45
+
46
+ export interface NormalizeMissionBriefResult {
47
+ /** `null` when the input had no usable `goal` — callers should treat this as "no brief", not an empty one. */
48
+ readonly brief: MissionBrief | null
49
+ /** What was trimmed/dropped, for a caller that wants to warn (never thrown). */
50
+ readonly truncated: ReadonlyArray<{ field: string; reason: 'field_too_long' | 'list_too_long' | 'item_too_long' }>
51
+ }
52
+
53
+ function truncateString(value: string, max: number): string {
54
+ if (value.length <= max) return value
55
+ return `${value.slice(0, max - 1).trimEnd()}…`
56
+ }
57
+
58
+ function normalizeStringList(
59
+ value: unknown,
60
+ field: string,
61
+ truncated: Array<{ field: string; reason: 'field_too_long' | 'list_too_long' | 'item_too_long' }>,
62
+ ): string[] | undefined {
63
+ if (value === undefined || value === null) return undefined
64
+ if (!Array.isArray(value)) return undefined
65
+ const items: string[] = []
66
+ let itemTruncated = false
67
+ for (const raw of value) {
68
+ if (typeof raw !== 'string') continue
69
+ const trimmed = raw.trim()
70
+ if (!trimmed) continue
71
+ if (trimmed.length > LIST_ITEM_MAX) itemTruncated = true
72
+ items.push(truncateString(trimmed, LIST_ITEM_MAX))
73
+ }
74
+ if (itemTruncated) truncated.push({ field, reason: 'item_too_long' })
75
+ if (items.length > LIST_MAX_ITEMS) {
76
+ truncated.push({ field, reason: 'list_too_long' })
77
+ return items.slice(0, LIST_MAX_ITEMS)
78
+ }
79
+ return items.length > 0 ? items : undefined
80
+ }
81
+
82
+ /**
83
+ * Normalize a caller-supplied brief. Never throws. Returns `brief: null` when
84
+ * there is no non-empty `goal` — the caller (e.g. `mesh_mission_upsert`'s
85
+ * handler) decides whether that means "reject" or "no brief attached", this
86
+ * module only normalizes shape.
87
+ */
88
+ export function normalizeMissionBrief(input: unknown): NormalizeMissionBriefResult {
89
+ const truncated: Array<{ field: string; reason: 'field_too_long' | 'list_too_long' | 'item_too_long' }> = []
90
+ if (!input || typeof input !== 'object') {
91
+ return { brief: null, truncated }
92
+ }
93
+ const raw = input as Record<string, unknown>
94
+
95
+ const rawGoal = typeof raw.goal === 'string' ? raw.goal.trim() : ''
96
+ if (!rawGoal) {
97
+ return { brief: null, truncated }
98
+ }
99
+ if (rawGoal.length > GOAL_MAX) truncated.push({ field: 'goal', reason: 'field_too_long' })
100
+ const goal = truncateString(rawGoal, GOAL_MAX)
101
+
102
+ const constraints = normalizeStringList(raw.constraints, 'constraints', truncated)
103
+ const doneCriteria = normalizeStringList(raw.doneCriteria ?? raw.done_criteria, 'doneCriteria', truncated)
104
+ const handoffNotes = normalizeStringList(raw.handoffNotes ?? raw.handoff_notes, 'handoffNotes', truncated)
105
+ const ownedPaths = normalizeStringList(raw.ownedPaths ?? raw.owned_paths, 'ownedPaths', truncated)
106
+
107
+ const brief: MissionBrief = { goal }
108
+ if (constraints) brief.constraints = constraints
109
+ if (doneCriteria) brief.doneCriteria = doneCriteria
110
+ if (handoffNotes) brief.handoffNotes = handoffNotes
111
+ if (ownedPaths) brief.ownedPaths = ownedPaths
112
+
113
+ return { brief, truncated }
114
+ }
115
+
116
+ function renderList(heading: string, items: readonly string[] | undefined): string[] {
117
+ if (!items || items.length === 0) return []
118
+ return [`${heading}:`, ...items.map(item => `- ${item}`)]
119
+ }
120
+
121
+ /**
122
+ * Deterministic Markdown rendering of a brief, capped at
123
+ * `MISSION_BRIEF_RENDER_MAX_CHARS`. Same field order every call (goal,
124
+ * constraints, done criteria, owned paths, handoff notes) so a snapshot test
125
+ * or a worker reading two renders of the same brief sees no drift. If the
126
+ * assembled text exceeds the cap, trailing sections are dropped whole (never
127
+ * mid-line) until it fits, and a final `(brief truncated)` marker is appended
128
+ * — a worker should never receive a block cut off mid-sentence.
129
+ */
130
+ export function renderMissionBriefBlock(brief: MissionBrief): string {
131
+ const sections: string[] = [`Mission goal: ${brief.goal}`]
132
+ sections.push(...renderList('Constraints', brief.constraints))
133
+ sections.push(...renderList('Done when', brief.doneCriteria))
134
+ sections.push(...renderList('Owned paths', brief.ownedPaths))
135
+ sections.push(...renderList('Handoff notes', brief.handoffNotes))
136
+
137
+ const full = sections.join('\n')
138
+ if (full.length <= MISSION_BRIEF_RENDER_MAX_CHARS) return full
139
+
140
+ // Drop whole lines from the end until it fits, then append the marker
141
+ // (itself budgeted into the cap so the final string never exceeds it).
142
+ const marker = '\n(brief truncated)'
143
+ const budget = MISSION_BRIEF_RENDER_MAX_CHARS - marker.length
144
+ const lines = full.split('\n')
145
+ let acc = ''
146
+ for (const line of lines) {
147
+ const next = acc ? `${acc}\n${line}` : line
148
+ if (next.length > budget) break
149
+ acc = next
150
+ }
151
+ if (!acc) acc = truncateString(lines[0] ?? '', Math.max(budget, 0))
152
+ return `${acc}${marker}`
153
+ }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * outbound-message — the one send-side unit crossing every transport.
3
+ *
4
+ * Wiring-unification Phase D (docs/design/2026-09-23-wiring-unification.md §6,
5
+ * D1) / workstream D-web. Full type derivation in the scratchpad
6
+ * `phase-D-plan.md` §1.1-§1.2 (`OutboundMessage`/`SendPolicy`/`SubmitOutcome`/
7
+ * `SendRefusal`). `SendRefusal` is imported from here by Phase C1's
8
+ * `turn-evidence.ts` (`RECLAIMING_SEND_REFUSALS`) — this file owns the type,
9
+ * C1 owns which of its members reclaim a mesh dispatch attempt.
10
+ *
11
+ * Before this type, a chat send was represented by twelve different loose
12
+ * request shapes (`{message, sendNow?, interrupt?}`, `{message, input}`,
13
+ * mesh `agent_command` args, MCP tool args, ...) and parked messages had no
14
+ * identity at all — the driver queue was keyed by exact text, so send-now,
15
+ * cancel, interrupt and the web echo-retirement all recovered identity by
16
+ * string matching. `OutboundMessage.messageId` is minted once, at the
17
+ * ORIGIN, and is the identity every layer keys on afterwards: the wire
18
+ * field in the `send_chat` command payload, the driver's queue key (daemon
19
+ * side, Phase D-daemon), the web pending-store key (`pendingQueuedMessages.ts`)
20
+ * and the echo-retirement key (`conversation-message-snapshot.ts`).
21
+ *
22
+ * This file is dependency-free by construction (mesh-shared leaf rule):
23
+ * `InputEnvelopeWire`/`InputPartWire` are STRUCTURAL MIRRORS of daemon-core's
24
+ * `InputEnvelope`/`InputPart` (`oss/packages/daemon-core/src/providers/
25
+ * io-contracts.ts`), not re-exports — daemon-core assigns its richer type
26
+ * into this one, never the other way round, so this package's published
27
+ * `.d.ts` never references a package that depends on it.
28
+ */
29
+
30
+ // ─── Input envelope (structural mirror of daemon-core's InputEnvelope) ─────
31
+
32
+ export interface ContentAnnotationsWire {
33
+ audience?: ('user' | 'assistant')[]
34
+ priority?: number
35
+ }
36
+
37
+ export interface TextInputPartWire {
38
+ type: 'text'
39
+ text: string
40
+ }
41
+
42
+ export interface ImageInputPartWire {
43
+ type: 'image'
44
+ mimeType: string
45
+ uri?: string
46
+ data?: string
47
+ alt?: string
48
+ }
49
+
50
+ export interface AudioInputPartWire {
51
+ type: 'audio'
52
+ mimeType: string
53
+ uri?: string
54
+ data?: string
55
+ transcript?: string
56
+ }
57
+
58
+ export interface VideoInputPartWire {
59
+ type: 'video'
60
+ mimeType: string
61
+ uri?: string
62
+ data?: string
63
+ transcript?: string
64
+ posterUri?: string
65
+ }
66
+
67
+ export interface ResourceInputPartWire {
68
+ type: 'resource'
69
+ uri: string
70
+ mimeType?: string
71
+ name?: string
72
+ text?: string
73
+ data?: string
74
+ }
75
+
76
+ export interface ResourceLinkInputPartWire {
77
+ type: 'resource_link'
78
+ uri: string
79
+ name: string
80
+ title?: string
81
+ description?: string
82
+ mimeType?: string
83
+ size?: number
84
+ annotations?: ContentAnnotationsWire
85
+ }
86
+
87
+ export type InputPartWire =
88
+ | TextInputPartWire
89
+ | ImageInputPartWire
90
+ | AudioInputPartWire
91
+ | VideoInputPartWire
92
+ | ResourceLinkInputPartWire
93
+ | ResourceInputPartWire
94
+
95
+ export interface InputEnvelopeWire {
96
+ parts: InputPartWire[]
97
+ textFallback: string
98
+ metadata?: {
99
+ source?: 'dashboard' | 'shortcut_api' | 'provider_script' | 'session_replay'
100
+ clientTimestamp?: number
101
+ }
102
+ }
103
+
104
+ // ─── OutboundMessage ────────────────────────────────────────────────────────
105
+
106
+ /**
107
+ * Who minted this `OutboundMessage`. Determines default policy and refusal
108
+ * surface — 'mesh' origins get the mesh-specific `SendRefusal` reasons,
109
+ * interactive origins get the interactive ones.
110
+ *
111
+ * `'api'`/`'cli'` are carried per the design doc's D1 type even though no
112
+ * current call site produces them distinct from `'dashboard'`/`'mcp'`/
113
+ * `'mesh'` (flagged as an open question in phase-D-plan.md §7.3 risk 5) —
114
+ * kept here unchanged pending an owner decision; dropping a union member is
115
+ * a decision for whoever lands D-daemon/D2, not this workstream.
116
+ */
117
+ export type OutboundMessageOrigin = 'dashboard' | 'mcp' | 'mesh' | 'api' | 'cli'
118
+
119
+ /**
120
+ * How this message should be admitted when the session is not idle.
121
+ * Replaces the loose `sendNow`/`interrupt`/`force`/`forceSend` booleans (and
122
+ * mesh `delivery_mode`) with one closed union. `'queue'` is the default for
123
+ * every origin.
124
+ */
125
+ export type SendPolicy =
126
+ | { mode: 'queue' }
127
+ | { mode: 'send_now' }
128
+ | { mode: 'interrupt' }
129
+
130
+ /**
131
+ * Every reason `submit()` can refuse a message, closed so callers branch on it
132
+ * instead of matching free text.
133
+ *
134
+ * ★ This is THE `SendRefusal` (docs/design/2026-09-23-wiring-unification.md
135
+ * §6 D1) — Phase C1's `turn-evidence.ts` imports `SendRefusal`/
136
+ * `SEND_REFUSAL_REASONS` from this file (`RECLAIMING_SEND_REFUSALS`, the
137
+ * subset that means a mesh dispatch attempt must be reclaimed rather than
138
+ * retried) rather than declaring its own copy. Do not fork this vocabulary —
139
+ * a member renamed or removed here must be re-checked against
140
+ * `RECLAIMING_SEND_REFUSALS`'s three members (`session_exited`, `no_target`,
141
+ * `unsupported_input`), which `satisfies readonly SendRefusal[]` there keeps
142
+ * honest at compile time.
143
+ */
144
+ export type SendRefusal =
145
+ | 'not_ready'
146
+ | 'send_in_flight'
147
+ | 'not_generating'
148
+ | 'not_supported'
149
+ | 'platform_unsupported'
150
+ | 'modal_parked'
151
+ | 'interrupt_not_implemented'
152
+ | 'interrupt_refused'
153
+ | 'idle_timeout'
154
+ | 'session_exited'
155
+ | 'unsupported_input'
156
+ | 'no_target'
157
+ | 'bootstrap_pending'
158
+ | 'duplicate_dispatch'
159
+ | 'internal_error'
160
+
161
+ /**
162
+ * Which write path carried a delivered/queued message (Phase D2, additive
163
+ * detail — callers branch on `kind`; `route` only refines the report):
164
+ *
165
+ * `pty` — the ordinary composer write (or the driver FIFO for `queued`);
166
+ * `agent_queue` — the POSIX split write the CLI's OWN input queue takes while
167
+ * a turn is generating (`send_now`). Written, not yet answered;
168
+ * `interrupt` — the turn in flight was stopped first, then this body was
169
+ * written (or re-parked) as a new turn;
170
+ */
171
+ export type SubmitRoute = 'pty' | 'agent_queue' | 'interrupt'
172
+
173
+ /** The stop key pressed by an `interrupt` submit (logs / operator display). */
174
+ export interface SubmitInterruptDetail {
175
+ keyName: string
176
+ /** 'declared' = spec-declared stop key whose busy→idle effect was never observed live. */
177
+ confidence: 'proven' | 'declared'
178
+ }
179
+
180
+ /**
181
+ * The one closed outcome shape every caller receives from `submit()`.
182
+ *
183
+ * The optional fields are additive DETAIL (Phase D2): `route`/`interrupt` say
184
+ * how a message was written, `message` is a human-readable refusal line and
185
+ * `restored` says whether a parked body that an out-of-band submit had claimed
186
+ * is back in the driver queue (`false` = nothing holds it any more). None of
187
+ * them changes what `kind` means.
188
+ */
189
+ export type SubmitOutcome =
190
+ | { kind: 'delivered'; route?: SubmitRoute; interrupt?: SubmitInterruptDetail }
191
+ | { kind: 'queued'; position: number; route?: SubmitRoute; interrupt?: SubmitInterruptDetail }
192
+ | { kind: 'duplicate'; of: string /* messageId */ }
193
+ | { kind: 'refused'; reason: SendRefusal; message?: string; restored?: boolean }
194
+
195
+ /**
196
+ * The one send-side unit crossing every transport. `messageId` is minted at
197
+ * the ORIGIN (never by the daemon) — this is what makes cross-transport
198
+ * dedupe and identity-based claim/cancel/send-now possible.
199
+ */
200
+ export interface OutboundMessage {
201
+ messageId: string
202
+ sessionId: string
203
+ input: InputEnvelopeWire
204
+ origin: OutboundMessageOrigin
205
+ policy: SendPolicy
206
+ createdAt: number
207
+ /**
208
+ * Present only for a mesh-dispatched message; lets the turn ledger
209
+ * (Phase C) bind the send to the attempt without the sender needing to
210
+ * know what an "attempt" is. Optional and inert until C lands.
211
+ */
212
+ meshAttemptRef?: string
213
+ }
214
+
215
+ // ─── Identity ───────────────────────────────────────────────────────────────
216
+
217
+ /**
218
+ * Mint a new `messageId`. `crypto.randomUUID` where available (browser and
219
+ * modern Node); a counter fallback otherwise, since the only requirement is
220
+ * uniqueness within one process/tab, not cryptographic strength.
221
+ */
222
+ let outboundMessageIdCounter = 0
223
+ export function mintMessageId(now: number = Date.now()): string {
224
+ try {
225
+ // `globalThis.crypto` is not in the ES2022 lib (mesh-shared is DOM-free), so read it structurally.
226
+ const cryptoRef = (typeof globalThis !== 'undefined'
227
+ ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto
228
+ : undefined)
229
+ if (typeof cryptoRef?.randomUUID === 'function') return `msg_${cryptoRef.randomUUID()}`
230
+ } catch {
231
+ /* fall through to the counter fallback */
232
+ }
233
+ outboundMessageIdCounter += 1
234
+ return `msg_${now}_${outboundMessageIdCounter}`
235
+ }
236
+
237
+ /** `messageId`s minted by this module always carry this prefix; used by the guard below. */
238
+ const MESSAGE_ID_PREFIX = 'msg_'
239
+
240
+ export function isMessageId(value: unknown): value is string {
241
+ return typeof value === 'string' && value.length > MESSAGE_ID_PREFIX.length && value.startsWith(MESSAGE_ID_PREFIX)
242
+ }
243
+
244
+ // ─── Guards ─────────────────────────────────────────────────────────────────
245
+
246
+ export const OUTBOUND_MESSAGE_ORIGINS = ['dashboard', 'mcp', 'mesh', 'api', 'cli'] as const satisfies readonly OutboundMessageOrigin[]
247
+
248
+ export function isOutboundMessageOrigin(value: unknown): value is OutboundMessageOrigin {
249
+ return typeof value === 'string' && (OUTBOUND_MESSAGE_ORIGINS as readonly string[]).includes(value)
250
+ }
251
+
252
+ export const SEND_POLICY_MODES = ['queue', 'send_now', 'interrupt'] as const satisfies readonly SendPolicy['mode'][]
253
+
254
+ export function isSendPolicyMode(value: unknown): value is SendPolicy['mode'] {
255
+ return typeof value === 'string' && (SEND_POLICY_MODES as readonly string[]).includes(value)
256
+ }
257
+
258
+ export function isSendPolicy(value: unknown): value is SendPolicy {
259
+ return !!value && typeof value === 'object' && isSendPolicyMode((value as { mode?: unknown }).mode)
260
+ }
261
+
262
+ export const SEND_REFUSAL_REASONS = [
263
+ 'not_ready',
264
+ 'send_in_flight',
265
+ 'not_generating',
266
+ 'not_supported',
267
+ 'platform_unsupported',
268
+ 'modal_parked',
269
+ 'interrupt_not_implemented',
270
+ 'interrupt_refused',
271
+ 'idle_timeout',
272
+ 'session_exited',
273
+ 'unsupported_input',
274
+ 'no_target',
275
+ 'bootstrap_pending',
276
+ 'duplicate_dispatch',
277
+ 'internal_error',
278
+ ] as const satisfies readonly SendRefusal[]
279
+
280
+ export function isSendRefusal(value: unknown): value is SendRefusal {
281
+ return typeof value === 'string' && (SEND_REFUSAL_REASONS as readonly string[]).includes(value)
282
+ }
283
+
284
+ const SUBMIT_OUTCOME_KINDS = ['delivered', 'queued', 'duplicate', 'refused'] as const
285
+
286
+ export function isSubmitOutcome(value: unknown): value is SubmitOutcome {
287
+ if (!value || typeof value !== 'object') return false
288
+ const kind = (value as { kind?: unknown }).kind
289
+ if (typeof kind !== 'string' || !(SUBMIT_OUTCOME_KINDS as readonly string[]).includes(kind)) return false
290
+ if (kind === 'queued') return typeof (value as { position?: unknown }).position === 'number'
291
+ if (kind === 'duplicate') return typeof (value as { of?: unknown }).of === 'string'
292
+ if (kind === 'refused') return isSendRefusal((value as { reason?: unknown }).reason)
293
+ return true
294
+ }
295
+
296
+ function isInputPartWire(value: unknown): value is InputPartWire {
297
+ if (!value || typeof value !== 'object') return false
298
+ const type = (value as { type?: unknown }).type
299
+ return type === 'text' || type === 'image' || type === 'audio' || type === 'video' || type === 'resource' || type === 'resource_link'
300
+ }
301
+
302
+ export function isInputEnvelopeWire(value: unknown): value is InputEnvelopeWire {
303
+ if (!value || typeof value !== 'object') return false
304
+ const parts = (value as { parts?: unknown }).parts
305
+ const textFallback = (value as { textFallback?: unknown }).textFallback
306
+ return Array.isArray(parts) && parts.every(isInputPartWire) && typeof textFallback === 'string'
307
+ }
308
+
309
+ /** Minimal structural check for the wire boundary — not a full decoder (mesh-shared stays a pure leaf; semantic validation stays with the handler that owns it). */
310
+ export function isOutboundMessage(value: unknown): value is OutboundMessage {
311
+ if (!value || typeof value !== 'object') return false
312
+ const candidate = value as Partial<OutboundMessage>
313
+ return isMessageId(candidate.messageId)
314
+ && typeof candidate.sessionId === 'string' && candidate.sessionId.length > 0
315
+ && isInputEnvelopeWire(candidate.input)
316
+ && isOutboundMessageOrigin(candidate.origin)
317
+ && isSendPolicy(candidate.policy)
318
+ && typeof candidate.createdAt === 'number'
319
+ }