@threahq/remote-session 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/index.js ADDED
@@ -0,0 +1,3683 @@
1
+ // src/session.ts
2
+ import { createHash as createHash2 } from "node:crypto";
3
+ import { homedir, hostname } from "node:os";
4
+ import { join as join2 } from "node:path";
5
+ import {
6
+ ArchiveGraceController,
7
+ BotKeyring,
8
+ E2eKeyring,
9
+ e2eKeyAccount,
10
+ mintE2eKeyRecord,
11
+ readLegacyBikFile,
12
+ resolveKeyStore,
13
+ FileKeyStore,
14
+ WS_BACKSTOP_POLL_MS,
15
+ BotRuntimeTransport,
16
+ mintStreamKeyWraps,
17
+ openSealedAck,
18
+ openSealedDecisionNote,
19
+ openSealedTurnContext,
20
+ parseSealedAckContext,
21
+ parseSealedTurnContext,
22
+ scrubSealedError,
23
+ sealDecision,
24
+ sealReply,
25
+ sealStep
26
+ } from "@threahq/bot-runtime-client";
27
+
28
+ // src/attachments.ts
29
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
30
+ import { basename, dirname, join, resolve } from "node:path";
31
+ import {
32
+ attachmentLocalPath,
33
+ decryptAttachmentBytes,
34
+ encryptAttachmentBytes
35
+ } from "@threahq/bot-runtime-client";
36
+ var ATTACH_DIRECTIVE_RE = /^THREA_ATTACH:\s*(.+?)\s*$/;
37
+ var ATTACHMENT_DIR = ".threa-attachments";
38
+ function extractAttachmentDirectives(markdown) {
39
+ const paths = [];
40
+ const lines = markdown.split(`
41
+ `).filter((line) => {
42
+ const match = line.match(ATTACH_DIRECTIVE_RE);
43
+ if (!match)
44
+ return true;
45
+ paths.push(match[1]);
46
+ return false;
47
+ });
48
+ return { markdown: lines.join(`
49
+ `).trim(), paths };
50
+ }
51
+ function guessMimeType(path) {
52
+ const lower = path.toLowerCase();
53
+ if (lower.endsWith(".png"))
54
+ return "image/png";
55
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg"))
56
+ return "image/jpeg";
57
+ if (lower.endsWith(".gif"))
58
+ return "image/gif";
59
+ if (lower.endsWith(".webp"))
60
+ return "image/webp";
61
+ if (lower.endsWith(".svg"))
62
+ return "image/svg+xml";
63
+ if (lower.endsWith(".html"))
64
+ return "text/html";
65
+ if (lower.endsWith(".md"))
66
+ return "text/markdown";
67
+ if (lower.endsWith(".txt"))
68
+ return "text/plain";
69
+ if (lower.endsWith(".csv"))
70
+ return "text/csv";
71
+ if (lower.endsWith(".json"))
72
+ return "application/json";
73
+ if (lower.endsWith(".pdf"))
74
+ return "application/pdf";
75
+ return "application/octet-stream";
76
+ }
77
+ function selectInboundAttachments(messages, sourceMessageId, contextMessageIds) {
78
+ const inScope = new Set([sourceMessageId, ...contextMessageIds]);
79
+ const ordered = [...messages].sort((a, b) => Number(b.id === sourceMessageId) - Number(a.id === sourceMessageId));
80
+ const seen = new Set;
81
+ const selected = [];
82
+ for (const message of ordered) {
83
+ if (!inScope.has(message.id))
84
+ continue;
85
+ for (const attachment of message.attachments ?? []) {
86
+ if (seen.has(attachment.id))
87
+ continue;
88
+ seen.add(attachment.id);
89
+ selected.push({ attachment, messageId: message.id, isSource: message.id === sourceMessageId });
90
+ }
91
+ }
92
+ return selected;
93
+ }
94
+ function formatInboundAttachmentManifest(downloaded) {
95
+ if (downloaded.length === 0)
96
+ return "";
97
+ const lines = downloaded.map((entry) => {
98
+ const marker = entry.isSource ? " [attached to the message you just received]" : "";
99
+ const { filename, mimeType, sizeBytes } = entry.attachment;
100
+ return `- ${filename} (${mimeType}, ${sizeBytes} bytes) → ${entry.localPath}${marker}`;
101
+ });
102
+ return ["Attachments saved into this session's working directory — read them from these paths:", ...lines].join(`
103
+ `);
104
+ }
105
+ function buildReplyAttachmentSection(uploaded, failed) {
106
+ const parts = [];
107
+ if (uploaded.length > 0) {
108
+ parts.push(["Attachments:", ...uploaded.map((a) => `- [${a.filename}](attachment:${a.id})`)].join(`
109
+ `));
110
+ }
111
+ if (failed.length > 0) {
112
+ parts.push(["Attachment upload failed:", ...failed.map((f) => `- ${f}`)].join(`
113
+ `));
114
+ }
115
+ return parts.join(`
116
+
117
+ `);
118
+ }
119
+ var DOWNLOAD_TIMEOUT_MS = 60000;
120
+ function throwIfAborted(signal) {
121
+ if (!signal?.aborted)
122
+ return;
123
+ throw signal.reason instanceof Error ? signal.reason : new Error("attachment hydration aborted");
124
+ }
125
+ async function fetchAttachmentBytes(url, timeoutMs = DOWNLOAD_TIMEOUT_MS, signal) {
126
+ const timeout = AbortSignal.timeout(timeoutMs);
127
+ const response = await fetch(url, { signal: signal ? AbortSignal.any([timeout, signal]) : timeout });
128
+ if (!response.ok)
129
+ throw new Error(`download failed with ${response.status}`);
130
+ const bytes = new Uint8Array(await response.arrayBuffer());
131
+ throwIfAborted(signal);
132
+ return bytes;
133
+ }
134
+ async function downloadInboundAttachments(client, params) {
135
+ throwIfAborted(params.signal);
136
+ const messages = await client.listStreamMessages(params.streamId, { limit: params.scanLimit });
137
+ throwIfAborted(params.signal);
138
+ const selected = selectInboundAttachments(messages, params.sourceMessageId, params.contextMessageIds);
139
+ if (selected.length === 0)
140
+ return [];
141
+ const dir = join(params.cwd, ATTACHMENT_DIR, params.invocationId);
142
+ const downloaded = [];
143
+ for (const item of selected) {
144
+ try {
145
+ throwIfAborted(params.signal);
146
+ const url = await client.getAttachmentDownloadUrl(item.attachment.id);
147
+ const bytes = await fetchAttachmentBytes(url, DOWNLOAD_TIMEOUT_MS, params.signal);
148
+ const localPath = attachmentLocalPath(dir, item.attachment.id, item.attachment.filename);
149
+ mkdirSync(dirname(localPath), { recursive: true });
150
+ writeFileSync(localPath, bytes);
151
+ throwIfAborted(params.signal);
152
+ downloaded.push({ ...item, localPath });
153
+ } catch (error) {
154
+ if (params.strict || params.signal?.aborted)
155
+ throw error;
156
+ params.log(`attachment ${item.attachment.id} download failed: ${String(error)}`);
157
+ }
158
+ }
159
+ return downloaded;
160
+ }
161
+ async function uploadFile(client, path, cwd) {
162
+ const absolute = resolve(cwd, path);
163
+ const stats = statSync(absolute);
164
+ if (!stats.isFile())
165
+ throw new Error(`${path} is not a file`);
166
+ const bytes = readFileSync(absolute);
167
+ const form = new FormData;
168
+ form.append("file", new Blob([bytes], { type: guessMimeType(absolute) }), basename(absolute));
169
+ return client.uploadAttachment(form);
170
+ }
171
+ async function uploadReplyAttachments(client, markdown, cwd) {
172
+ const { markdown: stripped, paths } = extractAttachmentDirectives(markdown);
173
+ const uploaded = [];
174
+ const failed = [];
175
+ for (const path of paths) {
176
+ try {
177
+ uploaded.push(await uploadFile(client, path, cwd));
178
+ } catch (error) {
179
+ failed.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
180
+ }
181
+ }
182
+ const section = buildReplyAttachmentSection(uploaded, failed);
183
+ const finalMarkdown = [stripped, section].filter(Boolean).join(`
184
+
185
+ `);
186
+ return { markdown: finalMarkdown, uploaded, failed };
187
+ }
188
+ var SEALED_UPLOAD_FILENAME = "encrypted";
189
+ var SEALED_UPLOAD_MIME = "application/octet-stream";
190
+ var MAX_SEALED_ATTACHMENTS_PER_MESSAGE = 16;
191
+ async function uploadSealedFile(client, path, cwd) {
192
+ const absolute = resolve(cwd, path);
193
+ const stats = statSync(absolute);
194
+ if (!stats.isFile())
195
+ throw new Error(`${path} is not a file`);
196
+ const bytes = readFileSync(absolute);
197
+ const encrypted = await encryptAttachmentBytes(bytes);
198
+ const form = new FormData;
199
+ form.append("e2e", "true");
200
+ form.append("file", new Blob([encrypted.ciphertext], { type: SEALED_UPLOAD_MIME }), SEALED_UPLOAD_FILENAME);
201
+ const summary = await client.uploadAttachment(form);
202
+ return {
203
+ attachmentId: summary.id,
204
+ key: encrypted.key,
205
+ iv: encrypted.iv,
206
+ filename: basename(absolute),
207
+ mimeType: guessMimeType(absolute),
208
+ sizeBytes: bytes.length
209
+ };
210
+ }
211
+ async function uploadSealedReplyAttachments(client, markdown, cwd) {
212
+ const { markdown: stripped, paths } = extractAttachmentDirectives(markdown);
213
+ const refs = [];
214
+ const failed = [];
215
+ for (const path of paths.slice(MAX_SEALED_ATTACHMENTS_PER_MESSAGE)) {
216
+ failed.push(`${path}: over the ${MAX_SEALED_ATTACHMENTS_PER_MESSAGE}-attachment limit for one message`);
217
+ }
218
+ for (const path of paths.slice(0, MAX_SEALED_ATTACHMENTS_PER_MESSAGE)) {
219
+ try {
220
+ refs.push(await uploadSealedFile(client, path, cwd));
221
+ } catch (error) {
222
+ failed.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
223
+ }
224
+ }
225
+ const failureNote = failed.length > 0 ? ["Attachment upload failed:", ...failed.map((f) => `- ${f}`)].join(`
226
+ `) : "";
227
+ return {
228
+ markdown: [stripped, failureNote].filter(Boolean).join(`
229
+
230
+ `),
231
+ refs,
232
+ attachmentIds: refs.map((ref) => ref.attachmentId)
233
+ };
234
+ }
235
+ function selectSealedInboundRefs(promptRefs, historyRefs) {
236
+ const seen = new Set;
237
+ const selected = [];
238
+ for (const { refs, isSource } of [
239
+ { refs: promptRefs, isSource: true },
240
+ { refs: historyRefs, isSource: false }
241
+ ]) {
242
+ for (const ref of refs) {
243
+ if (seen.has(ref.attachmentId))
244
+ continue;
245
+ seen.add(ref.attachmentId);
246
+ selected.push({ ref, isSource });
247
+ }
248
+ }
249
+ return selected;
250
+ }
251
+ async function downloadSealedInboundAttachments(client, params) {
252
+ throwIfAborted(params.signal);
253
+ if (params.refs.length === 0)
254
+ return [];
255
+ const dir = join(params.cwd, ATTACHMENT_DIR, params.invocationId);
256
+ const downloaded = [];
257
+ for (const { ref, isSource } of params.refs) {
258
+ try {
259
+ throwIfAborted(params.signal);
260
+ const url = await client.getAttachmentDownloadUrl(ref.attachmentId);
261
+ const ciphertext = await fetchAttachmentBytes(url, DOWNLOAD_TIMEOUT_MS, params.signal);
262
+ const plaintext = await decryptAttachmentBytes({ ciphertext, key: ref.key, iv: ref.iv });
263
+ throwIfAborted(params.signal);
264
+ const localPath = attachmentLocalPath(dir, ref.attachmentId, ref.filename);
265
+ mkdirSync(dirname(localPath), { recursive: true });
266
+ writeFileSync(localPath, plaintext);
267
+ downloaded.push({
268
+ attachment: { id: ref.attachmentId, filename: ref.filename, mimeType: ref.mimeType, sizeBytes: ref.sizeBytes },
269
+ messageId: "",
270
+ isSource,
271
+ localPath
272
+ });
273
+ } catch (error) {
274
+ if (params.strict || params.signal?.aborted)
275
+ throw error;
276
+ params.log(`sealed attachment ${ref.attachmentId} download failed: ${String(error)}`);
277
+ }
278
+ }
279
+ return downloaded;
280
+ }
281
+
282
+ // src/identity.ts
283
+ import { createHash } from "node:crypto";
284
+ import {
285
+ E2E_KEY_SCOPES,
286
+ E2E_KEY_STORE_KINDS
287
+ } from "@threahq/bot-runtime-client";
288
+ var TRACE_MODES = ["headline", "commands"];
289
+ var TRACE_MODE_SET = new Set(TRACE_MODES);
290
+ var UNSAFE_ID_CHARS = /[^A-Za-z0-9_-]+/g;
291
+ function sanitizeId(raw) {
292
+ return raw.replace(UNSAFE_ID_CHARS, "-").replace(/^-+|-+$/g, "");
293
+ }
294
+ function deriveStableId(prefix, seed) {
295
+ const hash = createHash("sha256").update(seed).digest("hex").slice(0, 16);
296
+ return `${prefix}-${hash}`.slice(0, 64);
297
+ }
298
+ function defaultDisplayName(cwd, prefix, override) {
299
+ const effective = override?.trim() ? override.trim() : prefix;
300
+ const dir = cwd.split("/").filter(Boolean).pop() ?? "session";
301
+ const name = `${effective} - ${dir}`;
302
+ return name.length > 100 ? name.slice(0, 100) : name;
303
+ }
304
+ function parseConfigFile(text) {
305
+ const parsed = JSON.parse(text);
306
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
307
+ throw new Error("config file must be a JSON object");
308
+ }
309
+ return parsed;
310
+ }
311
+ function str(value) {
312
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
313
+ }
314
+ function parseColdStartIfArchived(value) {
315
+ return str(value)?.toLowerCase() === "wait" ? "wait" : "replace";
316
+ }
317
+ function parseColdStartIfMissing(value) {
318
+ return str(value)?.toLowerCase() === "error" ? "error" : "create";
319
+ }
320
+ function parseBool(value, fallback) {
321
+ if (typeof value === "boolean")
322
+ return value;
323
+ const s = str(value)?.toLowerCase();
324
+ if (s === undefined)
325
+ return fallback;
326
+ if (["0", "false", "no", "off"].includes(s))
327
+ return false;
328
+ if (["1", "true", "yes", "on"].includes(s))
329
+ return true;
330
+ return fallback;
331
+ }
332
+ function parseNum(value, fallback, min) {
333
+ const n = typeof value === "number" ? value : Number(str(value));
334
+ return Number.isFinite(n) ? Math.max(min, Math.floor(n)) : fallback;
335
+ }
336
+ function parseTraceMode(value) {
337
+ const mode = str(value)?.toLowerCase();
338
+ return mode && TRACE_MODE_SET.has(mode) ? mode : undefined;
339
+ }
340
+ var KEY_SCOPE_SET = new Set(E2E_KEY_SCOPES);
341
+ var KEY_STORE_SET = new Set(E2E_KEY_STORE_KINDS);
342
+ function loadConfig(input, identity) {
343
+ const { env, cwd, hostname, file = {} } = input;
344
+ const baseUrl = str(env.THREA_BASE_URL) ?? str(file.baseUrl) ?? "https://app.threa.io";
345
+ const workspaceId = str(env.THREA_WORKSPACE_ID) ?? str(file.workspaceId);
346
+ const apiKey = str(env.THREA_API_KEY) ?? str(file.apiKey);
347
+ const missing = [!workspaceId && "THREA_WORKSPACE_ID", !apiKey && "THREA_API_KEY"].filter(Boolean);
348
+ if (missing.length > 0) {
349
+ const hint = identity.configPathHint ? ` or ${identity.configPathHint}` : "";
350
+ return { error: `Missing required config: ${missing.join(", ")}. Set env vars${hint}.` };
351
+ }
352
+ const displayName = defaultDisplayName(cwd, identity.displayNamePrefix, str(env.THREA_DISPLAY_NAME) ?? str(file.displayName));
353
+ const defaultLabel = str(env.THREA_DEFAULT_LABEL) ?? str(file.defaultLabel);
354
+ const configuredTraceMode = str(env.THREA_TRACE_MODE) ?? file.traceMode;
355
+ const traceMode = configuredTraceMode === undefined ? "headline" : parseTraceMode(configuredTraceMode);
356
+ if (!traceMode) {
357
+ return { error: "Invalid traceMode: expected headline or commands." };
358
+ }
359
+ const seed = `${hostname}:${cwd}`;
360
+ const instanceId = sanitizeId(str(env.THREA_INSTANCE_ID) ?? str(file.instanceId) ?? deriveStableId(identity.idPrefix, seed)).slice(0, 64);
361
+ const runtimeSessionId = sanitizeId(str(env.THREA_RUNTIME_SESSION_ID) ?? str(file.runtimeSessionId) ?? deriveStableId(identity.sessionIdPrefix, seed)).slice(0, 64);
362
+ if (!instanceId || !runtimeSessionId) {
363
+ return { error: "Could not derive a valid instanceId/runtimeSessionId (empty after sanitization)." };
364
+ }
365
+ const configuredKeyScope = str(env.THREA_E2E_KEY_SCOPE) ?? str(file.keyScope);
366
+ if (configuredKeyScope !== undefined && !KEY_SCOPE_SET.has(configuredKeyScope.toLowerCase())) {
367
+ return { error: `Invalid keyScope: expected one of ${E2E_KEY_SCOPES.join(", ")}.` };
368
+ }
369
+ const keyScope = configuredKeyScope?.toLowerCase() ?? "host";
370
+ const configuredKeyStore = str(env.THREA_E2E_KEY_STORE) ?? str(file.keyStore);
371
+ if (configuredKeyStore !== undefined && !KEY_STORE_SET.has(configuredKeyStore.toLowerCase())) {
372
+ return { error: `Invalid keyStore: expected one of ${E2E_KEY_STORE_KINDS.join(", ")}.` };
373
+ }
374
+ const keyStore = configuredKeyStore?.toLowerCase();
375
+ return {
376
+ config: {
377
+ baseUrl: baseUrl.replace(/\/$/, ""),
378
+ workspaceId,
379
+ apiKey,
380
+ displayName,
381
+ defaultLabel,
382
+ coldStartIfArchived: parseColdStartIfArchived(str(env.THREA_COLD_START_IF_ARCHIVED) ?? file.coldStartIfArchived),
383
+ coldStartIfMissing: parseColdStartIfMissing(str(env.THREA_COLD_START_IF_MISSING) ?? file.coldStartIfMissing),
384
+ expectedRootStreamId: str(env.THREA_EXPECTED_ROOT_STREAM_ID) ?? str(file.expectedRootStreamId),
385
+ instanceId,
386
+ runtimeSessionId,
387
+ permissionRelay: parseBool(env.THREA_PERMISSION_RELAY ?? file.permissionRelay, true),
388
+ pollMs: parseNum(env.THREA_POLL_MS ?? file.pollMs, 3000, 1000),
389
+ idleTimeoutMs: parseNum(env.THREA_IDLE_TIMEOUT_MS ?? file.idleTimeoutMs, 3600000, 60000),
390
+ bikPath: str(env.THREA_BIK_PATH) ?? str(file.bikPath),
391
+ keyScope,
392
+ keyStore,
393
+ keyDir: str(env.THREA_E2E_KEY_DIR) ?? str(file.keyDir),
394
+ e2e: parseBool(env.THREA_E2E ?? file.e2e, false),
395
+ sealedFullTrace: parseBool(env.THREA_SEALED_FULL_TRACE ?? file.sealedFullTrace, true),
396
+ traceMode,
397
+ delegations: parseBool(env.THREA_DELEGATIONS ?? file.delegations, false)
398
+ }
399
+ };
400
+ }
401
+
402
+ // src/client.ts
403
+ import {
404
+ THREA_CALLBACK_TOKEN_HEADER
405
+ } from "@threahq/bot-runtime-client";
406
+ var FETCH_TIMEOUT_MS = 30000;
407
+
408
+ class ThreaApiError extends Error {
409
+ status;
410
+ code;
411
+ retryAfterMs;
412
+ constructor(message, status, code, retryAfterMs) {
413
+ super(message);
414
+ this.status = status;
415
+ this.code = code;
416
+ this.retryAfterMs = retryAfterMs;
417
+ this.name = "ThreaApiError";
418
+ }
419
+ }
420
+ function retryDelayMs(headers) {
421
+ const retryAfter = headers.get("Retry-After")?.trim();
422
+ if (retryAfter) {
423
+ const delay = /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now();
424
+ if (Number.isFinite(delay))
425
+ return Math.max(0, delay);
426
+ }
427
+ const reset = headers.get("RateLimit-Reset")?.trim();
428
+ if (reset && /^\d+$/.test(reset)) {
429
+ const delay = Number(reset) * 1000;
430
+ if (Number.isFinite(delay))
431
+ return delay;
432
+ }
433
+ return;
434
+ }
435
+
436
+ class ThreaClient {
437
+ opts;
438
+ constructor(opts) {
439
+ this.opts = opts;
440
+ }
441
+ get base() {
442
+ return this.opts.baseUrl.replace(/\/$/, "");
443
+ }
444
+ async request(path, init) {
445
+ const controller = new AbortController;
446
+ const abortFromCaller = () => controller.abort(init?.signal?.reason);
447
+ if (init?.signal?.aborted)
448
+ abortFromCaller();
449
+ else
450
+ init?.signal?.addEventListener("abort", abortFromCaller, { once: true });
451
+ const timeout = setTimeout(() => controller.abort(), this.opts.fetchTimeoutMs ?? FETCH_TIMEOUT_MS);
452
+ try {
453
+ return await this.requestWithin(path, init, controller.signal);
454
+ } finally {
455
+ clearTimeout(timeout);
456
+ init?.signal?.removeEventListener("abort", abortFromCaller);
457
+ }
458
+ }
459
+ async requestWithin(path, init, signal) {
460
+ const isFormData = typeof FormData !== "undefined" && init?.body instanceof FormData;
461
+ const response = await fetch(`${this.base}${path}`, {
462
+ ...init,
463
+ signal,
464
+ headers: {
465
+ Authorization: `Bearer ${this.opts.apiKey}`,
466
+ ...isFormData ? {} : { "Content-Type": "application/json" },
467
+ ...init?.headers
468
+ }
469
+ });
470
+ if (!response.ok) {
471
+ let code;
472
+ let serverMessage;
473
+ if (response.headers.get("content-type")?.includes("application/json")) {
474
+ try {
475
+ const body = (await response.text()).slice(0, 2000);
476
+ const parsed = JSON.parse(body);
477
+ if (typeof parsed.code === "string")
478
+ code = parsed.code;
479
+ if (typeof parsed.error === "string")
480
+ serverMessage = parsed.error;
481
+ } catch {
482
+ code = undefined;
483
+ }
484
+ }
485
+ const detail = [code, serverMessage].filter(Boolean).join(" — ");
486
+ throw new ThreaApiError(`Threa API ${response.status}${detail ? ` (${detail})` : `: ${response.statusText}`}`, response.status, code, retryDelayMs(response.headers));
487
+ }
488
+ if (response.status === 204)
489
+ return;
490
+ return await response.json();
491
+ }
492
+ workspacePath(suffix) {
493
+ return `/api/v1/workspaces/${this.opts.workspaceId}${suffix}`;
494
+ }
495
+ async getMe() {
496
+ const body = await this.request(this.workspacePath("/me"));
497
+ return body.data;
498
+ }
499
+ async createSession(body) {
500
+ const result = await this.request(this.workspacePath("/bot-runtime/sessions"), {
501
+ method: "POST",
502
+ body: JSON.stringify(body)
503
+ });
504
+ return result.data;
505
+ }
506
+ async claim(body) {
507
+ const result = await this.request(this.workspacePath("/bot-invocations/claim"), { method: "POST", body: JSON.stringify(body) });
508
+ return result.data;
509
+ }
510
+ async complete(invocationId, body, signal) {
511
+ await this.request(this.workspacePath(`/bot-invocations/${invocationId}/complete`), {
512
+ method: "POST",
513
+ body: JSON.stringify(body),
514
+ signal
515
+ });
516
+ }
517
+ async fail(invocationId, body) {
518
+ await this.request(this.workspacePath(`/bot-invocations/${invocationId}/fail`), {
519
+ method: "POST",
520
+ body: JSON.stringify(body)
521
+ });
522
+ }
523
+ async sendMessage(streamId, body) {
524
+ const result = await this.request(this.workspacePath(`/streams/${streamId}/messages`), {
525
+ method: "POST",
526
+ body: JSON.stringify(body)
527
+ });
528
+ const id = result?.data?.id;
529
+ if (typeof id !== "string" || !id)
530
+ throw new Error(`Threa API returned no message id for stream ${streamId}`);
531
+ return { id };
532
+ }
533
+ async sendInvocationMessage(invocationId, body) {
534
+ await this.request(this.workspacePath(`/bot-invocations/${invocationId}/messages`), {
535
+ method: "POST",
536
+ body: JSON.stringify(body)
537
+ });
538
+ }
539
+ async sendSealedMessage(invocationId, callbackToken, body) {
540
+ await this.request(this.workspacePath(`/bot-invocations/${invocationId}/sealed-messages`), {
541
+ method: "POST",
542
+ headers: { [THREA_CALLBACK_TOKEN_HEADER]: callbackToken },
543
+ body: JSON.stringify(body)
544
+ });
545
+ }
546
+ async getOwnerE2eKey() {
547
+ const body = await this.request(this.workspacePath("/bot-runtime/owner-e2e-key"));
548
+ return body.data;
549
+ }
550
+ async provisionStreamKeyWraps(streamId, body) {
551
+ await this.request(this.workspacePath(`/streams/${streamId}/e2e/key-wraps`), {
552
+ method: "POST",
553
+ body: JSON.stringify(body)
554
+ });
555
+ }
556
+ async completeSealed(invocationId, callbackToken, body, signal) {
557
+ await this.request(this.workspacePath(`/bot-invocations/${invocationId}/sealed-complete`), {
558
+ method: "POST",
559
+ headers: { [THREA_CALLBACK_TOKEN_HEADER]: callbackToken },
560
+ body: JSON.stringify(body),
561
+ signal
562
+ });
563
+ }
564
+ async requestDecision(streamId, body) {
565
+ const result = await this.request(this.workspacePath(`/streams/${streamId}/decisions`), {
566
+ method: "POST",
567
+ body: JSON.stringify(body)
568
+ });
569
+ return result.data;
570
+ }
571
+ async getDecision(decisionId) {
572
+ const result = await this.request(this.workspacePath(`/decisions/${decisionId}`));
573
+ return result.data;
574
+ }
575
+ async cancelDecision(decisionId) {
576
+ const result = await this.request(this.workspacePath(`/decisions/${decisionId}/cancel`), { method: "POST" });
577
+ return result.data;
578
+ }
579
+ async listStreamMessages(streamId, query = {}) {
580
+ const suffix = query.limit ? `?limit=${query.limit}` : "";
581
+ const body = await this.request(this.workspacePath(`/streams/${streamId}/messages${suffix}`));
582
+ return body.data;
583
+ }
584
+ async getStreamArchivedAt(streamId) {
585
+ const body = await this.request(this.workspacePath(`/streams/${streamId}`));
586
+ return body.data?.archivedAt ?? null;
587
+ }
588
+ async getAttachmentDownloadUrl(attachmentId) {
589
+ const body = await this.request(this.workspacePath(`/attachments/${attachmentId}/url`));
590
+ return body.data.url;
591
+ }
592
+ async uploadAttachment(form) {
593
+ const body = await this.request(this.workspacePath("/attachments"), {
594
+ method: "POST",
595
+ body: form
596
+ });
597
+ return body.data;
598
+ }
599
+ }
600
+
601
+ // src/turn-route.ts
602
+ class RouteRevokedError extends Error {
603
+ }
604
+
605
+ class TurnRoute {
606
+ invocation;
607
+ order;
608
+ generation;
609
+ sentCount = 0;
610
+ replyText;
611
+ prepared;
612
+ closing;
613
+ deadline;
614
+ pending = new Map;
615
+ contributors;
616
+ execution = new AbortController;
617
+ tail = Promise.resolve();
618
+ reservedSeq = 0;
619
+ deadlineGeneration = 0;
620
+ stateValue = "open";
621
+ revokedFlag = false;
622
+ terminalFlag = false;
623
+ idleTimeoutMs;
624
+ onIdleDeadline;
625
+ constructor(options) {
626
+ this.invocation = options.invocation;
627
+ this.contributors = [...options.contributors ?? []];
628
+ this.order = options.order;
629
+ this.generation = options.generation;
630
+ this.idleTimeoutMs = options.idleTimeoutMs;
631
+ this.onIdleDeadline = options.onIdleDeadline;
632
+ }
633
+ get state() {
634
+ return this.stateValue;
635
+ }
636
+ get revoked() {
637
+ return this.revokedFlag;
638
+ }
639
+ get terminal() {
640
+ return this.terminalFlag;
641
+ }
642
+ isFenced(lifecycle) {
643
+ return this.revokedFlag || this.generation !== lifecycle;
644
+ }
645
+ enqueue(task) {
646
+ const run = this.tail.then(task, task);
647
+ this.tail = run.then(() => {
648
+ return;
649
+ }, () => {
650
+ return;
651
+ });
652
+ return run;
653
+ }
654
+ snapshotIntent(text, retryKey) {
655
+ let retry;
656
+ for (const pending of this.pending.values()) {
657
+ if (pending.text === text && pending.retryKey === retryKey && (!retry || pending.seq < retry.seq)) {
658
+ retry = pending;
659
+ }
660
+ }
661
+ return {
662
+ text,
663
+ ...retryKey === undefined ? {} : { retryKey },
664
+ ...retry ? { retry } : {}
665
+ };
666
+ }
667
+ claimSeq(retry) {
668
+ return retry?.seq ?? (this.reservedSeq += 1);
669
+ }
670
+ rememberFailedPost(seq, prepared, lifecycle) {
671
+ if (!this.isFenced(lifecycle))
672
+ this.pending.set(seq, prepared);
673
+ }
674
+ recordLandedPost(seq, prepared) {
675
+ if (this.pending.get(seq) === prepared)
676
+ this.pending.delete(seq);
677
+ this.sentCount += 1;
678
+ }
679
+ discardPrepared() {
680
+ this.pending.clear();
681
+ this.prepared = undefined;
682
+ }
683
+ revoke() {
684
+ this.revokedFlag = true;
685
+ this.pending.clear();
686
+ this.prepared = undefined;
687
+ this.clearDeadline();
688
+ }
689
+ markTerminal() {
690
+ this.stateValue = "closed";
691
+ this.terminalFlag = true;
692
+ this.revokedFlag = true;
693
+ this.clearDeadline();
694
+ this.deadlineGeneration += 1;
695
+ this.closing = undefined;
696
+ this.pending.clear();
697
+ this.prepared = undefined;
698
+ this.invocation.claimToken = "";
699
+ this.invocation.sealing = undefined;
700
+ this.invocation.sealedAttachments = undefined;
701
+ this.invocation.sealedAck = undefined;
702
+ }
703
+ beginClosing() {
704
+ this.stateValue = "closing";
705
+ this.clearDeadline();
706
+ }
707
+ markClosed() {
708
+ this.stateValue = "closed";
709
+ }
710
+ settleClosed(replyText) {
711
+ this.stateValue = "closed";
712
+ this.prepared = undefined;
713
+ if (replyText === undefined)
714
+ delete this.replyText;
715
+ else
716
+ this.replyText = replyText;
717
+ }
718
+ reopen(prepared) {
719
+ this.stateValue = "open";
720
+ this.prepared = prepared;
721
+ }
722
+ trackClosing(task) {
723
+ this.closing = task;
724
+ return task.finally(() => {
725
+ if (this.closing === task)
726
+ this.closing = undefined;
727
+ });
728
+ }
729
+ armIdleTimeout() {
730
+ const generation = this.deadlineGeneration += 1;
731
+ this.deadline = setTimeout(() => this.onIdleDeadline(this, generation), this.idleTimeoutMs);
732
+ }
733
+ touchIdleTimeout() {
734
+ if (this.stateValue !== "open" || this.revokedFlag)
735
+ return;
736
+ this.clearDeadline();
737
+ this.armIdleTimeout();
738
+ }
739
+ isCurrentDeadline(deadlineGeneration) {
740
+ return this.deadlineGeneration === deadlineGeneration;
741
+ }
742
+ clearDeadline() {
743
+ clearTimeout(this.deadline);
744
+ this.deadline = undefined;
745
+ }
746
+ }
747
+
748
+ // src/session.ts
749
+ var SUPPORTED_CAPABILITIES = ["active-scratchpad", "mentionable"];
750
+ var SESSION_CONTROL_CAPABILITY = "session-control";
751
+ var STEER_DRAIN_LIMIT = 10;
752
+ var STEER_SETTLE_MS = 250;
753
+ var CLAIM_TTL_SECONDS = 120;
754
+ var NO_SOCKET_POLL_CAP_MS = 2 * 60 * 1000;
755
+ var CLAIM_RETRY_CAP_MS = 2 * 60 * 1000;
756
+ var RECONNECT_HANDOFF_FALLBACK_MS = 30000;
757
+ var MAX_CLAIMS_PER_DRAIN = 20;
758
+ var MAX_EXCLUDED_RESPONSE_STREAMS = 32;
759
+ var MAX_CONCURRENT_TURNS = MAX_EXCLUDED_RESPONSE_STREAMS - MAX_CLAIMS_PER_DRAIN;
760
+ var MAX_STEP_FRAMES_PER_CALL = 50;
761
+ var MAX_CONTEXT_MESSAGES = 12;
762
+ var MAX_MESSAGE_CHARS = 2000;
763
+ var ATTACHMENT_SCAN_LIMIT = 30;
764
+ var COMPLETED_TURN_MEMORY = 64;
765
+ var RETRYABLE_POST_STATUSES = new Set([408, 425, 429]);
766
+ function parseSessionControlCommand(invocation) {
767
+ const meta = invocation.metadata?.command;
768
+ if (meta && typeof meta === "object") {
769
+ const value = meta;
770
+ if (value.executionKind === "bot-runtime" && typeof value.name === "string") {
771
+ return { name: value.name.toLowerCase(), args: typeof value.args === "string" ? value.args.trim() : "" };
772
+ }
773
+ }
774
+ if (invocation.trigger !== SESSION_CONTROL_CAPABILITY)
775
+ return null;
776
+ const match = invocation.promptMarkdown.trim().match(/^\/([\w-]+)(?:\s+([\s\S]*))?$/);
777
+ if (!match)
778
+ return null;
779
+ return { name: match[1].toLowerCase(), args: (match[2] ?? "").trim() };
780
+ }
781
+ function isSessionControlInvocation(invocation) {
782
+ return invocation.trigger === SESSION_CONTROL_CAPABILITY && parseSessionControlCommand(invocation) !== null;
783
+ }
784
+ function formatInvocationContent(invocation) {
785
+ const prompt = invocation.promptMarkdown.trim() || "(empty message)";
786
+ const history = (invocation.context?.messages ?? []).filter((message) => message.messageId !== invocation.sourceMessageId).slice(-MAX_CONTEXT_MESSAGES).map((message) => {
787
+ const author = message.authorDisplayName?.trim() || message.role;
788
+ const content = message.contentMarkdown.trim().slice(0, MAX_MESSAGE_CHARS);
789
+ return `- ${author}: ${content}`;
790
+ });
791
+ if (history.length === 0)
792
+ return prompt;
793
+ return [prompt, "", "Earlier in this scratchpad (oldest first, for context):", ...history].join(`
794
+ `);
795
+ }
796
+ function withInboundAttachments(content, manifest) {
797
+ return manifest ? `${content}
798
+
799
+ ${manifest}` : content;
800
+ }
801
+ function buildSteerContent(parts) {
802
+ if (parts.length === 1)
803
+ return parts[0];
804
+ return ["Handle all of the following together (most recent last):", "", parts.join(`
805
+
806
+ ---
807
+
808
+ `)].join(`
809
+ `);
810
+ }
811
+ function supportedCapabilitiesFor(sessionControlEnabled) {
812
+ return sessionControlEnabled ? [...SUPPORTED_CAPABILITIES, SESSION_CONTROL_CAPABILITY] : [...SUPPORTED_CAPABILITIES];
813
+ }
814
+ function effectiveRuntimeManifest(manifest, actuator) {
815
+ return {
816
+ output: { ...manifest?.output ?? {} },
817
+ input: { updates: actuator?.steer ? "live" : "restart" }
818
+ };
819
+ }
820
+ function claimCapabilitiesFor(busy, sessionControlEnabled) {
821
+ if (!busy)
822
+ return supportedCapabilitiesFor(sessionControlEnabled);
823
+ return sessionControlEnabled ? [SESSION_CONTROL_CAPABILITY] : [];
824
+ }
825
+ function runtimeCapabilitiesFor(runtimeSessionId, actuator) {
826
+ return {
827
+ runtimeSessionId,
828
+ supportsActiveScratchpad: true,
829
+ supportsPersistentSessions: true,
830
+ ...actuator ? {
831
+ supportsSessionControlCommands: true,
832
+ sessionControlCommands: [...actuator.commands],
833
+ ...actuator.modelSuggestions ? { modelSuggestions: [...actuator.modelSuggestions] } : {},
834
+ ...actuator.thinkingLevels ? { thinkingLevels: [...actuator.thinkingLevels] } : {},
835
+ ...actuator.spawnRuntimes ? { spawnRuntimes: [...actuator.spawnRuntimes] } : {},
836
+ ...actuator.spawnDefaultRuntime ? { spawnDefaultRuntime: actuator.spawnDefaultRuntime } : {}
837
+ } : {}
838
+ };
839
+ }
840
+
841
+ class StaleInputError extends Error {
842
+ }
843
+
844
+ class DecisionAbandonedError extends Error {
845
+ decisionId;
846
+ constructor(decisionId) {
847
+ super(`Decision ${decisionId} was abandoned: the remote session is shutting down.`);
848
+ this.decisionId = decisionId;
849
+ this.name = "DecisionAbandonedError";
850
+ }
851
+ }
852
+ function decisionAbortError(decisionId) {
853
+ const error = new Error(`Decision ${decisionId} was aborted by its caller.`);
854
+ error.name = "AbortError";
855
+ return error;
856
+ }
857
+
858
+ class RemoteSession {
859
+ config;
860
+ client;
861
+ delegate;
862
+ runtime;
863
+ transport;
864
+ log;
865
+ onPresence;
866
+ bik;
867
+ advertisedKeyIds = "";
868
+ hello;
869
+ botId;
870
+ link;
871
+ linkGeneration = 0;
872
+ claiming = false;
873
+ claimDrainTask;
874
+ claimRetryTimer;
875
+ claimFailures = 0;
876
+ reconnectHandoff = false;
877
+ onHandoffReset;
878
+ reconnectResetTimer;
879
+ reconnectFallbackTask;
880
+ stopped = false;
881
+ archive;
882
+ pollTimer;
883
+ emptyNoSocketPolls = 0;
884
+ inflight = new Map;
885
+ completed = new Map;
886
+ terminalReplies = new Map;
887
+ nextRouteOrder = 0;
888
+ lifecycle = 0;
889
+ presenceTail = Promise.resolve();
890
+ observedClaims = new Map;
891
+ cancelledInvocations = new WeakSet;
892
+ claimDrainRequested = false;
893
+ claimDrainScheduled = false;
894
+ activeTurnStream;
895
+ pendingDecisions = new Map;
896
+ decisionPollTimer;
897
+ maxConcurrentTurns;
898
+ constructor(options) {
899
+ const maxConcurrentTurns = options.runtime.maxConcurrentTurns ?? 1;
900
+ if (!Number.isInteger(maxConcurrentTurns) || maxConcurrentTurns < 1) {
901
+ throw new Error(`maxConcurrentTurns must be a positive integer, got ${maxConcurrentTurns}`);
902
+ }
903
+ if (maxConcurrentTurns > MAX_CONCURRENT_TURNS) {
904
+ throw new Error(`maxConcurrentTurns must be at most ${MAX_CONCURRENT_TURNS}, got ${maxConcurrentTurns}`);
905
+ }
906
+ this.maxConcurrentTurns = maxConcurrentTurns;
907
+ this.config = options.config;
908
+ this.client = options.client;
909
+ this.delegate = options.delegate;
910
+ this.runtime = options.runtime;
911
+ this.log = options.log ?? (() => {
912
+ return;
913
+ });
914
+ this.onPresence = options.onPresence;
915
+ this.archive = new ArchiveGraceController({
916
+ isArchived: async (rootStreamId) => Boolean(await this.client.getStreamArchivedAt(rootStreamId)),
917
+ reattach: () => this.relinkAfterRestore(),
918
+ onDetached: (rootStreamId) => this.detachForArchive(rootStreamId),
919
+ onReattached: async () => {
920
+ await this.syncPresence();
921
+ await this.claimDrain();
922
+ },
923
+ onWindDown: (rootStreamId) => this.windDownForArchive(rootStreamId),
924
+ log: this.log
925
+ }, options.archiveGraceMs === undefined ? {} : { graceMs: options.archiveGraceMs });
926
+ this.bik = new BotKeyring({ keyring: () => this.buildKeyring(), log: this.log });
927
+ this.hello = {
928
+ ...this.presenceBody("available"),
929
+ supportedCapabilities: supportedCapabilitiesFor(this.sessionControlEnabled)
930
+ };
931
+ this.transport = options.transport ?? new BotRuntimeTransport({
932
+ baseUrl: this.config.baseUrl,
933
+ workspaceId: this.config.workspaceId,
934
+ apiKey: this.config.apiKey,
935
+ hello: this.hello,
936
+ beforeHello: () => this.refreshHelloCapabilities(),
937
+ callbacks: {
938
+ onInvocationAvailable: () => void this.claimDrain(),
939
+ ...options.onDelegationAvailable ? { onDelegationAvailable: (payload) => options.onDelegationAvailable?.(payload) } : {},
940
+ onE2eGrant: (payload) => void this.keyGrantedStreams([payload.streamId]),
941
+ onE2eRevoke: (payload) => void this.keyRevokedStream(payload.streamId),
942
+ onBootstrap: (bootstrap) => {
943
+ if (bootstrap.botId)
944
+ this.botId = bootstrap.botId;
945
+ this.probeArchiveBackstop();
946
+ if (bootstrap.e2eGrantedStreamIds.length > 0)
947
+ this.keyGrantedStreams(bootstrap.e2eGrantedStreamIds);
948
+ if (bootstrap.availableInvocations.length > 0 || bootstrap.ownedClaims.length > 0)
949
+ this.claimDrain();
950
+ if (this.pendingDecisions.size > 0)
951
+ this.scheduleDecisionPoll(0);
952
+ },
953
+ onDisconnected: () => this.handleTransportDisconnected(),
954
+ onSessionArchived: (payload) => void this.handleSessionArchived(payload),
955
+ onSessionRestored: (payload) => void this.handleSessionRestored(payload),
956
+ onDecisionResolved: (payload) => this.handleDecisionPush(payload),
957
+ onDecisionCancelled: (payload) => this.handleDecisionPush(payload)
958
+ },
959
+ log: this.log
960
+ });
961
+ }
962
+ get sessionControlEnabled() {
963
+ return Boolean(this.delegate.sessionControl);
964
+ }
965
+ get parallel() {
966
+ return this.maxConcurrentTurns > 1;
967
+ }
968
+ inflightStreams() {
969
+ return new Set([...this.inflight.values()].map((route) => route.invocation.responseStreamId));
970
+ }
971
+ get atCapacity() {
972
+ return this.inflightStreams().size >= this.maxConcurrentTurns;
973
+ }
974
+ refreshHelloCapabilities() {
975
+ let status = "available";
976
+ if (this.stopped || this.archive.detached || !this.link)
977
+ status = "offline";
978
+ else if (this.reconnectHandoff || this.atCapacity)
979
+ status = "busy";
980
+ const body = this.presenceBody(status);
981
+ Object.assign(this.hello, body);
982
+ this.hello.supportedCapabilities = supportedCapabilitiesFor(this.sessionControlEnabled);
983
+ this.reportPresence(body);
984
+ }
985
+ get activeTurnStreamId() {
986
+ return this.activeTurnStream;
987
+ }
988
+ get rootStreamId() {
989
+ return this.link?.rootStreamId;
990
+ }
991
+ get statusSnapshot() {
992
+ const linkState = this.archive.detached ? "detached" : this.link ? "linked" : "unlinked";
993
+ return {
994
+ stopped: this.stopped,
995
+ linkGeneration: this.linkGeneration,
996
+ linkState,
997
+ rootStreamId: this.link?.rootStreamId ?? this.archive.pendingRootStreamId,
998
+ activeStreamId: this.link?.activeStreamId,
999
+ socketConnected: this.transport.socketConnected,
1000
+ inflightCount: this.inflight.size,
1001
+ activeTurnStreamId: this.activeTurnStream,
1002
+ inflightStreamIds: [...this.inflightStreams()],
1003
+ pendingDecisionCount: this.pendingDecisions.size
1004
+ };
1005
+ }
1006
+ buildKeyring() {
1007
+ const dir = this.config.keyDir ?? join2(homedir(), ".threa", "e2e-keys");
1008
+ const account = e2eKeyAccount({
1009
+ scope: this.config.keyScope,
1010
+ hostname: hostname(),
1011
+ instanceId: this.config.instanceId,
1012
+ identitySeed: this.config.apiKey
1013
+ });
1014
+ const files = new FileKeyStore({ dir });
1015
+ const legacyPath = this.config.bikPath ?? join2(homedir(), ".threa", `bik-${sanitizeId(this.runtime.kind)}.json`);
1016
+ return new E2eKeyring({
1017
+ store: resolveKeyStore({
1018
+ requested: this.config.keyStore,
1019
+ platform: process.platform,
1020
+ dir,
1021
+ hasExistingFileKey: account === null ? files.hasAny() : files.read(account) !== undefined
1022
+ }),
1023
+ account,
1024
+ mint: mintE2eKeyRecord,
1025
+ legacy: () => readLegacyBikFile(legacyPath),
1026
+ log: this.log
1027
+ });
1028
+ }
1029
+ async start() {
1030
+ await this.bik.ensure();
1031
+ this.advertisedKeyIds = this.bik.identities.map((identity) => identity.publicKeyId).join(",");
1032
+ Object.assign(this.hello, this.bik.presenceFields());
1033
+ await this.verifyPrincipal();
1034
+ await this.ensureLink();
1035
+ await this.transport.connect();
1036
+ this.startPoll();
1037
+ await this.claimDrain();
1038
+ }
1039
+ async ensureLink() {
1040
+ if (this.link || this.stopped || this.archive.detached)
1041
+ return;
1042
+ try {
1043
+ await this.createLink();
1044
+ } catch (error) {
1045
+ this.log(`could not link to Threa (will retry): ${this.summarize(error)}${this.linkErrorHint(error)}`);
1046
+ }
1047
+ }
1048
+ async relinkAfterRestore() {
1049
+ try {
1050
+ return await this.createLink();
1051
+ } catch (error) {
1052
+ this.log(`reattach link failed: ${this.summarize(error)}${this.linkErrorHint(error)}`);
1053
+ return false;
1054
+ }
1055
+ }
1056
+ async createLink() {
1057
+ const generation = this.archive.generation;
1058
+ const link = await this.createSession();
1059
+ if (this.stopped)
1060
+ return false;
1061
+ if (this.archive.generation !== generation) {
1062
+ this.log("link response raced an archive state change — dropped; the next probe decides");
1063
+ return false;
1064
+ }
1065
+ await this.delegate.onLinked?.(link);
1066
+ if (this.stopped)
1067
+ return false;
1068
+ if (this.archive.generation !== generation) {
1069
+ this.log("link response raced an archive state change — dropped; the next probe decides");
1070
+ return false;
1071
+ }
1072
+ this.link = link;
1073
+ this.linkGeneration += 1;
1074
+ this.log(`linked to scratchpad ${this.config.baseUrl}${this.link.streamUrlPath}`);
1075
+ if (!this.archive.detached)
1076
+ await this.syncPresence();
1077
+ return true;
1078
+ }
1079
+ linkErrorHint(error) {
1080
+ if (!(error instanceof ThreaApiError))
1081
+ return "";
1082
+ if (error.status === 401 || error.status === 403) {
1083
+ return " — check THREA_API_KEY (must be a bot key, threa_bk_…) and THREA_WORKSPACE_ID";
1084
+ }
1085
+ if (error.code === "SCRATCHPAD_ARCHIVED" && !this.archive.detached) {
1086
+ return " — the server does not support ifArchived replace yet; unarchive the scratchpad in Threa to link";
1087
+ }
1088
+ return "";
1089
+ }
1090
+ async shutdown(options = {}) {
1091
+ if (this.stopped)
1092
+ return;
1093
+ this.stopped = true;
1094
+ if (this.pollTimer)
1095
+ clearTimeout(this.pollTimer);
1096
+ if (this.claimRetryTimer)
1097
+ clearTimeout(this.claimRetryTimer);
1098
+ this.claimRetryTimer = undefined;
1099
+ this.archive.stop();
1100
+ const withdrawals = this.abandonPendingDecisions();
1101
+ this.resetReconnectHandoff();
1102
+ await this.reconnectFallbackTask;
1103
+ this.transport.disconnect();
1104
+ const routes = this.revokeAllRoutes();
1105
+ await this.waitForClaimDrain();
1106
+ await Promise.all([this.enqueueOfflinePresence(() => this.stopped), withdrawals]);
1107
+ if (options.hostGone && routes.length > 0) {
1108
+ this.log(`host gone: leaving ${routes.length} in-flight claim(s) to lapse for the revived session`);
1109
+ return;
1110
+ }
1111
+ await this.failUnansweredRoutes(routes);
1112
+ }
1113
+ revokeAllRoutes() {
1114
+ this.lifecycle += 1;
1115
+ const inflight = [...this.inflight.values()];
1116
+ for (const route of inflight)
1117
+ route.revoke();
1118
+ for (const route of this.completed.values())
1119
+ route.revoke();
1120
+ for (const context of this.observedClaims.values())
1121
+ this.fenceObservedClaim(context.invocation);
1122
+ this.inflight.clear();
1123
+ this.completed.clear();
1124
+ this.terminalReplies.clear();
1125
+ return inflight;
1126
+ }
1127
+ async failUnansweredRoutes(routes) {
1128
+ await Promise.allSettled(routes.map((route) => Promise.resolve(route.closing).catch(() => {
1129
+ return;
1130
+ }).then(() => {
1131
+ if (route.state === "closed")
1132
+ return;
1133
+ return Promise.allSettled([route.invocation, ...route.contributors].map((invocation) => this.client.fail(invocation.id, {
1134
+ instanceId: this.config.instanceId,
1135
+ claimToken: invocation.claimToken,
1136
+ errorMessage: this.runtime.shutdownErrorMessage
1137
+ })));
1138
+ })));
1139
+ }
1140
+ async verifyPrincipal() {
1141
+ try {
1142
+ const me = await this.client.getMe();
1143
+ if (me.kind !== "bot") {
1144
+ this.log("WARNING: the configured API key is not a bot key (threa_bk_…). Bot-runtime endpoints will reject it.");
1145
+ }
1146
+ } catch (error) {
1147
+ this.log(`could not verify principal (continuing): ${this.summarize(error)}`);
1148
+ }
1149
+ }
1150
+ async createSession() {
1151
+ const e2e = this.config.e2e ? await this.resolveE2eCreateBlock() : undefined;
1152
+ const link = await this.client.createSession({
1153
+ runtimeKind: this.runtime.kind,
1154
+ instanceId: this.config.instanceId,
1155
+ runtimeSessionId: this.config.runtimeSessionId,
1156
+ displayName: this.config.displayName,
1157
+ ...this.config.localCwd ? { localCwd: this.config.localCwd } : {},
1158
+ ifArchived: this.archive.detached ? "wait" : this.config.coldStartIfArchived ?? "replace",
1159
+ ifMissing: this.config.expectedRootStreamId ? "error" : this.config.coldStartIfMissing ?? "create",
1160
+ ...this.config.defaultLabel && { labelName: this.config.defaultLabel },
1161
+ ...e2e ? { e2e: { ownerKeyId: e2e.ownerKeyId } } : {}
1162
+ });
1163
+ if (this.config.expectedRootStreamId && link.rootStreamId !== this.config.expectedRootStreamId) {
1164
+ throw new Error(`Session link root mismatch: expected ${this.config.expectedRootStreamId}, got ${link.rootStreamId}`);
1165
+ }
1166
+ if (this.config.e2e && link.e2eEnabled !== true) {
1167
+ this.log(`WARNING: e2e is enabled but the resumed scratchpad ${link.rootStreamId} is plaintext ` + "(it predates the setting). Archive it to get a fresh encrypted scratchpad on the next start.");
1168
+ return link;
1169
+ }
1170
+ if (e2e && link.e2eEnabled === true) {
1171
+ await this.provisionE2eStreamKey(link, e2e);
1172
+ }
1173
+ return link;
1174
+ }
1175
+ async resolveE2eCreateBlock() {
1176
+ let ownerKey;
1177
+ try {
1178
+ ownerKey = await this.client.getOwnerE2eKey();
1179
+ } catch (error) {
1180
+ if (error instanceof ThreaApiError && error.status === 404) {
1181
+ throw new Error("e2e is enabled but the bot owner has not set up encryption in Threa yet — " + "set an encryption passphrase in the app, then this session will link encrypted.");
1182
+ }
1183
+ throw error;
1184
+ }
1185
+ return { ownerKeyId: ownerKey.keyId, ownerPublicKey: ownerKey.publicKey };
1186
+ }
1187
+ async provisionE2eStreamKey(link, e2e) {
1188
+ const bik = await this.bik.identityForStream(link.rootStreamId);
1189
+ if (!bik) {
1190
+ throw new Error("e2e is enabled but this install could not create a bot identity key (see earlier log)");
1191
+ }
1192
+ await this.advertiseKeyring();
1193
+ const { wraps } = await mintStreamKeyWraps({
1194
+ streamId: link.rootStreamId,
1195
+ keyGeneration: 0,
1196
+ recipients: [
1197
+ { recipientKind: "user", recipientKeyId: e2e.ownerKeyId, publicKeyBase64: e2e.ownerPublicKey },
1198
+ { recipientKind: "bot", recipientKeyId: bik.publicKeyId, publicKeyBase64: bik.publicKeyBase64 }
1199
+ ]
1200
+ });
1201
+ for (let attempt = 1;; attempt++) {
1202
+ try {
1203
+ await this.client.provisionStreamKeyWraps(link.rootStreamId, { keyGeneration: 0, wraps });
1204
+ this.log(`provisioned encrypted scratchpad ${link.rootStreamId} (gen 0, owner + BIK wraps)`);
1205
+ return;
1206
+ } catch (error) {
1207
+ if (error instanceof ThreaApiError && error.status === 409)
1208
+ return;
1209
+ if (attempt >= 3)
1210
+ throw error;
1211
+ await new Promise((resolve2) => setTimeout(resolve2, attempt * 1000));
1212
+ }
1213
+ }
1214
+ }
1215
+ claimDrain() {
1216
+ if (this.stopped || this.claimRetryTimer || this.archive.detached || this.reconnectHandoff) {
1217
+ return Promise.resolve(false);
1218
+ }
1219
+ if (this.claiming || this.claimDrainTask) {
1220
+ this.claimDrainRequested = true;
1221
+ return Promise.resolve(false);
1222
+ }
1223
+ this.claiming = true;
1224
+ const task = this.runClaimDrain();
1225
+ this.claimDrainTask = task;
1226
+ const clear = () => {
1227
+ if (this.claimDrainTask === task)
1228
+ this.claimDrainTask = undefined;
1229
+ this.scheduleRequestedClaimDrain();
1230
+ };
1231
+ task.then(clear, clear);
1232
+ return task;
1233
+ }
1234
+ async runClaimDrain() {
1235
+ let claimedAny = false;
1236
+ const stoppedStreams = new Set;
1237
+ try {
1238
+ for (let i = 0;i < MAX_CLAIMS_PER_DRAIN; i++) {
1239
+ if (this.reconnectHandoff || this.stopped || this.archive.detached)
1240
+ break;
1241
+ const busy = this.atCapacity;
1242
+ if (busy && !this.sessionControlEnabled)
1243
+ break;
1244
+ const exclude = busy ? [] : [...new Set([...this.inflightStreams(), ...stoppedStreams])];
1245
+ const invocation = await this.claimNext(busy, undefined, exclude);
1246
+ if (!invocation)
1247
+ break;
1248
+ claimedAny = true;
1249
+ this.markClaimProcessing(invocation);
1250
+ if (this.isClaimCancelled(invocation))
1251
+ continue;
1252
+ if (isSessionControlInvocation(invocation)) {
1253
+ const isStop = parseSessionControlCommand(invocation)?.name === "stop";
1254
+ await this.handleSessionControl(invocation);
1255
+ if (isStop) {
1256
+ if (this.parallel) {
1257
+ stoppedStreams.add(invocation.responseStreamId);
1258
+ continue;
1259
+ }
1260
+ this.claimDrainRequested = false;
1261
+ break;
1262
+ }
1263
+ continue;
1264
+ }
1265
+ if (this.routeForStream(invocation.responseStreamId)) {
1266
+ this.log(`claimed ${invocation.id} for busy stream ${invocation.responseStreamId}: server ignored excludeResponseStreamIds`);
1267
+ await this.failInvocation(invocation, "Threa server ignored excludeResponseStreamIds; resend the message");
1268
+ continue;
1269
+ }
1270
+ const deferred = await this.startFoldedTurn(invocation);
1271
+ for (const control of deferred) {
1272
+ if (parseSessionControlCommand(control)?.name === "stop") {
1273
+ await this.handleSessionControl(control);
1274
+ if (this.parallel) {
1275
+ stoppedStreams.add(control.responseStreamId);
1276
+ continue;
1277
+ }
1278
+ this.claimDrainRequested = false;
1279
+ return claimedAny;
1280
+ }
1281
+ await this.handleSessionControl(control);
1282
+ }
1283
+ }
1284
+ } catch (error) {
1285
+ this.log(`claim failed: ${this.summarize(error)}`);
1286
+ } finally {
1287
+ this.claiming = false;
1288
+ }
1289
+ return claimedAny;
1290
+ }
1291
+ async waitForClaimDrain() {
1292
+ await this.claimDrainTask;
1293
+ }
1294
+ async claimAndHydrate(busy, responseStreamId, excludeResponseStreamIds = []) {
1295
+ if (this.claimRetryTimer)
1296
+ return null;
1297
+ let invocation;
1298
+ try {
1299
+ invocation = await this.client.claim({
1300
+ ...this.claimBody(busy, excludeResponseStreamIds),
1301
+ ...responseStreamId ? { responseStreamId } : {}
1302
+ });
1303
+ this.claimFailures = 0;
1304
+ } catch (error) {
1305
+ this.scheduleClaimRetry(error);
1306
+ throw error;
1307
+ }
1308
+ if (!invocation || invocation.sealedContext === undefined)
1309
+ return invocation;
1310
+ const fail = async (reason) => {
1311
+ this.log(`sealed claim ${invocation.id} unusable: ${reason}`);
1312
+ await this.client.fail(invocation.id, {
1313
+ instanceId: this.config.instanceId,
1314
+ claimToken: invocation.claimToken,
1315
+ errorMessage: `Sealed turn failed: ${reason}`.slice(0, 200)
1316
+ }).catch(() => {
1317
+ return;
1318
+ });
1319
+ return null;
1320
+ };
1321
+ const sealed = parseSealedTurnContext(invocation.sealedContext);
1322
+ if (!sealed)
1323
+ return fail("malformed sealedContext");
1324
+ const identities = await this.bik.ensureForStream(invocation.rootStreamId);
1325
+ if (identities.length === 0)
1326
+ return fail("no bot identity key");
1327
+ try {
1328
+ const opened = await openSealedTurnContext({ sealed, identities, streamId: invocation.rootStreamId });
1329
+ const messages = opened.history.map((item) => ({
1330
+ messageId: `sealed-${item.sequence}`,
1331
+ role: item.role,
1332
+ authorId: "",
1333
+ authorType: item.role === "assistant" ? "bot" : "user",
1334
+ contentMarkdown: item.contentMarkdown,
1335
+ createdAt: ""
1336
+ }));
1337
+ const historyRefs = opened.history.flatMap((item) => item.attachmentRefs);
1338
+ return {
1339
+ ...invocation,
1340
+ sealedContext: undefined,
1341
+ promptMarkdown: opened.promptMarkdown,
1342
+ sealing: opened.sealing,
1343
+ ...opened.promptAttachmentRefs.length > 0 || historyRefs.length > 0 ? { sealedAttachments: { prompt: opened.promptAttachmentRefs, history: historyRefs } } : {},
1344
+ ...messages.length > 0 ? { context: { kind: "inline", messages } } : {}
1345
+ };
1346
+ } catch (error) {
1347
+ return fail(scrubSealedError(error));
1348
+ }
1349
+ }
1350
+ scheduleClaimRetry(error) {
1351
+ if (this.stopped || this.archive.detached || this.claimRetryTimer)
1352
+ return;
1353
+ if (error instanceof ThreaApiError && error.status < 500 && !RETRYABLE_POST_STATUSES.has(error.status))
1354
+ return;
1355
+ const backoff = Math.min(CLAIM_RETRY_CAP_MS, this.config.pollMs * 2 ** this.claimFailures);
1356
+ this.claimFailures++;
1357
+ const retryAfter = error instanceof ThreaApiError ? error.retryAfterMs ?? 0 : 0;
1358
+ const delay = Math.min(2147483647, Math.max(backoff, retryAfter));
1359
+ this.log(`claim failed; retrying in ${delay}ms: ${this.summarize(error)}`);
1360
+ this.claimRetryTimer = setTimeout(() => {
1361
+ this.claimRetryTimer = undefined;
1362
+ this.claimDrainRequested = true;
1363
+ this.scheduleRequestedClaimDrain();
1364
+ }, delay);
1365
+ }
1366
+ async claimNext(busy, responseStreamId, excludeResponseStreamIds = []) {
1367
+ const lifecycle = this.lifecycle;
1368
+ const invocation = await this.claimAndHydrate(busy, responseStreamId, excludeResponseStreamIds);
1369
+ if (!invocation || this.stopped || this.archive.detached || lifecycle !== this.lifecycle)
1370
+ return null;
1371
+ const identities = invocation.sealing ? this.bik.identities : [];
1372
+ const handle = this.transport.observeClaim({
1373
+ invocationId: invocation.id,
1374
+ claimToken: invocation.claimToken,
1375
+ sourceRevision: invocation.sourceRevision,
1376
+ claimTtlSeconds: CLAIM_TTL_SECONDS,
1377
+ instanceId: this.config.instanceId,
1378
+ callbacks: {
1379
+ onInputUpdated: (update, signal) => this.applyInputUpdate(invocation.id, update, signal),
1380
+ onCancelled: () => this.terminalizeObservedClaim(invocation.id, "Folded input was cancelled while the turn was running."),
1381
+ onClaimLost: () => this.terminalizeObservedClaim(invocation.id, "Folded claim ownership was lost while the turn was running.")
1382
+ },
1383
+ ...invocation.sealing && identities.length > 0 ? {
1384
+ sealed: {
1385
+ identities,
1386
+ streamId: invocation.rootStreamId,
1387
+ callbackToken: invocation.sealing.callbackToken
1388
+ }
1389
+ } : {}
1390
+ });
1391
+ this.observedClaims.set(invocation.id, {
1392
+ invocation,
1393
+ handle,
1394
+ phase: "unstarted",
1395
+ updateInProgress: false,
1396
+ restartPending: false,
1397
+ lifecycle: new AbortController
1398
+ });
1399
+ await handle.sync();
1400
+ return this.isClaimCancelled(invocation) ? null : invocation;
1401
+ }
1402
+ markClaimProcessing(invocation) {
1403
+ const context = this.observedClaims.get(invocation.id);
1404
+ if (context && context.invocation === invocation && context.phase === "unstarted")
1405
+ context.phase = "processing";
1406
+ }
1407
+ installInputUpdate(context, update) {
1408
+ Object.assign(context.invocation, {
1409
+ promptMarkdown: update.promptMarkdown,
1410
+ sourceRevision: update.sourceRevision,
1411
+ ...update.delivery === "sealed" ? {
1412
+ sealing: update.sealing,
1413
+ sealedAttachments: {
1414
+ prompt: update.attachmentRefs,
1415
+ history: context.invocation.sealedAttachments?.history ?? []
1416
+ }
1417
+ } : { sealing: undefined, sealedAttachments: undefined }
1418
+ });
1419
+ this.inflight.get(context.invocation.id)?.discardPrepared();
1420
+ }
1421
+ abortForInputRestart(context) {
1422
+ context.restartPending = true;
1423
+ context.updateInProgress = false;
1424
+ this.cancelledInvocations.add(context.invocation);
1425
+ this.abortRunningTurnForContext(context, "Folded input changed while the turn was running.");
1426
+ }
1427
+ abortRunningTurnForContext(context, ownerFailure) {
1428
+ const ownerId = context.runningOwnerInvocationId;
1429
+ const running = this.inflight.get(context.invocation.id) ?? (ownerId ? this.inflight.get(ownerId) : undefined);
1430
+ if (!running)
1431
+ return;
1432
+ try {
1433
+ this.delegate.sessionControl?.interrupt(running.invocation.responseStreamId);
1434
+ } catch {}
1435
+ running.execution.abort();
1436
+ this.clearInflight(running.invocation.id);
1437
+ if (this.activeTurnStream === running.invocation.responseStreamId)
1438
+ this.activeTurnStream = undefined;
1439
+ const affected = [running.invocation, ...running.contributors].filter((invocation) => invocation !== context.invocation);
1440
+ for (const invocation of affected)
1441
+ this.fenceObservedClaim(invocation);
1442
+ Promise.all(affected.map((invocation) => this.failFencedInvocation(invocation, ownerFailure))).finally(() => {
1443
+ this.syncPresence();
1444
+ });
1445
+ }
1446
+ async applyInputUpdate(invocationId, update, signal) {
1447
+ const context = this.observedClaims.get(invocationId);
1448
+ if (signal.aborted || !context || context.phase === "terminal" || update.delivery === "sealed" && !update.sealing) {
1449
+ return "restart-required";
1450
+ }
1451
+ if (context.phase === "unstarted") {
1452
+ this.installInputUpdate(context, update);
1453
+ return signal.aborted ? "restart-required" : "applied";
1454
+ }
1455
+ if (context.phase !== "running" || context.restartPending) {
1456
+ this.abortForInputRestart(context);
1457
+ return "restart-required";
1458
+ }
1459
+ const steer = this.delegate.sessionControl?.steer;
1460
+ const route = this.inflight.get(invocationId);
1461
+ if (!steer || !route || route.state !== "open" || route.revoked) {
1462
+ this.abortForInputRestart(context);
1463
+ return "restart-required";
1464
+ }
1465
+ context.updateInProgress = true;
1466
+ this.installInputUpdate(context, update);
1467
+ try {
1468
+ if (signal.aborted)
1469
+ throw signal.reason;
1470
+ const content = await this.buildTurnContent(context.invocation, { strictAttachments: true, signal });
1471
+ if (signal.aborted || this.isClaimCancelled(context.invocation))
1472
+ throw signal.reason;
1473
+ const interruptOnAbort = () => this.abortForInputRestart(context);
1474
+ signal.addEventListener("abort", interruptOnAbort, { once: true });
1475
+ let steered;
1476
+ try {
1477
+ if (signal.aborted)
1478
+ throw signal.reason;
1479
+ steered = await steer.call(this.delegate.sessionControl, content, context.invocation.responseStreamId);
1480
+ } finally {
1481
+ signal.removeEventListener("abort", interruptOnAbort);
1482
+ }
1483
+ if (signal.aborted || this.isClaimCancelled(context.invocation) || !steered) {
1484
+ this.abortForInputRestart(context);
1485
+ return "restart-required";
1486
+ }
1487
+ context.updateInProgress = false;
1488
+ route.touchIdleTimeout();
1489
+ return "applied";
1490
+ } catch {
1491
+ this.abortForInputRestart(context);
1492
+ return "restart-required";
1493
+ }
1494
+ }
1495
+ terminalizeObservedClaim(invocationId, ownerFailure) {
1496
+ const context = this.observedClaims.get(invocationId);
1497
+ if (!context || context.phase === "terminal")
1498
+ return;
1499
+ this.fenceObservedClaim(context.invocation);
1500
+ this.abortRunningTurnForContext(context, ownerFailure);
1501
+ if (!this.stopped && !this.archive.detached)
1502
+ this.syncPresence();
1503
+ this.claimDrainRequested = true;
1504
+ this.scheduleRequestedClaimDrain();
1505
+ }
1506
+ scheduleRequestedClaimDrain() {
1507
+ if (!this.claimDrainRequested || this.claimDrainScheduled || this.claiming || this.stopped || this.archive.detached)
1508
+ return;
1509
+ this.claimDrainScheduled = true;
1510
+ setTimeout(() => {
1511
+ this.claimDrainScheduled = false;
1512
+ if (!this.claimDrainRequested || this.stopped || this.archive.detached)
1513
+ return;
1514
+ this.claimDrainRequested = false;
1515
+ this.claimDrain().finally(() => this.scheduleRequestedClaimDrain());
1516
+ }, 0);
1517
+ }
1518
+ isClaimCancelled(invocation) {
1519
+ return this.cancelledInvocations.has(invocation);
1520
+ }
1521
+ isOutputCurrent(invocation, sourceRevision = invocation.sourceRevision) {
1522
+ const context = this.observedClaims.get(invocation.id);
1523
+ return !this.isClaimCancelled(invocation) && invocation.sourceRevision === sourceRevision && context?.phase !== "terminal" && context?.restartPending !== true && context?.updateInProgress !== true;
1524
+ }
1525
+ releaseObservation(invocationId) {
1526
+ const context = this.observedClaims.get(invocationId);
1527
+ if (!context)
1528
+ return;
1529
+ context.phase = "terminal";
1530
+ context.handle.unregister();
1531
+ this.observedClaims.delete(invocationId);
1532
+ }
1533
+ fenceObservedClaim(invocation) {
1534
+ this.cancelledInvocations.add(invocation);
1535
+ const context = this.observedClaims.get(invocation.id);
1536
+ if (!context)
1537
+ return;
1538
+ context.lifecycle.abort();
1539
+ context.handle.dispose();
1540
+ this.observedClaims.delete(invocation.id);
1541
+ }
1542
+ async failFencedInvocation(invocation, errorMessage) {
1543
+ const scrubbed = invocation.sealing ? "Sealed turn failed" : errorMessage.slice(0, 1000);
1544
+ await this.client.fail(invocation.id, {
1545
+ instanceId: this.config.instanceId,
1546
+ claimToken: invocation.claimToken,
1547
+ errorMessage: scrubbed
1548
+ }).catch((error) => this.log(`invocation fail write failed: ${this.summarize(error)}`));
1549
+ }
1550
+ async startFoldedTurn(invocation) {
1551
+ const primary = await this.buildTurnContent(invocation);
1552
+ if (this.isClaimCancelled(invocation))
1553
+ return [];
1554
+ const folded = [];
1555
+ const control = [];
1556
+ try {
1557
+ for (let i = 1;i < STEER_DRAIN_LIMIT; i++) {
1558
+ const extra = await this.claimNext(false, invocation.responseStreamId).catch(() => null);
1559
+ if (!extra)
1560
+ break;
1561
+ this.markClaimProcessing(extra);
1562
+ if (this.isClaimCancelled(extra))
1563
+ continue;
1564
+ if (isSessionControlInvocation(extra)) {
1565
+ control.push(extra);
1566
+ break;
1567
+ }
1568
+ folded.push(extra);
1569
+ }
1570
+ const foldedParts = await this.foldedTurnParts(folded);
1571
+ if (this.stopped || this.archive.detached) {
1572
+ return control;
1573
+ }
1574
+ if (this.isClaimCancelled(invocation))
1575
+ return control;
1576
+ const liveFolded = foldedParts.map(({ invocation: item }) => item);
1577
+ const content = buildSteerContent([primary, ...foldedParts.map((part) => part.content)]);
1578
+ this.bindRunningOwner(invocation.id, liveFolded);
1579
+ await this.deliverTurn(invocation, content, liveFolded);
1580
+ } catch (error) {
1581
+ const reason = `Could not start the turn: ${this.summarize(error)}`;
1582
+ this.clearInflight(invocation.id);
1583
+ await this.failInvocation(invocation, reason).catch(() => {
1584
+ return;
1585
+ });
1586
+ await Promise.all(folded.map((item) => this.failInvocation(item, reason).catch(() => {
1587
+ return;
1588
+ })));
1589
+ }
1590
+ return control;
1591
+ }
1592
+ async foldedTurnParts(folded) {
1593
+ const parts = [];
1594
+ for (const item of folded) {
1595
+ if (this.isClaimCancelled(item))
1596
+ continue;
1597
+ parts.push({ invocation: item, content: await this.buildTurnContent(item) });
1598
+ }
1599
+ return parts.filter((part) => !this.isClaimCancelled(part.invocation));
1600
+ }
1601
+ bindRunningOwner(ownerInvocationId, dependencies) {
1602
+ const running = this.inflight.get(ownerInvocationId);
1603
+ for (const dependency of dependencies) {
1604
+ const context = this.observedClaims.get(dependency.id);
1605
+ if (context && context.invocation === dependency) {
1606
+ context.runningOwnerInvocationId = ownerInvocationId;
1607
+ if (context.phase === "processing")
1608
+ context.phase = "running";
1609
+ }
1610
+ if (running && !running.contributors.some((item) => item.id === dependency.id)) {
1611
+ running.contributors.push(dependency);
1612
+ }
1613
+ }
1614
+ }
1615
+ unbindRunningOwner(ownerInvocationId, dependencies) {
1616
+ const ids = new Set(dependencies.map((item) => item.id));
1617
+ const running = this.inflight.get(ownerInvocationId);
1618
+ if (running)
1619
+ running.contributors = running.contributors.filter((item) => !ids.has(item.id));
1620
+ for (const dependency of dependencies) {
1621
+ const context = this.observedClaims.get(dependency.id);
1622
+ if (context?.runningOwnerInvocationId === ownerInvocationId)
1623
+ context.runningOwnerInvocationId = undefined;
1624
+ }
1625
+ }
1626
+ async completeContributors(route) {
1627
+ await Promise.all(route.contributors.map((invocation) => this.completeNoResponse(invocation)));
1628
+ }
1629
+ async failContributors(route, reason) {
1630
+ await Promise.all(route.contributors.map((invocation) => this.failInvocation(invocation, reason)));
1631
+ }
1632
+ async deliverTurn(invocation, content, inputDependencies = []) {
1633
+ if (this.isClaimCancelled(invocation))
1634
+ throw new Error("invocation request is closed");
1635
+ this.registerTurn(invocation, inputDependencies);
1636
+ this.activeTurnStream = invocation.responseStreamId;
1637
+ await this.syncPresence();
1638
+ await this.recordForwardedStep(invocation).catch(() => {
1639
+ return;
1640
+ });
1641
+ if (this.stopped || this.archive.detached || !this.inflight.has(invocation.id) || this.isClaimCancelled(invocation) || inputDependencies.some((item) => this.isClaimCancelled(item))) {
1642
+ throw new Error("session went offline or its input changed before the turn could be delivered");
1643
+ }
1644
+ await this.delegate.deliverTurn({
1645
+ invocationId: invocation.id,
1646
+ streamId: invocation.responseStreamId,
1647
+ rootStreamId: invocation.rootStreamId,
1648
+ sourceMessageId: invocation.sourceMessageId,
1649
+ content,
1650
+ sealed: invocation.sealing !== undefined
1651
+ });
1652
+ const dependencyChanged = inputDependencies.some((item) => this.isClaimCancelled(item));
1653
+ if (!this.inflight.has(invocation.id) || this.isClaimCancelled(invocation) || dependencyChanged) {
1654
+ if (dependencyChanged) {
1655
+ try {
1656
+ this.delegate.sessionControl?.interrupt(invocation.responseStreamId);
1657
+ } catch {}
1658
+ }
1659
+ throw new Error("invocation input changed while the runtime was accepting the turn");
1660
+ }
1661
+ const observed = this.observedClaims.get(invocation.id);
1662
+ if (observed && observed.phase === "processing")
1663
+ observed.phase = "running";
1664
+ }
1665
+ async recordForwardedStep(invocation) {
1666
+ const forwardedNote = this.runtime.forwardedNote;
1667
+ if (forwardedNote === undefined)
1668
+ return;
1669
+ const sourceRevision = invocation.sourceRevision;
1670
+ if (invocation.sealing) {
1671
+ const sealing = invocation.sealing;
1672
+ const frame = await sealStep(sealing, "thinking", forwardedNote);
1673
+ if (!this.isOutputCurrent(invocation, sourceRevision))
1674
+ return;
1675
+ await this.transport.recordSealedSteps(invocation.id, sealing.callbackToken, [frame]);
1676
+ return;
1677
+ }
1678
+ if (!this.isOutputCurrent(invocation, sourceRevision))
1679
+ return;
1680
+ await this.transport.recordSteps(invocation.id, invocation.claimToken, [{ stepType: "thinking", content: forwardedNote }], this.runtime.busyStatusText);
1681
+ }
1682
+ async completeTurn(invocation, body) {
1683
+ const signal = body.signal ?? this.observedClaims.get(invocation.id)?.lifecycle.signal;
1684
+ if (signal?.aborted || this.isClaimCancelled(invocation))
1685
+ throw new Error("invocation request is closed");
1686
+ const sourceRevision = invocation.sourceRevision;
1687
+ const assertCurrent = () => {
1688
+ if (signal?.aborted || !this.isOutputCurrent(invocation, sourceRevision)) {
1689
+ throw new Error("invocation request is closed");
1690
+ }
1691
+ };
1692
+ if (invocation.sealing) {
1693
+ const sealing = invocation.sealing;
1694
+ const payload = body.markdown ? { sourceRevision, reply: await sealReply(sealing, body.markdown) } : { noResponse: true, sourceRevision };
1695
+ assertCurrent();
1696
+ await this.client.completeSealed(invocation.id, sealing.callbackToken, payload, signal);
1697
+ assertCurrent();
1698
+ this.releaseObservation(invocation.id);
1699
+ return;
1700
+ }
1701
+ assertCurrent();
1702
+ await this.client.complete(invocation.id, {
1703
+ instanceId: this.config.instanceId,
1704
+ claimToken: invocation.claimToken,
1705
+ sourceRevision,
1706
+ ...body.markdown ? { finalMessageMarkdown: body.markdown } : { noResponse: true },
1707
+ ...body.metadata ? { metadata: body.metadata } : {}
1708
+ }, signal);
1709
+ assertCurrent();
1710
+ this.releaseObservation(invocation.id);
1711
+ }
1712
+ async handleSessionControl(invocation) {
1713
+ if (this.isClaimCancelled(invocation))
1714
+ return;
1715
+ const command = parseSessionControlCommand(invocation);
1716
+ if (!command) {
1717
+ await this.failInvocation(invocation, "Missing session-control command metadata");
1718
+ return;
1719
+ }
1720
+ const actuator = this.delegate.sessionControl;
1721
+ if (!actuator) {
1722
+ await this.failInvocation(invocation, "Session control is not available for this runtime");
1723
+ return;
1724
+ }
1725
+ try {
1726
+ if (this.isClaimCancelled(invocation))
1727
+ return;
1728
+ switch (command.name) {
1729
+ case "stop":
1730
+ return await this.runStop(invocation, actuator);
1731
+ case "steer":
1732
+ return await this.runSteer(invocation, actuator, command.args);
1733
+ default: {
1734
+ if (!actuator.commands.includes(command.name)) {
1735
+ await this.failInvocation(invocation, `Unsupported session-control command: ${command.name}`);
1736
+ return;
1737
+ }
1738
+ const outcome = await actuator.runCommand(command.name, command.args, {
1739
+ rootStreamId: invocation.rootStreamId,
1740
+ sourceMessageId: invocation.sourceMessageId
1741
+ });
1742
+ if (this.isClaimCancelled(invocation))
1743
+ return;
1744
+ if (!outcome.ok) {
1745
+ await this.failInvocation(invocation, outcome.message ?? "Command rejected.");
1746
+ return;
1747
+ }
1748
+ const ackOutcome = () => {
1749
+ if (outcome.message !== undefined)
1750
+ return this.completeReply(invocation, outcome.message);
1751
+ if (outcome.summary !== undefined)
1752
+ return this.completeAck(invocation, outcome.summary);
1753
+ return this.completeSilentAck(invocation);
1754
+ };
1755
+ if (outcome.handoff) {
1756
+ const parks = !outcome.handoffKeepsSessionRunning;
1757
+ if (parks) {
1758
+ this.reconnectHandoff = true;
1759
+ this.onHandoffReset = outcome.onHandoffReset;
1760
+ await this.syncPresence();
1761
+ }
1762
+ if (this.stopped || !this.link || this.archive.detached || this.isClaimCancelled(invocation)) {
1763
+ if (parks)
1764
+ this.resetReconnectHandoff();
1765
+ await this.failInvocation(invocation, "Remote session changed before the command was handed off.");
1766
+ return;
1767
+ }
1768
+ try {
1769
+ await outcome.handoff({
1770
+ workspaceId: invocation.workspaceId,
1771
+ invocationId: invocation.id,
1772
+ instanceId: this.config.instanceId,
1773
+ claimToken: invocation.claimToken
1774
+ });
1775
+ } catch (error) {
1776
+ if (parks)
1777
+ this.resetReconnectHandoff();
1778
+ throw error;
1779
+ }
1780
+ this.releaseObservation(invocation.id);
1781
+ if (parks) {
1782
+ this.reconnectResetTimer = setTimeout(() => this.resetReconnectHandoff(), RECONNECT_HANDOFF_FALLBACK_MS);
1783
+ }
1784
+ return;
1785
+ }
1786
+ if (!outcome.afterAck) {
1787
+ await ackOutcome();
1788
+ return;
1789
+ }
1790
+ this.reconnectHandoff = true;
1791
+ this.onHandoffReset = outcome.onHandoffReset;
1792
+ await this.syncPresence();
1793
+ const completed = await ackOutcome();
1794
+ if (!completed || this.stopped || !this.link || this.archive.detached) {
1795
+ this.resetReconnectHandoff();
1796
+ return;
1797
+ }
1798
+ try {
1799
+ await outcome.afterAck();
1800
+ this.reconnectResetTimer = setTimeout(() => this.resetReconnectHandoff(), RECONNECT_HANDOFF_FALLBACK_MS);
1801
+ } catch (error) {
1802
+ this.log(`session-control post-ack action failed: ${this.summarize(error)}`);
1803
+ this.resetReconnectHandoff();
1804
+ }
1805
+ }
1806
+ }
1807
+ } catch (error) {
1808
+ await this.failInvocation(invocation, this.summarize(error));
1809
+ }
1810
+ }
1811
+ async runStop(invocation, actuator) {
1812
+ const streamId = invocation.responseStreamId;
1813
+ if (this.parallel && !this.routeForStream(streamId)) {
1814
+ if (streamId === this.link?.rootStreamId)
1815
+ return await this.stopTurnsOutsideScratchpad(invocation, actuator);
1816
+ await this.completeAck(invocation, "No turn is running in this stream.");
1817
+ return;
1818
+ }
1819
+ if (!actuator.interrupt(streamId)) {
1820
+ await this.completeAck(invocation, "Could not send the interrupt (runtime control unavailable).");
1821
+ return;
1822
+ }
1823
+ const hadTurn = this.inflight.size > 0;
1824
+ await this.completeInterruptedTurns(this.controlStream(streamId));
1825
+ await this.completeAck(invocation, hadTurn ? "Stopped the current turn." : "Sent an interrupt to the session.");
1826
+ await this.syncPresence();
1827
+ }
1828
+ async stopTurnsOutsideScratchpad(invocation, actuator) {
1829
+ const root = this.link?.rootStreamId;
1830
+ const streams = new Set([...this.inflight.values()].filter((route) => route.invocation.rootStreamId !== root).map((route) => route.invocation.responseStreamId));
1831
+ if (streams.size === 0) {
1832
+ await this.completeAck(invocation, "No turn is running in this stream.");
1833
+ return;
1834
+ }
1835
+ const interrupted = [...streams].filter((streamId) => actuator.interrupt(streamId));
1836
+ for (const streamId of interrupted)
1837
+ await this.completeInterruptedTurns(streamId);
1838
+ if (interrupted.length === 0) {
1839
+ await this.completeAck(invocation, "Could not send the interrupt (runtime control unavailable).");
1840
+ return;
1841
+ }
1842
+ await this.completeAck(invocation, interrupted.length === 1 ? "Stopped the turn running outside this scratchpad." : `Stopped ${interrupted.length} turns running outside this scratchpad.`);
1843
+ await this.syncPresence();
1844
+ }
1845
+ async runSteer(invocation, actuator, text) {
1846
+ const steer = actuator.steer?.bind(actuator);
1847
+ const running = this.parallel ? this.routeForStream(invocation.responseStreamId) !== undefined : this.inflight.size > 0;
1848
+ if (running && steer) {
1849
+ return await this.steerRunningTurn(invocation, steer, text);
1850
+ }
1851
+ if (this.parallel && !running && this.atCapacity) {
1852
+ await this.completeAck(invocation, "Every turn slot is busy; resend the steer when a turn finishes.");
1853
+ return;
1854
+ }
1855
+ return await this.steerByInterrupt(invocation, actuator, text);
1856
+ }
1857
+ async steerRunningTurn(invocation, steer, text) {
1858
+ const streamId = invocation.responseStreamId;
1859
+ const { parts, swept, contents } = await this.sweepQueuedForSteer(text, streamId);
1860
+ if (this.isClaimCancelled(invocation))
1861
+ return;
1862
+ if (parts.length === 0) {
1863
+ await Promise.all(swept.map((item) => this.completeNoResponse(item)));
1864
+ if (invocation.metadata?.steeredMessage === true) {
1865
+ await this.completeTurn(invocation, {
1866
+ noResponse: true,
1867
+ metadata: {
1868
+ "remote.invocationId": invocation.id,
1869
+ "remote.sessionControl": "true",
1870
+ "remote.steered": "true"
1871
+ }
1872
+ });
1873
+ return;
1874
+ }
1875
+ await this.completeAck(invocation, "Nothing to steer with (no text, no queued messages); the turn continues.");
1876
+ return;
1877
+ }
1878
+ let combined = buildSteerContent(parts);
1879
+ for (const route of this.controlRoutes(streamId)) {
1880
+ await this.recordSteps(route.invocation.id, [{ stepType: "steer", content: combined }]);
1881
+ route.touchIdleTimeout();
1882
+ }
1883
+ if (this.isClaimCancelled(invocation))
1884
+ return;
1885
+ const liveSwept = swept.filter((item) => !this.isClaimCancelled(item));
1886
+ const currentParts = this.steerParts(liveSwept, text, contents);
1887
+ if (currentParts.length === 0) {
1888
+ await Promise.all(liveSwept.map((item) => this.completeNoResponse(item)));
1889
+ await this.completeAck(invocation, "Nothing to steer with; the turn continues.");
1890
+ return;
1891
+ }
1892
+ combined = buildSteerContent(currentParts);
1893
+ const owner = [...this.inflight.values()].find((entry) => entry.invocation.responseStreamId === invocation.responseStreamId)?.invocation;
1894
+ if (owner)
1895
+ this.bindRunningOwner(owner.id, [invocation, ...liveSwept]);
1896
+ if (!await steer(combined, streamId)) {
1897
+ if (owner)
1898
+ this.unbindRunningOwner(owner.id, [invocation, ...liveSwept]);
1899
+ await Promise.all(liveSwept.map((item) => this.failInvocation(item, "Steer not delivered (runtime control unavailable); resend.")));
1900
+ await this.completeAck(invocation, "Could not steer the session (runtime control unavailable).");
1901
+ return;
1902
+ }
1903
+ if (this.isClaimCancelled(invocation) || owner && !this.inflight.has(owner.id))
1904
+ return;
1905
+ if (owner)
1906
+ this.unbindRunningOwner(owner.id, [invocation]);
1907
+ await this.completeTurn(invocation, {
1908
+ noResponse: true,
1909
+ metadata: {
1910
+ "remote.invocationId": invocation.id,
1911
+ "remote.sessionControl": "true",
1912
+ "remote.steered": "true"
1913
+ }
1914
+ }).catch((error) => this.failAfterTerminalWrite(invocation, error, "steer completion"));
1915
+ }
1916
+ async steerByInterrupt(invocation, actuator, text) {
1917
+ const streamId = invocation.responseStreamId;
1918
+ if (!actuator.interrupt(streamId)) {
1919
+ await this.completeAck(invocation, "Could not interrupt the session (runtime control unavailable); steer not delivered.");
1920
+ return;
1921
+ }
1922
+ await new Promise((resolve2) => setTimeout(resolve2, STEER_SETTLE_MS));
1923
+ if (this.isClaimCancelled(invocation))
1924
+ return;
1925
+ await this.completeInterruptedTurns(this.controlStream(streamId));
1926
+ const { parts, swept } = await this.sweepQueuedForSteer(text, streamId);
1927
+ if (this.isClaimCancelled(invocation))
1928
+ return;
1929
+ if (parts.length === 0) {
1930
+ await this.completeAck(invocation, "Interrupted the session; nothing pending to steer with.");
1931
+ await this.syncPresence();
1932
+ return;
1933
+ }
1934
+ this.bindRunningOwner(invocation.id, swept);
1935
+ await this.deliverTurn(invocation, buildSteerContent(parts), swept);
1936
+ }
1937
+ async sweepQueuedForSteer(text, streamId) {
1938
+ const swept = [];
1939
+ const running = this.parallel ? streamId : [...this.inflight.values()][0]?.invocation.responseStreamId;
1940
+ for (let i = 0;i < STEER_DRAIN_LIMIT; i++) {
1941
+ const extra = await this.claimNext(false, running).catch(() => null);
1942
+ if (!extra)
1943
+ break;
1944
+ this.markClaimProcessing(extra);
1945
+ if (this.isClaimCancelled(extra))
1946
+ continue;
1947
+ swept.push(extra);
1948
+ }
1949
+ const contents = new Map;
1950
+ for (const item of swept) {
1951
+ if (this.isClaimCancelled(item))
1952
+ continue;
1953
+ contents.set(item.id, await this.foldedSteerContent(item));
1954
+ }
1955
+ const liveSwept = swept.filter((item) => !this.isClaimCancelled(item));
1956
+ return { parts: this.steerParts(liveSwept, text, contents), swept: liveSwept, contents };
1957
+ }
1958
+ steerParts(liveSwept, text, contents) {
1959
+ const parts = liveSwept.map((item) => contents.get(item.id) ?? "");
1960
+ if (text)
1961
+ parts.push(text);
1962
+ return parts.filter(Boolean);
1963
+ }
1964
+ async sealSessionControlAck(invocation, markdown) {
1965
+ const ack = parseSealedAckContext(invocation.sealedAck);
1966
+ if (!ack)
1967
+ return;
1968
+ const identities = await this.bik.ensureForStream(invocation.rootStreamId);
1969
+ if (identities.length === 0)
1970
+ return;
1971
+ try {
1972
+ const sealing = await openSealedAck({ ack, identities, streamId: invocation.rootStreamId });
1973
+ return await sealReply(sealing, markdown);
1974
+ } catch {
1975
+ return;
1976
+ }
1977
+ }
1978
+ async completeAck(invocation, summary) {
1979
+ if (this.isClaimCancelled(invocation))
1980
+ return false;
1981
+ const signal = this.observedClaims.get(invocation.id)?.lifecycle.signal;
1982
+ try {
1983
+ await this.client.complete(invocation.id, {
1984
+ instanceId: this.config.instanceId,
1985
+ claimToken: invocation.claimToken,
1986
+ sourceRevision: invocation.sourceRevision,
1987
+ summary,
1988
+ metadata: {
1989
+ "remote.invocationId": invocation.id,
1990
+ "remote.sessionControl": "true"
1991
+ }
1992
+ }, signal);
1993
+ if (signal?.aborted || this.isClaimCancelled(invocation))
1994
+ return false;
1995
+ } catch (error) {
1996
+ await this.failAfterTerminalWrite(invocation, error, "session-control acknowledgement");
1997
+ return false;
1998
+ }
1999
+ this.releaseObservation(invocation.id);
2000
+ return true;
2001
+ }
2002
+ async completeReply(invocation, markdown) {
2003
+ if (this.isClaimCancelled(invocation))
2004
+ return false;
2005
+ const signal = this.observedClaims.get(invocation.id)?.lifecycle.signal;
2006
+ const sealedReply = await this.sealSessionControlAck(invocation, markdown);
2007
+ if (signal?.aborted || this.isClaimCancelled(invocation))
2008
+ return false;
2009
+ try {
2010
+ await this.client.complete(invocation.id, {
2011
+ instanceId: this.config.instanceId,
2012
+ claimToken: invocation.claimToken,
2013
+ sourceRevision: invocation.sourceRevision,
2014
+ ...sealedReply ? { sealedReply } : { finalMessageMarkdown: markdown },
2015
+ metadata: {
2016
+ "remote.invocationId": invocation.id,
2017
+ "remote.sessionControl": "true"
2018
+ }
2019
+ }, signal);
2020
+ if (signal?.aborted || this.isClaimCancelled(invocation))
2021
+ return false;
2022
+ } catch (error) {
2023
+ if (!sealedReply && error instanceof ThreaApiError && error.code === "E2E_STREAM_PLAINTEXT_UNSUPPORTED") {
2024
+ try {
2025
+ await this.completeTurn(invocation, { noResponse: true });
2026
+ return false;
2027
+ } catch (inner) {
2028
+ await this.failAfterTerminalWrite(invocation, inner, "session-control silent acknowledgement");
2029
+ return false;
2030
+ }
2031
+ }
2032
+ await this.failAfterTerminalWrite(invocation, error, sealedReply ? "sealed session-control reply" : "session-control reply");
2033
+ return false;
2034
+ }
2035
+ this.releaseObservation(invocation.id);
2036
+ return true;
2037
+ }
2038
+ async completeSilentAck(invocation) {
2039
+ if (this.isClaimCancelled(invocation))
2040
+ return false;
2041
+ try {
2042
+ await this.completeTurn(invocation, {
2043
+ noResponse: true,
2044
+ metadata: { "remote.invocationId": invocation.id, "remote.sessionControl": "true" }
2045
+ });
2046
+ } catch (error) {
2047
+ await this.failAfterTerminalWrite(invocation, error, "silent session-control acknowledgement");
2048
+ return false;
2049
+ }
2050
+ return true;
2051
+ }
2052
+ async completeNoResponse(invocation) {
2053
+ try {
2054
+ await this.completeTurn(invocation, {
2055
+ noResponse: true,
2056
+ metadata: {
2057
+ "remote.invocationId": invocation.id,
2058
+ "remote.steered": "true"
2059
+ }
2060
+ });
2061
+ } catch (error) {
2062
+ await this.failAfterTerminalWrite(invocation, error, "no-response completion");
2063
+ }
2064
+ }
2065
+ async failAfterTerminalWrite(invocation, error, operation) {
2066
+ this.log(`${operation} failed: ${this.summarize(error)}`);
2067
+ if (this.isClaimCancelled(invocation))
2068
+ return;
2069
+ if (error instanceof ThreaApiError && error.status === 409 && error.code === "INVOCATION_INPUT_STALE") {
2070
+ this.releaseObservation(invocation.id);
2071
+ return;
2072
+ }
2073
+ await this.failInvocation(invocation, `Could not persist ${operation}.`);
2074
+ }
2075
+ async failInvocation(invocation, errorMessage) {
2076
+ if (this.isClaimCancelled(invocation))
2077
+ return;
2078
+ await this.failFencedInvocation(invocation, errorMessage);
2079
+ this.releaseObservation(invocation.id);
2080
+ }
2081
+ async completeInterruptedTurns(streamId) {
2082
+ const routes = this.controlRoutes(streamId);
2083
+ const interrupted = new Set(routes.map((route) => route.invocation.id));
2084
+ const withdrawals = this.abandonPendingDecisions((decision) => !!decision.requesterInvocationId && interrupted.has(decision.requesterInvocationId));
2085
+ const closes = routes.map((route) => {
2086
+ route.revoke();
2087
+ if (route.state === "open")
2088
+ route.beginClosing();
2089
+ if (this.activeTurnStream === route.invocation.responseStreamId)
2090
+ this.activeTurnStream = undefined;
2091
+ const generation = route.generation;
2092
+ const task = route.enqueue(async () => {
2093
+ if (this.stopped || generation !== this.lifecycle || route.state === "closed")
2094
+ return;
2095
+ try {
2096
+ await this.completeTurn(route.invocation, {
2097
+ noResponse: true,
2098
+ metadata: { "remote.invocationId": route.invocation.id, "remote.interrupted": "true" },
2099
+ signal: route.execution.signal
2100
+ });
2101
+ route.markClosed();
2102
+ await this.completeContributors(route);
2103
+ } catch (error) {
2104
+ await this.failAfterTerminalWrite(route.invocation, error, "interrupted-turn completion");
2105
+ await this.failContributors(route, "The owning turn could not be closed after interruption.");
2106
+ } finally {
2107
+ if (generation === this.lifecycle && this.inflight.get(route.invocation.id) === route) {
2108
+ this.inflight.delete(route.invocation.id);
2109
+ }
2110
+ }
2111
+ });
2112
+ return route.trackClosing(task);
2113
+ });
2114
+ await Promise.all([...closes, withdrawals]);
2115
+ }
2116
+ async buildTurnContent(invocation, options = {}) {
2117
+ return withInboundAttachments(formatInvocationContent(invocation), await this.inboundAttachmentManifest(invocation, options));
2118
+ }
2119
+ async foldedSteerContent(invocation) {
2120
+ if (isSessionControlInvocation(invocation)) {
2121
+ const queued = parseSessionControlCommand(invocation);
2122
+ return queued?.name === "steer" ? queued.args : "";
2123
+ }
2124
+ const prompt = invocation.promptMarkdown.trim() || "(empty message)";
2125
+ return withInboundAttachments(prompt, await this.inboundAttachmentManifest(invocation, { sourceOnly: true }));
2126
+ }
2127
+ async inboundAttachmentManifest(invocation, options = {}) {
2128
+ if (options.signal?.aborted)
2129
+ throw options.signal.reason;
2130
+ if (invocation.sealing) {
2131
+ const refs = invocation.sealedAttachments;
2132
+ if (!refs)
2133
+ return "";
2134
+ try {
2135
+ const downloaded = await downloadSealedInboundAttachments(this.client, {
2136
+ refs: selectSealedInboundRefs(refs.prompt, options.sourceOnly ? [] : refs.history),
2137
+ invocationId: invocation.id,
2138
+ cwd: process.cwd(),
2139
+ log: this.log,
2140
+ strict: options.strictAttachments,
2141
+ signal: options.signal
2142
+ });
2143
+ if (options.signal?.aborted)
2144
+ throw options.signal.reason;
2145
+ return formatInboundAttachmentManifest(downloaded);
2146
+ } catch (error) {
2147
+ if (options.strictAttachments || options.signal?.aborted)
2148
+ throw error;
2149
+ this.log(`sealed inbound attachment fetch failed: ${this.summarize(error)}`);
2150
+ return "";
2151
+ }
2152
+ }
2153
+ try {
2154
+ const downloaded = await downloadInboundAttachments(this.client, {
2155
+ streamId: invocation.activeStreamId,
2156
+ sourceMessageId: invocation.sourceMessageId,
2157
+ contextMessageIds: options.sourceOnly ? [] : (invocation.context?.messages ?? []).map((message) => message.messageId),
2158
+ invocationId: invocation.id,
2159
+ cwd: process.cwd(),
2160
+ scanLimit: ATTACHMENT_SCAN_LIMIT,
2161
+ log: this.log,
2162
+ strict: options.strictAttachments,
2163
+ signal: options.signal
2164
+ });
2165
+ if (options.signal?.aborted)
2166
+ throw options.signal.reason;
2167
+ return formatInboundAttachmentManifest(downloaded);
2168
+ } catch (error) {
2169
+ if (options.strictAttachments || options.signal?.aborted)
2170
+ throw error;
2171
+ this.log(`inbound attachment scan failed: ${this.summarize(error)}`);
2172
+ return "";
2173
+ }
2174
+ }
2175
+ route(invocationId) {
2176
+ return this.inflight.get(invocationId) ?? this.completed.get(invocationId);
2177
+ }
2178
+ registerTurn(invocation, contributors = []) {
2179
+ const route = new TurnRoute({
2180
+ invocation,
2181
+ contributors,
2182
+ order: this.nextRouteOrder += 1,
2183
+ generation: this.lifecycle,
2184
+ idleTimeoutMs: this.config.idleTimeoutMs,
2185
+ onIdleDeadline: (target, generation) => void this.onReplyTimeout(target, generation)
2186
+ });
2187
+ route.armIdleTimeout();
2188
+ this.inflight.set(invocation.id, route);
2189
+ return route;
2190
+ }
2191
+ revokedResult(route) {
2192
+ return {
2193
+ ok: false,
2194
+ retryable: false,
2195
+ message: `Request ${route.invocation.id} is no longer routable — this session stopped speaking for its stream.`
2196
+ };
2197
+ }
2198
+ async postTurnMessage(route, intent, metadata) {
2199
+ const seq = route.claimSeq(intent.retry);
2200
+ const sealing = route.invocation.sealing;
2201
+ const sourceRevision = route.invocation.sourceRevision;
2202
+ let prepared = intent.retry;
2203
+ if (!prepared) {
2204
+ if (sealing) {
2205
+ const uploaded = await uploadSealedReplyAttachments(this.client, intent.text, process.cwd());
2206
+ const body = await sealReply(sealing, uploaded.markdown.trim(), uploaded.refs.length > 0 ? { attachmentRefs: uploaded.refs } : undefined);
2207
+ prepared = {
2208
+ kind: "sealed",
2209
+ seq,
2210
+ text: intent.text,
2211
+ ...intent.retryKey === undefined ? {} : { retryKey: intent.retryKey },
2212
+ body,
2213
+ attachmentIds: uploaded.attachmentIds
2214
+ };
2215
+ } else {
2216
+ const { markdown } = await uploadReplyAttachments(this.client, intent.text, process.cwd());
2217
+ prepared = {
2218
+ kind: "plaintext",
2219
+ seq,
2220
+ text: intent.text,
2221
+ ...intent.retryKey === undefined ? {} : { retryKey: intent.retryKey },
2222
+ body: {
2223
+ instanceId: this.config.instanceId,
2224
+ claimToken: route.invocation.claimToken,
2225
+ content: markdown,
2226
+ clientMessageId: intent.retryKey ?? `remote-send-${route.invocation.id}-${seq}`,
2227
+ metadata
2228
+ }
2229
+ };
2230
+ }
2231
+ }
2232
+ if (!prepared)
2233
+ throw new Error("post preparation produced no body");
2234
+ if (route.isFenced(this.lifecycle)) {
2235
+ throw new RouteRevokedError(this.revokedResult(route).message);
2236
+ }
2237
+ if (!this.isOutputCurrent(route.invocation, sourceRevision)) {
2238
+ throw new StaleInputError("invocation input changed while the post was being prepared");
2239
+ }
2240
+ try {
2241
+ if (prepared.kind === "sealed") {
2242
+ await this.client.sendSealedMessage(route.invocation.id, sealing.callbackToken, {
2243
+ ...prepared.body,
2244
+ ...prepared.attachmentIds.length > 0 && { attachmentIds: prepared.attachmentIds }
2245
+ });
2246
+ } else {
2247
+ await this.client.sendInvocationMessage(route.invocation.id, prepared.body);
2248
+ }
2249
+ } catch (error) {
2250
+ route.rememberFailedPost(seq, prepared, this.lifecycle);
2251
+ throw error;
2252
+ }
2253
+ route.recordLandedPost(seq, prepared);
2254
+ }
2255
+ async writeRouteMessage(route, intent, metadata) {
2256
+ try {
2257
+ await this.postTurnMessage(route, intent, metadata);
2258
+ } catch (error) {
2259
+ await this.terminalizeRouteWrite(route, error);
2260
+ throw error;
2261
+ }
2262
+ }
2263
+ async prepareReply(route, text) {
2264
+ const sealing = route.invocation.sealing;
2265
+ const sourceRevision = route.invocation.sourceRevision;
2266
+ if (sealing) {
2267
+ const { markdown: markdown2, refs, attachmentIds: attachmentIds2 } = await uploadSealedReplyAttachments(this.client, text, process.cwd());
2268
+ const spoken = markdown2.trim();
2269
+ const reply = spoken.length > 0 || refs.length > 0 ? await sealReply(sealing, spoken, refs.length > 0 ? { attachmentRefs: refs } : undefined) : undefined;
2270
+ return {
2271
+ reason: "reply",
2272
+ sourceText: text,
2273
+ wire: {
2274
+ kind: "sealed",
2275
+ callbackToken: sealing.callbackToken,
2276
+ body: reply === undefined ? { noResponse: true, sourceRevision } : { sourceRevision, reply: { ...reply, ...attachmentIds2.length > 0 && { attachmentIds: attachmentIds2 } } }
2277
+ }
2278
+ };
2279
+ }
2280
+ const { markdown, uploaded } = await uploadReplyAttachments(this.client, text, process.cwd());
2281
+ const attachmentIds = uploaded.map((attachment) => attachment.id);
2282
+ return {
2283
+ reason: "reply",
2284
+ sourceText: text,
2285
+ wire: {
2286
+ kind: "plaintext",
2287
+ body: {
2288
+ instanceId: this.config.instanceId,
2289
+ claimToken: route.invocation.claimToken,
2290
+ sourceRevision,
2291
+ ...markdown.trim().length > 0 ? { finalMessageMarkdown: markdown } : { noResponse: true },
2292
+ metadata: {
2293
+ "remote.invocationId": route.invocation.id,
2294
+ "remote.instanceId": this.config.instanceId,
2295
+ ...attachmentIds.length > 0 && { "remote.attachmentIds": attachmentIds.join(",") }
2296
+ }
2297
+ }
2298
+ }
2299
+ };
2300
+ }
2301
+ async prepareTimeout(route) {
2302
+ const note = "_The session ended the turn without sending a reply._";
2303
+ const noResponse = route.sentCount > 0;
2304
+ const sealing = route.invocation.sealing;
2305
+ const sourceRevision = route.invocation.sourceRevision;
2306
+ if (sealing) {
2307
+ return {
2308
+ reason: "timeout",
2309
+ wire: {
2310
+ kind: "sealed",
2311
+ callbackToken: sealing.callbackToken,
2312
+ body: noResponse ? { noResponse: true, sourceRevision } : { sourceRevision, reply: await sealReply(sealing, note) }
2313
+ }
2314
+ };
2315
+ }
2316
+ return {
2317
+ reason: "timeout",
2318
+ wire: {
2319
+ kind: "plaintext",
2320
+ body: {
2321
+ instanceId: this.config.instanceId,
2322
+ claimToken: route.invocation.claimToken,
2323
+ sourceRevision,
2324
+ ...noResponse ? { noResponse: true } : { finalMessageMarkdown: note },
2325
+ metadata: { "remote.invocationId": route.invocation.id, "remote.timedOut": "true" }
2326
+ }
2327
+ }
2328
+ };
2329
+ }
2330
+ async postPreparedClose(route, prepared) {
2331
+ if (route.isFenced(this.lifecycle))
2332
+ throw new RouteRevokedError("route revoked");
2333
+ if (prepared.wire.kind === "sealed") {
2334
+ await this.client.completeSealed(route.invocation.id, prepared.wire.callbackToken, prepared.wire.body, route.execution.signal);
2335
+ return;
2336
+ }
2337
+ await this.client.complete(route.invocation.id, prepared.wire.body, route.execution.signal);
2338
+ }
2339
+ async settleClosed(route, replyText) {
2340
+ const invocationId = route.invocation.id;
2341
+ route.settleClosed(replyText);
2342
+ this.inflight.delete(invocationId);
2343
+ if (route.isFenced(this.lifecycle))
2344
+ return;
2345
+ this.completed.delete(invocationId);
2346
+ this.completed.set(invocationId, route);
2347
+ for (const oldest of this.completed.keys()) {
2348
+ if (this.completed.size <= COMPLETED_TURN_MEMORY)
2349
+ break;
2350
+ this.completed.delete(oldest);
2351
+ }
2352
+ if (this.activeTurnStream === route.invocation.responseStreamId)
2353
+ this.activeTurnStream = undefined;
2354
+ await this.syncPresence();
2355
+ this.claimDrainRequested = true;
2356
+ this.scheduleRequestedClaimDrain();
2357
+ }
2358
+ async reopenAfterFailedCompletion(route, prepared) {
2359
+ if (route.isFenced(this.lifecycle)) {
2360
+ route.reopen(undefined);
2361
+ return;
2362
+ }
2363
+ route.reopen(prepared);
2364
+ route.armIdleTimeout();
2365
+ this.inflight.set(route.invocation.id, route);
2366
+ await this.syncPresence();
2367
+ }
2368
+ touchCompleted(invocationId, record) {
2369
+ if (!this.completed.delete(invocationId))
2370
+ return;
2371
+ this.completed.set(invocationId, record);
2372
+ }
2373
+ isTerminalPostError(error) {
2374
+ return error instanceof ThreaApiError && error.status >= 400 && error.status < 500 && !RETRYABLE_POST_STATUSES.has(error.status);
2375
+ }
2376
+ async terminalizeRouteWrite(route, error) {
2377
+ if (!this.isTerminalPostError(error))
2378
+ return false;
2379
+ if (!route.terminal)
2380
+ await this.evictTerminalRoute(route);
2381
+ return true;
2382
+ }
2383
+ terminalWriteResult(invocationId, kind, error) {
2384
+ return {
2385
+ ok: false,
2386
+ retryable: false,
2387
+ message: `Threa rejected the ${kind} for request ${invocationId}: ${this.summarize(error)}.`
2388
+ };
2389
+ }
2390
+ terminalResult(invocationId, kind, text) {
2391
+ const tombstone = this.terminalReplies.get(invocationId);
2392
+ if (kind === "reply" && tombstone?.replyDigest === this.replyDigest(text))
2393
+ return { ok: true, message: "sent" };
2394
+ return {
2395
+ ok: false,
2396
+ retryable: false,
2397
+ message: `Threa refused further messages for request ${invocationId} — it is closed and no longer accepts follow-ups.`
2398
+ };
2399
+ }
2400
+ async evictTerminalRoute(route) {
2401
+ const invocationId = route.invocation.id;
2402
+ route.markTerminal();
2403
+ this.releaseObservation(invocationId);
2404
+ this.inflight.delete(invocationId);
2405
+ this.completed.delete(invocationId);
2406
+ const newerRouteOwnsStream = [...this.inflight.values()].some((candidate) => candidate.order > route.order && !candidate.revoked && candidate.invocation.responseStreamId === route.invocation.responseStreamId);
2407
+ if (this.activeTurnStream === route.invocation.responseStreamId && !newerRouteOwnsStream) {
2408
+ this.activeTurnStream = undefined;
2409
+ }
2410
+ this.terminalReplies.delete(invocationId);
2411
+ this.terminalReplies.set(invocationId, {
2412
+ ...route.replyText === undefined ? {} : { replyDigest: this.replyDigest(route.replyText) }
2413
+ });
2414
+ for (const oldest of this.terminalReplies.keys()) {
2415
+ if (this.terminalReplies.size <= COMPLETED_TURN_MEMORY)
2416
+ break;
2417
+ this.terminalReplies.delete(oldest);
2418
+ }
2419
+ await this.syncPresence();
2420
+ }
2421
+ async postThroughClosed(route, kind, intent) {
2422
+ const invocationId = route.invocation.id;
2423
+ if (kind === "reply" && route.replyText === intent.text) {
2424
+ this.touchCompleted(invocationId, route);
2425
+ return { ok: true, message: "sent" };
2426
+ }
2427
+ if (route.terminal)
2428
+ return this.terminalResult(invocationId, kind, intent.text);
2429
+ if (intent.text.trim().length === 0) {
2430
+ return {
2431
+ ok: false,
2432
+ retryable: false,
2433
+ message: `Request ${invocationId} had already closed, and an empty ${kind} has nothing to post as a follow-up.`
2434
+ };
2435
+ }
2436
+ try {
2437
+ await this.writeRouteMessage(route, intent, {
2438
+ "remote.invocationId": invocationId,
2439
+ "remote.followUp": "true"
2440
+ });
2441
+ } catch (error) {
2442
+ if (error instanceof RouteRevokedError)
2443
+ return this.revokedResult(route);
2444
+ if (this.isTerminalPostError(error))
2445
+ return this.terminalWriteResult(invocationId, "message", error);
2446
+ return { ok: false, message: `Failed to post message to Threa: ${this.summarize(error)}`, retryable: true };
2447
+ }
2448
+ if (kind === "reply")
2449
+ route.replyText = intent.text;
2450
+ this.touchCompleted(invocationId, route);
2451
+ return {
2452
+ ok: true,
2453
+ message: `Posted as a follow-up message — request ${invocationId} had already closed, and stays closed.`
2454
+ };
2455
+ }
2456
+ async sendInterim(invocationId, text) {
2457
+ const route = this.route(invocationId);
2458
+ if (!route) {
2459
+ if (this.terminalReplies.has(invocationId))
2460
+ return this.terminalResult(invocationId, "send", text);
2461
+ return {
2462
+ ok: false,
2463
+ retryable: false,
2464
+ message: `No open request with invocation_id ${invocationId} — interim messages need an open request (it may have been answered or closed).`
2465
+ };
2466
+ }
2467
+ if (text.trim().length === 0) {
2468
+ return { ok: false, retryable: false, message: "An interim message needs content — nothing was posted." };
2469
+ }
2470
+ const intent = route.snapshotIntent(text);
2471
+ return route.enqueue(() => this.runSend(route, intent));
2472
+ }
2473
+ async runSend(route, intent) {
2474
+ if (route.terminal)
2475
+ return this.terminalResult(route.invocation.id, "send", intent.text);
2476
+ if (route.revoked)
2477
+ return this.revokedResult(route);
2478
+ if (route.state === "closed")
2479
+ return this.postThroughClosed(route, "send", intent);
2480
+ if (this.observedClaims.get(route.invocation.id)?.updateInProgress) {
2481
+ return { ok: false, message: "Input update is still in progress; retry this send.", retryable: true };
2482
+ }
2483
+ const sourceRevision = route.invocation.sourceRevision;
2484
+ try {
2485
+ await this.writeRouteMessage(route, intent, {
2486
+ "remote.invocationId": route.invocation.id,
2487
+ "remote.interim": "true"
2488
+ });
2489
+ } catch (error) {
2490
+ if (error instanceof RouteRevokedError)
2491
+ return this.revokedResult(route);
2492
+ if (error instanceof StaleInputError) {
2493
+ return {
2494
+ ok: false,
2495
+ message: "Input changed while preparing this send; retry on the current turn.",
2496
+ retryable: true
2497
+ };
2498
+ }
2499
+ if (this.isTerminalPostError(error))
2500
+ return this.terminalWriteResult(route.invocation.id, "message", error);
2501
+ return { ok: false, message: `Failed to post message to Threa: ${this.summarize(error)}`, retryable: true };
2502
+ }
2503
+ if (!this.isOutputCurrent(route.invocation, sourceRevision)) {
2504
+ return {
2505
+ ok: false,
2506
+ message: `Message post raced a source update for ${route.invocation.id}; do not treat it as current-turn output.`
2507
+ };
2508
+ }
2509
+ route.touchIdleTimeout();
2510
+ return { ok: true, message: "sent" };
2511
+ }
2512
+ async reply(invocationId, text) {
2513
+ const route = this.route(invocationId);
2514
+ if (!route) {
2515
+ if (this.terminalReplies.has(invocationId))
2516
+ return this.terminalResult(invocationId, "reply", text);
2517
+ return {
2518
+ ok: false,
2519
+ retryable: false,
2520
+ message: `No open request with invocation_id ${invocationId} (already answered, expired, or unknown).`
2521
+ };
2522
+ }
2523
+ const intent = route.snapshotIntent(text);
2524
+ return route.enqueue(() => this.runReply(route, intent));
2525
+ }
2526
+ async runReply(route, intent) {
2527
+ if (route.state === "closed" && route.replyText === intent.text)
2528
+ return { ok: true, message: "sent" };
2529
+ if (route.terminal)
2530
+ return this.terminalResult(route.invocation.id, "reply", intent.text);
2531
+ if (route.revoked)
2532
+ return this.revokedResult(route);
2533
+ if (route.state === "closed")
2534
+ return this.postThroughClosed(route, "reply", intent);
2535
+ if (this.observedClaims.get(route.invocation.id)?.updateInProgress) {
2536
+ return { ok: false, message: "Input update is still in progress; retry this reply.", retryable: true };
2537
+ }
2538
+ if (route.prepared && (route.prepared.reason === "timeout" || route.prepared.sourceText !== intent.text)) {
2539
+ const resolved = await this.closeWith(route, route.prepared);
2540
+ if (!resolved.ok) {
2541
+ return {
2542
+ ok: false,
2543
+ retryable: resolved.retryable ?? false,
2544
+ message: `The earlier close for request ${route.invocation.id} has not landed yet, so it still owns the close: ${resolved.message}`
2545
+ };
2546
+ }
2547
+ const followUp = await this.postThroughClosed(route, "reply", intent);
2548
+ return resolved.closedTurn ? { ...followUp, closedTurn: true } : followUp;
2549
+ }
2550
+ return this.closeWith(route, route.prepared ?? { kind: "reply", text: intent.text });
2551
+ }
2552
+ closeWith(route, source) {
2553
+ route.beginClosing();
2554
+ return route.trackClosing(this.runCompletion(route, source));
2555
+ }
2556
+ async runCompletion(route, source) {
2557
+ const sourceRevision = route.invocation.sourceRevision;
2558
+ let prepared;
2559
+ if ("wire" in source) {
2560
+ prepared = source;
2561
+ } else {
2562
+ try {
2563
+ prepared = source.kind === "reply" ? await this.prepareReply(route, source.text) : await this.prepareTimeout(route);
2564
+ } catch (error) {
2565
+ await this.reopenAfterFailedCompletion(route, undefined);
2566
+ if (route.revoked)
2567
+ return this.revokedResult(route);
2568
+ return {
2569
+ ok: false,
2570
+ message: `Failed to post reply to Threa (will stay open for retry): ${this.summarize(error)}`,
2571
+ retryable: true
2572
+ };
2573
+ }
2574
+ }
2575
+ if (!this.isOutputCurrent(route.invocation, sourceRevision))
2576
+ return this.closeStaleReply(route);
2577
+ route.prepared = prepared;
2578
+ try {
2579
+ await this.postPreparedClose(route, prepared);
2580
+ } catch (error) {
2581
+ if (this.isClaimCancelled(route.invocation))
2582
+ return this.closeStaleReply(route);
2583
+ if (error instanceof ThreaApiError && error.status === 409 && error.code === "INVOCATION_INPUT_STALE") {
2584
+ this.releaseObservation(route.invocation.id);
2585
+ await this.failContributors(route, "The owning turn input became stale during completion.");
2586
+ return this.closeStaleReply(route);
2587
+ }
2588
+ if (await this.terminalizeRouteWrite(route, error)) {
2589
+ return this.terminalWriteResult(route.invocation.id, "reply", error);
2590
+ }
2591
+ await this.reopenAfterFailedCompletion(route, prepared);
2592
+ if (error instanceof RouteRevokedError || route.revoked)
2593
+ return this.revokedResult(route);
2594
+ return {
2595
+ ok: false,
2596
+ message: `Failed to post reply to Threa (will stay open for retry): ${this.summarize(error)}`,
2597
+ retryable: true
2598
+ };
2599
+ }
2600
+ this.releaseObservation(route.invocation.id);
2601
+ await this.completeContributors(route);
2602
+ await this.settleClosed(route, prepared.reason === "reply" ? prepared.sourceText : undefined);
2603
+ return { ok: true, message: "sent", closedTurn: true };
2604
+ }
2605
+ async closeStaleReply(route) {
2606
+ route.revoke();
2607
+ if (this.inflight.get(route.invocation.id) === route)
2608
+ this.inflight.delete(route.invocation.id);
2609
+ if (this.activeTurnStream === route.invocation.responseStreamId)
2610
+ this.activeTurnStream = undefined;
2611
+ await this.syncPresence();
2612
+ return { ok: false, message: `Request ${route.invocation.id} is closed; its source changed.` };
2613
+ }
2614
+ async failTurn(invocationId, errorMessage) {
2615
+ const route = this.route(invocationId);
2616
+ if (!route)
2617
+ return false;
2618
+ return route.enqueue(() => route.trackClosing((async () => {
2619
+ if (route.state === "closed" || route.terminal)
2620
+ return false;
2621
+ route.beginClosing();
2622
+ await this.failContributors(route, errorMessage);
2623
+ await this.failInvocation(route.invocation, errorMessage);
2624
+ route.markClosed();
2625
+ this.clearInflight(invocationId);
2626
+ if (this.activeTurnStream === route.invocation.responseStreamId)
2627
+ this.activeTurnStream = undefined;
2628
+ await this.syncPresence();
2629
+ this.claimDrainRequested = true;
2630
+ this.scheduleRequestedClaimDrain();
2631
+ return true;
2632
+ })()));
2633
+ }
2634
+ async recordSteps(invocationId, frames, statusText) {
2635
+ const entry = this.openRoute(invocationId);
2636
+ if (!entry)
2637
+ return false;
2638
+ if (this.observedClaims.get(invocationId)?.updateInProgress)
2639
+ return true;
2640
+ for (let start = 0;start < frames.length; start += MAX_STEP_FRAMES_PER_CALL) {
2641
+ const chunk = frames.slice(start, start + MAX_STEP_FRAMES_PER_CALL);
2642
+ const sourceRevision = entry.invocation.sourceRevision;
2643
+ if (entry.invocation.sealing) {
2644
+ const sealing = entry.invocation.sealing;
2645
+ const finished = chunk.filter((frame) => frame.phase !== "started");
2646
+ if (finished.length === 0)
2647
+ continue;
2648
+ try {
2649
+ const sealedFrames = await Promise.all(finished.map((frame) => sealStep(sealing, frame.stepType, frame.content, frame.durationMs !== undefined ? { durationMs: frame.durationMs } : undefined)));
2650
+ if (!this.isOutputCurrent(entry.invocation, sourceRevision))
2651
+ return this.inflight.has(invocationId);
2652
+ await this.transport.recordSealedSteps(invocationId, sealing.callbackToken, sealedFrames);
2653
+ } catch (error) {
2654
+ this.log(`recordSteps (sealed) failed: ${this.summarize(error)}`);
2655
+ }
2656
+ } else {
2657
+ if (!this.isOutputCurrent(entry.invocation, sourceRevision))
2658
+ return this.inflight.has(invocationId);
2659
+ await this.transport.recordSteps(invocationId, entry.invocation.claimToken, chunk, statusText ?? this.runtime.busyStatusText).catch((error) => this.log(`recordSteps failed: ${this.summarize(error)}`));
2660
+ }
2661
+ if (!this.openRoute(invocationId))
2662
+ return false;
2663
+ }
2664
+ return true;
2665
+ }
2666
+ async postToInvocation(invocationId, body) {
2667
+ const route = this.route(invocationId);
2668
+ if (!route)
2669
+ throw this.routeUnavailableError(invocationId);
2670
+ await this.postRouteOwnedMessage(route, body);
2671
+ }
2672
+ async postToStream(streamId, body) {
2673
+ const route = this.routeForStream(streamId);
2674
+ if (route) {
2675
+ await this.postRouteOwnedMessage(route, body);
2676
+ return;
2677
+ }
2678
+ await this.client.sendMessage(streamId, body);
2679
+ }
2680
+ async postRouteOwnedMessage(route, body) {
2681
+ const intent = route.snapshotIntent(body.content, body.clientMessageId);
2682
+ await route.enqueue(async () => {
2683
+ if (route.terminal)
2684
+ throw this.routeUnavailableError(route.invocation.id);
2685
+ if (route.revoked)
2686
+ throw new RouteRevokedError(this.revokedResult(route).message);
2687
+ await this.writeRouteMessage(route, intent, {
2688
+ "remote.invocationId": route.invocation.id,
2689
+ ...body.metadata
2690
+ });
2691
+ if (route.state === "closed")
2692
+ this.touchCompleted(route.invocation.id, route);
2693
+ });
2694
+ }
2695
+ routeUnavailableError(invocationId) {
2696
+ if (this.terminalReplies.has(invocationId)) {
2697
+ return new Error(`Threa refused further messages for request ${invocationId}; its route is terminal.`);
2698
+ }
2699
+ return new Error(`No routable request with invocation_id ${invocationId} exists in this session.`);
2700
+ }
2701
+ controlStream(streamId) {
2702
+ return this.parallel ? streamId : undefined;
2703
+ }
2704
+ controlRoutes(streamId) {
2705
+ const routes = [...this.inflight.values()];
2706
+ const scope = streamId === undefined ? undefined : this.controlStream(streamId);
2707
+ return scope === undefined ? routes : routes.filter((route) => route.invocation.responseStreamId === scope);
2708
+ }
2709
+ routeForStream(streamId) {
2710
+ let closing;
2711
+ for (const route of this.inflight.values()) {
2712
+ if (route.invocation.responseStreamId !== streamId)
2713
+ continue;
2714
+ if (route.state === "open")
2715
+ return route;
2716
+ closing ??= route;
2717
+ }
2718
+ return closing;
2719
+ }
2720
+ openRoute(invocationId) {
2721
+ const route = this.inflight.get(invocationId);
2722
+ return route?.state === "open" && !route.revoked ? route : undefined;
2723
+ }
2724
+ isInflight(invocationId) {
2725
+ return this.openRoute(invocationId) !== undefined;
2726
+ }
2727
+ keepAlive(streamId) {
2728
+ for (const entry of this.inflight.values()) {
2729
+ if (entry.state === "open" && entry.invocation.responseStreamId === streamId)
2730
+ entry.touchIdleTimeout();
2731
+ }
2732
+ }
2733
+ async requestDecision(input, opts = {}) {
2734
+ if (!input.streamId && this.parallel && this.inflightStreams().size > 1) {
2735
+ throw new Error("Cannot open a decision: turns are running in several streams; pass streamId.");
2736
+ }
2737
+ const onlyStream = this.parallel ? [...this.inflightStreams()][0] : undefined;
2738
+ const streamId = input.streamId ?? onlyStream ?? this.activeTurnStream ?? this.link?.rootStreamId;
2739
+ if (!streamId)
2740
+ throw new Error("Cannot open a decision: this session has no active turn and no linked scratchpad.");
2741
+ const route = this.routeForStream(streamId);
2742
+ const invocationId = input.invocationId ?? route?.invocation.id;
2743
+ const sealing = route?.invocation.sealing;
2744
+ const question = sealing ? await this.sealedDecisionQuestion(streamId, input, sealing) : {
2745
+ title: input.title,
2746
+ ...input.body ? { bodyMarkdown: input.body } : {},
2747
+ options: input.options
2748
+ };
2749
+ const decision = await this.client.requestDecision(streamId, {
2750
+ ...question,
2751
+ ...input.allowNote === undefined ? {} : { allowNote: input.allowNote },
2752
+ ...input.externalRef ? { externalRef: input.externalRef } : {},
2753
+ ...input.expiresInMs === undefined ? {} : { expiresInMs: input.expiresInMs },
2754
+ runtimeSessionId: this.config.runtimeSessionId,
2755
+ ...invocationId ? { invocationId } : {}
2756
+ });
2757
+ if (decision.status !== "open") {
2758
+ return this.outcomeFor(decision, await this.decisionNote(decision, sealing));
2759
+ }
2760
+ if (this.stopped) {
2761
+ this.cancelDecision(decision.id).catch(() => {});
2762
+ throw new DecisionAbandonedError(decision.id);
2763
+ }
2764
+ const promise = new Promise((resolve2, reject) => {
2765
+ this.pendingDecisions.set(decision.id, { decision, ...sealing ? { sealing } : {}, resolve: resolve2, reject });
2766
+ });
2767
+ const abort = () => {
2768
+ if (!this.pendingDecisions.has(decision.id))
2769
+ return;
2770
+ this.failDecision(decision.id, decisionAbortError(decision.id));
2771
+ this.cancelDecision(decision.id).catch((error) => this.log(`decision ${decision.id} cancel-on-abort failed: ${this.summarize(error)}`));
2772
+ };
2773
+ if (opts.signal?.aborted)
2774
+ abort();
2775
+ else
2776
+ opts.signal?.addEventListener("abort", abort, { once: true });
2777
+ this.keepTurnAliveForDecisions();
2778
+ this.scheduleDecisionPoll();
2779
+ try {
2780
+ return await promise;
2781
+ } finally {
2782
+ opts.signal?.removeEventListener("abort", abort);
2783
+ }
2784
+ }
2785
+ async cancelDecision(decisionId) {
2786
+ await this.client.cancelDecision(decisionId);
2787
+ }
2788
+ async sealedDecisionQuestion(streamId, input, sealing) {
2789
+ if (!this.botId) {
2790
+ throw new Error("Cannot seal a decision: the bot plane never said which bot this session speaks as.");
2791
+ }
2792
+ const sealed = await sealDecision(sealing, { streamId, requesterBotId: this.botId }, {
2793
+ title: input.title,
2794
+ ...input.body ? { bodyMarkdown: input.body } : {},
2795
+ optionLabels: Object.fromEntries(input.options.map((option) => [option.id, option.label ?? option.id]))
2796
+ });
2797
+ return {
2798
+ options: input.options.map((option) => ({ id: option.id, tone: option.tone })),
2799
+ decisionId: sealed.decisionId,
2800
+ sealed: { ciphertext: sealed.ciphertext, envelope: sealed.envelope }
2801
+ };
2802
+ }
2803
+ async decisionNote(decision, sealing) {
2804
+ const resolution = decision.resolution;
2805
+ if (!resolution?.noteCiphertext || !resolution.noteEnvelope)
2806
+ return resolution?.note ?? null;
2807
+ if (!sealing || !resolution.decidedBy) {
2808
+ this.log(`decision ${decision.id} carries a sealed note this session has no way to open`);
2809
+ return null;
2810
+ }
2811
+ const opened = await openSealedDecisionNote(sealing, {
2812
+ streamId: decision.streamId,
2813
+ decisionId: decision.id,
2814
+ decidedBy: resolution.decidedBy,
2815
+ ciphertext: resolution.noteCiphertext,
2816
+ envelope: resolution.noteEnvelope
2817
+ });
2818
+ if (opened === null)
2819
+ this.log(`decision ${decision.id} sealed note did not open`);
2820
+ return opened;
2821
+ }
2822
+ outcomeFor(decision, note) {
2823
+ if (decision.status === "resolved") {
2824
+ const optionId = decision.resolution?.optionId;
2825
+ if (!optionId)
2826
+ throw new Error(`Decision ${decision.id} resolved without an option id.`);
2827
+ return { status: "resolved", optionId, note, decision };
2828
+ }
2829
+ return { status: decision.status === "expired" ? "expired" : "cancelled", decision };
2830
+ }
2831
+ handleDecisionPush(payload) {
2832
+ if (!payload || typeof payload !== "object")
2833
+ return;
2834
+ const pending = this.pendingDecisions.get(payload.decisionId);
2835
+ if (!pending)
2836
+ return;
2837
+ if (payload.runtimeSessionId !== this.config.runtimeSessionId)
2838
+ return;
2839
+ this.settleDecision({
2840
+ ...pending.decision,
2841
+ status: payload.status,
2842
+ version: payload.version,
2843
+ ...payload.status === "resolved" && payload.optionId ? {
2844
+ resolution: {
2845
+ optionId: payload.optionId,
2846
+ ...payload.note === null ? {} : { note: payload.note },
2847
+ ...payload.noteCiphertext && payload.noteEnvelope ? { noteCiphertext: payload.noteCiphertext, noteEnvelope: payload.noteEnvelope } : {},
2848
+ ...payload.decidedBy === null ? {} : { decidedBy: payload.decidedBy }
2849
+ }
2850
+ } : {}
2851
+ });
2852
+ }
2853
+ async settleDecision(decision) {
2854
+ const pending = this.pendingDecisions.get(decision.id);
2855
+ if (!pending)
2856
+ return;
2857
+ this.pendingDecisions.delete(decision.id);
2858
+ this.stopDecisionPollWhenIdle();
2859
+ try {
2860
+ pending.resolve(this.outcomeFor(decision, await this.decisionNote(decision, pending.sealing)));
2861
+ } catch (error) {
2862
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
2863
+ }
2864
+ }
2865
+ failDecision(decisionId, error) {
2866
+ const pending = this.pendingDecisions.get(decisionId);
2867
+ if (!pending)
2868
+ return;
2869
+ this.pendingDecisions.delete(decisionId);
2870
+ this.stopDecisionPollWhenIdle();
2871
+ pending.reject(error);
2872
+ }
2873
+ keepTurnAliveForDecisions() {
2874
+ for (const streamId of new Set([...this.pendingDecisions.values()].map((pending) => pending.decision.streamId))) {
2875
+ this.keepAlive(streamId);
2876
+ }
2877
+ }
2878
+ scheduleDecisionPoll(delayMs) {
2879
+ if (this.decisionPollTimer)
2880
+ clearTimeout(this.decisionPollTimer);
2881
+ this.decisionPollTimer = undefined;
2882
+ if (this.stopped || this.pendingDecisions.size === 0)
2883
+ return;
2884
+ const delay = delayMs ?? (this.transport.socketConnected ? Math.min(WS_BACKSTOP_POLL_MS, Math.floor(this.config.idleTimeoutMs / 2)) : this.config.pollMs);
2885
+ this.decisionPollTimer = setTimeout(() => {
2886
+ this.decisionPollTimer = undefined;
2887
+ this.pollPendingDecisions();
2888
+ }, delay);
2889
+ }
2890
+ stopDecisionPollWhenIdle() {
2891
+ if (this.pendingDecisions.size > 0)
2892
+ return;
2893
+ if (this.decisionPollTimer)
2894
+ clearTimeout(this.decisionPollTimer);
2895
+ this.decisionPollTimer = undefined;
2896
+ }
2897
+ async pollPendingDecisions() {
2898
+ if (this.stopped)
2899
+ return;
2900
+ this.keepTurnAliveForDecisions();
2901
+ for (const decisionId of [...this.pendingDecisions.keys()]) {
2902
+ try {
2903
+ const decision = await this.client.getDecision(decisionId);
2904
+ if (decision.status !== "open")
2905
+ await this.settleDecision(decision);
2906
+ } catch (error) {
2907
+ if (error instanceof ThreaApiError && error.status === 404) {
2908
+ this.failDecision(decisionId, error);
2909
+ continue;
2910
+ }
2911
+ this.log(`decision ${decisionId} backstop poll failed: ${this.summarize(error)}`);
2912
+ }
2913
+ }
2914
+ this.scheduleDecisionPoll();
2915
+ }
2916
+ async abandonPendingDecisions(matches = () => true) {
2917
+ const abandoned = [...this.pendingDecisions.values()].map((pending) => pending.decision).filter(matches);
2918
+ for (const decision of abandoned)
2919
+ this.failDecision(decision.id, new DecisionAbandonedError(decision.id));
2920
+ this.stopDecisionPollWhenIdle();
2921
+ await Promise.all(abandoned.map((decision) => this.cancelDecision(decision.id).catch((error) => this.log(`decision ${decision.id} withdraw failed: ${this.summarize(error)}`))));
2922
+ }
2923
+ async onReplyTimeout(route, generation) {
2924
+ if (!route.isCurrentDeadline(generation) || route.state !== "open")
2925
+ return;
2926
+ await route.enqueue(() => this.runIdleTimeout(route, generation));
2927
+ }
2928
+ async runIdleTimeout(route, generation) {
2929
+ if (!route.isCurrentDeadline(generation) || route.state !== "open" || route.revoked)
2930
+ return;
2931
+ const result = await this.closeWith(route, route.prepared ?? { kind: "timeout" });
2932
+ if (!result.ok)
2933
+ this.log(`timeout close failed: ${result.message}`);
2934
+ }
2935
+ clearInflight(invocationId) {
2936
+ const entry = this.inflight.get(invocationId);
2937
+ if (!entry)
2938
+ return;
2939
+ entry.revoke();
2940
+ this.inflight.delete(invocationId);
2941
+ }
2942
+ async handleSessionArchived(payload) {
2943
+ const data = payload ?? {};
2944
+ if (typeof data.runtimeSessionId === "string" && data.runtimeSessionId !== this.config.runtimeSessionId)
2945
+ return;
2946
+ const linked = this.link?.rootStreamId;
2947
+ const rootStreamId = typeof data.rootStreamId === "string" ? data.rootStreamId : linked;
2948
+ if (!rootStreamId || linked && rootStreamId !== linked)
2949
+ return;
2950
+ await this.archive.archived(rootStreamId);
2951
+ }
2952
+ async handleSessionRestored(payload) {
2953
+ const data = payload ?? {};
2954
+ if (typeof data.runtimeSessionId === "string" && data.runtimeSessionId !== this.config.runtimeSessionId)
2955
+ return;
2956
+ await this.archive.restored();
2957
+ }
2958
+ async probeArchiveBackstop() {
2959
+ await this.archive.probe(this.link?.rootStreamId);
2960
+ }
2961
+ async detachForArchive(rootStreamId) {
2962
+ const inflight = this.revokeAllRoutes();
2963
+ this.activeTurnStream = undefined;
2964
+ this.link = undefined;
2965
+ this.linkGeneration += 1;
2966
+ this.resetReconnectHandoff();
2967
+ await this.reconnectFallbackTask;
2968
+ this.reschedulePoll(this.archive.probeDelayMs);
2969
+ await this.waitForClaimDrain();
2970
+ const offline = this.enqueueOfflinePresence(() => !this.stopped && this.archive.pendingRootStreamId === rootStreamId);
2971
+ await this.failUnansweredRoutes(inflight);
2972
+ await offline;
2973
+ }
2974
+ async windDownForArchive(rootStreamId) {
2975
+ await this.shutdown();
2976
+ await this.delegate.onArchived?.({ rootStreamId });
2977
+ }
2978
+ startPoll() {
2979
+ this.reschedulePoll(this.config.pollMs);
2980
+ }
2981
+ handleTransportDisconnected() {
2982
+ this.emptyNoSocketPolls = 0;
2983
+ this.reschedulePoll(this.config.pollMs);
2984
+ if (this.pendingDecisions.size > 0)
2985
+ this.scheduleDecisionPoll(this.config.pollMs);
2986
+ }
2987
+ reschedulePoll(delayMs) {
2988
+ if (this.stopped)
2989
+ return;
2990
+ if (this.pollTimer)
2991
+ clearTimeout(this.pollTimer);
2992
+ this.pollTimer = setTimeout(() => void this.pollTick(), delayMs);
2993
+ }
2994
+ async pollTick() {
2995
+ if (this.stopped)
2996
+ return;
2997
+ await this.probeArchiveBackstop();
2998
+ if (!this.link)
2999
+ await this.ensureLink();
3000
+ if (!this.transport.socketConnected)
3001
+ await this.transport.connect();
3002
+ const claimed = await this.claimDrain();
3003
+ if (!this.stopped)
3004
+ this.reschedulePoll(this.nextPollDelay(claimed));
3005
+ }
3006
+ nextPollDelay(claimed) {
3007
+ if (this.archive.detached)
3008
+ return this.archive.probeDelayMs;
3009
+ if (this.transport.socketConnected) {
3010
+ this.emptyNoSocketPolls = 0;
3011
+ return WS_BACKSTOP_POLL_MS;
3012
+ }
3013
+ if (claimed) {
3014
+ this.emptyNoSocketPolls = 0;
3015
+ return this.config.pollMs;
3016
+ }
3017
+ const delay = Math.min(NO_SOCKET_POLL_CAP_MS, this.config.pollMs * 2 ** this.emptyNoSocketPolls);
3018
+ this.emptyNoSocketPolls = Math.min(this.emptyNoSocketPolls + 1, 30);
3019
+ return delay;
3020
+ }
3021
+ resetReconnectHandoff() {
3022
+ if (this.reconnectResetTimer)
3023
+ clearTimeout(this.reconnectResetTimer);
3024
+ this.reconnectResetTimer = undefined;
3025
+ const wasHandoff = this.reconnectHandoff;
3026
+ this.reconnectHandoff = false;
3027
+ const onHandoffReset = this.onHandoffReset;
3028
+ this.onHandoffReset = undefined;
3029
+ if (!wasHandoff && !onHandoffReset)
3030
+ return;
3031
+ let callbackTask;
3032
+ if (onHandoffReset) {
3033
+ try {
3034
+ callbackTask = Promise.resolve(onHandoffReset()).catch((error) => this.log(`session-control handoff reset failed: ${this.summarize(error)}`));
3035
+ } catch (error) {
3036
+ this.log(`session-control handoff reset failed: ${this.summarize(error)}`);
3037
+ }
3038
+ }
3039
+ if (this.stopped || !this.link || this.archive.detached) {
3040
+ if (callbackTask)
3041
+ this.reconnectFallbackTask = callbackTask;
3042
+ return;
3043
+ }
3044
+ let task;
3045
+ const restoreIntake = () => this.syncPresence().then(() => this.claimDrain());
3046
+ task = (callbackTask ? callbackTask.then(restoreIntake) : restoreIntake()).finally(() => {
3047
+ if (this.reconnectFallbackTask === task)
3048
+ this.reconnectFallbackTask = undefined;
3049
+ });
3050
+ this.reconnectFallbackTask = task;
3051
+ }
3052
+ async keyGrantedStreams(streamIds) {
3053
+ for (const streamId of streamIds)
3054
+ await this.bik.ensureForStream(streamId);
3055
+ await this.advertiseKeyring();
3056
+ }
3057
+ async keyRevokedStream(streamId) {
3058
+ await this.bik.dropStream(streamId);
3059
+ await this.advertiseKeyring();
3060
+ }
3061
+ async advertiseKeyring() {
3062
+ const advertised = this.bik.identities.map((identity) => identity.publicKeyId).join(",");
3063
+ if (advertised === this.advertisedKeyIds)
3064
+ return;
3065
+ this.advertisedKeyIds = advertised;
3066
+ Object.assign(this.hello, this.bik.presenceFields());
3067
+ await this.syncPresence();
3068
+ }
3069
+ enqueuePresence(write) {
3070
+ const queued = this.presenceTail.then(write);
3071
+ this.presenceTail = queued.catch(() => {
3072
+ return;
3073
+ });
3074
+ return this.presenceTail;
3075
+ }
3076
+ syncPresence() {
3077
+ const lifecycle = this.lifecycle;
3078
+ return this.enqueuePresence(async () => {
3079
+ if (lifecycle !== this.lifecycle || this.stopped || this.archive.detached)
3080
+ return;
3081
+ const busy = this.reconnectHandoff || this.atCapacity;
3082
+ await this.publishPresence(this.presenceBody(busy ? "busy" : "available", busy ? this.runtime.busyStatusText : undefined));
3083
+ });
3084
+ }
3085
+ enqueueOfflinePresence(isCurrent) {
3086
+ const lifecycle = this.lifecycle;
3087
+ return this.enqueuePresence(async () => {
3088
+ if (lifecycle !== this.lifecycle || !isCurrent())
3089
+ return;
3090
+ await this.publishPresence(this.presenceBody("offline"));
3091
+ });
3092
+ }
3093
+ async publishPresence(body) {
3094
+ await this.transport.updatePresence(body);
3095
+ this.reportPresence(body);
3096
+ }
3097
+ reportPresence(body) {
3098
+ if (!this.onPresence)
3099
+ return;
3100
+ try {
3101
+ this.onPresence({
3102
+ runtimeKind: body.runtimeKind,
3103
+ instanceId: body.instanceId,
3104
+ runtimeSessionId: body.runtimeSessionId,
3105
+ displayName: body.displayName,
3106
+ status: body.status,
3107
+ capabilities: body.capabilities,
3108
+ manifest: body.manifest
3109
+ });
3110
+ } catch (error) {
3111
+ this.log(`presence report failed: ${this.summarize(error)}`);
3112
+ }
3113
+ }
3114
+ presenceBody(status, statusText) {
3115
+ return {
3116
+ runtimeKind: this.runtime.kind,
3117
+ instanceId: this.config.instanceId,
3118
+ runtimeSessionId: this.config.runtimeSessionId,
3119
+ displayName: this.config.displayName,
3120
+ status,
3121
+ acceptingInvocations: status === "available",
3122
+ capabilities: runtimeCapabilitiesFor(this.config.runtimeSessionId, this.delegate.sessionControl),
3123
+ manifest: effectiveRuntimeManifest(this.runtime.manifest, this.delegate.sessionControl),
3124
+ ...statusText ? { statusText } : {},
3125
+ ...this.bik.presenceFields()
3126
+ };
3127
+ }
3128
+ claimBody(busy, excludeResponseStreamIds = []) {
3129
+ return {
3130
+ runtimeKind: this.runtime.kind,
3131
+ instanceId: this.config.instanceId,
3132
+ runtimeSessionId: this.config.runtimeSessionId,
3133
+ supportedCapabilities: claimCapabilitiesFor(busy, this.sessionControlEnabled),
3134
+ claimTtlSeconds: CLAIM_TTL_SECONDS,
3135
+ ...!busy && excludeResponseStreamIds.length > 0 ? { excludeResponseStreamIds } : {}
3136
+ };
3137
+ }
3138
+ replyDigest(text) {
3139
+ return createHash2("sha256").update(text).digest("hex");
3140
+ }
3141
+ summarize(error) {
3142
+ return (error instanceof Error ? error.message : String(error)).slice(0, 200);
3143
+ }
3144
+ }
3145
+ // src/config-file.ts
3146
+ import { chmodSync, existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "node:fs";
3147
+ import { dirname as dirname2 } from "node:path";
3148
+ function readConfigFile(path, log) {
3149
+ if (!existsSync(path))
3150
+ return;
3151
+ try {
3152
+ return parseConfigFile(readFileSync2(path, "utf8"));
3153
+ } catch (error) {
3154
+ log(`ignoring ${path}: ${error instanceof Error ? error.message : String(error)}`);
3155
+ return;
3156
+ }
3157
+ }
3158
+ function writeFileAtomic(path, content, mode = 384) {
3159
+ mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
3160
+ const temp = `${path}.${process.pid}.tmp`;
3161
+ writeFileSync2(temp, content, { mode, flag: "wx" });
3162
+ chmodSync(temp, mode);
3163
+ renameSync(temp, path);
3164
+ }
3165
+ // src/lifecycle.ts
3166
+ function describeError(value) {
3167
+ if (value instanceof Error)
3168
+ return value.stack ?? value.message;
3169
+ return String(value);
3170
+ }
3171
+ function defaultParentAlive(pid) {
3172
+ try {
3173
+ process.kill(pid, 0);
3174
+ return true;
3175
+ } catch {
3176
+ return false;
3177
+ }
3178
+ }
3179
+ function wireLifecycle(server, host, options = {}) {
3180
+ const logPrefix = options.logPrefix ?? "[threa-remote]";
3181
+ const exitGuardMs = options.exitGuardMs ?? 4000;
3182
+ const parentAlive = options.parentAlive ?? defaultParentAlive;
3183
+ const parentProbeDelayMs = options.parentProbeDelayMs ?? 250;
3184
+ const hostPid = host.ppid;
3185
+ let shuttingDown = false;
3186
+ const shutdownAndExit = async (code, reason, shutdown = {}) => {
3187
+ if (shuttingDown)
3188
+ return;
3189
+ shuttingDown = true;
3190
+ host.stderr.write(`${logPrefix} shutting down (${reason})
3191
+ `);
3192
+ const guard = setTimeout(() => host.exit(code), exitGuardMs);
3193
+ if (guard && typeof guard === "object" && "unref" in guard)
3194
+ guard.unref();
3195
+ await server.shutdown(shutdown).catch(() => {
3196
+ return;
3197
+ });
3198
+ clearTimeout(guard);
3199
+ host.exit(code);
3200
+ };
3201
+ host.on("SIGINT", () => void shutdownAndExit(0, "SIGINT"));
3202
+ host.on("SIGTERM", () => void shutdownAndExit(0, "SIGTERM"));
3203
+ host.on("SIGHUP", () => void shutdownAndExit(0, "SIGHUP"));
3204
+ const stdinClosed = () => {
3205
+ if (shuttingDown)
3206
+ return;
3207
+ setTimeout(() => void shutdownAndExit(0, "stdin closed by parent", { hostGone: !parentAlive(hostPid) }), parentProbeDelayMs);
3208
+ };
3209
+ host.stdin.on("end", stdinClosed);
3210
+ host.stdin.on("close", stdinClosed);
3211
+ host.on("uncaughtException", (error) => {
3212
+ host.stderr.write(`${logPrefix} uncaughtException: ${describeError(error)}
3213
+ `);
3214
+ shutdownAndExit(1, "uncaughtException");
3215
+ });
3216
+ host.on("unhandledRejection", (reason) => {
3217
+ host.stderr.write(`${logPrefix} unhandledRejection: ${describeError(reason)}
3218
+ `);
3219
+ shutdownAndExit(1, "unhandledRejection");
3220
+ });
3221
+ }
3222
+ // src/delegation-client.ts
3223
+ var FETCH_TIMEOUT_MS2 = 30000;
3224
+ var CALLBACK_TOKEN_HEADER = "X-Threa-Callback-Token";
3225
+
3226
+ class DelegationClient {
3227
+ opts;
3228
+ constructor(opts) {
3229
+ this.opts = opts;
3230
+ }
3231
+ get base() {
3232
+ return this.opts.baseUrl.replace(/\/$/, "");
3233
+ }
3234
+ path(suffix) {
3235
+ return `${this.base}/api/v1/workspaces/${this.opts.workspaceId}/delegations${suffix}`;
3236
+ }
3237
+ async request(url, init) {
3238
+ const controller = new AbortController;
3239
+ const timeout = setTimeout(() => controller.abort(), this.opts.fetchTimeoutMs ?? FETCH_TIMEOUT_MS2);
3240
+ try {
3241
+ return await this.requestWithin(url, init, controller.signal);
3242
+ } finally {
3243
+ clearTimeout(timeout);
3244
+ }
3245
+ }
3246
+ async requestWithin(url, init, signal) {
3247
+ const { claimToken, ...request } = init ?? {};
3248
+ const response = await fetch(url, {
3249
+ ...request,
3250
+ signal,
3251
+ headers: {
3252
+ Authorization: `Bearer ${this.opts.apiKey}`,
3253
+ "Content-Type": "application/json",
3254
+ ...claimToken ? { [CALLBACK_TOKEN_HEADER]: claimToken } : {},
3255
+ ...request.headers
3256
+ }
3257
+ });
3258
+ if (!response.ok) {
3259
+ let code;
3260
+ if (response.headers.get("content-type")?.includes("application/json")) {
3261
+ try {
3262
+ const parsed = JSON.parse((await response.text()).slice(0, 2000));
3263
+ if (typeof parsed.code === "string")
3264
+ code = parsed.code;
3265
+ } catch {
3266
+ code = undefined;
3267
+ }
3268
+ }
3269
+ throw new ThreaApiError(`Threa API ${response.status}: ${response.statusText}`, response.status, code);
3270
+ }
3271
+ const { data } = await response.json();
3272
+ return data;
3273
+ }
3274
+ async get(id) {
3275
+ return this.request(this.path(`/${encodeURIComponent(id)}`));
3276
+ }
3277
+ async listOpen(opts) {
3278
+ const query = opts?.since ? `?since=${encodeURIComponent(opts.since)}` : "";
3279
+ return this.request(this.path(query));
3280
+ }
3281
+ async claim(id, body) {
3282
+ return this.request(this.path(`/${id}/claim`), {
3283
+ method: "POST",
3284
+ body: JSON.stringify(body)
3285
+ });
3286
+ }
3287
+ async release(id, claimToken) {
3288
+ return this.request(this.path(`/${encodeURIComponent(id)}/release`), {
3289
+ method: "POST",
3290
+ body: "{}",
3291
+ claimToken
3292
+ });
3293
+ }
3294
+ async heartbeat(id, claimToken) {
3295
+ return this.request(this.path(`/${id}/heartbeat`), { method: "POST", body: "{}", claimToken });
3296
+ }
3297
+ async reportStatus(id, claimToken, statusNote) {
3298
+ return this.request(this.path(`/${id}/status`), {
3299
+ method: "POST",
3300
+ body: JSON.stringify({ statusNote }),
3301
+ claimToken
3302
+ });
3303
+ }
3304
+ async complete(id, claimToken, body) {
3305
+ return this.request(this.path(`/${id}/complete`), {
3306
+ method: "POST",
3307
+ body: JSON.stringify(body),
3308
+ claimToken
3309
+ });
3310
+ }
3311
+ async fail(id, claimToken, errorMessage) {
3312
+ return this.request(this.path(`/${id}/fail`), {
3313
+ method: "POST",
3314
+ body: JSON.stringify({ errorMessage }),
3315
+ claimToken
3316
+ });
3317
+ }
3318
+ async requestAccess(delegationId, opts) {
3319
+ return this.request(this.path(`/${delegationId}/request-access`), {
3320
+ method: "POST",
3321
+ body: JSON.stringify(opts?.requestedByLabel ? { requestedByLabel: opts.requestedByLabel } : {})
3322
+ });
3323
+ }
3324
+ }
3325
+ // src/delegation-runner.ts
3326
+ var DEFAULT_POLL_MS = 60000;
3327
+ var DEFAULT_HEARTBEAT_MS = 5 * 60 * 1000;
3328
+ var FAIL_MESSAGE_MAX = 1000;
3329
+ var STATUS_NOTE_MAX = 2000;
3330
+ var SHUTDOWN_WAIT_MS = 2000;
3331
+ var DELEGATION_STOP_REASON = "runner_shutdown";
3332
+
3333
+ class DelegationRunner {
3334
+ client;
3335
+ executor;
3336
+ claimedByLabel;
3337
+ persistIdempotencyKey;
3338
+ pollMs;
3339
+ heartbeatMs;
3340
+ shutdownWaitMs;
3341
+ log;
3342
+ stopped = true;
3343
+ generation = 0;
3344
+ current;
3345
+ stopOperation;
3346
+ pollTimer;
3347
+ active;
3348
+ pendingNudged = new Set;
3349
+ accessRequested = new Set;
3350
+ constructor(opts) {
3351
+ this.client = opts.client;
3352
+ this.executor = opts.executor;
3353
+ this.claimedByLabel = opts.claimedByLabel;
3354
+ this.persistIdempotencyKey = opts.persistIdempotencyKey;
3355
+ this.pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
3356
+ this.heartbeatMs = opts.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
3357
+ this.shutdownWaitMs = opts.shutdownWaitMs ?? SHUTDOWN_WAIT_MS;
3358
+ this.log = opts.log ?? (() => {});
3359
+ }
3360
+ start() {
3361
+ if (!this.stopped)
3362
+ return;
3363
+ this.stopped = false;
3364
+ this.generation += 1;
3365
+ const generation = this.generation;
3366
+ this.pollTimer = setInterval(() => this.drain(generation), this.pollMs);
3367
+ this.drain(generation);
3368
+ }
3369
+ async stop(_reason = DELEGATION_STOP_REASON, options) {
3370
+ if (!this.stopped)
3371
+ this.stopped = true;
3372
+ const generation = this.generation;
3373
+ if (this.pollTimer)
3374
+ clearInterval(this.pollTimer);
3375
+ this.pollTimer = undefined;
3376
+ const active = this.active?.generation === generation ? this.active : undefined;
3377
+ active?.cleanupHeartbeat?.();
3378
+ active?.controller.abort();
3379
+ let pending = this.stopOperation?.generation === generation ? this.stopOperation.promise : undefined;
3380
+ if (!pending) {
3381
+ const release = active && !active.lost ? this.releaseActive(active) : undefined;
3382
+ pending = release ?? (this.current?.generation === generation ? this.current.promise : Promise.resolve());
3383
+ this.stopOperation = { generation, promise: pending };
3384
+ }
3385
+ if (this.current?.generation === generation)
3386
+ this.current = undefined;
3387
+ if (this.active === active)
3388
+ this.active = undefined;
3389
+ const timeoutError = new Error(`delegation runner stop timed out after ${this.shutdownWaitMs}ms`);
3390
+ let timer;
3391
+ const timeout = new Promise((_, reject) => {
3392
+ timer = setTimeout(() => reject(timeoutError), this.shutdownWaitMs);
3393
+ });
3394
+ try {
3395
+ if (options?.strict)
3396
+ await Promise.race([pending, timeout]);
3397
+ else
3398
+ await Promise.race([pending.catch(() => {
3399
+ return;
3400
+ }), timeout.catch(() => {
3401
+ return;
3402
+ })]);
3403
+ } finally {
3404
+ if (timer)
3405
+ clearTimeout(timer);
3406
+ }
3407
+ }
3408
+ notifyAvailable(nudge) {
3409
+ if (nudge?.delegationId)
3410
+ this.pendingNudged.add(nudge.delegationId);
3411
+ this.drain(this.generation);
3412
+ }
3413
+ isCurrent(generation) {
3414
+ return !this.stopped && this.generation === generation;
3415
+ }
3416
+ drain(generation) {
3417
+ if (!this.isCurrent(generation) || this.current?.generation === generation)
3418
+ return;
3419
+ const promise = this.runDrain(generation).finally(() => {
3420
+ if (this.current?.promise === promise)
3421
+ this.current = undefined;
3422
+ });
3423
+ this.current = { generation, promise };
3424
+ promise.catch((error) => {
3425
+ this.log(`delegation drain failed: ${error instanceof Error ? error.message : String(error)}`);
3426
+ });
3427
+ }
3428
+ async runDrain(generation) {
3429
+ let executed = true;
3430
+ while (executed && this.isCurrent(generation)) {
3431
+ executed = false;
3432
+ const open = await this.client.listOpen();
3433
+ if (!this.isCurrent(generation))
3434
+ return;
3435
+ for (const summary of open)
3436
+ this.pendingNudged.delete(summary.id);
3437
+ for (const summary of open) {
3438
+ if (!this.isCurrent(generation))
3439
+ return;
3440
+ const claimed = await this.tryClaim(summary);
3441
+ if (!claimed)
3442
+ continue;
3443
+ if (!this.isCurrent(generation)) {
3444
+ await this.releaseStoppedClaim(claimed, generation);
3445
+ return;
3446
+ }
3447
+ await this.execute(claimed, generation);
3448
+ executed = true;
3449
+ break;
3450
+ }
3451
+ if (executed)
3452
+ continue;
3453
+ for (const id of [...this.pendingNudged]) {
3454
+ if (!this.isCurrent(generation))
3455
+ return;
3456
+ const claimed = await this.tryClaimNudged(id);
3457
+ if (!claimed)
3458
+ continue;
3459
+ if (!this.isCurrent(generation)) {
3460
+ await this.releaseStoppedClaim(claimed, generation);
3461
+ return;
3462
+ }
3463
+ await this.execute(claimed, generation);
3464
+ executed = true;
3465
+ break;
3466
+ }
3467
+ }
3468
+ }
3469
+ async tryClaim(summary) {
3470
+ const idempotencyKey = crypto.randomUUID();
3471
+ await this.persistIdempotencyKey?.(summary.id, idempotencyKey);
3472
+ try {
3473
+ return await this.client.claim(summary.id, { claimedByLabel: this.claimedByLabel, idempotencyKey });
3474
+ } catch (error) {
3475
+ if (error instanceof ThreaApiError && (error.status === 409 || error.status === 404))
3476
+ return null;
3477
+ throw error;
3478
+ }
3479
+ }
3480
+ async tryClaimNudged(id) {
3481
+ const idempotencyKey = crypto.randomUUID();
3482
+ await this.persistIdempotencyKey?.(id, idempotencyKey);
3483
+ try {
3484
+ const claimed = await this.client.claim(id, { claimedByLabel: this.claimedByLabel, idempotencyKey });
3485
+ this.pendingNudged.delete(id);
3486
+ return claimed;
3487
+ } catch (error) {
3488
+ if (error instanceof ThreaApiError && error.status === 409) {
3489
+ this.pendingNudged.delete(id);
3490
+ return null;
3491
+ }
3492
+ if (error instanceof ThreaApiError && error.status === 404) {
3493
+ await this.requestAccessOnce(id);
3494
+ this.pendingNudged.delete(id);
3495
+ return null;
3496
+ }
3497
+ throw error;
3498
+ }
3499
+ }
3500
+ async requestAccessOnce(id) {
3501
+ if (this.accessRequested.has(id))
3502
+ return;
3503
+ try {
3504
+ await this.client.requestAccess(id, { requestedByLabel: this.claimedByLabel });
3505
+ this.accessRequested.add(id);
3506
+ this.log(`delegation ${id} not claimable (no access) — filed an access request`);
3507
+ } catch (error) {
3508
+ this.log(`delegation ${id} access request failed: ${error instanceof Error ? error.message : String(error)}`);
3509
+ }
3510
+ }
3511
+ async releaseStoppedClaim(task, generation) {
3512
+ const active = this.createActiveClaim(task, generation);
3513
+ await this.releaseActive(active);
3514
+ }
3515
+ createActiveClaim(task, generation) {
3516
+ let settleLost;
3517
+ const lostPromise = new Promise((resolve2) => settleLost = resolve2);
3518
+ return { task, generation, controller: new AbortController, lost: false, settleLost, lostPromise };
3519
+ }
3520
+ releaseActive(active) {
3521
+ if (active.release)
3522
+ return active.release;
3523
+ const release = this.client.release(active.task.id, active.task.claimToken).then(() => {
3524
+ return;
3525
+ });
3526
+ active.release = release;
3527
+ release.catch((error) => {
3528
+ this.log(`delegation ${active.task.id} release failed: ${error instanceof Error ? error.message : String(error)}`);
3529
+ });
3530
+ return release;
3531
+ }
3532
+ async execute(task, generation) {
3533
+ const { id, claimToken } = task;
3534
+ const active = this.createActiveClaim(task, generation);
3535
+ this.active = active;
3536
+ let heartbeatBusy = false;
3537
+ const cleanupHeartbeat = () => {
3538
+ if (!active.cleanupHeartbeat)
3539
+ return;
3540
+ active.cleanupHeartbeat = undefined;
3541
+ clearInterval(heartbeat);
3542
+ };
3543
+ const loseClaim = () => {
3544
+ if (active.lost)
3545
+ return;
3546
+ active.lost = true;
3547
+ cleanupHeartbeat();
3548
+ active.controller.abort();
3549
+ active.settleLost();
3550
+ };
3551
+ const handleLifecycleError = (kind, error) => {
3552
+ if (error instanceof ThreaApiError && error.status === 404)
3553
+ loseClaim();
3554
+ this.log(`delegation ${id} ${kind} failed: ${error instanceof Error ? error.message : String(error)}`);
3555
+ };
3556
+ const heartbeat = setInterval(async () => {
3557
+ if (heartbeatBusy || active.lost || active.controller.signal.aborted)
3558
+ return;
3559
+ heartbeatBusy = true;
3560
+ try {
3561
+ await this.client.heartbeat(id, claimToken);
3562
+ } catch (error) {
3563
+ handleLifecycleError("heartbeat", error);
3564
+ } finally {
3565
+ heartbeatBusy = false;
3566
+ }
3567
+ }, this.heartbeatMs);
3568
+ active.cleanupHeartbeat = cleanupHeartbeat;
3569
+ const ctx = {
3570
+ signal: active.controller.signal,
3571
+ reportStatus: async (note) => {
3572
+ if (active.lost || active.controller.signal.aborted)
3573
+ return;
3574
+ try {
3575
+ await this.client.reportStatus(id, claimToken, note.slice(0, STATUS_NOTE_MAX));
3576
+ } catch (error) {
3577
+ handleLifecycleError("status report", error);
3578
+ }
3579
+ }
3580
+ };
3581
+ const execution = Promise.resolve().then(() => this.executor(task, ctx)).then((result) => ({ kind: "result", result }), (error) => ({ kind: "error", error }));
3582
+ try {
3583
+ const outcome = await Promise.race([execution, active.lostPromise.then(() => ({ kind: "lost" }))]);
3584
+ if (outcome.kind === "lost")
3585
+ return;
3586
+ if (active.lost || active.controller.signal.aborted || !this.isCurrent(generation) || this.active !== active)
3587
+ return;
3588
+ if (outcome.kind === "error")
3589
+ throw outcome.error;
3590
+ await this.client.complete(id, claimToken, {
3591
+ resultMarkdown: outcome.result?.resultMarkdown,
3592
+ metadata: outcome.result?.metadata
3593
+ });
3594
+ this.log(`delegation ${id} completed`);
3595
+ } catch (error) {
3596
+ if (active.lost || active.controller.signal.aborted || !this.isCurrent(generation) || this.active !== active)
3597
+ return;
3598
+ const message = (error instanceof Error ? error.message : String(error)).slice(0, FAIL_MESSAGE_MAX);
3599
+ try {
3600
+ await this.client.fail(id, claimToken, message || "Delegation runner failed without a message");
3601
+ } catch (failError) {
3602
+ this.log(`delegation ${id} failed AND the fail report failed: ${failError instanceof Error ? failError.message : String(failError)}`);
3603
+ }
3604
+ } finally {
3605
+ cleanupHeartbeat();
3606
+ if (this.active === active)
3607
+ this.active = undefined;
3608
+ }
3609
+ }
3610
+ }
3611
+ // src/tool-trace.ts
3612
+ var TOOL_TRACE_FORMAT = "pi_tool_trace";
3613
+ var TOOL_TRACE_MAX_CHARS = 9500;
3614
+ var HEADLINE_MAX_CHARS = 500;
3615
+ var TRUNCATION_MARKER = `
3616
+
3617
+ …[section truncated;`;
3618
+ function toolTraceContent(params) {
3619
+ const headline = params.headline.length <= HEADLINE_MAX_CHARS ? params.headline : `${params.headline.slice(0, HEADLINE_MAX_CHARS)}…`;
3620
+ const sections = params.sections.map((section) => ({ ...section, originalBody: section.body }));
3621
+ for (let attempt = 0;attempt < 24; attempt++) {
3622
+ const payload = JSON.stringify({
3623
+ format: TOOL_TRACE_FORMAT,
3624
+ headline,
3625
+ sections: sections.map(({ originalBody: _originalBody, ...section }) => section)
3626
+ });
3627
+ if (payload.length <= TOOL_TRACE_MAX_CHARS)
3628
+ return payload;
3629
+ const largestIndex = sections.reduce((largest2, section, index) => section.body.length > sections[largest2].body.length ? index : largest2, 0);
3630
+ const largest = sections[largestIndex];
3631
+ if (!largest || largest.originalBody.length === 0)
3632
+ break;
3633
+ const overflow = payload.length - TOOL_TRACE_MAX_CHARS;
3634
+ const currentVisibleLength = largest.body.includes(TRUNCATION_MARKER) ? largest.body.indexOf(TRUNCATION_MARKER) : largest.body.length;
3635
+ const nextVisibleLength = Math.max(0, currentVisibleLength - Math.max(overflow + 256, 512));
3636
+ const omitted = largest.originalBody.length - nextVisibleLength;
3637
+ largest.body = `${largest.originalBody.slice(0, nextVisibleLength).trimEnd()}${TRUNCATION_MARKER} ${omitted} more characters]`;
3638
+ }
3639
+ return JSON.stringify({
3640
+ format: TOOL_TRACE_FORMAT,
3641
+ headline,
3642
+ sections: [{ label: "Details", body: "Trace content was too large to serialize safely.", lang: null }]
3643
+ });
3644
+ }
3645
+ export {
3646
+ writeFileAtomic,
3647
+ wireLifecycle,
3648
+ uploadReplyAttachments,
3649
+ toolTraceContent,
3650
+ supportedCapabilitiesFor,
3651
+ sanitizeId,
3652
+ runtimeCapabilitiesFor,
3653
+ readConfigFile,
3654
+ parseSessionControlCommand,
3655
+ parseConfigFile,
3656
+ loadConfig,
3657
+ isSessionControlInvocation,
3658
+ guessMimeType,
3659
+ formatInvocationContent,
3660
+ formatInboundAttachmentManifest,
3661
+ extractAttachmentDirectives,
3662
+ effectiveRuntimeManifest,
3663
+ downloadInboundAttachments,
3664
+ deriveStableId,
3665
+ defaultDisplayName,
3666
+ claimCapabilitiesFor,
3667
+ buildSteerContent,
3668
+ ThreaClient,
3669
+ ThreaApiError,
3670
+ TRACE_MODES,
3671
+ STEER_SETTLE_MS,
3672
+ SESSION_CONTROL_CAPABILITY,
3673
+ RemoteSession,
3674
+ DelegationRunner,
3675
+ DelegationClient,
3676
+ DecisionAbandonedError,
3677
+ COMPLETED_TURN_MEMORY,
3678
+ ATTACH_DIRECTIVE_RE,
3679
+ ATTACHMENT_DIR
3680
+ };
3681
+
3682
+ //# debugId=EE71C0ED22C7B43264756E2164756E21
3683
+ //# sourceMappingURL=index.js.map