@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.
- package/README.md +1962 -0
- package/bin/opsee.js +28 -0
- package/package.json +40 -0
- package/skills/README.md +3 -0
- package/skills/to-issues/SKILL.md +92 -0
- package/skills/to-issues/agents/openai.yaml +5 -0
- package/skills/to-spec/SKILL.md +79 -0
- package/skills/to-spec/agents/openai.yaml +5 -0
- package/skills/wayfinder/SKILL.md +138 -0
- package/skills/wayfinder/agents/openai.yaml +5 -0
- package/src/args.ts +676 -0
- package/src/cli.ts +341 -0
- package/src/commands/account.ts +121 -0
- package/src/commands/deps.ts +11 -0
- package/src/commands/foreman-control.ts +242 -0
- package/src/commands/foreman-debug.ts +131 -0
- package/src/commands/foreman-plan.ts +213 -0
- package/src/commands/foreman-service.ts +186 -0
- package/src/commands/foreman-up.ts +165 -0
- package/src/commands/foreman-views.ts +398 -0
- package/src/commands/foreman.ts +465 -0
- package/src/commands/init.ts +176 -0
- package/src/commands/initiative.ts +192 -0
- package/src/commands/login.ts +24 -0
- package/src/commands/whoami.ts +15 -0
- package/src/foreman/account-store.ts +96 -0
- package/src/foreman/account.ts +474 -0
- package/src/foreman/claude-worker-adapter.ts +412 -0
- package/src/foreman/codex-worker-adapter.ts +472 -0
- package/src/foreman/completion-report.ts +153 -0
- package/src/foreman/core/context.ts +169 -0
- package/src/foreman/core/defects.ts +280 -0
- package/src/foreman/core/exec.ts +20 -0
- package/src/foreman/core/gates.ts +493 -0
- package/src/foreman/core/handoff.ts +163 -0
- package/src/foreman/core/install.ts +109 -0
- package/src/foreman/core/learnings.ts +368 -0
- package/src/foreman/core/outbox-tracker.ts +192 -0
- package/src/foreman/core/pin.ts +226 -0
- package/src/foreman/core/plan-context.ts +238 -0
- package/src/foreman/core/process-table.ts +535 -0
- package/src/foreman/core/reconcile.ts +227 -0
- package/src/foreman/core/report.ts +60 -0
- package/src/foreman/core/run.ts +2836 -0
- package/src/foreman/core/scheduler.ts +244 -0
- package/src/foreman/core/summary.ts +166 -0
- package/src/foreman/core/text.ts +97 -0
- package/src/foreman/core/transcripts.ts +38 -0
- package/src/foreman/core/triage.ts +138 -0
- package/src/foreman/core/verifier.ts +800 -0
- package/src/foreman/core/views.ts +940 -0
- package/src/foreman/core/work-contract.ts +152 -0
- package/src/foreman/core/workspace.ts +335 -0
- package/src/foreman/fake-handoff.ts +33 -0
- package/src/foreman/fake-learnings.ts +26 -0
- package/src/foreman/fake-remote-api.ts +70 -0
- package/src/foreman/fake-tracker-adapter.ts +355 -0
- package/src/foreman/fake-worker-adapter.ts +221 -0
- package/src/foreman/host.ts +75 -0
- package/src/foreman/local-dir.ts +28 -0
- package/src/foreman/opsee-tracker-adapter.ts +612 -0
- package/src/foreman/process-group.ts +160 -0
- package/src/foreman/remote-api.ts +283 -0
- package/src/foreman/run-recipe.ts +274 -0
- package/src/foreman/service-unit.ts +257 -0
- package/src/foreman/tracker-adapter.ts +298 -0
- package/src/foreman/triage-draft.ts +40 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/verdict.ts +120 -0
- package/src/foreman/worker-adapter.ts +177 -0
- package/src/foreman/worker-process.ts +488 -0
- package/src/identity.ts +49 -0
- package/src/index.ts +3 -0
- package/src/init/managed.ts +84 -0
- package/src/init/mcp-config.ts +77 -0
- package/src/init/paths.ts +16 -0
- package/src/init/pointer-block.ts +45 -0
- package/src/init/project.ts +22 -0
- package/src/init/prompt.ts +45 -0
- package/src/init/run-recipe-config.ts +133 -0
- package/src/init/skills.ts +38 -0
- package/src/init/text.ts +22 -0
- package/src/init/tracker-doc.ts +106 -0
- package/src/opsee-config.ts +116 -0
- 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
|
+
|