@adhdev/mesh-shared 1.0.57 → 1.0.58-rc.10

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/magi.d.ts CHANGED
@@ -24,6 +24,38 @@
24
24
  * launch; `capabilityTags` route by tag when no nodeId is given; `n` is an optional
25
25
  * per-slot replica count. This is the SOLE panel-member shape — the fan-out planner
26
26
  * (buildMagiFanoutPlan) resolves a `MagiSlot[]` directly.
27
+ *
28
+ * ─── DELIBERATELY REDUCED SCHEMA — not a missing feature ─────────────────────
29
+ *
30
+ * A MagiSlot is a strict subset of a node's `NodeCapabilitySlot`. It accepts
31
+ * `provider` + optional `model` / `nodeId` / `capabilityTags` / `n`, and it
32
+ * deliberately does NOT accept the node-capability routing axes — `thinkingLevel`,
33
+ * `difficulty`, `maxParallel`. That asymmetry is the design, not an oversight:
34
+ *
35
+ * - A node capability slot answers **"which work goes where"** — routing FITNESS.
36
+ * Difficulty and thinking level exist there to match a task against the slot best
37
+ * suited to run it.
38
+ * - A MAGI panel slot answers **"who answers independently"** — cross-verification
39
+ * DIVERSITY. Its whole value is that the replicas are NOT selected for fitness.
40
+ *
41
+ * Reviving the difficulty/thinking axes here would couple panel membership to routing
42
+ * optimization, and the best-fitting provider would win every slot. The panel would
43
+ * collapse toward one provider — which is precisely the failure MAGI exists to
44
+ * prevent, since agreement among coupled agents carries no information (see the
45
+ * `source_coupled` weighting in synthesis). A reduced schema is what keeps the two
46
+ * axes orthogonal.
47
+ *
48
+ * Consistent with that, `mesh_magi_review` enqueues every replica with a fixed
49
+ * `difficulty: 'freeform'` sentinel rather than a caller-chosen grade — the panel has
50
+ * already pinned the (node, provider) target, so any difficulty would be inert at best
51
+ * and would fight the panel's own slot selection at worst. To change how hard a
52
+ * replica thinks or how much parallelism a node grants, edit that NODE's capability
53
+ * slots (`mesh_node_slots_set`); it is a different axis, on purpose.
54
+ *
55
+ * Unknown keys are dropped rather than rejected, so slots written by another version
56
+ * stay readable. Write paths pair the normalizer with
57
+ * `collectIgnoredMagiSlotFields()` and report the drops as `ignoredFields`, so the
58
+ * reduction is visible instead of silent.
27
59
  */
