@openscout/protocol 0.2.64 → 0.2.83

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 (214) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +103 -52
  3. package/dist/a2a.d.ts +224 -0
  4. package/dist/a2a.d.ts.map +1 -0
  5. package/dist/a2a.js +225 -0
  6. package/dist/activity.d.ts +70 -0
  7. package/dist/activity.d.ts.map +1 -0
  8. package/dist/activity.js +1 -0
  9. package/dist/actors.d.ts +7 -3
  10. package/dist/actors.d.ts.map +1 -0
  11. package/dist/actors.js +15 -1
  12. package/dist/agent-broker-feed.d.ts +84 -0
  13. package/dist/agent-broker-feed.d.ts.map +1 -0
  14. package/dist/agent-broker-feed.js +1 -0
  15. package/dist/agent-identity.d.ts +23 -0
  16. package/dist/agent-identity.d.ts.map +1 -0
  17. package/dist/agent-identity.js +122 -8
  18. package/dist/agent-runs.d.ts +191 -0
  19. package/dist/agent-runs.d.ts.map +1 -0
  20. package/dist/agent-runs.js +365 -0
  21. package/dist/assigned-roles.d.ts +205 -0
  22. package/dist/assigned-roles.d.ts.map +1 -0
  23. package/dist/assigned-roles.js +228 -0
  24. package/dist/broker-routes.d.ts +40 -0
  25. package/dist/broker-routes.d.ts.map +1 -0
  26. package/dist/broker-routes.js +56 -0
  27. package/dist/budget.d.ts +65 -0
  28. package/dist/budget.d.ts.map +1 -0
  29. package/dist/budget.js +19 -0
  30. package/dist/capability-matrix.d.ts +230 -0
  31. package/dist/capability-matrix.d.ts.map +1 -0
  32. package/dist/capability-matrix.js +304 -0
  33. package/dist/channel-identity.d.ts +40 -0
  34. package/dist/channel-identity.d.ts.map +1 -0
  35. package/dist/channel-identity.js +108 -0
  36. package/dist/collaboration.d.ts +45 -16
  37. package/dist/collaboration.d.ts.map +1 -0
  38. package/dist/collaboration.js +39 -23
  39. package/dist/common.d.ts +4 -3
  40. package/dist/common.d.ts.map +1 -0
  41. package/dist/conversations.d.ts +1 -0
  42. package/dist/conversations.d.ts.map +1 -0
  43. package/dist/cursor-transport.d.ts +22 -0
  44. package/dist/cursor-transport.d.ts.map +1 -0
  45. package/dist/cursor-transport.js +10 -0
  46. package/dist/debug-attach.d.ts +29 -0
  47. package/dist/debug-attach.d.ts.map +1 -0
  48. package/dist/debug-attach.js +6 -0
  49. package/dist/deliveries.d.ts +16 -2
  50. package/dist/deliveries.d.ts.map +1 -0
  51. package/dist/durable-actions.d.ts +94 -0
  52. package/dist/durable-actions.d.ts.map +1 -0
  53. package/dist/durable-actions.js +1 -0
  54. package/dist/events.d.ts +27 -1
  55. package/dist/events.d.ts.map +1 -0
  56. package/dist/events.js +14 -1
  57. package/dist/harness-feature-support.d.ts +76 -0
  58. package/dist/harness-feature-support.d.ts.map +1 -0
  59. package/dist/harness-feature-support.js +29 -0
  60. package/dist/host-integrations.d.ts +22 -0
  61. package/dist/host-integrations.d.ts.map +1 -0
  62. package/dist/host-integrations.js +64 -0
  63. package/dist/inbox.d.ts +50 -0
  64. package/dist/inbox.d.ts.map +1 -0
  65. package/dist/inbox.js +9 -0
  66. package/dist/index.d.ts +35 -2
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +34 -2
  69. package/dist/invocations.d.ts +119 -4
  70. package/dist/invocations.d.ts.map +1 -0
  71. package/dist/invocations.js +201 -1
  72. package/dist/mesh.d.ts +98 -0
  73. package/dist/mesh.d.ts.map +1 -0
  74. package/dist/mesh.js +18 -1
  75. package/dist/messages.d.ts +3 -0
  76. package/dist/messages.d.ts.map +1 -0
  77. package/dist/native-interactions.d.ts +30 -0
  78. package/dist/native-interactions.d.ts.map +1 -0
  79. package/dist/native-interactions.js +51 -0
  80. package/dist/observed-status.d.ts +33 -0
  81. package/dist/observed-status.d.ts.map +1 -0
  82. package/dist/observed-status.js +6 -0
  83. package/dist/orchestrator-prompt.d.ts +10 -0
  84. package/dist/orchestrator-prompt.d.ts.map +1 -0
  85. package/dist/orchestrator-prompt.js +36 -0
  86. package/dist/permission-policy.d.ts +5 -0
  87. package/dist/permission-policy.d.ts.map +1 -0
  88. package/dist/permission-policy.js +15 -0
  89. package/dist/provisional-agent-names.d.ts +30 -0
  90. package/dist/provisional-agent-names.d.ts.map +1 -0
  91. package/dist/provisional-agent-names.js +224 -0
  92. package/dist/read-receipts.d.ts +12 -0
  93. package/dist/read-receipts.d.ts.map +1 -0
  94. package/dist/read-receipts.js +1 -0
  95. package/dist/relay-agent-card.d.ts +10 -0
  96. package/dist/relay-agent-card.d.ts.map +1 -0
  97. package/dist/rendezvous.d.ts +97 -0
  98. package/dist/rendezvous.d.ts.map +1 -0
  99. package/dist/rendezvous.js +35 -0
  100. package/dist/reserved-vocabulary.d.ts +21 -0
  101. package/dist/reserved-vocabulary.d.ts.map +1 -0
  102. package/dist/reserved-vocabulary.js +101 -0
  103. package/dist/route-aliases.d.ts +112 -0
  104. package/dist/route-aliases.d.ts.map +1 -0
  105. package/dist/route-aliases.js +1 -0
  106. package/dist/runtime-catalog-contract.d.ts +39 -0
  107. package/dist/runtime-catalog-contract.d.ts.map +1 -0
  108. package/dist/runtime-catalog-contract.js +17 -0
  109. package/dist/runtime-catalog.d.ts +11 -0
  110. package/dist/runtime-catalog.d.ts.map +1 -0
  111. package/dist/runtime-catalog.generated.d.ts +230 -0
  112. package/dist/runtime-catalog.generated.d.ts.map +1 -0
  113. package/dist/runtime-catalog.generated.js +299 -0
  114. package/dist/runtime-catalog.js +192 -0
  115. package/dist/runtime-execution.d.ts +161 -0
  116. package/dist/runtime-execution.d.ts.map +1 -0
  117. package/dist/runtime-execution.js +327 -0
  118. package/dist/runtime-profiles.d.ts +12 -0
  119. package/dist/runtime-profiles.d.ts.map +1 -0
  120. package/dist/runtime-profiles.js +20 -0
  121. package/dist/scout-agent-card.d.ts +10 -0
  122. package/dist/scout-agent-card.d.ts.map +1 -0
  123. package/dist/scout-composer.d.ts +27 -0
  124. package/dist/scout-composer.d.ts.map +1 -0
  125. package/dist/scout-composer.js +192 -0
  126. package/dist/scout-delivery.d.ts +99 -4
  127. package/dist/scout-delivery.d.ts.map +1 -0
  128. package/dist/scout-dispatch.d.ts +40 -3
  129. package/dist/scout-dispatch.d.ts.map +1 -0
  130. package/dist/scout-reply-context.d.ts +1 -0
  131. package/dist/scout-reply-context.d.ts.map +1 -0
  132. package/dist/session-display-state.d.ts +132 -0
  133. package/dist/session-display-state.d.ts.map +1 -0
  134. package/dist/session-display-state.js +94 -0
  135. package/dist/terminal-host-topology.d.ts +119 -0
  136. package/dist/terminal-host-topology.d.ts.map +1 -0
  137. package/dist/terminal-host-topology.js +21 -0
  138. package/dist/terminal-sessions.d.ts +142 -0
  139. package/dist/terminal-sessions.d.ts.map +1 -0
  140. package/dist/terminal-sessions.js +81 -0
  141. package/dist/terminal-surface-id.d.ts +73 -0
  142. package/dist/terminal-surface-id.d.ts.map +1 -0
  143. package/dist/terminal-surface-id.js +162 -0
  144. package/dist/terminal-workspaces.d.ts +199 -0
  145. package/dist/terminal-workspaces.d.ts.map +1 -0
  146. package/dist/terminal-workspaces.js +287 -0
  147. package/dist/thread-events.d.ts +1 -0
  148. package/dist/thread-events.d.ts.map +1 -0
  149. package/dist/time.d.ts +16 -0
  150. package/dist/time.d.ts.map +1 -0
  151. package/dist/time.js +33 -0
  152. package/dist/transports.d.ts +1 -0
  153. package/dist/transports.d.ts.map +1 -0
  154. package/package.json +37 -9
  155. package/src/a2a.ts +471 -0
  156. package/src/activity.ts +113 -0
  157. package/src/actors.ts +42 -8
  158. package/src/agent-broker-feed.ts +102 -0
  159. package/src/agent-identity.ts +143 -8
  160. package/src/agent-runs.ts +675 -0
  161. package/src/assigned-roles.ts +437 -0
  162. package/src/broker-routes.ts +62 -0
  163. package/src/budget.ts +88 -0
  164. package/src/capability-matrix.ts +648 -0
  165. package/src/channel-identity.ts +147 -0
  166. package/src/collaboration.ts +78 -48
  167. package/src/common.ts +24 -5
  168. package/src/cursor-transport.ts +34 -0
  169. package/src/debug-attach.ts +38 -0
  170. package/src/deliveries.ts +16 -1
  171. package/src/durable-actions.ts +125 -0
  172. package/src/events.ts +31 -0
  173. package/src/harness-feature-support.ts +116 -0
  174. package/src/host-integrations.ts +104 -0
  175. package/src/inbox.ts +68 -0
  176. package/src/index.ts +34 -2
  177. package/src/invocations.ts +351 -4
  178. package/src/mesh.ts +130 -0
  179. package/src/messages.ts +2 -0
  180. package/src/native-interactions.ts +100 -0
  181. package/src/observed-status.ts +90 -0
  182. package/src/orchestrator-prompt.ts +47 -0
  183. package/src/permission-policy.ts +21 -0
  184. package/src/provisional-agent-names.ts +272 -0
  185. package/src/read-receipts.ts +12 -0
  186. package/src/relay-agent-card.ts +73 -63
  187. package/src/rendezvous.ts +154 -0
  188. package/src/reserved-vocabulary.ts +135 -0
  189. package/src/route-aliases.ts +130 -0
  190. package/src/runtime-catalog-contract.ts +60 -0
  191. package/src/runtime-catalog.generated.ts +300 -0
  192. package/src/runtime-catalog.md +21 -0
  193. package/src/runtime-catalog.ts +193 -0
  194. package/src/runtime-catalog.v1.json +81 -0
  195. package/src/runtime-execution.ts +545 -0
  196. package/src/runtime-profiles.ts +37 -0
  197. package/src/scout-agent-card.ts +10 -0
  198. package/src/scout-composer.ts +249 -0
  199. package/src/scout-delivery.ts +113 -3
  200. package/src/scout-dispatch.ts +33 -2
  201. package/src/session-display-state.ts +202 -0
  202. package/src/terminal-host-topology.ts +132 -0
  203. package/src/terminal-sessions.ts +187 -0
  204. package/src/terminal-surface-id.ts +189 -0
  205. package/src/terminal-workspaces.ts +455 -0
  206. package/src/time.ts +49 -0
  207. package/dist/agent-address.d.ts +0 -1
  208. package/dist/agent-address.js +0 -1
  209. package/dist/agent-selectors.d.ts +0 -1
  210. package/dist/agent-selectors.js +0 -1
  211. package/src/agent-address.ts +0 -1
  212. package/src/agent-identity.test.ts +0 -435
  213. package/src/agent-selectors.ts +0 -1
  214. package/src/scout-agent-card.test.ts +0 -54
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Terminal surface identity.
3
+ *
4
+ * A surface id is an opaque token that names one materialized terminal surface:
5
+ * a host session (and optionally a pane) on a host backend, on a node. It is
6
+ * the durable handle a saved workspace cell, a deep link, or a route alias
7
+ * holds onto.
8
+ *
9
+ * Two rules make it durable, and both are the point of this module:
10
+ *
11
+ * 1. Identity comes from (node, backend, host session, pane) — never from the
12
+ * display name. Renaming a session used to change its Scout id, because ids
13
+ * were `sha1(name)`.
14
+ * 2. There is exactly ONE constructor ({@link formatTerminalSurfaceId}) and ONE
15
+ * parser ({@link parseTerminalSurfaceId}). Nothing else may split a surface
16
+ * id on a separator. Four different separator conventions had grown across
17
+ * the server, the web client, and the macOS app, each with its own
18
+ * assumptions about which characters could appear in a session name; the
19
+ * only reason they worked was an unrelated validator that happened to
20
+ * exclude `:`.
21
+ *
22
+ * The token is opaque to consumers but not to this module: it encodes its parts
23
+ * so a server can resolve one without a lookup table, and so a client that
24
+ * receives an id it has never seen can still be routed. Legacy `backend:name`
25
+ * keys parse too, so persisted workspaces and in-flight URLs keep working.
26
+ */
27
+
28
+ /** Backends with first-class protocol support. */
29
+ export type TerminalBackend = "tmux" | "zellij";
30
+
31
+ /**
32
+ * Any terminal host Scout can address. {@link TerminalBackend} is the subset the
33
+ * protocol types name explicitly; adapters may register further hosts (herdr,
34
+ * ssh, host-control) without a protocol change.
35
+ */
36
+ export type TerminalHostId = TerminalBackend | (string & {});
37
+
38
+ /** The parts a surface id is built from. */
39
+ export type TerminalSurfaceAddress = {
40
+ /** Host adapter that owns the surface. */
41
+ backend: TerminalHostId;
42
+ /** Backend-native session name. Mutable display metadata, not identity on its own. */
43
+ hostSession: string;
44
+ /** Pane or tab within the host session, when the host addresses one. */
45
+ paneId?: string | null;
46
+ /** Scout node the surface lives on. Null/absent means the local node. */
47
+ nodeId?: string | null;
48
+ };
49
+
50
+ /** Opaque surface handle. Never split, slice, or interpolate into a pattern. */
51
+ export type TerminalSurfaceId = string;
52
+
53
+ const SURFACE_ID_PREFIX = "srf1.";
54
+ /** Legacy `backend:sessionName` keys, still written by older clients and links. */
55
+ const LEGACY_SURFACE_KEY = /^(?<backend>[A-Za-z][A-Za-z0-9_-]*):(?<hostSession>.+)$/u;
56
+ const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
57
+
58
+ export function isTerminalSurfaceId(value: string | null | undefined): boolean {
59
+ return typeof value === "string" && value.startsWith(SURFACE_ID_PREFIX);
60
+ }
61
+
62
+ /**
63
+ * Build the surface id for an address. Deterministic: the same address always
64
+ * yields the same id, on any node, in any process, so two writers converge on
65
+ * one row instead of forking one.
66
+ */
67
+ export function formatTerminalSurfaceId(address: TerminalSurfaceAddress): TerminalSurfaceId {
68
+ const backend = address.backend.trim();
69
+ const hostSession = address.hostSession.trim();
70
+ if (!backend || !hostSession) {
71
+ throw new Error("a terminal surface id needs a backend and a host session");
72
+ }
73
+ const paneId = address.paneId?.trim() || null;
74
+ const nodeId = address.nodeId?.trim() || null;
75
+ return `${SURFACE_ID_PREFIX}${encodeBase64Url(JSON.stringify([nodeId, backend, hostSession, paneId]))}`;
76
+ }
77
+
78
+ /**
79
+ * Resolve a surface id back to its address. Accepts the opaque form and the
80
+ * legacy `backend:sessionName` key. Returns null for anything else rather than
81
+ * guessing — a caller holding an unparseable handle must say so, not fabricate
82
+ * a target.
83
+ */
84
+ export function parseTerminalSurfaceId(
85
+ value: string | null | undefined,
86
+ ): TerminalSurfaceAddress | null {
87
+ const trimmed = value?.trim();
88
+ if (!trimmed) return null;
89
+
90
+ if (trimmed.startsWith(SURFACE_ID_PREFIX)) {
91
+ const decoded = decodeBase64Url(trimmed.slice(SURFACE_ID_PREFIX.length));
92
+ if (decoded === null) return null;
93
+ let parsed: unknown;
94
+ try {
95
+ parsed = JSON.parse(decoded) as unknown;
96
+ } catch {
97
+ return null;
98
+ }
99
+ if (!Array.isArray(parsed) || parsed.length < 3) return null;
100
+ const [nodeId, backend, hostSession, paneId] = parsed as unknown[];
101
+ if (typeof backend !== "string" || !backend.trim()) return null;
102
+ if (typeof hostSession !== "string" || !hostSession.trim()) return null;
103
+ return {
104
+ backend: backend.trim(),
105
+ hostSession: hostSession.trim(),
106
+ paneId: typeof paneId === "string" && paneId.trim() ? paneId.trim() : null,
107
+ nodeId: typeof nodeId === "string" && nodeId.trim() ? nodeId.trim() : null,
108
+ };
109
+ }
110
+
111
+ const legacy = LEGACY_SURFACE_KEY.exec(trimmed);
112
+ if (!legacy?.groups) return null;
113
+ const hostSession = legacy.groups.hostSession!.trim();
114
+ if (!hostSession) return null;
115
+ return {
116
+ backend: legacy.groups.backend!,
117
+ hostSession,
118
+ paneId: null,
119
+ nodeId: null,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Compare two handles by what they address, not by how they were written, so a
125
+ * legacy key and its opaque replacement resolve to the same tile.
126
+ */
127
+ export function terminalSurfaceIdsEqual(
128
+ left: string | null | undefined,
129
+ right: string | null | undefined,
130
+ ): boolean {
131
+ const leftAddress = parseTerminalSurfaceId(left);
132
+ const rightAddress = parseTerminalSurfaceId(right);
133
+ if (!leftAddress || !rightAddress) return false;
134
+ return leftAddress.backend === rightAddress.backend
135
+ && leftAddress.hostSession === rightAddress.hostSession
136
+ && (leftAddress.paneId ?? null) === (rightAddress.paneId ?? null)
137
+ && (leftAddress.nodeId ?? null) === (rightAddress.nodeId ?? null);
138
+ }
139
+
140
+ /**
141
+ * The legacy `backend:sessionName` key for an address. Kept so ids issued by
142
+ * this build stay readable by clients that have not adopted the opaque form
143
+ * (macOS, iOS). Delete once every surface reads {@link TerminalSurfaceId}.
144
+ */
145
+ export function legacyTerminalSurfaceKey(address: TerminalSurfaceAddress): string {
146
+ return `${address.backend}:${address.hostSession}`;
147
+ }
148
+
149
+ // Base64url over UTF-8, hand-rolled so this module runs unchanged in a browser
150
+ // bundle, a Bun server, and a Node relay without Buffer or atob/btoa.
151
+
152
+ function encodeBase64Url(value: string): string {
153
+ const bytes = new TextEncoder().encode(value);
154
+ let out = "";
155
+ for (let index = 0; index < bytes.length; index += 3) {
156
+ const byte0 = bytes[index]!;
157
+ const byte1 = bytes[index + 1];
158
+ const byte2 = bytes[index + 2];
159
+ out += BASE64URL_ALPHABET[byte0 >> 2];
160
+ out += BASE64URL_ALPHABET[((byte0 & 0b11) << 4) | ((byte1 ?? 0) >> 4)];
161
+ if (byte1 === undefined) break;
162
+ out += BASE64URL_ALPHABET[((byte1 & 0b1111) << 2) | ((byte2 ?? 0) >> 6)];
163
+ if (byte2 === undefined) break;
164
+ out += BASE64URL_ALPHABET[byte2 & 0b111111];
165
+ }
166
+ return out;
167
+ }
168
+
169
+ function decodeBase64Url(value: string): string | null {
170
+ if (!value) return null;
171
+ const bytes: number[] = [];
172
+ let buffer = 0;
173
+ let bits = 0;
174
+ for (const character of value) {
175
+ const index = BASE64URL_ALPHABET.indexOf(character);
176
+ if (index < 0) return null;
177
+ buffer = (buffer << 6) | index;
178
+ bits += 6;
179
+ if (bits >= 8) {
180
+ bits -= 8;
181
+ bytes.push((buffer >> bits) & 0xff);
182
+ }
183
+ }
184
+ try {
185
+ return new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array(bytes));
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
@@ -0,0 +1,455 @@
1
+ /**
2
+ * Durable terminal workspaces.
3
+ *
4
+ * A workspace is a named, server-owned arrangement of agent-CLI tiles. It is
5
+ * the Scout object the three clients had each invented privately —
6
+ * `openscout.terminal.workspaces.v1` in localStorage,
7
+ * `scout.terminals.workspaces.v1` in UserDefaults, and nothing at all on iOS —
8
+ * with the same version number, the same idea, and no synchronization.
9
+ *
10
+ * Where this stands TODAY, so nobody reads more into it than is built: the web
11
+ * client treats the record as its source of truth, seeding its deck from it and
12
+ * writing changes back. macOS still keeps its own UserDefaults store and has no
13
+ * reference to `/api/terminal-workspaces`, and iOS has nothing. "One workspace
14
+ * on all three clients" is the direction this type exists to make possible, not
15
+ * something it has delivered; the other two clients follow.
16
+ *
17
+ * The load-bearing design decision is that a cell stores INTENT, not just a
18
+ * binding. A saved cell that only remembers "tmux session scout-tmux-cell-7"
19
+ * is worth nothing after a reboot: tmux is empty and the name resolves to
20
+ * nothing. A cell that also remembers which host to use, what directory, and
21
+ * how to resume the harness can be rebuilt. This is the same conclusion
22
+ * tmux-resurrect/continuum reached — persist intent and replay it, never
23
+ * process state — and what macOS's `restoreCommandLine` already does in a
24
+ * cruder form.
25
+ */
26
+
27
+ import {
28
+ terminalSurfaceMatchesId,
29
+ terminalSurfaceNodeId,
30
+ terminalSurfaceNodeScopeMatches,
31
+ } from "./terminal-sessions.js";
32
+ import type { TerminalSessionRecord, TerminalSurface } from "./terminal-sessions.js";
33
+ import { parseTerminalSurfaceId } from "./terminal-surface-id.js";
34
+ import type { TerminalHostId, TerminalSurfaceId } from "./terminal-surface-id.js";
35
+
36
+ /**
37
+ * Everything needed to re-materialize a cell when nothing live matches it.
38
+ * Every field is optional because a workspace authored before a given field
39
+ * existed must still resolve; what a cell cannot say, reconciliation refuses to
40
+ * invent.
41
+ */
42
+ export type TerminalWorkspaceCellIntent = {
43
+ /** Host to materialize on. Absent means "whatever the operator's default is". */
44
+ hostId?: TerminalHostId | null;
45
+ /** Durable per-cell host session name. This is what makes a tile reattach. */
46
+ sessionName?: string | null;
47
+ /** Working directory to open in. */
48
+ cwd?: string | null;
49
+ /** Harness to resume, when the cell is an agent rather than a shell. */
50
+ harness?: string | null;
51
+ /** Harness-native resume command, e.g. `claude --resume <id>`. */
52
+ resumeCommand?: string | null;
53
+ };
54
+
55
+ export type TerminalWorkspaceCell = {
56
+ /** Stable cell id. Minted once, at authoring; never derived from a name. */
57
+ id: string;
58
+ /** Durable handle for the surface this cell last bound to. */
59
+ surfaceId?: TerminalSurfaceId | null;
60
+ /** Registry record that surface belonged to, when it had one. */
61
+ terminalSessionId?: string | null;
62
+ intent: TerminalWorkspaceCellIntent;
63
+ };
64
+
65
+ /**
66
+ * How a workspace arranges its tiles.
67
+ *
68
+ * Three shapes, not a list of named presets: one tile, N side-by-side lanes, or
69
+ * a 2D grid. The old SOLO/SPLIT/TRIO/QUAD taxonomy conflated the shape with a
70
+ * particular tile count, which is why authoring was capped at four and a
71
+ * nine-tile workspace silently truncated — "Quad" was both a layout and a size.
72
+ * Size is now just how many cells exist.
73
+ */
74
+ export type TerminalWorkspaceLayoutMode = "solo" | "lanes" | "grid";
75
+
76
+ /**
77
+ * `"dynamic"` fits the column count to the number of tiles instead of pinning
78
+ * it, so adding a tile re-flows the workspace rather than leaving a hole.
79
+ */
80
+ export type TerminalWorkspaceColumnCount = number | "dynamic";
81
+
82
+ export type TerminalWorkspaceLayout = {
83
+ mode: TerminalWorkspaceLayoutMode;
84
+ columns?: TerminalWorkspaceColumnCount;
85
+ };
86
+
87
+ export type TerminalWorkspaceRecord = {
88
+ id: string;
89
+ name: string;
90
+ purpose: string;
91
+ /**
92
+ * Resolved column count at the time of writing. Kept for records and clients
93
+ * that predate {@link layout}; `layout` wins when both are present.
94
+ */
95
+ columns: number;
96
+ layout?: TerminalWorkspaceLayout;
97
+ cells: TerminalWorkspaceCell[];
98
+ createdAt: number;
99
+ updatedAt: number;
100
+ metadata?: Record<string, unknown>;
101
+ };
102
+
103
+ export type TerminalWorkspaceRecordInput = {
104
+ id?: string;
105
+ name: string;
106
+ purpose?: string;
107
+ columns?: number;
108
+ layout?: TerminalWorkspaceLayout;
109
+ cells?: TerminalWorkspaceCell[];
110
+ metadata?: Record<string, unknown>;
111
+ };
112
+
113
+ /** Highest column count a workspace may reopen with. */
114
+ export const TERMINAL_WORKSPACE_MAX_COLUMNS = 6;
115
+ export const TERMINAL_WORKSPACE_DEFAULT_COLUMNS = 2;
116
+
117
+ /**
118
+ * The layout a workspace should open with, folding a pre-layout record forward.
119
+ *
120
+ * A record written before layouts existed carries only a column count, so the
121
+ * mode is inferred from how that count relates to the cells: one cell is solo,
122
+ * a count that fits every cell on one row is lanes, anything else was a grid.
123
+ */
124
+ export function terminalWorkspaceLayoutOf(input: {
125
+ layout?: TerminalWorkspaceLayout | null;
126
+ columns?: number | null;
127
+ cellCount?: number;
128
+ }): TerminalWorkspaceLayout {
129
+ if (input.layout?.mode) {
130
+ return {
131
+ mode: input.layout.mode,
132
+ ...(input.layout.columns === undefined ? {} : { columns: normalizeTerminalWorkspaceColumnCount(input.layout.columns) }),
133
+ };
134
+ }
135
+ const cellCount = input.cellCount ?? 0;
136
+ const columns = normalizeTerminalWorkspaceColumns(input.columns ?? TERMINAL_WORKSPACE_DEFAULT_COLUMNS);
137
+ if (cellCount <= 1) return { mode: "solo" };
138
+ if (columns >= cellCount) return { mode: "lanes", columns };
139
+ return { mode: "grid", columns };
140
+ }
141
+
142
+ /**
143
+ * Column count to render with.
144
+ *
145
+ * Solo is always one. A dynamic lane count is the tile count, so every tile
146
+ * gets its own lane; a dynamic grid is the squarest arrangement that holds
147
+ * them. Both clamp, because past a handful of columns a terminal is too narrow
148
+ * to read.
149
+ */
150
+ export function resolveTerminalWorkspaceColumns(
151
+ layout: TerminalWorkspaceLayout,
152
+ input: { tileCount: number },
153
+ ): number {
154
+ const tileCount = Math.max(1, Math.floor(input.tileCount) || 1);
155
+ if (layout.mode === "solo") return 1;
156
+ if (layout.columns === undefined || layout.columns === "dynamic") {
157
+ const dynamic = layout.mode === "lanes" ? tileCount : Math.ceil(Math.sqrt(tileCount));
158
+ return Math.max(1, Math.min(TERMINAL_WORKSPACE_MAX_COLUMNS, dynamic));
159
+ }
160
+ return normalizeTerminalWorkspaceColumns(layout.columns);
161
+ }
162
+
163
+ export function normalizeTerminalWorkspaceColumnCount(value: unknown): TerminalWorkspaceColumnCount {
164
+ return value === "dynamic" ? "dynamic" : normalizeTerminalWorkspaceColumns(value);
165
+ }
166
+
167
+ /**
168
+ * Read a stored `layout_json` column back, or null when there is nothing
169
+ * trustworthy in it.
170
+ *
171
+ * Every store that persists a workspace decodes the layout through this, and
172
+ * that is the point: `layout_json` was added to the schema and then read by one
173
+ * handle and ignored by the other, so a layout written through the web server
174
+ * came back as `null` through the runtime store. One decoder means one answer.
175
+ *
176
+ * The mode is validated rather than trusted. It flows straight into the
177
+ * client's grid resolver, so a value that is not one of the three shapes must
178
+ * become "no stored layout" — which folds forward from the column count — and
179
+ * not a mode nothing can render.
180
+ */
181
+ export function parseTerminalWorkspaceLayoutJson(
182
+ value: string | null | undefined,
183
+ ): TerminalWorkspaceLayout | null {
184
+ if (!value) return null;
185
+ let parsed: unknown;
186
+ try {
187
+ parsed = JSON.parse(value) as unknown;
188
+ } catch {
189
+ return null;
190
+ }
191
+ if (!parsed || typeof parsed !== "object") return null;
192
+ const candidate = parsed as Partial<TerminalWorkspaceLayout>;
193
+ const mode = candidate.mode;
194
+ if (mode !== "solo" && mode !== "lanes" && mode !== "grid") return null;
195
+ return {
196
+ mode,
197
+ ...(candidate.columns === undefined
198
+ ? {}
199
+ : { columns: normalizeTerminalWorkspaceColumnCount(candidate.columns) }),
200
+ };
201
+ }
202
+
203
+ export function terminalWorkspaceLayoutLabel(layout: TerminalWorkspaceLayout): string {
204
+ if (layout.mode === "solo") return "Solo";
205
+ const shape = layout.mode === "lanes" ? "Lanes" : "Grid";
206
+ return layout.columns === undefined || layout.columns === "dynamic"
207
+ ? `${shape} · dynamic`
208
+ : `${shape} · ${layout.columns} column${layout.columns === 1 ? "" : "s"}`;
209
+ }
210
+
211
+ export type TerminalWorkspaceCellStatus = "live" | "revivable" | "unavailable";
212
+
213
+ /** What reconciliation needs to know about a host, with no host access. */
214
+ export type TerminalWorkspaceHostState = {
215
+ id: TerminalHostId;
216
+ installed: boolean;
217
+ /** Whether Scout can materialize a session on this host headlessly. */
218
+ canCreate: boolean;
219
+ };
220
+
221
+ export type TerminalWorkspaceRevivePlan = {
222
+ hostId: TerminalHostId;
223
+ sessionName: string;
224
+ cwd: string | null;
225
+ /** Harness resume command to run inside the revived surface, when known. */
226
+ resumeCommand: string | null;
227
+ };
228
+
229
+ export type TerminalWorkspaceCellResolution = {
230
+ cellId: string;
231
+ status: TerminalWorkspaceCellStatus;
232
+ surfaceId: TerminalSurfaceId | null;
233
+ terminalSessionId: string | null;
234
+ surface: TerminalSurface | null;
235
+ /** Operator-facing reason, in product language. */
236
+ detail: string;
237
+ /** Present exactly when the status is `revivable`. */
238
+ revive: TerminalWorkspaceRevivePlan | null;
239
+ };
240
+
241
+ export type TerminalWorkspaceResolution = {
242
+ workspaceId: string;
243
+ cells: TerminalWorkspaceCellResolution[];
244
+ liveCount: number;
245
+ revivableCount: number;
246
+ unavailableCount: number;
247
+ };
248
+
249
+ /**
250
+ * Map every saved cell to a live surface, a restorable-but-dead one, or an
251
+ * unavailable one.
252
+ *
253
+ * This is the only honest answer to a reboot. tmux and zellij sessions do not
254
+ * survive a restart; a workspace that stored intent can rebuild itself, and one
255
+ * that stored only a session name cannot. Judgement lives here, on the server
256
+ * side of the wire, so all three clients inherit the same answer instead of
257
+ * each re-deriving it — and it never fabricates a record: a cell with nothing
258
+ * to rebuild from reports `unavailable` and says why.
259
+ */
260
+ export function reconcileTerminalWorkspace(
261
+ workspace: TerminalWorkspaceRecord,
262
+ input: {
263
+ sessions: readonly TerminalSessionRecord[];
264
+ hosts: readonly TerminalWorkspaceHostState[];
265
+ /** Host used when a cell's intent names none. */
266
+ defaultHostId?: TerminalHostId | null;
267
+ /**
268
+ * This host's own Scout node id. The observations passed in were made HERE,
269
+ * so this is the node whose cells they can prove live; a cell scoped to any
270
+ * other node is not answered by them. Absent means the caller cannot say,
271
+ * and then no node-scoped cell is claimed by a node-less observation.
272
+ */
273
+ localNodeId?: string | null;
274
+ },
275
+ ): TerminalWorkspaceResolution {
276
+ const cells = workspace.cells.map((cell) => resolveTerminalWorkspaceCell(cell, input));
277
+ return {
278
+ workspaceId: workspace.id,
279
+ cells,
280
+ liveCount: cells.filter((cell) => cell.status === "live").length,
281
+ revivableCount: cells.filter((cell) => cell.status === "revivable").length,
282
+ unavailableCount: cells.filter((cell) => cell.status === "unavailable").length,
283
+ };
284
+ }
285
+
286
+ function resolveTerminalWorkspaceCell(
287
+ cell: TerminalWorkspaceCell,
288
+ input: {
289
+ sessions: readonly TerminalSessionRecord[];
290
+ hosts: readonly TerminalWorkspaceHostState[];
291
+ defaultHostId?: TerminalHostId | null;
292
+ localNodeId?: string | null;
293
+ },
294
+ ): TerminalWorkspaceCellResolution {
295
+ const live = findLiveSurface(cell, input.sessions, input.localNodeId?.trim() || null);
296
+ if (live) {
297
+ return {
298
+ cellId: cell.id,
299
+ status: "live",
300
+ surfaceId: live.surface.surfaceId ?? cell.surfaceId ?? null,
301
+ terminalSessionId: live.session.id,
302
+ surface: live.surface,
303
+ detail: "Running",
304
+ revive: null,
305
+ };
306
+ }
307
+
308
+ const hostId = cell.intent.hostId ?? input.defaultHostId ?? null;
309
+ const sessionName = cell.intent.sessionName?.trim() || null;
310
+ if (!hostId || !sessionName) {
311
+ return unavailable(cell, "This tile was saved without enough detail to reopen it.");
312
+ }
313
+
314
+ const host = input.hosts.find((candidate) => candidate.id === hostId) ?? null;
315
+ if (!host) {
316
+ return unavailable(cell, `This tile needs ${hostId}, which Scout does not know about.`);
317
+ }
318
+ if (!host.installed) {
319
+ return unavailable(cell, `This tile needs ${hostId}, which is not installed here.`);
320
+ }
321
+ if (!host.canCreate) {
322
+ return unavailable(cell, `Scout cannot reopen ${hostId} sessions for you; open it there and come back.`);
323
+ }
324
+
325
+ return {
326
+ cellId: cell.id,
327
+ status: "revivable",
328
+ surfaceId: cell.surfaceId ?? null,
329
+ terminalSessionId: cell.terminalSessionId ?? null,
330
+ surface: null,
331
+ detail: "Not running. Scout can start it again.",
332
+ revive: {
333
+ hostId,
334
+ sessionName,
335
+ cwd: cell.intent.cwd?.trim() || null,
336
+ resumeCommand: cell.intent.resumeCommand?.trim() || null,
337
+ },
338
+ };
339
+ }
340
+
341
+ function unavailable(
342
+ cell: TerminalWorkspaceCell,
343
+ detail: string,
344
+ ): TerminalWorkspaceCellResolution {
345
+ return {
346
+ cellId: cell.id,
347
+ status: "unavailable",
348
+ surfaceId: cell.surfaceId ?? null,
349
+ terminalSessionId: cell.terminalSessionId ?? null,
350
+ surface: null,
351
+ detail,
352
+ revive: null,
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Whether a record is an OBSERVATION of the host rather than something Scout
358
+ * wrote down once. Only an observation can prove a surface is running.
359
+ */
360
+ function isHostObservedRecord(session: TerminalSessionRecord): boolean {
361
+ // `metadata.registryState` is the pre-`origin` signal; both are honoured
362
+ // until every writer has moved over.
363
+ return session.origin === "discovered" || session.metadata?.registryState === "discovered";
364
+ }
365
+
366
+ /**
367
+ * A cell binds to a live surface by durable handle first. The intent's session
368
+ * name is the fallback, because a surface re-created under the same name on the
369
+ * same host IS the tile's session — that is the whole point of stable per-cell
370
+ * names — while a record id is not enough on its own, since ids move when a
371
+ * discovered session is renamed.
372
+ *
373
+ * Two rules make the answer an observation instead of a memory, and a review
374
+ * reproduced what happens without them.
375
+ *
376
+ * Only a host-observed record can establish "live". A registry record is
377
+ * written once, at intake, with `state: "live"` baked in, and it is never
378
+ * updated again — so after a reboot every saved tile still resolved to
379
+ * `{status: "live", detail: "Running"}` against a host that had nothing on it.
380
+ * Scout was reporting its own filing cabinet back to the operator as the state
381
+ * of their machine.
382
+ *
383
+ * And only `state === "live"` counts. `detached` means different things per
384
+ * host — for tmux it is a running session with no client, for herdr it is a
385
+ * session whose server is STOPPED — so accepting "anything but exited" reported
386
+ * stopped herdr sessions as running. Hosts that stay attachable while detached
387
+ * report `live` from their adapter, so requiring it costs nothing and stops
388
+ * that.
389
+ *
390
+ * When an observation is found, the registry record for the same surface is
391
+ * preferred as the cell's record: it carries the harness and resume command a
392
+ * discovered record cannot know. The SURFACE always comes from the observation,
393
+ * because that is the one with the current state.
394
+ *
395
+ * The third rule is that a machine only speaks for itself. The name fallback is
396
+ * matched under the same node scoping as the handle, because otherwise it
397
+ * quietly reopened the hole the handle matcher closed: a cell scoped to `node-b`
398
+ * whose intent named `scout-tmux-cell-1` bound to THIS host's session of that
399
+ * name, so `node-a` and `node-b` both reported the one discovered session
400
+ * Running. One observed session never proves more than one node's workspace
401
+ * live.
402
+ */
403
+ function findLiveSurface(
404
+ cell: TerminalWorkspaceCell,
405
+ sessions: readonly TerminalSessionRecord[],
406
+ localNodeId: string | null,
407
+ ): { session: TerminalSessionRecord; surface: TerminalSurface } | null {
408
+ const observedSessions = sessions.filter(isHostObservedRecord);
409
+ const handle = cell.surfaceId?.trim() || null;
410
+ const sessionName = cell.intent.sessionName?.trim() || null;
411
+ const hostId = cell.intent.hostId ?? null;
412
+ // The cell's node is whatever its durable handle names; a cell with only an
413
+ // intent names no machine and keeps matching wherever it lands.
414
+ const cellNodeId = parseTerminalSurfaceId(handle)?.nodeId ?? null;
415
+
416
+ const matches = (surface: TerminalSurface): boolean => {
417
+ if (surface.state !== "live") return false;
418
+ if (handle && terminalSurfaceMatchesId(surface, handle, { localNodeId })) return true;
419
+ if (!sessionName || surface.sessionName !== sessionName) return false;
420
+ if (hostId && surface.backend !== hostId) return false;
421
+ return terminalSurfaceNodeScopeMatches({
422
+ handleNodeId: cellNodeId,
423
+ surfaceNodeId: terminalSurfaceNodeId(surface),
424
+ localNodeId,
425
+ });
426
+ };
427
+
428
+ for (const session of observedSessions) {
429
+ const surface = session.surfaces.find(matches);
430
+ if (!surface) continue;
431
+ // Under the same node rule: a registry record from another machine that
432
+ // happens to hold this session name is not the identity of the session
433
+ // observed here, and lending it would put a foreign record id on a local
434
+ // surface.
435
+ const registryRecord = sessions.find((candidate) =>
436
+ !isHostObservedRecord(candidate)
437
+ && candidate.surfaces.some((known) =>
438
+ known.backend === surface.backend
439
+ && known.sessionName === surface.sessionName
440
+ && terminalSurfaceNodeScopeMatches({
441
+ handleNodeId: terminalSurfaceNodeId(known),
442
+ surfaceNodeId: terminalSurfaceNodeId(surface),
443
+ localNodeId,
444
+ })
445
+ )
446
+ );
447
+ return { session: registryRecord ?? session, surface };
448
+ }
449
+ return null;
450
+ }
451
+
452
+ export function normalizeTerminalWorkspaceColumns(value: unknown): number {
453
+ if (typeof value !== "number" || !Number.isFinite(value)) return TERMINAL_WORKSPACE_DEFAULT_COLUMNS;
454
+ return Math.max(1, Math.min(TERMINAL_WORKSPACE_MAX_COLUMNS, Math.floor(value)));
455
+ }
package/src/time.ts ADDED
@@ -0,0 +1,49 @@
1
+ declare const epochMsBrand: unique symbol;
2
+ declare const durationMsBrand: unique symbol;
3
+
4
+ export type EpochMs = number & { readonly [epochMsBrand]: "EpochMs" };
5
+ export type DurationMs = number & { readonly [durationMsBrand]: "DurationMs" };
6
+
7
+ export const EPOCH_MILLISECONDS_FLOOR = 1_000_000_000_000;
8
+
9
+ function isFinitePositive(value: number): boolean {
10
+ return Number.isFinite(value) && value > 0;
11
+ }
12
+
13
+ function requireFinitePositive(value: number, label: string): number {
14
+ if (!isFinitePositive(value)) {
15
+ throw new RangeError(`${label} must be a finite positive number`);
16
+ }
17
+
18
+ return value;
19
+ }
20
+
21
+ export function nowMs(): EpochMs {
22
+ return Date.now() as EpochMs;
23
+ }
24
+
25
+ export function epochMs(value: unknown): EpochMs | null {
26
+ const parsed = typeof value === "number"
27
+ ? value
28
+ : typeof value === "string"
29
+ ? Number(value)
30
+ : null;
31
+
32
+ if (parsed === null || !isFinitePositive(parsed)) {
33
+ return null;
34
+ }
35
+
36
+ return Math.trunc(parsed < EPOCH_MILLISECONDS_FLOOR ? parsed * 1000 : parsed) as EpochMs;
37
+ }
38
+
39
+ export function epochMsFromSeconds(value: number): EpochMs {
40
+ return Math.trunc(requireFinitePositive(value, "Epoch seconds") * 1000) as EpochMs;
41
+ }
42
+
43
+ export function durationMs(value: number): DurationMs {
44
+ return Math.trunc(requireFinitePositive(value, "Duration milliseconds")) as DurationMs;
45
+ }
46
+
47
+ export function toIso(ms: EpochMs): string {
48
+ return new Date(ms).toISOString();
49
+ }
@@ -1 +0,0 @@
1
- export * from "./agent-identity.js";
@@ -1 +0,0 @@
1
- export * from "./agent-identity.js";