@opsee/cli 0.11.9

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.
Files changed (85) hide show
  1. package/README.md +1962 -0
  2. package/bin/opsee.js +28 -0
  3. package/package.json +40 -0
  4. package/skills/README.md +3 -0
  5. package/skills/to-issues/SKILL.md +92 -0
  6. package/skills/to-issues/agents/openai.yaml +5 -0
  7. package/skills/to-spec/SKILL.md +79 -0
  8. package/skills/to-spec/agents/openai.yaml +5 -0
  9. package/skills/wayfinder/SKILL.md +138 -0
  10. package/skills/wayfinder/agents/openai.yaml +5 -0
  11. package/src/args.ts +676 -0
  12. package/src/cli.ts +341 -0
  13. package/src/commands/account.ts +121 -0
  14. package/src/commands/deps.ts +11 -0
  15. package/src/commands/foreman-control.ts +242 -0
  16. package/src/commands/foreman-debug.ts +131 -0
  17. package/src/commands/foreman-plan.ts +213 -0
  18. package/src/commands/foreman-service.ts +186 -0
  19. package/src/commands/foreman-up.ts +165 -0
  20. package/src/commands/foreman-views.ts +398 -0
  21. package/src/commands/foreman.ts +465 -0
  22. package/src/commands/init.ts +176 -0
  23. package/src/commands/initiative.ts +192 -0
  24. package/src/commands/login.ts +24 -0
  25. package/src/commands/whoami.ts +15 -0
  26. package/src/foreman/account-store.ts +96 -0
  27. package/src/foreman/account.ts +474 -0
  28. package/src/foreman/claude-worker-adapter.ts +412 -0
  29. package/src/foreman/codex-worker-adapter.ts +472 -0
  30. package/src/foreman/completion-report.ts +153 -0
  31. package/src/foreman/core/context.ts +169 -0
  32. package/src/foreman/core/defects.ts +280 -0
  33. package/src/foreman/core/exec.ts +20 -0
  34. package/src/foreman/core/gates.ts +493 -0
  35. package/src/foreman/core/handoff.ts +163 -0
  36. package/src/foreman/core/install.ts +109 -0
  37. package/src/foreman/core/learnings.ts +368 -0
  38. package/src/foreman/core/outbox-tracker.ts +192 -0
  39. package/src/foreman/core/pin.ts +226 -0
  40. package/src/foreman/core/plan-context.ts +238 -0
  41. package/src/foreman/core/process-table.ts +535 -0
  42. package/src/foreman/core/reconcile.ts +227 -0
  43. package/src/foreman/core/report.ts +60 -0
  44. package/src/foreman/core/run.ts +2836 -0
  45. package/src/foreman/core/scheduler.ts +244 -0
  46. package/src/foreman/core/summary.ts +166 -0
  47. package/src/foreman/core/text.ts +97 -0
  48. package/src/foreman/core/transcripts.ts +38 -0
  49. package/src/foreman/core/triage.ts +138 -0
  50. package/src/foreman/core/verifier.ts +800 -0
  51. package/src/foreman/core/views.ts +940 -0
  52. package/src/foreman/core/work-contract.ts +152 -0
  53. package/src/foreman/core/workspace.ts +335 -0
  54. package/src/foreman/fake-handoff.ts +33 -0
  55. package/src/foreman/fake-learnings.ts +26 -0
  56. package/src/foreman/fake-remote-api.ts +70 -0
  57. package/src/foreman/fake-tracker-adapter.ts +355 -0
  58. package/src/foreman/fake-worker-adapter.ts +221 -0
  59. package/src/foreman/host.ts +75 -0
  60. package/src/foreman/local-dir.ts +28 -0
  61. package/src/foreman/opsee-tracker-adapter.ts +612 -0
  62. package/src/foreman/process-group.ts +160 -0
  63. package/src/foreman/remote-api.ts +283 -0
  64. package/src/foreman/run-recipe.ts +274 -0
  65. package/src/foreman/service-unit.ts +257 -0
  66. package/src/foreman/tracker-adapter.ts +298 -0
  67. package/src/foreman/triage-draft.ts +40 -0
  68. package/src/foreman/vendor.ts +23 -0
  69. package/src/foreman/verdict.ts +120 -0
  70. package/src/foreman/worker-adapter.ts +177 -0
  71. package/src/foreman/worker-process.ts +488 -0
  72. package/src/identity.ts +49 -0
  73. package/src/index.ts +3 -0
  74. package/src/init/managed.ts +84 -0
  75. package/src/init/mcp-config.ts +77 -0
  76. package/src/init/paths.ts +16 -0
  77. package/src/init/pointer-block.ts +45 -0
  78. package/src/init/project.ts +22 -0
  79. package/src/init/prompt.ts +45 -0
  80. package/src/init/run-recipe-config.ts +133 -0
  81. package/src/init/skills.ts +38 -0
  82. package/src/init/text.ts +22 -0
  83. package/src/init/tracker-doc.ts +106 -0
  84. package/src/opsee-config.ts +116 -0
  85. package/templates/issue-tracker.md +162 -0