28
60
  export interface MagiSlot {
29
61
  /** Optional — pin to a specific mesh node id. Absent → route by capabilityTags + provider. */
@@ -18,7 +18,7 @@
18
18
  * Order mirrors mcp-server `ALL_MESH_TOOLS` for easy visual diffing, but the consistency
19
19
  * checks are set-based (order-insensitive).
20
20
  */
21
- export declare const CANONICAL_MESH_TOOL_NAMES: readonly ["mesh_status", "mesh_list_nodes", "mesh_enqueue_batch", "mesh_enqueue_task", "mesh_view_queue", "mesh_graph_view", "mesh_graph_gate_claim", "mesh_graph_gate_release", "mesh_graph_gate_abandon", "mesh_queue_cancel", "mesh_queue_requeue", "mesh_send_task", "mesh_read_chat", "mesh_read_debug", "mesh_read_terminal", "mesh_send_keys", "mesh_launch_session", "mesh_git_status", "mesh_read_node_logs", "mesh_fast_forward_node", "mesh_restart_daemon", "mesh_checkpoint", "mesh_approve", "mesh_answer_question", "mesh_list_pending_approvals", "mesh_plan_onboarding", "mesh_create", "mesh_add_node", "mesh_clone_node", "mesh_remove_node", "mesh_cleanup_worktree_nodes", "mesh_refine_node", "mesh_refine_batch", "mesh_refine_config", "mesh_change_impact_config", "mesh_init", "mesh_reinit", "mesh_write_mesh_json_config", "mesh_refine_plan", "mesh_cleanup_sessions", "mesh_prune_stale_direct", "mesh_task_history", "mesh_ledger_query", "mesh_record_note", "mesh_forget_note", "mesh_reconcile_ledger", "mesh_requeue_held_events", "mesh_mission_upsert", "mesh_mission_list", "mesh_review_inbox", "mesh_magi_review", "mesh_magi_collect", "mesh_magi_kind_panel_set", "mesh_magi_kind_panel_list", "mesh_node_slots_set", "mesh_node_slots_list", "mesh_node_slots_propose", "mesh_coordinator_prompt_append_get", "mesh_coordinator_prompt_append_set"];
21
+ export declare const CANONICAL_MESH_TOOL_NAMES: readonly ["mesh_status", "mesh_route_preview", "mesh_list_nodes", "mesh_enqueue_batch", "mesh_enqueue_task", "mesh_view_queue", "mesh_graph_view", "mesh_graph_gate_claim", "mesh_graph_gate_release", "mesh_graph_gate_abandon", "mesh_queue_cancel", "mesh_queue_requeue", "mesh_send_task", "mesh_read_chat", "mesh_read_debug", "mesh_read_terminal", "mesh_send_keys", "mesh_launch_session", "mesh_git_status", "mesh_read_node_logs", "mesh_fast_forward_node", "mesh_restart_daemon", "mesh_checkpoint", "mesh_approve", "mesh_answer_question", "mesh_list_pending_approvals", "mesh_plan_onboarding", "mesh_create", "mesh_add_node", "mesh_clone_node", "mesh_remove_node", "mesh_cleanup_worktree_nodes", "mesh_refine_node", "mesh_refine_batch", "mesh_refine_config", "mesh_change_impact_config", "mesh_init", "mesh_reinit", "mesh_write_mesh_json_config", "mesh_refine_plan", "mesh_cleanup_sessions", "mesh_prune_stale_direct", "mesh_task_history", "mesh_ledger_query", "mesh_record_note", "mesh_forget_note", "mesh_reconcile_ledger", "mesh_requeue_held_events", "mesh_mission_upsert", "mesh_mission_list", "mesh_review_inbox", "mesh_magi_review", "mesh_magi_collect", "mesh_magi_kind_panel_set", "mesh_magi_kind_panel_list", "mesh_node_slots_set", "mesh_node_slots_list", "mesh_node_slots_propose", "mesh_coordinator_prompt_append_get", "mesh_coordinator_prompt_append_set"];
22
22
  export type CanonicalMeshToolName = typeof CANONICAL_MESH_TOOL_NAMES[number];
23
23
  /** The count the `NN tools` barrel doc comments and consistency test assert against. */
24
- export declare const CANONICAL_MESH_TOOL_COUNT: 59;
24
+ export declare const CANONICAL_MESH_TOOL_COUNT: 60;
@@ -51,6 +51,25 @@ export interface MeshNodeFactsProviderQuota {
51
51
  status: string;
52
52
  session: MeshNodeFactsQuotaWindow | null;
53
53
  weekly: MeshNodeFactsQuotaWindow | null;
54
+ /** 30-day billing-style window, only for providers that report one
55
+ * (cursor-cli). Promoted from the index signature 2026-08-24 so readers
56
+ * can render it typed — an 'ok' cursor snapshot often carries ONLY this
57
+ * axis, and a reader that looks at session/weekly alone misreads a
58
+ * healthy reading as a failure. */
59
+ monthly?: MeshNodeFactsQuotaWindow | null;
60
+ /** Per-pool quota buckets, only for providers whose plan has more than one
61
+ * pool (antigravity-cli: Gemini vs Claude/GPT groups, each with a 5h and
62
+ * a weekly bucket). `session`/`weekly` above collapse these to the WORST
63
+ * bucket per window (the routing-safe headline); the buckets carry the
64
+ * per-pool detail a reader should surface. Promoted from the index
65
+ * signature 2026-08-24, same reasoning as `monthly`. */
66
+ buckets?: Array<{
67
+ name?: string;
68
+ usedPercent?: number;
69
+ windowMinutes?: number;
70
+ resetsAt?: number | null;
71
+ [extra: string]: unknown;
72
+ }> | null;
54
73
  /** Unix ms of the snapshot itself — older than the bundle's reportedAt. */
55
74
  updatedAt: number;
56
75
  error: string | null;
@@ -136,7 +155,7 @@ export interface MeshNodeFacts {
136
155
  machineNickname?: string;
137
156
  /**
138
157
  * Per-provider quota snapshots, keyed by QuotaProvider id ('claude-cli',
139
- * 'codex-cli', 'kimi'). Consumed by ROUTING as well as observation: the
158
+ * 'codex-cli', 'cursor-cli', 'kimi'). Consumed by ROUTING as well as observation: the
140
159
  * coordinator's quota gate / spread bonus (daemon-core mesh-quota-routing.ts,
141
160
  * thresholds in RepoMeshPolicy.quotaRouting) reads exactly this shape, so
142
161
  * field renames here are a routing-contract change, not a cosmetic one.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Semver precedence comparison — the single version-ordering primitive for the
3
+ * daemon's upgrade paths.
4
+ *
5
+ * Why this exists: the upgrade path used to decide "is this a no-op?" with a
6
+ * raw string equality check (`currentInstalled === latest`) and nothing else.
7
+ * Equality answers "is it the same version?" but never "which one is newer?",
8
+ * so any target that merely differed from the running build was installed —
9
+ * including one that is OLDER. That is how a node running `1.0.49-rc.2`
10
+ * silently got rolled back to `1.0.48`.
11
+ *
12
+ * String comparison cannot fix it either: `'1.0.49-rc.2' < '1.0.48'` is TRUE
13
+ * lexicographically (the '4' in "-rc.2"'s prefix never gets that far — '9' vs
14
+ * '8' at index 4 decides it, and even where it works `rc.10` sorts below
15
+ * `rc.9`). Only field-wise numeric comparison with semver §11 prerelease rules
16
+ * gives the right answer.
17
+ *
18
+ * This module is a PURE LEAF: zero imports, no I/O. It lives in mesh-shared
19
+ * (fragmentation audit) so BOTH the OSS daemon and the proprietary Workers
20
+ * server (`packages/server/src/utils/version-policy.ts`, previously a
21
+ * byte-identical independent copy) can consume ONE implementation — the two
22
+ * gate the same auto-update decision from opposite ends, and a drift between
23
+ * them can produce an unbreakable update-nag loop (server says behind, daemon
24
+ * gate refuses the "upgrade" as a rollback). daemon-core re-exports this from
25
+ * its original `version-compare.ts` path, so existing imports keep working.
26
+ *
27
+ * NOTE ON PRERELEASE SEMANTICS: this is STRICT semver §11 — `1.0.49-rc.2` is
28
+ * BELOW `1.0.49`, because a prerelease precedes its own release. That is the
29
+ * correct rule for "would installing this move me backwards?", which is the
30
+ * only question this module is used to answer. It deliberately differs from
31
+ * `oss/packages/web-core/src/utils/version-update.ts`, whose
32
+ * `isDaemonBehindTarget` treats an rc as up-to-date against its own base
33
+ * release so the dashboard does not nag preview users with an update banner.
34
+ * Those are two different questions; do not collapse them into one helper.
35
+ */
36
+ /** Parsed semver fields. Build metadata (`+…`) is discarded: it never affects precedence (§10). */
37
+ export interface ParsedSemver {
38
+ readonly major: number;
39
+ readonly minor: number;
40
+ readonly patch: number;
41
+ /** Dot-separated prerelease identifiers; empty for a release build. */
42
+ readonly prerelease: readonly string[];
43
+ }
44
+ /**
45
+ * Parse a semver string, tolerating a leading `v` (npm/CLI output carries it
46
+ * inconsistently). Returns null for anything unparsable so callers can fail
47
+ * closed rather than guess.
48
+ */
49
+ export declare function parseSemver(version: unknown): ParsedSemver | null;
50
+ /**
51
+ * Compare two versions by semver precedence.
52
+ *
53
+ * @returns -1 when `a` precedes `b`, 0 when equal, 1 when `a` succeeds `b`, and
54
+ * **null when either side is unparsable** — callers MUST treat null as
55
+ * "direction unknown" and fail closed rather than coercing it to a number
56
+ * (`null` compares as `0` in JS numeric contexts, which would read as "equal").
57
+ */
58
+ export declare function compareSemver(a: unknown, b: unknown): number | null;
59
+ /**
60
+ * Would installing `target` move a daemon currently on `current` BACKWARDS?
61
+ *
62
+ * Returns false when the direction cannot be established (either version
63
+ * unparsable) — an unknown direction must never block an upgrade, because the
64
+ * cost of a false block (the whole fleet can no longer be upgraded) is far
65
+ * higher than the cost of a missed downgrade guard. Equal versions are NOT a
66
+ * downgrade, so a same-version reinstall stays allowed.
67
+ */
68
+ export declare function isDowngrade(current: unknown, target: unknown): boolean;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * ws-protocol — shared string-literal unions for the daemon↔server WS surface
3
+ * and the daemon↔dashboard P2P DataChannel surface.
4
+ *
5
+ * Fragmentation audit: these message types existed as a TypeScript union in
6
+ * exactly ONE package (the proprietary daemon-cloud's server-connection.ts),
7
+ * which is a leaf CONSUMER — the other two participants (the Workers server
8
+ * and OSS daemon-core, which is the primary `status_report` producer) matched
9
+ * bare string literals by hand. Renaming `auth_ok` server-side would compile
10
+ * everywhere and leave the daemon reconnecting forever. Pure literals, zero
11
+ * runtime deps — the textbook mesh-shared leaf.
12
+ *
13
+ * SCOPE HONESTY: this file declares the OSS-visible protocol surface. The
14
+ * proprietary repo's server-connection.ts remains the authority for the full
15
+ * ServerToDaemon command set; it should adopt these unions and extend them
16
+ * (`ServerToDaemonMsg | <proprietary extras>`) rather than re-declaring the
17
+ * shared members. Members here are the ones OSS daemon-core itself produces
18
+ * or matches.
19
+ */
20
+ /** Messages the daemon sends UP to the Workers server over the WS bridge. */
21
+ export type DaemonToServerWsMsg = 'auth' | 'status_report' | 'status_heartbeat' | 'status_event' | 'command_result' | 'error' | 'agent_event' | 'log';
22
+ /** Server→daemon control messages the OSS engine reacts to. */
23
+ export type ServerToDaemonWsMsg = 'auth_ok' | 'auth_error' | 'machine_evicted' | 'force_disconnect' | 'token_revoked' | 'version_mismatch' | 'force_update_required' | 'command' | 'agent_command' | 'resolve_action';
24
+ /** P2P signaling relayed through the server WS. */
25
+ export type P2PSignalingWsMsg = 'p2p_ready' | 'offer' | 'answer' | 'ice' | 'mesh_p2p_ready' | 'mesh_p2p_offer' | 'mesh_p2p_answer' | 'mesh_p2p_ice';
26
+ /**
27
+ * Dashboard↔daemon P2P DataChannel JSON message kinds. Previously matched as
28
+ * hand-synced literals on both ends with NO shared symbol anywhere —
29
+ * `p2p_evicted` had exactly two occurrences repo-wide (emit + handle).
30
+ */
31
+ export type DashboardP2PMessageKind = 'ping' | 'pong' | 'status_report' | 'status_event' | 'p2p_evicted' | 'command' | 'command_result' | 'command_result_chunk' | 'screenshot_start' | 'screenshot_stop' | 'pty_input' | 'pty_resize';
32
+ export declare const DAEMON_TO_SERVER_WS_MSGS: readonly DaemonToServerWsMsg[];
33
+ export declare const SERVER_TO_DAEMON_WS_MSGS: readonly ServerToDaemonWsMsg[];
34
+ export declare function isDaemonToServerWsMsg(value: unknown): value is DaemonToServerWsMsg;
35
+ export declare function isServerToDaemonWsMsg(value: unknown): value is ServerToDaemonWsMsg;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/mesh-shared",
3
- "version": "1.0.57",
3
+ "version": "1.0.58-rc.10",
4
4
  "description": "ADHDev mesh-shared — pure mesh/git status normalizers shared by daemon-core and web-core",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -26,3 +26,5 @@ export * from './interpolation'
26
26
  export * from './mesh-tool-names'
27
27
  export * from './mesh-status-probe'
28
28
  export * from './rpc-chunking'
29
+ export * from './semver-compare'
30
+ export * from './ws-protocol'
package/src/magi.ts CHANGED
@@ -33,6 +33,38 @@
33
33
  * launch; `capabilityTags` route by tag when no nodeId is given; `n` is an optional
34
34
  * per-slot replica count. This is the SOLE panel-member shape — the fan-out planner
35
35
  * (buildMagiFanoutPlan) resolves a `MagiSlot[]` directly.
36
+ *
37
+ * ─── DELIBERATELY REDUCED SCHEMA — not a missing feature ─────────────────────
38
+ *
39
+ * A MagiSlot is a strict subset of a node's `NodeCapabilitySlot`. It accepts
40
+ * `provider` + optional `model` / `nodeId` / `capabilityTags` / `n`, and it
41
+ * deliberately does NOT accept the node-capability routing axes — `thinkingLevel`,
42
+ * `difficulty`, `maxParallel`. That asymmetry is the design, not an oversight:
43
+ *
44
+ * - A node capability slot answers **"which work goes where"** — routing FITNESS.
45
+ * Difficulty and thinking level exist there to match a task against the slot best
46
+ * suited to run it.
47
+ * - A MAGI panel slot answers **"who answers independently"** — cross-verification
48
+ * DIVERSITY. Its whole value is that the replicas are NOT selected for fitness.
49
+ *
50
+ * Reviving the difficulty/thinking axes here would couple panel membership to routing
51
+ * optimization, and the best-fitting provider would win every slot. The panel would
52
+ * collapse toward one provider — which is precisely the failure MAGI exists to
53
+ * prevent, since agreement among coupled agents carries no information (see the
54
+ * `source_coupled` weighting in synthesis). A reduced schema is what keeps the two
55
+ * axes orthogonal.
56
+ *
57
+ * Consistent with that, `mesh_magi_review` enqueues every replica with a fixed
58
+ * `difficulty: 'freeform'` sentinel rather than a caller-chosen grade — the panel has
59
+ * already pinned the (node, provider) target, so any difficulty would be inert at best
60
+ * and would fight the panel's own slot selection at worst. To change how hard a
61
+ * replica thinks or how much parallelism a node grants, edit that NODE's capability
62
+ * slots (`mesh_node_slots_set`); it is a different axis, on purpose.
63
+ *
64
+ * Unknown keys are dropped rather than rejected, so slots written by another version
65
+ * stay readable. Write paths pair the normalizer with
66
+ * `collectIgnoredMagiSlotFields()` and report the drops as `ignoredFields`, so the
67
+ * reduction is visible instead of silent.
36
68
  */
37
69
  export interface MagiSlot {
38
70
  /** Optional — pin to a specific mesh node id. Absent → route by capabilityTags + provider. */
@@ -20,6 +20,7 @@
20
20
  */
21
21
  export const CANONICAL_MESH_TOOL_NAMES = [
22
22
  'mesh_status',
23
+ 'mesh_route_preview',
23
24
  'mesh_list_nodes',
24
25
  // GRAPH-ORCHESTRATION Phase F — batch before task, mirroring ALL_MESH_TOOLS.
25
26
  'mesh_enqueue_batch',
package/src/node-facts.ts CHANGED
@@ -54,6 +54,25 @@ export interface MeshNodeFactsProviderQuota {
54
54
  status: string
55
55
  session: MeshNodeFactsQuotaWindow | null
56
56
  weekly: MeshNodeFactsQuotaWindow | null
57
+ /** 30-day billing-style window, only for providers that report one
58
+ * (cursor-cli). Promoted from the index signature 2026-08-24 so readers
59
+ * can render it typed — an 'ok' cursor snapshot often carries ONLY this
60
+ * axis, and a reader that looks at session/weekly alone misreads a
61
+ * healthy reading as a failure. */
62
+ monthly?: MeshNodeFactsQuotaWindow | null
63
+ /** Per-pool quota buckets, only for providers whose plan has more than one
64
+ * pool (antigravity-cli: Gemini vs Claude/GPT groups, each with a 5h and
65
+ * a weekly bucket). `session`/`weekly` above collapse these to the WORST
66
+ * bucket per window (the routing-safe headline); the buckets carry the
67
+ * per-pool detail a reader should surface. Promoted from the index
68
+ * signature 2026-08-24, same reasoning as `monthly`. */
69
+ buckets?: Array<{
70
+ name?: string
71
+ usedPercent?: number
72
+ windowMinutes?: number
73
+ resetsAt?: number | null
74
+ [extra: string]: unknown
75
+ }> | null
57
76
  /** Unix ms of the snapshot itself — older than the bundle's reportedAt. */
58
77
  updatedAt: number
59
78
  error: string | null
@@ -141,7 +160,7 @@ export interface MeshNodeFacts {
141
160
  machineNickname?: string
142
161
  /**
143
162
  * Per-provider quota snapshots, keyed by QuotaProvider id ('claude-cli',
144
- * 'codex-cli', 'kimi'). Consumed by ROUTING as well as observation: the
163
+ * 'codex-cli', 'cursor-cli', 'kimi'). Consumed by ROUTING as well as observation: the
145
164
  * coordinator's quota gate / spread bonus (daemon-core mesh-quota-routing.ts,
146
165
  * thresholds in RepoMeshPolicy.quotaRouting) reads exactly this shape, so
147
166
  * field renames here are a routing-contract change, not a cosmetic one.
@@ -246,6 +265,7 @@ export const QUOTA_SUPPORTED_PROVIDERS: readonly string[] = [
246
265
  'antigravity-cli',
247
266
  'claude-cli',
248
267
  'codex-cli',
268
+ 'cursor-cli',
249
269
  'grok-cli',
250
270
  'kimi',
251
271
  'opencode',
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Semver precedence comparison — the single version-ordering primitive for the
3
+ * daemon's upgrade paths.
4
+ *
5
+ * Why this exists: the upgrade path used to decide "is this a no-op?" with a
6
+ * raw string equality check (`currentInstalled === latest`) and nothing else.
7
+ * Equality answers "is it the same version?" but never "which one is newer?",
8
+ * so any target that merely differed from the running build was installed —
9
+ * including one that is OLDER. That is how a node running `1.0.49-rc.2`
10
+ * silently got rolled back to `1.0.48`.
11
+ *
12
+ * String comparison cannot fix it either: `'1.0.49-rc.2' < '1.0.48'` is TRUE
13
+ * lexicographically (the '4' in "-rc.2"'s prefix never gets that far — '9' vs
14
+ * '8' at index 4 decides it, and even where it works `rc.10` sorts below
15
+ * `rc.9`). Only field-wise numeric comparison with semver §11 prerelease rules
16
+ * gives the right answer.
17
+ *
18
+ * This module is a PURE LEAF: zero imports, no I/O. It lives in mesh-shared
19
+ * (fragmentation audit) so BOTH the OSS daemon and the proprietary Workers
20
+ * server (`packages/server/src/utils/version-policy.ts`, previously a
21
+ * byte-identical independent copy) can consume ONE implementation — the two
22
+ * gate the same auto-update decision from opposite ends, and a drift between
23
+ * them can produce an unbreakable update-nag loop (server says behind, daemon
24
+ * gate refuses the "upgrade" as a rollback). daemon-core re-exports this from
25
+ * its original `version-compare.ts` path, so existing imports keep working.
26
+ *
27
+ * NOTE ON PRERELEASE SEMANTICS: this is STRICT semver §11 — `1.0.49-rc.2` is
28
+ * BELOW `1.0.49`, because a prerelease precedes its own release. That is the
29
+ * correct rule for "would installing this move me backwards?", which is the
30
+ * only question this module is used to answer. It deliberately differs from
31
+ * `oss/packages/web-core/src/utils/version-update.ts`, whose
32
+ * `isDaemonBehindTarget` treats an rc as up-to-date against its own base
33
+ * release so the dashboard does not nag preview users with an update banner.
34
+ * Those are two different questions; do not collapse them into one helper.
35
+ */
36
+
37
+ /** Parsed semver fields. Build metadata (`+…`) is discarded: it never affects precedence (§10). */
38
+ export interface ParsedSemver {
39
+ readonly major: number;
40
+ readonly minor: number;
41
+ readonly patch: number;
42
+ /** Dot-separated prerelease identifiers; empty for a release build. */
43
+ readonly prerelease: readonly string[];
44
+ }
45
+
46
+ const SEMVER_PATTERN = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
47
+ const NUMERIC_IDENTIFIER = /^\d+$/;
48
+
49
+ /**
50
+ * Parse a semver string, tolerating a leading `v` (npm/CLI output carries it
51
+ * inconsistently). Returns null for anything unparsable so callers can fail
52
+ * closed rather than guess.
53
+ */
54
+ export function parseSemver(version: unknown): ParsedSemver | null {
55
+ if (typeof version !== 'string') return null;
56
+ const match = version.trim().replace(/^v/, '').match(SEMVER_PATTERN);
57
+ if (!match) return null;
58
+ return {
59
+ major: Number(match[1]),
60
+ minor: Number(match[2]),
61
+ patch: Number(match[3]),
62
+ prerelease: match[4] ? match[4].split('.') : [],
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Semver §11 prerelease precedence: a release outranks its own prereleases;
68
+ * numeric identifiers compare numerically (so rc.10 > rc.9) and rank below
69
+ * alphanumeric ones; alphanumerics compare lexically; a shorter identifier
70
+ * list ranks below an otherwise-equal longer one.
71
+ */
72
+ function comparePrerelease(a: readonly string[], b: readonly string[]): number {
73
+ if (a.length === 0 && b.length === 0) return 0;
74
+ // An empty prerelease list means "release build", which outranks any prerelease.
75
+ if (a.length === 0) return 1;
76
+ if (b.length === 0) return -1;
77
+ for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
78
+ const idA = a[i];
79
+ const idB = b[i];
80
+ if (idA === idB) continue;
81
+ const numA = NUMERIC_IDENTIFIER.test(idA);
82
+ const numB = NUMERIC_IDENTIFIER.test(idB);
83
+ if (numA && numB) return Number(idA) < Number(idB) ? -1 : 1;
84
+ if (numA) return -1;
85
+ if (numB) return 1;
86
+ return idA < idB ? -1 : 1;
87
+ }
88
+ return a.length === b.length ? 0 : (a.length < b.length ? -1 : 1);
89
+ }
90
+
91
+ /**
92
+ * Compare two versions by semver precedence.
93
+ *
94
+ * @returns -1 when `a` precedes `b`, 0 when equal, 1 when `a` succeeds `b`, and
95
+ * **null when either side is unparsable** — callers MUST treat null as
96
+ * "direction unknown" and fail closed rather than coercing it to a number
97
+ * (`null` compares as `0` in JS numeric contexts, which would read as "equal").
98
+ */
99
+ export function compareSemver(a: unknown, b: unknown): number | null {
100
+ const pa = parseSemver(a);
101
+ const pb = parseSemver(b);
102
+ if (!pa || !pb) return null;
103
+ if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1;
104
+ if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1;
105
+ if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1;
106
+ return comparePrerelease(pa.prerelease, pb.prerelease);
107
+ }
108
+
109
+ /**
110
+ * Would installing `target` move a daemon currently on `current` BACKWARDS?
111
+ *
112
+ * Returns false when the direction cannot be established (either version
113
+ * unparsable) — an unknown direction must never block an upgrade, because the
114
+ * cost of a false block (the whole fleet can no longer be upgraded) is far
115
+ * higher than the cost of a missed downgrade guard. Equal versions are NOT a
116
+ * downgrade, so a same-version reinstall stays allowed.
117
+ */
118
+ export function isDowngrade(current: unknown, target: unknown): boolean {
119
+ const direction = compareSemver(target, current);
120
+ if (direction === null) return false;
121
+ return direction < 0;
122
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * ws-protocol — shared string-literal unions for the daemon↔server WS surface
3
+ * and the daemon↔dashboard P2P DataChannel surface.
4
+ *
5
+ * Fragmentation audit: these message types existed as a TypeScript union in
6
+ * exactly ONE package (the proprietary daemon-cloud's server-connection.ts),
7
+ * which is a leaf CONSUMER — the other two participants (the Workers server
8
+ * and OSS daemon-core, which is the primary `status_report` producer) matched
9
+ * bare string literals by hand. Renaming `auth_ok` server-side would compile
10
+ * everywhere and leave the daemon reconnecting forever. Pure literals, zero
11
+ * runtime deps — the textbook mesh-shared leaf.
12
+ *
13
+ * SCOPE HONESTY: this file declares the OSS-visible protocol surface. The
14
+ * proprietary repo's server-connection.ts remains the authority for the full
15
+ * ServerToDaemon command set; it should adopt these unions and extend them
16
+ * (`ServerToDaemonMsg | <proprietary extras>`) rather than re-declaring the
17
+ * shared members. Members here are the ones OSS daemon-core itself produces
18
+ * or matches.
19
+ */
20
+
21
+ /** Messages the daemon sends UP to the Workers server over the WS bridge. */
22
+ export type DaemonToServerWsMsg =
23
+ | 'auth'
24
+ | 'status_report'
25
+ | 'status_heartbeat'
26
+ | 'status_event'
27
+ | 'command_result'
28
+ | 'error'
29
+ | 'agent_event'
30
+ | 'log';
31
+
32
+ /** Server→daemon control messages the OSS engine reacts to. */
33
+ export type ServerToDaemonWsMsg =
34
+ | 'auth_ok'
35
+ | 'auth_error'
36
+ | 'machine_evicted'
37
+ | 'force_disconnect'
38
+ | 'token_revoked'
39
+ | 'version_mismatch'
40
+ | 'force_update_required'
41
+ | 'command'
42
+ | 'agent_command'
43
+ | 'resolve_action';
44
+
45
+ /** P2P signaling relayed through the server WS. */
46
+ export type P2PSignalingWsMsg =
47
+ | 'p2p_ready'
48
+ | 'offer'
49
+ | 'answer'
50
+ | 'ice'
51
+ | 'mesh_p2p_ready'
52
+ | 'mesh_p2p_offer'
53
+ | 'mesh_p2p_answer'
54
+ | 'mesh_p2p_ice';
55
+
56
+ /**
57
+ * Dashboard↔daemon P2P DataChannel JSON message kinds. Previously matched as
58
+ * hand-synced literals on both ends with NO shared symbol anywhere —
59
+ * `p2p_evicted` had exactly two occurrences repo-wide (emit + handle).
60
+ */
61
+ export type DashboardP2PMessageKind =
62
+ | 'ping'
63
+ | 'pong'
64
+ | 'status_report'
65
+ | 'status_event'
66
+ | 'p2p_evicted'
67
+ | 'command'
68
+ | 'command_result'
69
+ | 'command_result_chunk'
70
+ | 'screenshot_start'
71
+ | 'screenshot_stop'
72
+ | 'pty_input'
73
+ | 'pty_resize';
74
+
75
+ export const DAEMON_TO_SERVER_WS_MSGS: readonly DaemonToServerWsMsg[] = [
76
+ 'auth', 'status_report', 'status_heartbeat', 'status_event', 'command_result', 'error', 'agent_event', 'log',
77
+ ];
78
+
79
+ export const SERVER_TO_DAEMON_WS_MSGS: readonly ServerToDaemonWsMsg[] = [
80
+ 'auth_ok', 'auth_error', 'machine_evicted', 'force_disconnect', 'token_revoked',
81
+ 'version_mismatch', 'force_update_required', 'command', 'agent_command', 'resolve_action',
82
+ ];
83
+
84
+ export function isDaemonToServerWsMsg(value: unknown): value is DaemonToServerWsMsg {
85
+ return typeof value === 'string' && (DAEMON_TO_SERVER_WS_MSGS as readonly string[]).includes(value);
86
+ }
87
+
88
+ export function isServerToDaemonWsMsg(value: unknown): value is ServerToDaemonWsMsg {
89
+ return typeof value === 'string' && (SERVER_TO_DAEMON_WS_MSGS as readonly string[]).includes(value);
90
+ }