@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.
- 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,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
|
+
}
|
package/src/slot-proposal.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CLI auto-detect → capability-slot
|
|
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
|
-
* (`
|
|
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
|
-
}
|