@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,472 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Account } from "./account.js";
|
|
5
|
+
import { COMPLETION_REPORT_CONTRACT, COMPLETION_REPORT_JSON_SCHEMA, completedEvent } from "./completion-report.js";
|
|
6
|
+
import { VENDOR_CONFIG_DIR_ENV } from "./vendor.js";
|
|
7
|
+
import type { InteractiveCommand, InteractiveSessionRequest, McpServerSpec, OutputContract, TurnFailureReason, TurnHandle, TurnRequest, TurnSandbox, WorkerAdapter } from "./worker-adapter.js";
|
|
8
|
+
import {
|
|
9
|
+
buildWorkerEnv,
|
|
10
|
+
errorMessage,
|
|
11
|
+
failedHandle,
|
|
12
|
+
launchFailureReason,
|
|
13
|
+
ProcessTurn,
|
|
14
|
+
rateLimitedEvent,
|
|
15
|
+
spawnProcess,
|
|
16
|
+
type ProcessSpawner,
|
|
17
|
+
type ProcessTurnSpec,
|
|
18
|
+
} from "./worker-process.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The Worker Adapter for Codex (ADR-0001): the vendor's own `codex` binary in non-interactive
|
|
22
|
+
* `exec` mode with JSONL output, under one Account and pinned to one directory. It answers to the
|
|
23
|
+
* same interface and the same contract suite as the Claude Code adapter, so every later Foreman
|
|
24
|
+
* feature runs on either vendor.
|
|
25
|
+
*
|
|
26
|
+
* Flags, checked against `codex exec --help` and `codex exec resume --help` of codex-cli 0.147.0
|
|
27
|
+
* and against two recorded runs of it (the happy-turn and resumed-turn fixtures), plus the docs
|
|
28
|
+
* at https://learn.chatgpt.com/docs/non-interactive-mode (where developers.openai.com/codex
|
|
29
|
+
* redirects) and the event vocabulary in codex-rs/exec/src/exec_events.rs:
|
|
30
|
+
*
|
|
31
|
+
* - `exec [PROMPT]` runs one turn without a terminal; `--json` prints one event per line:
|
|
32
|
+
* `thread.started` (with `thread_id`, the only place the session id appears), `turn.started`,
|
|
33
|
+
* `item.started|updated|completed` (items typed `agent_message`, `reasoning`,
|
|
34
|
+
* `command_execution`, `file_change`, `mcp_tool_call`, `web_search`, `todo_list`, `error`),
|
|
35
|
+
* then `turn.completed` (token `usage`, no cost) or `turn.failed` (`error.message`). A
|
|
36
|
+
* top-level `error` event is non-fatal on its own; the run ends only with a `turn.*` line.
|
|
37
|
+
* - `--output-schema <FILE>` takes a *path* to a JSON Schema for the final response. Codex hands
|
|
38
|
+
* it to the model as a strict structured output, and the OpenAI endpoint rejects the shared
|
|
39
|
+
* report schema verbatim ("'required' is required to be supplied and to be an array including
|
|
40
|
+
* every key in properties", observed live), so `CODEX_OUTPUT_SCHEMA` below is the strict
|
|
41
|
+
* projection of `COMPLETION_REPORT_JSON_SCHEMA`: every property required and the optional ones
|
|
42
|
+
* nullable. The report then arrives as the text of the last `agent_message` item and the nulls
|
|
43
|
+
* are folded away before the shared validator sees it. Each turn writes the schema to its own
|
|
44
|
+
* temporary file and removes it when the turn ends.
|
|
45
|
+
* - `-s workspace-write` lets an unattended Worker edit its Workspace without an approval nobody
|
|
46
|
+
* is there to give (exec defaults to a read-only sandbox). `exec resume` has no `-s`, so both
|
|
47
|
+
* forms set it through `-c sandbox_mode="workspace-write"`, which is the same setting. A
|
|
48
|
+
* `readonly-browser` turn (`TurnRequest.sandbox`, the Verifier's) gets
|
|
49
|
+
* `-c sandbox_mode="read-only"` and `-c approval_policy="never"` instead (the config keys
|
|
50
|
+
* behind `-s read-only` and `-a never`; `exec` has no `-a` of its own and `exec resume` has
|
|
51
|
+
* neither flag), so a command the model proposes runs read-only and a request for more is
|
|
52
|
+
* refused rather than waited on.
|
|
53
|
+
* - `--skip-git-repo-check`: a Workspace is a git worktree, but the debug command's cwd need not
|
|
54
|
+
* be, and the check would otherwise refuse to start.
|
|
55
|
+
* - `-c mcp_servers.<name>.command=...` and `-c mcp_servers.<name>.args=[...]` add an MCP server
|
|
56
|
+
* for the turn the way `~/.codex/config.toml` would (`[mcp_servers.<name>]`, the same keys;
|
|
57
|
+
* https://developers.openai.com/codex/mcp): the Verifier's Playwright MCP, core/verifier.ts.
|
|
58
|
+
* The value of `-c` is parsed as TOML, so strings are quoted and the arguments are an array.
|
|
59
|
+
* - `exec resume <SESSION_ID> [PROMPT]` continues a stored session; its `thread.started` line
|
|
60
|
+
* carries the resumed id. There is no `-C` on resume; the cwd is the spawn's, as it is for
|
|
61
|
+
* `exec`. Codex has no per-turn cap, so `TurnRequest.maxTurns` is not applied and this adapter
|
|
62
|
+
* never fails with `max_turns`.
|
|
63
|
+
* - Stdin is closed at spawn (`stdio: "ignore"`); Codex notes "Reading additional input from
|
|
64
|
+
* stdin..." on stderr, reads the empty stream and goes on.
|
|
65
|
+
*
|
|
66
|
+
* Rate limits do not have an event of their own in the exec stream (exec_events.rs defines none;
|
|
67
|
+
* the JSONL processor caches token usage and drops the rest), so they are recognised from the
|
|
68
|
+
* message of `turn.failed` or a preceding `error` line, with the strings in
|
|
69
|
+
* codex-rs/protocol/src/error.rs: "You've hit your usage limit. ... Try again at <local time>."
|
|
70
|
+
* on a ChatGPT plan, "rate limit exceeded: ..." / "exceeded retry limit, last status: 429" on an
|
|
71
|
+
* API key, "Quota exceeded. ...".
|
|
72
|
+
*
|
|
73
|
+
* The credential boundary (ADR-0013) is kept the way the Claude adapter keeps it: a subscription
|
|
74
|
+
* Account is named to the process only through `CODEX_HOME`, an API-key Account only through the
|
|
75
|
+
* value of the variable it names, copied into `CODEX_API_KEY`, which `codex exec` takes ahead of
|
|
76
|
+
* any stored login ("API key via env var takes precedence over any other auth method",
|
|
77
|
+
* codex-rs/login/src/auth/manager.rs, `load_auth`; `OPENAI_API_KEY` is read only by the
|
|
78
|
+
* interactive onboarding). Nothing here opens a path inside the config directory, and
|
|
79
|
+
* account-boundary.test.ts runs this adapter under the same fs audit as registration.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
export const CODEX_BINARY = "codex";
|
|
83
|
+
export const CODEX_SANDBOX_MODE = "workspace-write";
|
|
84
|
+
/** The sandbox and approval policy of a `readonly-browser` turn. */
|
|
85
|
+
export const CODEX_READONLY_SANDBOX_MODE = "read-only";
|
|
86
|
+
export const CODEX_READONLY_APPROVAL_POLICY = "never";
|
|
87
|
+
/** The variable `codex exec` reads an API key from, ahead of `auth.json` in `CODEX_HOME`. */
|
|
88
|
+
export const CODEX_API_KEY_ENV = "CODEX_API_KEY";
|
|
89
|
+
/** File name of the per-turn schema file handed to `--output-schema`. */
|
|
90
|
+
export const CODEX_SCHEMA_FILE = "completion-report.schema.json";
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `COMPLETION_REPORT_JSON_SCHEMA` as the OpenAI structured-output endpoint accepts it: every key
|
|
94
|
+
* required, so `handOff` becomes nullable and its two fields nullable-and-required. The two
|
|
95
|
+
* schemas describe the same reports; `normalizeCodexReport` maps one shape onto the other, and
|
|
96
|
+
* the test pins that the projection is derived from the shared schema rather than a copy.
|
|
97
|
+
*/
|
|
98
|
+
export const CODEX_OUTPUT_SCHEMA = codexOutputSchema(COMPLETION_REPORT_CONTRACT);
|
|
99
|
+
|
|
100
|
+
type JsonSchema = { type?: string | string[]; properties?: Record<string, unknown>; required?: readonly string[]; anyOf?: unknown[]; [key: string]: unknown };
|
|
101
|
+
|
|
102
|
+
/** The strict projection of a contract's schema: what `--output-schema` gets for that turn. */
|
|
103
|
+
export function codexOutputSchema(contract: Pick<OutputContract, "jsonSchema">): Record<string, unknown> {
|
|
104
|
+
return strictProjection(contract.jsonSchema as JsonSchema);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function strictProjection(schema: JsonSchema): Record<string, unknown> {
|
|
108
|
+
const project = (node: JsonSchema, nullable: boolean): Record<string, unknown> => {
|
|
109
|
+
if (node.type !== "object" || !node.properties) {
|
|
110
|
+
if (!nullable) return { ...node };
|
|
111
|
+
const type = Array.isArray(node.type) ? node.type : node.type ? [node.type] : [];
|
|
112
|
+
return { ...node, type: type.includes("null") ? type : [...type, "null"] };
|
|
113
|
+
}
|
|
114
|
+
const required = new Set(node.required ?? []);
|
|
115
|
+
const properties: Record<string, unknown> = {};
|
|
116
|
+
for (const [name, child] of Object.entries(node.properties)) {
|
|
117
|
+
properties[name] = project(child as JsonSchema, !required.has(name));
|
|
118
|
+
}
|
|
119
|
+
const object = { ...node, properties, required: Object.keys(node.properties) };
|
|
120
|
+
return nullable ? { anyOf: [object, { type: "null" }] } : object;
|
|
121
|
+
};
|
|
122
|
+
return project(schema, false);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Folds the strict projection's nulls back into the contract's own shape: a null where the
|
|
126
|
+
* contract's schema has an optional key is an absent key, at every level of nested objects.
|
|
127
|
+
* Anything else is left for the validator to judge. */
|
|
128
|
+
export function foldNulls(schema: Pick<OutputContract, "jsonSchema">, candidate: unknown): unknown {
|
|
129
|
+
const fold = (node: JsonSchema | undefined, value: unknown): unknown => {
|
|
130
|
+
if (!node || node.type !== "object" || !node.properties || typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
|
131
|
+
const required = new Set(node.required ?? []);
|
|
132
|
+
const record: Record<string, unknown> = {};
|
|
133
|
+
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
|
134
|
+
if (v === null && key in node.properties && !required.has(key)) continue;
|
|
135
|
+
record[key] = fold(node.properties[key] as JsonSchema | undefined, v);
|
|
136
|
+
}
|
|
137
|
+
return record;
|
|
138
|
+
};
|
|
139
|
+
return fold(schema.jsonSchema as JsonSchema, candidate);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `foldNulls` for the Completion Report: a null `handOff` is an absent one, and null
|
|
143
|
+
* `branch`/`prUrl` inside it are absent too. */
|
|
144
|
+
export function normalizeCodexReport(candidate: unknown): unknown {
|
|
145
|
+
return foldNulls({ jsonSchema: COMPLETION_REPORT_JSON_SCHEMA as unknown as Record<string, unknown> }, candidate);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface CodexWorkerAdapterOptions {
|
|
149
|
+
spawn?: ProcessSpawner;
|
|
150
|
+
/** Base environment the Worker inherits before the Account's variables are applied. */
|
|
151
|
+
baseEnv?: Readonly<Record<string, string | undefined>>;
|
|
152
|
+
/** Sees every raw stdout line before it is parsed; the debug command uses it to re-record fixtures. */
|
|
153
|
+
onLine?: (line: string) => void;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The Worker's environment for one Account (see `buildWorkerEnv`). */
|
|
157
|
+
export function buildCodexEnv(
|
|
158
|
+
account: Account,
|
|
159
|
+
baseEnv: Readonly<Record<string, string | undefined>>,
|
|
160
|
+
overrides: Readonly<Record<string, string>> = {},
|
|
161
|
+
sandbox: TurnSandbox = "worker",
|
|
162
|
+
): Record<string, string> {
|
|
163
|
+
return buildWorkerEnv(account, { configDir: VENDOR_CONFIG_DIR_ENV.codex, apiKey: CODEX_API_KEY_ENV }, baseEnv, overrides, sandbox);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The attended turn (story 53): `codex resume <session-id>`, the interactive TUI's own resume of a
|
|
167
|
+
* stored session (`codex resume --help` of codex-cli 0.147.0: "Resume a previous interactive
|
|
168
|
+
* session"), which is the same session `exec resume` continues unattended. No flags: `resume` has
|
|
169
|
+
* no `--skip-git-repo-check` (that one is `exec`'s, and `resume` rejects it), and the cwd is the
|
|
170
|
+
* spawn's, the Workspace, which is a git worktree anyway. */
|
|
171
|
+
export function buildCodexInteractiveArgs(sessionId: string): string[] {
|
|
172
|
+
return ["resume", sessionId];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A TOML value for `-c`: JSON's string and array-of-string forms are valid TOML for the plain
|
|
176
|
+
* ASCII a command line carries; an inline table for the environment. */
|
|
177
|
+
function toml(value: string | string[] | Readonly<Record<string, string>>): string {
|
|
178
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
179
|
+
if (Array.isArray(value)) return `[${value.map((v) => JSON.stringify(v)).join(", ")}]`;
|
|
180
|
+
return `{ ${Object.entries(value).map(([k, v]) => `${JSON.stringify(k)} = ${JSON.stringify(v)}`).join(", ")} }`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The `-c` overrides that add the request's MCP servers for this turn. */
|
|
184
|
+
export function codexMcpOverrides(servers: Readonly<Record<string, McpServerSpec>>): string[] {
|
|
185
|
+
const args: string[] = [];
|
|
186
|
+
for (const [name, s] of Object.entries(servers)) {
|
|
187
|
+
args.push("-c", `mcp_servers.${name}.command=${toml(s.command)}`, "-c", `mcp_servers.${name}.args=${toml(s.args)}`);
|
|
188
|
+
if (s.env) args.push("-c", `mcp_servers.${name}.env=${toml(s.env)}`);
|
|
189
|
+
}
|
|
190
|
+
return args;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The attended planning session (story 10): `codex <prompt>`, the interactive TUI with the prompt
|
|
194
|
+
* as its first message (`codex [OPTIONS] [PROMPT]` in `codex --help` of codex-cli 0.147.0). No
|
|
195
|
+
* `exec`, no sandbox flag, no schema: the human is there to answer, and `--skip-git-repo-check`
|
|
196
|
+
* is `exec`'s alone. */
|
|
197
|
+
export function buildCodexInteractiveSessionArgs(prompt: string): string[] {
|
|
198
|
+
return [prompt];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The `codex` command line for one turn; `schemaPath` is where this turn's schema file is. */
|
|
202
|
+
export function buildCodexArgs(request: TurnRequest, schemaPath: string, resumeSessionId?: string): string[] {
|
|
203
|
+
const args = ["exec"];
|
|
204
|
+
if (resumeSessionId !== undefined) args.push("resume", resumeSessionId);
|
|
205
|
+
if (request.sandbox === "readonly-browser") {
|
|
206
|
+
args.push("--json", "--skip-git-repo-check", "-c", `sandbox_mode=${JSON.stringify(CODEX_READONLY_SANDBOX_MODE)}`, "-c", `approval_policy=${JSON.stringify(CODEX_READONLY_APPROVAL_POLICY)}`);
|
|
207
|
+
} else {
|
|
208
|
+
args.push("--json", "--skip-git-repo-check", "-c", `sandbox_mode=${JSON.stringify(CODEX_SANDBOX_MODE)}`);
|
|
209
|
+
}
|
|
210
|
+
args.push(...codexMcpOverrides(request.mcpServers ?? {}));
|
|
211
|
+
args.push("--output-schema", schemaPath, request.prompt);
|
|
212
|
+
return args;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** How a rate limit reads in a `turn.failed` or `error` message (codex-rs/protocol/src/error.rs).
|
|
216
|
+
* Matched only against those, never against a Worker's own report. */
|
|
217
|
+
export const CODEX_RATE_LIMIT_PATTERNS: readonly RegExp[] = [
|
|
218
|
+
/hit your usage limit/i,
|
|
219
|
+
/exceeded retry limit, last status: 429/i,
|
|
220
|
+
/quota exceeded/i,
|
|
221
|
+
/too many requests/i,
|
|
222
|
+
/rate limit/i,
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* How a dead credential reads in a `turn.failed` or `error` message (OPS-288).
|
|
227
|
+
*
|
|
228
|
+
* Codex has no auth variant in `CodexErr` the way it has rate-limit ones, so its authentication
|
|
229
|
+
* failures arrive as the transport's own status text — `unexpected status 401 Unauthorized`, and
|
|
230
|
+
* the `exceeded retry limit, last status: 401` form that is the exact sibling of the 429 already
|
|
231
|
+
* matched above (openai/codex issues 5456, 13838, 19743). The two remaining patterns are the
|
|
232
|
+
* messages the token-refresh path produces when the stored login is gone.
|
|
233
|
+
*
|
|
234
|
+
* Matched only against `turn.failed` and `error` text, and only after `CODEX_RATE_LIMIT_PATTERNS`:
|
|
235
|
+
* `last status: 429` and `last status: 401` differ by three characters, and getting that ordering
|
|
236
|
+
* wrong would quarantine an Account the vendor had merely throttled.
|
|
237
|
+
*/
|
|
238
|
+
export const CODEX_CREDENTIAL_PATTERNS: readonly RegExp[] = [
|
|
239
|
+
/401 unauthorized/i,
|
|
240
|
+
/status: 401/i,
|
|
241
|
+
/refresh token was revoked/i,
|
|
242
|
+
/no auth credentials found/i,
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Codex prints the reset as local wall-clock text: " Try again at 3:10 AM." on the same day,
|
|
247
|
+
* " Try again at Sep 8th, 2026 3:10 AM." otherwise (`format_retry_timestamp`). The dated form is
|
|
248
|
+
* parsed in this machine's zone, which is the zone Codex formatted it in; the time-only form
|
|
249
|
+
* names no date and is left to the scheduler's default, as the Claude adapter leaves "resets 5am".
|
|
250
|
+
*/
|
|
251
|
+
export function resetAtFromCodexLimitText(text: string): string | undefined {
|
|
252
|
+
const match = /try again at ([^.]*?\d{4} \d{1,2}:\d{2} [AP]M)\./i.exec(text);
|
|
253
|
+
if (!match) return undefined;
|
|
254
|
+
const parsed = new Date(match[1].replace(/(\d)(st|nd|rd|th),/, "$1,"));
|
|
255
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface CodexItem {
|
|
259
|
+
id?: string;
|
|
260
|
+
type?: string;
|
|
261
|
+
text?: string;
|
|
262
|
+
message?: string;
|
|
263
|
+
server?: string;
|
|
264
|
+
tool?: string;
|
|
265
|
+
status?: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
interface CodexEvent {
|
|
269
|
+
type?: string;
|
|
270
|
+
thread_id?: string;
|
|
271
|
+
item?: CodexItem;
|
|
272
|
+
error?: { message?: string };
|
|
273
|
+
message?: string;
|
|
274
|
+
usage?: unknown;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Item types that are the Worker doing something, reported as `tool` when they start. */
|
|
278
|
+
const TOOL_ITEMS = new Set(["command_execution", "file_change", "mcp_tool_call", "web_search"]);
|
|
279
|
+
|
|
280
|
+
/** Translates one `codex exec --json` stream into the adapter's event stream; one per turn. */
|
|
281
|
+
class CodexTurn extends ProcessTurn {
|
|
282
|
+
private lastAgentMessage: string | undefined;
|
|
283
|
+
private rateLimit: { message: string; resetAt?: string } | undefined;
|
|
284
|
+
private lastError: string | undefined;
|
|
285
|
+
|
|
286
|
+
constructor(
|
|
287
|
+
spec: ProcessTurnSpec,
|
|
288
|
+
private readonly schemaDir: string,
|
|
289
|
+
private readonly contract: OutputContract,
|
|
290
|
+
) {
|
|
291
|
+
super(spec);
|
|
292
|
+
this.begin();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
protected cleanup(): void {
|
|
296
|
+
rmSync(this.schemaDir, { recursive: true, force: true });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
protected handleMessage(parsed: unknown): void {
|
|
300
|
+
const event = parsed as CodexEvent;
|
|
301
|
+
switch (event.type) {
|
|
302
|
+
case "thread.started":
|
|
303
|
+
if (typeof event.thread_id === "string" && event.thread_id !== "") {
|
|
304
|
+
this.sessionId = event.thread_id;
|
|
305
|
+
this.emit({ type: "started", sessionId: event.thread_id });
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
case "item.started":
|
|
309
|
+
this.handleItemStarted(event.item);
|
|
310
|
+
return;
|
|
311
|
+
case "item.completed":
|
|
312
|
+
this.handleItemCompleted(event.item);
|
|
313
|
+
return;
|
|
314
|
+
case "error":
|
|
315
|
+
this.handleError(typeof event.message === "string" ? event.message : "");
|
|
316
|
+
return;
|
|
317
|
+
case "turn.completed":
|
|
318
|
+
this.handleCompleted();
|
|
319
|
+
return;
|
|
320
|
+
case "turn.failed":
|
|
321
|
+
this.handleFailed(typeof event.error?.message === "string" ? event.error.message : "");
|
|
322
|
+
return;
|
|
323
|
+
default:
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private handleItemStarted(item: CodexItem | undefined): void {
|
|
329
|
+
if (!item?.type || !TOOL_ITEMS.has(item.type)) return;
|
|
330
|
+
const name = item.type === "mcp_tool_call" && item.server && item.tool ? `mcp:${item.server}/${item.tool}` : item.type;
|
|
331
|
+
this.emit({ type: "tool", name });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private handleItemCompleted(item: CodexItem | undefined): void {
|
|
335
|
+
if (!item) return;
|
|
336
|
+
if (item.type === "agent_message" && typeof item.text === "string" && item.text !== "") {
|
|
337
|
+
// Under --output-schema every message is schema-shaped, so the report is the last non-empty
|
|
338
|
+
// one; the earlier ones are progress and shown as such, never parsed.
|
|
339
|
+
this.lastAgentMessage = item.text;
|
|
340
|
+
this.emit({ type: "output", text: item.text });
|
|
341
|
+
} else if (item.type === "error" && typeof item.message === "string" && item.message !== "") {
|
|
342
|
+
// Warnings (config, deprecation, model rerouted) arrive as error items; progress, not failure.
|
|
343
|
+
this.emit({ type: "output", text: item.message });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** A top-level error does not end the run by itself (the processor keeps going and `turn.*`
|
|
348
|
+
* decides); it is remembered for the failure that may follow and reported at once when it is a
|
|
349
|
+
* rate limit, so the scheduler can pause the Account even if the turn goes on to complete. */
|
|
350
|
+
private handleError(message: string): void {
|
|
351
|
+
if (message === "") return;
|
|
352
|
+
this.lastError = message;
|
|
353
|
+
if (!this.rateLimit && CODEX_RATE_LIMIT_PATTERNS.some((p) => p.test(message))) {
|
|
354
|
+
const resetAt = resetAtFromCodexLimitText(message);
|
|
355
|
+
this.rateLimit = { message, resetAt };
|
|
356
|
+
this.emit(rateLimitedEvent(message, resetAt));
|
|
357
|
+
} else {
|
|
358
|
+
this.emit({ type: "output", text: message });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private handleCompleted(): void {
|
|
363
|
+
const sessionId = this.sessionId;
|
|
364
|
+
const failure = (reason: TurnFailureReason, message: string, details?: string[]) =>
|
|
365
|
+
this.end({ type: "failed", reason, message, sessionId, details });
|
|
366
|
+
if (this.lastAgentMessage === undefined) {
|
|
367
|
+
failure("invalid_report", "The Worker finished without a final message", ["no final message"]);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
let candidate: unknown = this.lastAgentMessage;
|
|
371
|
+
try {
|
|
372
|
+
candidate = foldNulls(this.contract, JSON.parse(this.lastAgentMessage));
|
|
373
|
+
} catch {
|
|
374
|
+
// Not JSON: the validator says so in its own words.
|
|
375
|
+
}
|
|
376
|
+
const parsed = this.contract.parse(candidate);
|
|
377
|
+
if (!parsed.ok) {
|
|
378
|
+
failure("invalid_report", `The Worker's final message is not a ${this.contract.name}`, parsed.errors);
|
|
379
|
+
} else if (sessionId === undefined) {
|
|
380
|
+
failure("vendor_error", "The Worker reported without ever naming its session");
|
|
381
|
+
} else {
|
|
382
|
+
this.end({ type: "completed", sessionId, ...completedEvent(this.contract, parsed.value, sessionId) });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private handleFailed(message: string): void {
|
|
387
|
+
const sessionId = this.sessionId;
|
|
388
|
+
const text = message || this.lastError || "turn failed";
|
|
389
|
+
if (this.rateLimit) {
|
|
390
|
+
this.end({ type: "failed", reason: "rate_limited", message: this.rateLimit.message, sessionId, details: text !== this.rateLimit.message ? [text] : undefined });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (CODEX_RATE_LIMIT_PATTERNS.some((p) => p.test(text))) {
|
|
394
|
+
this.emit(rateLimitedEvent(text, resetAtFromCodexLimitText(text)));
|
|
395
|
+
this.end({ type: "failed", reason: "rate_limited", message: text, sessionId });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
// After the rate limit, so `last status: 429` is never read as `last status: 401` (OPS-288).
|
|
399
|
+
if (CODEX_CREDENTIAL_PATTERNS.some((p) => p.test(text))) {
|
|
400
|
+
this.end({ type: "failed", reason: "credential_failed", message: text, sessionId });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
this.end({ type: "failed", reason: "vendor_error", message: text, sessionId });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export class CodexWorkerAdapter implements WorkerAdapter {
|
|
408
|
+
private readonly spawn: ProcessSpawner;
|
|
409
|
+
private readonly baseEnv: Readonly<Record<string, string | undefined>>;
|
|
410
|
+
private readonly onLine: ((line: string) => void) | undefined;
|
|
411
|
+
|
|
412
|
+
constructor(options: CodexWorkerAdapterOptions = {}) {
|
|
413
|
+
this.spawn = options.spawn ?? spawnProcess;
|
|
414
|
+
this.baseEnv = options.baseEnv ?? process.env;
|
|
415
|
+
this.onLine = options.onLine;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
launch(request: TurnRequest): TurnHandle {
|
|
419
|
+
return this.start(request, undefined);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
resume(sessionId: string, request: TurnRequest): TurnHandle {
|
|
423
|
+
return this.start(request, sessionId);
|
|
424
|
+
}
|
|
425
|
+
interactiveCommand(sessionId: string, request: Pick<TurnRequest, "account" | "cwd">): InteractiveCommand {
|
|
426
|
+
return { command: CODEX_BINARY, args: buildCodexInteractiveArgs(sessionId), cwd: request.cwd, env: buildCodexEnv(request.account, this.baseEnv) };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
interactiveSession(request: InteractiveSessionRequest): InteractiveCommand {
|
|
430
|
+
return { command: CODEX_BINARY, args: buildCodexInteractiveSessionArgs(request.prompt), cwd: request.cwd, env: buildCodexEnv(request.account, this.baseEnv) };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
private start(request: TurnRequest, resumeSessionId: string | undefined): TurnHandle {
|
|
435
|
+
let env: Record<string, string>;
|
|
436
|
+
try {
|
|
437
|
+
env = buildCodexEnv(request.account, this.baseEnv, request.env, request.sandbox);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
return failedHandle(errorMessage(error), launchFailureReason(error));
|
|
440
|
+
}
|
|
441
|
+
// The schema goes to a temp file of its own, never into the Workspace, whose contents belong
|
|
442
|
+
// to the Worker; the turn removes the directory when it ends.
|
|
443
|
+
let schemaDir: string;
|
|
444
|
+
try {
|
|
445
|
+
schemaDir = mkdtempSync(join(tmpdir(), "opsee-foreman-codex-"));
|
|
446
|
+
} catch (error) {
|
|
447
|
+
return failedHandle(`Could not create the schema directory: ${errorMessage(error)}`);
|
|
448
|
+
}
|
|
449
|
+
const contract = request.contract ?? COMPLETION_REPORT_CONTRACT;
|
|
450
|
+
const schemaPath = join(schemaDir, CODEX_SCHEMA_FILE);
|
|
451
|
+
try {
|
|
452
|
+
writeFileSync(schemaPath, JSON.stringify(contract === COMPLETION_REPORT_CONTRACT ? CODEX_OUTPUT_SCHEMA : codexOutputSchema(contract)), { mode: 0o600 });
|
|
453
|
+
} catch (error) {
|
|
454
|
+
rmSync(schemaDir, { recursive: true, force: true });
|
|
455
|
+
return failedHandle(`Could not write the schema file: ${errorMessage(error)}`);
|
|
456
|
+
}
|
|
457
|
+
return new CodexTurn(
|
|
458
|
+
{
|
|
459
|
+
spawn: this.spawn,
|
|
460
|
+
command: CODEX_BINARY,
|
|
461
|
+
args: buildCodexArgs(request, schemaPath, resumeSessionId),
|
|
462
|
+
options: { cwd: request.cwd, env },
|
|
463
|
+
stallTimeoutMs: request.stallTimeoutMs,
|
|
464
|
+
sessionId: resumeSessionId,
|
|
465
|
+
onLine: this.onLine,
|
|
466
|
+
},
|
|
467
|
+
schemaDir,
|
|
468
|
+
contract,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Completion Report (see ../../CONTEXT.md): the structured record a Worker produces at the end
|
|
3
|
+
* of a turn. It is what the Worker Adapter demands as the final message of every unattended turn,
|
|
4
|
+
* so a turn that ends without one is a typed failure, never a free-text guess (story 43).
|
|
5
|
+
*/
|
|
6
|
+
import type { OutputContract } from "./worker-adapter.js";
|
|
7
|
+
|
|
8
|
+
export const OUTCOMES = ["done", "blocked", "failed"] as const;
|
|
9
|
+
export type Outcome = (typeof OUTCOMES)[number];
|
|
10
|
+
|
|
11
|
+
export interface HandOff {
|
|
12
|
+
branch?: string;
|
|
13
|
+
prUrl?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CompletionReport {
|
|
17
|
+
outcome: Outcome;
|
|
18
|
+
/** One paragraph for the human who reads the Tracker comment. */
|
|
19
|
+
summary: string;
|
|
20
|
+
/** Choices made that a later Worker or reviewer would otherwise re-litigate. */
|
|
21
|
+
decisions: string[];
|
|
22
|
+
/** What was attempted, including what did not work; the memory that stops a sibling retrying it. */
|
|
23
|
+
tried: string[];
|
|
24
|
+
/** Why the outcome is not `done`, when it is not. */
|
|
25
|
+
blockers: string[];
|
|
26
|
+
/** Proposed Learnings: reusable observations about the repo, not yet trusted. */
|
|
27
|
+
proposedLearnings: string[];
|
|
28
|
+
/** The Hand-off, when the Worker opened one. */
|
|
29
|
+
handOff?: HandOff;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The same shape as a JSON Schema for the vendor's structured-output flag. Declared once here and
|
|
34
|
+
* checked against `CompletionReport` by the test, so the two cannot drift.
|
|
35
|
+
*/
|
|
36
|
+
export const COMPLETION_REPORT_JSON_SCHEMA = {
|
|
37
|
+
type: "object",
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
required: ["outcome", "summary", "decisions", "tried", "blockers", "proposedLearnings"],
|
|
40
|
+
properties: {
|
|
41
|
+
outcome: { type: "string", enum: [...OUTCOMES] },
|
|
42
|
+
summary: { type: "string" },
|
|
43
|
+
decisions: { type: "array", items: { type: "string" } },
|
|
44
|
+
tried: { type: "array", items: { type: "string" } },
|
|
45
|
+
blockers: { type: "array", items: { type: "string" } },
|
|
46
|
+
proposedLearnings: { type: "array", items: { type: "string" } },
|
|
47
|
+
handOff: {
|
|
48
|
+
type: "object",
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
properties: {
|
|
51
|
+
branch: { type: "string" },
|
|
52
|
+
prUrl: { type: "string" },
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
export type ParsedReport = { ok: true; report: CompletionReport } | { ok: false; errors: string[] };
|
|
59
|
+
|
|
60
|
+
const STRING_LISTS = ["decisions", "tried", "blockers", "proposedLearnings"] as const;
|
|
61
|
+
|
|
62
|
+
function isStringArray(value: unknown): value is string[] {
|
|
63
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validates a candidate report. A string is parsed as JSON first, since a vendor that fails to
|
|
68
|
+
* apply the schema still tends to hand the object back as text. Every defect is reported, not only
|
|
69
|
+
* the first, because the list goes into the failure event a human reads.
|
|
70
|
+
*/
|
|
71
|
+
export function parseCompletionReport(candidate: unknown): ParsedReport {
|
|
72
|
+
let value = candidate;
|
|
73
|
+
if (typeof value === "string") {
|
|
74
|
+
try {
|
|
75
|
+
value = JSON.parse(value);
|
|
76
|
+
} catch {
|
|
77
|
+
return { ok: false, errors: ["report is not JSON"] };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
81
|
+
return { ok: false, errors: ["report must be an object"] };
|
|
82
|
+
}
|
|
83
|
+
const record = value as Record<string, unknown>;
|
|
84
|
+
const errors: string[] = [];
|
|
85
|
+
|
|
86
|
+
if (!(OUTCOMES as readonly unknown[]).includes(record.outcome)) {
|
|
87
|
+
errors.push(`outcome must be one of ${OUTCOMES.map((o) => `"${o}"`).join(", ")}`);
|
|
88
|
+
}
|
|
89
|
+
if (typeof record.summary !== "string") {
|
|
90
|
+
errors.push("summary must be a string");
|
|
91
|
+
}
|
|
92
|
+
for (const field of STRING_LISTS) {
|
|
93
|
+
if (!isStringArray(record[field])) errors.push(`${field} must be an array of strings`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let handOff: HandOff | undefined;
|
|
97
|
+
if (record.handOff !== undefined) {
|
|
98
|
+
if (typeof record.handOff !== "object" || record.handOff === null || Array.isArray(record.handOff)) {
|
|
99
|
+
errors.push("handOff must be an object");
|
|
100
|
+
} else {
|
|
101
|
+
const raw = record.handOff as Record<string, unknown>;
|
|
102
|
+
handOff = {};
|
|
103
|
+
for (const field of ["branch", "prUrl"] as const) {
|
|
104
|
+
if (raw[field] === undefined) continue;
|
|
105
|
+
if (typeof raw[field] !== "string") errors.push(`handOff.${field} must be a string`);
|
|
106
|
+
else handOff[field] = raw[field];
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
112
|
+
const report: CompletionReport = {
|
|
113
|
+
outcome: record.outcome as Outcome,
|
|
114
|
+
summary: record.summary as string,
|
|
115
|
+
decisions: record.decisions as string[],
|
|
116
|
+
tried: record.tried as string[],
|
|
117
|
+
blockers: record.blockers as string[],
|
|
118
|
+
proposedLearnings: record.proposedLearnings as string[],
|
|
119
|
+
};
|
|
120
|
+
if (handOff) report.handOff = handOff;
|
|
121
|
+
return { ok: true, report };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The Completion Report as an output contract (worker-adapter.ts `OutputContract`): what every
|
|
125
|
+
* implementing turn runs under when its request names none. */
|
|
126
|
+
export const COMPLETION_REPORT_CONTRACT: OutputContract<CompletionReport> = {
|
|
127
|
+
name: "Completion Report",
|
|
128
|
+
jsonSchema: COMPLETION_REPORT_JSON_SCHEMA as unknown as Record<string, unknown>,
|
|
129
|
+
parse(candidate) {
|
|
130
|
+
const parsed = parseCompletionReport(candidate);
|
|
131
|
+
return parsed.ok ? { ok: true, value: parsed.report } : parsed;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** The stand-in Completion Report a turn under another contract completes with: the stream's
|
|
136
|
+
* `completed` event always carries a report, and this one says where the real result is. */
|
|
137
|
+
export function contractReport(contract: Pick<OutputContract, "name">): CompletionReport {
|
|
138
|
+
return {
|
|
139
|
+
outcome: "done",
|
|
140
|
+
summary: `The turn ran under the ${contract.name} contract; its result is the structured output, not this report.`,
|
|
141
|
+
decisions: [],
|
|
142
|
+
tried: [],
|
|
143
|
+
blockers: [],
|
|
144
|
+
proposedLearnings: [],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The `completed` event for a validated final message: the report itself under the Completion
|
|
149
|
+
* Report contract, or the stand-in with the object under `structured` under any other. */
|
|
150
|
+
export function completedEvent(contract: OutputContract, value: unknown, sessionId: string): { report: CompletionReport; structured?: unknown } {
|
|
151
|
+
if (contract === COMPLETION_REPORT_CONTRACT) return { report: value as CompletionReport };
|
|
152
|
+
return { report: contractReport(contract), structured: value };
|
|
153
|
+
}
|