@termfleet/core 0.1.0
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/agent-launch.d.ts +78 -0
- package/dist/agent-launch.js +247 -0
- package/dist/agent-session-id.d.ts +10 -0
- package/dist/agent-session-id.js +36 -0
- package/dist/agent-session-index-client.d.ts +7 -0
- package/dist/agent-session-index-client.js +86 -0
- package/dist/agent-session-index-worker.d.ts +1 -0
- package/dist/agent-session-index-worker.js +20 -0
- package/dist/agent-session-index.d.ts +34 -0
- package/dist/agent-session-index.js +527 -0
- package/dist/agent-session-tail.d.ts +33 -0
- package/dist/agent-session-tail.js +184 -0
- package/dist/agent-session-watcher.d.ts +36 -0
- package/dist/agent-session-watcher.js +194 -0
- package/dist/agent-session.d.ts +380 -0
- package/dist/agent-session.js +1688 -0
- package/dist/background-runner.d.ts +3 -0
- package/dist/background-runner.js +55 -0
- package/dist/boot-queue.d.ts +35 -0
- package/dist/boot-queue.js +66 -0
- package/dist/build-info.d.ts +5 -0
- package/dist/build-info.js +38 -0
- package/dist/collab/canvas-doc.d.ts +47 -0
- package/dist/collab/canvas-doc.js +83 -0
- package/dist/contracts/auth.d.ts +77 -0
- package/dist/contracts/auth.js +1 -0
- package/dist/contracts/canvas.d.ts +34 -0
- package/dist/contracts/canvas.js +76 -0
- package/dist/contracts/console-layout.d.ts +39 -0
- package/dist/contracts/console-layout.js +135 -0
- package/dist/contracts/files.d.ts +38 -0
- package/dist/contracts/files.js +37 -0
- package/dist/contracts/provider-url.d.ts +3 -0
- package/dist/contracts/provider-url.js +49 -0
- package/dist/contracts/registry.d.ts +58 -0
- package/dist/contracts/registry.js +285 -0
- package/dist/launch-trace.d.ts +6 -0
- package/dist/launch-trace.js +33 -0
- package/dist/lib/errors.d.ts +1 -0
- package/dist/lib/errors.js +5 -0
- package/dist/lib/exec.d.ts +13 -0
- package/dist/lib/exec.js +134 -0
- package/dist/local-providers.d.ts +32 -0
- package/dist/local-providers.js +184 -0
- package/dist/local-tunnel.d.ts +6 -0
- package/dist/local-tunnel.js +258 -0
- package/dist/provider-access-token.d.ts +11 -0
- package/dist/provider-access-token.js +77 -0
- package/dist/provider-client.d.ts +152 -0
- package/dist/provider-client.js +666 -0
- package/dist/provider-url-resolver.d.ts +16 -0
- package/dist/provider-url-resolver.js +37 -0
- package/dist/registry-client.d.ts +93 -0
- package/dist/registry-client.js +170 -0
- package/dist/registry.d.ts +56 -0
- package/dist/registry.js +406 -0
- package/dist/session-attention.d.ts +24 -0
- package/dist/session-attention.js +54 -0
- package/dist/session-lifecycle.d.ts +83 -0
- package/dist/session-lifecycle.js +658 -0
- package/dist/session-window.d.ts +3 -0
- package/dist/session-window.js +20 -0
- package/dist/terminal-client.d.ts +49 -0
- package/dist/terminal-client.js +89 -0
- package/dist/types.d.ts +155 -0
- package/dist/types.js +21 -0
- package/package.json +26 -0
package/dist/registry.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { asError } from "@termfleet/core/lib/errors.js";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { isLocalProviderUrl, isSsrfReservedProviderHost, normalizeProviderOrigin } from "./contracts/provider-url.js";
|
|
4
|
+
import { mergeRegistryProviders, normalizeRegistryPayload, normalizeRegistryRegistrationResponse, providerAliases } from "./contracts/registry.js";
|
|
5
|
+
export class ProviderRegistry {
|
|
6
|
+
localRegistryFile;
|
|
7
|
+
remoteRegistryUrls;
|
|
8
|
+
remoteCacheTtlMs;
|
|
9
|
+
onRemoteReadError;
|
|
10
|
+
remoteRegistryCache = {
|
|
11
|
+
expiresAt: 0,
|
|
12
|
+
providers: []
|
|
13
|
+
};
|
|
14
|
+
remoteRegistryRead;
|
|
15
|
+
// Per-token cache for the signed-in provider list. The token-less cache above
|
|
16
|
+
// is org-agnostic; an authenticated read is org-scoped, so it is keyed by
|
|
17
|
+
// token. Without this, the console's 5s poll hit the registry Worker on every
|
|
18
|
+
// tick (one signed-in tab ≈ 17k Worker requests/day) — a primary contributor
|
|
19
|
+
// to tripping the Worker's daily request cap. Successful reads cache for the
|
|
20
|
+
// TTL and concurrent reads dedupe; errors are NOT cached and NOT served stale,
|
|
21
|
+
// so a revoked token still surfaces as an auth failure to the caller.
|
|
22
|
+
remoteTokenCache = new Map();
|
|
23
|
+
remoteTokenRead = new Map();
|
|
24
|
+
lastRegistryReadFailure = "";
|
|
25
|
+
// Serializes every local-registry read-modify-write. The file is the whole
|
|
26
|
+
// registry rewritten in one shot, so two concurrent writers (the identity
|
|
27
|
+
// sweep fans out over all records at once, plus registrations/deletes) would
|
|
28
|
+
// each read the same `previous` and clobber the other's change — last writer
|
|
29
|
+
// wins, the rest are lost. Chaining each mutation behind the previous one
|
|
30
|
+
// closes that lost-update race; pure reads (`readLocal`/`list`) stay lock-free.
|
|
31
|
+
localWriteChain = Promise.resolve();
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.localRegistryFile = options.localRegistryFile;
|
|
34
|
+
const remoteCandidates = options.remoteRegistryUrls?.length
|
|
35
|
+
? options.remoteRegistryUrls
|
|
36
|
+
: options.remoteRegistryUrl
|
|
37
|
+
? [options.remoteRegistryUrl]
|
|
38
|
+
: [];
|
|
39
|
+
this.remoteRegistryUrls = uniqueStrings(remoteCandidates.map((url) => url.trim()).filter((url) => url.length > 0));
|
|
40
|
+
this.remoteCacheTtlMs = options.remoteCacheTtlMs ?? 30_000;
|
|
41
|
+
this.onRemoteReadError = options.onRemoteReadError;
|
|
42
|
+
}
|
|
43
|
+
async list(options = {}) {
|
|
44
|
+
const localProviders = await this.readLocal();
|
|
45
|
+
const remoteProviders = await this.readRemoteForList(options.authToken);
|
|
46
|
+
return mergeRegistryProviders([
|
|
47
|
+
...localProviders,
|
|
48
|
+
...decorateRegistryProviders(options.runtimeProviders ?? [], "console-memory"),
|
|
49
|
+
...remoteProviders
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
async listRemoteWithToken(authToken) {
|
|
53
|
+
if (!authToken) {
|
|
54
|
+
return await this.fetchRemote(authToken);
|
|
55
|
+
}
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const cached = this.remoteTokenCache.get(authToken);
|
|
58
|
+
if (cached && cached.expiresAt > now) {
|
|
59
|
+
return cached.providers;
|
|
60
|
+
}
|
|
61
|
+
const inFlight = this.remoteTokenRead.get(authToken);
|
|
62
|
+
if (inFlight) {
|
|
63
|
+
return inFlight;
|
|
64
|
+
}
|
|
65
|
+
const read = this.fetchRemote(authToken)
|
|
66
|
+
.then((providers) => {
|
|
67
|
+
for (const [token, entry] of this.remoteTokenCache) {
|
|
68
|
+
if (entry.expiresAt <= now) {
|
|
69
|
+
this.remoteTokenCache.delete(token);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
this.remoteTokenCache.set(authToken, { expiresAt: Date.now() + this.remoteCacheTtlMs, providers });
|
|
73
|
+
return providers;
|
|
74
|
+
})
|
|
75
|
+
.finally(() => {
|
|
76
|
+
this.remoteTokenRead.delete(authToken);
|
|
77
|
+
});
|
|
78
|
+
this.remoteTokenRead.set(authToken, read);
|
|
79
|
+
return read;
|
|
80
|
+
}
|
|
81
|
+
async resolveProvider(target, options = {}) {
|
|
82
|
+
const normalizedTarget = normalizeProviderTarget(target);
|
|
83
|
+
const providers = await this.list(options);
|
|
84
|
+
const matchGroups = [
|
|
85
|
+
providers.filter((provider) => providerAliases(provider).includes(target.toLowerCase())),
|
|
86
|
+
providers.filter((provider) => providerId(provider) === target.toLowerCase()),
|
|
87
|
+
providers.filter((provider) => provider.label?.toLowerCase() === target.toLowerCase()),
|
|
88
|
+
providers.filter((provider) => provider.baseUrl === normalizedTarget)
|
|
89
|
+
];
|
|
90
|
+
for (const matches of matchGroups) {
|
|
91
|
+
if (matches.length === 1 && matches[0]) {
|
|
92
|
+
return matches[0];
|
|
93
|
+
}
|
|
94
|
+
if (matches.length > 1) {
|
|
95
|
+
throw new Error(`Provider target "${target}" matched multiple providers: ${matches.map(formatProviderTargetMatch).join(", ")}.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`Provider target "${target}" was not found in the registry.`);
|
|
99
|
+
}
|
|
100
|
+
async register(provider) {
|
|
101
|
+
if (isLocalProviderUrl(provider.baseUrl)) {
|
|
102
|
+
return await this.writeLocal(provider);
|
|
103
|
+
}
|
|
104
|
+
return await this.writeRemote(provider);
|
|
105
|
+
}
|
|
106
|
+
async registerLocal(provider) {
|
|
107
|
+
if (!isLocalProviderUrl(provider.baseUrl)) {
|
|
108
|
+
throw new Error("Local registry providers must use localhost or loopback URLs.");
|
|
109
|
+
}
|
|
110
|
+
return await this.writeLocal(provider);
|
|
111
|
+
}
|
|
112
|
+
async registerRemote(provider, options = {}) {
|
|
113
|
+
if (isLocalProviderUrl(provider.baseUrl)) {
|
|
114
|
+
throw new Error("Remote registry providers must use a LAN, tunnel, or remote URL.");
|
|
115
|
+
}
|
|
116
|
+
if (isSsrfReservedProviderHost(provider.baseUrl)) {
|
|
117
|
+
throw new Error("Remote provider URL must not be a link-local or cloud-metadata address.");
|
|
118
|
+
}
|
|
119
|
+
return await this.writeRemote(provider, options);
|
|
120
|
+
}
|
|
121
|
+
async removeRemote(baseUrl, options = {}) {
|
|
122
|
+
const normalizedBaseUrl = normalizeProviderOrigin(baseUrl);
|
|
123
|
+
if (isLocalProviderUrl(normalizedBaseUrl)) {
|
|
124
|
+
throw new Error("Shared registry providers must use a LAN, tunnel, or remote URL.");
|
|
125
|
+
}
|
|
126
|
+
return await this.deleteRemote(normalizedBaseUrl, options);
|
|
127
|
+
}
|
|
128
|
+
async removeLocal(baseUrl) {
|
|
129
|
+
const normalizedBaseUrl = normalizeProviderOrigin(baseUrl);
|
|
130
|
+
let removed = { baseUrl: normalizedBaseUrl };
|
|
131
|
+
await this.withLocalLock(async () => {
|
|
132
|
+
const previous = await this.readLocal();
|
|
133
|
+
removed = previous.find((candidate) => candidate.baseUrl === normalizedBaseUrl) ?? { baseUrl: normalizedBaseUrl };
|
|
134
|
+
await this.writeLocalFile(previous.filter((candidate) => candidate.baseUrl !== normalizedBaseUrl));
|
|
135
|
+
});
|
|
136
|
+
return removed;
|
|
137
|
+
}
|
|
138
|
+
async readLocal() {
|
|
139
|
+
try {
|
|
140
|
+
const payload = JSON.parse(await readFile(this.localRegistryFile, "utf8"));
|
|
141
|
+
return decorateRegistryProviders(normalizeRegistryPayload(payload), "local-registry");
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
const nodeError = error;
|
|
145
|
+
if (nodeError.code === "ENOENT" || asError(error).message.includes("ENOENT")) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async writeLocal(provider) {
|
|
152
|
+
const normalized = normalizeRegistryPayload([provider])[0];
|
|
153
|
+
if (!normalized) {
|
|
154
|
+
throw new Error("Registry provider payload is required.");
|
|
155
|
+
}
|
|
156
|
+
await this.withLocalLock(async () => {
|
|
157
|
+
const previous = await this.readLocal();
|
|
158
|
+
// Merge ONTO the existing same-baseUrl record (normalized last so its
|
|
159
|
+
// explicit fields win) rather than dropping it — otherwise a bare pointer
|
|
160
|
+
// re-registration would erase the learned instanceId/owner and re-orphan
|
|
161
|
+
// the machine to its address key until the next probe re-stamps it.
|
|
162
|
+
await this.writeLocalFile(mergeRegistryProviders([...previous, normalized]));
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
...normalized,
|
|
166
|
+
registrySource: "local-registry"
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// Atomically stamp a learned instanceId onto a record. Enforces the registry
|
|
170
|
+
// invariant that no two records share a serial: if another machine already
|
|
171
|
+
// owns this instanceId, the stamp is REFUSED and its baseUrl returned so the
|
|
172
|
+
// caller can warn — two providers minting the same id (a copied
|
|
173
|
+
// .termfleet-instance file, a baked image) would otherwise collapse onto one
|
|
174
|
+
// layout key and silently share appearance. Reads fresh inside the lock, so it
|
|
175
|
+
// can't lose a sibling stamp (the sweep fans out), clobber another field, or
|
|
176
|
+
// resurrect a record a racing delete removed.
|
|
177
|
+
async stampInstanceId(baseUrl, instanceId) {
|
|
178
|
+
let outcome = { stamped: false };
|
|
179
|
+
await this.withLocalLock(async () => {
|
|
180
|
+
const records = await this.readLocal();
|
|
181
|
+
const conflict = records.find((candidate) => candidate.baseUrl !== baseUrl && candidate.instanceId === instanceId);
|
|
182
|
+
if (conflict) {
|
|
183
|
+
outcome = { conflictWith: conflict.baseUrl, stamped: false };
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const index = records.findIndex((candidate) => candidate.baseUrl === baseUrl);
|
|
187
|
+
const existing = records[index];
|
|
188
|
+
if (!existing || existing.instanceId === instanceId) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const updated = [...records];
|
|
192
|
+
updated[index] = { ...existing, instanceId };
|
|
193
|
+
await this.writeLocalFile(mergeRegistryProviders(updated));
|
|
194
|
+
outcome = { stamped: true };
|
|
195
|
+
});
|
|
196
|
+
return outcome;
|
|
197
|
+
}
|
|
198
|
+
withLocalLock(operation) {
|
|
199
|
+
const result = this.localWriteChain.then(operation);
|
|
200
|
+
// Keep the chain alive past a failed mutation so one error doesn't wedge
|
|
201
|
+
// every later write; the failure still propagates to its own caller.
|
|
202
|
+
this.localWriteChain = result.then(() => undefined, () => undefined);
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
async writeLocalFile(providers) {
|
|
206
|
+
await writeFile(this.localRegistryFile, `${JSON.stringify({ providers: providers.map(stripRegistrySource) }, null, 2)}\n`);
|
|
207
|
+
}
|
|
208
|
+
async readRemote(authToken) {
|
|
209
|
+
if (authToken) {
|
|
210
|
+
return await this.fetchRemote(authToken);
|
|
211
|
+
}
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
if (this.remoteRegistryCache.expiresAt > now) {
|
|
214
|
+
return this.remoteRegistryCache.providers;
|
|
215
|
+
}
|
|
216
|
+
if (this.remoteRegistryRead) {
|
|
217
|
+
return this.remoteRegistryRead;
|
|
218
|
+
}
|
|
219
|
+
this.remoteRegistryRead = this.fetchRemote()
|
|
220
|
+
.then((providers) => {
|
|
221
|
+
this.remoteRegistryCache = {
|
|
222
|
+
expiresAt: Date.now() + this.remoteCacheTtlMs,
|
|
223
|
+
providers
|
|
224
|
+
};
|
|
225
|
+
this.lastRegistryReadFailure = "";
|
|
226
|
+
return providers;
|
|
227
|
+
})
|
|
228
|
+
.catch((error) => {
|
|
229
|
+
const message = asError(error).message;
|
|
230
|
+
if (message !== this.lastRegistryReadFailure) {
|
|
231
|
+
this.onRemoteReadError?.(message);
|
|
232
|
+
this.lastRegistryReadFailure = message;
|
|
233
|
+
}
|
|
234
|
+
this.remoteRegistryCache = {
|
|
235
|
+
expiresAt: Date.now() + this.remoteCacheTtlMs,
|
|
236
|
+
providers: this.remoteRegistryCache.providers
|
|
237
|
+
};
|
|
238
|
+
return this.remoteRegistryCache.providers;
|
|
239
|
+
})
|
|
240
|
+
.finally(() => {
|
|
241
|
+
this.remoteRegistryRead = undefined;
|
|
242
|
+
});
|
|
243
|
+
return this.remoteRegistryRead;
|
|
244
|
+
}
|
|
245
|
+
async readRemoteForList(authToken) {
|
|
246
|
+
try {
|
|
247
|
+
return await this.readRemote(authToken);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
const message = asError(error).message;
|
|
251
|
+
if (message !== this.lastRegistryReadFailure) {
|
|
252
|
+
this.onRemoteReadError?.(message);
|
|
253
|
+
this.lastRegistryReadFailure = message;
|
|
254
|
+
}
|
|
255
|
+
return this.remoteRegistryCache.providers;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
assertRemoteConfigured() {
|
|
259
|
+
if (this.remoteRegistryUrls.length === 0) {
|
|
260
|
+
throw new Error("No shared registry is configured. Set TERMFLEET_REGISTRY_URL to a registry server URL to use shared providers.");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async fetchRemote(authToken) {
|
|
264
|
+
// No shared registry configured ⇒ local-only, no outbound request.
|
|
265
|
+
if (this.remoteRegistryUrls.length === 0) {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
let lastError;
|
|
269
|
+
for (const remoteRegistryUrl of this.remoteRegistryUrls) {
|
|
270
|
+
try {
|
|
271
|
+
const response = await fetch(remoteRegistryUrl, {
|
|
272
|
+
headers: authToken ? { authorization: `Bearer ${authToken}` } : undefined,
|
|
273
|
+
signal: AbortSignal.timeout(2500)
|
|
274
|
+
});
|
|
275
|
+
if (!response.ok) {
|
|
276
|
+
throw new Error(formatRemoteRegistryHttpError(remoteRegistryUrl, response.status, await response.text()));
|
|
277
|
+
}
|
|
278
|
+
return decorateRegistryProviders(normalizeRegistryPayload(await response.json())
|
|
279
|
+
.filter((provider) => !isLocalProviderUrl(provider.baseUrl)), "remote-registry");
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
lastError = remoteRegistryError(remoteRegistryUrl, "read", error);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
throw asError(lastError);
|
|
286
|
+
}
|
|
287
|
+
async writeRemote(provider, options = {}) {
|
|
288
|
+
const normalized = normalizeRegistryPayload([provider])[0];
|
|
289
|
+
if (!normalized) {
|
|
290
|
+
throw new Error("Registry provider payload is required.");
|
|
291
|
+
}
|
|
292
|
+
this.assertRemoteConfigured();
|
|
293
|
+
let lastError;
|
|
294
|
+
for (const remoteRegistryUrl of this.remoteRegistryUrls) {
|
|
295
|
+
try {
|
|
296
|
+
const response = await fetch(remoteRegistryUrl, {
|
|
297
|
+
body: JSON.stringify(normalized),
|
|
298
|
+
headers: {
|
|
299
|
+
...(options.authToken ? { authorization: `Bearer ${options.authToken}` } : {}),
|
|
300
|
+
"content-type": "application/json"
|
|
301
|
+
},
|
|
302
|
+
method: "POST"
|
|
303
|
+
});
|
|
304
|
+
if (!response.ok) {
|
|
305
|
+
throw new Error(formatRemoteRegistryHttpError(remoteRegistryUrl, response.status, await response.text(), "registration"));
|
|
306
|
+
}
|
|
307
|
+
const responsePayload = await response.json().catch(() => normalized);
|
|
308
|
+
return {
|
|
309
|
+
...normalizeRegistryRegistrationResponse(responsePayload, normalized),
|
|
310
|
+
registrySource: "remote-registry"
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
lastError = remoteRegistryError(remoteRegistryUrl, "registration", error);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
throw asError(lastError);
|
|
318
|
+
}
|
|
319
|
+
async deleteRemote(baseUrl, options = {}) {
|
|
320
|
+
this.assertRemoteConfigured();
|
|
321
|
+
let lastError;
|
|
322
|
+
for (const remoteRegistryUrl of this.remoteRegistryUrls) {
|
|
323
|
+
try {
|
|
324
|
+
const url = new URL(remoteRegistryUrl);
|
|
325
|
+
url.searchParams.set("baseUrl", baseUrl);
|
|
326
|
+
const response = await fetch(url, {
|
|
327
|
+
headers: options.authToken ? { authorization: `Bearer ${options.authToken}` } : undefined,
|
|
328
|
+
method: "DELETE"
|
|
329
|
+
});
|
|
330
|
+
if (!response.ok) {
|
|
331
|
+
throw new Error(formatRemoteRegistryHttpError(remoteRegistryUrl, response.status, await response.text(), "unregistration"));
|
|
332
|
+
}
|
|
333
|
+
const responsePayload = await response.json().catch(() => ({ baseUrl }));
|
|
334
|
+
const [removed] = normalizeRegistryPayload([responsePayload]);
|
|
335
|
+
this.remoteRegistryCache = {
|
|
336
|
+
expiresAt: 0,
|
|
337
|
+
providers: this.remoteRegistryCache.providers.filter((provider) => provider.baseUrl !== baseUrl)
|
|
338
|
+
};
|
|
339
|
+
return {
|
|
340
|
+
...(removed ?? { baseUrl }),
|
|
341
|
+
registrySource: "remote-registry"
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
lastError = remoteRegistryError(remoteRegistryUrl, "unregistration", error);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
throw asError(lastError);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function uniqueStrings(values) {
|
|
352
|
+
const seen = new Set();
|
|
353
|
+
return values.filter((value) => {
|
|
354
|
+
if (seen.has(value))
|
|
355
|
+
return false;
|
|
356
|
+
seen.add(value);
|
|
357
|
+
return true;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
export function decorateRegistryProviders(providers, registrySource) {
|
|
361
|
+
return providers.map((provider) => ({
|
|
362
|
+
...provider,
|
|
363
|
+
registrySource
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
function stripRegistrySource(provider) {
|
|
367
|
+
const { registrySource: _registrySource, ...rest } = provider;
|
|
368
|
+
return rest;
|
|
369
|
+
}
|
|
370
|
+
export function providerId(provider) {
|
|
371
|
+
return (provider.label ?? provider.baseUrl)
|
|
372
|
+
.toLowerCase()
|
|
373
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
374
|
+
.replace(/^-+|-+$/g, "");
|
|
375
|
+
}
|
|
376
|
+
function formatProviderTargetMatch(provider) {
|
|
377
|
+
return provider.label ? `${provider.label} (${provider.baseUrl})` : provider.baseUrl;
|
|
378
|
+
}
|
|
379
|
+
export function normalizeProviderTarget(target) {
|
|
380
|
+
try {
|
|
381
|
+
return normalizeProviderOrigin(target);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return target;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function formatRemoteRegistryHttpError(remoteRegistryUrl, status, body, operation = "read") {
|
|
388
|
+
const message = body.trim();
|
|
389
|
+
if (status === 502 && /ECONNREFUSED|Local server error/i.test(message)) {
|
|
390
|
+
const url = new URL(remoteRegistryUrl);
|
|
391
|
+
return [
|
|
392
|
+
`Shared registry ${operation} failed: ${url.origin} is reachable, but its backing registry server is not running.`,
|
|
393
|
+
"The registry tunnel is forwarding to a local port that refused the connection.",
|
|
394
|
+
"Start the backing registry server on the tunnel target, for example:",
|
|
395
|
+
" npx tsx src/cli.ts serve-registry --host 127.0.0.1 --port 7401 --file .termfleet-remote-registry.json"
|
|
396
|
+
].join("\n");
|
|
397
|
+
}
|
|
398
|
+
return `Shared registry ${operation} failed: ${remoteRegistryUrl} returned ${status}${message ? `: ${message}` : ""}`;
|
|
399
|
+
}
|
|
400
|
+
function remoteRegistryError(remoteRegistryUrl, operation, error) {
|
|
401
|
+
const message = asError(error).message;
|
|
402
|
+
if (message.includes(remoteRegistryUrl)) {
|
|
403
|
+
return new Error(message);
|
|
404
|
+
}
|
|
405
|
+
return new Error(`Shared registry ${operation} failed: ${remoteRegistryUrl}: ${message}`);
|
|
406
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SessionLifecycleState } from "./session-lifecycle.js";
|
|
2
|
+
export type SessionLiveness = "background" | "ended" | "running" | "waiting";
|
|
3
|
+
export declare function livenessForState(state: SessionLifecycleState): Exclude<SessionLiveness, "ended">;
|
|
4
|
+
export type AttentionLabel = "asking" | "awaiting_me" | "ended" | "errored" | "stuck" | "working";
|
|
5
|
+
export declare const ATTENTION_RANK: Record<AttentionLabel, number>;
|
|
6
|
+
export declare const STUCK_IDLE_SECONDS = 300;
|
|
7
|
+
export declare function computeAttention(options: {
|
|
8
|
+
idleSeconds?: number;
|
|
9
|
+
liveness: SessionLiveness;
|
|
10
|
+
signal?: "asking" | "errored";
|
|
11
|
+
}): AttentionLabel;
|
|
12
|
+
export declare const NEEDS_ATTENTION: ReadonlySet<AttentionLabel>;
|
|
13
|
+
export type AttentionRow = {
|
|
14
|
+
agentSessionId: string;
|
|
15
|
+
attention: AttentionLabel;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
terminalId?: string;
|
|
18
|
+
title: string;
|
|
19
|
+
windowId?: number;
|
|
20
|
+
};
|
|
21
|
+
export type AttentionTransition = AttentionRow & {
|
|
22
|
+
from: AttentionLabel | "new";
|
|
23
|
+
};
|
|
24
|
+
export declare function attentionTransitions(previous: Map<string, AttentionLabel>, rows: AttentionRow[], firstTick: boolean): AttentionTransition[];
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Collapse the raw lifecycle observation into normalized liveness (the live cases).
|
|
2
|
+
export function livenessForState(state) {
|
|
3
|
+
if (state === "session_running")
|
|
4
|
+
return "running";
|
|
5
|
+
if (state === "session_stopped_background_running")
|
|
6
|
+
return "background";
|
|
7
|
+
return "waiting";
|
|
8
|
+
}
|
|
9
|
+
export const ATTENTION_RANK = { asking: 1, awaiting_me: 2, ended: 5, errored: 0, stuck: 3, working: 4 };
|
|
10
|
+
// A foreground-running session whose transcript hasn't moved in this long looks
|
|
11
|
+
// busy but is likely wedged (long tool hang / crash without exit). Heuristic.
|
|
12
|
+
export const STUCK_IDLE_SECONDS = 300;
|
|
13
|
+
// `signal` (errored/asking) is the finer pane read from the lifecycle and overrides
|
|
14
|
+
// the coarse liveness when present — a crash or a pending choice needs the human now.
|
|
15
|
+
export function computeAttention(options) {
|
|
16
|
+
if (options.liveness !== "ended") {
|
|
17
|
+
if (options.signal === "errored")
|
|
18
|
+
return "errored";
|
|
19
|
+
if (options.signal === "asking")
|
|
20
|
+
return "asking";
|
|
21
|
+
}
|
|
22
|
+
if (options.liveness === "ended")
|
|
23
|
+
return "ended";
|
|
24
|
+
if (options.liveness === "waiting")
|
|
25
|
+
return "awaiting_me";
|
|
26
|
+
if (options.liveness === "running" && (options.idleSeconds ?? 0) >= STUCK_IDLE_SECONDS)
|
|
27
|
+
return "stuck";
|
|
28
|
+
return "working";
|
|
29
|
+
}
|
|
30
|
+
// The attention states that warrant interrupting the human. Notify fires only on a
|
|
31
|
+
// transition INTO one of these — not every poll, and not for working/ended.
|
|
32
|
+
export const NEEDS_ATTENTION = new Set(["errored", "asking", "awaiting_me", "stuck"]);
|
|
33
|
+
// Diff the current rows against the last-seen attention per session and return the
|
|
34
|
+
// ones that just entered a needs-attention state. Mutates `previous` to the new
|
|
35
|
+
// state. The first tick only seeds the baseline (no spam on startup).
|
|
36
|
+
export function attentionTransitions(previous, rows, firstTick) {
|
|
37
|
+
const transitions = [];
|
|
38
|
+
for (const row of rows) {
|
|
39
|
+
const prior = previous.get(row.agentSessionId);
|
|
40
|
+
previous.set(row.agentSessionId, row.attention);
|
|
41
|
+
if (firstTick || prior === row.attention || !NEEDS_ATTENTION.has(row.attention))
|
|
42
|
+
continue;
|
|
43
|
+
transitions.push({
|
|
44
|
+
agentSessionId: row.agentSessionId,
|
|
45
|
+
attention: row.attention,
|
|
46
|
+
...(row.cwd ? { cwd: row.cwd } : {}),
|
|
47
|
+
from: prior ?? "new",
|
|
48
|
+
...(row.terminalId ? { terminalId: row.terminalId } : {}),
|
|
49
|
+
title: row.title,
|
|
50
|
+
...(row.windowId !== undefined ? { windowId: row.windowId } : {})
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return transitions;
|
|
54
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export type SessionLifecycleState = "session_running" | "session_stopped_background_running" | "session_waiting";
|
|
2
|
+
export type PaneLifecycle = {
|
|
3
|
+
paneId: string;
|
|
4
|
+
target: string;
|
|
5
|
+
rootPid: number;
|
|
6
|
+
currentSessionId: string | null;
|
|
7
|
+
sessionIds: string[];
|
|
8
|
+
};
|
|
9
|
+
export type SessionAttentionSignal = "asking" | "errored";
|
|
10
|
+
export type SessionLifecycle = {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
agentSessionId?: string;
|
|
13
|
+
provider: string;
|
|
14
|
+
attachedPaneId: string | null;
|
|
15
|
+
lastPaneId: string | null;
|
|
16
|
+
firstSeenAt: string;
|
|
17
|
+
lastSeenAt: string;
|
|
18
|
+
state: SessionLifecycleState;
|
|
19
|
+
signal?: SessionAttentionSignal;
|
|
20
|
+
mainPid?: number;
|
|
21
|
+
backgroundLeaseDir?: string;
|
|
22
|
+
background: BackgroundWorkItem[];
|
|
23
|
+
};
|
|
24
|
+
export type LifecycleSnapshot = {
|
|
25
|
+
observedAt: string;
|
|
26
|
+
panes: PaneLifecycle[];
|
|
27
|
+
sessions: SessionLifecycle[];
|
|
28
|
+
};
|
|
29
|
+
export type LifecycleStore = {
|
|
30
|
+
panes: Map<string, PaneLifecycle>;
|
|
31
|
+
sessions: Map<string, SessionLifecycle>;
|
|
32
|
+
};
|
|
33
|
+
export type PaneProcessObservation = {
|
|
34
|
+
backgroundLeaseDir?: string;
|
|
35
|
+
contents?: string;
|
|
36
|
+
paneId: string;
|
|
37
|
+
target: string;
|
|
38
|
+
rootPid: number;
|
|
39
|
+
};
|
|
40
|
+
export type ProcessTreeRow = {
|
|
41
|
+
pid: number;
|
|
42
|
+
ppid: number;
|
|
43
|
+
pgid?: number;
|
|
44
|
+
stat?: string;
|
|
45
|
+
command: string;
|
|
46
|
+
args: string;
|
|
47
|
+
};
|
|
48
|
+
export type BackgroundWorkItem = {
|
|
49
|
+
args?: string;
|
|
50
|
+
command?: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
pid: number;
|
|
53
|
+
ppid?: number;
|
|
54
|
+
pgid?: number;
|
|
55
|
+
processStartTime?: string;
|
|
56
|
+
source: "descendant" | "explicit-lease" | "leased-descendant";
|
|
57
|
+
};
|
|
58
|
+
type BackgroundLeaseFile = {
|
|
59
|
+
args?: unknown;
|
|
60
|
+
command?: unknown;
|
|
61
|
+
description?: unknown;
|
|
62
|
+
pid?: unknown;
|
|
63
|
+
processStartTime?: unknown;
|
|
64
|
+
};
|
|
65
|
+
export declare function snapshotProcessTreeAsync(rootPids?: number[]): Promise<Map<number, ProcessTreeRow>>;
|
|
66
|
+
export declare function snapshotProcessTree(rootPids?: number[]): Map<number, ProcessTreeRow>;
|
|
67
|
+
export declare function snapshotDescendantPids(rootPids: number[]): number[];
|
|
68
|
+
export declare function createLifecycleStore(): LifecycleStore;
|
|
69
|
+
export type ObserveIo = {
|
|
70
|
+
leasesByDir: Map<string, BackgroundLeaseFile[]>;
|
|
71
|
+
startTimes: Map<number, string>;
|
|
72
|
+
};
|
|
73
|
+
export declare function observeLifecycle(options: {
|
|
74
|
+
io?: ObserveIo;
|
|
75
|
+
now?: Date;
|
|
76
|
+
panes: PaneProcessObservation[];
|
|
77
|
+
processRows: Map<number, ProcessTreeRow>;
|
|
78
|
+
store?: LifecycleStore;
|
|
79
|
+
}): LifecycleSnapshot;
|
|
80
|
+
export declare function classifyAttentionSignal(contents?: string): SessionAttentionSignal | undefined;
|
|
81
|
+
export declare function gatherProcessStartTimes(): Promise<Map<number, string>>;
|
|
82
|
+
export declare function gatherBackgroundLeases(dirs: Array<string | undefined>): Promise<Map<string, BackgroundLeaseFile[]>>;
|
|
83
|
+
export {};
|