@agentswf/engine 0.1.0-beta.5 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +13 -9
- package/src/accounting/format.ts +4 -3
- package/src/accounting/spent.ts +128 -0
- package/src/accounting/summary.ts +40 -32
- package/src/agent-cli/cli.ts +28 -55
- package/src/agent-cli/client.ts +1 -27
- package/src/allowance-command.ts +8 -10
- package/src/attempt-ending.ts +4 -4
- package/src/attempt-output.ts +13 -31
- package/src/attempt-view.ts +33 -32
- package/src/author-typebox.ts +13 -0
- package/src/client/checks.ts +94 -0
- package/src/client/exported.ts +17 -0
- package/src/client/index.ts +122 -0
- package/src/client/locate.ts +34 -0
- package/src/client/published.ts +3 -0
- package/src/client/runs.ts +337 -0
- package/src/command-line.ts +64 -0
- package/src/commands.ts +7 -1
- package/src/control-plane.ts +4 -6
- package/src/coordinator-prompt.ts +4 -3
- package/src/coordinator-socket.ts +27 -28
- package/src/deadlines.ts +6 -40
- package/src/decisions/directory.ts +50 -37
- package/src/decisions/fake.ts +1 -1
- package/src/decisions/openrouter.ts +4 -2
- package/src/decisions/seam.ts +4 -4
- package/src/display-path.ts +3 -3
- package/src/duration.ts +19 -33
- package/src/emit-command.ts +38 -22
- package/src/emit.ts +148 -59
- package/src/event-ids.ts +31 -0
- package/src/files.ts +44 -4
- package/src/hand-over.ts +150 -0
- package/src/help/allowance.txt +4 -3
- package/src/help/emit.txt +11 -7
- package/src/help/inbox.txt +5 -3
- package/src/help/logs.txt +7 -5
- package/src/help/open.txt +29 -0
- package/src/help/reply.txt +6 -8
- package/src/help/run.txt +13 -8
- package/src/help/runs.txt +4 -2
- package/src/help/tick.txt +9 -4
- package/src/herdr-placement.ts +3 -33
- package/src/herdr-run-session.ts +5 -13
- package/src/herdr-workspace-marks.ts +7 -17
- package/src/holder-command.ts +54 -136
- package/src/in-lines.ts +28 -0
- package/src/inbox-command.ts +42 -56
- package/src/index.ts +1 -1
- package/src/init-command.ts +8 -17
- package/src/init-template/hello/workflow.ts.template +3 -2
- package/src/jsonl.ts +10 -6
- package/src/keep-alive.ts +7 -19
- package/src/logs-command.ts +134 -53
- package/src/message-operation.ts +6 -3
- package/src/messaging/messaging.ts +8 -10
- package/src/messaging/text.ts +2 -2
- package/src/open-command.ts +770 -0
- package/src/operation-events.ts +4 -6
- package/src/operation-liveness.ts +19 -4
- package/src/operator-cli.ts +206 -90
- package/src/operator-runtime.ts +24 -2
- package/src/ops.ts +270 -0
- package/src/pane-layout.ts +49 -96
- package/src/progress-view.ts +2 -2
- package/src/project-engine.ts +79 -0
- package/src/projects.ts +13 -2
- package/src/refusals.ts +119 -0
- package/src/result-slots.ts +11 -31
- package/src/result-validation.ts +2 -2
- package/src/run-command.ts +27 -31
- package/src/run-commands.ts +42 -114
- package/src/run-event-log.ts +1 -1
- package/src/run-lines.ts +159 -0
- package/src/run-lock.ts +57 -1
- package/src/run-policy/stage-plan.ts +154 -0
- package/src/run-policy/stages.ts +113 -0
- package/src/run-policy/waits.ts +452 -0
- package/src/run-prepare.ts +165 -41
- package/src/run-read.ts +109 -41
- package/src/run-state.ts +170 -50
- package/src/run-text.ts +54 -0
- package/src/run-unreachable.ts +18 -0
- package/src/run-usage.ts +17 -11
- package/src/run-view.ts +73 -14
- package/src/runs-command.ts +81 -63
- package/src/runs.ts +284 -222
- package/src/sandbox-homes.ts +65 -13
- package/src/sandboxes.ts +182 -28
- package/src/session-lines.ts +93 -0
- package/src/skills/run-skills.ts +8 -1
- package/src/sources.ts +2 -39
- package/src/spend.ts +333 -0
- package/src/stage-ledger.ts +111 -153
- package/src/stop-crashed.ts +6 -5
- package/src/stopped.ts +4 -4
- package/src/test-command.ts +37 -19
- package/src/testing/operation-events.ts +3 -23
- package/src/testing.ts +66 -18
- package/src/tick-command.ts +145 -15
- package/src/tools.ts +11 -30
- package/src/turn-ends.ts +37 -0
- package/src/waits.ts +651 -0
- package/src/wake.ts +84 -54
- package/src/workflow-loader.ts +49 -2
- package/src/workflow-runner.ts +525 -618
- package/src/workflow-test-preload.ts +4 -1
- package/src/workflow-testing/coordinators.ts +1 -1
- package/src/workflow-testing/fake-client.ts +387 -0
- package/src/workflow-testing/host.ts +28 -29
- package/src/workflow-testing/index.ts +9 -27
- package/src/workflow-testing/run-test.ts +201 -286
- package/src/workflow-testing/script.ts +7 -39
- package/src/deprecations.ts +0 -177
- package/src/hold-run-lock.fixture.ts +0 -8
- package/src/mailbox.ts +0 -972
- package/src/stage-plan.ts +0 -186
- package/src/workflow-testing/coordinator.typecheck.ts +0 -54
- package/src/workflow-testing/workflow-testing.typecheck.ts +0 -67
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
import { mkdtemp, readdir, realpath, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { join, relative, sep } from "node:path";
|
|
4
|
+
import type {
|
|
5
|
+
AgentOp,
|
|
6
|
+
OpenResultJson,
|
|
7
|
+
OpRecord,
|
|
8
|
+
RefusalCode,
|
|
9
|
+
RunView,
|
|
10
|
+
SessionSandbox,
|
|
11
|
+
} from "@agentswf/contract/records";
|
|
12
|
+
import {
|
|
13
|
+
type AgentSkills,
|
|
14
|
+
findHarness,
|
|
15
|
+
forkRecordedSession,
|
|
16
|
+
HARNESSES,
|
|
17
|
+
type Harness,
|
|
18
|
+
launchSettings,
|
|
19
|
+
OPERATOR_HOMES,
|
|
20
|
+
type RunProcess,
|
|
21
|
+
runAttached,
|
|
22
|
+
runProcess,
|
|
23
|
+
sandboxedArgs,
|
|
24
|
+
sandboxedOperatorArgs,
|
|
25
|
+
skillsLaunch,
|
|
26
|
+
} from "@agentswf/harness";
|
|
27
|
+
import type { SandboxProviders } from "@agentswf/sandbox";
|
|
28
|
+
import { installSandboxedDoor } from "./agent-launcher";
|
|
29
|
+
import { type Out, parsePlace, type ReadPlace, shellLine } from "./command-line";
|
|
30
|
+
import { messageOf } from "./errors";
|
|
31
|
+
import help from "./help/open.txt" with { type: "text" };
|
|
32
|
+
import { coordinatorDir } from "./machine";
|
|
33
|
+
import { listOps, opFile, readOpFile } from "./ops";
|
|
34
|
+
import { asksJson, printRefusal } from "./refusals";
|
|
35
|
+
import { failed } from "./run-commands";
|
|
36
|
+
import { readRunLines } from "./run-lines";
|
|
37
|
+
import {
|
|
38
|
+
type ClaimHolder,
|
|
39
|
+
claimNow,
|
|
40
|
+
releaseClaim,
|
|
41
|
+
runLockFile,
|
|
42
|
+
runOpenFile,
|
|
43
|
+
withRunLock,
|
|
44
|
+
} from "./run-lock";
|
|
45
|
+
import { findRunByRef, RunNotFound, type RunStatus } from "./run-state";
|
|
46
|
+
import { isLive, readAttempts, workDir } from "./runs";
|
|
47
|
+
import { refreshHome, type SeededHome } from "./sandbox-homes";
|
|
48
|
+
import { type ReopenedPlace, reopenSandbox } from "./sandboxes";
|
|
49
|
+
import { type Launch, wake } from "./wake";
|
|
50
|
+
|
|
51
|
+
export const openUsage = help.trimEnd();
|
|
52
|
+
|
|
53
|
+
type Environment = Readonly<Record<string, string | undefined>>;
|
|
54
|
+
type SessionLine = RunView["sessions"]["all"][number];
|
|
55
|
+
|
|
56
|
+
/** A harness's TUI, run on this terminal: its exit code once it ends. */
|
|
57
|
+
export type Attach = (input: {
|
|
58
|
+
argv: readonly string[];
|
|
59
|
+
cwd: string;
|
|
60
|
+
env: Record<string, string | undefined>;
|
|
61
|
+
/** A sandbox's command, which gets exactly `env` and nothing of this process's. */
|
|
62
|
+
exact?: true;
|
|
63
|
+
}) => Promise<number>;
|
|
64
|
+
|
|
65
|
+
export type OpenContext = {
|
|
66
|
+
home?: string;
|
|
67
|
+
environment?: Environment;
|
|
68
|
+
/** Runs a fork, or the turn `-p` asks; the operator runtime's withholds what no agent inherits. */
|
|
69
|
+
run?: RunProcess;
|
|
70
|
+
/** Runs the TUI on this terminal, withholding the same. */
|
|
71
|
+
attach?: Attach;
|
|
72
|
+
stdin?: () => Promise<string>;
|
|
73
|
+
/** Then nothing is piped in: `-p` without text is refused rather than waiting. */
|
|
74
|
+
stdinIsTerminal?: boolean;
|
|
75
|
+
/** How the wake at a held run's release starts its attempt; absent, as `awf tick` does. */
|
|
76
|
+
launch?: Launch;
|
|
77
|
+
/** The operator's sandbox providers, which reopen a sandboxed agent's place. */
|
|
78
|
+
providers?: () => Promise<SandboxProviders>;
|
|
79
|
+
/** The CLI's cancel, at SIGINT, SIGTERM or SIGHUP: ends a fork or a `-p` question. */
|
|
80
|
+
signal?: AbortSignal;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** What a sandboxed agent's `wfa` does in a reopened place: nothing reaches the run. */
|
|
84
|
+
const INERT_WFA = `console.error("wfa: this session was opened with awf open, outside its run; nothing reaches the run");
|
|
85
|
+
process.exit(1);
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* How long a person's TUI in a sandbox may stay open. A docker box lives as long, so one an `awf
|
|
90
|
+
* open` killed outright left behind is gone within a day.
|
|
91
|
+
*/
|
|
92
|
+
const TUI_TIMEOUT_MS = 24 * 60 * 60_000;
|
|
93
|
+
|
|
94
|
+
/** What a TUI in a sandbox is given of the operator's terminal, which a provider's environment drops. */
|
|
95
|
+
const TERMINAL_ENVIRONMENT = ["TERM", "COLORTERM", "LANG", "LC_ALL", "LC_CTYPE"];
|
|
96
|
+
|
|
97
|
+
type Options = {
|
|
98
|
+
ref: string;
|
|
99
|
+
agent: string;
|
|
100
|
+
/** `--no-fork`: the agent's own session, the run held while it is open. */
|
|
101
|
+
noFork: boolean;
|
|
102
|
+
session?: string;
|
|
103
|
+
/** `-p`: its text, or `""` for stdin. */
|
|
104
|
+
print?: string;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** A refusal with its code, its sentence the next step. */
|
|
108
|
+
class OpenRefused extends Error {
|
|
109
|
+
constructor(
|
|
110
|
+
message: string,
|
|
111
|
+
readonly code: RefusalCode,
|
|
112
|
+
) {
|
|
113
|
+
super(message);
|
|
114
|
+
this.name = "OpenRefused";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** How long one question asked with `-p` may take. */
|
|
119
|
+
const PRINT_TIMEOUT_MS = 60 * 60_000;
|
|
120
|
+
|
|
121
|
+
/** `awf open -p --json`'s `"v"`, which its refusals carry too. */
|
|
122
|
+
export const OPEN_V = 1 satisfies OpenResultJson["v"];
|
|
123
|
+
|
|
124
|
+
export async function openCommand(
|
|
125
|
+
argv: readonly string[],
|
|
126
|
+
shellCwd: string,
|
|
127
|
+
out: Out,
|
|
128
|
+
context: OpenContext = {},
|
|
129
|
+
): Promise<number> {
|
|
130
|
+
let place: ReadPlace;
|
|
131
|
+
let json: boolean;
|
|
132
|
+
let asked: Asked;
|
|
133
|
+
try {
|
|
134
|
+
const parsed = parsePlace(argv, shellCwd);
|
|
135
|
+
place = parsed.place;
|
|
136
|
+
json = parsed.json;
|
|
137
|
+
asked = parseOpen(parsed.rest);
|
|
138
|
+
if (json && asked.print === undefined)
|
|
139
|
+
throw new Error("a TUI has no JSON: --json goes with -p");
|
|
140
|
+
} catch (error) {
|
|
141
|
+
printRefusal(out, { json: asksJson(argv), v: OPEN_V }, error, "usage", openUsage);
|
|
142
|
+
return 2;
|
|
143
|
+
}
|
|
144
|
+
const refuse = (error: OpenRefused) => {
|
|
145
|
+
printRefusal(out, { json, v: OPEN_V }, error, error.code);
|
|
146
|
+
return error.code === "usage" ? 2 : error.code === "unreachable" ? 3 : 1;
|
|
147
|
+
};
|
|
148
|
+
const home = context.home ?? homedir();
|
|
149
|
+
const environment = context.environment ?? process.env;
|
|
150
|
+
try {
|
|
151
|
+
if (
|
|
152
|
+
asked.print === "" &&
|
|
153
|
+
(context.stdinIsTerminal ?? (context.stdin === undefined && process.stdin.isTTY === true))
|
|
154
|
+
)
|
|
155
|
+
throw new OpenRefused("nothing to ask: give -p its text, or pipe it in", "usage");
|
|
156
|
+
let status: RunStatus;
|
|
157
|
+
try {
|
|
158
|
+
status = await findRunByRef(asked.ref, place, home);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error instanceof RunNotFound)
|
|
161
|
+
return failed(error, place, out, error.message, { json, v: OPEN_V });
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
if (asked.agent === undefined) throw await agentNeeded(status, place);
|
|
165
|
+
const options: Options = { ...asked, agent: asked.agent };
|
|
166
|
+
const where = await agentPlace(status, options);
|
|
167
|
+
const spec = HARNESSES[where.harness];
|
|
168
|
+
const settings = launchSettings(where.execution);
|
|
169
|
+
const skills = where.skills
|
|
170
|
+
? await skillsLaunch(where.harness, where.skills, environment)
|
|
171
|
+
: undefined;
|
|
172
|
+
// On the host, a home of its own is pointed at here; in a sandbox, by the provider.
|
|
173
|
+
const env: Record<string, string> = {
|
|
174
|
+
...(where.home && !where.sandbox
|
|
175
|
+
? OPERATOR_HOMES.host(where.harness, where.home, environment).env
|
|
176
|
+
: {}),
|
|
177
|
+
...skills?.env,
|
|
178
|
+
};
|
|
179
|
+
const webSearch = where.sandbox?.spec.webSearch === true;
|
|
180
|
+
const launchArgs = (person: boolean) => [
|
|
181
|
+
...(where.sandbox
|
|
182
|
+
? (person ? sandboxedOperatorArgs : sandboxedArgs)(where.harness, webSearch)
|
|
183
|
+
: []),
|
|
184
|
+
...(skills?.args ?? []),
|
|
185
|
+
];
|
|
186
|
+
const turnContext = (sessionHint: string) => ({
|
|
187
|
+
...settings,
|
|
188
|
+
sessionHint,
|
|
189
|
+
launchArgs: launchArgs(false),
|
|
190
|
+
...(where.home ? { home: where.home } : {}),
|
|
191
|
+
});
|
|
192
|
+
const hostRun = context.run ?? runProcess;
|
|
193
|
+
if (options.noFork && where.copy)
|
|
194
|
+
throw new OpenRefused(
|
|
195
|
+
`${options.session} is not one of ${options.agent}'s sessions in ${status.ref}: a copy an earlier awf open made is yours already; open it without --no-fork`,
|
|
196
|
+
"usage",
|
|
197
|
+
);
|
|
198
|
+
// Every refusal comes before the copy: a refused open leaves nothing behind.
|
|
199
|
+
if (options.print !== undefined) {
|
|
200
|
+
if (!spec.resumeTurn)
|
|
201
|
+
throw new OpenRefused(
|
|
202
|
+
`${where.harness} cannot be asked headless: ${spec.absent.resumeTurn}`,
|
|
203
|
+
"usage",
|
|
204
|
+
);
|
|
205
|
+
if (spec.meteredHeadless && where.execution.metered !== true)
|
|
206
|
+
throw new OpenRefused(
|
|
207
|
+
`headless ${where.harness} is billed per token even on a subscription login, and ${options.agent} did not run metered: open it without -p`,
|
|
208
|
+
"usage",
|
|
209
|
+
);
|
|
210
|
+
} else if (!spec.operatorResume)
|
|
211
|
+
throw new OpenRefused(
|
|
212
|
+
`${where.harness} has no TUI a person can resume: ${spec.absent.operatorResume}`,
|
|
213
|
+
"usage",
|
|
214
|
+
);
|
|
215
|
+
const text =
|
|
216
|
+
options.print === undefined
|
|
217
|
+
? undefined
|
|
218
|
+
: options.print || (await (context.stdin ?? readStdin)());
|
|
219
|
+
if (text !== undefined && text.trim() === "")
|
|
220
|
+
throw new OpenRefused("nothing to ask: stdin was empty", "usage");
|
|
221
|
+
const held = options.noFork
|
|
222
|
+
? await holdRun(status, options.agent, place, where.sandbox !== undefined)
|
|
223
|
+
: undefined;
|
|
224
|
+
let box: ReopenedPlace | undefined;
|
|
225
|
+
let doors: string | undefined;
|
|
226
|
+
let boxClaim: { file: string; held: ClaimHolder } | undefined;
|
|
227
|
+
let ownHome: SeededHome | undefined;
|
|
228
|
+
let finished: Promise<void> | undefined;
|
|
229
|
+
// The sandbox closed first, then the run let go: an attempt the release wakes finds it closed.
|
|
230
|
+
const finish = () => {
|
|
231
|
+
finished ??= (async () => {
|
|
232
|
+
await ownHome
|
|
233
|
+
?.writeBack()
|
|
234
|
+
.catch((error: unknown) =>
|
|
235
|
+
out.stderr(`awf: ${options.agent}'s login was not handed back: ${messageOf(error)}`),
|
|
236
|
+
);
|
|
237
|
+
for (const failure of (await box?.close()) ?? [])
|
|
238
|
+
out.stderr(`awf: the sandbox did not close cleanly: ${failure}`);
|
|
239
|
+
if (doors) await rm(doors, { recursive: true, force: true });
|
|
240
|
+
if (boxClaim) await releaseClaim(boxClaim.file, boxClaim.held);
|
|
241
|
+
await held?.release(out, context);
|
|
242
|
+
})();
|
|
243
|
+
return finished;
|
|
244
|
+
};
|
|
245
|
+
try {
|
|
246
|
+
// Read again once held: an attempt that ended meanwhile may have recorded a newer session.
|
|
247
|
+
if (held && (await agentPlace(status, options)).session !== where.session)
|
|
248
|
+
throw new OpenRefused(
|
|
249
|
+
`${options.agent}'s session changed as the run was held; open it again: ${shellLine(["awf", "open", status.ref, options.agent, "--no-fork", ...place.repeat])}`,
|
|
250
|
+
"live",
|
|
251
|
+
);
|
|
252
|
+
// A home of its own on the host holds the login copied when the run started: refreshed from
|
|
253
|
+
// the operator's now, and a refresh handed back at the end, as a run's turns do. A live
|
|
254
|
+
// attempt owns it: its rotated login, not yet handed back, would be overwritten. An attempt
|
|
255
|
+
// that starts meanwhile seats a new home.
|
|
256
|
+
if (
|
|
257
|
+
where.home &&
|
|
258
|
+
!where.sandbox &&
|
|
259
|
+
!(await readAttempts(status.run)).some((attempt) => isLive(attempt))
|
|
260
|
+
)
|
|
261
|
+
ownHome = await refreshHome(
|
|
262
|
+
where.home,
|
|
263
|
+
OPERATOR_HOMES.host(where.harness, where.home, environment),
|
|
264
|
+
new Map(),
|
|
265
|
+
).catch((error: unknown) => {
|
|
266
|
+
throw new OpenRefused(messageOf(error), "unreachable");
|
|
267
|
+
});
|
|
268
|
+
if (where.sandbox) {
|
|
269
|
+
// A provider keeps a sandbox's open state in its directory: the run's own sandbox, open
|
|
270
|
+
// while an attempt runs, or another awf open's, would be broken by this one's close.
|
|
271
|
+
const plain = shellLine(["awf", "open", status.ref, options.agent, ...place.repeat]);
|
|
272
|
+
if ((await readAttempts(status.run)).some((attempt) => isLive(attempt)))
|
|
273
|
+
throw new OpenRefused(
|
|
274
|
+
`${status.ref} is running, and ${options.agent}'s sandbox is open in it; open it once the run is parked or done: ${plain}`,
|
|
275
|
+
"live",
|
|
276
|
+
);
|
|
277
|
+
const file = `${where.sandbox.directory}.open`;
|
|
278
|
+
const claimed = await claimNow(file);
|
|
279
|
+
if ("heldBy" in claimed)
|
|
280
|
+
throw new OpenRefused(
|
|
281
|
+
`${options.agent}'s sandbox is open in another awf open (pid ${claimed.heldBy.pid}); open it once that exits: ${plain}`,
|
|
282
|
+
"opened",
|
|
283
|
+
);
|
|
284
|
+
boxClaim = { file, held: claimed.held };
|
|
285
|
+
doors = await mkdtemp(join(tmpdir(), "awf-open-"));
|
|
286
|
+
const door = await installSandboxedDoor(
|
|
287
|
+
doors,
|
|
288
|
+
join(doors, "open.sock"),
|
|
289
|
+
INERT_WFA,
|
|
290
|
+
spec.sessionEnv,
|
|
291
|
+
);
|
|
292
|
+
const providers = context.providers ? await context.providers() : { installed: {} };
|
|
293
|
+
box = await reopenSandbox(
|
|
294
|
+
where.sandbox,
|
|
295
|
+
{ harness: where.harness, model: settings.model, cwd: where.cwd, home: where.home! },
|
|
296
|
+
{
|
|
297
|
+
providers,
|
|
298
|
+
runRoot: status.run.root,
|
|
299
|
+
door,
|
|
300
|
+
// The box lives as long as what runs in it may: a docker box ends at its deadline.
|
|
301
|
+
deadline: {
|
|
302
|
+
unixMilliseconds:
|
|
303
|
+
Date.now() + (text === undefined ? TUI_TIMEOUT_MS : PRINT_TIMEOUT_MS + 10 * 60_000),
|
|
304
|
+
},
|
|
305
|
+
environment,
|
|
306
|
+
machineHome: home,
|
|
307
|
+
coordinatorRoot: coordinatorDir(home, environment),
|
|
308
|
+
},
|
|
309
|
+
).catch((error: unknown) => {
|
|
310
|
+
throw new OpenRefused(
|
|
311
|
+
`${options.agent}'s ${where.sandbox!.provider} sandbox could not be opened again: ${messageOf(error)}`,
|
|
312
|
+
"unreachable",
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
const occupant = box?.occupant;
|
|
317
|
+
const run: RunProcess = occupant
|
|
318
|
+
? (input) => {
|
|
319
|
+
const { holdStdinUntil, onStdoutLine, timeoutAt, ...process } = input as Parameters<
|
|
320
|
+
typeof runProcess
|
|
321
|
+
>[0] & { holdStdinUntil?: (line: string) => boolean };
|
|
322
|
+
return hostRun({
|
|
323
|
+
...occupant.launch(process as Parameters<typeof occupant.launch>[0]),
|
|
324
|
+
...(holdStdinUntil ? { holdStdinUntil } : {}),
|
|
325
|
+
...(onStdoutLine ? { onStdoutLine } : {}),
|
|
326
|
+
...(timeoutAt ? { timeoutAt } : {}),
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
: hostRun;
|
|
330
|
+
let session = where.session;
|
|
331
|
+
let forkedFrom: string | undefined;
|
|
332
|
+
if (!where.copy && !options.noFork) {
|
|
333
|
+
forkedFrom = session;
|
|
334
|
+
const forked = await forkRecordedSession(
|
|
335
|
+
where.harness,
|
|
336
|
+
session,
|
|
337
|
+
{ cwd: where.cwd, env, context: turnContext(session) },
|
|
338
|
+
run,
|
|
339
|
+
context.signal ? { signal: context.signal } : {},
|
|
340
|
+
).catch((error: unknown) => {
|
|
341
|
+
throw new OpenRefused(
|
|
342
|
+
`${options.agent}'s session ${where.session} could not be copied: ${messageOf(error)}`,
|
|
343
|
+
"internal-error",
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
session = forked.sessionRef;
|
|
347
|
+
}
|
|
348
|
+
const again = shellLine([
|
|
349
|
+
"awf",
|
|
350
|
+
"open",
|
|
351
|
+
status.ref,
|
|
352
|
+
options.agent,
|
|
353
|
+
...(options.noFork ? ["--no-fork"] : []),
|
|
354
|
+
...(!options.noFork || options.session !== undefined ? ["--session", session] : []),
|
|
355
|
+
...place.repeat,
|
|
356
|
+
]);
|
|
357
|
+
if (text !== undefined) {
|
|
358
|
+
const plan = spec.resumeTurn!(text, session, turnContext(session));
|
|
359
|
+
const result = await run({
|
|
360
|
+
argv: plan.argv,
|
|
361
|
+
cwd: where.cwd,
|
|
362
|
+
env,
|
|
363
|
+
...(plan.stdin === undefined ? {} : { stdin: plan.stdin }),
|
|
364
|
+
timeoutMs: PRINT_TIMEOUT_MS,
|
|
365
|
+
signal: context.signal ?? new AbortController().signal,
|
|
366
|
+
});
|
|
367
|
+
if (result.cancelled)
|
|
368
|
+
throw new OpenRefused("the question was interrupted", "internal-error");
|
|
369
|
+
if (result.timedOut)
|
|
370
|
+
throw new OpenRefused(
|
|
371
|
+
`${where.harness} gave no answer within ${PRINT_TIMEOUT_MS / 60_000} minutes`,
|
|
372
|
+
"internal-error",
|
|
373
|
+
);
|
|
374
|
+
if (result.exitCode !== 0)
|
|
375
|
+
throw new OpenRefused(
|
|
376
|
+
`${where.harness} exited ${result.exitCode}: ${(result.stderr || result.stdout).trim().slice(-400)}`,
|
|
377
|
+
"internal-error",
|
|
378
|
+
);
|
|
379
|
+
const reply = (spec.readTranscript?.(result.stdout) ?? result.stdout).trim();
|
|
380
|
+
out.stdout(
|
|
381
|
+
json
|
|
382
|
+
? JSON.stringify({
|
|
383
|
+
v: OPEN_V,
|
|
384
|
+
session,
|
|
385
|
+
...(forkedFrom ? { forkedFrom } : {}),
|
|
386
|
+
reply,
|
|
387
|
+
again,
|
|
388
|
+
} satisfies OpenResultJson)
|
|
389
|
+
: reply,
|
|
390
|
+
);
|
|
391
|
+
await finish();
|
|
392
|
+
out.stderr(`again: ${again}`);
|
|
393
|
+
return 0;
|
|
394
|
+
}
|
|
395
|
+
const { model, effort } = settings;
|
|
396
|
+
out.stderr(
|
|
397
|
+
[
|
|
398
|
+
options.agent,
|
|
399
|
+
[where.harness, model, effort].filter(Boolean).join(" "),
|
|
400
|
+
...(where.sandbox ? [where.sandbox.provider] : []),
|
|
401
|
+
forkedFrom
|
|
402
|
+
? `a copy of ${forkedFrom}`
|
|
403
|
+
: held
|
|
404
|
+
? `session ${session}, the run held until you exit`
|
|
405
|
+
: `session ${session}`,
|
|
406
|
+
].join(" · "),
|
|
407
|
+
);
|
|
408
|
+
// A Ctrl-C while the place was made is the operator's no; once the TUI has the terminal, it is the TUI's.
|
|
409
|
+
if (context.signal?.aborted)
|
|
410
|
+
throw new OpenRefused(
|
|
411
|
+
`interrupted before the TUI opened; open it again: ${again}`,
|
|
412
|
+
"internal-error",
|
|
413
|
+
);
|
|
414
|
+
const plan = spec.operatorResume!(session, settings, launchArgs(true));
|
|
415
|
+
const attach =
|
|
416
|
+
context.attach ?? ((input) => runAttached(input, input.exact ? {} : environment));
|
|
417
|
+
let code: number;
|
|
418
|
+
if (occupant) {
|
|
419
|
+
const terminal = Object.fromEntries(
|
|
420
|
+
TERMINAL_ENVIRONMENT.flatMap((name) => {
|
|
421
|
+
const value = environment[name];
|
|
422
|
+
return value === undefined ? [] : [[name, value]];
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
const command = occupant.launch({
|
|
426
|
+
argv: plan.argv,
|
|
427
|
+
cwd: where.cwd,
|
|
428
|
+
env: { ...terminal, ...env },
|
|
429
|
+
timeoutMs: TUI_TIMEOUT_MS,
|
|
430
|
+
terminal: true,
|
|
431
|
+
});
|
|
432
|
+
try {
|
|
433
|
+
code = await attach({
|
|
434
|
+
argv: command.argv,
|
|
435
|
+
cwd: where.cwd,
|
|
436
|
+
env: command.env ?? {},
|
|
437
|
+
exact: true,
|
|
438
|
+
});
|
|
439
|
+
} finally {
|
|
440
|
+
await command.reap?.().catch(() => undefined);
|
|
441
|
+
}
|
|
442
|
+
} else code = await attach({ argv: plan.argv, cwd: where.cwd, env });
|
|
443
|
+
await finish();
|
|
444
|
+
out.stderr(`again: ${again}`);
|
|
445
|
+
return code;
|
|
446
|
+
} finally {
|
|
447
|
+
// Once, by whichever comes first; a refusal or a throw lets go here.
|
|
448
|
+
await finish();
|
|
449
|
+
}
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (error instanceof OpenRefused) return refuse(error);
|
|
452
|
+
return failed(error, place, out, messageOf(error), { json, v: OPEN_V });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** A run `--no-fork` holds: let go once, then woken as `awf tick` would wake it. */
|
|
457
|
+
type Held = { release(out: Out, context: OpenContext): Promise<void> };
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Holds the run for `--no-fork`: refused while an attempt is live or another `awf open --no-fork`
|
|
461
|
+
* holds it. Checked under the run's lock, which is let go at once: senders take it for every
|
|
462
|
+
* append. An attempt claims its number without that lock, then looks for this claim, so this one
|
|
463
|
+
* looks for a live attempt again once it holds: of the two, one always sees the other.
|
|
464
|
+
*/
|
|
465
|
+
async function holdRun(
|
|
466
|
+
status: RunStatus,
|
|
467
|
+
agent: string,
|
|
468
|
+
place: ReadPlace,
|
|
469
|
+
sandboxed: boolean,
|
|
470
|
+
): Promise<Held> {
|
|
471
|
+
const { run, ref } = status;
|
|
472
|
+
const { workflow, id } = run.record;
|
|
473
|
+
const file = runOpenFile(run.root, workflow, id);
|
|
474
|
+
const plain = shellLine(["awf", "open", ref, agent, ...place.repeat]);
|
|
475
|
+
// A sandboxed agent's copy reopens its sandbox, which a live attempt has open.
|
|
476
|
+
const instead = sandboxed
|
|
477
|
+
? `open a copy once the run is parked or done: ${plain}`
|
|
478
|
+
: `a copy opens anyway: ${plain}`;
|
|
479
|
+
const live = () =>
|
|
480
|
+
readAttempts(run).then((attempts) => attempts.find((attempt) => isLive(attempt)));
|
|
481
|
+
const running = () =>
|
|
482
|
+
new OpenRefused(
|
|
483
|
+
`${ref} is running, and ${agent}'s session may be written as you type; ${instead}`,
|
|
484
|
+
"live",
|
|
485
|
+
);
|
|
486
|
+
const holding = await withRunLock(runLockFile(run.root, workflow, id), async () => {
|
|
487
|
+
if (await live()) throw running();
|
|
488
|
+
const claimed = await claimNow(file);
|
|
489
|
+
if ("heldBy" in claimed)
|
|
490
|
+
throw new OpenRefused(
|
|
491
|
+
`${ref} is open in awf open --no-fork (pid ${claimed.heldBy.pid}); a copy opens anyway: ${plain}`,
|
|
492
|
+
"opened",
|
|
493
|
+
);
|
|
494
|
+
try {
|
|
495
|
+
if (await live()) throw running();
|
|
496
|
+
} catch (error) {
|
|
497
|
+
await releaseClaim(file, claimed.held);
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
return claimed.held;
|
|
501
|
+
});
|
|
502
|
+
let released: Promise<void> | undefined;
|
|
503
|
+
return {
|
|
504
|
+
release: (out, context) => {
|
|
505
|
+
released ??= (async () => {
|
|
506
|
+
await releaseClaim(file, holding);
|
|
507
|
+
if (run.record.events) await wakeAfter(status, out, context);
|
|
508
|
+
})();
|
|
509
|
+
return released;
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** What waited while the run was held, taken as `awf tick` takes it: said, never thrown. */
|
|
515
|
+
async function wakeAfter(status: RunStatus, out: Out, context: OpenContext): Promise<void> {
|
|
516
|
+
const { run, ref } = status;
|
|
517
|
+
try {
|
|
518
|
+
const woken = await withRunLock(runLockFile(run.root, run.record.workflow, run.record.id), () =>
|
|
519
|
+
wake(run, {
|
|
520
|
+
home: context.home ?? homedir(),
|
|
521
|
+
environment: context.environment ?? process.env,
|
|
522
|
+
nudgeLive: false,
|
|
523
|
+
...(context.launch ? { launch: context.launch } : {}),
|
|
524
|
+
}),
|
|
525
|
+
);
|
|
526
|
+
if (woken.why === "launch-failed")
|
|
527
|
+
out.stderr(`awf: ${ref} was not woken: ${woken.error}; awf tick tries again`);
|
|
528
|
+
else if (woken.did === "started" || woken.did === "continued")
|
|
529
|
+
out.stderr(`woke ${ref}: ${woken.did}, for ${WOKEN_FOR[woken.cause ?? "event"]}`);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
out.stderr(`awf: ${ref} was not woken: ${messageOf(error)}; awf tick wakes it`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const WOKEN_FOR = {
|
|
536
|
+
event: "an event that waited",
|
|
537
|
+
timer: "a timer due",
|
|
538
|
+
crashed: "an attempt that was interrupted",
|
|
539
|
+
failed: "an attempt that failed",
|
|
540
|
+
} as const;
|
|
541
|
+
|
|
542
|
+
/** Where the agent's session is and how it ran, as the run recorded it; or why it can't be opened. */
|
|
543
|
+
type AgentPlace = {
|
|
544
|
+
harness: Harness;
|
|
545
|
+
/** The session to open: one the run recorded, copied first, or the operator's copy as it is. */
|
|
546
|
+
session: string;
|
|
547
|
+
/** `--session` named an id the run never recorded: a copy an earlier `awf open` made. */
|
|
548
|
+
copy: boolean;
|
|
549
|
+
line: SessionLine;
|
|
550
|
+
execution: AgentOp["execution"];
|
|
551
|
+
cwd: string;
|
|
552
|
+
/** The agent's own harness home, where it had one. */
|
|
553
|
+
home?: string;
|
|
554
|
+
/** The sandbox it ran in, reopened for it. */
|
|
555
|
+
sandbox?: SessionSandbox;
|
|
556
|
+
/** The skills its harness was given, given again. */
|
|
557
|
+
skills?: AgentSkills;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
/** No agent was named: the command that opens each of the run's agents with a session. */
|
|
561
|
+
async function agentNeeded(status: RunStatus, place: ReadPlace): Promise<OpenRefused> {
|
|
562
|
+
const { view } = await readRunLines(status.run.dir);
|
|
563
|
+
const agents = Object.keys(view.sessions.latest);
|
|
564
|
+
if (agents.length === 0)
|
|
565
|
+
return new OpenRefused(`${status.ref} has no agent's session recorded`, "no-session");
|
|
566
|
+
const commands = agents.map(
|
|
567
|
+
(agent) => ` ${shellLine(["awf", "open", status.ref, agent, ...place.repeat])}`,
|
|
568
|
+
);
|
|
569
|
+
return new OpenRefused(`which agent? Open one:\n${commands.join("\n")}`, "usage");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function agentPlace(status: RunStatus, options: Options): Promise<AgentPlace> {
|
|
573
|
+
const { view } = await readRunLines(status.run.dir);
|
|
574
|
+
const { agent } = options;
|
|
575
|
+
const latest = view.sessions.latest[agent];
|
|
576
|
+
if (!latest) {
|
|
577
|
+
const known = Object.keys(view.sessions.latest);
|
|
578
|
+
throw new OpenRefused(
|
|
579
|
+
`${status.ref} has no session of ${agent}'s recorded${known.length > 0 ? `; agents with one: ${known.join(", ")}` : ": no agent's session is"}`,
|
|
580
|
+
"no-session",
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
const named =
|
|
584
|
+
options.session === undefined
|
|
585
|
+
? latest
|
|
586
|
+
: view.sessions.all.findLast(
|
|
587
|
+
(line) => line.agent === agent && line.session === options.session,
|
|
588
|
+
);
|
|
589
|
+
const others = named
|
|
590
|
+
? undefined
|
|
591
|
+
: view.sessions.all.find((each) => each.session === options.session && each.agent !== agent);
|
|
592
|
+
if (others)
|
|
593
|
+
throw new OpenRefused(
|
|
594
|
+
`${options.session} is ${others.agent}'s session, not ${agent}'s: awf open ${status.ref} ${others.agent} --session ${options.session}`,
|
|
595
|
+
"no-session",
|
|
596
|
+
);
|
|
597
|
+
const line = named ?? (await copysLine(view.sessions.all, agent, options.session)) ?? latest;
|
|
598
|
+
if (!findHarness(line.harness))
|
|
599
|
+
throw new OpenRefused(
|
|
600
|
+
`${agent} ran on ${line.harness}, a harness this awf does not know`,
|
|
601
|
+
"usage",
|
|
602
|
+
);
|
|
603
|
+
const op = await agentOp(status.run.dir, agent, line);
|
|
604
|
+
const cwd = op.cwd;
|
|
605
|
+
if (!(await isDirectory(cwd)))
|
|
606
|
+
throw new OpenRefused(
|
|
607
|
+
`${agent} worked in ${cwd}, which is gone; its harness finds the session only from there`,
|
|
608
|
+
"unreachable",
|
|
609
|
+
);
|
|
610
|
+
let home: string | undefined;
|
|
611
|
+
if (line.home !== undefined) {
|
|
612
|
+
if (!(await isDirectory(line.home)))
|
|
613
|
+
throw new OpenRefused(`${agent}'s own home ${line.home} is gone`, "unreachable");
|
|
614
|
+
// A home outside the run's folder is a sandbox's: a line written before it named the sandbox
|
|
615
|
+
// says nothing of where it ran.
|
|
616
|
+
if (!line.sandbox && !(await within(workDir(status.run.dir), line.home)))
|
|
617
|
+
throw new OpenRefused(
|
|
618
|
+
`${agent} ran in a sandbox this run did not record, so it cannot be reopened where it ran`,
|
|
619
|
+
"unreachable",
|
|
620
|
+
);
|
|
621
|
+
home = line.home;
|
|
622
|
+
}
|
|
623
|
+
if (line.sandbox && home === undefined)
|
|
624
|
+
throw new OpenRefused(`${agent}'s session line names a sandbox but no home`, "unreachable");
|
|
625
|
+
let skills: AgentSkills | undefined;
|
|
626
|
+
if (line.skills) {
|
|
627
|
+
if (!(await isDirectory(line.skills.directory)))
|
|
628
|
+
throw new OpenRefused(
|
|
629
|
+
`${agent}'s skills were in ${line.skills.directory}, which is gone`,
|
|
630
|
+
"unreachable",
|
|
631
|
+
);
|
|
632
|
+
skills = {
|
|
633
|
+
names: line.skills.names,
|
|
634
|
+
directory: line.skills.directory,
|
|
635
|
+
sandboxed: line.sandbox !== undefined,
|
|
636
|
+
...(line.sandbox === undefined && home !== undefined ? { ownHome: home } : {}),
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
harness: line.harness as Harness,
|
|
641
|
+
session: options.session ?? latest.session,
|
|
642
|
+
copy: options.session !== undefined && named === undefined,
|
|
643
|
+
line,
|
|
644
|
+
execution: op.execution,
|
|
645
|
+
cwd,
|
|
646
|
+
...(home === undefined ? {} : { home }),
|
|
647
|
+
...(line.sandbox ? { sandbox: line.sandbox } : {}),
|
|
648
|
+
...(skills ? { skills } : {}),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* The line a copy was made from, by the home that holds it: an agent with a home of its own gets a
|
|
654
|
+
* new one each attempt, and a copy is made in its source's. Every harness names a session's files
|
|
655
|
+
* for its id. None for an agent on the operator's home, whose latest line places it as well.
|
|
656
|
+
*/
|
|
657
|
+
async function copysLine(
|
|
658
|
+
lines: readonly SessionLine[],
|
|
659
|
+
agent: string,
|
|
660
|
+
session: string | undefined,
|
|
661
|
+
): Promise<SessionLine | undefined> {
|
|
662
|
+
if (session === undefined) return undefined;
|
|
663
|
+
const seen = new Set<string>();
|
|
664
|
+
for (const line of lines.toReversed()) {
|
|
665
|
+
if (line.agent !== agent || line.home === undefined || seen.has(line.home)) continue;
|
|
666
|
+
seen.add(line.home);
|
|
667
|
+
const names = await readdir(line.home, { recursive: true }).catch(() => [] as string[]);
|
|
668
|
+
if (names.some((name) => name.includes(session))) return line;
|
|
669
|
+
}
|
|
670
|
+
return undefined;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* The operation that recorded the session: its line's, else the agent's latest, whose cwd and
|
|
675
|
+
* settings are the agent's: a fork's line is written before its first operation names it.
|
|
676
|
+
*/
|
|
677
|
+
async function agentOp(runDir: string, agent: string, line: SessionLine): Promise<AgentOp> {
|
|
678
|
+
const ops = line.operation ? [line.operation] : await agentOps(runDir, agent);
|
|
679
|
+
if (ops.length === 0)
|
|
680
|
+
throw new OpenRefused(
|
|
681
|
+
`${agent} has no operation recorded, so where it ran is unknown`,
|
|
682
|
+
"no-session",
|
|
683
|
+
);
|
|
684
|
+
let failure: unknown;
|
|
685
|
+
for (const op of ops) {
|
|
686
|
+
try {
|
|
687
|
+
const record = await readOpFile<OpRecord>(opFile(runDir, op), "op", { refuse: true });
|
|
688
|
+
if (record && record.kind !== "decision" && typeof record.cwd === "string") return record;
|
|
689
|
+
} catch (error) {
|
|
690
|
+
failure = error;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
throw new OpenRefused(
|
|
694
|
+
`${agent}'s operation ${ops[0]} could not be read${failure ? `: ${messageOf(failure)}` : ""}`,
|
|
695
|
+
"unreachable",
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** The agent's operations, latest first. */
|
|
700
|
+
async function agentOps(runDir: string, agent: string): Promise<string[]> {
|
|
701
|
+
const last = (await listOps(runDir)).get(agent);
|
|
702
|
+
if (last?.of !== "agent") return [];
|
|
703
|
+
return Array.from({ length: last.last }, (_, index) => `${agent}.${last.last - index}`);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
707
|
+
return stat(path).then(
|
|
708
|
+
(found) => found.isDirectory(),
|
|
709
|
+
() => false,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Whether `path` is inside `directory`, both by their real paths. */
|
|
714
|
+
async function within(directory: string, path: string): Promise<boolean> {
|
|
715
|
+
const [outer, inner] = await Promise.all([
|
|
716
|
+
realpath(directory).catch(() => directory),
|
|
717
|
+
realpath(path).catch(() => path),
|
|
718
|
+
]);
|
|
719
|
+
const inside = relative(outer, inner);
|
|
720
|
+
return inside !== "" && !inside.startsWith(`..${sep}`) && inside !== "..";
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** The options `parseOpen` takes, which `-p` never reads as its text. */
|
|
724
|
+
const OPTIONS = new Set(["--no-fork", "--session", "--"]);
|
|
725
|
+
|
|
726
|
+
/** The command line as given: the agent may be left out, for awf to say which there are. */
|
|
727
|
+
type Asked = Omit<Options, "agent"> & { agent?: string };
|
|
728
|
+
|
|
729
|
+
function parseOpen(rest: readonly string[]): Asked {
|
|
730
|
+
const words: string[] = [];
|
|
731
|
+
let session: string | undefined;
|
|
732
|
+
let print: string | undefined;
|
|
733
|
+
let noFork = false;
|
|
734
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
735
|
+
const arg = rest[index]!;
|
|
736
|
+
if (arg === "--no-fork") noFork = true;
|
|
737
|
+
else if (arg === "--session") {
|
|
738
|
+
const value = rest[index + 1];
|
|
739
|
+
if (!value || value.startsWith("-")) throw new Error("--session needs a session's id");
|
|
740
|
+
session = value;
|
|
741
|
+
index += 1;
|
|
742
|
+
} else if (arg === "-p") {
|
|
743
|
+
const value = rest[index + 1];
|
|
744
|
+
// Its text, unless it is absent, `-`, or one of this command's options: then stdin.
|
|
745
|
+
if (value !== undefined && value !== "-" && !OPTIONS.has(value)) print = value;
|
|
746
|
+
else print = "";
|
|
747
|
+
if (value === "-" || (value !== undefined && !OPTIONS.has(value))) index += 1;
|
|
748
|
+
} else if (arg === "--") {
|
|
749
|
+
words.push(...rest.slice(index + 1));
|
|
750
|
+
break;
|
|
751
|
+
} else if (arg.startsWith("-")) throw new Error(`unknown option: ${arg}`);
|
|
752
|
+
else words.push(arg);
|
|
753
|
+
}
|
|
754
|
+
const [ref, agent, ...extra] = words;
|
|
755
|
+
if (!ref) throw new Error("open needs a run, <workflow>/<id> or <id>, and an agent");
|
|
756
|
+
if (agent !== undefined && !agent.trim()) throw new Error("open needs an agent's name");
|
|
757
|
+
if (extra.length > 0)
|
|
758
|
+
throw new Error(`open takes a run and an agent; ${extra[0]} is neither: -p takes the question`);
|
|
759
|
+
return {
|
|
760
|
+
ref,
|
|
761
|
+
...(agent === undefined ? {} : { agent }),
|
|
762
|
+
noFork,
|
|
763
|
+
...(session === undefined ? {} : { session }),
|
|
764
|
+
...(print === undefined ? {} : { print }),
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function readStdin(): Promise<string> {
|
|
769
|
+
return new Response(Bun.stdin.stream()).text();
|
|
770
|
+
}
|