@adhdev/mesh-shared 1.0.60-rc.9 → 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,75 @@
1
+ /**
2
+ * protocol/envelope — helpers shared by the three link protocols.
3
+ *
4
+ * Wiring-unification Phase A2 (docs/design/2026-09-23-wiring-unification.md).
5
+ *
6
+ * Every link (daemon↔server WS, dashboard↔UserSession WS, dashboard↔daemon
7
+ * P2P DataChannel) is described the same way:
8
+ *
9
+ * - a `const` tuple of message-kind names per direction;
10
+ * - a discriminated union of `{ type, ... }` frames derived from it;
11
+ * - a `decode<Link><Direction>(raw)` parse-boundary function that checks the
12
+ * `type` is a member and the frame carries the MINIMAL shape the union
13
+ * promises (an object payload where one is required, the top-level fields
14
+ * a payload-less frame is keyed by). Semantic validation — is this session
15
+ * id known, is the SDP well formed — stays with the handler that owns it.
16
+ *
17
+ * mesh-shared is a dependency-free leaf, so payload types here are STRUCTURAL
18
+ * mirrors of the richer daemon-core types (`RoutingSessionEntry`,
19
+ * `DaemonStatusEventPayload`, ...): daemon-core, the server and the web assign
20
+ * their richer types into these, never the other way round. A type-only import
21
+ * of daemon-core would make the published `.d.ts` of this package reference a
22
+ * package that depends on it — a cycle a leaf must not create.
23
+ */
24
+
25
+ export type MessageId = string
26
+
27
+ /** `{ type, payload }` frame with the optional correlation/ordering fields every link uses. */
28
+ export type Envelope<T extends string, P> = {
29
+ type: T
30
+ payload: P
31
+ id?: MessageId
32
+ timestamp?: number
33
+ }
34
+
35
+ /** Genuinely dynamic payloads (command arguments and results). Allowed by design; the union stays exhaustive on `type`. */
36
+ export type CommandPayload = Record<string, unknown>
37
+
38
+ export function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
40
+ }
41
+
42
+ export function isNonEmptyString(value: unknown): value is string {
43
+ return typeof value === 'string' && value.length > 0
44
+ }
45
+
46
+ export function isFiniteNumber(value: unknown): value is number {
47
+ return typeof value === 'number' && Number.isFinite(value)
48
+ }
49
+
50
+ export function makeTypeGuard<T extends readonly string[]>(names: T): (value: unknown) => value is T[number] {
51
+ const set: ReadonlySet<string> = new Set(names)
52
+ return (value: unknown): value is T[number] => typeof value === 'string' && set.has(value)
53
+ }
54
+
55
+ /**
56
+ * Compile-time proof that a name tuple and a union have exactly the same
57
+ * members. Used as `const _: AssertSameMembers<typeof NAMES, Union> = true`.
58
+ */
59
+ export type AssertSameMembers<Tuple extends readonly string[], Union extends string> =
60
+ [Exclude<Union, Tuple[number]>, Exclude<Tuple[number], Union>] extends [never, never] ? true : never
61
+
62
+ /** Narrow a frame's `type` to a union member, or null. */
63
+ export function decodeTypeField<T extends string>(raw: unknown, isType: (value: unknown) => value is T): { type: T; frame: Record<string, unknown> } | null {
64
+ if (!isRecord(raw)) return null
65
+ if (!isType(raw.type)) return null
66
+ return { type: raw.type, frame: raw }
67
+ }
68
+
69
+ /** Read the optional envelope fields a frame may carry, dropping malformed ones. */
70
+ export function readEnvelopeFields(frame: Record<string, unknown>): { id?: MessageId; timestamp?: number } {
71
+ return {
72
+ ...(isNonEmptyString(frame.id) ? { id: frame.id } : {}),
73
+ ...(isFiniteNumber(frame.timestamp) ? { timestamp: frame.timestamp } : {}),
74
+ }
75
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * protocol — typed wire contracts for the three transport links.
3
+ *
4
+ * ./daemon-server.ts daemon ↔ Workers server WS
5
+ * ./dashboard-server.ts dashboard ↔ UserSessionDO WS (+ share viewer ↔ SharedSessionDO)
6
+ * ./dashboard-daemon-p2p.ts dashboard ↔ daemon DataChannel
7
+ *
8
+ * Each exports name tuples, discriminated unions, type guards and a
9
+ * `decode<Link><Direction>(raw)` parse-boundary function. ../ws-protocol.ts
10
+ * keeps the pre-existing name-only aliases derived from these.
11
+ */
12
+
13
+ export * from './envelope'
14
+ export * from './daemon-server'
15
+ export * from './dashboard-server'
16
+ export * from './dashboard-daemon-p2p'
17
+ export * from './dashboard-wire-version'
@@ -0,0 +1,245 @@
1
+ /**
2
+ * session-launch — how a session was launched: provider, model and thinking
3
+ * level, and WHERE each value came from.
4
+ *
5
+ * Wiring-unification Phase E (docs/design/2026-09-23-wiring-unification.md §7,
6
+ * RC5). Before this record the only trace of a session's model was a display
7
+ * string in recent-activity (`buildLegacyModelModeSummaryMetadata`), so nobody
8
+ * could answer "did I pick this model, did the dashboard restore it from last
9
+ * time, did a mesh slot choose it, or is it just the provider default?" — and
10
+ * after a daemon restart not even the value survived.
11
+ *
12
+ * The daemon owns the record (daemon-core `SessionRegistry`); this file holds the
13
+ * shape and the pure readers so daemon-core, web-core, the server and the MCP
14
+ * server all read one definition. It is a dependency-free leaf, like the rest of
15
+ * mesh-shared.
16
+ *
17
+ * ★Server content boundary (CLAUDE.md): the full record rides P2P only. The
18
+ * server path carries exactly two derived fields — `model` (an identifier,
19
+ * passed through {@link sanitizeModelIdentifier}) and `modelSource` (an enum,
20
+ * passed through {@link isModelAxisSource}). Never widen that to the record.
21
+ */
22
+
23
+ /**
24
+ * Where a model / thinking-level value came from.
25
+ *
26
+ * `restore` is deliberately NOT a member: a hosted session re-attached after a
27
+ * daemon restart keeps the ORIGINAL source (the user still picked that model);
28
+ * only `SessionLaunchRecord.launchedBy` records that the session came back via
29
+ * restore.
30
+ */
31
+ import { readString } from './json'
32
+
33
+ export const MODEL_AXIS_SOURCES = [
34
+ /** Picked explicitly for this launch (dashboard dropdown, coordinator dialog). */
35
+ 'user',
36
+ /** The dashboard dialog restored it from the last launch (browser-local memory). */
37
+ 'remembered',
38
+ /** A mesh capability slot supplied it (or a difficulty preset the slot outranked). */
39
+ 'mesh_slot',
40
+ /** An explicit per-task model / thinking level on a mesh queue task. */
41
+ 'task_override',
42
+ /** Nothing was requested; the provider's default is known (discovery / manifest). */
43
+ 'provider_default',
44
+ /** A value was requested without saying where it came from, or nothing is known. */
45
+ 'unspecified',
46
+ ] as const
47
+
48
+ export type ModelAxisSource = typeof MODEL_AXIS_SOURCES[number]
49
+
50
+ export function isModelAxisSource(value: unknown): value is ModelAxisSource {
51
+ return typeof value === 'string' && (MODEL_AXIS_SOURCES as readonly string[]).includes(value)
52
+ }
53
+
54
+ /**
55
+ * Which surface started the session. `restore` means "re-attached to a hosted
56
+ * runtime that outlived a daemon restart" — the axis sources are preserved.
57
+ */
58
+ export const SESSION_LAUNCHED_BY = ['dashboard', 'mesh', 'cli', 'api', 'restore'] as const
59
+
60
+ export type SessionLaunchedBy = typeof SESSION_LAUNCHED_BY[number]
61
+
62
+ export function isSessionLaunchedBy(value: unknown): value is SessionLaunchedBy {
63
+ return typeof value === 'string' && (SESSION_LAUNCHED_BY as readonly string[]).includes(value)
64
+ }
65
+
66
+ /** How a value entered the axis history. */
67
+ export type ModelSelectionVia = 'launch' | 'change_model' | 'observed'
68
+
69
+ export interface ModelSelectionHistoryEntry {
70
+ at: number
71
+ value: string
72
+ via: ModelSelectionVia
73
+ }
74
+
75
+ /** One axis (model, or thinking level) of a session launch. */
76
+ export interface ModelSelection {
77
+ /** What the caller asked for. Absent = nothing requested. */
78
+ requested?: string
79
+ source: ModelAxisSource
80
+ /**
81
+ * The value actually handed to the provider: after `modelLaunchValueMap`
82
+ * (CLI `--model` template) or the ACP `setConfigOption` call. Absent when the
83
+ * request was not applied (no template, the config call failed) or nothing
84
+ * was requested.
85
+ */
86
+ launchValue?: string
87
+ /**
88
+ * The provider default this session is expected to run with when nothing was
89
+ * requested (`source === 'provider_default'` only): discovery `models[0]`,
90
+ * else the manifest's first option. Nothing was passed to the provider.
91
+ */
92
+ resolvedDefault?: string
93
+ /** Last explicit runtime change (`change_model` / `set_thought_level`). */
94
+ current?: string
95
+ /** Last value the provider itself reported (statusline, native history). */
96
+ observed?: string
97
+ observedAt?: number
98
+ /** Every value change in order. The last entry is the value in force. */
99
+ history: ModelSelectionHistoryEntry[]
100
+ }
101
+
102
+ export interface SessionLaunchRecord {
103
+ sessionId: string
104
+ providerType: string
105
+ providerVersion?: string
106
+ providerChannel?: 'stable' | 'preview'
107
+ /** `restore` = re-attached after a daemon restart; the axis sources stay the original ones. */
108
+ launchedBy: SessionLaunchedBy
109
+ launchedAt: number
110
+ workspace?: string
111
+ model: ModelSelection
112
+ thinkingLevel: ModelSelection
113
+ autoApproveModeId?: string
114
+ /**
115
+ * Reserved by the design (§7 E1) for sanitized launch argv. Not populated:
116
+ * argv carries coordinator system-prompt text and config paths, and the
117
+ * session-host record already keeps the raw argv for restore.
118
+ */
119
+ cliArgs?: string[]
120
+ }
121
+
122
+ /** History is bounded; the first `launch` entry is always kept. */
123
+ export const MODEL_SELECTION_HISTORY_LIMIT = 20
124
+
125
+ /** The value in force now: the latest change, else the launch-time value. */
126
+ export function effectiveModelSelectionValue(selection: ModelSelection | null | undefined): string | undefined {
127
+ if (!selection) return undefined
128
+ const last = Array.isArray(selection.history) ? selection.history[selection.history.length - 1] : undefined
129
+ return last?.value
130
+ ?? selection.current
131
+ ?? selection.observed
132
+ ?? launchModelSelectionValue(selection)
133
+ }
134
+
135
+ /** The value the session started with (what was passed, else asked for, else the default). */
136
+ export function launchModelSelectionValue(selection: ModelSelection | null | undefined): string | undefined {
137
+ if (!selection) return undefined
138
+ const launch = Array.isArray(selection.history)
139
+ ? selection.history.find((entry) => entry.via === 'launch')
140
+ : undefined
141
+ return launch?.value ?? selection.launchValue ?? selection.requested ?? selection.resolvedDefault
142
+ }
143
+
144
+ export interface ModelSelectionDisplay {
145
+ /** The value in force now. */
146
+ value: string
147
+ /** The value the session launched with, when it differs from `value`. */
148
+ changedFrom?: string
149
+ source: ModelAxisSource
150
+ }
151
+
152
+ /** Pure projection for UI chips: "sonnet · chosen", "sonnet → opus (changed)". */
153
+ export function describeModelSelection(selection: ModelSelection | null | undefined): ModelSelectionDisplay | undefined {
154
+ const value = effectiveModelSelectionValue(selection)
155
+ if (!selection || !value) return undefined
156
+ const launched = launchModelSelectionValue(selection)
157
+ return {
158
+ value,
159
+ ...(launched && launched !== value ? { changedFrom: launched } : {}),
160
+ source: isModelAxisSource(selection.source) ? selection.source : 'unspecified',
161
+ }
162
+ }
163
+
164
+ /**
165
+ * The only form a model / thinking value may take on the server path: an
166
+ * identifier (`claude-opus-4-1`, `gpt-5.6-sol`, `openai/gpt-4o`, `opus[1m]`,
167
+ * `high`). No whitespace, bounded length — a free-text sentence cannot pass.
168
+ * Human labels (`Gemini 3.7 Flash (High)`) are P2P-only and are dropped here.
169
+ */
170
+ const MODEL_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@[\]+-]{0,95}$/
171
+
172
+ export function sanitizeModelIdentifier(value: unknown): string | undefined {
173
+ if (typeof value !== 'string') return undefined
174
+ const trimmed = value.trim()
175
+ return MODEL_IDENTIFIER_PATTERN.test(trimmed) ? trimmed : undefined
176
+ }
177
+
178
+ function readFiniteNumber(value: unknown): number | undefined {
179
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
180
+ }
181
+
182
+ function parseModelSelection(raw: unknown): ModelSelection | undefined {
183
+ if (!raw || typeof raw !== 'object') return undefined
184
+ const record = raw as Record<string, unknown>
185
+ const history: ModelSelectionHistoryEntry[] = []
186
+ if (Array.isArray(record.history)) {
187
+ for (const entry of record.history) {
188
+ if (!entry || typeof entry !== 'object') continue
189
+ const e = entry as Record<string, unknown>
190
+ const value = readString(e.value)
191
+ const at = readFiniteNumber(e.at)
192
+ const via = e.via === 'launch' || e.via === 'change_model' || e.via === 'observed' ? e.via : undefined
193
+ if (value && at !== undefined && via) history.push({ at, value, via })
194
+ }
195
+ }
196
+ const requested = readString(record.requested)
197
+ const launchValue = readString(record.launchValue)
198
+ const resolvedDefault = readString(record.resolvedDefault)
199
+ const current = readString(record.current)
200
+ const observed = readString(record.observed)
201
+ const observedAt = readFiniteNumber(record.observedAt)
202
+ return {
203
+ source: isModelAxisSource(record.source) ? record.source : 'unspecified',
204
+ ...(requested ? { requested } : {}),
205
+ ...(launchValue ? { launchValue } : {}),
206
+ ...(resolvedDefault ? { resolvedDefault } : {}),
207
+ ...(current ? { current } : {}),
208
+ ...(observed ? { observed } : {}),
209
+ ...(observedAt !== undefined ? { observedAt } : {}),
210
+ history: history.slice(-MODEL_SELECTION_HISTORY_LIMIT),
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Validate a record that crossed a process or storage boundary (session-host
216
+ * record meta on restore, a P2P snapshot). Returns undefined for anything that
217
+ * is not recognisably a launch record; unknown keys are dropped.
218
+ */
219
+ export function parseSessionLaunchRecord(raw: unknown): SessionLaunchRecord | undefined {
220
+ if (!raw || typeof raw !== 'object') return undefined
221
+ const record = raw as Record<string, unknown>
222
+ const sessionId = readString(record.sessionId)
223
+ const providerType = readString(record.providerType)
224
+ const launchedAt = readFiniteNumber(record.launchedAt)
225
+ const model = parseModelSelection(record.model)
226
+ const thinkingLevel = parseModelSelection(record.thinkingLevel)
227
+ if (!sessionId || !providerType || launchedAt === undefined || !model || !thinkingLevel) return undefined
228
+ const providerVersion = readString(record.providerVersion)
229
+ const workspace = readString(record.workspace)
230
+ const autoApproveModeId = readString(record.autoApproveModeId)
231
+ return {
232
+ sessionId,
233
+ providerType,
234
+ ...(providerVersion ? { providerVersion } : {}),
235
+ ...(record.providerChannel === 'stable' || record.providerChannel === 'preview'
236
+ ? { providerChannel: record.providerChannel }
237
+ : {}),
238
+ launchedBy: isSessionLaunchedBy(record.launchedBy) ? record.launchedBy : 'api',
239
+ launchedAt,
240
+ ...(workspace ? { workspace } : {}),
241
+ model,
242
+ thinkingLevel,
243
+ ...(autoApproveModeId ? { autoApproveModeId } : {}),
244
+ }
245
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * session-status — the ONE session status vocabulary.
3
+ *
4
+ * Wiring-unification Phase A1 (docs/design/2026-09-23-wiring-unification.md).
5
+ *
6
+ * Before this file, the same eleven-member union was declared three times in
7
+ * daemon-core (`SessionStatus`, `ManagedStatus`, and a stale `AgentStatus`) and
8
+ * five independent "is it busy?" sets disagreed with each other — for example
9
+ * `waiting_approval` counted as busy in three of them and idle in the other
10
+ * two. Every consumer now derives from here:
11
+ *
12
+ * - the canonical union `SessionStatus` and its member list `SESSION_STATUSES`;
13
+ * - a behavioural CLASS per status (`SESSION_STATUS_CLASS`) — the only thing
14
+ * send / dispatch / UI logic may branch on;
15
+ * - an alias table for the raw spellings providers and status lanes still
16
+ * emit (`running`, `streaming`, `no_progress`, …) so classification is total.
17
+ *
18
+ * This lives in mesh-shared (a dependency-free leaf) because web-core must not
19
+ * value-import the daemon-core barrel, and the server has no daemon-core at all.
20
+ */
21
+
22
+ export const SESSION_STATUSES = [
23
+ 'idle',
24
+ 'generating',
25
+ 'waiting_approval',
26
+ 'waiting_choice',
27
+ 'finalizing',
28
+ 'error',
29
+ 'stopped',
30
+ 'starting',
31
+ 'panel_hidden',
32
+ 'not_monitored',
33
+ 'disconnected',
34
+ ] as const
35
+
36
+ export type SessionStatus = typeof SESSION_STATUSES[number]
37
+
38
+ export const RECENT_SESSION_BUCKETS = ['needs_attention', 'working', 'task_complete', 'idle'] as const
39
+
40
+ export type RecentSessionBucket = typeof RECENT_SESSION_BUCKETS[number]
41
+
42
+ /**
43
+ * Behavioural class of a status.
44
+ *
45
+ * working — the agent owns the turn; input is queued, not delivered.
46
+ * blocked — the agent is waiting on a human decision (approval / choice);
47
+ * alive, and the turn is not over.
48
+ * ready — the agent will accept input now.
49
+ * dead — the session will never accept input again.
50
+ * unknown — the spelling is not one we classify (callers must fail safe).
51
+ */
52
+ export type SessionStatusClass = 'working' | 'blocked' | 'ready' | 'dead' | 'unknown'
53
+
54
+ export const SESSION_STATUS_CLASS: Record<SessionStatus, Exclude<SessionStatusClass, 'unknown'>> = {
55
+ generating: 'working',
56
+ finalizing: 'working',
57
+ starting: 'working',
58
+ waiting_approval: 'blocked',
59
+ waiting_choice: 'blocked',
60
+ idle: 'ready',
61
+ panel_hidden: 'ready',
62
+ not_monitored: 'ready',
63
+ error: 'dead',
64
+ stopped: 'dead',
65
+ disconnected: 'dead',
66
+ }
67
+
68
+ /**
69
+ * Raw spellings that still reach status consumers from IDE/CDP providers, the
70
+ * chat-tail status lane and legacy adapters, mapped onto the canonical member
71
+ * that carries the same behavioural meaning. Keep this table exhaustive over
72
+ * what the five pre-unification busy sets accepted; a spelling missing here
73
+ * classifies as `unknown`, which every consumer must treat as "do not assume
74
+ * idle".
75
+ */
76
+ export const SESSION_STATUS_ALIASES: Readonly<Record<string, SessionStatus>> = {
77
+ running: 'generating',
78
+ streaming: 'generating',
79
+ busy: 'generating',
80
+ working: 'generating',
81
+ loading: 'generating',
82
+ loading_reference: 'generating',
83
+ thinking: 'generating',
84
+ active: 'generating',
85
+ // Status-lane refinements of a turn that is still owned by the agent.
86
+ no_progress: 'generating',
87
+ long_generating: 'generating',
88
+ initializing: 'starting',
89
+ waiting: 'waiting_approval',
90
+ }
91
+
92
+ const SESSION_STATUS_SET: ReadonlySet<string> = new Set(SESSION_STATUSES)
93
+
94
+ export function isSessionStatus(value: unknown): value is SessionStatus {
95
+ return typeof value === 'string' && SESSION_STATUS_SET.has(value)
96
+ }
97
+
98
+ /** Canonical member for a raw status spelling, or null when unrecognised. */
99
+ export function normalizeSessionStatus(raw: unknown): SessionStatus | null {
100
+ if (typeof raw !== 'string') return null
101
+ const value = raw.trim().toLowerCase()
102
+ if (SESSION_STATUS_SET.has(value)) return value as SessionStatus
103
+ return SESSION_STATUS_ALIASES[value] ?? null
104
+ }
105
+
106
+ export function classifySessionStatus(raw: unknown): SessionStatusClass {
107
+ const status = normalizeSessionStatus(raw)
108
+ return status ? SESSION_STATUS_CLASS[status] : 'unknown'
109
+ }
110
+
111
+ export function isWorkingStatus(raw: unknown): boolean {
112
+ return classifySessionStatus(raw) === 'working'
113
+ }
114
+
115
+ export function isBlockedStatus(raw: unknown): boolean {
116
+ return classifySessionStatus(raw) === 'blocked'
117
+ }
118
+
119
+ export function isReadyStatus(raw: unknown): boolean {
120
+ return classifySessionStatus(raw) === 'ready'
121
+ }
122
+
123
+ export function isDeadStatus(raw: unknown): boolean {
124
+ return classifySessionStatus(raw) === 'dead'
125
+ }
126
+
127
+ /**
128
+ * "Busy" in the sense every pre-unification set meant: the agent cannot take a
129
+ * new instruction right now, whether because it is working or because it is
130
+ * blocked on a human. Dead sessions are NOT busy — they are gone, and callers
131
+ * that need to distinguish must check `isDeadStatus` first.
132
+ */
133
+ export function isBusyStatus(raw: unknown): boolean {
134
+ const cls = classifySessionStatus(raw)
135
+ return cls === 'working' || cls === 'blocked'
136
+ }
137
+
138
+ export function statusesOfClass(cls: Exclude<SessionStatusClass, 'unknown'>): SessionStatus[] {
139
+ return SESSION_STATUSES.filter((status) => SESSION_STATUS_CLASS[status] === cls)
140
+ }
141
+
142
+ /**
143
+ * Mesh-record terminal spellings: a session RECORD in one of these is gone
144
+ * (the vocabulary's `dead` class covers the canonical members; records also
145
+ * carry `failed` / `terminated` / `exited` / `closed`).
146
+ */
147
+ export const TERMINAL_SESSION_RECORD_STATUSES = ['stopped', 'failed', 'terminated', 'exited', 'closed'] as const
148
+
149
+ export function isTerminalSessionRecordStatus(raw: unknown): boolean {
150
+ return typeof raw === 'string' && (TERMINAL_SESSION_RECORD_STATUSES as readonly string[]).includes(raw.trim().toLowerCase())
151
+ }
152
+
153
+ /**
154
+ * THE "idle — can take a task now" predicate over a session state/record (its
155
+ * top-level `status` and chat lane). Every idle-candidate check (queue drain,
156
+ * auto-launch orphan sweep, dispatch pick) uses this one, so they cannot
157
+ * disagree about which sessions are candidates.
158
+ */
159
+ export function isIdleSessionState(state: unknown): boolean {
160
+ const record = state && typeof state === 'object' ? state as { status?: unknown; activeChat?: { status?: unknown } } : {}
161
+ const status = typeof record.status === 'string' ? record.status.trim().toLowerCase() : ''
162
+ if (isTerminalSessionRecordStatus(status) || isDeadStatus(status)) return false
163
+ return isReadyStatus(status) || record.activeChat?.status === 'waiting_input'
164
+ }
@@ -1,9 +1,9 @@
1
1
  /**
2
- * CLI auto-detect → capability-slot / MAGI-panel PROPOSAL generator.
2
+ * CLI auto-detect → capability-slot PROPOSAL generator.
3
3
  *
4
4
  * Detection of installed CLI providers already exists per node (the status
5
5
  * snapshot's `availableProviders`), and applying a slot profile already exists
6
- * (`mesh_node_slots_set`, dry-run by default). What was missing is the bit in
6
+ * (`mesh_node_slots` action "set", dry-run by default). What was missing is the bit in
7
7
  * between: turning "these CLIs are installed on this node" into a concrete
8
8
  * `NodeCapabilitySlot[]` draft the operator can review. This module is that
9
9
  * bridge, and nothing more — it is a PURE proposal generator. It never writes;
@@ -31,7 +31,6 @@ import {
31
31
  type MeshTaskDifficulty,
32
32
  type NodeCapabilitySlot,
33
33
  } from './brain-routing'
34
- import type { MagiSlot } from './magi'
35
34
 
36
35
  /**
37
36
  * One provider's seeded slot recipe. A provider may map to MORE THAN ONE slot
@@ -280,48 +279,3 @@ export function buildSlotProposal(
280
279
  destructive: droppedSlots.length > 0,
281
280
  }
282
281
  }
283
-
284
- /**
285
- * Build a MAGI panel proposal from the same detections.
286
- *
287
- * ─── Deliberately narrow ──────────────────────────────────────────────────────
288
- *
289
- * MAGI's value is provider INDEPENDENCE: replicas from different providers
290
- * (ideally different machines) answering the same question, so agreement means
291
- * something. Detection tells us which providers exist — that is exactly enough
292
- * to propose one panel of distinct providers, and no more.
293
- *
294
- * What detection does NOT tell us is which provider suits which review KIND
295
- * (rca vs design vs claim_audit). Nothing in any manifest grades a provider for
296
- * root-cause analysis over design review, and inventing a per-kind assignment
297
- * would fabricate a rationale that does not exist. So this proposes ONE panel of
298
- * the detected providers and leaves the kind binding to the operator; the caller
299
- * decides which `task_kind` to bind it to via the existing dry-run tool.
300
- *
301
- * Ordering follows {@link CLI_SLOT_RECIPES} insertion order (recipe-known
302
- * providers first, in table order), so the panel leads with the providers whose
303
- * suitability is actually attested.
304
- */
305
- export function buildMagiPanelProposal(
306
- detected: readonly DetectedCliProvider[],
307
- opts: { nodeId?: string; maxSlots?: number } = {},
308
- ): MagiSlot[] {
309
- const providers = dedupeDetected(detected ?? [])
310
- const tableOrder = Object.keys(CLI_SLOT_RECIPES)
311
- const rank = (type: string): number => {
312
- const i = tableOrder.indexOf(type)
313
- return i === -1 ? Number.MAX_SAFE_INTEGER : i
314
- }
315
- const ordered = [...providers].sort((a, b) => rank(a.type) - rank(b.type))
316
- const limit = Number.isFinite(opts.maxSlots) && (opts.maxSlots as number) > 0
317
- ? Math.floor(opts.maxSlots as number)
318
- : ordered.length
319
-
320
- return ordered.slice(0, limit).map((p): MagiSlot => ({
321
- ...(opts.nodeId ? { nodeId: opts.nodeId } : {}),
322
- provider: p.type,
323
- // A model is intentionally NOT pinned: the panel's job is cross-provider
324
- // independence, and pinning models here would silently couple the panel
325
- // to this table's cost assumptions rather than to review quality.
326
- }))
327
- }