@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.
- package/LICENSE +202 -0
- package/README.md +103 -52
- package/dist/a2a.d.ts +224 -0
- package/dist/a2a.d.ts.map +1 -0
- package/dist/a2a.js +225 -0
- package/dist/activity.d.ts +70 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +1 -0
- package/dist/actors.d.ts +7 -3
- package/dist/actors.d.ts.map +1 -0
- package/dist/actors.js +15 -1
- package/dist/agent-broker-feed.d.ts +84 -0
- package/dist/agent-broker-feed.d.ts.map +1 -0
- package/dist/agent-broker-feed.js +1 -0
- package/dist/agent-identity.d.ts +23 -0
- package/dist/agent-identity.d.ts.map +1 -0
- package/dist/agent-identity.js +122 -8
- package/dist/agent-runs.d.ts +191 -0
- package/dist/agent-runs.d.ts.map +1 -0
- package/dist/agent-runs.js +365 -0
- package/dist/assigned-roles.d.ts +205 -0
- package/dist/assigned-roles.d.ts.map +1 -0
- package/dist/assigned-roles.js +228 -0
- package/dist/broker-routes.d.ts +40 -0
- package/dist/broker-routes.d.ts.map +1 -0
- package/dist/broker-routes.js +56 -0
- package/dist/budget.d.ts +65 -0
- package/dist/budget.d.ts.map +1 -0
- package/dist/budget.js +19 -0
- package/dist/capability-matrix.d.ts +230 -0
- package/dist/capability-matrix.d.ts.map +1 -0
- package/dist/capability-matrix.js +304 -0
- package/dist/channel-identity.d.ts +40 -0
- package/dist/channel-identity.d.ts.map +1 -0
- package/dist/channel-identity.js +108 -0
- package/dist/collaboration.d.ts +45 -16
- package/dist/collaboration.d.ts.map +1 -0
- package/dist/collaboration.js +39 -23
- package/dist/common.d.ts +4 -3
- package/dist/common.d.ts.map +1 -0
- package/dist/conversations.d.ts +1 -0
- package/dist/conversations.d.ts.map +1 -0
- package/dist/cursor-transport.d.ts +22 -0
- package/dist/cursor-transport.d.ts.map +1 -0
- package/dist/cursor-transport.js +10 -0
- package/dist/debug-attach.d.ts +29 -0
- package/dist/debug-attach.d.ts.map +1 -0
- package/dist/debug-attach.js +6 -0
- package/dist/deliveries.d.ts +16 -2
- package/dist/deliveries.d.ts.map +1 -0
- package/dist/durable-actions.d.ts +94 -0
- package/dist/durable-actions.d.ts.map +1 -0
- package/dist/durable-actions.js +1 -0
- package/dist/events.d.ts +27 -1
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +14 -1
- package/dist/harness-feature-support.d.ts +76 -0
- package/dist/harness-feature-support.d.ts.map +1 -0
- package/dist/harness-feature-support.js +29 -0
- package/dist/host-integrations.d.ts +22 -0
- package/dist/host-integrations.d.ts.map +1 -0
- package/dist/host-integrations.js +64 -0
- package/dist/inbox.d.ts +50 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +9 -0
- package/dist/index.d.ts +35 -2
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -2
- package/dist/invocations.d.ts +119 -4
- package/dist/invocations.d.ts.map +1 -0
- package/dist/invocations.js +201 -1
- package/dist/mesh.d.ts +98 -0
- package/dist/mesh.d.ts.map +1 -0
- package/dist/mesh.js +18 -1
- package/dist/messages.d.ts +3 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/native-interactions.d.ts +30 -0
- package/dist/native-interactions.d.ts.map +1 -0
- package/dist/native-interactions.js +51 -0
- package/dist/observed-status.d.ts +33 -0
- package/dist/observed-status.d.ts.map +1 -0
- package/dist/observed-status.js +6 -0
- package/dist/orchestrator-prompt.d.ts +10 -0
- package/dist/orchestrator-prompt.d.ts.map +1 -0
- package/dist/orchestrator-prompt.js +36 -0
- package/dist/permission-policy.d.ts +5 -0
- package/dist/permission-policy.d.ts.map +1 -0
- package/dist/permission-policy.js +15 -0
- package/dist/provisional-agent-names.d.ts +30 -0
- package/dist/provisional-agent-names.d.ts.map +1 -0
- package/dist/provisional-agent-names.js +224 -0
- package/dist/read-receipts.d.ts +12 -0
- package/dist/read-receipts.d.ts.map +1 -0
- package/dist/read-receipts.js +1 -0
- package/dist/relay-agent-card.d.ts +10 -0
- package/dist/relay-agent-card.d.ts.map +1 -0
- package/dist/rendezvous.d.ts +97 -0
- package/dist/rendezvous.d.ts.map +1 -0
- package/dist/rendezvous.js +35 -0
- package/dist/reserved-vocabulary.d.ts +21 -0
- package/dist/reserved-vocabulary.d.ts.map +1 -0
- package/dist/reserved-vocabulary.js +101 -0
- package/dist/route-aliases.d.ts +112 -0
- package/dist/route-aliases.d.ts.map +1 -0
- package/dist/route-aliases.js +1 -0
- package/dist/runtime-catalog-contract.d.ts +39 -0
- package/dist/runtime-catalog-contract.d.ts.map +1 -0
- package/dist/runtime-catalog-contract.js +17 -0
- package/dist/runtime-catalog.d.ts +11 -0
- package/dist/runtime-catalog.d.ts.map +1 -0
- package/dist/runtime-catalog.generated.d.ts +230 -0
- package/dist/runtime-catalog.generated.d.ts.map +1 -0
- package/dist/runtime-catalog.generated.js +299 -0
- package/dist/runtime-catalog.js +192 -0
- package/dist/runtime-execution.d.ts +161 -0
- package/dist/runtime-execution.d.ts.map +1 -0
- package/dist/runtime-execution.js +327 -0
- package/dist/runtime-profiles.d.ts +12 -0
- package/dist/runtime-profiles.d.ts.map +1 -0
- package/dist/runtime-profiles.js +20 -0
- package/dist/scout-agent-card.d.ts +10 -0
- package/dist/scout-agent-card.d.ts.map +1 -0
- package/dist/scout-composer.d.ts +27 -0
- package/dist/scout-composer.d.ts.map +1 -0
- package/dist/scout-composer.js +192 -0
- package/dist/scout-delivery.d.ts +99 -4
- package/dist/scout-delivery.d.ts.map +1 -0
- package/dist/scout-dispatch.d.ts +40 -3
- package/dist/scout-dispatch.d.ts.map +1 -0
- package/dist/scout-reply-context.d.ts +1 -0
- package/dist/scout-reply-context.d.ts.map +1 -0
- package/dist/session-display-state.d.ts +132 -0
- package/dist/session-display-state.d.ts.map +1 -0
- package/dist/session-display-state.js +94 -0
- package/dist/terminal-host-topology.d.ts +119 -0
- package/dist/terminal-host-topology.d.ts.map +1 -0
- package/dist/terminal-host-topology.js +21 -0
- package/dist/terminal-sessions.d.ts +142 -0
- package/dist/terminal-sessions.d.ts.map +1 -0
- package/dist/terminal-sessions.js +81 -0
- package/dist/terminal-surface-id.d.ts +73 -0
- package/dist/terminal-surface-id.d.ts.map +1 -0
- package/dist/terminal-surface-id.js +162 -0
- package/dist/terminal-workspaces.d.ts +199 -0
- package/dist/terminal-workspaces.d.ts.map +1 -0
- package/dist/terminal-workspaces.js +287 -0
- package/dist/thread-events.d.ts +1 -0
- package/dist/thread-events.d.ts.map +1 -0
- package/dist/time.d.ts +16 -0
- package/dist/time.d.ts.map +1 -0
- package/dist/time.js +33 -0
- package/dist/transports.d.ts +1 -0
- package/dist/transports.d.ts.map +1 -0
- package/package.json +37 -9
- package/src/a2a.ts +471 -0
- package/src/activity.ts +113 -0
- package/src/actors.ts +42 -8
- package/src/agent-broker-feed.ts +102 -0
- package/src/agent-identity.ts +143 -8
- package/src/agent-runs.ts +675 -0
- package/src/assigned-roles.ts +437 -0
- package/src/broker-routes.ts +62 -0
- package/src/budget.ts +88 -0
- package/src/capability-matrix.ts +648 -0
- package/src/channel-identity.ts +147 -0
- package/src/collaboration.ts +78 -48
- package/src/common.ts +24 -5
- package/src/cursor-transport.ts +34 -0
- package/src/debug-attach.ts +38 -0
- package/src/deliveries.ts +16 -1
- package/src/durable-actions.ts +125 -0
- package/src/events.ts +31 -0
- package/src/harness-feature-support.ts +116 -0
- package/src/host-integrations.ts +104 -0
- package/src/inbox.ts +68 -0
- package/src/index.ts +34 -2
- package/src/invocations.ts +351 -4
- package/src/mesh.ts +130 -0
- package/src/messages.ts +2 -0
- package/src/native-interactions.ts +100 -0
- package/src/observed-status.ts +90 -0
- package/src/orchestrator-prompt.ts +47 -0
- package/src/permission-policy.ts +21 -0
- package/src/provisional-agent-names.ts +272 -0
- package/src/read-receipts.ts +12 -0
- package/src/relay-agent-card.ts +73 -63
- package/src/rendezvous.ts +154 -0
- package/src/reserved-vocabulary.ts +135 -0
- package/src/route-aliases.ts +130 -0
- package/src/runtime-catalog-contract.ts +60 -0
- package/src/runtime-catalog.generated.ts +300 -0
- package/src/runtime-catalog.md +21 -0
- package/src/runtime-catalog.ts +193 -0
- package/src/runtime-catalog.v1.json +81 -0
- package/src/runtime-execution.ts +545 -0
- package/src/runtime-profiles.ts +37 -0
- package/src/scout-agent-card.ts +10 -0
- package/src/scout-composer.ts +249 -0
- package/src/scout-delivery.ts +113 -3
- package/src/scout-dispatch.ts +33 -2
- package/src/session-display-state.ts +202 -0
- package/src/terminal-host-topology.ts +132 -0
- package/src/terminal-sessions.ts +187 -0
- package/src/terminal-surface-id.ts +189 -0
- package/src/terminal-workspaces.ts +455 -0
- package/src/time.ts +49 -0
- package/dist/agent-address.d.ts +0 -1
- package/dist/agent-address.js +0 -1
- package/dist/agent-selectors.d.ts +0 -1
- package/dist/agent-selectors.js +0 -1
- package/src/agent-address.ts +0 -1
- package/src/agent-identity.test.ts +0 -435
- package/src/agent-selectors.ts +0 -1
- 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
|
+
}
|
package/dist/agent-address.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from "./agent-identity.js";
|
package/dist/agent-address.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export * from "./agent-identity.js";
|