@yul-labs/agent-relay 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2571 @@
1
+ import fs3, { promises, constants, statSync, chmodSync } from 'fs';
2
+ import path7 from 'path';
3
+ import { z } from 'zod';
4
+ import { randomUUID } from 'crypto';
5
+ import { execa } from 'execa';
6
+ import os from 'os';
7
+ import * as nodePty from 'node-pty';
8
+ import { createRequire } from 'module';
9
+
10
+ // src/core/config.ts
11
+
12
+ // src/core/errors.ts
13
+ var AgentRelayError = class extends Error {
14
+ code;
15
+ hint;
16
+ constructor(message, code, hint) {
17
+ super(message);
18
+ this.name = "AgentRelayError";
19
+ this.code = code;
20
+ this.hint = hint;
21
+ }
22
+ };
23
+ var ConfigError = class extends AgentRelayError {
24
+ constructor(message, hint) {
25
+ super(message, "CONFIG_ERROR", hint);
26
+ this.name = "ConfigError";
27
+ }
28
+ };
29
+ var UnknownAdapterError = class extends AgentRelayError {
30
+ constructor(name, available) {
31
+ super(
32
+ `Unknown adapter "${name}".`,
33
+ "UNKNOWN_ADAPTER",
34
+ available.length ? `Available adapters: ${available.join(", ")}.` : "No adapters are configured."
35
+ );
36
+ this.name = "UnknownAdapterError";
37
+ }
38
+ };
39
+ var SessionNotFoundError = class extends AgentRelayError {
40
+ constructor(sessionId) {
41
+ super(
42
+ `Session "${sessionId}" not found.`,
43
+ "SESSION_NOT_FOUND",
44
+ "Use `agent-relay run` to create a session first."
45
+ );
46
+ this.name = "SessionNotFoundError";
47
+ }
48
+ };
49
+
50
+ // src/core/config.ts
51
+ var CONFIG_FILENAME = "agent-relay.config.json";
52
+ var adapterModeSchema = z.enum(["pty", "test"]);
53
+ var approvalPolicySchema = z.enum(["auto", "gated", "readonly"]);
54
+ var sandboxSchema = z.enum([
55
+ "read-only",
56
+ "workspace-write",
57
+ "danger-full-access"
58
+ ]);
59
+ var adapterConfigSchema = z.object({
60
+ type: z.string().min(1),
61
+ mode: adapterModeSchema,
62
+ command: z.string().min(1).optional(),
63
+ args: z.array(z.string()).default([]),
64
+ /** Extra env vars to inject when spawning. */
65
+ env: z.record(z.string()).optional(),
66
+ /** Per-adapter approval override (falls back to defaults). */
67
+ approvalPolicy: approvalPolicySchema.optional(),
68
+ /** Per-adapter sandbox override (falls back to defaults). */
69
+ sandbox: sandboxSchema.optional()
70
+ }).strict();
71
+ var defaultsSchema = z.object({
72
+ maxTurns: z.number().int().positive().default(20),
73
+ timeoutMs: z.number().int().positive().default(18e5),
74
+ idleTimeoutMs: z.number().int().positive().default(3e5),
75
+ /**
76
+ * Default autonomy level. `auto` (the default) makes runs fully
77
+ * unattended — the orchestrator auto-approves and the agent CLIs are
78
+ * launched with their non-interactive "don't ask" flags.
79
+ */
80
+ approvalPolicy: approvalPolicySchema.optional(),
81
+ /** Default sandbox strength for process-spawning agents. */
82
+ sandbox: sandboxSchema.optional(),
83
+ /** Interactive: idle window before a finished agent's TUI is quit (ms). */
84
+ completionIdleMs: z.number().int().positive().optional(),
85
+ /**
86
+ * Legacy flag. When `approvalPolicy` is unset, `true` -> `gated` and
87
+ * `false` -> `auto`. Kept for back-compat; prefer `approvalPolicy`.
88
+ */
89
+ requireApprovalOnRiskyActions: z.boolean().default(false)
90
+ }).strict();
91
+ function resolveApprovalMode(defaults, adapter) {
92
+ return adapter?.approvalPolicy ?? defaults.approvalPolicy ?? (defaults.requireApprovalOnRiskyActions ? "gated" : "auto");
93
+ }
94
+ function resolveSandbox(defaults, adapter) {
95
+ return adapter?.sandbox ?? defaults.sandbox ?? "workspace-write";
96
+ }
97
+ var hooksSchema = z.object({
98
+ /** Shell command run just before the agent starts. */
99
+ onStart: z.string().min(1).optional(),
100
+ /** Shell command run after the run reaches a terminal status. */
101
+ onComplete: z.string().min(1).optional()
102
+ }).strict();
103
+ var deciderSchema = z.object({
104
+ type: z.enum(["rule", "always-approve", "command", "api"]).default("rule"),
105
+ /** rule: regex strings that trigger deny / a negative choice. */
106
+ denyPatterns: z.array(z.string()).optional(),
107
+ /** rule: stance for non-dangerous approvals. */
108
+ defaultApproval: z.enum(["approve", "deny"]).optional(),
109
+ /** rule: default answer for free-text input prompts. */
110
+ defaultAnswer: z.string().optional(),
111
+ /** command: the decider model CLI + args (e.g. claude -p / codex exec). */
112
+ command: z.string().optional(),
113
+ args: z.array(z.string()).optional(),
114
+ env: z.record(z.string()).optional(),
115
+ timeoutMs: z.number().int().positive().optional(),
116
+ /** api: OpenAI-compatible chat-completions endpoint + model. */
117
+ url: z.string().url().optional(),
118
+ model: z.string().optional(),
119
+ apiKey: z.string().optional(),
120
+ maxTokens: z.number().int().positive().optional()
121
+ }).strict();
122
+ var configSchema = z.object({
123
+ defaultAdapter: z.string().min(1),
124
+ sessionsDir: z.string().min(1).default(".agent-relay/sessions"),
125
+ logsDir: z.string().min(1).default(".agent-relay/logs"),
126
+ defaults: defaultsSchema.default({}),
127
+ adapters: z.record(adapterConfigSchema),
128
+ /** Which decider answers interactive prompts. */
129
+ decider: deciderSchema.optional(),
130
+ /** Optional shell-command lifecycle hooks. */
131
+ hooks: hooksSchema.optional()
132
+ }).strict().superRefine((cfg, ctx) => {
133
+ if (!cfg.adapters[cfg.defaultAdapter]) {
134
+ ctx.addIssue({
135
+ code: z.ZodIssueCode.custom,
136
+ path: ["defaultAdapter"],
137
+ message: `defaultAdapter "${cfg.defaultAdapter}" is not present in "adapters".`
138
+ });
139
+ }
140
+ });
141
+ function createDefaultConfig() {
142
+ return configSchema.parse({
143
+ defaultAdapter: "claude",
144
+ sessionsDir: ".agent-relay/sessions",
145
+ logsDir: ".agent-relay/logs",
146
+ defaults: {
147
+ maxTurns: 20,
148
+ timeoutMs: 18e5,
149
+ idleTimeoutMs: 3e5,
150
+ // Autonomous by default: agents run unattended without asking.
151
+ approvalPolicy: "auto",
152
+ sandbox: "workspace-write",
153
+ requireApprovalOnRiskyActions: false
154
+ },
155
+ // Interactive (PTY) is the default mode: the agent runs in its real TUI and
156
+ // its approval/choice prompts are answered by the decider.
157
+ decider: { type: "rule" },
158
+ adapters: {
159
+ claude: { type: "claude", mode: "pty", command: "claude", args: [] },
160
+ codex: { type: "codex", mode: "pty", command: "codex", args: [] },
161
+ fake: { type: "fake", mode: "test", args: [] }
162
+ }
163
+ });
164
+ }
165
+ function parseConfig(value) {
166
+ const result = configSchema.safeParse(value);
167
+ if (!result.success) {
168
+ const detail = result.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
169
+ throw new ConfigError(
170
+ `Invalid agent-relay config:
171
+ ${detail}`,
172
+ "Run `agent-relay init` to regenerate a valid config, or fix the listed fields."
173
+ );
174
+ }
175
+ return result.data;
176
+ }
177
+ function parseDeciderConfig(value) {
178
+ const result = deciderSchema.safeParse(value);
179
+ if (!result.success) {
180
+ const detail = result.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
181
+ throw new ConfigError(
182
+ `Invalid decider options:
183
+ ${detail}`,
184
+ "Check the --decider* flags (e.g. --decider api --decider-url <url>)."
185
+ );
186
+ }
187
+ return result.data;
188
+ }
189
+ function configPath(rootDir) {
190
+ return path7.resolve(rootDir, CONFIG_FILENAME);
191
+ }
192
+ async function loadConfig(rootDir) {
193
+ const file = configPath(rootDir);
194
+ let raw;
195
+ try {
196
+ raw = await promises.readFile(file, "utf8");
197
+ } catch {
198
+ throw new ConfigError(
199
+ `Config file not found at ${file}.`,
200
+ "Run `agent-relay init` to create one."
201
+ );
202
+ }
203
+ let json;
204
+ try {
205
+ json = JSON.parse(raw);
206
+ } catch (err) {
207
+ throw new ConfigError(
208
+ `Config file ${file} is not valid JSON: ${err.message}`,
209
+ "Fix the JSON syntax or run `agent-relay init` to regenerate it."
210
+ );
211
+ }
212
+ return parseConfig(json);
213
+ }
214
+ async function loadConfigOrDefault(rootDir, onDefault) {
215
+ const file = configPath(rootDir);
216
+ try {
217
+ await promises.access(file);
218
+ } catch {
219
+ onDefault?.();
220
+ return createDefaultConfig();
221
+ }
222
+ return loadConfig(rootDir);
223
+ }
224
+ function stringifyConfig(config) {
225
+ return `${JSON.stringify(config, null, 2)}
226
+ `;
227
+ }
228
+ async function saveConfig(rootDir, config) {
229
+ const file = configPath(rootDir);
230
+ await promises.mkdir(path7.dirname(file), { recursive: true });
231
+ await promises.writeFile(file, stringifyConfig(config), "utf8");
232
+ return file;
233
+ }
234
+ var SessionManager = class {
235
+ constructor(sessionsDir) {
236
+ this.sessionsDir = sessionsDir;
237
+ }
238
+ sessionsDir;
239
+ /** Absolute path to a session's JSON metadata file. */
240
+ filePath(sessionId) {
241
+ return path7.join(this.sessionsDir, `${sessionId}.json`);
242
+ }
243
+ /** Build a fresh session record in the `created` state (not yet persisted). */
244
+ create(input) {
245
+ return {
246
+ sessionId: input.sessionId ?? randomUUID(),
247
+ prompt: input.prompt,
248
+ adapter: input.adapter,
249
+ mode: input.mode,
250
+ status: "created",
251
+ cwd: input.cwd,
252
+ dryRun: input.dryRun ?? false,
253
+ startedAt: input.startedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
254
+ logFile: input.logFile
255
+ };
256
+ }
257
+ /** Persist (create or overwrite) a session's metadata. */
258
+ async save(meta) {
259
+ await promises.mkdir(this.sessionsDir, { recursive: true });
260
+ const file = this.filePath(meta.sessionId);
261
+ await promises.writeFile(file, `${JSON.stringify(meta, null, 2)}
262
+ `, "utf8");
263
+ return file;
264
+ }
265
+ /** Load a session's metadata, throwing {@link SessionNotFoundError}. */
266
+ async load(sessionId) {
267
+ const file = this.filePath(sessionId);
268
+ let raw;
269
+ try {
270
+ raw = await promises.readFile(file, "utf8");
271
+ } catch {
272
+ throw new SessionNotFoundError(sessionId);
273
+ }
274
+ try {
275
+ return JSON.parse(raw);
276
+ } catch {
277
+ throw new AgentRelayError(
278
+ `Session "${sessionId}" metadata is corrupt and could not be parsed.`,
279
+ "SESSION_CORRUPT",
280
+ `Delete ${file} and re-run, or restore it from a backup.`
281
+ );
282
+ }
283
+ }
284
+ /** Apply a partial update and persist; returns the updated record. */
285
+ async update(sessionId, patch) {
286
+ const current = await this.load(sessionId);
287
+ const next = { ...current, ...patch };
288
+ await this.save(next);
289
+ return next;
290
+ }
291
+ /** Convenience helper to set status (and optionally endedAt). */
292
+ async setStatus(sessionId, status, extra = {}) {
293
+ return this.update(sessionId, { status, ...extra });
294
+ }
295
+ /** List all sessions, newest first. Returns [] if the dir does not exist. */
296
+ async list() {
297
+ let entries;
298
+ try {
299
+ entries = await promises.readdir(this.sessionsDir);
300
+ } catch {
301
+ return [];
302
+ }
303
+ const metas = [];
304
+ for (const entry of entries) {
305
+ if (!entry.endsWith(".json")) continue;
306
+ try {
307
+ const raw = await promises.readFile(
308
+ path7.join(this.sessionsDir, entry),
309
+ "utf8"
310
+ );
311
+ metas.push(JSON.parse(raw));
312
+ } catch {
313
+ }
314
+ }
315
+ metas.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
316
+ return metas;
317
+ }
318
+ };
319
+ var RunLogger = class {
320
+ constructor(logFile, opts = {}) {
321
+ this.logFile = logFile;
322
+ this.opts = opts;
323
+ }
324
+ logFile;
325
+ opts;
326
+ bytes = 0;
327
+ suppressed = 0;
328
+ truncated = false;
329
+ append(line) {
330
+ const text = line.endsWith("\n") ? line : `${line}
331
+ `;
332
+ if (this.opts.maxBytes && this.bytes >= this.opts.maxBytes) {
333
+ if (!this.truncated) {
334
+ this.truncated = true;
335
+ fs3.appendFileSync(
336
+ this.logFile,
337
+ `... [log truncated at ${this.opts.maxBytes} bytes \u2014 raise --max-log-bytes for more]
338
+ `
339
+ );
340
+ }
341
+ return;
342
+ }
343
+ fs3.appendFileSync(this.logFile, text);
344
+ this.bytes += Buffer.byteLength(text);
345
+ }
346
+ /** Ensure the log directory exists and write the run header. */
347
+ start(header) {
348
+ fs3.mkdirSync(path7.dirname(this.logFile), { recursive: true });
349
+ const lines = [
350
+ "================ agent-relay run ================",
351
+ `sessionId : ${header.sessionId}`,
352
+ `startedAt : ${header.startedAt}`,
353
+ `adapter : ${header.adapter}`,
354
+ `mode : ${header.mode}`,
355
+ `cwd : ${header.cwd}`
356
+ ];
357
+ if (header.command) {
358
+ lines.push(`command : ${header.command.display}`);
359
+ }
360
+ lines.push("---- prompt ----");
361
+ lines.push(header.prompt);
362
+ lines.push("---- events ----");
363
+ this.append(lines.join("\n"));
364
+ }
365
+ /** Record a single normalized event. */
366
+ event(event) {
367
+ if (!this.opts.verbose && (event.type === "stdout" || event.type === "stderr")) {
368
+ this.suppressed += 1;
369
+ return;
370
+ }
371
+ let detail = "";
372
+ if (event.text) {
373
+ detail = event.text.replace(/\s+$/g, "");
374
+ } else if (event.data !== void 0) {
375
+ try {
376
+ detail = JSON.stringify(event.data);
377
+ } catch {
378
+ detail = String(event.data);
379
+ }
380
+ } else if (event.raw) {
381
+ detail = event.raw;
382
+ }
383
+ const oneLine = detail.replace(/\r\n|\r|\n/g, "\\n");
384
+ this.append(`[${event.timestamp}] ${event.type.padEnd(18)} ${oneLine}`);
385
+ }
386
+ /** Write the run footer with terminal status and optional error. */
387
+ finish(status, endedAt, error) {
388
+ const lines = ["---- end ----", `status : ${status}`, `endedAt : ${endedAt}`];
389
+ if (error) {
390
+ lines.push(`error : [${error.code ?? "ERROR"}] ${error.message}`);
391
+ if (error.hint) lines.push(`hint : ${error.hint}`);
392
+ }
393
+ if (this.suppressed > 0) {
394
+ lines.push(
395
+ `note : suppressed ${this.suppressed} raw output event(s) \u2014 re-run with --verbose to log them`
396
+ );
397
+ }
398
+ lines.push("================================================");
399
+ this.append(lines.join("\n"));
400
+ }
401
+ };
402
+
403
+ // src/core/completion.ts
404
+ var DefaultCompletionDetector = class {
405
+ name = "default";
406
+ detect(ctx) {
407
+ if (ctx.abortReason) {
408
+ switch (ctx.abortReason) {
409
+ case "timeout":
410
+ case "idle":
411
+ return "timeout";
412
+ case "cancel":
413
+ return "cancelled";
414
+ case "maxTurns":
415
+ return "failed";
416
+ }
417
+ }
418
+ if (ctx.error) return "failed";
419
+ const result = ctx.result;
420
+ if (result) {
421
+ if (result.success) return "completed";
422
+ if (result.exitCode === 0 && !result.error) return "completed";
423
+ }
424
+ return "failed";
425
+ }
426
+ };
427
+ var CompositeCompletionDetector = class {
428
+ name = "composite";
429
+ detectors;
430
+ fallback = new DefaultCompletionDetector();
431
+ constructor(detectors = []) {
432
+ this.detectors = detectors;
433
+ }
434
+ detect(ctx) {
435
+ for (const detector of this.detectors) {
436
+ const verdict = detector.detect(ctx);
437
+ if (verdict) return verdict;
438
+ }
439
+ return this.fallback.detect(ctx);
440
+ }
441
+ };
442
+ var OutputPatternDetector = class {
443
+ constructor(options) {
444
+ this.options = options;
445
+ }
446
+ options;
447
+ name = "output-pattern";
448
+ detect(ctx) {
449
+ const text = ctx.events.filter((e) => e.type === "assistant_message" || e.type === "stdout").map((e) => e.text ?? "").join("\n");
450
+ if (this.options.failurePattern?.test(text)) return "failed";
451
+ if (this.options.successPattern?.test(text)) return "completed";
452
+ return void 0;
453
+ }
454
+ };
455
+
456
+ // src/core/util/json.ts
457
+ function safeJsonParse(text) {
458
+ try {
459
+ return JSON.parse(text);
460
+ } catch {
461
+ return void 0;
462
+ }
463
+ }
464
+
465
+ // src/core/decider.ts
466
+ var DEFAULT_DENY_PATTERNS = [
467
+ // any `rm` with a recursive/force flag, in any flag order/combination
468
+ /\brm\s+(?:-[a-z]*[rf]|--(?:recursive|force))/i,
469
+ /\bsudo\b/i,
470
+ /\bgit\s+push\b[^\n]*--force\b|\bpush\s+-f\b/i,
471
+ /\bdrop\s+(table|database)\b/i,
472
+ /\bcurl\b[^\n]*\|\s*(sh|bash)\b/i,
473
+ /\bmkfs\b|\bdd\s+if=/i,
474
+ /:\s*\(\s*\)\s*\{/
475
+ // fork bomb-ish
476
+ ];
477
+ var DEFAULT_AFFIRMATIVE = /\b(yes|proceed|approve|allow|accept|confirm|continue|ok)\b/i;
478
+ var DEFAULT_NEGATIVE = /\b(no|cancel|reject|deny|abort|stop|decline)\b/i;
479
+ var RuleDecider = class {
480
+ name;
481
+ opts;
482
+ constructor(options = {}) {
483
+ this.name = options.name ?? "rule";
484
+ this.opts = {
485
+ // Approve everything by DEFAULT so the agent's task actually progresses.
486
+ // Blocking on a danger-regex matched against mangled TUI text both breaks
487
+ // legitimate task work and misses real risks — so danger patterns are
488
+ // opt-in (pass `denyPatterns: DEFAULT_DENY_PATTERNS` to enable them). For
489
+ // task-aware "redirect only off-task danger", use an LLM decider instead.
490
+ // Setting `denyPatterns` ALSO switches choice menus from "confirm the
491
+ // recommended option" (the default) to label-aware affirmative/negative
492
+ // selection — see `decide()`.
493
+ denyPatterns: options.denyPatterns ?? [],
494
+ defaultApproval: options.defaultApproval ?? "approve",
495
+ affirmativePattern: options.affirmativePattern ?? DEFAULT_AFFIRMATIVE,
496
+ negativePattern: options.negativePattern ?? DEFAULT_NEGATIVE,
497
+ defaultAnswer: options.defaultAnswer ?? ""
498
+ };
499
+ }
500
+ isDangerous(text) {
501
+ return this.opts.denyPatterns.some((re) => re.test(text));
502
+ }
503
+ async decide(req) {
504
+ const haystack = `${req.prompt}
505
+ ${req.context ?? ""}`;
506
+ const hasDenyPolicy = this.opts.denyPatterns.length > 0;
507
+ const dangerous = this.isDangerous(haystack);
508
+ if (req.kind === "choice" && req.options && req.options.length > 0) {
509
+ if (req.multiSelect) {
510
+ const keep = (req.checked ?? []).flatMap((c, i) => c ? [i] : []);
511
+ return {
512
+ action: "select",
513
+ optionIndexes: keep,
514
+ reason: "rule: keep current multi-select & submit",
515
+ by: this.name
516
+ };
517
+ }
518
+ if (!hasDenyPolicy) {
519
+ return {
520
+ action: "select",
521
+ optionIndex: 0,
522
+ reason: "rule: confirm recommended (first) option",
523
+ by: this.name
524
+ };
525
+ }
526
+ if (dangerous) {
527
+ const negIdx = req.options.findIndex(
528
+ (o) => this.opts.negativePattern.test(o)
529
+ );
530
+ if (negIdx >= 0) {
531
+ return {
532
+ action: "select",
533
+ optionIndex: negIdx,
534
+ reason: "rule: dangerous prompt -> negative option",
535
+ by: this.name
536
+ };
537
+ }
538
+ return {
539
+ action: "deny",
540
+ reason: "rule: dangerous prompt, no negative option -> cancel",
541
+ by: this.name
542
+ };
543
+ }
544
+ const affIdx = req.options.findIndex(
545
+ (o) => this.opts.affirmativePattern.test(o)
546
+ );
547
+ return {
548
+ action: "select",
549
+ optionIndex: affIdx >= 0 ? affIdx : 0,
550
+ reason: "rule: affirmative option",
551
+ by: this.name
552
+ };
553
+ }
554
+ if (req.kind === "input") {
555
+ return {
556
+ action: "answer",
557
+ text: this.opts.defaultAnswer,
558
+ reason: "rule: default answer",
559
+ by: this.name
560
+ };
561
+ }
562
+ if (dangerous) {
563
+ return { action: "deny", reason: "rule: matched deny pattern", by: this.name };
564
+ }
565
+ return {
566
+ action: this.opts.defaultApproval,
567
+ reason: `rule: default ${this.opts.defaultApproval}`,
568
+ by: this.name
569
+ };
570
+ }
571
+ };
572
+ var AlwaysApproveDecider = class {
573
+ name = "always-approve";
574
+ async decide(req) {
575
+ if (req.kind === "choice" && req.options?.length) {
576
+ if (req.multiSelect) {
577
+ const keep = (req.checked ?? []).flatMap((c, i) => c ? [i] : []);
578
+ return { action: "select", optionIndexes: keep, by: this.name };
579
+ }
580
+ return { action: "select", optionIndex: 0, by: this.name };
581
+ }
582
+ if (req.kind === "input") return { action: "answer", text: "", by: this.name };
583
+ return { action: "approve", by: this.name };
584
+ }
585
+ };
586
+ var FunctionDecider = class {
587
+ constructor(fn, name = "function") {
588
+ this.fn = fn;
589
+ this.name = name;
590
+ }
591
+ fn;
592
+ name;
593
+ async decide(req) {
594
+ return this.fn(req);
595
+ }
596
+ };
597
+ function renderDecisionPrompt(req) {
598
+ const options = req.options && req.options.length ? `
599
+ Options (0-indexed):
600
+ ${req.options.map(
601
+ (o, i) => req.multiSelect ? ` ${i}. [${req.checked?.[i] ? "x" : " "}] ${o}` : ` ${i}. ${o}`
602
+ ).join("\n")}` : "";
603
+ const multiNote = req.multiSelect ? `
604
+ This is a MULTI-SELECT (checkboxes; [x] = currently checked). Return "optionIndexes" = the COMPLETE set of 0-based indices that should END UP checked to advance the TASK (keep already-good ones). The agent toggles the difference and submits.` : "";
605
+ return [
606
+ `You are the approval policy for an autonomous coding agent (${req.agent ?? "agent"}).`,
607
+ `The agent is working on this TASK:`,
608
+ `"""`,
609
+ (req.task ?? "(task not provided)").slice(0, 2e3),
610
+ `"""`,
611
+ ``,
612
+ `It paused and needs a decision (kind: "${req.kind}") on:`,
613
+ req.prompt,
614
+ options,
615
+ multiNote,
616
+ req.context ? `
617
+ Recent terminal context:
618
+ ${req.context.slice(-800)}` : "",
619
+ ``,
620
+ `POLICY \u2014 bias strongly toward letting the agent finish the TASK:`,
621
+ `- DEFAULT TO APPROVING. Allow commands, file edits, installs and other actions that plausibly help complete the TASK, even risky-looking ones, so the project actually progresses.`,
622
+ `- ONLY deny (or pick the negative option) when the action is BOTH clearly dangerous AND unrelated to the TASK (off-task) \u2014 e.g. deleting unrelated data, modifying the system, or network exfiltration with no task purpose. Then put a short redirect in "text" telling the agent to get back to the TASK.`,
623
+ req.multiSelect ? `- For this multi-select, return "optionIndexes" with EXACTLY the options that advance the TASK (action "select").` : `- For a choice menu, pick the affirmative option that advances the TASK.`,
624
+ ``,
625
+ `Respond with ONLY a single JSON object, no prose:`,
626
+ `{"action":"approve|deny|select|answer|abort","optionIndex":<number?>,"optionIndexes":[<numbers?>],"text":"<answer or redirect comment?>","reason":"<short>"}`
627
+ ].filter(Boolean).join("\n");
628
+ }
629
+ function parseDecisionReply(reply) {
630
+ for (const candidate of reply.match(/\{[^{}]*\}/g) ?? []) {
631
+ const obj = safeJsonParse(candidate);
632
+ if (obj && typeof obj.action === "string") {
633
+ const action = obj.action;
634
+ if (["approve", "deny", "select", "answer", "abort"].includes(action)) {
635
+ return {
636
+ action,
637
+ optionIndex: typeof obj.optionIndex === "number" ? obj.optionIndex : void 0,
638
+ optionIndexes: Array.isArray(obj.optionIndexes) ? obj.optionIndexes.filter((x) => typeof x === "number") : void 0,
639
+ text: typeof obj.text === "string" ? obj.text : void 0,
640
+ reason: typeof obj.reason === "string" ? obj.reason : void 0
641
+ };
642
+ }
643
+ }
644
+ }
645
+ const lower = reply.toLowerCase();
646
+ const negative = /\b(deny|reject|no|abort)\b/.test(lower);
647
+ if (negative) {
648
+ return { action: "deny", reason: "keyword:deny" };
649
+ }
650
+ if (/\b(approve|allow|yes|proceed)\b/.test(lower)) {
651
+ return { action: "approve", reason: "keyword:approve" };
652
+ }
653
+ return void 0;
654
+ }
655
+ var CommandDecider = class {
656
+ name;
657
+ opts;
658
+ constructor(options) {
659
+ this.name = options.name ?? `command:${options.command}`;
660
+ this.opts = options;
661
+ }
662
+ async decide(req) {
663
+ const render = this.opts.renderPrompt ?? renderDecisionPrompt;
664
+ const prompt = render(req);
665
+ const fallback = this.opts.fallback ?? {
666
+ action: "deny",
667
+ reason: "decider unavailable -> safe deny",
668
+ by: this.name
669
+ };
670
+ try {
671
+ const result = await execa(this.opts.command, this.opts.args ?? [], {
672
+ input: prompt,
673
+ env: { ...process.env, ...this.opts.env },
674
+ reject: false,
675
+ timeout: this.opts.timeoutMs ?? 6e4,
676
+ stripFinalNewline: true
677
+ });
678
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
679
+ const parsed = parseDecisionReply(stdout);
680
+ if (parsed) return { ...parsed, by: this.name };
681
+ return fallback;
682
+ } catch {
683
+ return fallback;
684
+ }
685
+ }
686
+ };
687
+ var ApiDecider = class {
688
+ constructor(opts) {
689
+ this.opts = opts;
690
+ this.name = opts.name ?? `api:${opts.model ?? "model"}`;
691
+ }
692
+ opts;
693
+ name;
694
+ async decide(req) {
695
+ const render = this.opts.renderPrompt ?? renderDecisionPrompt;
696
+ const fallback = this.opts.fallback ?? {
697
+ action: "deny",
698
+ reason: "api decider unavailable -> safe deny",
699
+ by: this.name
700
+ };
701
+ const controller = new AbortController();
702
+ const timer = setTimeout(
703
+ () => controller.abort(),
704
+ this.opts.timeoutMs ?? 6e4
705
+ );
706
+ try {
707
+ const res = await fetch(this.opts.url, {
708
+ method: "POST",
709
+ headers: {
710
+ "Content-Type": "application/json",
711
+ ...this.opts.apiKey ? { Authorization: `Bearer ${this.opts.apiKey}` } : {}
712
+ },
713
+ body: JSON.stringify({
714
+ model: this.opts.model ?? "default",
715
+ messages: [{ role: "user", content: render(req) }],
716
+ max_tokens: this.opts.maxTokens ?? 2048,
717
+ temperature: this.opts.temperature ?? 0,
718
+ stream: false
719
+ }),
720
+ signal: controller.signal
721
+ });
722
+ if (!res.ok) return fallback;
723
+ const data = await res.json();
724
+ const msg = data.choices?.[0]?.message ?? {};
725
+ const parsed = parseDecisionReply(msg.content ?? "") ?? parseDecisionReply(msg.reasoning_content ?? "");
726
+ return parsed ? { ...parsed, by: this.name } : fallback;
727
+ } catch {
728
+ return fallback;
729
+ } finally {
730
+ clearTimeout(timer);
731
+ }
732
+ }
733
+ };
734
+ function createDecider(config) {
735
+ if (!config) return new RuleDecider();
736
+ switch (config.type) {
737
+ case "always-approve":
738
+ return new AlwaysApproveDecider();
739
+ case "command":
740
+ if (!config.command) {
741
+ return new RuleDecider();
742
+ }
743
+ return new CommandDecider({
744
+ command: config.command,
745
+ args: config.args,
746
+ env: config.env,
747
+ timeoutMs: config.timeoutMs
748
+ });
749
+ case "api":
750
+ if (!config.url) {
751
+ return new RuleDecider();
752
+ }
753
+ return new ApiDecider({
754
+ url: config.url,
755
+ model: config.model,
756
+ apiKey: config.apiKey,
757
+ maxTokens: config.maxTokens,
758
+ timeoutMs: config.timeoutMs
759
+ });
760
+ case "rule":
761
+ default:
762
+ return new RuleDecider({
763
+ denyPatterns: config.denyPatterns?.map((s) => new RegExp(s, "i")),
764
+ defaultApproval: config.defaultApproval,
765
+ defaultAnswer: config.defaultAnswer
766
+ });
767
+ }
768
+ }
769
+ async function runShellHook(command, ctx) {
770
+ try {
771
+ const result = await execa(command, {
772
+ shell: true,
773
+ cwd: ctx.cwd,
774
+ reject: false,
775
+ timeout: 6e4,
776
+ env: {
777
+ ...process.env,
778
+ AGENT_RELAY_SESSION_ID: ctx.sessionId,
779
+ AGENT_RELAY_ADAPTER: ctx.adapter,
780
+ AGENT_RELAY_STATUS: ctx.status,
781
+ AGENT_RELAY_CWD: ctx.cwd,
782
+ AGENT_RELAY_LOG_FILE: ctx.logFile,
783
+ AGENT_RELAY_EXIT_CODE: ctx.exitCode == null ? "" : String(ctx.exitCode)
784
+ }
785
+ });
786
+ const code = typeof result.exitCode === "number" ? result.exitCode : null;
787
+ return { ok: code === 0, exitCode: code };
788
+ } catch {
789
+ return { ok: false, exitCode: null };
790
+ }
791
+ }
792
+ async function safeHook(fn, arg) {
793
+ if (!fn) return;
794
+ try {
795
+ await fn(arg);
796
+ } catch {
797
+ }
798
+ }
799
+ async function runAgent(options) {
800
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
801
+ const iso = () => now().toISOString();
802
+ const config = options.config;
803
+ const adapterName = options.adapterName ?? config.defaultAdapter;
804
+ const adapterConfig = config.adapters[adapterName];
805
+ if (!adapterConfig) {
806
+ throw new UnknownAdapterError(adapterName, Object.keys(config.adapters));
807
+ }
808
+ const adapter = options.resolveAdapter(adapterName, config);
809
+ const detector = options.detector ?? new CompositeCompletionDetector();
810
+ const rootDir = path7.resolve(options.rootDir);
811
+ const cwd = path7.resolve(options.cwd ?? rootDir);
812
+ const sessionsDir = path7.resolve(rootDir, config.sessionsDir);
813
+ const logsDir = path7.resolve(rootDir, config.logsDir);
814
+ const sessionId = options.sessionId ?? randomUUID();
815
+ const logFile = path7.join(logsDir, `${sessionId}.log`);
816
+ const sessions = new SessionManager(sessionsDir);
817
+ const startedAt = iso();
818
+ const session = sessions.create({
819
+ sessionId,
820
+ prompt: options.prompt,
821
+ adapter: adapterName,
822
+ mode: adapter.definition.mode,
823
+ cwd,
824
+ logFile,
825
+ dryRun: options.dryRun ?? false,
826
+ startedAt
827
+ });
828
+ await sessions.save(session);
829
+ await safeHook(options.hooks?.onSessionStart, session);
830
+ const finalize = async (outcome, runShellComplete) => {
831
+ await safeHook(options.hooks?.onComplete, outcome);
832
+ if (runShellComplete && config.hooks?.onComplete) {
833
+ await runShellHook(config.hooks.onComplete, {
834
+ sessionId: outcome.session.sessionId,
835
+ adapter: outcome.session.adapter,
836
+ status: outcome.status,
837
+ cwd: outcome.session.cwd,
838
+ logFile: outcome.logFile,
839
+ exitCode: outcome.session.exitCode
840
+ });
841
+ }
842
+ return outcome;
843
+ };
844
+ const persist = async (patch) => {
845
+ try {
846
+ return await sessions.update(sessionId, patch);
847
+ } catch {
848
+ return { ...session, ...patch };
849
+ }
850
+ };
851
+ const positiveOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
852
+ const approvalMode = options.approvalMode ?? resolveApprovalMode(config.defaults, adapterConfig);
853
+ const sandbox = options.sandbox ?? resolveSandbox(config.defaults, adapterConfig);
854
+ const input = {
855
+ prompt: options.prompt,
856
+ cwd,
857
+ maxTurns: positiveOr(options.maxTurns, config.defaults.maxTurns),
858
+ timeoutMs: positiveOr(options.timeoutMs, config.defaults.timeoutMs),
859
+ idleTimeoutMs: positiveOr(options.idleTimeoutMs, config.defaults.idleTimeoutMs),
860
+ dryRun: options.dryRun ?? false,
861
+ // Per-adapter `args` from config (e.g. ["--effort","max"]) come first, then
862
+ // any per-run `--extra` flags — both are appended after the adapter's own
863
+ // pre-args and before the prompt when spawning.
864
+ extraArgs: [...adapterConfig.args ?? [], ...options.extraArgs ?? []],
865
+ approvalMode,
866
+ sandbox,
867
+ completionIdleMs: options.completionIdleMs ?? config.defaults.completionIdleMs,
868
+ ultracode: options.ultracode,
869
+ resume: options.resume
870
+ };
871
+ const preview = safeDescribe(adapter, input);
872
+ const logger = new RunLogger(logFile, {
873
+ verbose: options.verbose,
874
+ maxBytes: options.maxLogBytes
875
+ });
876
+ logger.start({
877
+ sessionId,
878
+ adapter: adapterName,
879
+ mode: adapter.definition.mode,
880
+ prompt: options.prompt,
881
+ cwd,
882
+ command: preview,
883
+ startedAt
884
+ });
885
+ if (input.dryRun) {
886
+ const endedAt2 = iso();
887
+ const result2 = {
888
+ success: true,
889
+ exitCode: null,
890
+ meta: { dryRun: true, command: preview }
891
+ };
892
+ logger.event({
893
+ type: "status",
894
+ timestamp: endedAt2,
895
+ text: `[dry-run] would execute: ${preview?.display ?? "(no command)"}`
896
+ });
897
+ logger.finish("completed", endedAt2);
898
+ const finalSession2 = await persist({
899
+ status: "completed",
900
+ endedAt: endedAt2,
901
+ exitCode: null,
902
+ meta: { dryRun: true }
903
+ });
904
+ return finalize(
905
+ {
906
+ session: finalSession2,
907
+ status: "completed",
908
+ result: result2,
909
+ events: [],
910
+ logFile
911
+ },
912
+ false
913
+ );
914
+ }
915
+ const availability = await adapter.isAvailable();
916
+ if (!availability.ok) {
917
+ const endedAt2 = iso();
918
+ const error2 = {
919
+ message: availability.reason ?? `Adapter "${adapterName}" is not available.`,
920
+ code: "ADAPTER_UNAVAILABLE",
921
+ hint: availability.hint
922
+ };
923
+ const errorEvent = {
924
+ type: "error",
925
+ timestamp: endedAt2,
926
+ text: error2.message,
927
+ data: error2
928
+ };
929
+ logger.event(errorEvent);
930
+ options.onEvent?.(errorEvent);
931
+ logger.finish("failed", endedAt2, error2);
932
+ const finalSession2 = await persist({
933
+ status: "failed",
934
+ endedAt: endedAt2,
935
+ error: error2
936
+ });
937
+ return finalize(
938
+ {
939
+ session: finalSession2,
940
+ status: "failed",
941
+ events: [errorEvent],
942
+ logFile
943
+ },
944
+ true
945
+ );
946
+ }
947
+ await sessions.setStatus(sessionId, "running");
948
+ if (config.hooks?.onStart) {
949
+ await runShellHook(config.hooks.onStart, {
950
+ sessionId,
951
+ adapter: adapterName,
952
+ status: "running",
953
+ cwd,
954
+ logFile,
955
+ exitCode: null
956
+ });
957
+ }
958
+ const controller = new AbortController();
959
+ const events = [];
960
+ let abortReason;
961
+ let turnCount = 0;
962
+ let hardTimer;
963
+ let idleTimer;
964
+ const abortWith = (reason) => {
965
+ if (!abortReason) abortReason = reason;
966
+ if (!controller.signal.aborted) controller.abort();
967
+ };
968
+ const clearIdle = () => {
969
+ if (idleTimer) clearTimeout(idleTimer);
970
+ idleTimer = void 0;
971
+ };
972
+ const armIdle = () => {
973
+ clearIdle();
974
+ idleTimer = setTimeout(() => abortWith("idle"), input.idleTimeoutMs);
975
+ idleTimer.unref?.();
976
+ };
977
+ const onEvent = (event) => {
978
+ events.push(event);
979
+ try {
980
+ logger.event(event);
981
+ } catch {
982
+ }
983
+ options.onEvent?.(event);
984
+ if (options.hooks?.onEvent) {
985
+ void Promise.resolve(options.hooks.onEvent(event, session)).catch(
986
+ () => {
987
+ }
988
+ );
989
+ }
990
+ armIdle();
991
+ if (event.type === "assistant_message") {
992
+ turnCount += 1;
993
+ if (turnCount > input.maxTurns) abortWith("maxTurns");
994
+ }
995
+ };
996
+ hardTimer = setTimeout(() => abortWith("timeout"), input.timeoutMs);
997
+ hardTimer.unref?.();
998
+ armIdle();
999
+ const installSignals = options.installSignalHandlers ?? true;
1000
+ const onSignal = () => abortWith("cancel");
1001
+ if (installSignals) {
1002
+ process.on("SIGINT", onSignal);
1003
+ process.on("SIGTERM", onSignal);
1004
+ }
1005
+ const externalSignal = options.signal;
1006
+ const onExternalAbort = () => abortWith("cancel");
1007
+ if (externalSignal) {
1008
+ if (externalSignal.aborted) abortWith("cancel");
1009
+ else externalSignal.addEventListener("abort", onExternalAbort, { once: true });
1010
+ }
1011
+ const decider = options.decider ?? (options.hooks?.onApprovalRequest ? new FunctionDecider(options.hooks.onApprovalRequest, "hook") : createDecider(config.decider));
1012
+ const ctx = {
1013
+ signal: controller.signal,
1014
+ onEvent,
1015
+ cwd,
1016
+ decider
1017
+ };
1018
+ let result;
1019
+ let runError;
1020
+ try {
1021
+ result = input.resume && adapter.resume ? await adapter.resume(input.resume, input, ctx) : await adapter.run(input, ctx);
1022
+ } catch (err) {
1023
+ runError = err instanceof Error ? err : new Error(String(err));
1024
+ } finally {
1025
+ if (hardTimer) clearTimeout(hardTimer);
1026
+ clearIdle();
1027
+ if (installSignals) {
1028
+ process.removeListener("SIGINT", onSignal);
1029
+ process.removeListener("SIGTERM", onSignal);
1030
+ }
1031
+ externalSignal?.removeEventListener("abort", onExternalAbort);
1032
+ }
1033
+ const status = detector.detect({ result, events, abortReason, error: runError }) ?? "failed";
1034
+ const endedAt = iso();
1035
+ const error = runError && !result?.error ? { message: runError.message, code: "ADAPTER_THREW" } : result?.error ?? (abortReason === "timeout" || abortReason === "idle" ? {
1036
+ message: `Run aborted: ${abortReason} after waiting limit.`,
1037
+ code: abortReason === "idle" ? "IDLE_TIMEOUT" : "TIMEOUT"
1038
+ } : abortReason === "maxTurns" ? {
1039
+ message: `Run stopped: reached maxTurns (${input.maxTurns}).`,
1040
+ code: "MAX_TURNS"
1041
+ } : void 0);
1042
+ logger.finish(status, endedAt, error);
1043
+ const finalSession = await persist({
1044
+ status,
1045
+ endedAt,
1046
+ exitCode: result?.exitCode ?? null,
1047
+ error,
1048
+ sessionRef: result?.sessionRef,
1049
+ meta: result?.meta
1050
+ });
1051
+ return finalize(
1052
+ {
1053
+ session: finalSession,
1054
+ status,
1055
+ result,
1056
+ events,
1057
+ abortReason,
1058
+ logFile
1059
+ },
1060
+ true
1061
+ );
1062
+ }
1063
+ function safeDescribe(adapter, input) {
1064
+ try {
1065
+ return adapter.describeCommand(input);
1066
+ } catch {
1067
+ return void 0;
1068
+ }
1069
+ }
1070
+
1071
+ // src/adapters/fake-adapter.ts
1072
+ var DEFINITION = {
1073
+ name: "fake",
1074
+ type: "fake",
1075
+ mode: "test",
1076
+ description: "Deterministic in-process adapter for tests and dry runs.",
1077
+ supportsResume: true
1078
+ };
1079
+ var FakeAgentAdapter = class {
1080
+ definition = DEFINITION;
1081
+ options;
1082
+ constructor(options = {}) {
1083
+ this.options = {
1084
+ scenario: options.scenario ?? "auto",
1085
+ assistantMessages: options.assistantMessages,
1086
+ emitTool: options.emitTool ?? false,
1087
+ delayMs: options.delayMs ?? 0,
1088
+ failureExitCode: options.failureExitCode ?? 1,
1089
+ hangSafetyMs: options.hangSafetyMs ?? 6e4,
1090
+ now: options.now ?? (() => /* @__PURE__ */ new Date())
1091
+ };
1092
+ }
1093
+ async isAvailable() {
1094
+ return { ok: true, version: "fake-1.0.0" };
1095
+ }
1096
+ describeCommand(input) {
1097
+ return {
1098
+ mode: this.definition.mode,
1099
+ command: "(fake)",
1100
+ args: [],
1101
+ display: `fake-adapter scenario=${this.resolveScenario(input)} prompt=${JSON.stringify(
1102
+ truncate(input.prompt, 60)
1103
+ )}`
1104
+ };
1105
+ }
1106
+ resolveScenario(input) {
1107
+ if (this.options.scenario !== "auto") return this.options.scenario;
1108
+ const p = input.prompt.toLowerCase();
1109
+ if (p.includes("timeout") || p.includes("hang")) return "timeout";
1110
+ if (p.includes("fail") || p.includes("error")) return "failure";
1111
+ return "success";
1112
+ }
1113
+ emit(ctx, type, extra = {}) {
1114
+ const event = {
1115
+ type,
1116
+ timestamp: this.options.now().toISOString(),
1117
+ ...extra
1118
+ };
1119
+ ctx.onEvent(event);
1120
+ }
1121
+ async run(input, ctx) {
1122
+ const scenario = this.resolveScenario(input);
1123
+ const sessionRef = {
1124
+ adapter: this.definition.name,
1125
+ nativeSessionId: `fake-${scenario}`,
1126
+ resumable: true
1127
+ };
1128
+ if (ctx.signal.aborted) {
1129
+ return { success: false, exitCode: null, aborted: true, sessionRef };
1130
+ }
1131
+ this.emit(ctx, "start", { text: `fake run started (scenario=${scenario})` });
1132
+ if (scenario === "timeout") {
1133
+ return this.hangUntilAborted(ctx, sessionRef);
1134
+ }
1135
+ const messages = this.options.assistantMessages ?? (scenario === "failure" ? ["Attempting the task...", "Something went wrong."] : ["Working on the task...", "Task complete."]);
1136
+ for (const message of messages) {
1137
+ if (ctx.signal.aborted) {
1138
+ return { success: false, exitCode: null, aborted: true, sessionRef };
1139
+ }
1140
+ this.emit(ctx, "assistant_message", { text: message });
1141
+ }
1142
+ if (this.options.emitTool && !ctx.signal.aborted) {
1143
+ this.emit(ctx, "tool_call", {
1144
+ data: { name: "write_file", input: { path: "out.txt" } }
1145
+ });
1146
+ this.emit(ctx, "tool_result", {
1147
+ data: { name: "write_file", ok: true }
1148
+ });
1149
+ }
1150
+ if (this.options.delayMs > 0) {
1151
+ await this.sleep(this.options.delayMs, ctx.signal);
1152
+ if (ctx.signal.aborted) {
1153
+ return { success: false, exitCode: null, aborted: true, sessionRef };
1154
+ }
1155
+ }
1156
+ if (scenario === "failure") {
1157
+ const error = {
1158
+ message: "Fake adapter failure scenario.",
1159
+ code: "FAKE_FAILURE"
1160
+ };
1161
+ this.emit(ctx, "error", { text: error.message });
1162
+ this.emit(ctx, "complete", { data: { success: false } });
1163
+ return {
1164
+ success: false,
1165
+ exitCode: this.options.failureExitCode,
1166
+ error,
1167
+ sessionRef,
1168
+ finalMessage: messages[messages.length - 1]
1169
+ };
1170
+ }
1171
+ this.emit(ctx, "complete", { data: { success: true } });
1172
+ return {
1173
+ success: true,
1174
+ exitCode: 0,
1175
+ sessionRef,
1176
+ finalMessage: messages[messages.length - 1],
1177
+ meta: { turns: messages.length }
1178
+ };
1179
+ }
1180
+ async resume(ref, input, ctx) {
1181
+ this.emit(ctx, "status", {
1182
+ text: `resuming fake session ${ref.nativeSessionId ?? "(none)"}`
1183
+ });
1184
+ return this.run(input, ctx);
1185
+ }
1186
+ hangUntilAborted(ctx, sessionRef) {
1187
+ this.emit(ctx, "status", { text: "fake run is hanging (awaiting abort)" });
1188
+ return new Promise((resolve) => {
1189
+ const finish = (aborted) => {
1190
+ clearTimeout(safety);
1191
+ ctx.signal.removeEventListener("abort", onAbort);
1192
+ resolve({ success: false, exitCode: null, aborted, sessionRef });
1193
+ };
1194
+ const onAbort = () => finish(true);
1195
+ const safety = setTimeout(() => finish(false), this.options.hangSafetyMs);
1196
+ safety.unref?.();
1197
+ if (ctx.signal.aborted) return finish(true);
1198
+ ctx.signal.addEventListener("abort", onAbort, { once: true });
1199
+ });
1200
+ }
1201
+ sleep(ms, signal) {
1202
+ return new Promise((resolve) => {
1203
+ const timer = setTimeout(done, ms);
1204
+ timer.unref?.();
1205
+ function done() {
1206
+ clearTimeout(timer);
1207
+ signal.removeEventListener("abort", done);
1208
+ resolve();
1209
+ }
1210
+ if (signal.aborted) return done();
1211
+ signal.addEventListener("abort", done, { once: true });
1212
+ });
1213
+ }
1214
+ };
1215
+ function truncate(text, max) {
1216
+ return text.length <= max ? text : `${text.slice(0, max - 1)}\u2026`;
1217
+ }
1218
+ function pathExtensions() {
1219
+ if (process.platform !== "win32") return [""];
1220
+ const exts = (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
1221
+ return ["", ...exts];
1222
+ }
1223
+ async function isExecutableFile(file) {
1224
+ try {
1225
+ const stat = await promises.stat(file);
1226
+ if (!stat.isFile()) return false;
1227
+ if (process.platform === "win32") return true;
1228
+ await promises.access(file, constants.X_OK);
1229
+ return true;
1230
+ } catch {
1231
+ return false;
1232
+ }
1233
+ }
1234
+ async function which(command) {
1235
+ if (!command) return null;
1236
+ const exts = pathExtensions();
1237
+ if (command.includes(path7.sep) || command.includes("/")) {
1238
+ const base = path7.resolve(command);
1239
+ for (const ext of exts) {
1240
+ const candidate = base + ext;
1241
+ if (await isExecutableFile(candidate)) return candidate;
1242
+ }
1243
+ return null;
1244
+ }
1245
+ const pathEnv = process.env.PATH ?? process.env.Path ?? "";
1246
+ const dirs = pathEnv.split(path7.delimiter).filter(Boolean);
1247
+ const fallbacks = process.platform === "win32" ? [] : ["/usr/local/bin", "/opt/homebrew/bin", path7.join(os.homedir(), ".local/bin")];
1248
+ for (const dir of [...dirs, ...fallbacks]) {
1249
+ for (const ext of exts) {
1250
+ const candidate = path7.join(dir, command + ext);
1251
+ if (await isExecutableFile(candidate)) return candidate;
1252
+ }
1253
+ }
1254
+ return null;
1255
+ }
1256
+
1257
+ // src/core/util/ansi.ts
1258
+ var ANSI_PATTERN = [
1259
+ "[\\u001B\\u009B][[\\]()#;?]*",
1260
+ "(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*",
1261
+ "|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
1262
+ "|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))"
1263
+ ].join("");
1264
+ var ANSI_RE = new RegExp(ANSI_PATTERN, "g");
1265
+ var CONTROL_RE = new RegExp("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]", "g");
1266
+ function stripAnsi(input) {
1267
+ return input.replace(ANSI_RE, "");
1268
+ }
1269
+ function cleanTerminalText(input) {
1270
+ let text = stripAnsi(input).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
1271
+ text = text.replace(CONTROL_RE, "");
1272
+ return text;
1273
+ }
1274
+ function tailLines(text, n) {
1275
+ const lines = text.split("\n").map((l) => l.replace(/\s+$/g, "")).filter((l) => l.trim().length > 0);
1276
+ return lines.slice(-n);
1277
+ }
1278
+
1279
+ // src/adapters/interactive/prompt-detector.ts
1280
+ function parseCheckbox(label) {
1281
+ const bracket = /^\[\s*([ xX✓✔*])?\s*\]\s*(.*)$/.exec(label);
1282
+ if (bracket) {
1283
+ return { checked: /[xX✓✔*]/.test(bracket[1] ?? " "), label: (bracket[2] ?? "").trim() };
1284
+ }
1285
+ const glyph = /^([◉◯☑☐⊠☒□■●○])\s*(.*)$/.exec(label);
1286
+ if (glyph) {
1287
+ const checked = "\u25C9\u2611\u22A0\u2612\u25A0\u25CF".includes(glyph[1]);
1288
+ return { checked, label: (glyph[2] ?? "").trim() };
1289
+ }
1290
+ return null;
1291
+ }
1292
+ var DEFAULT_APPROVAL = /(\[y\/n\]|\(y\/n\)|\[y\/N\]|\(y\/N\)|\[Y\/n\]|\byes\/no\b|do you want|allow this|proceed\?|approve\?|confirm\?)/i;
1293
+ var DEFAULT_OPTION_LINE = /^[ \t]*[>❯*]?[ \t]*(\d+)[.)][ \t]*(\S.*)$/;
1294
+ var PromptDetector = class {
1295
+ opts;
1296
+ constructor(options = {}) {
1297
+ this.opts = {
1298
+ approvalPattern: options.approvalPattern ?? DEFAULT_APPROVAL,
1299
+ optionLine: options.optionLine ?? DEFAULT_OPTION_LINE,
1300
+ inputPattern: options.inputPattern,
1301
+ minOptions: options.minOptions ?? 2,
1302
+ tailLines: options.tailLines ?? 12
1303
+ };
1304
+ }
1305
+ /** Detect a settled prompt from raw terminal output, or null. */
1306
+ detect(rawOutput) {
1307
+ const clean = cleanTerminalText(rawOutput);
1308
+ const lines = tailLines(clean, this.opts.tailLines);
1309
+ if (lines.length === 0) return null;
1310
+ const optionMatches = [];
1311
+ for (const line of lines) {
1312
+ const m = this.opts.optionLine.exec(line);
1313
+ if (m) {
1314
+ optionMatches.push({
1315
+ number: Number(m[1]),
1316
+ label: (m[2] ?? "").trim(),
1317
+ line
1318
+ });
1319
+ }
1320
+ }
1321
+ if (optionMatches.length >= this.opts.minOptions) {
1322
+ const firstOptIdx = lines.indexOf(optionMatches[0].line);
1323
+ const question = lines.slice(0, firstOptIdx).slice(-2).join(" ").trim();
1324
+ const prompt = question || "Choose an option";
1325
+ const boxes = optionMatches.map((o) => parseCheckbox(o.label));
1326
+ const boxed = boxes.filter(Boolean).length;
1327
+ if (boxed >= 2 && boxed >= Math.ceil(optionMatches.length / 2)) {
1328
+ const options = optionMatches.map((o, i) => boxes[i]?.label ?? o.label);
1329
+ const checked = optionMatches.map((o, i) => boxes[i]?.checked ?? false);
1330
+ const marked = optionMatches.findIndex((o) => /^[ \t]*[>❯]/.test(o.line));
1331
+ return {
1332
+ kind: "choice",
1333
+ prompt,
1334
+ options,
1335
+ optionNumbers: optionMatches.map((o) => o.number),
1336
+ multiSelect: true,
1337
+ checked,
1338
+ cursorIndex: marked >= 0 ? marked : 0,
1339
+ // Signature includes the checked state so a toggled redraw is a new
1340
+ // prompt, but an unchanged re-render is de-duped.
1341
+ signature: `multi:${options.map((l, i) => `${checked[i] ? "x" : " "}.${l}`).join("|")}`
1342
+ };
1343
+ }
1344
+ return {
1345
+ kind: "choice",
1346
+ prompt,
1347
+ options: optionMatches.map((o) => o.label),
1348
+ optionNumbers: optionMatches.map((o) => o.number),
1349
+ signature: `choice:${optionMatches.map((o) => `${o.number}.${o.label}`).join("|")}`
1350
+ };
1351
+ }
1352
+ const approvalLineIdx = lines.findIndex(
1353
+ (l) => this.opts.approvalPattern.test(l)
1354
+ );
1355
+ if (approvalLineIdx >= 0) {
1356
+ const prompt = lines.slice(Math.max(0, approvalLineIdx - 1)).join(" ").trim();
1357
+ return { kind: "approval", prompt, signature: `approval:${prompt}` };
1358
+ }
1359
+ if (this.opts.inputPattern) {
1360
+ const idx = lines.findIndex((l) => this.opts.inputPattern.test(l));
1361
+ if (idx >= 0) {
1362
+ const prompt = lines.slice(Math.max(0, idx - 1)).join(" ").trim();
1363
+ return { kind: "input", prompt, signature: `input:${prompt}` };
1364
+ }
1365
+ }
1366
+ return null;
1367
+ }
1368
+ };
1369
+ var ensured = false;
1370
+ function ensurePtyHelperExecutable() {
1371
+ if (ensured) return;
1372
+ ensured = true;
1373
+ if (process.platform === "win32") return;
1374
+ try {
1375
+ const require2 = createRequire(import.meta.url);
1376
+ const root = path7.dirname(require2.resolve("node-pty/package.json"));
1377
+ const candidates = [
1378
+ path7.join(root, "build", "Release", "spawn-helper"),
1379
+ path7.join(root, "build", "Debug", "spawn-helper"),
1380
+ path7.join(
1381
+ root,
1382
+ "prebuilds",
1383
+ `${process.platform}-${process.arch}`,
1384
+ "spawn-helper"
1385
+ )
1386
+ ];
1387
+ for (const file of candidates) {
1388
+ try {
1389
+ const st = statSync(file);
1390
+ if ((st.mode & 73) === 0) chmodSync(file, 493);
1391
+ } catch {
1392
+ }
1393
+ }
1394
+ } catch {
1395
+ }
1396
+ }
1397
+
1398
+ // src/adapters/interactive/pty-session.ts
1399
+ var ENTER = "\r";
1400
+ var DOWN = "\x1B[B";
1401
+ var UP = "\x1B[A";
1402
+ var RIGHT = "\x1B[C";
1403
+ var ESC = "\x1B";
1404
+ var SPACE = " ";
1405
+ function multiSelectKeys(decision, prompt, submitKeys) {
1406
+ const n = prompt.options?.length ?? 0;
1407
+ const current = prompt.checked ?? new Array(n).fill(false);
1408
+ const desired = new Set(
1409
+ decision.optionIndexes ?? current.flatMap((c, i) => c ? [i] : [])
1410
+ );
1411
+ let pos = Math.min(Math.max(0, prompt.cursorIndex ?? 0), Math.max(0, n - 1));
1412
+ let keys = "";
1413
+ for (let i = 0; i < n; i++) {
1414
+ if (desired.has(i) !== current[i]) {
1415
+ const delta = i - pos;
1416
+ keys += (delta >= 0 ? DOWN : UP).repeat(Math.abs(delta)) + SPACE;
1417
+ pos = i;
1418
+ }
1419
+ }
1420
+ return keys + submitKeys;
1421
+ }
1422
+ var DefaultKeymap = class {
1423
+ constructor(multiSelectSubmit = RIGHT) {
1424
+ this.multiSelectSubmit = multiSelectSubmit;
1425
+ }
1426
+ multiSelectSubmit;
1427
+ keysFor(decision, prompt) {
1428
+ if (prompt.multiSelect && (decision.action === "select" || decision.action === "approve")) {
1429
+ return multiSelectKeys(decision, prompt, this.multiSelectSubmit);
1430
+ }
1431
+ switch (decision.action) {
1432
+ case "approve":
1433
+ return /\[y|\(y|y\/n/i.test(prompt.prompt) ? `y${ENTER}` : ENTER;
1434
+ case "deny":
1435
+ return prompt.kind === "choice" ? ESC : `n${ENTER}`;
1436
+ case "select": {
1437
+ const idx = decision.optionIndex ?? 0;
1438
+ const num = prompt.optionNumbers && prompt.optionNumbers[idx] !== void 0 ? prompt.optionNumbers[idx] : idx + 1;
1439
+ return `${num}${ENTER}`;
1440
+ }
1441
+ case "answer":
1442
+ return `${decision.text ?? ""}${ENTER}`;
1443
+ case "abort":
1444
+ return null;
1445
+ default:
1446
+ return null;
1447
+ }
1448
+ }
1449
+ };
1450
+ var ArrowKeymap = class {
1451
+ constructor(multiSelectSubmit = RIGHT) {
1452
+ this.multiSelectSubmit = multiSelectSubmit;
1453
+ }
1454
+ multiSelectSubmit;
1455
+ keysFor(decision, prompt) {
1456
+ if (prompt.multiSelect && (decision.action === "select" || decision.action === "approve")) {
1457
+ return multiSelectKeys(decision, prompt, this.multiSelectSubmit);
1458
+ }
1459
+ switch (decision.action) {
1460
+ case "approve":
1461
+ return ENTER;
1462
+ case "select": {
1463
+ const idx = Math.max(0, decision.optionIndex ?? 0);
1464
+ return DOWN.repeat(idx) + ENTER;
1465
+ }
1466
+ case "deny": {
1467
+ const opts = prompt.options;
1468
+ if (prompt.kind === "choice" && opts && opts.length > 0) {
1469
+ const negIdx = opts.findIndex(
1470
+ (o) => /\b(no|cancel|reject|deny|do ?n['o]?t)\b/i.test(o)
1471
+ );
1472
+ const idx = negIdx >= 0 ? negIdx : opts.length - 1;
1473
+ return DOWN.repeat(idx) + ENTER;
1474
+ }
1475
+ return ESC;
1476
+ }
1477
+ case "answer":
1478
+ return `${decision.text ?? ""}${ENTER}`;
1479
+ case "abort":
1480
+ return null;
1481
+ default:
1482
+ return null;
1483
+ }
1484
+ }
1485
+ };
1486
+ function runPtySession(opts, ctx) {
1487
+ const now = opts.now ?? (() => /* @__PURE__ */ new Date());
1488
+ const keymap = opts.keymap ?? new DefaultKeymap();
1489
+ const idleMs = opts.idleMs ?? 450;
1490
+ const maxInteractions = opts.maxInteractions ?? 100;
1491
+ const exitGraceMs = opts.exitGraceMs ?? 4e3;
1492
+ const emit = (type, text, extra = {}) => {
1493
+ ctx.onEvent({ type, timestamp: now().toISOString(), text, ...extra });
1494
+ };
1495
+ return new Promise((resolve) => {
1496
+ if (ctx.signal.aborted) {
1497
+ resolve({ success: false, exitCode: null, aborted: true });
1498
+ return;
1499
+ }
1500
+ let child;
1501
+ try {
1502
+ ensurePtyHelperExecutable();
1503
+ child = nodePty.spawn(opts.command, opts.args, {
1504
+ name: "xterm-256color",
1505
+ cols: opts.cols ?? 120,
1506
+ rows: opts.rows ?? 34,
1507
+ cwd: opts.cwd,
1508
+ env: { ...process.env, ...opts.env }
1509
+ });
1510
+ } catch (err) {
1511
+ const code = err.code;
1512
+ const error = code === "ENOENT" ? {
1513
+ message: `Command "${opts.command}" was not found on your PATH.`,
1514
+ code: "COMMAND_NOT_FOUND",
1515
+ hint: opts.installHint
1516
+ } : {
1517
+ message: `Failed to start PTY for ${opts.command}: ${err.message}`,
1518
+ code: "PTY_SPAWN_FAILED",
1519
+ hint: opts.installHint
1520
+ };
1521
+ emit("error", error.message, { data: error });
1522
+ resolve({ success: false, exitCode: null, error });
1523
+ return;
1524
+ }
1525
+ let buffer = "";
1526
+ let lastSig = "";
1527
+ let bytesSinceHandled = 0;
1528
+ let pendingComment;
1529
+ const setupSteps = opts.setup ?? [];
1530
+ let setupActive = setupSteps.length > 0 || opts.promptAfterSetup !== void 0;
1531
+ let setupIdx = 0;
1532
+ let setupStarted = false;
1533
+ let setupWaiting = false;
1534
+ let setupTimer;
1535
+ let interactions = 0;
1536
+ let settleTimer;
1537
+ let exitTimer;
1538
+ let completionTimer;
1539
+ let handling = false;
1540
+ let quitting = false;
1541
+ let settled = false;
1542
+ let finished = false;
1543
+ let disposeData;
1544
+ let disposeExit;
1545
+ const clearTimers = () => {
1546
+ if (settleTimer) clearTimeout(settleTimer);
1547
+ if (exitTimer) clearTimeout(exitTimer);
1548
+ if (completionTimer) clearTimeout(completionTimer);
1549
+ if (setupTimer) clearTimeout(setupTimer);
1550
+ settleTimer = void 0;
1551
+ exitTimer = void 0;
1552
+ completionTimer = void 0;
1553
+ setupTimer = void 0;
1554
+ };
1555
+ const triggerQuit = (reason) => {
1556
+ if (finished || quitting) return;
1557
+ quitting = true;
1558
+ if (completionTimer) clearTimeout(completionTimer);
1559
+ completionTimer = void 0;
1560
+ emit("status", `task appears complete (${reason})`);
1561
+ if (opts.quitKeys) {
1562
+ try {
1563
+ child.write(opts.quitKeys);
1564
+ } catch {
1565
+ }
1566
+ }
1567
+ exitTimer = setTimeout(() => {
1568
+ try {
1569
+ child.kill();
1570
+ } catch {
1571
+ }
1572
+ }, exitGraceMs);
1573
+ exitTimer.unref?.();
1574
+ };
1575
+ const finish = (result) => {
1576
+ if (finished) return;
1577
+ finished = true;
1578
+ clearTimers();
1579
+ ctx.signal.removeEventListener("abort", onAbort);
1580
+ try {
1581
+ disposeData?.dispose();
1582
+ disposeExit?.dispose();
1583
+ } catch {
1584
+ }
1585
+ resolve(result);
1586
+ };
1587
+ const onAbort = () => {
1588
+ try {
1589
+ child.kill();
1590
+ } catch {
1591
+ }
1592
+ finish({ success: false, exitCode: null, aborted: true });
1593
+ };
1594
+ const armSettle = () => {
1595
+ if (settleTimer) clearTimeout(settleTimer);
1596
+ if (completionTimer) {
1597
+ clearTimeout(completionTimer);
1598
+ completionTimer = void 0;
1599
+ }
1600
+ settleTimer = setTimeout(onSettle, idleMs);
1601
+ settleTimer.unref?.();
1602
+ };
1603
+ const context = () => cleanTerminalText(buffer).slice(-1500);
1604
+ const runNextSetup = () => {
1605
+ if (finished || quitting) return;
1606
+ if (setupIdx >= setupSteps.length) {
1607
+ setupActive = false;
1608
+ setupWaiting = false;
1609
+ if (opts.promptAfterSetup) {
1610
+ try {
1611
+ child.write(`${opts.promptAfterSetup}\r`);
1612
+ emit("status", "setup complete; sent task prompt");
1613
+ } catch {
1614
+ }
1615
+ }
1616
+ return;
1617
+ }
1618
+ const step = setupSteps[setupIdx];
1619
+ const waitMs = step.waitMs ?? 8e3;
1620
+ if (step.send) {
1621
+ try {
1622
+ child.write(`${step.send}\r`);
1623
+ } catch {
1624
+ }
1625
+ }
1626
+ emit("status", `setup: ${step.send ? `sent "${step.send}", ` : ""}waiting ${waitMs}ms`);
1627
+ setupWaiting = true;
1628
+ setupTimer = setTimeout(() => {
1629
+ setupWaiting = false;
1630
+ setupIdx += 1;
1631
+ runNextSetup();
1632
+ }, waitMs);
1633
+ setupTimer.unref?.();
1634
+ };
1635
+ const onSettle = async () => {
1636
+ if (finished || handling || quitting) return;
1637
+ if (pendingComment !== void 0) {
1638
+ const comment = pendingComment;
1639
+ pendingComment = void 0;
1640
+ try {
1641
+ child.write(`${comment}\r`);
1642
+ emit("tool_result", "sent redirect comment to the agent");
1643
+ } catch {
1644
+ }
1645
+ return;
1646
+ }
1647
+ if (opts.completionPattern && opts.completionPattern.test(context())) {
1648
+ triggerQuit("completion pattern");
1649
+ return;
1650
+ }
1651
+ const prompt = opts.detector.detect(buffer);
1652
+ if (setupActive) {
1653
+ if (setupWaiting || setupStarted) return;
1654
+ if (!prompt || prompt.signature === lastSig) {
1655
+ setupStarted = true;
1656
+ runNextSetup();
1657
+ return;
1658
+ }
1659
+ }
1660
+ if (!prompt || prompt.signature === lastSig) {
1661
+ if (opts.completionIdleMs && !completionTimer) {
1662
+ const working = opts.workingPattern?.test(
1663
+ tailLines(cleanTerminalText(buffer), 8).join(" ")
1664
+ ) ?? false;
1665
+ const base = Math.max(0, opts.completionIdleMs - idleMs);
1666
+ completionTimer = setTimeout(
1667
+ () => triggerQuit("sustained idle"),
1668
+ working ? base * 3 : base
1669
+ );
1670
+ completionTimer.unref?.();
1671
+ }
1672
+ return;
1673
+ }
1674
+ handling = true;
1675
+ try {
1676
+ interactions += 1;
1677
+ settled = true;
1678
+ if (interactions > maxInteractions) {
1679
+ const error = {
1680
+ message: `Exceeded max interactions (${maxInteractions}).`,
1681
+ code: "MAX_INTERACTIONS"
1682
+ };
1683
+ emit("error", error.message);
1684
+ try {
1685
+ child.kill();
1686
+ } catch {
1687
+ }
1688
+ finish({ success: false, exitCode: null, error });
1689
+ return;
1690
+ }
1691
+ emit("status", `prompt[${prompt.kind}]: ${prompt.prompt.slice(0, 160)}`, {
1692
+ data: { kind: prompt.kind, options: prompt.options }
1693
+ });
1694
+ const req = {
1695
+ kind: prompt.kind,
1696
+ prompt: prompt.prompt,
1697
+ task: opts.task,
1698
+ options: prompt.options,
1699
+ multiSelect: prompt.multiSelect,
1700
+ checked: prompt.checked,
1701
+ context: context(),
1702
+ cwd: opts.cwd,
1703
+ agent: opts.agent
1704
+ };
1705
+ let decision;
1706
+ try {
1707
+ decision = await opts.decider.decide(req);
1708
+ } catch (err) {
1709
+ decision = {
1710
+ action: "deny",
1711
+ reason: `decider error: ${err.message}`
1712
+ };
1713
+ }
1714
+ if (finished) return;
1715
+ emit(
1716
+ "tool_call",
1717
+ `decision: ${decision.action}${decision.reason ? ` (${decision.reason})` : ""}`,
1718
+ { data: decision }
1719
+ );
1720
+ if (decision.action === "abort") {
1721
+ const error = {
1722
+ message: `Decider aborted the run${decision.reason ? `: ${decision.reason}` : ""}.`,
1723
+ code: "DECIDER_ABORT"
1724
+ };
1725
+ try {
1726
+ child.kill();
1727
+ } catch {
1728
+ }
1729
+ finish({ success: false, exitCode: null, error });
1730
+ return;
1731
+ }
1732
+ const keys = keymap.keysFor(decision, prompt);
1733
+ if (keys != null) {
1734
+ try {
1735
+ child.write(keys);
1736
+ emit("tool_result", `sent response for ${prompt.kind}`);
1737
+ } catch {
1738
+ }
1739
+ }
1740
+ if (decision.action === "deny" && decision.text) {
1741
+ pendingComment = decision.text;
1742
+ }
1743
+ lastSig = prompt.signature;
1744
+ bytesSinceHandled = 0;
1745
+ buffer = "";
1746
+ } finally {
1747
+ handling = false;
1748
+ if (!finished && !quitting) armSettle();
1749
+ }
1750
+ };
1751
+ emit("start", `[pty] ${opts.command} ${opts.args.join(" ")}`.trim());
1752
+ disposeData = child.onData((data) => {
1753
+ buffer = (buffer + data).slice(-65536);
1754
+ const cleaned = cleanTerminalText(data).trim();
1755
+ if (!cleaned) return;
1756
+ bytesSinceHandled += cleaned.length;
1757
+ if (lastSig && bytesSinceHandled > 40) lastSig = "";
1758
+ emit("stdout", cleaned, { raw: data });
1759
+ armSettle();
1760
+ });
1761
+ disposeExit = child.onExit(({ exitCode }) => {
1762
+ const success = exitCode === 0;
1763
+ const error = success ? void 0 : {
1764
+ message: `${opts.agent ?? opts.command} exited with code ${exitCode}.`,
1765
+ code: "NON_ZERO_EXIT"
1766
+ };
1767
+ emit("complete", void 0, { data: { exitCode, interactions } });
1768
+ finish({
1769
+ success,
1770
+ exitCode,
1771
+ error,
1772
+ sessionRef: void 0,
1773
+ meta: { interactions, settled }
1774
+ });
1775
+ });
1776
+ if (ctx.signal.aborted) onAbort();
1777
+ else ctx.signal.addEventListener("abort", onAbort, { once: true });
1778
+ if (opts.initialInput) {
1779
+ const t = setTimeout(() => {
1780
+ try {
1781
+ child.write(`${opts.initialInput}${ENTER}`);
1782
+ } catch {
1783
+ }
1784
+ }, opts.initialDelayMs ?? 800);
1785
+ t.unref?.();
1786
+ }
1787
+ });
1788
+ }
1789
+
1790
+ // src/adapters/interactive/interactive-adapter.ts
1791
+ var InteractivePtyAdapter = class {
1792
+ definition;
1793
+ cfg;
1794
+ detector;
1795
+ constructor(cfg) {
1796
+ this.cfg = cfg;
1797
+ this.definition = cfg.definition;
1798
+ this.detector = new PromptDetector(cfg.detector);
1799
+ }
1800
+ async isAvailable() {
1801
+ const resolved = await which(this.cfg.command);
1802
+ if (resolved) return { ok: true, version: resolved };
1803
+ return {
1804
+ ok: false,
1805
+ reason: `Command "${this.cfg.command}" was not found on your PATH.`,
1806
+ hint: this.cfg.installHint
1807
+ };
1808
+ }
1809
+ buildArgs(input) {
1810
+ const args = [...this.cfg.preArgs(input)];
1811
+ if (input.extraArgs?.length) args.push(...input.extraArgs);
1812
+ if ((this.cfg.promptMode ?? "arg") === "type") {
1813
+ return { args, initialInput: input.prompt };
1814
+ }
1815
+ args.push(input.prompt);
1816
+ return { args };
1817
+ }
1818
+ describeCommand(input) {
1819
+ const setup = this.cfg.setup?.(input);
1820
+ let args;
1821
+ if (setup && setup.length) {
1822
+ args = [...this.cfg.preArgs(input)];
1823
+ if (input.extraArgs?.length) args.push(...input.extraArgs);
1824
+ } else {
1825
+ ({ args } = this.buildArgs(input));
1826
+ }
1827
+ const shown = args.map((a) => /\s/.test(a) ? JSON.stringify(a) : a).join(" ");
1828
+ return {
1829
+ mode: this.definition.mode,
1830
+ command: this.cfg.command,
1831
+ args,
1832
+ display: `[pty] ${this.cfg.command} ${shown}`.trim()
1833
+ };
1834
+ }
1835
+ run(input, ctx) {
1836
+ const setup = this.cfg.setup?.(input);
1837
+ if (setup && setup.length) {
1838
+ const args2 = [...this.cfg.preArgs(input)];
1839
+ if (input.extraArgs?.length) args2.push(...input.extraArgs);
1840
+ return this.spawn(args2, void 0, input, ctx, {
1841
+ setup,
1842
+ promptAfterSetup: input.prompt
1843
+ });
1844
+ }
1845
+ const { args, initialInput } = this.buildArgs(input);
1846
+ return this.spawn(args, initialInput, input, ctx);
1847
+ }
1848
+ resume(ref, input, ctx) {
1849
+ if (!this.cfg.resumeArgs) {
1850
+ return Promise.resolve({
1851
+ success: false,
1852
+ exitCode: null,
1853
+ error: {
1854
+ message: `The ${this.definition.name} adapter cannot resume in PTY mode.`,
1855
+ code: "RESUME_UNSUPPORTED"
1856
+ }
1857
+ });
1858
+ }
1859
+ const args = [...this.cfg.resumeArgs(input, ref)];
1860
+ if (input.extraArgs?.length) args.push(...input.extraArgs);
1861
+ const typesPrompt = typeof this.cfg.resumeTypesPrompt === "function" ? this.cfg.resumeTypesPrompt(input, ref) : this.cfg.resumeTypesPrompt ?? false;
1862
+ if (typesPrompt) {
1863
+ return this.spawn(args, void 0, input, ctx, {
1864
+ setup: this.cfg.resumeWarmupMs ? [{ send: "", waitMs: this.cfg.resumeWarmupMs }] : void 0,
1865
+ promptAfterSetup: input.prompt
1866
+ });
1867
+ }
1868
+ let initialInput;
1869
+ if ((this.cfg.promptMode ?? "arg") === "type") initialInput = input.prompt;
1870
+ else args.push(input.prompt);
1871
+ return this.spawn(args, initialInput, input, ctx);
1872
+ }
1873
+ spawn(args, initialInput, input, ctx, extra) {
1874
+ const decider = ctx.decider ?? new RuleDecider();
1875
+ return runPtySession(
1876
+ {
1877
+ command: this.cfg.command,
1878
+ args,
1879
+ cwd: input.cwd,
1880
+ env: this.cfg.env,
1881
+ decider,
1882
+ detector: this.detector,
1883
+ keymap: this.cfg.keymap,
1884
+ completionPattern: this.cfg.completionPattern,
1885
+ completionIdleMs: input.completionIdleMs ?? this.cfg.completionIdleMs,
1886
+ workingPattern: this.cfg.workingPattern,
1887
+ quitKeys: this.cfg.quitKeys,
1888
+ setup: extra?.setup,
1889
+ promptAfterSetup: extra?.promptAfterSetup,
1890
+ idleMs: this.cfg.idleMs,
1891
+ agent: this.definition.name,
1892
+ task: input.prompt,
1893
+ maxInteractions: input.maxTurns,
1894
+ initialInput,
1895
+ installHint: this.cfg.installHint,
1896
+ now: this.cfg.now
1897
+ },
1898
+ ctx
1899
+ );
1900
+ }
1901
+ };
1902
+
1903
+ // src/adapters/interactive/claude-interactive.ts
1904
+ var DEFINITION2 = {
1905
+ name: "claude",
1906
+ type: "claude",
1907
+ mode: "pty",
1908
+ description: "Claude Code driven interactively in a PTY; permission prompts answered by the decider.",
1909
+ supportsResume: true
1910
+ };
1911
+ var ClaudeInteractiveAdapter = class _ClaudeInteractiveAdapter extends InteractivePtyAdapter {
1912
+ constructor(opts = {}) {
1913
+ super({
1914
+ definition: DEFINITION2,
1915
+ command: opts.command ?? "claude",
1916
+ // Enable Claude Code's experimental agent-teams feature by default; a
1917
+ // config `adapters.claude.env` can override (e.g. set it to "0").
1918
+ env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1", ...opts.env },
1919
+ now: opts.now,
1920
+ installHint: "Install Claude Code (`npm i -g @anthropic-ai/claude-code`), authenticate (run `claude` once), and ensure `claude` is on your PATH.",
1921
+ preArgs: (input) => {
1922
+ const effort = ["--effort", "xhigh"];
1923
+ const model = input.ultracode ? ["--model", "opus"] : [];
1924
+ const head = [...model, ...effort];
1925
+ if (input.approvalMode === "readonly")
1926
+ return [...head, "--permission-mode", "plan"];
1927
+ if (input.approvalMode === "gated")
1928
+ return [...head, "--permission-mode", "acceptEdits"];
1929
+ return [...head, "--dangerously-skip-permissions"];
1930
+ },
1931
+ // Resume the most recent conversation in the cwd and send the follow-up
1932
+ // prompt. (`--continue` picks the latest session; the native id is not
1933
+ // captured in PTY mode, so a specific `--resume <id>` is not used.)
1934
+ resumeArgs: (input) => {
1935
+ const mode = input.approvalMode === "readonly" ? ["--permission-mode", "plan"] : input.approvalMode === "gated" ? ["--permission-mode", "acceptEdits"] : ["--dangerously-skip-permissions"];
1936
+ return ["--continue", "--effort", "xhigh", ...mode];
1937
+ },
1938
+ // Unattended ultracode: once Claude is idle (past the trust dialog), type
1939
+ // `/effort ultracode`, wait for its animated picker to apply and revert,
1940
+ // THEN the task is typed. Needs Opus (set in preArgs). Verified end-to-end.
1941
+ setup: (input) => input.ultracode ? [{ send: "/effort ultracode", waitMs: 9e3 }] : void 0,
1942
+ detector: {
1943
+ // Substring keywords (TUI text runs words together after ANSI strip)
1944
+ // anchored to a nearby '?' or [y/n] to avoid false positives on prose.
1945
+ // Most Claude permission prompts are numbered menus, caught as choices.
1946
+ approvalPattern: /(allow|grant|permission|approve|trust|proceed|do you want)[^\n]{0,60}\?|\[y\/n\]|\(y\/n\)/i
1947
+ },
1948
+ keymap: new ArrowKeymap(),
1949
+ // Claude's TUI stays open after a task; quit once it's been idle a while.
1950
+ completionIdleMs: 8e3,
1951
+ // While Claude is ACTIVELY working it shows "(esc to interrupt)" — that is
1952
+ // the reliable, verb-INDEPENDENT "still working" signal. Do NOT match the
1953
+ // thinking-verb itself: Claude rotates whimsical gerunds (Cogitating,
1954
+ // Beaming, Pondering…) and its DONE summary keeps the PAST tense of the same
1955
+ // verb on screen ("Cogitated for 7s"), so a stem like /cogitat/ stays matched
1956
+ // after the task finishes — completion never fires and the run hangs to the
1957
+ // idle timeout. "interrupt" appears only in the active footer, never on the
1958
+ // idle prompt, so it cleanly distinguishes working vs done.
1959
+ workingPattern: /interrupt/i,
1960
+ quitKeys: ""
1961
+ });
1962
+ }
1963
+ static fromConfig(config) {
1964
+ return new _ClaudeInteractiveAdapter({
1965
+ command: config.command ?? "claude",
1966
+ env: config.env
1967
+ });
1968
+ }
1969
+ };
1970
+ var ROLLOUT_UUID_RE = /-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$/;
1971
+ async function realish(p) {
1972
+ try {
1973
+ return await promises.realpath(p);
1974
+ } catch {
1975
+ return p;
1976
+ }
1977
+ }
1978
+ async function listRollouts(root) {
1979
+ const out = [];
1980
+ let years;
1981
+ try {
1982
+ years = await promises.readdir(root);
1983
+ } catch {
1984
+ return out;
1985
+ }
1986
+ for (const y of years) {
1987
+ const yDir = path7.join(root, y);
1988
+ let months;
1989
+ try {
1990
+ months = await promises.readdir(yDir);
1991
+ } catch {
1992
+ continue;
1993
+ }
1994
+ for (const m of months) {
1995
+ const mDir = path7.join(yDir, m);
1996
+ let days;
1997
+ try {
1998
+ days = await promises.readdir(mDir);
1999
+ } catch {
2000
+ continue;
2001
+ }
2002
+ for (const d of days) {
2003
+ const dDir = path7.join(mDir, d);
2004
+ let files;
2005
+ try {
2006
+ files = await promises.readdir(dDir);
2007
+ } catch {
2008
+ continue;
2009
+ }
2010
+ for (const f of files) {
2011
+ if (!f.startsWith("rollout-") || !f.endsWith(".jsonl")) continue;
2012
+ const file = path7.join(dDir, f);
2013
+ try {
2014
+ const st = await promises.stat(file);
2015
+ out.push({ file, mtimeMs: st.mtimeMs });
2016
+ } catch {
2017
+ }
2018
+ }
2019
+ }
2020
+ }
2021
+ }
2022
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
2023
+ return out;
2024
+ }
2025
+ async function readSessionMeta(file) {
2026
+ let text;
2027
+ try {
2028
+ text = await promises.readFile(file, "utf8");
2029
+ } catch {
2030
+ return void 0;
2031
+ }
2032
+ const firstLine = text.split("\n", 1)[0];
2033
+ if (!firstLine) return void 0;
2034
+ try {
2035
+ const rec = JSON.parse(firstLine);
2036
+ if (rec.type !== "session_meta") return void 0;
2037
+ const id = typeof rec.payload?.id === "string" ? rec.payload.id : void 0;
2038
+ const cwd = typeof rec.payload?.cwd === "string" ? rec.payload.cwd : void 0;
2039
+ return { id, cwd };
2040
+ } catch {
2041
+ return void 0;
2042
+ }
2043
+ }
2044
+ function uuidFromRolloutFilename(file) {
2045
+ const m = ROLLOUT_UUID_RE.exec(path7.basename(file));
2046
+ return m?.[1];
2047
+ }
2048
+ async function findLatestCodexSessionId(opts) {
2049
+ const root = opts.sessionsDir ?? path7.join(os.homedir(), ".codex", "sessions");
2050
+ const since = opts.sinceMs ?? 0;
2051
+ const targetCwd = await realish(opts.cwd);
2052
+ const rollouts = await listRollouts(root);
2053
+ for (const { file, mtimeMs } of rollouts) {
2054
+ if (mtimeMs + 2e3 < since) continue;
2055
+ const meta = await readSessionMeta(file);
2056
+ if (!meta?.cwd) continue;
2057
+ const metaCwd = await realish(meta.cwd);
2058
+ if (metaCwd !== targetCwd) continue;
2059
+ const id = meta.id ?? uuidFromRolloutFilename(file);
2060
+ if (id) return id;
2061
+ }
2062
+ return void 0;
2063
+ }
2064
+
2065
+ // src/adapters/interactive/codex-interactive.ts
2066
+ var DEFINITION3 = {
2067
+ name: "codex",
2068
+ type: "codex",
2069
+ mode: "pty",
2070
+ description: "Codex CLI driven interactively in a PTY; approvals answered by the decider.",
2071
+ supportsResume: true
2072
+ };
2073
+ var CodexInteractiveAdapter = class _CodexInteractiveAdapter extends InteractivePtyAdapter {
2074
+ clock;
2075
+ /** Override the sessions root (~/.codex/sessions) for tests. */
2076
+ sessionsDir;
2077
+ constructor(opts = {}) {
2078
+ super({
2079
+ definition: DEFINITION3,
2080
+ command: opts.command ?? "codex",
2081
+ env: opts.env,
2082
+ now: opts.now,
2083
+ installHint: "Install the Codex CLI (`npm i -g @openai/codex`), run `codex login`, and ensure `codex` is on your PATH.",
2084
+ preArgs: (input) => {
2085
+ const sandbox = input.approvalMode === "readonly" ? "read-only" : input.sandbox ?? "workspace-write";
2086
+ const approval = input.approvalMode === "gated" ? "on-request" : "never";
2087
+ return ["-s", sandbox, "-a", approval];
2088
+ },
2089
+ // Resume by Codex's NATIVE session id (captured in run(), below) so the
2090
+ // follow-up prompt rides along as a normal positional arg:
2091
+ // `codex resume <SESSION_ID> "<prompt>"` (clap binds 1st->SESSION_ID,
2092
+ // 2nd->PROMPT). When no id was captured (e.g. a session predating this
2093
+ // capture), fall back to `resume --last` and TYPE the prompt instead, since
2094
+ // `codex resume --last "<prompt>"` would misparse the prompt as SESSION_ID.
2095
+ resumeArgs: (_input, ref) => ref.nativeSessionId ? ["resume", ref.nativeSessionId] : ["resume", "--last"],
2096
+ // Pass the prompt as an arg when we have a native id; only type it on the
2097
+ // legacy `--last` fallback (no id) to avoid the `--last "<prompt>"` misparse.
2098
+ resumeTypesPrompt: (_input, ref) => !ref.nativeSessionId,
2099
+ // Codex reloads MCP servers on resume; on the typed fallback, wait for that
2100
+ // before typing the prompt. (Unused on the native-id arg path.)
2101
+ resumeWarmupMs: 12e3,
2102
+ detector: {
2103
+ // Substring keywords (TUI collapses spaces) anchored to a nearby '?'
2104
+ // or [y/n] affordance to avoid false positives on benign output.
2105
+ approvalPattern: /(allow|approve|run this|apply|patch|grant|permission|trust|do you want)[^\n]{0,60}\?|\[y\/n\]/i
2106
+ },
2107
+ keymap: new ArrowKeymap(),
2108
+ // Codex's TUI stays open after a task; quit once it's been idle a while.
2109
+ // Tune per-run with --completion-idle-ms / config defaults.completionIdleMs.
2110
+ completionIdleMs: 8e3,
2111
+ // While Codex is working it shows an "(esc to interrupt)" indicator — match
2112
+ // only that, not the thinking-verb (whose past tense can linger after the
2113
+ // task finishes and wrongly suppress completion; see claude-interactive).
2114
+ workingPattern: /interrupt/i,
2115
+ quitKeys: ""
2116
+ });
2117
+ this.clock = opts.now ?? (() => /* @__PURE__ */ new Date());
2118
+ this.sessionsDir = opts.sessionsDir;
2119
+ }
2120
+ /**
2121
+ * Run Codex, then capture its NATIVE session id (the rollout UUID) for this
2122
+ * cwd and attach it to the result's `sessionRef` so the runner persists it and
2123
+ * a later resume can use `codex resume <id> "<prompt>"`. Capture is best-effort:
2124
+ * if no rollout matches (or any I/O fails) the result is returned unchanged, so
2125
+ * the run still resumes via the `--last` fallback.
2126
+ */
2127
+ async run(input, ctx) {
2128
+ const startedAt = this.clock().getTime();
2129
+ const result = await super.run(input, ctx);
2130
+ if (result.error) return result;
2131
+ const nativeSessionId = await findLatestCodexSessionId({
2132
+ cwd: input.cwd,
2133
+ sinceMs: startedAt,
2134
+ sessionsDir: this.sessionsDir
2135
+ });
2136
+ if (!nativeSessionId) return result;
2137
+ return {
2138
+ ...result,
2139
+ sessionRef: {
2140
+ adapter: this.definition.name,
2141
+ nativeSessionId,
2142
+ resumable: true
2143
+ }
2144
+ };
2145
+ }
2146
+ static fromConfig(config) {
2147
+ return new _CodexInteractiveAdapter({
2148
+ command: config.command ?? "codex",
2149
+ env: config.env
2150
+ });
2151
+ }
2152
+ };
2153
+
2154
+ // src/adapters/registry.ts
2155
+ var BUILTIN_ADAPTER_DEFINITIONS = [
2156
+ new ClaudeInteractiveAdapter().definition,
2157
+ new CodexInteractiveAdapter().definition,
2158
+ new FakeAgentAdapter().definition
2159
+ ];
2160
+ var AdapterRegistry = class {
2161
+ builders = /* @__PURE__ */ new Map();
2162
+ constructor() {
2163
+ this.register("claude", (c) => ClaudeInteractiveAdapter.fromConfig(c));
2164
+ this.register("codex", (c) => CodexInteractiveAdapter.fromConfig(c));
2165
+ this.register("fake", () => new FakeAgentAdapter());
2166
+ }
2167
+ register(type, builder) {
2168
+ this.builders.set(type, builder);
2169
+ }
2170
+ has(type) {
2171
+ return this.builders.has(type);
2172
+ }
2173
+ /** List the adapter types this registry can build. */
2174
+ types() {
2175
+ return [...this.builders.keys()];
2176
+ }
2177
+ /** Build an adapter from a config entry, by its `type`. */
2178
+ build(config) {
2179
+ const builder = this.builders.get(config.type);
2180
+ if (!builder) {
2181
+ throw new UnknownAdapterError(config.type, this.types());
2182
+ }
2183
+ return builder(config);
2184
+ }
2185
+ /** Resolve an adapter by its config name within a {@link RelayConfig}. */
2186
+ resolveByName(name, config) {
2187
+ const entry = config.adapters[name];
2188
+ if (!entry) {
2189
+ throw new UnknownAdapterError(name, Object.keys(config.adapters));
2190
+ }
2191
+ return this.build(entry);
2192
+ }
2193
+ };
2194
+ var defaultRegistry = new AdapterRegistry();
2195
+ function createAdapterFactory(registry = defaultRegistry) {
2196
+ return (name, config) => registry.resolveByName(name, config);
2197
+ }
2198
+ async function dirExists(p) {
2199
+ try {
2200
+ const stat = await promises.stat(p);
2201
+ return stat.isDirectory();
2202
+ } catch {
2203
+ return false;
2204
+ }
2205
+ }
2206
+ async function runInit(options) {
2207
+ const rootDir = path7.resolve(options.rootDir);
2208
+ const cfgPath = configPath(rootDir);
2209
+ let configCreated = false;
2210
+ let configExists = false;
2211
+ try {
2212
+ await promises.access(cfgPath);
2213
+ configExists = true;
2214
+ } catch {
2215
+ configExists = false;
2216
+ }
2217
+ if (!configExists || options.force) {
2218
+ await saveConfig(rootDir, createDefaultConfig());
2219
+ configCreated = true;
2220
+ }
2221
+ const config = await loadConfig(rootDir);
2222
+ const sessionsDir = path7.resolve(rootDir, config.sessionsDir);
2223
+ const logsDir = path7.resolve(rootDir, config.logsDir);
2224
+ const createdDirs = [];
2225
+ for (const dir of [sessionsDir, logsDir]) {
2226
+ if (!await dirExists(dir)) {
2227
+ await promises.mkdir(dir, { recursive: true });
2228
+ createdDirs.push(dir);
2229
+ }
2230
+ }
2231
+ return {
2232
+ configPath: cfgPath,
2233
+ configCreated,
2234
+ sessionsDir,
2235
+ logsDir,
2236
+ createdDirs
2237
+ };
2238
+ }
2239
+
2240
+ // src/commands/adapters.ts
2241
+ function listAdapters(config) {
2242
+ const byType = new Map(
2243
+ BUILTIN_ADAPTER_DEFINITIONS.map((d) => [d.type, d])
2244
+ );
2245
+ const items = [];
2246
+ const seenTypes = /* @__PURE__ */ new Set();
2247
+ if (config) {
2248
+ for (const [name, entry] of Object.entries(config.adapters)) {
2249
+ const def = byType.get(entry.type);
2250
+ items.push({
2251
+ name,
2252
+ type: entry.type,
2253
+ mode: entry.mode,
2254
+ description: def?.description ?? `Custom "${entry.type}" adapter.`,
2255
+ supportsResume: def?.supportsResume ?? false,
2256
+ configured: true,
2257
+ command: entry.command
2258
+ });
2259
+ seenTypes.add(entry.type);
2260
+ }
2261
+ }
2262
+ for (const def of BUILTIN_ADAPTER_DEFINITIONS) {
2263
+ if (seenTypes.has(def.type)) continue;
2264
+ items.push({
2265
+ name: def.name,
2266
+ type: def.type,
2267
+ mode: def.mode,
2268
+ description: def.description,
2269
+ supportsResume: def.supportsResume,
2270
+ configured: false
2271
+ });
2272
+ }
2273
+ return items;
2274
+ }
2275
+ var MIN_NODE = { major: 18, minor: 19 };
2276
+ function checkNode() {
2277
+ const version = process.versions.node;
2278
+ const [major = 0, minor = 0] = version.split(".").map((n) => Number(n));
2279
+ const okVer = major > MIN_NODE.major || major === MIN_NODE.major && minor >= MIN_NODE.minor;
2280
+ return {
2281
+ name: "node",
2282
+ level: okVer ? "ok" : "error",
2283
+ detail: `Node.js ${version} (require >= ${MIN_NODE.major}.${MIN_NODE.minor})`,
2284
+ hint: okVer ? void 0 : "Upgrade Node.js to a supported version."
2285
+ };
2286
+ }
2287
+ async function dirCheck(name, dir) {
2288
+ try {
2289
+ const stat = await promises.stat(dir);
2290
+ if (stat.isDirectory()) {
2291
+ return { name, level: "ok", detail: `${dir} exists` };
2292
+ }
2293
+ return { name, level: "error", detail: `${dir} is not a directory` };
2294
+ } catch {
2295
+ return {
2296
+ name,
2297
+ level: "warn",
2298
+ detail: `${dir} does not exist yet`,
2299
+ hint: "Run `agent-relay init` (or any `run`) to create it."
2300
+ };
2301
+ }
2302
+ }
2303
+ async function commandCheck(name, command, installHint) {
2304
+ const resolved = await which(command);
2305
+ if (resolved) {
2306
+ return { name, level: "ok", detail: `${command} found at ${resolved}` };
2307
+ }
2308
+ return {
2309
+ name,
2310
+ level: "warn",
2311
+ detail: `${command} not found on PATH`,
2312
+ hint: installHint
2313
+ };
2314
+ }
2315
+ async function runDoctor(options) {
2316
+ const rootDir = path7.resolve(options.rootDir);
2317
+ const checks = [];
2318
+ checks.push(checkNode());
2319
+ let sessionsDir = path7.resolve(rootDir, ".agent-relay/sessions");
2320
+ let logsDir = path7.resolve(rootDir, ".agent-relay/logs");
2321
+ try {
2322
+ const config = await loadConfig(rootDir);
2323
+ checks.push({
2324
+ name: "config",
2325
+ level: "ok",
2326
+ detail: `Valid config (defaultAdapter: ${config.defaultAdapter})`
2327
+ });
2328
+ sessionsDir = path7.resolve(rootDir, config.sessionsDir);
2329
+ logsDir = path7.resolve(rootDir, config.logsDir);
2330
+ } catch (err) {
2331
+ const message = err instanceof Error ? err.message : String(err);
2332
+ const missing = message.includes("not found");
2333
+ checks.push({
2334
+ name: "config",
2335
+ level: missing ? "warn" : "error",
2336
+ detail: missing ? "No config file \u2014 built-in defaults will be used" : `Invalid config: ${message}`,
2337
+ hint: missing ? "Optional: run `agent-relay init` to customize adapters/decider/dirs." : "Run `agent-relay init` to regenerate a valid config, or fix the listed fields."
2338
+ });
2339
+ }
2340
+ checks.push(await dirCheck("sessionsDir", sessionsDir));
2341
+ checks.push(await dirCheck("logsDir", logsDir));
2342
+ checks.push(
2343
+ await commandCheck(
2344
+ "claude",
2345
+ "claude",
2346
+ "Install Claude Code (`npm i -g @anthropic-ai/claude-code`) to use the claude adapter."
2347
+ )
2348
+ );
2349
+ checks.push(
2350
+ await commandCheck(
2351
+ "codex",
2352
+ "codex",
2353
+ "Install the Codex CLI (`npm i -g @openai/codex`) to use the codex adapter."
2354
+ )
2355
+ );
2356
+ const ok = !checks.some((c) => c.level === "error");
2357
+ return { checks, ok };
2358
+ }
2359
+ function deciderConfigFromFlags(flags) {
2360
+ const anyGiven = flags.decider != null || flags.deciderCommand != null || flags.deciderUrl != null || flags.deciderModel != null || flags.deciderKey != null || flags.deciderMaxTokens != null || flags.deciderTimeoutMs != null;
2361
+ if (!anyGiven) return void 0;
2362
+ const type = flags.decider ?? (flags.deciderUrl ? "api" : flags.deciderCommand ? "command" : "rule");
2363
+ const raw = { type };
2364
+ if (type === "command") {
2365
+ if (!flags.deciderCommand) {
2366
+ throw new AgentRelayError(
2367
+ '--decider command requires --decider-command "<cmd ...>".',
2368
+ "INVALID_ARGS"
2369
+ );
2370
+ }
2371
+ const parts = flags.deciderCommand.trim().split(/\s+/);
2372
+ raw.command = parts[0];
2373
+ if (parts.length > 1) raw.args = parts.slice(1);
2374
+ }
2375
+ if (type === "api") {
2376
+ if (!flags.deciderUrl) {
2377
+ throw new AgentRelayError(
2378
+ "--decider api requires --decider-url <url>.",
2379
+ "INVALID_ARGS"
2380
+ );
2381
+ }
2382
+ raw.url = flags.deciderUrl;
2383
+ if (flags.deciderModel) raw.model = flags.deciderModel;
2384
+ if (flags.deciderKey) raw.apiKey = flags.deciderKey;
2385
+ if (flags.deciderMaxTokens) raw.maxTokens = flags.deciderMaxTokens;
2386
+ }
2387
+ if (flags.deciderTimeoutMs) raw.timeoutMs = flags.deciderTimeoutMs;
2388
+ return parseDeciderConfig(raw);
2389
+ }
2390
+ async function resolvePrompt(options) {
2391
+ if (options.prompt && options.promptFile) {
2392
+ throw new AgentRelayError(
2393
+ "Provide either --prompt or --prompt-file, not both.",
2394
+ "INVALID_ARGS"
2395
+ );
2396
+ }
2397
+ if (options.promptFile) {
2398
+ const file = path7.resolve(options.rootDir, options.promptFile);
2399
+ try {
2400
+ const text = await promises.readFile(file, "utf8");
2401
+ if (!text.trim()) {
2402
+ throw new AgentRelayError(
2403
+ `Prompt file ${file} is empty.`,
2404
+ "EMPTY_PROMPT"
2405
+ );
2406
+ }
2407
+ return text;
2408
+ } catch (err) {
2409
+ if (err instanceof AgentRelayError) throw err;
2410
+ throw new AgentRelayError(
2411
+ `Could not read prompt file ${file}: ${err.message}`,
2412
+ "PROMPT_FILE_ERROR"
2413
+ );
2414
+ }
2415
+ }
2416
+ if (options.prompt && options.prompt.trim()) {
2417
+ return options.prompt;
2418
+ }
2419
+ throw new AgentRelayError(
2420
+ 'A prompt is required. Pass --prompt "..." or --prompt-file ./task.md.',
2421
+ "MISSING_PROMPT"
2422
+ );
2423
+ }
2424
+ async function runCommand(options) {
2425
+ const rootDir = path7.resolve(options.rootDir);
2426
+ const config = options.config ?? await loadConfigOrDefault(rootDir, options.onDefaultConfig);
2427
+ const prompt = await resolvePrompt({
2428
+ prompt: options.prompt,
2429
+ promptFile: options.promptFile,
2430
+ rootDir
2431
+ });
2432
+ const decider = options.deciderConfig ? createDecider(options.deciderConfig) : void 0;
2433
+ return runAgent({
2434
+ config,
2435
+ rootDir,
2436
+ adapterName: options.adapter,
2437
+ prompt,
2438
+ cwd: options.cwd,
2439
+ decider,
2440
+ maxTurns: options.maxTurns,
2441
+ timeoutMs: options.timeoutMs,
2442
+ idleTimeoutMs: options.idleTimeoutMs,
2443
+ completionIdleMs: options.completionIdleMs,
2444
+ approvalMode: options.approvalMode,
2445
+ ultracode: options.ultracode,
2446
+ dryRun: options.dryRun,
2447
+ extraArgs: options.extraArgs,
2448
+ verbose: options.verbose,
2449
+ maxLogBytes: options.maxLogBytes,
2450
+ resolveAdapter: options.resolveAdapter ?? createAdapterFactory(),
2451
+ onEvent: options.onEvent,
2452
+ installSignalHandlers: options.installSignalHandlers,
2453
+ now: options.now
2454
+ });
2455
+ }
2456
+ async function resumeCommand(options) {
2457
+ const rootDir = path7.resolve(options.rootDir);
2458
+ const config = options.config ?? await loadConfigOrDefault(rootDir, options.onDefaultConfig);
2459
+ const sessionsDir = path7.resolve(rootDir, config.sessionsDir);
2460
+ const sessions = new SessionManager(sessionsDir);
2461
+ const session = await sessions.load(options.sessionId);
2462
+ const resolveAdapter = options.resolveAdapter ?? createAdapterFactory();
2463
+ let resumable = false;
2464
+ try {
2465
+ const adapter = resolveAdapter(session.adapter, config);
2466
+ resumable = adapter.definition.supportsResume && Boolean(adapter.resume);
2467
+ } catch {
2468
+ resumable = false;
2469
+ }
2470
+ if (!options.prompt || !options.prompt.trim()) {
2471
+ return {
2472
+ session,
2473
+ resumable,
2474
+ resumed: false,
2475
+ reason: "No follow-up prompt provided; showing session metadata only."
2476
+ };
2477
+ }
2478
+ if (!resumable) {
2479
+ return {
2480
+ session,
2481
+ resumable,
2482
+ resumed: false,
2483
+ reason: `Adapter "${session.adapter}" does not support resume.`
2484
+ };
2485
+ }
2486
+ const outcome = await runAgent({
2487
+ config,
2488
+ rootDir,
2489
+ adapterName: session.adapter,
2490
+ prompt: options.prompt,
2491
+ cwd: session.cwd,
2492
+ maxTurns: options.maxTurns,
2493
+ timeoutMs: options.timeoutMs,
2494
+ idleTimeoutMs: options.idleTimeoutMs,
2495
+ completionIdleMs: options.completionIdleMs,
2496
+ decider: options.deciderConfig ? createDecider(options.deciderConfig) : void 0,
2497
+ verbose: options.verbose,
2498
+ maxLogBytes: options.maxLogBytes,
2499
+ // If we never captured a native id, resume with no id; the adapter falls
2500
+ // back to its "most recent session" behavior (codex --last / claude --continue).
2501
+ resume: session.sessionRef ?? { adapter: session.adapter, resumable: true },
2502
+ resolveAdapter,
2503
+ onEvent: options.onEvent,
2504
+ installSignalHandlers: options.installSignalHandlers,
2505
+ now: options.now
2506
+ });
2507
+ return { session, resumable, resumed: true, outcome };
2508
+ }
2509
+ async function resolveManager(opts) {
2510
+ const rootDir = path7.resolve(opts.rootDir);
2511
+ const config = await loadConfigOrDefault(rootDir, opts.onDefaultConfig);
2512
+ const sessionsDir = path7.resolve(rootDir, config.sessionsDir);
2513
+ const sessions = new SessionManager(sessionsDir);
2514
+ return { sessions, metas: await sessions.list() };
2515
+ }
2516
+ async function fileBytes(file) {
2517
+ try {
2518
+ return (await promises.stat(file)).size;
2519
+ } catch {
2520
+ return 0;
2521
+ }
2522
+ }
2523
+ async function listSessions(opts) {
2524
+ const { metas } = await resolveManager(opts);
2525
+ const items = [];
2526
+ let total = 0;
2527
+ for (const m of metas) {
2528
+ const logBytes = await fileBytes(m.logFile);
2529
+ total += logBytes;
2530
+ items.push({ ...m, logBytes });
2531
+ }
2532
+ return { items, totalLogBytes: total };
2533
+ }
2534
+ async function pruneSessions(opts) {
2535
+ if (opts.keep === void 0 && opts.olderThanDays === void 0 && !opts.all) {
2536
+ throw new AgentRelayError(
2537
+ "Pruning needs a filter: --keep <n>, --older-than <days>, or --all.",
2538
+ "INVALID_ARGS"
2539
+ );
2540
+ }
2541
+ const { sessions, metas } = await resolveManager(opts);
2542
+ const now = (opts.now ?? (() => /* @__PURE__ */ new Date()))();
2543
+ const doomed = /* @__PURE__ */ new Set();
2544
+ metas.forEach((m, i) => {
2545
+ if (opts.all) return doomed.add(m.sessionId);
2546
+ if (opts.keep !== void 0 && i >= opts.keep) doomed.add(m.sessionId);
2547
+ if (opts.olderThanDays !== void 0) {
2548
+ const ts = Date.parse(m.endedAt ?? m.startedAt);
2549
+ const ageDays = (now.getTime() - ts) / 864e5;
2550
+ if (Number.isFinite(ageDays) && ageDays > opts.olderThanDays) {
2551
+ doomed.add(m.sessionId);
2552
+ }
2553
+ }
2554
+ });
2555
+ let freed = 0;
2556
+ for (const m of metas) {
2557
+ if (!doomed.has(m.sessionId)) continue;
2558
+ freed += await fileBytes(m.logFile);
2559
+ await promises.rm(m.logFile, { force: true }).catch(() => {
2560
+ });
2561
+ await promises.rm(sessions.filePath(m.sessionId), { force: true }).catch(() => {
2562
+ });
2563
+ }
2564
+ return {
2565
+ deleted: doomed.size,
2566
+ freedBytes: freed,
2567
+ kept: metas.length - doomed.size
2568
+ };
2569
+ }
2570
+
2571
+ export { AdapterRegistry, AgentRelayError, AlwaysApproveDecider, ApiDecider, BUILTIN_ADAPTER_DEFINITIONS, CONFIG_FILENAME, ClaudeInteractiveAdapter, CodexInteractiveAdapter, CommandDecider, CompositeCompletionDetector, ConfigError, DEFAULT_DENY_PATTERNS, DefaultCompletionDetector, DefaultKeymap, FakeAgentAdapter, FunctionDecider, InteractivePtyAdapter, OutputPatternDetector, PromptDetector, RuleDecider, RunLogger, SessionManager, SessionNotFoundError, UnknownAdapterError, adapterConfigSchema, approvalPolicySchema, cleanTerminalText, configPath, configSchema, createAdapterFactory, createDecider, createDefaultConfig, deciderConfigFromFlags, deciderSchema, defaultRegistry, defaultsSchema, hooksSchema, listAdapters, listSessions, loadConfig, loadConfigOrDefault, parseCheckbox, parseConfig, parseDecisionReply, pruneSessions, renderDecisionPrompt, resolveApprovalMode, resolvePrompt, resolveSandbox, resumeCommand, runAgent, runCommand, runDoctor, runInit, runPtySession, runShellHook, sandboxSchema, saveConfig, stringifyConfig, stripAnsi, tailLines };