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