@adhdev/mesh-shared 1.0.60-rc.9 → 1.0.61-rc.1
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/brain-routing.d.ts +6 -8
- package/dist/git-diff.d.ts +21 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +2876 -77
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2625 -72
- package/dist/index.mjs.map +1 -1
- package/dist/interpolation.d.ts +0 -9
- package/dist/json.d.ts +4 -0
- package/dist/keyed-doc-delta.d.ts +105 -0
- package/dist/mesh-tool-names.d.ts +31 -2
- package/dist/mesh-vocabulary.d.ts +66 -0
- package/dist/mission-brief.d.ts +62 -0
- package/dist/outbound-message.d.ts +201 -0
- package/dist/path-ownership.d.ts +101 -0
- package/dist/protocol/daemon-server.d.ts +356 -0
- package/dist/protocol/dashboard-daemon-p2p.d.ts +213 -0
- package/dist/protocol/dashboard-server.d.ts +240 -0
- package/dist/protocol/dashboard-wire-version.d.ts +21 -0
- package/dist/protocol/envelope.d.ts +55 -0
- package/dist/protocol/index.d.ts +16 -0
- package/dist/session-launch.d.ts +105 -0
- package/dist/session-status.d.ts +75 -0
- package/dist/slot-proposal.d.ts +2 -28
- package/dist/turn-evidence.d.ts +434 -0
- package/dist/turn-ipc-guards.d.ts +18 -0
- package/dist/turn-ipc-mission.d.ts +250 -0
- package/dist/turn-ipc-queue.d.ts +196 -0
- package/dist/turn-ipc-stats.d.ts +72 -0
- package/dist/turn-ipc-turn.d.ts +176 -0
- package/dist/turn-ipc.d.ts +90 -0
- package/dist/types.d.ts +11 -0
- package/dist/worker-protocol.d.ts +69 -0
- package/dist/ws-protocol.d.ts +69 -41
- package/package.json +1 -1
- package/src/brain-routing.ts +9 -11
- package/src/git-diff.ts +48 -0
- package/src/index.ts +12 -1
- package/src/interpolation.ts +0 -18
- package/src/json.ts +10 -0
- package/src/keyed-doc-delta.ts +413 -0
- package/src/mesh-tool-names.ts +66 -25
- package/src/mesh-vocabulary.ts +94 -0
- package/src/mission-brief.ts +153 -0
- package/src/outbound-message.ts +319 -0
- package/src/path-ownership.ts +271 -0
- package/src/protocol/daemon-server.ts +510 -0
- package/src/protocol/dashboard-daemon-p2p.ts +273 -0
- package/src/protocol/dashboard-server.ts +340 -0
- package/src/protocol/dashboard-wire-version.ts +27 -0
- package/src/protocol/envelope.ts +75 -0
- package/src/protocol/index.ts +17 -0
- package/src/session-launch.ts +245 -0
- package/src/session-status.ts +164 -0
- package/src/slot-proposal.ts +2 -48
- package/src/turn-evidence.ts +596 -0
- package/src/turn-ipc-guards.ts +61 -0
- package/src/turn-ipc-mission.ts +647 -0
- package/src/turn-ipc-queue.ts +493 -0
- package/src/turn-ipc-stats.ts +193 -0
- package/src/turn-ipc-turn.ts +453 -0
- package/src/turn-ipc.ts +137 -0
- package/src/types.ts +8 -0
- package/src/worker-protocol.ts +164 -0
- package/src/ws-protocol.ts +98 -93
- package/dist/magi.d.ts +0 -281
- 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
|
+
}
|