@catheadowl/ukp 0.2.5 → 0.3.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.
@@ -88,6 +88,25 @@ export function providerTimeoutMs(): number {
88
88
  return envTimeoutMs("UKP_PROVIDER_TIMEOUT_MS", 60_000);
89
89
  }
90
90
 
91
+ /**
92
+ * Resolved spawn timeout for `ukp search` (owner ruling 2026-10-28, TODO
93
+ * 20261028-1113 option 3): the explicit `--timeout <seconds>` flag — `0`
94
+ * DISABLES the guard entirely (the 2026-08-16 interactive semantics revived
95
+ * on demand: user interrupt is then the only cancel mechanism — the
96
+ * curl/wget/ssh_config "zero disables" grammar), a positive value wins over
97
+ * the env override. Absent flag falls back to `UKP_PROVIDER_TIMEOUT_MS` /
98
+ * default 60s — the default-guard-ON posture maps to the unattended
99
+ * orchestration family (GitHub Actions / GitLab CI / Kubernetes): search's
100
+ * primary consumers are agent sessions nobody watches. Returns `undefined`
101
+ * = no timeout (wait indefinitely). `providerTimeoutMs` itself stays
102
+ * always-bounded for internal probes (rename recovery L2).
103
+ */
104
+ export function searchSpawnTimeoutMs(flagSeconds?: number): number | undefined {
105
+ if (flagSeconds === 0) return undefined;
106
+ if (flagSeconds !== undefined) return flagSeconds * 1000;
107
+ return providerTimeoutMs();
108
+ }
109
+
91
110
  /**
92
111
  * Update runs `qmd update`, which may legitimately index a large corpus —
93
112
  * its ceiling is correspondingly higher (default 600s, overridable via
@@ -164,9 +164,13 @@ function validateEndpointRelativePath(reference: string): string[] {
164
164
  throw new ReadUsageError("reference must be endpoint-scoped, not absolute");
165
165
  }
166
166
  if (result.reason === "empty-segment") {
167
- throw new ReadUsageError("reference must not contain empty path segments");
167
+ throw new ReadUsageError(
168
+ "reference must not contain empty path segments (use --from <route> to resolve a document-relative reference)",
169
+ );
168
170
  }
169
- throw new ReadUsageError("reference must not contain '.' or '..' path segments");
171
+ throw new ReadUsageError(
172
+ "reference must not contain '.' or '..' path segments (use --from <route> to resolve a document-relative reference)",
173
+ );
170
174
  }
171
175
  return result.segments;
172
176
  }
@@ -721,6 +725,18 @@ export function runRead(request: ReadRequest, context: ReadContext): ReadOutcome
721
725
  request = { ...request, path: resolved, sourceReference: request.path };
722
726
  }
723
727
 
728
+ // Trailing separators are address-encoding noise (ADR 0023 / D-099): strip
729
+ // once here so every downstream consumer — grammar validation, the
730
+ // miss-path did-you-mean scan, rename recovery, the envelope echo — sees
731
+ // the canonical route. The tolerant pre-pass above has already run: its
732
+ // rewritten routes never carry trailing separators, and absolute shapes
733
+ // were routed before this point (an input that is only separators is
734
+ // absolute-shaped and never reaches here).
735
+ const canonicalPath = request.path.replace(/[\\/]+$/, "");
736
+ if (canonicalPath !== request.path) {
737
+ request = { ...request, path: canonicalPath };
738
+ }
739
+
724
740
  const outcome = executeResolvedRead(request, context, registry);
725
741
  if (resolution === undefined) return outcome;
726
742
  return outcome.ok
@@ -65,8 +65,10 @@ function remoteTimeoutMs(): number {
65
65
  * chain. Default 10s (the Ansible baseline); `UKP_SSH_CONNECT_TIMEOUT_MS`
66
66
  * overrides, mirroring UKP_REMOTE_TIMEOUT_MS. ssh takes whole seconds, so
67
67
  * an integer ms value ≥1000 floors (1500 → 1s); anything else — non-numeric,
68
- * fractional, below 1000 — keeps the default. */
69
- function sshConnectTimeoutSeconds(): number {
68
+ * fractional, below 1000 — keeps the default. Exported for the session
69
+ * remote transport (ukp_session_remote W2): one knob, one value, every
70
+ * ssh process UKP spawns. */
71
+ export function sshConnectTimeoutSeconds(): number {
70
72
  const raw = process.env.UKP_SSH_CONNECT_TIMEOUT_MS;
71
73
  if (raw === undefined || raw.trim() === "") return 10;
72
74
  const value = Number(raw);
@@ -22,11 +22,17 @@ import {
22
22
  import { isRemoteBinding, localPathOf, readRegistry, type RegistryBinding } from "../registry.ts";
23
23
  import { resolveScope } from "../scope.ts";
24
24
  import { isAbsoluteShapedPath, isInsideRealRoot } from "../path-safety.ts";
25
- import { buildQmdInvocation, defaultQmdCommand, isDocidBody, providerTimeoutMs, stripDocidHash } from "./qmd.ts";
25
+ import { buildQmdInvocation, defaultQmdCommand, isDocidBody, searchSpawnTimeoutMs, stripDocidHash } from "./qmd.ts";
26
26
 
27
27
  export interface SearchRequest {
28
28
  query: string;
29
29
  limit: number;
30
+ /** Provider spawn timeout for this invocation, in whole seconds
31
+ * (owner ruling 2026-10-28, TODO 20261028-1113 option 3): 0 disables
32
+ * the hang guard (interrupt-only cancellation), a positive value wins
33
+ * over `UKP_PROVIDER_TIMEOUT_MS`. Absent = env/default (60s). Execution
34
+ * semantics like `limit`, not a surface-shaping option. */
35
+ timeoutSeconds?: number;
30
36
  }
31
37
 
32
38
  export interface SearchOptions {
@@ -435,13 +441,14 @@ function runHumanMode(
435
441
  }
436
442
 
437
443
  const command = commandFor(endpoint, parsed, true);
444
+ const timeoutMs = searchSpawnTimeoutMs(parsed.request.timeoutSeconds);
438
445
  const result = spawnSync(command.file, command.args, {
439
446
  cwd: endpoint.folder!,
440
447
  encoding: "utf8",
441
448
  windowsHide: true,
442
449
  windowsVerbatimArguments: command.verbatim,
443
450
  maxBuffer: 64 * 1024 * 1024,
444
- timeout: providerTimeoutMs(),
451
+ ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
445
452
  });
446
453
  const providerOutput = (result.stdout ?? "").trimEnd();
447
454
  const outcome: SearchEndpointOutcome = {
@@ -455,9 +462,13 @@ function runHumanMode(
455
462
  endpoints.push(outcome);
456
463
  if (result.signal === "SIGTERM") {
457
464
  // Zero-output hang guard: a timed-out provider is a classified failure,
458
- // never silence (see qmd.ts providerTimeoutMs).
465
+ // never silence (see qmd.ts searchSpawnTimeoutMs). The message names
466
+ // the flag (owner ruling form 4: diagnosable, never disguised as a
467
+ // provider crash); with the guard disabled a SIGTERM is external.
459
468
  failed = true;
460
- const message = `endpoint '${endpoint.name}' provider timed out after ${providerTimeoutMs() / 1000}s (set UKP_PROVIDER_TIMEOUT_MS to adjust)`;
469
+ const message = timeoutMs === undefined
470
+ ? `endpoint '${endpoint.name}' provider terminated by SIGTERM (timeout guard disabled by --timeout 0)`
471
+ : `endpoint '${endpoint.name}' provider timed out after ${timeoutMs / 1000}s - raise or disable via --timeout`;
461
472
  warnings.push(message);
462
473
  outcome.status = "failed";
463
474
  outcome.providerExitStatus = null;
@@ -1085,12 +1096,13 @@ function runJsonMode(
1085
1096
  stdoutFd = openSync(artifact, "w", 0o600);
1086
1097
  stderrFd = openSync(errorArtifact, "w", 0o600);
1087
1098
  const command = commandFor(endpoint, parsed, true);
1099
+ const timeoutMs = searchSpawnTimeoutMs(parsed.request.timeoutSeconds);
1088
1100
  const result = spawnSync(command.file, command.args, {
1089
1101
  cwd: endpoint.folder!,
1090
1102
  stdio: ["ignore", stdoutFd, stderrFd],
1091
1103
  windowsHide: true,
1092
1104
  windowsVerbatimArguments: command.verbatim,
1093
- timeout: providerTimeoutMs(),
1105
+ ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
1094
1106
  });
1095
1107
  closeSync(stdoutFd);
1096
1108
  stdoutFd = undefined;
@@ -1102,9 +1114,13 @@ function runJsonMode(
1102
1114
 
1103
1115
  if (result.signal === "SIGTERM") {
1104
1116
  // Zero-output hang guard: classified failure + error artifact, never
1105
- // silence (see qmd.ts providerTimeoutMs).
1117
+ // silence (see qmd.ts searchSpawnTimeoutMs). The artifact message
1118
+ // names the flag (owner ruling form 4); with the guard disabled a
1119
+ // SIGTERM is external, not a timeout.
1106
1120
  failed = true;
1107
- appendErrorArtifact(errorArtifact, `provider timed out after ${providerTimeoutMs() / 1000}s`);
1121
+ appendErrorArtifact(errorArtifact, timeoutMs === undefined
1122
+ ? "provider terminated by SIGTERM (timeout guard disabled by --timeout 0)"
1123
+ : `provider timed out after ${timeoutMs / 1000}s - raise or disable via --timeout`);
1108
1124
  const message = `endpoint '${endpoint.name}' provider timed out`;
1109
1125
  warnings.push(message);
1110
1126
  outcome.status = "failed";
@@ -0,0 +1,455 @@
1
+ // ukp_session W1→W2/G3 (line spec: session-command; ADR-SESSION-001/002/003;
2
+ // W2 extraction ruling X4):
3
+ // the session capability core in WRAPPER form. UKP owns naming and relay
4
+ // only (core-idea D-1): conversation history lives in the agent's own
5
+ // global space, so this module persists the host-scoped address book and
6
+ // nothing else — the no-transcript invariant is a spec §6 test anchor.
7
+ //
8
+ // The substrate mechanism (argv mapping, AG-UI synthesis, resume, sandbox
9
+ // pinning) retired to the amux binary in W2/G3 — ukp spawns it per
10
+ // invocation and consumes its structured face (the rg precedent: the
11
+ // wrapped tool holds the structured surface; the wrapper holds policy).
12
+ // amux's README is the tool contract authority; ukp never imports its
13
+ // source. The migration is provenance, not implementation.
14
+ //
15
+ // Layers (ADR 0021): this module owns the amux consumption mechanism and
16
+ // classified errors; the CLI adapter (commands/session.ts) owns argument
17
+ // surfaces, exit codes and channel text.
18
+
19
+ import {
20
+ existsSync,
21
+ mkdirSync,
22
+ readFileSync,
23
+ renameSync,
24
+ unlinkSync,
25
+ writeFileSync,
26
+ } from "node:fs";
27
+ import { dirname, join } from "node:path";
28
+ import { randomUUID } from "node:crypto";
29
+ import type { SessionWriteTier } from "../config/agent-backed.ts";
30
+ import { toolRelayArgv, TOOL_NOT_FOUND_EXIT } from "../spawn-relay.ts";
31
+
32
+ // ---- Classified errors (ADR 0021: capability classifies, CLI maps codes)
33
+
34
+ export type SessionErrorClass =
35
+ | "usage"
36
+ | "not-authorized"
37
+ | "run-failed"
38
+ | "substrate-unavailable";
39
+
40
+ export class SessionError extends Error {
41
+ constructor(
42
+ readonly errorClass: SessionErrorClass,
43
+ message: string,
44
+ options?: ErrorOptions,
45
+ ) {
46
+ super(message, options);
47
+ this.name = "SessionError";
48
+ }
49
+ }
50
+
51
+ // ---- Address book (spec §6): host-scoped, id = the agent's native id
52
+
53
+ export interface SessionRecord {
54
+ id: string;
55
+ endpoint: string;
56
+ agent: string;
57
+ tier: "sdk";
58
+ owner: string;
59
+ created_at: string;
60
+ last_run_at: string;
61
+ last_outcome: "finished" | "failed" | "unknown";
62
+ }
63
+
64
+ export function sessionStorePath(registryPath: string): string {
65
+ return join(dirname(registryPath), "sessions.json");
66
+ }
67
+
68
+ export function loadSessionRecords(storePath: string): SessionRecord[] {
69
+ if (!existsSync(storePath)) return [];
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(readFileSync(storePath, "utf8"));
73
+ } catch (error) {
74
+ throw new SessionError("usage", `session address book is not readable: ${storePath}`, { cause: error });
75
+ }
76
+ if (!Array.isArray(parsed)) {
77
+ throw new SessionError("usage", `session address book is malformed: ${storePath}`);
78
+ }
79
+ return parsed as SessionRecord[];
80
+ }
81
+
82
+ function writeSessionRecords(storePath: string, records: readonly SessionRecord[]): void {
83
+ // Atomic write (temp + rename), the registry's own durability discipline.
84
+ const directory = dirname(storePath);
85
+ mkdirSync(directory, { recursive: true });
86
+ const tempPath = `${storePath}.tmp.${randomUUID()}`;
87
+ writeFileSync(tempPath, `${JSON.stringify(records, null, 2)}\n`, "utf8");
88
+ try {
89
+ renameSync(tempPath, storePath);
90
+ } catch (error) {
91
+ unlinkSync(tempPath);
92
+ throw error;
93
+ }
94
+ }
95
+
96
+ export function appendSessionRecord(storePath: string, record: SessionRecord): void {
97
+ const records = loadSessionRecords(storePath);
98
+ records.push(record);
99
+ writeSessionRecords(storePath, records);
100
+ }
101
+
102
+ export function updateSessionRecord(
103
+ storePath: string,
104
+ id: string,
105
+ lastRunAt: string,
106
+ lastOutcome: "finished" | "failed",
107
+ ): void {
108
+ const records = loadSessionRecords(storePath);
109
+ const record = records.find((candidate) => candidate.id === id);
110
+ if (record === undefined) return;
111
+ record.last_run_at = lastRunAt;
112
+ record.last_outcome = lastOutcome;
113
+ writeSessionRecords(storePath, records);
114
+ }
115
+
116
+ /** Single-id resolution; the agent-native id namespace spans agents, so a
117
+ * collision is possible by construction — it degrades to a usage error
118
+ * listing the candidates (spec §3). */
119
+ export function resolveSessionRecord(records: readonly SessionRecord[], id: string): SessionRecord {
120
+ const matches = records.filter((record) => record.id === id);
121
+ if (matches.length === 0) {
122
+ throw new SessionError("usage", `unknown session '${id}' (see 'ukp session list')`);
123
+ }
124
+ if (matches.length > 1) {
125
+ const candidates = matches
126
+ .map((record) => `${record.id} (endpoint ${record.endpoint}, agent ${record.agent})`)
127
+ .join("; ");
128
+ throw new SessionError(
129
+ "usage",
130
+ `session id '${id}' is ambiguous across agents: ${candidates} — address it after re-spawning, or prune the address book`,
131
+ );
132
+ }
133
+ return matches[0]!;
134
+ }
135
+
136
+ // ---- amux consumption (spec §4, wrapper form). The mechanism contract:
137
+ // ukp invokes `amux` with `--events`, consumes the AG-UI JSONL, and derives
138
+ // everything it needs from the event stream. amux's human stderr is never
139
+ // forwarded on the success path (the command layer writes its own
140
+ // endpoint-flavored notes); amux's classified stderr lines are consulted
141
+ // only when the process exits non-zero without a derivable outcome.
142
+
143
+ /** amux's sandbox vocabulary (README trust model): pinned in both
144
+ * directions so the substrate's own default is never ridden. */
145
+ export type AmuxSandboxTier = "read-only" | "workspace-write";
146
+
147
+ /** The endpoint-declared write tier mapped onto amux's invocation flag —
148
+ * the policy vocabulary (manifest `write`) never leaks into the mechanism
149
+ * argv (ADR-SESSION-004: the grant is resolved before the spawn). */
150
+ export function amuxSandboxTier(write: SessionWriteTier): AmuxSandboxTier {
151
+ return write === "workspace" ? "workspace-write" : "read-only";
152
+ }
153
+
154
+ export type AmuxInvocation =
155
+ | {
156
+ kind: "spawn";
157
+ /** Endpoint folder — amux anchors the session to this path. */
158
+ folder: string;
159
+ /** The registry-declared agent command, passed through verbatim. */
160
+ agentCommand: readonly string[];
161
+ prompt: string;
162
+ sandbox: AmuxSandboxTier;
163
+ }
164
+ | {
165
+ kind: "send";
166
+ /** Native session id to resume. */
167
+ sessionId: string;
168
+ prompt: string;
169
+ sandbox: AmuxSandboxTier;
170
+ };
171
+
172
+ /** What ukp derives from one amux invocation (spec §4 derivation rules). */
173
+ export interface AmuxRunOutcome {
174
+ /** First event's threadId (spawn: the native session id); undefined when
175
+ * amux emitted no events at all. */
176
+ threadId?: string;
177
+ outcome: "finished" | "failed";
178
+ /** The turn's final agent message — the default report face. */
179
+ finalMessage?: string;
180
+ /** Failure detail for the command layer's stderr note. */
181
+ detail?: string;
182
+ /** The AG-UI JSONL lines verbatim (amux's face, ukp never rewrites) —
183
+ * the `--events` passthrough payload. */
184
+ eventLines: readonly string[];
185
+ }
186
+
187
+ export interface AmuxRunOptions {
188
+ /** Explicit amux command (test injection, the rg.ts rgCommand precedent);
189
+ * never wrapped in the win32 relay. */
190
+ amuxCommand?: readonly string[];
191
+ }
192
+
193
+ /** Default amux location: the PATH binary (rg precedent — no bundled copy,
194
+ * no version negotiation; the wrapped tool owns its own distribution). */
195
+ export function defaultAmuxCommand(): readonly string[] {
196
+ return ["amux"];
197
+ }
198
+
199
+ /** UKP_AMUX env override: the amux binary to spawn instead of the PATH
200
+ * lookup (explicit override — spawned as given, never relay-wrapped). */
201
+ export function amuxCommandFromEnvironment(): readonly string[] | undefined {
202
+ const value = process.env.UKP_AMUX;
203
+ return value === undefined || value.trim() === "" ? undefined : [value];
204
+ }
205
+
206
+ export function resolveAmuxCommand(options?: AmuxRunOptions): readonly string[] {
207
+ return options?.amuxCommand ?? amuxCommandFromEnvironment() ?? defaultAmuxCommand();
208
+ }
209
+
210
+ /** Same env knob and defaults as the provider timeout (qmd) — spec §5
211
+ * deliberately reuses UKP_PROVIDER_TIMEOUT_MS; minimum 1s. The value is
212
+ * forwarded to amux as AMUX_TIMEOUT_MS so both layers run on one deadline
213
+ * instead of racing two different defaults. */
214
+ export function sessionSubstrateTimeoutMs(): number {
215
+ const raw = process.env.UKP_PROVIDER_TIMEOUT_MS;
216
+ if (raw === undefined || raw.trim() === "") return 60_000;
217
+ const parsed = Number(raw);
218
+ if (!Number.isSafeInteger(parsed) || parsed < 1_000) return 60_000;
219
+ return parsed;
220
+ }
221
+
222
+ /** The amux invocation argv (without the binary): `amux <folder>
223
+ * <command...> --events --sandbox <tier> --text <prompt>` or the send
224
+ * form. `--events` is always on — the event stream is how ukp derives
225
+ * id/outcome/report; the caller's `--events` only selects what ukp
226
+ * re-emits on stdout. */
227
+ export function amuxInvocationArgv(request: AmuxInvocation): string[] {
228
+ const tail = ["--events", "--sandbox", request.sandbox, "--text", request.prompt];
229
+ return request.kind === "spawn"
230
+ ? [request.folder, ...request.agentCommand, ...tail]
231
+ : ["send", request.sessionId, ...tail];
232
+ }
233
+
234
+ /** amux's classified stderr line: `amux: <label>: <message>` (exit codes
235
+ * 2 = usage, 1 = runtime, the label carries the classification). Mapped
236
+ * onto ukp's classes; labels inherit amux's vocabulary so the wrapper's
237
+ * classification stays honest about which layer failed. */
238
+ const AMUX_ERROR_CLASS_BY_LABEL: Readonly<Record<string, SessionErrorClass>> = {
239
+ "usage error": "usage",
240
+ "run failed": "run-failed",
241
+ "agent substrate unavailable": "substrate-unavailable",
242
+ };
243
+
244
+ function parseAmuxClassifiedLine(stderrText: string): { errorClass: SessionErrorClass; message: string } | undefined {
245
+ for (const line of stderrText.split(/\r?\n/)) {
246
+ const match = /^amux: ([^:]+): (.*)$/.exec(line);
247
+ if (match === null) continue;
248
+ const errorClass = AMUX_ERROR_CLASS_BY_LABEL[match[1]!];
249
+ if (errorClass !== undefined) return { errorClass, message: match[2]! };
250
+ }
251
+ return undefined;
252
+ }
253
+
254
+ /** ukp owns the timeout knob; amux's remedy text is re-pointed so the
255
+ * user-facing knob stays UKP_PROVIDER_TIMEOUT_MS (spec §4). */
256
+ function inheritAmuxMessage(message: string): string {
257
+ return message.replaceAll("AMUX_TIMEOUT_MS", "UKP_PROVIDER_TIMEOUT_MS");
258
+ }
259
+
260
+ function isRecord(value: unknown): value is Record<string, unknown> {
261
+ return typeof value === "object" && value !== null && !Array.isArray(value);
262
+ }
263
+
264
+ function amuxUnavailableMessage(command: readonly string[], reason: string): string {
265
+ return `amux is not available (${reason}: '${command.join(" ")}') — install amux on PATH or set UKP_AMUX to its binary`;
266
+ }
267
+
268
+ /** Runs one amux invocation and derives the outcome from its AG-UI event
269
+ * stream (spec §4): id = first event threadId, finalMessage = last
270
+ * TEXT_MESSAGE_CONTENT, outcome = last RUN_FINISHED/RUN_ERROR with the
271
+ * amux exit code as the final authority. When the stream ends without a
272
+ * terminal event, the exit-code synthesis rule closes the pair — a
273
+ * defensive backstop one layer up from amux's own guarantee. */
274
+ export async function runAmuxSession(
275
+ request: AmuxInvocation,
276
+ options: AmuxRunOptions = {},
277
+ ): Promise<AmuxRunOutcome> {
278
+ const command = resolveAmuxCommand(options);
279
+ const argv = [...command, ...amuxInvocationArgv(request)];
280
+ // Win32 default-PATH spawns ride the powershell relay (the rg precedent:
281
+ // the direct bun→tool edge is Defender-taxed, and npm .cmd shims do not
282
+ // resolve without it). Explicit overrides (context or UKP_AMUX) are
283
+ // spawned as given — the rg rule that test injections stay unwrapped.
284
+ const explicitCommand = options.amuxCommand !== undefined || amuxCommandFromEnvironment() !== undefined;
285
+ const spawnArgv = process.platform === "win32" && !explicitCommand
286
+ ? toolRelayArgv(argv, { notFoundGuard: true })
287
+ : argv;
288
+
289
+ // Spec §7.2 (local single-user tier): the process environment equals
290
+ // the caller's, plus the shared timeout knob — amux runs its own
291
+ // substrate timer on the same deadline instead of a second default.
292
+ return runDerivedAmuxProcess({
293
+ spawnArgv,
294
+ env: { ...process.env, AMUX_TIMEOUT_MS: String(sessionSubstrateTimeoutMs()) },
295
+ timeoutMs: sessionSubstrateTimeoutMs(),
296
+ backstopKind: request.kind,
297
+ backstopSessionId: request.kind === "send" ? request.sessionId : undefined,
298
+ unavailable: { command, explicit: explicitCommand },
299
+ });
300
+ }
301
+
302
+ // ---- The derive kernel: spawn one subprocess carrying an AG-UI JSONL
303
+ // amux event stream on stdout, run the wall timer, and derive id / outcome
304
+ // / report / eventLines.
305
+ async function runDerivedAmuxProcess(run: {
306
+ spawnArgv: readonly string[];
307
+ env: Record<string, string | undefined>;
308
+ timeoutMs: number;
309
+ backstopKind: AmuxInvocation["kind"];
310
+ backstopSessionId: string | undefined;
311
+ unavailable: { command: readonly string[]; explicit: boolean };
312
+ }): Promise<AmuxRunOutcome> {
313
+ let child: import("bun").Subprocess<"ignore", "pipe", "pipe">;
314
+ try {
315
+ child = Bun.spawn([...run.spawnArgv], {
316
+ env: run.env,
317
+ stdin: "ignore",
318
+ stdout: "pipe",
319
+ stderr: "pipe",
320
+ });
321
+ } catch (error) {
322
+ throw new SessionError("substrate-unavailable", amuxUnavailableMessage(run.unavailable.command, "failed to start"), {
323
+ cause: error,
324
+ });
325
+ }
326
+
327
+ const timedOut = { value: false };
328
+ const timer = setTimeout(() => {
329
+ timedOut.value = true;
330
+ child.kill("SIGTERM");
331
+ }, run.timeoutMs);
332
+
333
+ const eventLines: string[] = [];
334
+ let threadId: string | undefined;
335
+ let finalMessage: string | undefined;
336
+ let terminal: { kind: "finished" | "failed"; runId?: string } | undefined;
337
+ let lastRunId: string | undefined;
338
+
339
+ const drainStderr = async (): Promise<string> => {
340
+ const reader = (child.stderr as ReadableStream<Uint8Array>).getReader();
341
+ const decoder = new TextDecoder();
342
+ let text = "";
343
+ for (;;) {
344
+ const { done, value } = await reader.read();
345
+ if (done) break;
346
+ text += decoder.decode(value, { stream: true });
347
+ }
348
+ return text;
349
+ };
350
+ const drainStdout = async (): Promise<void> => {
351
+ const reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
352
+ const decoder = new TextDecoder();
353
+ let buffer = "";
354
+ for (;;) {
355
+ const { done, value } = await reader.read();
356
+ if (done) break;
357
+ buffer += decoder.decode(value, { stream: true });
358
+ let newline = buffer.indexOf("\n");
359
+ while (newline !== -1) {
360
+ const line = buffer.slice(0, newline).trim();
361
+ buffer = buffer.slice(newline + 1);
362
+ if (line === "") continue;
363
+ eventLines.push(line);
364
+ let event: unknown;
365
+ try {
366
+ event = JSON.parse(line);
367
+ } catch {
368
+ continue; // Non-JSON noise on the events channel: passed through, never derived from.
369
+ }
370
+ if (!isRecord(event)) continue;
371
+ if (threadId === undefined && typeof event.threadId === "string") threadId = event.threadId;
372
+ if (typeof event.runId === "string") lastRunId = event.runId;
373
+ if (event.type === "TEXT_MESSAGE_CONTENT" && typeof event.content === "string") {
374
+ finalMessage = event.content;
375
+ }
376
+ if (event.type === "RUN_FINISHED") terminal = { kind: "finished", runId: lastRunId };
377
+ if (event.type === "RUN_ERROR") terminal = { kind: "failed", runId: lastRunId };
378
+ newline = buffer.indexOf("\n");
379
+ }
380
+ }
381
+ };
382
+
383
+ let stderrText = "";
384
+ let amuxExitCode: number | null = null;
385
+ try {
386
+ await Promise.all([
387
+ drainStdout(),
388
+ drainStderr().then((text) => {
389
+ stderrText = text;
390
+ }),
391
+ ]);
392
+ amuxExitCode = await child.exited;
393
+ } finally {
394
+ clearTimeout(timer);
395
+ }
396
+
397
+ const stderrTail = stderrText.trim().split(/\r?\n/).filter((line) => line !== "").slice(-3).join(" | ");
398
+ const tailSuffix = stderrTail === "" ? "" : `: ${stderrTail}`;
399
+ const classified = parseAmuxClassifiedLine(stderrText);
400
+
401
+ if (timedOut.value) {
402
+ throw new SessionError(
403
+ "substrate-unavailable",
404
+ `amux timed out after ${run.timeoutMs / 1000}s (set UKP_PROVIDER_TIMEOUT_MS to adjust)`,
405
+ );
406
+ }
407
+ if (amuxExitCode === TOOL_NOT_FOUND_EXIT && process.platform === "win32" && !run.unavailable.explicit) {
408
+ throw new SessionError("substrate-unavailable", amuxUnavailableMessage(run.unavailable.command, "not found on PATH"));
409
+ }
410
+
411
+ if (eventLines.length === 0) {
412
+ // amux produced no event stream: a mechanism/substrate failure before
413
+ // the first event (amux classifies its own; the classes inherit).
414
+ if (classified !== undefined) {
415
+ throw new SessionError(classified.errorClass, inheritAmuxMessage(classified.message));
416
+ }
417
+ if (amuxExitCode === 2) {
418
+ throw new SessionError("usage", `amux exited with a usage error (code 2)${tailSuffix}`);
419
+ }
420
+ throw new SessionError("substrate-unavailable", `amux exited without events (code ${amuxExitCode ?? "signal"})${tailSuffix}`);
421
+ }
422
+
423
+ if (terminal === undefined) {
424
+ // Backstop synthesis (amux already closes every pair; this covers an
425
+ // amux crash mid-stream): the exit code decides the boundary.
426
+ const failed = amuxExitCode !== 0;
427
+ const event = {
428
+ type: failed ? "RUN_ERROR" : "RUN_FINISHED",
429
+ threadId: threadId ?? (run.backstopKind === "send" && run.backstopSessionId !== undefined
430
+ ? run.backstopSessionId
431
+ : randomUUID()),
432
+ runId: lastRunId ?? randomUUID(),
433
+ ...(failed ? { message: `amux exited with code ${amuxExitCode ?? "signal"}` } : {}),
434
+ };
435
+ eventLines.push(JSON.stringify(event));
436
+ terminal = { kind: failed ? "failed" : "finished", runId: event.runId };
437
+ }
438
+
439
+ // The process is the final authority on its own outcome: a non-zero
440
+ // amux exit fails the run even when the stream claimed RUN_FINISHED.
441
+ const failed = amuxExitCode !== 0 || terminal.kind === "failed";
442
+ return {
443
+ ...(threadId === undefined ? {} : { threadId }),
444
+ outcome: failed ? "failed" : "finished",
445
+ ...(finalMessage === undefined ? {} : { finalMessage }),
446
+ ...(failed
447
+ ? {
448
+ detail: terminal.kind === "failed" && terminal.runId !== undefined
449
+ ? `see RUN_ERROR on run ${terminal.runId}`
450
+ : `amux exited with code ${amuxExitCode ?? "signal"}`,
451
+ }
452
+ : {}),
453
+ eventLines,
454
+ };
455
+ }