@adhdev/mesh-shared 1.0.60-rc.70 → 1.0.60-rc.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ /**
2
+ * protocol/dashboard-wire-version — the dashboard ↔ daemon wire version.
3
+ *
4
+ * Both dashboards (cloud over P2P, standalone over WS) state the version they
5
+ * speak on every topic subscribe; the daemon states its own on the standalone
6
+ * hello, on every keyed snapshot frame, and in the `protocol_mismatch` reply it
7
+ * sends INSTEAD of state when the two differ. There is no dual-format serving:
8
+ * a mismatched pair renders no state at all —
9
+ * - the page is older than the daemon → the page must reload (its bundle
10
+ * cannot read the daemon's frames);
11
+ * - the daemon is older than the page → the daemon must be updated.
12
+ *
13
+ * Bump this whenever a frame shape changes incompatibly; one release carries
14
+ * the same value on both ends.
15
+ * 1 — implicit (frames before versioning).
16
+ * 2 — daemon.metadata deltas on the keyed-doc engine (2026-09-29).
17
+ */
18
+ export const DASHBOARD_WIRE_VERSION = 2
19
+
20
+ export type DashboardWireCompat = 'ok' | 'reload_required' | 'daemon_update_required'
21
+
22
+ /** How a page speaking `pageVersion` relates to a daemon speaking `daemonVersion` (absent = 1). */
23
+ export function compareDashboardWireVersion(pageVersion: number, daemonVersion: number | null | undefined): DashboardWireCompat {
24
+ const daemon = typeof daemonVersion === 'number' && Number.isFinite(daemonVersion) ? daemonVersion : 1
25
+ if (daemon === pageVersion) return 'ok'
26
+ return daemon > pageVersion ? 'reload_required' : 'daemon_update_required'
27
+ }
@@ -14,3 +14,4 @@ export * from './envelope'
14
14
  export * from './daemon-server'
15
15
  export * from './dashboard-server'
16
16
  export * from './dashboard-daemon-p2p'
17
+ export * from './dashboard-wire-version'
@@ -138,3 +138,27 @@ export function isBusyStatus(raw: unknown): boolean {
138
138
  export function statusesOfClass(cls: Exclude<SessionStatusClass, 'unknown'>): SessionStatus[] {
139
139
  return SESSION_STATUSES.filter((status) => SESSION_STATUS_CLASS[status] === cls)
140
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
+ }
@@ -388,11 +388,9 @@ function makeGuard<T extends readonly string[]>(values: T): (value: unknown) =>
388
388
  return (value: unknown): value is T[number] => typeof value === 'string' && set.has(value)
389
389
  }
390
390
 
391
- export const isEvidenceSourceId = makeGuard(EVIDENCE_SOURCE_IDS)
392
391
  export const isTurnEvidenceKind = makeGuard(TURN_EVIDENCE_KINDS as readonly TurnEvidenceKind[])
393
392
  export const isTurnReason = makeGuard(TURN_REASONS)
394
393
  export const isNotifyKind = makeGuard(NOTIFY_KINDS)
395
- export const isHoldReason = makeGuard(HOLD_REASONS)
396
394
  export const isTurnOutcome = makeGuard(TURN_OUTCOMES)
397
395
  export const isCommitStrength = makeGuard(COMMIT_STRENGTHS)
398
396
 
@@ -491,9 +489,6 @@ export function isTurnEvidence(value: unknown): value is TurnEvidence {
491
489
  return true
492
490
  }
493
491
 
494
- export function isTerminalClassEvidenceKind(kind: TurnEvidenceKind): kind is typeof TERMINAL_CLASS_EVIDENCE_KINDS[number] {
495
- return (TERMINAL_CLASS_EVIDENCE_KINDS as readonly string[]).includes(kind)
496
- }
497
492
 
498
493
  // ─── mesh.<id>.events topic entries (C2) ─────────────────────────────────
499
494
 
@@ -516,7 +511,6 @@ export type MeshTopicEntry = { v: typeof MESH_TOPIC_PROTOCOL_VERSION; eventId: s
516
511
  | { k: 'mesh.record'; ledgerKind: string; nodeId?: string; sessionId?: string; taskId?: string; payload: ProjectedScalars }
517
512
  )
518
513
 
