project-autopilot 12.0.0 → 12.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -10
- package/SKILL.md +1 -3
- package/adapters/agents.json +11 -1
- package/config.example.json +5 -3
- package/config.schema.json +28 -9
- package/docs/configuration.md +5 -3
- package/package.json +1 -1
- package/scripts/adapter-drivers.mjs +209 -22
- package/scripts/agent-contract.mjs +24 -2
- package/scripts/agent-invocation.mjs +32 -5
- package/scripts/autopilot.mjs +787 -222
- package/scripts/config-layout.mjs +6 -0
- package/scripts/config-migration.mjs +3 -3
- package/scripts/config-validation.mjs +12 -4
- package/scripts/e2e-detect.mjs +30 -0
- package/scripts/escalation-naming.mjs +19 -0
- package/scripts/failure-contract.mjs +25 -9
- package/scripts/gen-config-schema.mjs +7 -0
- package/scripts/idle-hang-watchdog.mjs +34 -7
- package/scripts/implementation-baseline.mjs +95 -4
- package/scripts/model-discovery.mjs +176 -38
- package/scripts/opencode-generation.mjs +116 -0
- package/scripts/per-task-budget.mjs +11 -7
- package/scripts/plan-review-rounds.mjs +164 -9
- package/scripts/planning-boundary.mjs +16 -6
- package/scripts/planning-continuation.mjs +43 -23
- package/scripts/planning-review-decision.mjs +3 -0
- package/scripts/planning-review-wiring.mjs +12 -8
- package/scripts/relay-guards.mjs +133 -11
- package/scripts/review-canary.mjs +5 -2
- package/scripts/role-fallback.mjs +23 -8
- package/scripts/run-history.mjs +32 -1
- package/scripts/run-observability.mjs +46 -2
- package/scripts/setup-discovery.mjs +2 -1
- package/scripts/setup.mjs +183 -81
- package/scripts/skill-copy.mjs +49 -0
- package/scripts/task-requirements.mjs +24 -0
- package/scripts/watch-report.mjs +146 -21
- package/scripts/watch-summary.mjs +26 -3
- package/scripts/plan-mode-model-note.mjs +0 -27
package/README.md
CHANGED
|
@@ -5,10 +5,15 @@
|
|
|
5
5
|
|
|
6
6
|
<p align="center">
|
|
7
7
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"/></a>
|
|
8
|
-
<img src="https://img.shields.io/badge/version-12.0.
|
|
8
|
+
<img src="https://img.shields.io/badge/version-12.0.1-informational" alt="Version 12.0.1"/>
|
|
9
9
|
<img src="https://img.shields.io/badge/node-%3E%3D18-brightgreen" alt="Node 18 or newer"/>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
|
+
<p align="center">
|
|
13
|
+
<img src="docs/media/install.gif" alt="npm install -g project-autopilot, then ap setup in a project: it finds the agent CLIs and saves the settings" width="900"/>
|
|
14
|
+
<br/><sub>Install and set up a project in about a minute (Enter takes the default answers). Then run <code>ap run "your request"</code>.</sub>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
12
17
|
<p align="center">
|
|
13
18
|
<img src="docs/media/demo.gif" alt="ap run on a small Node project: the planner, coder, reviewers and tests run in turn until the change passes" width="900"/>
|
|
14
19
|
<br/><sub>A real 14-minute run, sped up. Left: <code>ap run</code>; right: the live watch report.</sub>
|
|
@@ -53,7 +58,7 @@ Needs Node.js 18+, Git and at least one signed-in agent CLI
|
|
|
53
58
|
([details](#get-started)).
|
|
54
59
|
|
|
55
60
|
```bash
|
|
56
|
-
npm install -g
|
|
61
|
+
npm install -g project-autopilot
|
|
57
62
|
cd your-project && ap setup
|
|
58
63
|
ap run "your request"
|
|
59
64
|
```
|
|
@@ -113,7 +118,7 @@ On Linux and WSL2 you also need `bubblewrap` for the read-only reviewers
|
|
|
113
118
|
**1. Install** — one command, then `ap` works in every terminal:
|
|
114
119
|
|
|
115
120
|
```bash
|
|
116
|
-
npm install -g
|
|
121
|
+
npm install -g project-autopilot
|
|
117
122
|
```
|
|
118
123
|
|
|
119
124
|
**2. Set up a project** — from the project folder (a Git repository):
|
|
@@ -152,6 +157,7 @@ its output to the mission's `orchestrator.log`.
|
|
|
152
157
|
- **Update:** run the same `npm install -g …` command again.
|
|
153
158
|
- **Remove the CLI:** `npm uninstall -g project-autopilot`. To remove Autopilot from
|
|
154
159
|
one project instead, run `ap uninstall` there (see [Commands](#commands)).
|
|
160
|
+
- **Latest code from GitHub:** `npm install -g github:mmetwally-dev/project-autopilot`.
|
|
155
161
|
- **From a local folder:** `npm install -g <path-to-project-autopilot>`.
|
|
156
162
|
- **Developing the skill itself:** run `npm link` once inside the checkout; `ap`
|
|
157
163
|
then runs that folder, so every edit is live. `npm unlink -g project-autopilot` undoes it.
|
|
@@ -570,7 +576,7 @@ GATE (orchestrator) - T1.1 > T1.7 PASS 00:
|
|
|
570
576
|
**CONTEXT** — the largest context the session reached. **TURNS** — model turns.
|
|
571
577
|
`-` means the CLI reports no usage.
|
|
572
578
|
- **NOTE** — the session number, the number of findings, and warnings such as
|
|
573
|
-
`⚠ high usage` (over the role's cap)
|
|
579
|
+
`⚠ high usage` (over the role's cap).
|
|
574
580
|
|
|
575
581
|
The same folder has `orchestrator.log` (the full log), `gates/` (each gate's
|
|
576
582
|
output), `handoff/` (hand-off notes), `phase-briefs/` and `runs/` (each agent
|
|
@@ -622,12 +628,6 @@ documented fallback list). Before saving, each role is checked: a live model mus
|
|
|
622
628
|
be in the live list and unsupported flags are refused. `node scripts/doctor.mjs`
|
|
623
629
|
flags a documented list older than 180 days.
|
|
624
630
|
|
|
625
|
-
**Haiku on read-only Claude roles runs as Sonnet.** The read-only roles start
|
|
626
|
-
Claude with `--permission-mode plan`, and Claude Code runs plan mode on Sonnet
|
|
627
|
-
when Haiku is selected. A reviewer set to `haiku` therefore really runs (and
|
|
628
|
-
bills) Sonnet. Setup, `ap config` and `ap run` warn about it, and the report
|
|
629
|
-
marks it `haiku→sonnet !`. Haiku as implementer runs as Haiku.
|
|
630
|
-
|
|
631
631
|
**What really ran.** For every run Autopilot records the exact CLI command and
|
|
632
632
|
checks before launch that the configured model, effort and variant are really in
|
|
633
633
|
it. Each run writes a result file (`autopilot-relay.result.v3`) with the
|
package/SKILL.md
CHANGED
|
@@ -36,7 +36,7 @@ Requires Node.js 18+, Git, one signed-in agent CLI, and `bubblewrap` on
|
|
|
36
36
|
Linux/WSL2 for the read-only reviewers.
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
npm install -g
|
|
39
|
+
npm install -g project-autopilot # once; or: node <skill>/bin/ap.cjs …
|
|
40
40
|
ap setup # once per project (wizard)
|
|
41
41
|
ap run "Add a dark-mode toggle to the settings page" # a full mission
|
|
42
42
|
```
|
|
@@ -253,8 +253,6 @@ here.` and exits 0, without starting the setup wizard.
|
|
|
253
253
|
`tests.gate_network` is true.
|
|
254
254
|
- Keep reviewers independent: never the same model for `code_reviewer` as
|
|
255
255
|
`implementer`, or for `plan_reviewer` as `planner`, as primary or fallback.
|
|
256
|
-
- Haiku on a read-only Claude role runs as Sonnet (plan mode); setup, config and
|
|
257
|
-
run warn, and the report shows `haiku→sonnet !`.
|
|
258
256
|
|
|
259
257
|
### Known Git-guard limitation
|
|
260
258
|
|
package/adapters/agents.json
CHANGED
|
@@ -32,8 +32,18 @@
|
|
|
32
32
|
"--input-format",
|
|
33
33
|
"text",
|
|
34
34
|
"--verbose",
|
|
35
|
+
"--allowedTools",
|
|
36
|
+
"Read",
|
|
37
|
+
"Glob",
|
|
38
|
+
"Grep",
|
|
39
|
+
"Bash",
|
|
40
|
+
"--disallowedTools",
|
|
41
|
+
"Monitor",
|
|
42
|
+
"TaskStop",
|
|
43
|
+
"--settings",
|
|
44
|
+
"{\"env\":{\"CLAUDE_CODE_DISABLE_BACKGROUND_TASKS\":\"1\"}}",
|
|
35
45
|
"--permission-mode",
|
|
36
|
-
"
|
|
46
|
+
"default"
|
|
37
47
|
],
|
|
38
48
|
"model_flag": "--model",
|
|
39
49
|
"effort_flag": "--effort",
|
package/config.example.json
CHANGED
|
@@ -61,9 +61,9 @@
|
|
|
61
61
|
},
|
|
62
62
|
"plan_reviewer": {
|
|
63
63
|
"max_turns": 30,
|
|
64
|
-
"max_tokens":
|
|
65
|
-
"extra_sessions":
|
|
66
|
-
"wrap_up_at_tokens":
|
|
64
|
+
"max_tokens": 250000,
|
|
65
|
+
"extra_sessions": 4,
|
|
66
|
+
"wrap_up_at_tokens": 225000
|
|
67
67
|
},
|
|
68
68
|
"implementer": {
|
|
69
69
|
"max_turns": 40,
|
|
@@ -126,6 +126,8 @@
|
|
|
126
126
|
"require_reviewer": true,
|
|
127
127
|
"browser": "native",
|
|
128
128
|
"require_browser": "when-task-requires",
|
|
129
|
+
"e2e": null,
|
|
130
|
+
"e2e_setup_pending": false,
|
|
129
131
|
"verify_denylist": []
|
|
130
132
|
},
|
|
131
133
|
"safety": {
|
package/config.schema.json
CHANGED
|
@@ -783,9 +783,9 @@
|
|
|
783
783
|
},
|
|
784
784
|
"plan_reviewer": {
|
|
785
785
|
"max_turns": 30,
|
|
786
|
-
"max_tokens":
|
|
787
|
-
"extra_sessions":
|
|
788
|
-
"wrap_up_at_tokens":
|
|
786
|
+
"max_tokens": 250000,
|
|
787
|
+
"extra_sessions": 4,
|
|
788
|
+
"wrap_up_at_tokens": 225000
|
|
789
789
|
},
|
|
790
790
|
"implementer": {
|
|
791
791
|
"max_turns": 40,
|
|
@@ -859,9 +859,9 @@
|
|
|
859
859
|
"description": "Caps for the plan reviewer role.",
|
|
860
860
|
"default": {
|
|
861
861
|
"max_turns": 30,
|
|
862
|
-
"max_tokens":
|
|
863
|
-
"extra_sessions":
|
|
864
|
-
"wrap_up_at_tokens":
|
|
862
|
+
"max_tokens": 250000,
|
|
863
|
+
"extra_sessions": 4,
|
|
864
|
+
"wrap_up_at_tokens": 225000
|
|
865
865
|
},
|
|
866
866
|
"properties": {
|
|
867
867
|
"max_turns": {
|
|
@@ -873,19 +873,19 @@
|
|
|
873
873
|
"max_tokens": {
|
|
874
874
|
"type": "number",
|
|
875
875
|
"description": "Largest single-turn context (input plus cached input) this role may use; at the limit the role stops and hands off, then extra_sessions decides whether a new session may continue.",
|
|
876
|
-
"default":
|
|
876
|
+
"default": 250000,
|
|
877
877
|
"minimum": 1
|
|
878
878
|
},
|
|
879
879
|
"extra_sessions": {
|
|
880
880
|
"type": "number",
|
|
881
881
|
"description": "Extra sessions this role may start after hitting its turn or token cap; at the limit no further session starts and the mission stops or reports the cap.",
|
|
882
|
-
"default":
|
|
882
|
+
"default": 4,
|
|
883
883
|
"minimum": 0
|
|
884
884
|
},
|
|
885
885
|
"wrap_up_at_tokens": {
|
|
886
886
|
"type": "number",
|
|
887
887
|
"description": "Token count at which this role is told to wrap up and hand off before max_tokens; at the limit the role writes its hand-off and stops.",
|
|
888
|
-
"default":
|
|
888
|
+
"default": 225000,
|
|
889
889
|
"minimum": 1
|
|
890
890
|
}
|
|
891
891
|
},
|
|
@@ -1364,6 +1364,25 @@
|
|
|
1364
1364
|
"items": {
|
|
1365
1365
|
"type": "string"
|
|
1366
1366
|
}
|
|
1367
|
+
},
|
|
1368
|
+
"e2e": {
|
|
1369
|
+
"type": [
|
|
1370
|
+
"string",
|
|
1371
|
+
"null"
|
|
1372
|
+
],
|
|
1373
|
+
"description": "End-to-end test tool for UI projects: playwright, cypress or none. Unset (null) = setup asks once; treated as none until answered.",
|
|
1374
|
+
"default": null,
|
|
1375
|
+
"enum": [
|
|
1376
|
+
"playwright",
|
|
1377
|
+
"cypress",
|
|
1378
|
+
"none",
|
|
1379
|
+
null
|
|
1380
|
+
]
|
|
1381
|
+
},
|
|
1382
|
+
"e2e_setup_pending": {
|
|
1383
|
+
"type": "boolean",
|
|
1384
|
+
"description": "True while an end-to-end tool setup is still pending for the project; unset or false means nothing is pending.",
|
|
1385
|
+
"default": false
|
|
1367
1386
|
}
|
|
1368
1387
|
},
|
|
1369
1388
|
"additionalProperties": false,
|
package/docs/configuration.md
CHANGED
|
@@ -104,9 +104,9 @@ Per-role turn, token and session caps plus the mission token backstop.
|
|
|
104
104
|
| `caps.planner.extra_sessions` | `2` | >= 0 | Extra sessions this role may start after hitting its turn or token cap; at the limit no further session starts and the mission stops or reports the cap. |
|
|
105
105
|
| `caps.planner.wrap_up_at_tokens` | `180000` | >= 1 | Token count at which this role is told to wrap up and hand off before max_tokens; at the limit the role writes its hand-off and stops. |
|
|
106
106
|
| `caps.plan_reviewer.max_turns` | `30` | >= 1 | Most model turns this role may use in one session; at the limit the role stops and hands off, then extra_sessions decides whether a new session may continue. |
|
|
107
|
-
| `caps.plan_reviewer.max_tokens` | `
|
|
108
|
-
| `caps.plan_reviewer.extra_sessions` | `
|
|
109
|
-
| `caps.plan_reviewer.wrap_up_at_tokens` | `
|
|
107
|
+
| `caps.plan_reviewer.max_tokens` | `250000` | >= 1 | Largest single-turn context (input plus cached input) this role may use; at the limit the role stops and hands off, then extra_sessions decides whether a new session may continue. |
|
|
108
|
+
| `caps.plan_reviewer.extra_sessions` | `4` | >= 0 | Extra sessions this role may start after hitting its turn or token cap; at the limit no further session starts and the mission stops or reports the cap. |
|
|
109
|
+
| `caps.plan_reviewer.wrap_up_at_tokens` | `225000` | >= 1 | Token count at which this role is told to wrap up and hand off before max_tokens; at the limit the role writes its hand-off and stops. |
|
|
110
110
|
| `caps.implementer.max_turns` | `40` | >= 1 | Most model turns this role may use in one session; at the limit the role stops and hands off, then extra_sessions decides whether a new session may continue. |
|
|
111
111
|
| `caps.implementer.max_tokens` | `180000` | >= 1 | Largest single-turn context (input plus cached input) this role may use; at the limit the role stops and hands off, then extra_sessions decides whether a new session may continue. |
|
|
112
112
|
| `caps.implementer.extra_sessions` | `5` | >= 0 | Extra sessions this role may start after hitting its turn or token cap; at the limit no further session starts and the mission stops or reports the cap. |
|
|
@@ -184,6 +184,8 @@ Gate commands, reviewer requirement and verification policy.
|
|
|
184
184
|
| `tests.browser` | `"native"` | `"native"`, `"playwright"`, `"both"` | Browser capability used for verification: native (the agent's own browser), playwright or both. |
|
|
185
185
|
| `tests.require_browser` | `"when-task-requires"` | `"always"`, `"when-task-requires"`, `"never"` | How browser capabilities are required for the verifier: always adds them to every call; when-task-requires and never add them only when the task itself needs a browser. |
|
|
186
186
|
| `tests.verify_denylist` | `[]` | — | Commands or paths that a task or verification step may never run; empty adds no bans beyond the built-in full-suite ban. |
|
|
187
|
+
| `tests.e2e` | `null` | `"playwright"`, `"cypress"`, `"none"`, `null` | End-to-end test tool for UI projects: playwright, cypress or none. Unset (null) = setup asks once; treated as none until answered. |
|
|
188
|
+
| `tests.e2e_setup_pending` | `false` | — | True while an end-to-end tool setup is still pending for the project; unset or false means nothing is pending. |
|
|
187
189
|
|
|
188
190
|
### safety
|
|
189
191
|
|
package/package.json
CHANGED
|
@@ -9,6 +9,8 @@ import {equivalentEventPath, eventReferencesPath} from "./event-paths.mjs";
|
|
|
9
9
|
import {decodeAgyEvents, observeAgyEvents, accumulateAgyUsage, agyRunModelPatch, writeAgyAllowRule} from "./agy-events.mjs";
|
|
10
10
|
import {resolveRunModel} from "./model-extract.mjs";
|
|
11
11
|
import {updateRunModel} from "./run-observability.mjs";
|
|
12
|
+
import {FREE_QUOTA_REASON, OPENCODE_TITLE_MODEL_LINE_RE} from "./idle-hang-watchdog.mjs";
|
|
13
|
+
import {detectOpenCodeGeneration, splitOpenCodeModelSuffix} from "./opencode-generation.mjs";
|
|
12
14
|
|
|
13
15
|
const DRIVERS = Object.freeze({
|
|
14
16
|
codex: codexDriver(),
|
|
@@ -76,6 +78,11 @@ function resolveOpenCodeBinary(binary) {
|
|
|
76
78
|
function opencodeInvocation(request) {
|
|
77
79
|
// --print-logs: the only place opencode reports the model it ran (#107).
|
|
78
80
|
const args = ["run", "--print-logs", "--format", "json"];
|
|
81
|
+
// 2.x resolves config-defined agents (autopilot_readonly lives in
|
|
82
|
+
// OPENCODE_CONFIG_CONTENT) only in a private server, so every run role on
|
|
83
|
+
// that generation needs --standalone.
|
|
84
|
+
const generation = detectOpenCodeGeneration({binary: request.binary});
|
|
85
|
+
if (generation === "2") args.push("--standalone");
|
|
79
86
|
if (["write", "scratch"].includes(request.mode)) args.push("--auto");
|
|
80
87
|
// Not the native "plan" agent: OpenCode 1.18.29's built-in "plan" agent
|
|
81
88
|
// carries an unconditional `edit "*": deny` in its own resolved permission
|
|
@@ -89,8 +96,14 @@ function opencodeInvocation(request) {
|
|
|
89
96
|
// repository containment still comes from bubblewrap's read-only bind, not
|
|
90
97
|
// from this permission block.
|
|
91
98
|
else args.push("--agent", "autopilot_readonly");
|
|
92
|
-
|
|
93
|
-
|
|
99
|
+
// 2.x carries the effort level as provider/model#level (there is no
|
|
100
|
+
// --variant); 1.x keeps the flag it was verified against.
|
|
101
|
+
const effort = generation === "2" && request.reasoning;
|
|
102
|
+
if (effort && !request.model) throw new Error("OpenCode 2.x reasoning/effort requires a selected model.");
|
|
103
|
+
if (request.model || effort) {
|
|
104
|
+
args.push("--model", effort ? `${request.model}#${request.reasoning}` : request.model);
|
|
105
|
+
}
|
|
106
|
+
if (request.reasoning && generation !== "2") args.push("--variant", request.reasoning);
|
|
94
107
|
return {binary: request.binary, args};
|
|
95
108
|
}
|
|
96
109
|
|
|
@@ -99,7 +112,12 @@ function opencodeAppliedSelections(invocation) {
|
|
|
99
112
|
const index = invocation.args.indexOf(flag);
|
|
100
113
|
return index >= 0 ? invocation.args[index + 1] : null;
|
|
101
114
|
};
|
|
102
|
-
|
|
115
|
+
const model = pick("--model");
|
|
116
|
+
if (model && String(model).includes("#")) {
|
|
117
|
+
const split = splitOpenCodeModelSuffix(model);
|
|
118
|
+
return {model: split.model, reasoning: split.reasoning};
|
|
119
|
+
}
|
|
120
|
+
return {model, reasoning: pick("--variant")};
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
// Containment inside the trusted envelope comes from bubblewrap, not from any
|
|
@@ -158,7 +176,7 @@ const SENTINEL_TOKEN = "AUTOPILOT_REVIEW";
|
|
|
158
176
|
// (file_read_*, write_*, outside_write_*, git_mutation_*, command_*,
|
|
159
177
|
// timeout_child_command_started, permission_denied).
|
|
160
178
|
function observeOpenCodeEvents(events, probe = {}) {
|
|
161
|
-
const observations = [...detectOpenCodeRateLimited(events), ...detectOpenCodeProviderError(events)];
|
|
179
|
+
const observations = [...detectOpenCodeRateLimited(events), ...detectOpenCodeProviderError(events), ...detectOpenCodeAgentNotFound(events)];
|
|
162
180
|
const emit = (type, event, details = {}) => observations.push({type, source: "cli_event", ...details, event});
|
|
163
181
|
for (let index = 0; index < events.length; index += 1) {
|
|
164
182
|
const event = events[index];
|
|
@@ -249,22 +267,59 @@ function observeOpenCodeEvents(events, probe = {}) {
|
|
|
249
267
|
return observations;
|
|
250
268
|
}
|
|
251
269
|
|
|
252
|
-
//
|
|
253
|
-
// `{"type":"error","error":{"name":
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
270
|
+
// opencode provider errors (never prose). Two channels carry them:
|
|
271
|
+
// - the structured stdout error event `{"type":"error","error":{"name":
|
|
272
|
+
// "APIError","data":{"message":...,"statusCode":429}}}` (2026-10-13
|
|
273
|
+
// incident B);
|
|
274
|
+
// - opencode's own logfmt ERROR line on stderr (mission 2026-10-28 backlog
|
|
275
|
+
// #14: `error.error="AI_APICallError: Free usage limit reached ..."`, no
|
|
276
|
+
// 429 status at all).
|
|
277
|
+
// Rate limit, quota ("Insufficient account funds") and the free tier's
|
|
278
|
+
// "Endpoint is unavailable"/"Free usage limit reached" are all the same
|
|
279
|
+
// operator-visible state: RATE_LIMITED. The 1.x stderr branch requires the
|
|
280
|
+
// engine error source token (`AI_APICallError`/`AI_RetryError`); 2.x logfmt
|
|
281
|
+
// ERROR lines instead carry `cause=`/`role=server` (real 2.2.0 shape, see
|
|
282
|
+
// scripts/test-fixtures/opencode-2x-run-bad-model.stderr.txt). Either way the
|
|
283
|
+
// line must come from stderr, so model prose or tool output containing the
|
|
284
|
+
// same words never matches.
|
|
285
|
+
const OPENCODE_FREE_QUOTA_MESSAGE = /free usage limit reached|usage limit reached|insufficient[_\s-]?quota|quota[_\s-]?(?:exceeded|exhausted)|subscribe to [^\n]{0,80}to keep going/i;
|
|
286
|
+
const OPENCODE_RATE_LIMIT_MESSAGE = new RegExp(`rate[_\\s]limit[_\\s]exceeded|endpoint is unavailable|insufficient account funds|${OPENCODE_FREE_QUOTA_MESSAGE.source}`, "i");
|
|
287
|
+
const OPENCODE_PROVIDER_ERROR_SOURCE = /AI_APICallError|AI_RetryError/;
|
|
288
|
+
// 2.x's own logfmt ERROR lines are engine-level diagnostics too, but they do
|
|
289
|
+
// not spell the 1.x AI SDK error class. The structural marker (server role or
|
|
290
|
+
// a stack-bearing cause=) keeps the existing 1.x negative control — a no-source
|
|
291
|
+
// line is still not a rate-limit hit — intact.
|
|
292
|
+
const OPENCODE_2X_ENGINE_ERROR_LINE = /\blevel=ERROR\b[^\n]*(?:\brole=server\b|\bcause=)/;
|
|
293
|
+
function openCodeEngineErrorLine(text) {
|
|
294
|
+
return OPENCODE_PROVIDER_ERROR_SOURCE.test(text) || OPENCODE_2X_ENGINE_ERROR_LINE.test(text);
|
|
295
|
+
}
|
|
258
296
|
function detectOpenCodeRateLimited(events) {
|
|
259
297
|
const observations = [];
|
|
260
298
|
for (const event of events) {
|
|
261
|
-
if (!event || typeof event !== "object"
|
|
299
|
+
if (!event || typeof event !== "object") continue;
|
|
300
|
+
if (event.type === "stderr") {
|
|
301
|
+
const text = String(event.text ?? "");
|
|
302
|
+
// Backlog #107: the small title-generation model's stream errors
|
|
303
|
+
// (small=true agent=title) say nothing about the role's own model, so a
|
|
304
|
+
// completed run must never be reclassified RATE_LIMITED from one.
|
|
305
|
+
if (OPENCODE_TITLE_MODEL_LINE_RE.test(text)) continue;
|
|
306
|
+
if (!openCodeEngineErrorLine(text) || !OPENCODE_RATE_LIMIT_MESSAGE.test(text)) continue;
|
|
307
|
+
observations.push(observation("rate_limited", event, {
|
|
308
|
+
reason: OPENCODE_FREE_QUOTA_MESSAGE.test(text) ? FREE_QUOTA_REASON : "opencode_stderr_rate_limit",
|
|
309
|
+
message: text.slice(0, 200),
|
|
310
|
+
}));
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (event.type !== "error") continue;
|
|
262
314
|
const err = event.error && typeof event.error === "object" ? event.error : {};
|
|
263
315
|
const data = err.data && typeof err.data === "object" ? err.data : {};
|
|
264
316
|
const status = Number(data.statusCode ?? err.statusCode);
|
|
265
317
|
const message = String(data.message ?? err.message ?? "");
|
|
266
318
|
if (status === 429 || OPENCODE_RATE_LIMIT_MESSAGE.test(message)) {
|
|
267
|
-
|
|
319
|
+
const freeQuota = OPENCODE_FREE_QUOTA_MESSAGE.test(message);
|
|
320
|
+
observations.push(observation("rate_limited", event, {
|
|
321
|
+
reason: freeQuota ? FREE_QUOTA_REASON : status === 429 ? "api_error_status_429" : "opencode_error_message",
|
|
322
|
+
}));
|
|
268
323
|
}
|
|
269
324
|
}
|
|
270
325
|
return observations;
|
|
@@ -275,24 +330,51 @@ function detectOpenCodeRateLimited(events) {
|
|
|
275
330
|
// {"message":"Unexpected server error. ..."}}}` and the run did no tool work
|
|
276
331
|
// (a failure tied to the brief/model, not to anything the agent did). Not a
|
|
277
332
|
// rate limit (detectOpenCodeRateLimited keeps precedence in the classifier).
|
|
333
|
+
// 2.x reports an unavailable model as `{"type":"error","error":{"type":
|
|
334
|
+
// "provider.no-route","message":"Model unavailable: provider/model"}}`; that is
|
|
335
|
+
// the same provider/model-side failure class even when tools already ran.
|
|
278
336
|
const OPENCODE_PROVIDER_ERROR_MESSAGE = /unexpected server error/i;
|
|
337
|
+
const OPENCODE_MODEL_UNAVAILABLE_MESSAGE = /model unavailable/i;
|
|
279
338
|
function detectOpenCodeProviderError(events) {
|
|
280
339
|
const hasToolUse = events.some(event => event && typeof event === "object"
|
|
281
340
|
&& (event.type === "tool_use" || event.type === "tool" || event.part?.type === "tool"));
|
|
282
|
-
if (hasToolUse) return [];
|
|
283
341
|
const observations = [];
|
|
284
342
|
for (const event of events) {
|
|
285
343
|
if (!event || typeof event !== "object" || event.type !== "error") continue;
|
|
286
344
|
const err = event.error && typeof event.error === "object" ? event.error : {};
|
|
287
345
|
const data = err.data && typeof err.data === "object" ? err.data : {};
|
|
288
346
|
const message = String(data.message ?? err.message ?? "");
|
|
289
|
-
|
|
347
|
+
const errorType = String(err.type ?? "");
|
|
348
|
+
if (errorType === "provider.no-route" || OPENCODE_MODEL_UNAVAILABLE_MESSAGE.test(message)) {
|
|
349
|
+
observations.push(observation("provider_error", event, {reason: "opencode_model_unavailable", errorType: errorType || null, errorName: err.name ?? null, message: message.slice(0, 200)}));
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!hasToolUse && (err.name === "UnknownError" || OPENCODE_PROVIDER_ERROR_MESSAGE.test(message))) {
|
|
290
353
|
observations.push(observation("provider_error", event, {reason: "opencode_unknown_error", errorName: err.name ?? null, message: message.slice(0, 200), ref: data.ref ?? null}));
|
|
291
354
|
}
|
|
292
355
|
}
|
|
293
356
|
return observations;
|
|
294
357
|
}
|
|
295
358
|
|
|
359
|
+
// 2.x without --standalone cannot see an OPENCODE_CONFIG_CONTENT agent:
|
|
360
|
+
// `{"type":"error","error":{"type":"unknown","message":"Agent not found:
|
|
361
|
+
// \"autopilot_readonly\""}}`. That is an invocation/config failure, not an idle
|
|
362
|
+
// hang or a provider limit, so it gets its own structured observation.
|
|
363
|
+
const OPENCODE_AGENT_NOT_FOUND_MESSAGE = /agent not found/i;
|
|
364
|
+
function detectOpenCodeAgentNotFound(events) {
|
|
365
|
+
const observations = [];
|
|
366
|
+
for (const event of events) {
|
|
367
|
+
if (!event || typeof event !== "object" || event.type !== "error") continue;
|
|
368
|
+
const err = event.error && typeof event.error === "object" ? event.error : {};
|
|
369
|
+
const data = err.data && typeof err.data === "object" ? err.data : {};
|
|
370
|
+
const message = String(data.message ?? err.message ?? "");
|
|
371
|
+
if (OPENCODE_AGENT_NOT_FOUND_MESSAGE.test(message)) {
|
|
372
|
+
observations.push(observation("agent_not_found", event, {errorType: String(err.type ?? "") || null, message: message.slice(0, 200)}));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return observations;
|
|
376
|
+
}
|
|
377
|
+
|
|
296
378
|
// Whether a later event is a successful tool call, derived from the CLI's own
|
|
297
379
|
// status plus, where a shell exit was recorded, that exit code — the same
|
|
298
380
|
// derivation the observer's own `failed` uses. Only successful calls suppress a
|
|
@@ -587,6 +669,37 @@ function joinMissingSyntheticExe(originalPath) {
|
|
|
587
669
|
return path.join(dir, base + ".synthetic-missing-target.exe");
|
|
588
670
|
}
|
|
589
671
|
|
|
672
|
+
// Claude Code permission-rule syntax is `Tool(//absolute/path/**)`; a single
|
|
673
|
+
// leading `/` is project-relative, so the scratch grant uses two. A read-only
|
|
674
|
+
// role may write ONLY its hand-off note tree. No scratch dir => no Write/Edit
|
|
675
|
+
// grant at all (fail closed), never an unrestricted tool.
|
|
676
|
+
export const CLAUDE_READ_ONLY_ALLOWED_TOOLS = Object.freeze(["Read", "Glob", "Grep", "Bash"]);
|
|
677
|
+
|
|
678
|
+
export function claudeScratchAllowRules(scratchDir) {
|
|
679
|
+
const normalized = String(scratchDir ?? "").trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
|
680
|
+
if (!/^(?:[A-Za-z]:\/|\/)/.test(normalized)) return [];
|
|
681
|
+
if (normalized.split("/").includes("..")) return [];
|
|
682
|
+
const base = `//${normalized.replace(/^\/+/, "")}`;
|
|
683
|
+
return [`Write(${base}/**)`, `Edit(${base}/**)`];
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export function applyClaudeReadOnlyScratchAllow(args, scratchDir) {
|
|
687
|
+
const out = Array.isArray(args) ? [...args] : [];
|
|
688
|
+
const rules = claudeScratchAllowRules(scratchDir);
|
|
689
|
+
if (!rules.length) return out;
|
|
690
|
+
const at = out.indexOf("--allowedTools");
|
|
691
|
+
if (at < 0) {
|
|
692
|
+
const before = out.indexOf("--permission-mode");
|
|
693
|
+
const insertAt = before >= 0 ? before : out.length;
|
|
694
|
+
out.splice(insertAt, 0, "--allowedTools", ...rules);
|
|
695
|
+
return out;
|
|
696
|
+
}
|
|
697
|
+
let end = at + 1;
|
|
698
|
+
while (end < out.length && !String(out[end]).startsWith("--")) end += 1;
|
|
699
|
+
out.splice(end, 0, ...rules);
|
|
700
|
+
return out;
|
|
701
|
+
}
|
|
702
|
+
|
|
590
703
|
function claudeInvocation(request) {
|
|
591
704
|
const args = ["-p"];
|
|
592
705
|
args.push("--output-format", "stream-json");
|
|
@@ -601,7 +714,16 @@ function claudeInvocation(request) {
|
|
|
601
714
|
throw new Error("Claude invocation must not carry a pre-existing --permission-mode flag (fail closed: mode decides).");
|
|
602
715
|
}
|
|
603
716
|
if (request.mode === "read_only") {
|
|
604
|
-
|
|
717
|
+
// Plan mode refuses EVERY Write, including the sanctioned scratch note a
|
|
718
|
+
// capped reviewer must leave for the next session (backlog #2). Run in
|
|
719
|
+
// default mode with an explicit allow list instead: read tools + Bash,
|
|
720
|
+
// plus Write/Edit scoped to the scratch dir the brief names. The
|
|
721
|
+
// repository stays read-only through bubblewrap (Linux/WSL) or the
|
|
722
|
+
// disposable workspace (Windows), never through the permission mode.
|
|
723
|
+
args.push("--allowedTools", ...CLAUDE_READ_ONLY_ALLOWED_TOOLS, ...claudeScratchAllowRules(request.scratchDir));
|
|
724
|
+
args.push("--disallowedTools", "Monitor", "TaskStop");
|
|
725
|
+
args.push("--settings", CLAUDE_FOREGROUND_ONLY_SETTINGS);
|
|
726
|
+
args.push("--permission-mode", "default");
|
|
605
727
|
} else if (request.mode === "write" || request.mode === "scratch") {
|
|
606
728
|
// Least-privileged non-interactive write mode: auto-accept file edits
|
|
607
729
|
// without the broad bypass of --dangerously-skip-permissions.
|
|
@@ -668,7 +790,49 @@ function claudeProviderProfile({binary}) {
|
|
|
668
790
|
// for accounting. It does not introduce new containment, safety, or
|
|
669
791
|
// isolation authority.
|
|
670
792
|
function observeClaudeEvents(events, probe = {}) {
|
|
671
|
-
return [...detectClaudeRateLimited(events), ...observeCodexEvents(events, probe)];
|
|
793
|
+
return [...detectClaudeRateLimited(events), ...detectClaudeAuthRequired(events), ...observeCodexEvents(events, probe)];
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Mission 002 runs 09-11 (2026-10-28, a real operator project): the claude CLI
|
|
797
|
+
// ended with a genuine `{"type":"result","is_error":true,...}` line whose
|
|
798
|
+
// `api_error_status` was 401 ("Failed to authenticate: OAuth token revoked.
|
|
799
|
+
// Please log in again…") or null with the same text naming an expired OAuth
|
|
800
|
+
// session. Nothing recognized it, so it fell through to a generic adapter
|
|
801
|
+
// error and was retried until adapterRetryBudget_exhausted — an escalation
|
|
802
|
+
// that never told the operator to log in. Auth is terminal for the CLI
|
|
803
|
+
// itself: no same-adapter retry can succeed.
|
|
804
|
+
//
|
|
805
|
+
// Text is trusted ONLY on the CLI's own terminal result record with
|
|
806
|
+
// `is_error:true` (or an explicit 401 status), never on assistant prose: a
|
|
807
|
+
// model answer that merely discusses "log in again" is not an auth failure.
|
|
808
|
+
// TODO: codex/opencode/agy have no captured auth-failure fixture yet; extend
|
|
809
|
+
// this mapping with their own terminal event shapes when one is observed.
|
|
810
|
+
const CLAUDE_AUTH_REQUIRED_TEXT = /failed to authenticate|oauth token revoked|oauth session expired|please log in again|invalid api key/i;
|
|
811
|
+
const CLAUDE_AUTH_REASON_PATTERNS = [
|
|
812
|
+
/oauth token revoked/i,
|
|
813
|
+
/oauth session expired[^.]*/i,
|
|
814
|
+
/invalid api key/i,
|
|
815
|
+
/please log in again/i,
|
|
816
|
+
/failed to authenticate/i,
|
|
817
|
+
];
|
|
818
|
+
function detectClaudeAuthRequired(events) {
|
|
819
|
+
const observations = [];
|
|
820
|
+
for (const event of events) {
|
|
821
|
+
if (!event || typeof event !== "object") continue;
|
|
822
|
+
const status = event.api_error_status == null ? null : Number(event.api_error_status);
|
|
823
|
+
const resultFailed = event.type === "result" && event.is_error === true;
|
|
824
|
+
const text = typeof event.result === "string" ? event.result : "";
|
|
825
|
+
if (status !== 401 && !(resultFailed && CLAUDE_AUTH_REQUIRED_TEXT.test(text))) continue;
|
|
826
|
+
const reasonPattern = CLAUDE_AUTH_REASON_PATTERNS.find((pattern) => pattern.test(text));
|
|
827
|
+
const matched = reasonPattern ? text.match(reasonPattern) : null;
|
|
828
|
+
observations.push(observation("auth_required", event, {
|
|
829
|
+
adapter: "claude",
|
|
830
|
+
reason: status === 401 ? "api_error_status_401" : (matched ? matched[0] : "authentication failed"),
|
|
831
|
+
status: Number.isFinite(status) ? status : null,
|
|
832
|
+
message: text.slice(0, 200),
|
|
833
|
+
}));
|
|
834
|
+
}
|
|
835
|
+
return observations;
|
|
672
836
|
}
|
|
673
837
|
|
|
674
838
|
// Mission 001 runs 27-28: a real Anthropic rate-limit rejection (`{"type":
|
|
@@ -927,8 +1091,8 @@ function priceWeightedUsage({input, cacheCreation, cacheRead, output}) {
|
|
|
927
1091
|
// together; the breakdown is the point of this function, not the total,
|
|
928
1092
|
// because per-task dispatch (this task list's reason for existing) changes
|
|
929
1093
|
// the MIX of these four, not just their sum.
|
|
930
|
-
export function accumulateUsage(adapterId, events) {
|
|
931
|
-
if (adapterId === "opencode") return accumulateOpenCodeUsage(events);
|
|
1094
|
+
export function accumulateUsage(adapterId, events, options = {}) {
|
|
1095
|
+
if (adapterId === "opencode") return accumulateOpenCodeUsage(events, options);
|
|
932
1096
|
if (adapterId === "claude") return accumulateClaudeUsage(events);
|
|
933
1097
|
if (adapterId === "antigravity") return accumulateAgyUsage(events);
|
|
934
1098
|
return null;
|
|
@@ -974,16 +1138,38 @@ export function recordRunModel(dir, adapterId, events, configuredModel = null) {
|
|
|
974
1138
|
// component. The return also carries `maxSingleTurnContext`: the largest
|
|
975
1139
|
// single-step context footprint (`input + cache.read + cache.write`), i.e.
|
|
976
1140
|
// the current context size rather than the cumulative session total.
|
|
977
|
-
|
|
1141
|
+
//
|
|
1142
|
+
// OpenCode 2.x omits step_finish for the FINAL step (a one-step reply has no
|
|
1143
|
+
// step_finish at all; a tool run's last step has text but no finish), so its
|
|
1144
|
+
// sum is inherently a lower bound. The caller passes the generation detected
|
|
1145
|
+
// at launch; on 2.x a text-bearing step after the last step_finish marks the
|
|
1146
|
+
// result `usage_partial:true` while still summing every step_finish that did
|
|
1147
|
+
// arrive. A 2.x stream with no step_finish at all returns only that flag —
|
|
1148
|
+
// never zeroed/invented token counts. 1.x (and callers with no generation,
|
|
1149
|
+
// e.g. the existing unit tests) keep the exact old null/throw-free behavior.
|
|
1150
|
+
function openCodeTextAfterLastFinish(events, lastFinishIndex) {
|
|
1151
|
+
for (let index = lastFinishIndex + 1; index < events.length; index += 1) {
|
|
1152
|
+
const event = events[index];
|
|
1153
|
+
const part = event?.part || {};
|
|
1154
|
+
const type = String(event?.type || part.type || "");
|
|
1155
|
+
if (type !== "text") continue;
|
|
1156
|
+
if (String(part.text ?? event?.text ?? "").trim() !== "") return true;
|
|
1157
|
+
}
|
|
1158
|
+
return false;
|
|
1159
|
+
}
|
|
1160
|
+
function accumulateOpenCodeUsage(events, {generation = null} = {}) {
|
|
978
1161
|
let input = 0, output = 0, cacheCreation = 0, cacheRead = 0, turns = 0, seen = false;
|
|
979
1162
|
let maxSingleTurnContext = 0;
|
|
980
|
-
|
|
1163
|
+
let lastFinishIndex = -1;
|
|
1164
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
1165
|
+
const event = events[index];
|
|
981
1166
|
const part = event?.part || {};
|
|
982
1167
|
const type = String(event?.type || part.type || "");
|
|
983
1168
|
if (type !== "step_finish" && type !== "step-finish") continue;
|
|
984
1169
|
const tokens = part.tokens;
|
|
985
1170
|
if (!tokens || typeof tokens !== "object") continue;
|
|
986
1171
|
seen = true;
|
|
1172
|
+
lastFinishIndex = index;
|
|
987
1173
|
turns += 1;
|
|
988
1174
|
input += Number(tokens.input) || 0;
|
|
989
1175
|
output += (Number(tokens.output) || 0) + (Number(tokens.reasoning) || 0);
|
|
@@ -994,9 +1180,10 @@ function accumulateOpenCodeUsage(events) {
|
|
|
994
1180
|
+ (Number(tokens.cache?.write) || 0);
|
|
995
1181
|
if (turnContext > maxSingleTurnContext) maxSingleTurnContext = turnContext;
|
|
996
1182
|
}
|
|
997
|
-
|
|
1183
|
+
const partial = generation === "2" && openCodeTextAfterLastFinish(events, lastFinishIndex);
|
|
1184
|
+
if (!seen) return partial ? {usage_partial: true} : null;
|
|
998
1185
|
const usage = {input, cacheCreation, cacheRead, output, turns, maxSingleTurnContext};
|
|
999
|
-
return {...usage, priceWeighted: priceWeightedUsage(usage)};
|
|
1186
|
+
return {...usage, priceWeighted: priceWeightedUsage(usage), ...(partial ? {usage_partial: true} : {})};
|
|
1000
1187
|
}
|
|
1001
1188
|
|
|
1002
1189
|
// Claude Code's stream-json carries a per-Messages-API-shaped `usage` object
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
discoverCapabilities,
|
|
3
|
+
discoverModelCatalog,
|
|
4
|
+
loadDocumentedCatalog,
|
|
5
|
+
modelDiscoveryRetryTimeoutMs,
|
|
6
|
+
modelDiscoveryTimeoutLabel
|
|
7
|
+
} from "./model-discovery.mjs";
|
|
2
8
|
import {ADAPTER_CONTRACT_VERSION, validateCapabilityDeclaration} from "./capability-contract.mjs";
|
|
3
9
|
|
|
4
10
|
export const ADAPTER_CONTRACT = "autopilot-adapter.v2";
|
|
@@ -26,15 +32,31 @@ export function validateAdapterContract(id, adapter) {
|
|
|
26
32
|
return {ok: errors.length === 0, errors};
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
// Backlog #5: a cold first start can exceed the normal model-list budget while
|
|
36
|
+
// the CLI is still working. Retry a timed-out list ONCE with double the
|
|
37
|
+
// effective budget before setup/config falls back to the free-text prompt.
|
|
38
|
+
// Unsupported/error results are never retried.
|
|
39
|
+
async function discoverCatalogWithTimeoutRetry(id, adapter) {
|
|
40
|
+
const first = await discoverModelCatalog(adapter);
|
|
41
|
+
if (first?.status !== "timeout") return first;
|
|
42
|
+
const retryMs = modelDiscoveryRetryTimeoutMs();
|
|
43
|
+
const binary = adapter.binary || id;
|
|
44
|
+
console.log(`${binary} is slow to answer (first start can take a while) — trying again for up to ${modelDiscoveryTimeoutLabel(retryMs)}…`);
|
|
45
|
+
return discoverModelCatalog(adapter, {timeoutMs: retryMs});
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
export async function discoverAgentContract(id, adapter) {
|
|
30
49
|
const validation = validateAdapterContract(id, adapter);
|
|
31
50
|
if (!validation.ok) return {contract: ADAPTER_CONTRACT, adapter: id, valid: false, errors: validation.errors, capabilities: null, access: {method: "cli", requires_api_key: false}, models: {status: "failed", source: "contract", items: []}};
|
|
32
51
|
const capabilities = await discoverCapabilities(adapter);
|
|
33
52
|
try {
|
|
34
|
-
const items = await
|
|
53
|
+
const items = await discoverCatalogWithTimeoutRetry(id, adapter);
|
|
35
54
|
if (Array.isArray(items) && items.length) {
|
|
36
55
|
return {contract: ADAPTER_CONTRACT, adapter: id, valid: true, errors: [], capabilities, access: {method: "cli", requires_api_key: false, note: "Uses the installed CLI/provider authentication context."}, models: {status: "reported", source: adapter.model_discovery || "cli", items}};
|
|
37
56
|
}
|
|
57
|
+
if (items?.status === "timeout") {
|
|
58
|
+
return {contract: ADAPTER_CONTRACT, adapter: id, valid: true, errors: [], capabilities, access: {method: "cli", requires_api_key: false}, models: {status: "timeout", source: adapter.model_discovery || "cli", items: []}};
|
|
59
|
+
}
|
|
38
60
|
} catch (error) {
|
|
39
61
|
return {contract: ADAPTER_CONTRACT, adapter: id, valid: true, errors: [String(error?.message || error)], capabilities, access: {method: "cli", requires_api_key: false}, models: {status: "failed", source: adapter.model_discovery || "cli", items: []}};
|
|
40
62
|
}
|