@@ -0,0 +1,412 @@
1
+ import type { Account } from "./account.js";
2
+ import { COMPLETION_REPORT_CONTRACT, completedEvent } from "./completion-report.js";
3
+ import { VENDOR_CONFIG_DIR_ENV } from "./vendor.js";
4
+ import type { InteractiveCommand, InteractiveSessionRequest, McpServerSpec, OutputContract, TurnFailureReason, TurnHandle, TurnRequest, TurnSandbox, WorkerAdapter } from "./worker-adapter.js";
5
+ import {
6
+ buildWorkerEnv,
7
+ errorMessage,
8
+ failedHandle,
9
+ launchFailureReason,
10
+ ProcessTurn,
11
+ rateLimitedEvent,
12
+ spawnProcess,
13
+ type ProcessSpawner,
14
+ type ProcessTurnSpec,
15
+ } from "./worker-process.js";
16
+
17
+ /**
18
+ * The Worker Adapter for Claude Code (ADR-0001): the vendor's own `claude` binary, spawned in
19
+ * headless print mode with JSON streaming, under one Account and pinned to one directory.
20
+ *
21
+ * Flags, checked against the CLI reference and headless docs (https://code.claude.com/docs/en/cli-reference.md,
22
+ * .../headless.md, .../structured-outputs.md) and against `claude --help` plus one recorded run of
23
+ * 2.1.263 (the happy-turn fixture); `--max-turns` is in the reference and accepted by the binary
24
+ * but not listed in its `--help`:
25
+ *
26
+ * - `-p` prints and exits; `--output-format stream-json` emits one JSON object per line and needs
27
+ * `--verbose` in print mode.
28
+ * - `--json-schema <schema>` requests structured output; the Worker delivers it through a
29
+ * `StructuredOutput` tool call and the validated object arrives in the final `result` line as
30
+ * `structured_output` (and as JSON text in `result`). A Worker that cannot satisfy the schema
31
+ * ends with `subtype: "error_max_structured_output_retries"`.
32
+ * - `--max-turns <n>` caps agentic turns; the cap ends the run with `subtype: "error_max_turns"`.
33
+ * - `--resume <session-id>` continues a stored session; the `session_id` is on every line.
34
+ * - `--permission-mode acceptEdits` lets an unattended Worker write files without a prompt nobody
35
+ * is there to answer. It covers file edits and a handful of file commands (`mkdir`, `touch`,
36
+ * `rm`, `rmdir`, `mv`, `cp`, `sed`) and nothing else, so the git the Worker is *told* to run is
37
+ * pre-approved by name instead (`CLAUDE_GIT_TOOLS`).
38
+ * - `--permission-prompts none` says who answers a prompt in print mode: nobody, so anything the
39
+ * mode and the allow-list do not already settle is denied outright and reported in the stream.
40
+ * Without it a turn that reaches for a tool it was not given waits on a question no terminal is
41
+ * attached to answer, and the turn ends at the stall timeout with nothing said.
42
+ * - `--mcp-config <json>` adds MCP servers for the turn and `--strict-mcp-config` makes them the
43
+ * only ones (the Verifier's Playwright MCP, core/verifier.ts); `--allowedTools mcp__<name>`
44
+ * lets the unattended turn call that server's tools without a permission prompt. Both
45
+ * `--mcp-config` and `--allowedTools` are variadic, so each is followed by another flag rather
46
+ * than by the prompt, which would otherwise be read as one more of their values.
47
+ * - A `readonly-browser` turn (`TurnRequest.sandbox`, the Verifier's) also gets `--tools ""`,
48
+ * which removes every built-in tool (`--allowedTools` only pre-approves, it never removes
49
+ * Read, Glob, Grep, WebFetch, WebSearch, Write, Edit or Bash), and `--permission-mode dontAsk`,
50
+ * which denies without a prompt whatever is not allowed, so the MCP server's tools are the only
51
+ * ones the turn has. `--tools` is variadic too and is followed by a flag. The mode is `dontAsk`
52
+ * and not `default` because 2.1.263 has no `default` choice (`acceptEdits`, `auto`,
53
+ * `bypassPermissions`, `manual`, `dontAsk`, `plan`) and `manual` would wait on a prompt nobody
54
+ * answers.
55
+ *
56
+ * The credential boundary (ADR-0013) is kept by construction: a subscription Account is only ever
57
+ * named to the process through `CLAUDE_CONFIG_DIR`, an API-key Account only through the value of
58
+ * the variable it names, copied into `ANTHROPIC_API_KEY` at spawn time (print mode prefers a key
59
+ * over a login, https://code.claude.com/docs/en/env-vars.md). Nothing here opens a path, and
60
+ * account-boundary.test.ts runs the adapter under the same fs audit as registration.
61
+ *
62
+ * The process seam, the environment building and the turn lifecycle are shared with the Codex
63
+ * adapter through worker-process.ts.
64
+ */
65
+
66
+ export const CLAUDE_BINARY = "claude";
67
+ export const CLAUDE_PERMISSION_MODE = "acceptEdits";
68
+ /** The permission mode of a `readonly-browser` turn: anything not pre-allowed is denied outright. */
69
+ export const CLAUDE_READONLY_PERMISSION_MODE = "dontAsk";
70
+
71
+ /** Who answers a permission prompt in print mode: nobody. An unattended turn has no terminal, so
72
+ * the only two ends a prompt can have are "denied and said so" and "hangs until the stall timeout";
73
+ * this picks the first. `--permission-mode` still decides everything the prompt never reaches. */
74
+ export const CLAUDE_PERMISSION_PROMPTS = "none";
75
+
76
+ /**
77
+ * The Bash commands an implementer Worker may run without a prompt.
78
+ *
79
+ * The Worker prompt tells it to "Commit your work on this branch" (core/context.ts) and a turn
80
+ * that leaves no commit past `origin/<base>` is not a Hand-off at all (core/handoff.ts): the
81
+ * attempt settles incomplete, the Gates never run, the Verifier never runs and no pull request
82
+ * opens. `acceptEdits` auto-approves the file edits and a handful of file commands, but not
83
+ * `git add` or `git commit`, so without this list every unattended Claude turn asks a question
84
+ * nobody is there to answer.
85
+ *
86
+ * The list mirrors the Foreman's own git guard (core/workspace.ts `guardedGit`, ADR-0003): a fixed
87
+ * set of subcommands, nothing that reaches a remote, nothing that moves the branch.
88
+ *
89
+ * - No `push`, `fetch`, `remote` or `ls-remote`: the Foreman pushes the branch and opens the draft
90
+ * pull request itself (core/handoff.ts), through the guarded runner that refuses the default
91
+ * branch, and the prompt tells the Worker not to.
92
+ * - No `merge` or `pull`, for the same reason the guard refuses them.
93
+ * - No `checkout`, `switch`, `branch` or `worktree`: the prompt says never change branches, and a
94
+ * branch renamed or left detached is one the Hand-off can no longer count commits on.
95
+ * - `diff` is here although the guard has no use for it: a Worker about to commit reads what it is
96
+ * staging, and a denial there would only push it towards `cat`-ing files it already has open.
97
+ *
98
+ * Everything else is left to the denial `CLAUDE_PERMISSION_PROMPTS` makes automatic: a turn that
99
+ * reaches for something outside this list is told no in its own stream, and reports it, rather than
100
+ * stalling silently.
101
+ */
102
+ export const CLAUDE_GIT_TOOLS: readonly string[] = [
103
+ "Bash(git add:*)",
104
+ "Bash(git commit:*)",
105
+ "Bash(git status:*)",
106
+ "Bash(git diff:*)",
107
+ "Bash(git log:*)",
108
+ "Bash(git show:*)",
109
+ "Bash(git rev-parse:*)",
110
+ "Bash(git rev-list:*)",
111
+ ];
112
+
113
+ export interface ClaudeWorkerAdapterOptions {
114
+ spawn?: ProcessSpawner;
115
+ /** Base environment the Worker inherits before the Account's variables are applied. */
116
+ baseEnv?: Readonly<Record<string, string | undefined>>;
117
+ /** Sees every raw stdout line before it is parsed; the debug command uses it to re-record fixtures. */
118
+ onLine?: (line: string) => void;
119
+ }
120
+
121
+ /** The variable print mode reads an API key from (https://code.claude.com/docs/en/env-vars.md). */
122
+ export const CLAUDE_API_KEY_ENV = "ANTHROPIC_API_KEY";
123
+
124
+ /** The Worker's environment for one Account (see `buildWorkerEnv`). */
125
+ export function buildClaudeEnv(
126
+ account: Account,
127
+ baseEnv: Readonly<Record<string, string | undefined>>,
128
+ overrides: Readonly<Record<string, string>> = {},
129
+ sandbox: TurnSandbox = "worker",
130
+ ): Record<string, string> {
131
+ return buildWorkerEnv(account, { configDir: VENDOR_CONFIG_DIR_ENV.claude, apiKey: CLAUDE_API_KEY_ENV }, baseEnv, overrides, sandbox);
132
+ }
133
+
134
+ /** The attended turn (story 53): `claude --resume <session-id>`, the interactive form of the same
135
+ * resume the unattended turn uses (https://code.claude.com/docs/en/cli-reference.md), with no `-p`
136
+ * so the terminal is the vendor's own. */
137
+ export function buildClaudeInteractiveArgs(sessionId: string): string[] {
138
+ return ["--resume", sessionId];
139
+ }
140
+
141
+ /** The `--mcp-config` document for the request's MCP servers (https://code.claude.com/docs/en/mcp.md). */
142
+ export function claudeMcpConfig(servers: Readonly<Record<string, McpServerSpec>>): Record<string, unknown> {
143
+ return { mcpServers: Object.fromEntries(Object.entries(servers).map(([name, s]) => [name, { type: "stdio", command: s.command, args: s.args, ...(s.env ? { env: s.env } : {}) }])) };
144
+ }
145
+
146
+ /** The attended planning session (story 10): `claude <prompt>`, the interactive form with the
147
+ * prompt as its first message (`claude [options] [command] [prompt]`, "starts an interactive
148
+ * session by default"; https://code.claude.com/docs/en/cli-reference.md). No `-p`, no schema, no
149
+ * permission mode: the human is there to answer. */
150
+ export function buildClaudeInteractiveSessionArgs(prompt: string): string[] {
151
+ return [prompt];
152
+ }
153
+
154
+ export function buildClaudeArgs(request: TurnRequest, resumeSessionId?: string): string[] {
155
+ const contract = request.contract ?? COMPLETION_REPORT_CONTRACT;
156
+ const args = ["-p", "--output-format", "stream-json", "--verbose"];
157
+ const servers = request.mcpServers ?? {};
158
+ const readonly = request.sandbox === "readonly-browser";
159
+ // `mcp__<server>` allows every tool of that server. A read-only turn has no built-in tool to
160
+ // allow (`--tools ""` below removes them), so the git list is the implementer's alone.
161
+ const allowed = [...Object.keys(servers).map((name) => `mcp__${name}`), ...(readonly ? [] : CLAUDE_GIT_TOOLS)];
162
+ // A variadic flag ends at the next flag, so every list here is followed by one, never by the prompt.
163
+ if (allowed.length > 0) args.push("--allowedTools", ...allowed);
164
+ if (Object.keys(servers).length > 0) {
165
+ args.push("--mcp-config", JSON.stringify(claudeMcpConfig(servers)), "--strict-mcp-config");
166
+ }
167
+ // No built-in tool at all for a read-only turn; `--tools` is variadic and is followed by a flag.
168
+ if (readonly) args.push("--tools", "");
169
+ args.push(
170
+ "--json-schema",
171
+ JSON.stringify(contract.jsonSchema),
172
+ "--permission-mode",
173
+ readonly ? CLAUDE_READONLY_PERMISSION_MODE : CLAUDE_PERMISSION_MODE,
174
+ "--permission-prompts",
175
+ CLAUDE_PERMISSION_PROMPTS,
176
+ );
177
+ if (request.maxTurns !== undefined) args.push("--max-turns", String(request.maxTurns));
178
+ if (resumeSessionId !== undefined) args.push("--resume", resumeSessionId);
179
+ args.push(request.prompt);
180
+ return args;
181
+ }
182
+
183
+ /**
184
+ * The strings by which a rate limit reaches print mode as the text of an error result rather than
185
+ * as a `rate_limit_event` (https://code.claude.com/docs/en/errors.md lists the first three). Matched
186
+ * only against error results, since a Worker's own report may well mention a rate limit it met.
187
+ */
188
+ export const RATE_LIMIT_TEXT_PATTERNS: readonly RegExp[] = [
189
+ /hit your (session|weekly|usage) limit/i,
190
+ /usage limit reached/i,
191
+ /request rejected \(429\)/i,
192
+ /rate limit/i,
193
+ ];
194
+
195
+ /**
196
+ * The strings by which a dead credential reaches print mode (OPS-288). Every one is a documented
197
+ * message from https://code.claude.com/docs/en/errors.md and every one means the login itself is
198
+ * finished: Claude Code is telling the operator to authenticate again, or the API refused the
199
+ * credentials outright. Matched only against error results, like `RATE_LIMIT_TEXT_PATTERNS`, and
200
+ * only after them — a message that reads as both is a rate limit, because a Pause lifts by itself
201
+ * where a quarantine waits for a human.
202
+ *
203
+ * Narrow on purpose. Documented messages deliberately left out, because none of them says the
204
+ * credential is dead and each would cost a working Account a Lane for the night:
205
+ * `Credit balance is too low` and the spend-cap family are a funded identity that is out of money
206
+ * (quota, which is OPS-289's ground); `This organization has been disabled` and the org-policy
207
+ * messages are a policy state a re-login cannot change; the AWS/Bedrock messages name a credential
208
+ * chain the Foreman never hands a Worker.
209
+ */
210
+ export const CREDENTIAL_TEXT_PATTERNS: readonly RegExp[] = [
211
+ // "Not logged in · Please run /login", "Login expired · Please run /login",
212
+ // "OAuth token refresh failed — run /login to re-authenticate", "JWT refresh failed: no OAuth token — run /login".
213
+ /run \/login/i,
214
+ // "Login expired", "Claude.ai login expired", "Anthropic profile login expired".
215
+ /login expired/i,
216
+ /oauth token (has expired|revoked)/i,
217
+ /oauth session expired and could not be refreshed/i,
218
+ /invalid api key/i,
219
+ // "API Error: 401 Invalid authentication credentials".
220
+ /api error: 401/i,
221
+ ];
222
+
223
+ /** The `Claude AI usage limit reached|<unix seconds>` form carries its reset; the "resets 5am"
224
+ * form names a wall-clock time without a date and is left to the scheduler's default. */
225
+ export function resetAtFromLimitText(text: string): string | undefined {
226
+ const epoch = /\|(\d{9,10})\b/.exec(text);
227
+ return epoch ? new Date(Number(epoch[1]) * 1000).toISOString() : undefined;
228
+ }
229
+
230
+ /** The Claude Code tool through which `--json-schema` output is delivered; its input is the report. */
231
+ export const STRUCTURED_OUTPUT_TOOL = "StructuredOutput";
232
+
233
+ interface StreamLine {
234
+ type?: string;
235
+ subtype?: string;
236
+ session_id?: string;
237
+ message?: { content?: unknown };
238
+ rate_limit_info?: { status?: string; resetsAt?: number; rateLimitType?: string };
239
+ is_error?: boolean;
240
+ result?: unknown;
241
+ structured_output?: unknown;
242
+ total_cost_usd?: number;
243
+ num_turns?: number;
244
+ errors?: unknown;
245
+ }
246
+
247
+ /** Translates one Claude Code print-mode stream into the adapter's event stream; one per turn. */
248
+ class ClaudeTurn extends ProcessTurn {
249
+ private rateLimit: { message: string; resetAt?: string } | undefined;
250
+
251
+ constructor(spec: ProcessTurnSpec, private readonly contract: OutputContract) {
252
+ super(spec);
253
+ this.begin();
254
+ }
255
+
256
+ protected handleMessage(parsed: unknown): void {
257
+ const line = parsed as StreamLine;
258
+ if (typeof line.session_id === "string" && line.session_id !== "" && this.sessionId === undefined) {
259
+ this.sessionId = line.session_id;
260
+ }
261
+ switch (line.type) {
262
+ case "system":
263
+ if (line.subtype === "init" && typeof line.session_id === "string") {
264
+ this.sessionId = line.session_id;
265
+ this.emit({ type: "started", sessionId: line.session_id });
266
+ }
267
+ return;
268
+ case "assistant":
269
+ this.handleAssistant(line.message?.content);
270
+ return;
271
+ case "rate_limit_event":
272
+ this.handleRateLimit(line.rate_limit_info);
273
+ return;
274
+ case "result":
275
+ this.handleResult(line);
276
+ return;
277
+ default:
278
+ return;
279
+ }
280
+ }
281
+
282
+ private handleAssistant(content: unknown): void {
283
+ if (!Array.isArray(content)) return;
284
+ for (const block of content as { type?: string; text?: string; name?: string }[]) {
285
+ if (block.type === "text" && typeof block.text === "string" && block.text !== "") {
286
+ this.emit({ type: "output", text: block.text });
287
+ } else if (block.type === "tool_use" && typeof block.name === "string" && block.name !== STRUCTURED_OUTPUT_TOOL) {
288
+ this.emit({ type: "tool", name: block.name });
289
+ }
290
+ }
291
+ }
292
+
293
+ private handleRateLimit(info: StreamLine["rate_limit_info"]): void {
294
+ // `allowed` and `allowed_warning` are advisory (a warning fires at 90% of a window and the run
295
+ // goes on); only a refusal is reported, and it fails the turn only if the result confirms it.
296
+ if (!info || info.status === "allowed" || info.status === "allowed_warning") return;
297
+ const resetAt = typeof info.resetsAt === "number" ? new Date(info.resetsAt * 1000).toISOString() : undefined;
298
+ const message = `Rate limit ${info.status ?? "reported"}${info.rateLimitType ? ` (${info.rateLimitType})` : ""}`;
299
+ this.rateLimit = { message, resetAt };
300
+ this.emit(rateLimitedEvent(message, resetAt));
301
+ }
302
+
303
+ private handleResult(line: StreamLine): void {
304
+ const sessionId = this.sessionId;
305
+ const resultText = typeof line.result === "string" ? line.result : "";
306
+ const failure = (reason: TurnFailureReason, message: string, details?: string[]) =>
307
+ this.end({ type: "failed", reason, message, sessionId, details });
308
+ const isError = line.is_error === true || (line.subtype !== undefined && line.subtype !== "success");
309
+
310
+ if (!isError) {
311
+ const parsed = this.contract.parse(line.structured_output ?? resultText);
312
+ if (!parsed.ok) {
313
+ failure("invalid_report", `The Worker's final message is not a ${this.contract.name}`, parsed.errors);
314
+ } else if (sessionId === undefined) {
315
+ failure("vendor_error", "The Worker reported without ever naming its session");
316
+ } else {
317
+ this.end({
318
+ type: "completed",
319
+ sessionId,
320
+ ...completedEvent(this.contract, parsed.value, sessionId),
321
+ // Read off the vendor's final line and carried on the event; nothing consumes it yet.
322
+ // It is deliberately not written to the Run Record as a `spend` event: that event counts
323
+ // Opsee credits and per-model tokens, not a vendor's dollars for the operator's own
324
+ // subscription (see "Spend is not tracked yet" in the README).
325
+ costUsd: typeof line.total_cost_usd === "number" ? line.total_cost_usd : undefined,
326
+ numTurns: typeof line.num_turns === "number" ? line.num_turns : undefined,
327
+ });
328
+ }
329
+ return;
330
+ }
331
+
332
+ if (this.rateLimit) {
333
+ failure("rate_limited", this.rateLimit.message, resultText ? [resultText] : undefined);
334
+ return;
335
+ }
336
+ const errorTexts = Array.isArray(line.errors) ? (line.errors as unknown[]).map(String) : [];
337
+ const limitText = [resultText, ...errorTexts].find((t) => RATE_LIMIT_TEXT_PATTERNS.some((p) => p.test(t)));
338
+ if (limitText !== undefined) {
339
+ this.emit(rateLimitedEvent(limitText, resetAtFromLimitText(limitText)));
340
+ failure("rate_limited", limitText);
341
+ return;
342
+ }
343
+ // After the rate limit and before the catch-all: a refused credential is otherwise a
344
+ // `vendor_error`, indistinguishable from a bad working directory, and the Account keeps being
345
+ // handed Tasks all night (OPS-288).
346
+ const credentialText = [resultText, ...errorTexts].find((t) => CREDENTIAL_TEXT_PATTERNS.some((p) => p.test(t)));
347
+ if (credentialText !== undefined) {
348
+ failure("credential_failed", credentialText);
349
+ return;
350
+ }
351
+ switch (line.subtype) {
352
+ case "error_max_turns":
353
+ failure("max_turns", "The Worker reached its turn cap before it reported", resultText ? [resultText] : undefined);
354
+ return;
355
+ case "error_max_structured_output_retries":
356
+ failure("invalid_report", `The Worker could not produce a schema-valid ${this.contract.name}`, [resultText || "no final message"]);
357
+ return;
358
+ default:
359
+ failure("vendor_error", resultText || errorTexts.join("; ") || `result ${line.subtype ?? "error"}`);
360
+ }
361
+ }
362
+ }
363
+
364
+ export class ClaudeWorkerAdapter implements WorkerAdapter {
365
+ private readonly spawn: ProcessSpawner;
366
+ private readonly baseEnv: Readonly<Record<string, string | undefined>>;
367
+ private readonly onLine: ((line: string) => void) | undefined;
368
+
369
+ constructor(options: ClaudeWorkerAdapterOptions = {}) {
370
+ this.spawn = options.spawn ?? spawnProcess;
371
+ this.baseEnv = options.baseEnv ?? process.env;
372
+ this.onLine = options.onLine;
373
+ }
374
+
375
+ launch(request: TurnRequest): TurnHandle {
376
+ return this.start(request, undefined);
377
+ }
378
+
379
+ resume(sessionId: string, request: TurnRequest): TurnHandle {
380
+ return this.start(request, sessionId);
381
+ }
382
+ interactiveCommand(sessionId: string, request: Pick<TurnRequest, "account" | "cwd">): InteractiveCommand {
383
+ return { command: CLAUDE_BINARY, args: buildClaudeInteractiveArgs(sessionId), cwd: request.cwd, env: buildClaudeEnv(request.account, this.baseEnv) };
384
+ }
385
+
386
+ interactiveSession(request: InteractiveSessionRequest): InteractiveCommand {
387
+ return { command: CLAUDE_BINARY, args: buildClaudeInteractiveSessionArgs(request.prompt), cwd: request.cwd, env: buildClaudeEnv(request.account, this.baseEnv) };
388
+ }
389
+
390
+
391
+ private start(request: TurnRequest, resumeSessionId: string | undefined): TurnHandle {
392
+ let env: Record<string, string>;
393
+ try {
394
+ env = buildClaudeEnv(request.account, this.baseEnv, request.env, request.sandbox);
395
+ } catch (error) {
396
+ return failedHandle(errorMessage(error), launchFailureReason(error));
397
+ }
398
+ return new ClaudeTurn(
399
+ {
400
+ spawn: this.spawn,
401
+ command: CLAUDE_BINARY,
402
+ args: buildClaudeArgs(request, resumeSessionId),
403
+ options: { cwd: request.cwd, env },
404
+ stallTimeoutMs: request.stallTimeoutMs,
405
+ sessionId: resumeSessionId,
406
+ onLine: this.onLine,
407
+ },
408
+ request.contract ?? COMPLETION_REPORT_CONTRACT,
409
+ );
410
+ }
411
+ }
412
+