519
- export const isMeshTopicEntryKind = makeGuard(MESH_TOPIC_ENTRY_KINDS)
520
514
 
521
515
  function isOptionalId(value: unknown): boolean {
522
516
  return value === undefined || isEvidenceIdentifier(value)
package/src/turn-ipc.ts CHANGED
@@ -55,13 +55,12 @@ import {
55
55
  isTurnEvidence,
56
56
  isTurnReason,
57
57
  type ProjectedScalars,
58
- type SummaryRef,
59
58
  type TurnAttemptRef,
60
59
  type TurnEvidence,
61
60
  type TurnEvidenceKind,
62
61
  type TurnOutcome,
63
62
  type TurnReason,
64
- } from './turn-evidence'
63
+ } from './turn-evidence';
65
64
  import { isRecord } from './protocol/envelope'
66
65
 
67
66
  // ─── shared primitives ─────────────────────────────────────────────────────
@@ -18,8 +18,6 @@
18
18
  import {
19
19
  DAEMON_TO_SERVER_TYPES,
20
20
  SERVER_TO_DAEMON_CONTROL_TYPES,
21
- isDaemonToServerType,
22
- isServerToDaemonControlType,
23
21
  type DaemonToServerType,
24
22
  type ServerToDaemonControlType,
25
23
  type P2PSignalType,
@@ -48,9 +46,7 @@ export const DAEMON_TO_SERVER_WS_MSGS: readonly DaemonToServerWsMsg[] = DAEMON_T
48
46
 
49
47
  export const SERVER_TO_DAEMON_WS_MSGS: readonly ServerToDaemonWsMsg[] = SERVER_TO_DAEMON_CONTROL_TYPES;
50
48
 
51
- export const isDaemonToServerWsMsg: (value: unknown) => value is DaemonToServerWsMsg = isDaemonToServerType;
52
49
 
53
- export const isServerToDaemonWsMsg: (value: unknown) => value is ServerToDaemonWsMsg = isServerToDaemonControlType;
54
50
 
55
51
  /**
56
52
  * `auth_ok.payload.limits` — the plan-limit contract the server sends DOWN to
@@ -1,76 +0,0 @@
1
- /**
2
- * Keyed status delta — the diff/fold pair behind the `daemon.metadata` lane
3
- * (the ONE daemon→dashboard state lane; data-path audit 2026-09-29 P0-3).
4
- *
5
- * The daemon digests every body it would have sent, diffs it against the
6
- * digest of the last body DELIVERED to a subscription, and sends only the
7
- * difference: changed top-level fields, changed daemon-level `status` fields,
8
- * and per-session changed fields keyed by session id, with removals explicit.
9
- * An unchanged state diffs to `null` — zero bytes on the wire. The dashboard
10
- * folds each delta into its held snapshot; the fold of every delta since a
11
- * snapshot equals the latest snapshot (pinned by keyed-status-delta.test.ts).
12
- *
13
- * Pure leaf: plain objects in, plain objects out; no transport, no clock.
14
- */
15
- /** A session row: identified by `id`, everything else is a field. */
16
- export interface KeyedStatusSession {
17
- id: string;
18
- [field: string]: unknown;
19
- }
20
- /** The snapshot body being diffed: top-level fields plus a `status` with a keyed session list. */
21
- export interface KeyedStatusBody {
22
- status: {
23
- sessions: KeyedStatusSession[];
24
- [field: string]: unknown;
25
- };
26
- [field: string]: unknown;
27
- }
28
- export interface KeyedStatusDigestOptions {
29
- /** Top-level keys that are never diffed (envelope identity: daemonId, …). */
30
- ignoreTopLevel?: readonly string[];
31
- /** `status` keys that are never diffed (in addition to `sessions`). */
32
- ignoreStatus?: readonly string[];
33
- /**
34
- * Session fields that change on every build without meaning anything
35
- * (a build-time `lastUpdated` stamp). A change to ONLY these fields is not
36
- * a change; when something else changed they ride along.
37
- */
38
- volatileSessionFields?: readonly string[];
39
- }
40
- interface SessionDigest {
41
- readonly value: KeyedStatusSession;
42
- /** Signature of every non-volatile field (fast unchanged check). */
43
- readonly sig: string;
44
- /** Per-field signatures, computed lazily — only for sessions that changed. */
45
- fieldSigs?: Map<string, string>;
46
- }
47
- export interface KeyedStatusDigest {
48
- readonly top: ReadonlyMap<string, string>;
49
- readonly topValues: Readonly<Record<string, unknown>>;
50
- readonly status: ReadonlyMap<string, string>;
51
- readonly statusValues: Readonly<Record<string, unknown>>;
52
- readonly sessions: ReadonlyMap<string, SessionDigest>;
53
- readonly order: readonly string[];
54
- readonly volatile: ReadonlySet<string>;
55
- }
56
- export interface KeyedStatusDelta {
57
- set?: Record<string, unknown>;
58
- unset?: string[];
59
- statusSet?: Record<string, unknown>;
60
- statusUnset?: string[];
61
- sessions?: KeyedStatusSession[];
62
- sessionUnset?: Record<string, string[]>;
63
- removedSessionIds?: string[];
64
- sessionOrder?: string[];
65
- }
66
- /** Digest one body. Cheap to hold: values are referenced, not copied. */
67
- export declare function digestKeyedStatus(body: KeyedStatusBody, options?: KeyedStatusDigestOptions): KeyedStatusDigest;
68
- /**
69
- * The difference `prev → next`, or `null` when nothing a consumer can observe
70
- * changed. `prev === null` is not accepted here — a subscription with no
71
- * delivered baseline gets a snapshot, not a delta.
72
- */
73
- export declare function diffKeyedStatus(prev: KeyedStatusDigest, next: KeyedStatusDigest): KeyedStatusDelta | null;
74
- /** Rebuild the full body from a held one plus a delta. Never mutates `held`. */
75
- export declare function foldKeyedStatus<T extends KeyedStatusBody>(held: T, delta: KeyedStatusDelta): T;
76
- export {};
@@ -1,239 +0,0 @@
1
- /**
2
- * Keyed status delta — the diff/fold pair behind the `daemon.metadata` lane
3
- * (the ONE daemon→dashboard state lane; data-path audit 2026-09-29 P0-3).
4
- *
5
- * The daemon digests every body it would have sent, diffs it against the
6
- * digest of the last body DELIVERED to a subscription, and sends only the
7
- * difference: changed top-level fields, changed daemon-level `status` fields,
8
- * and per-session changed fields keyed by session id, with removals explicit.
9
- * An unchanged state diffs to `null` — zero bytes on the wire. The dashboard
10
- * folds each delta into its held snapshot; the fold of every delta since a
11
- * snapshot equals the latest snapshot (pinned by keyed-status-delta.test.ts).
12
- *
13
- * Pure leaf: plain objects in, plain objects out; no transport, no clock.
14
- */
15
-
16
- /** A session row: identified by `id`, everything else is a field. */
17
- export interface KeyedStatusSession {
18
- id: string
19
- [field: string]: unknown
20
- }
21
-
22
- /** The snapshot body being diffed: top-level fields plus a `status` with a keyed session list. */
23
- export interface KeyedStatusBody {
24
- status: { sessions: KeyedStatusSession[]; [field: string]: unknown }
25
- [field: string]: unknown
26
- }
27
-
28
- export interface KeyedStatusDigestOptions {
29
- /** Top-level keys that are never diffed (envelope identity: daemonId, …). */
30
- ignoreTopLevel?: readonly string[]
31
- /** `status` keys that are never diffed (in addition to `sessions`). */
32
- ignoreStatus?: readonly string[]
33
- /**
34
- * Session fields that change on every build without meaning anything
35
- * (a build-time `lastUpdated` stamp). A change to ONLY these fields is not
36
- * a change; when something else changed they ride along.
37
- */
38
- volatileSessionFields?: readonly string[]
39
- }
40
-
41
- interface SessionDigest {
42
- readonly value: KeyedStatusSession
43
- /** Signature of every non-volatile field (fast unchanged check). */
44
- readonly sig: string
45
- /** Per-field signatures, computed lazily — only for sessions that changed. */
46
- fieldSigs?: Map<string, string>
47
- }
48
-
49
- export interface KeyedStatusDigest {
50
- readonly top: ReadonlyMap<string, string>
51
- readonly topValues: Readonly<Record<string, unknown>>
52
- readonly status: ReadonlyMap<string, string>
53
- readonly statusValues: Readonly<Record<string, unknown>>
54
- readonly sessions: ReadonlyMap<string, SessionDigest>
55
- readonly order: readonly string[]
56
- readonly volatile: ReadonlySet<string>
57
- }
58
-
59
- export interface KeyedStatusDelta {
60
- set?: Record<string, unknown>
61
- unset?: string[]
62
- statusSet?: Record<string, unknown>
63
- statusUnset?: string[]
64
- sessions?: KeyedStatusSession[]
65
- sessionUnset?: Record<string, string[]>
66
- removedSessionIds?: string[]
67
- sessionOrder?: string[]
68
- }
69
-
70
- function sig(value: unknown): string | undefined {
71
- if (value === undefined) return undefined
72
- try {
73
- return JSON.stringify(value)
74
- } catch {
75
- return undefined
76
- }
77
- }
78
-
79
- function digestFields(
80
- source: Record<string, unknown>,
81
- ignore: ReadonlySet<string>,
82
- ): { sigs: Map<string, string>; values: Record<string, unknown> } {
83
- const sigs = new Map<string, string>()
84
- const values: Record<string, unknown> = {}
85
- for (const [key, value] of Object.entries(source)) {
86
- if (ignore.has(key)) continue
87
- const s = sig(value)
88
- if (s === undefined) continue
89
- sigs.set(key, s)
90
- values[key] = value
91
- }
92
- return { sigs, values }
93
- }
94
-
95
- function sessionSignature(session: KeyedStatusSession, volatile: ReadonlySet<string>): string {
96
- if (volatile.size === 0) return sig(session) ?? ''
97
- const stable: Record<string, unknown> = {}
98
- for (const [key, value] of Object.entries(session)) {
99
- if (!volatile.has(key)) stable[key] = value
100
- }
101
- return sig(stable) ?? ''
102
- }
103
-
104
- function fieldSigsOf(digest: SessionDigest): Map<string, string> {
105
- if (!digest.fieldSigs) {
106
- const sigs = new Map<string, string>()
107
- for (const [key, value] of Object.entries(digest.value)) {
108
- const s = sig(value)
109
- if (s !== undefined) sigs.set(key, s)
110
- }
111
- digest.fieldSigs = sigs
112
- }
113
- return digest.fieldSigs
114
- }
115
-
116
- /** Digest one body. Cheap to hold: values are referenced, not copied. */
117
- export function digestKeyedStatus(body: KeyedStatusBody, options: KeyedStatusDigestOptions = {}): KeyedStatusDigest {
118
- const volatile = new Set(options.volatileSessionFields ?? [])
119
- const top = digestFields(body as Record<string, unknown>, new Set(['status', ...(options.ignoreTopLevel ?? [])]))
120
- const status = digestFields(body.status, new Set(['sessions', ...(options.ignoreStatus ?? [])]))
121
- const sessions = new Map<string, SessionDigest>()
122
- const order: string[] = []
123
- for (const session of Array.isArray(body.status?.sessions) ? body.status.sessions : []) {
124
- if (!session || typeof session.id !== 'string' || !session.id) continue
125
- if (sessions.has(session.id)) continue
126
- sessions.set(session.id, { value: session, sig: sessionSignature(session, volatile) })
127
- order.push(session.id)
128
- }
129
- return { top: top.sigs, topValues: top.values, status: status.sigs, statusValues: status.values, sessions, order, volatile }
130
- }
131
-
132
- function diffFieldMaps(
133
- prev: ReadonlyMap<string, string>,
134
- next: ReadonlyMap<string, string>,
135
- nextValues: Readonly<Record<string, unknown>>,
136
- ): { set?: Record<string, unknown>; unset?: string[] } {
137
- let set: Record<string, unknown> | undefined
138
- let unset: string[] | undefined
139
- for (const [key, s] of next) {
140
- if (prev.get(key) === s) continue
141
- ;(set ??= {})[key] = nextValues[key]
142
- }
143
- for (const key of prev.keys()) {
144
- if (!next.has(key)) (unset ??= []).push(key)
145
- }
146
- return { ...(set ? { set } : {}), ...(unset ? { unset } : {}) }
147
- }
148
-
149
- function sameOrder(a: readonly string[], b: readonly string[]): boolean {
150
- if (a.length !== b.length) return false
151
- for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false
152
- return true
153
- }
154
-
155
- /**
156
- * The difference `prev → next`, or `null` when nothing a consumer can observe
157
- * changed. `prev === null` is not accepted here — a subscription with no
158
- * delivered baseline gets a snapshot, not a delta.
159
- */
160
- export function diffKeyedStatus(prev: KeyedStatusDigest, next: KeyedStatusDigest): KeyedStatusDelta | null {
161
- const delta: KeyedStatusDelta = {}
162
- const top = diffFieldMaps(prev.top, next.top, next.topValues)
163
- if (top.set) delta.set = top.set
164
- if (top.unset) delta.unset = top.unset
165
- const status = diffFieldMaps(prev.status, next.status, next.statusValues)
166
- if (status.set) delta.statusSet = status.set
167
- if (status.unset) delta.statusUnset = status.unset
168
-
169
- for (const [id, nextSession] of next.sessions) {
170
- const prevSession = prev.sessions.get(id)
171
- if (!prevSession) {
172
- ;(delta.sessions ??= []).push(nextSession.value)
173
- continue
174
- }
175
- if (prevSession.sig === nextSession.sig) continue
176
- const prevSigs = fieldSigsOf(prevSession)
177
- const nextSigs = fieldSigsOf(nextSession)
178
- const change: KeyedStatusSession = { id }
179
- let changed = false
180
- for (const [key, s] of nextSigs) {
181
- if (key === 'id' || prevSigs.get(key) === s) continue
182
- change[key] = nextSession.value[key]
183
- changed = true
184
- }
185
- const gone: string[] = []
186
- for (const key of prevSigs.keys()) {
187
- if (!nextSigs.has(key)) gone.push(key)
188
- }
189
- if (changed) (delta.sessions ??= []).push(change)
190
- if (gone.length > 0) (delta.sessionUnset ??= {})[id] = gone
191
- }
192
- for (const id of prev.sessions.keys()) {
193
- if (!next.sessions.has(id)) (delta.removedSessionIds ??= []).push(id)
194
- }
195
- if (!sameOrder(prev.order, next.order)) delta.sessionOrder = [...next.order]
196
-
197
- return Object.keys(delta).length > 0 ? delta : null
198
- }
199
-
200
- /** Rebuild the full body from a held one plus a delta. Never mutates `held`. */
201
- export function foldKeyedStatus<T extends KeyedStatusBody>(held: T, delta: KeyedStatusDelta): T {
202
- const next: Record<string, unknown> = { ...held }
203
- for (const key of delta.unset ?? []) delete next[key]
204
- Object.assign(next, delta.set ?? {})
205
-
206
- const status: Record<string, unknown> = { ...held.status }
207
- for (const key of delta.statusUnset ?? []) delete status[key]
208
- Object.assign(status, delta.statusSet ?? {})
209
-
210
- const byId = new Map<string, KeyedStatusSession>()
211
- for (const session of held.status.sessions ?? []) byId.set(session.id, session)
212
- for (const id of delta.removedSessionIds ?? []) byId.delete(id)
213
- for (const change of delta.sessions ?? []) {
214
- const base = byId.get(change.id)
215
- byId.set(change.id, base ? { ...base, ...change } : { ...change })
216
- }
217
- for (const [id, keys] of Object.entries(delta.sessionUnset ?? {})) {
218
- const base = byId.get(id)
219
- if (!base) continue
220
- const trimmed = { ...base }
221
- for (const key of keys) delete trimmed[key]
222
- byId.set(id, trimmed)
223
- }
224
- const order = delta.sessionOrder ?? (held.status.sessions ?? []).map((session) => session.id)
225
- const sessions: KeyedStatusSession[] = []
226
- const placed = new Set<string>()
227
- for (const id of order) {
228
- const session = byId.get(id)
229
- if (!session || placed.has(id)) continue
230
- sessions.push(session)
231
- placed.add(id)
232
- }
233
- for (const [id, session] of byId) {
234
- if (!placed.has(id)) sessions.push(session)
235
- }
236
- status.sessions = sessions
237
- next.status = status
238
- return next as T
239
- }