flanders 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,7 @@ Hi-diddly-ho, neighbor! I am a Node.js toolkit that helps avoid AI drifting. I w
11
11
  - [Configuration](#configuration)
12
12
  - [Usage](#usage)
13
13
  - [A worked example](#a-worked-example)
14
+ - [Hard stop](#hard-stop)
14
15
 
15
16
  ## How it works
16
17
 
@@ -40,7 +41,7 @@ A few neighborly things to have on hand before you start:
40
41
 
41
42
  - **Node.js**.
42
43
  - **A git repository** — the `implement` command requires the project to be a git repository.
43
- - **A supported CLI AI coding tool** — currently Claude Code or Codex.
44
+ - **A supported CLI AI coding tool** — currently Claude Code or Codex CLI.
44
45
 
45
46
  ## Installation
46
47
 
@@ -74,18 +75,20 @@ For each AI tool you select for skills, `install` writes one skill artifact per
74
75
  | Claude Code | `.claude/skills/` | `~/.claude/skills/` |
75
76
  | Codex CLI | `.codex/prompts/` | `~/.codex/prompts/` |
76
77
 
77
- Select `both` and the artifacts are written for both tools, each into its own folder. Alongside the skills, the command writes the `.flanders/` configuration at the chosen scope (see [Configuration](#configuration)).
78
+ Select both tools and the artifacts are written for each, into its own folder. Alongside the skills, the command writes the `.flanders/` configuration at the chosen scope (see [Configuration](#configuration)).
78
79
 
79
80
  ### Interactive prompts
80
81
 
81
82
  Run it without flags and Flanders walks you through the setup, asking in this order:
82
83
 
83
- 1. **Skills tool** — `claude`, `codex`, or `both`.
84
+ 1. **Skills tool** — one or more of `claude` and `codex`.
84
85
  2. **Scope** — `--project` or `--global`, each option labelled with the concrete destination path(s) for the skills tool you picked.
85
86
  3. **Worker tool, model, and effort** — the AI the `implement` command's worker uses.
86
87
  4. **Reviewer configuration** — an ordered list of one or more adversarial reviewers, each with its own tool, model, and effort. You can have any number of reviewers, for example Claude Opus, Claude Sonnet and Codex. You can even duplicate them if you think that one pass is not enough to detect a problem.
87
88
  5. **Weighted-review configuration** — when two or more reviewers are configured, the minimum number of reviewers that must run to a verdict, and which reviewers are optional. Optional reviewers will not halt the implementation when they reach a rate limit.
88
89
 
90
+ After the effort question, both the worker and each reviewer are also asked whether to enable Claude Code's fast mode — but only when that role's tool is Claude Code and its chosen model supports fast mode. The question defaults to off, since fast mode bills at a higher rate, and your answer is persisted per role in the `.flanders/` configuration.
91
+
89
92
  And if a `.flanders/` configuration already lives at the scope you choose, neighbor, `install` reads it the moment you pick that scope and pre-selects your stored answers as the question defaults. Just press Enter straight through to reproduce your saved configuration just as it was.
90
93
 
91
94
  ### Flags
@@ -99,10 +102,11 @@ Every question has an equivalent command-line flag, so the whole setup can run w
99
102
 
100
103
  **Skills and worker**
101
104
 
102
- - `--skills-tool=<claude|codex|both>` — which AI tool(s) the skills are installed for.
105
+ - `--skills-tool=<claude|codex|claude,codex>` — which AI tool(s) the skills are installed for, as a comma-separated list of one or more of `claude` and `codex`.
103
106
  - `--worker-tool=<claude|codex>` — which AI tool the `implement` worker uses.
104
107
  - `--worker-model=<value>` — model the worker tool invokes; an empty value means "use the tool's default configured model".
105
108
  - `--worker-effort=<value>` — reasoning effort the worker tool invokes; an empty value means "use the tool's default configured effort".
109
+ - `--worker-fast` — a presence flag that enables Claude Code's fast mode for the worker; off by default, and valid only for a worker whose tool is `claude` and whose model supports fast mode.
106
110
 
107
111
  **Reviewers** — an ordered list, where reviewer 1 uses the unindexed names and reviewer `N` (2 or greater) carries the index:
108
112
 
@@ -112,6 +116,8 @@ Every question has an equivalent command-line flag, so the whole setup can run w
112
116
 
113
117
  The reviewer indices must form a contiguous run starting at reviewer 1. Supplying any reviewer flag fixes the reviewer list to those indices and skips the "configure another reviewer?" prompt.
114
118
 
119
+ A presence flag, `--reviewer-fast` / `--reviewer-N-fast`, enables Claude Code's fast mode for that reviewer; off by default, and valid only for a reviewer whose tool is `claude` and whose model supports fast mode. It annotates a reviewer within the established list rather than establishing or extending it, so — unlike the tool, model, and effort flags above — it neither fixes the list nor skips the "configure another reviewer?" prompt.
120
+
115
121
  **Weighted review** — only meaningful with two or more reviewers:
116
122
 
117
123
  - `--reviewer-optional` / `--reviewer-N-optional` — a presence flag that marks that reviewer optional; a reviewer with no such flag is required.
@@ -131,7 +137,7 @@ Updated the lib and itching for the freshest skills, neighbor? Just run:
131
137
  flanders update
132
138
  ```
133
139
 
134
- `update` takes no flags. It scans the four skill destinations `install` writes to — Claude Code and Codex CLI, each at project and global scope — and wherever it finds at least one Flanders skill artifact already in place, it rewrites the full `/flanders-spec`, `/flanders-plan`, and `/flanders-work` trio there with the current version. A destination where no Flanders skill artifact is present is left untouched, so `update` refreshes the installations you already have and never creates one where you had none.
140
+ `update` takes no flags. It scans the four skill destinations `install` writes to — Claude Code's `.claude/skills/` and `~/.claude/skills/`, and Codex CLI's `.codex/prompts/` and `~/.codex/prompts/` — and wherever it finds at least one Flanders skill artifact already in place, it rewrites the full `/flanders-spec`, `/flanders-plan`, and `/flanders-work` trio there with the current version. A destination where no Flanders skill artifact is present is left untouched, so `update` refreshes the installations you already have and never creates one where you had none.
135
141
 
136
142
  ## Configuration
137
143
 
@@ -238,4 +244,14 @@ If you really want the font to be kept at that size, just save the spec, and no
238
244
  ```
239
245
  ```
240
246
  /flanders-work
241
- ```
247
+ ```
248
+
249
+ ## Hard stop
250
+
251
+ Even the most neighborly run can run out of road. When Flanders can't get a single task past the build, test, and review gates within its five attempts, it doesn't keep flailing away — it calls a **hard stop**: the whole `implement` run ends right there and exits with a non-zero status.
252
+
253
+ It won't leave you guessing, though. Flanders prints an error that names the task that got stuck — its line number in the plan and its title — and points you at that run's temporary folder. Every other time Flanders exits it tidies that folder away, but on a hard stop it leaves it right where it is, on purpose, so you can have a look.
254
+
255
+ Inside you'll find the sessions from every attempt on the task: the worker's output, the build and test output, each reviewer's output, and the `error.log` that briefed the final iteration. It's the whole story of what was tried and where each go-round fell short.
256
+
257
+ And here's the neighborly part — you don't have to untangle it all yourself. Hand that folder to your AI coding tool and just ask it to review the folder and tell you why the run failed. It'll read back through the sessions and walk you through what went wrong, so you can mend the spec, the plan, or the task and send Flanders off to try again.
@@ -1,5 +1,5 @@
1
1
  import type { TimeContext } from "../contexts";
2
- import type { ToolAdapter, ToolAdapterUsageCallback, ToolEventOutput } from "./ToolAdapter";
2
+ import type { ToolAdapter, ToolAdapterUsageCallback, ToolTokenUsage, ToolEventOutput } from "./ToolAdapter";
3
3
  export type RunCallbacks = Readonly<{
4
4
  onOutput(event: ToolEventOutput): void;
5
5
  onSessionId(id: string): void;
@@ -12,7 +12,9 @@ export type RunArgs = Readonly<{
12
12
  prompt: string;
13
13
  model: string;
14
14
  effort: string;
15
+ fast: boolean;
15
16
  resumeSessionId?: string;
17
+ priorSessionUsage?: ToolTokenUsage;
16
18
  abortSignal: AbortSignal;
17
19
  callbacks: RunCallbacks;
18
20
  time: TimeContext;
@@ -7,7 +7,7 @@ const INITIAL_TRANSIENT_WAIT_MS = 1000;
7
7
  const TRANSIENT_WAIT_CAP_MS = 60000;
8
8
  async function run(args) {
9
9
  var _a, _b;
10
- const { adapter, prompt, model, effort, abortSignal, callbacks, time } = args;
10
+ const { adapter, prompt, model, effort, fast, abortSignal, callbacks, time } = args;
11
11
  if (abortSignal.aborted) {
12
12
  throw abortError();
13
13
  }
@@ -15,7 +15,7 @@ async function run(args) {
15
15
  let transientAttempt = 0;
16
16
  let firstInvocation = true;
17
17
  for (;;) {
18
- const base = { prompt, model, effort, abortSignal, onUsage: callbacks.onUsage };
18
+ const base = { prompt, model, effort, fast, abortSignal, onUsage: callbacks.onUsage, priorSessionUsage: args.priorSessionUsage };
19
19
  let invokeArgs;
20
20
  if (firstInvocation) {
21
21
  if (args.resumeSessionId) {
@@ -1,5 +1,5 @@
1
1
  import type { OutputContext, TimeContext } from "../contexts";
2
- import type { ToolAdapter } from "./ToolAdapter";
2
+ import type { ToolAdapter, ToolTokenUsage } from "./ToolAdapter";
3
3
  export type AiSessionResult = Readonly<{
4
4
  text: string;
5
5
  sessionId: string | null;
@@ -11,7 +11,9 @@ export type AiSessionOptions = Readonly<{
11
11
  prompt: string;
12
12
  model: string;
13
13
  effort: string;
14
+ fast: boolean;
14
15
  resumeSessionId?: string | null;
16
+ priorSessionUsage?: ToolTokenUsage;
15
17
  onLongWaitStart?(kind: "rate-limit", endTimeMs: number): void;
16
18
  onLongWaitEnd?(): void;
17
19
  }>;
@@ -71,7 +71,9 @@ class AiSession {
71
71
  prompt: this._options.prompt,
72
72
  model: this._options.model,
73
73
  effort: this._options.effort,
74
+ fast: this._options.fast,
74
75
  ...(this._options.resumeSessionId != null ? { resumeSessionId: this._options.resumeSessionId } : null),
76
+ ...(this._options.priorSessionUsage != null ? { priorSessionUsage: this._options.priorSessionUsage } : null),
75
77
  abortSignal: controller.signal,
76
78
  callbacks: {
77
79
  onOutput,
@@ -1,8 +1,9 @@
1
- import type { ScriptContext, TimeContext } from "../contexts";
1
+ import type { ScriptContext, TimeContext, RandomContext } from "../contexts";
2
2
  import type { ToolAdapter, ToolAdapterInvokeArgs, ToolEvent } from "./ToolAdapter";
3
3
  export type ClaudeAdapterContexts = Readonly<{
4
4
  claude: ScriptContext;
5
5
  time: TimeContext;
6
+ random: RandomContext;
6
7
  }>;
7
8
  export declare function formatToolInput(input: Readonly<Record<string, unknown>> | undefined): string;
8
9
  export declare class ClaudeAdapter implements ToolAdapter {
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ClaudeAdapter = void 0;
4
4
  exports.formatToolInput = formatToolInput;
5
+ const toolErrorClassification_1 = require("./toolErrorClassification");
5
6
  const TOOL_INPUT_INLINE_MAX = 120;
6
7
  function formatToolInput(input) {
7
8
  if (!input || typeof input !== "object") {
@@ -81,11 +82,11 @@ class ClaudeAdapterIterator {
81
82
  this._capturedSessionId = null;
82
83
  this._queue = [];
83
84
  this._done = false;
84
- this._error = null;
85
85
  this._waitResolve = null;
86
86
  this._abortListener = null;
87
87
  this._pendingTerminal = null;
88
88
  this._exitPromise = null;
89
+ this._retainedRateLimitInfo = null;
89
90
  this._start();
90
91
  }
91
92
  _start() {
@@ -122,13 +123,24 @@ class ClaudeAdapterIterator {
122
123
  });
123
124
  proc.on("error", (e) => {
124
125
  if (!this._done) {
126
+ const err = e instanceof Error ? e : new Error(String(e));
125
127
  this._done = true;
126
- this._error = e instanceof Error ? e : new Error(String(e));
128
+ this._queue.push({
129
+ type: "error",
130
+ retryable: false,
131
+ message: err.code === "ENOENT"
132
+ ? "claude binary not found"
133
+ : err.message
134
+ });
127
135
  this._wake();
128
136
  }
129
137
  exitResolve === null || exitResolve === void 0 ? void 0 : exitResolve();
130
138
  });
131
- proc.on("exit", () => {
139
+ proc.on("exit", (code, signal) => {
140
+ if (this._done) {
141
+ exitResolve === null || exitResolve === void 0 ? void 0 : exitResolve();
142
+ return;
143
+ }
132
144
  if (stderrBuf) {
133
145
  this._queue.push({
134
146
  type: "output",
@@ -142,6 +154,20 @@ class ClaudeAdapterIterator {
142
154
  this._queue.push(this._pendingTerminal);
143
155
  this._pendingTerminal = null;
144
156
  }
157
+ else if (signal) {
158
+ this._queue.push({
159
+ type: "error",
160
+ retryable: true,
161
+ message: `claude terminated by signal ${signal}`
162
+ });
163
+ }
164
+ else {
165
+ this._queue.push({
166
+ type: "error",
167
+ retryable: true,
168
+ message: `claude exited unexpectedly (code ${code} signal ${signal})`
169
+ });
170
+ }
145
171
  if (!this._done) {
146
172
  this._done = true;
147
173
  }
@@ -149,10 +175,10 @@ class ClaudeAdapterIterator {
149
175
  exitResolve === null || exitResolve === void 0 ? void 0 : exitResolve();
150
176
  });
151
177
  this._abortListener = () => {
178
+ this._done = true;
152
179
  if (this._proc) {
153
180
  this._proc.kill("SIGINT");
154
181
  }
155
- this._done = true;
156
182
  this._wake();
157
183
  };
158
184
  if (this._args.abortSignal.aborted) {
@@ -173,11 +199,16 @@ class ClaudeAdapterIterator {
173
199
  if (this._args.effort) {
174
200
  argv.push("--effort", this._args.effort);
175
201
  }
202
+ if (this._args.fast) {
203
+ argv.push("--settings", JSON.stringify({ fastMode: true }));
204
+ }
176
205
  argv.push("--input-format", "stream-json", "--output-format", "stream-json", "--include-partial-messages", "--verbose", "--print", "--dangerously-skip-permissions");
177
206
  return argv;
178
207
  }
179
208
  _handleLine(line) {
180
209
  var _a, _b, _c, _d, _e, _f;
210
+ if (this._done)
211
+ return;
181
212
  let parsed = null;
182
213
  try {
183
214
  parsed = JSON.parse(line);
@@ -197,6 +228,9 @@ class ClaudeAdapterIterator {
197
228
  this._queue.push({ type: "session", id: parsed.session_id });
198
229
  }
199
230
  }
231
+ if (parsed.type === "rate_limit_event" && parsed.rate_limit_info) {
232
+ this._retainedRateLimitInfo = parsed.rate_limit_info;
233
+ }
200
234
  if (parsed.type === "assistant" && ((_a = parsed.message) === null || _a === void 0 ? void 0 : _a.content)) {
201
235
  for (const block of parsed.message.content) {
202
236
  if (block.type === "tool_use" && typeof block.name === "string") {
@@ -259,12 +293,12 @@ class ClaudeAdapterIterator {
259
293
  this._wake();
260
294
  }
261
295
  _classifyError(parsed) {
262
- var _a, _b;
296
+ var _a, _b, _c;
263
297
  const status = parsed.api_error_status;
264
298
  const subtype = parsed.subtype;
265
299
  const message = (_b = (_a = parsed.error) === null || _a === void 0 ? void 0 : _a.message) !== null && _b !== void 0 ? _b : "unknown error";
266
- if (status === 429) {
267
- const info = parsed.rate_limit_info;
300
+ const info = (_c = this._retainedRateLimitInfo) !== null && _c !== void 0 ? _c : parsed.rate_limit_info;
301
+ if (info || status === 429) {
268
302
  if (info) {
269
303
  const target = info.isUsingOverage && typeof info.overageResetsAt === "number"
270
304
  ? info.overageResetsAt
@@ -273,7 +307,7 @@ class ClaudeAdapterIterator {
273
307
  return { type: "rate_limit", waitUntilMs: target * 1000 };
274
308
  }
275
309
  }
276
- return { type: "error", retryable: true, message };
310
+ return (0, toolErrorClassification_1.synthesizeRateLimitEvent)(this._contexts.time, this._contexts.random);
277
311
  }
278
312
  if (typeof status === "number" && status >= 500) {
279
313
  return { type: "error", retryable: true, message };
@@ -309,9 +343,6 @@ class ClaudeAdapterIterator {
309
343
  if (this._queue.length > 0) {
310
344
  return { value: this._queue.shift(), done: false };
311
345
  }
312
- if (this._error) {
313
- throw this._error;
314
- }
315
346
  if (this._done && this._queue.length === 0) {
316
347
  this._cleanup();
317
348
  if (this._exitPromise) {
@@ -2,39 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CodexAdapter = void 0;
4
4
  exports.formatCodexCommand = formatCodexCommand;
5
+ const toolErrorClassification_1 = require("./toolErrorClassification");
5
6
  const COMMAND_INLINE_MAX = 120;
6
- const RATE_LIMIT_SUBSTRINGS = [
7
- "out of credits",
8
- "refill",
9
- "usage limit",
10
- "rate limit",
11
- "rate-limit",
12
- "rate_limit",
13
- "quota",
14
- "too many requests"
15
- ];
16
- const RATE_LIMIT_429_RE = /\b429\b/;
17
- const EIGHT_MINUTES_MS = 8 * 60000;
18
- const TWELVE_MINUTES_MS = 12 * 60000;
19
- const FIVE_XX_RE = /\b5\d{2}\b/;
20
- const STATUS_408_RE = /\b408\b/;
21
- const STATUS_425_RE = /\b425\b/;
22
- const TRANSPORT_SUBSTRINGS = [
23
- "timeout",
24
- "timed out",
25
- "connection reset",
26
- "connection refused",
27
- "socket hang up",
28
- "temporarily unavailable",
29
- "service unavailable",
30
- "gateway",
31
- "network",
32
- "econnreset",
33
- "econnrefused",
34
- "enotfound",
35
- "etimedout",
36
- "eai_again"
37
- ];
38
7
  function formatCodexCommand(command) {
39
8
  if (!command)
40
9
  return "";
@@ -44,32 +13,6 @@ function formatCodexCommand(command) {
44
13
  }
45
14
  return firstLine;
46
15
  }
47
- function isRateLimitMessage(message) {
48
- const lower = message.trim().toLowerCase();
49
- for (const sub of RATE_LIMIT_SUBSTRINGS) {
50
- if (lower.includes(sub))
51
- return true;
52
- }
53
- return RATE_LIMIT_429_RE.test(message.trim());
54
- }
55
- function isRetryableHttpStatus(message) {
56
- const trimmed = message.trim();
57
- if (FIVE_XX_RE.test(trimmed))
58
- return true;
59
- if (STATUS_408_RE.test(trimmed))
60
- return true;
61
- if (STATUS_425_RE.test(trimmed))
62
- return true;
63
- return false;
64
- }
65
- function isRetryableTransport(message) {
66
- const lower = message.trim().toLowerCase();
67
- for (const sub of TRANSPORT_SUBSTRINGS) {
68
- if (lower.includes(sub))
69
- return true;
70
- }
71
- return false;
72
- }
73
16
  class CodexAdapter {
74
17
  constructor(_contexts) {
75
18
  this._contexts = _contexts;
@@ -97,13 +40,12 @@ class CodexAdapterIterator {
97
40
  this._exitPromise = null;
98
41
  this._sawTurnCompleted = false;
99
42
  this._receivedAnyEvent = false;
100
- this._fallbackAttempted = false;
101
43
  this._usedResume = false;
102
- this._start(false);
44
+ this._start();
103
45
  }
104
- _start(useExecFallback) {
46
+ _start() {
105
47
  var _a, _b, _c;
106
- const isResume = !useExecFallback && !!this._args.resumeSessionId;
48
+ const isResume = !!this._args.resumeSessionId;
107
49
  this._usedResume = isResume;
108
50
  const argv = this._buildArgv(isResume);
109
51
  const spawnOptions = { stdio: "pipe" };
@@ -148,23 +90,6 @@ class CodexAdapterIterator {
148
90
  exitResolve === null || exitResolve === void 0 ? void 0 : exitResolve();
149
91
  return;
150
92
  }
151
- if (this._usedResume && !this._receivedAnyEvent && !this._fallbackAttempted) {
152
- this._fallbackAttempted = true;
153
- this._queue.push({
154
- type: "output",
155
- title: "Continuity lost",
156
- subtitle: "",
157
- details: "codex resume unavailable in installed CLI"
158
- });
159
- this._cleanup();
160
- this._sawTurnCompleted = false;
161
- this._receivedAnyEvent = false;
162
- this._usedResume = false;
163
- exitResolve === null || exitResolve === void 0 ? void 0 : exitResolve();
164
- this._start(true);
165
- this._wake();
166
- return;
167
- }
168
93
  if (this._sawTurnCompleted) {
169
94
  this._queue.push({ type: "done" });
170
95
  }
@@ -175,6 +100,13 @@ class CodexAdapterIterator {
175
100
  message: `codex terminated by signal ${signal}`
176
101
  });
177
102
  }
103
+ else if (this._usedResume && !this._receivedAnyEvent) {
104
+ this._queue.push({
105
+ type: "error",
106
+ retryable: false,
107
+ message: "codex exec resume unavailable in installed CLI"
108
+ });
109
+ }
178
110
  else {
179
111
  this._queue.push({
180
112
  type: "error",
@@ -187,10 +119,10 @@ class CodexAdapterIterator {
187
119
  exitResolve === null || exitResolve === void 0 ? void 0 : exitResolve();
188
120
  });
189
121
  this._abortListener = () => {
122
+ this._done = true;
190
123
  if (this._proc) {
191
124
  this._proc.kill("SIGINT");
192
125
  }
193
- this._done = true;
194
126
  this._wake();
195
127
  };
196
128
  if (this._args.abortSignal.aborted) {
@@ -203,7 +135,7 @@ class CodexAdapterIterator {
203
135
  _buildArgv(isResume) {
204
136
  const argv = [];
205
137
  if (isResume) {
206
- argv.push("resume", this._args.resumeSessionId);
138
+ argv.push("exec", "resume", this._args.resumeSessionId);
207
139
  }
208
140
  else {
209
141
  argv.push("exec");
@@ -221,7 +153,7 @@ class CodexAdapterIterator {
221
153
  return argv;
222
154
  }
223
155
  _handleLine(line) {
224
- var _a, _b, _c;
156
+ var _a, _b, _c, _d, _e;
225
157
  if (this._done)
226
158
  return;
227
159
  let parsed = null;
@@ -244,9 +176,10 @@ class CodexAdapterIterator {
244
176
  else if (parsed.type === "turn.completed") {
245
177
  this._sawTurnCompleted = true;
246
178
  if (parsed.usage && this._args.onUsage) {
179
+ const base = this._usedResume ? this._args.priorSessionUsage : undefined;
247
180
  this._args.onUsage({
248
- inputTokens: (_a = parsed.usage.input_tokens) !== null && _a !== void 0 ? _a : 0,
249
- outputTokens: (_b = parsed.usage.output_tokens) !== null && _b !== void 0 ? _b : 0
181
+ inputTokens: ((_a = parsed.usage.input_tokens) !== null && _a !== void 0 ? _a : 0) - ((_b = base === null || base === void 0 ? void 0 : base.inputTokens) !== null && _b !== void 0 ? _b : 0),
182
+ outputTokens: ((_c = parsed.usage.output_tokens) !== null && _c !== void 0 ? _c : 0) - ((_d = base === null || base === void 0 ? void 0 : base.outputTokens) !== null && _d !== void 0 ? _d : 0)
250
183
  });
251
184
  }
252
185
  }
@@ -254,7 +187,7 @@ class CodexAdapterIterator {
254
187
  this._handleFailure(typeof parsed.message === "string" ? parsed.message : "unknown error");
255
188
  }
256
189
  else if (parsed.type === "turn.failed") {
257
- this._handleFailure(typeof ((_c = parsed.error) === null || _c === void 0 ? void 0 : _c.message) === "string" ? parsed.error.message : "unknown error");
190
+ this._handleFailure(typeof ((_e = parsed.error) === null || _e === void 0 ? void 0 : _e.message) === "string" ? parsed.error.message : "unknown error");
258
191
  }
259
192
  this._wake();
260
193
  }
@@ -285,22 +218,7 @@ class CodexAdapterIterator {
285
218
  }
286
219
  }
287
220
  _handleFailure(message) {
288
- if (isRateLimitMessage(message)) {
289
- const r = EIGHT_MINUTES_MS + Math.round(this._contexts.random.random() * (TWELVE_MINUTES_MS - EIGHT_MINUTES_MS));
290
- this._queue.push({
291
- type: "rate_limit",
292
- waitUntilMs: this._contexts.time.now() + r
293
- });
294
- }
295
- else if (isRetryableHttpStatus(message)) {
296
- this._queue.push({ type: "error", retryable: true, message });
297
- }
298
- else if (isRetryableTransport(message)) {
299
- this._queue.push({ type: "error", retryable: true, message });
300
- }
301
- else {
302
- this._queue.push({ type: "error", retryable: false, message });
303
- }
221
+ this._queue.push((0, toolErrorClassification_1.classifyToolFailure)(message, this._contexts.time, this._contexts.random));
304
222
  this._done = true;
305
223
  }
306
224
  _wake() {
@@ -1,3 +1,4 @@
1
+ import type { TOOL_NAMES } from "../toolNames";
1
2
  export type ToolEventOutput = Readonly<{
2
3
  type: "output";
3
4
  title: string;
@@ -21,16 +22,19 @@ export type ToolEventDone = Readonly<{
21
22
  type: "done";
22
23
  }>;
23
24
  export type ToolEvent = ToolEventOutput | ToolEventSession | ToolEventError | ToolEventRateLimit | ToolEventDone;
24
- export type ToolAdapterUsageCallback = (usage: Readonly<{
25
+ export type ToolTokenUsage = Readonly<{
25
26
  inputTokens: number;
26
27
  outputTokens: number;
27
- }>) => void;
28
+ }>;
29
+ export type ToolAdapterUsageCallback = (usage: ToolTokenUsage) => void;
28
30
  type ToolAdapterInvokeArgsBase = Readonly<{
29
31
  prompt: string;
30
32
  model: string;
31
33
  effort: string;
34
+ fast: boolean;
32
35
  abortSignal: AbortSignal;
33
36
  onUsage?: ToolAdapterUsageCallback;
37
+ priorSessionUsage?: ToolTokenUsage;
34
38
  }>;
35
39
  export type ToolAdapterInvokeArgsFresh = ToolAdapterInvokeArgsBase & Readonly<{
36
40
  resumeSessionId?: undefined;
@@ -42,5 +46,5 @@ export type ToolAdapterInvokeArgs = ToolAdapterInvokeArgsFresh | ToolAdapterInvo
42
46
  export interface ToolAdapter {
43
47
  invoke(args: ToolAdapterInvokeArgs): AsyncIterable<ToolEvent>;
44
48
  }
45
- export type ToolName = "claude" | "codex";
49
+ export type ToolName = (typeof TOOL_NAMES)[number];
46
50
  export {};
@@ -0,0 +1,7 @@
1
+ import type { RandomContext, TimeContext } from "../contexts";
2
+ import type { ToolEventError, ToolEventRateLimit } from "./ToolAdapter";
3
+ export declare function isRateLimitMessage(message: string): boolean;
4
+ export declare function isRetryableHttpStatus(message: string): boolean;
5
+ export declare function isRetryableTransport(message: string): boolean;
6
+ export declare function synthesizeRateLimitEvent(time: TimeContext, random: RandomContext): ToolEventRateLimit;
7
+ export declare function classifyToolFailure(message: string, time: TimeContext, random: RandomContext): ToolEventRateLimit | ToolEventError;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isRateLimitMessage = isRateLimitMessage;
4
+ exports.isRetryableHttpStatus = isRetryableHttpStatus;
5
+ exports.isRetryableTransport = isRetryableTransport;
6
+ exports.synthesizeRateLimitEvent = synthesizeRateLimitEvent;
7
+ exports.classifyToolFailure = classifyToolFailure;
8
+ const RATE_LIMIT_SUBSTRINGS = [
9
+ "out of credits",
10
+ "refill",
11
+ "usage limit",
12
+ "rate limit",
13
+ "rate-limit",
14
+ "rate_limit",
15
+ "quota",
16
+ "too many requests"
17
+ ];
18
+ const RATE_LIMIT_429_RE = /\b429\b/;
19
+ const EIGHT_MINUTES_MS = 8 * 60000;
20
+ const TWELVE_MINUTES_MS = 12 * 60000;
21
+ const FIVE_XX_RE = /\b5\d{2}\b/;
22
+ const STATUS_408_RE = /\b408\b/;
23
+ const STATUS_425_RE = /\b425\b/;
24
+ const TRANSPORT_SUBSTRINGS = [
25
+ "timeout",
26
+ "timed out",
27
+ "connection reset",
28
+ "connection refused",
29
+ "socket hang up",
30
+ "temporarily unavailable",
31
+ "service unavailable",
32
+ "gateway",
33
+ "network",
34
+ "econnreset",
35
+ "econnrefused",
36
+ "enotfound",
37
+ "etimedout",
38
+ "eai_again"
39
+ ];
40
+ function isRateLimitMessage(message) {
41
+ const lower = message.trim().toLowerCase();
42
+ for (const sub of RATE_LIMIT_SUBSTRINGS) {
43
+ if (lower.includes(sub))
44
+ return true;
45
+ }
46
+ return RATE_LIMIT_429_RE.test(message.trim());
47
+ }
48
+ function isRetryableHttpStatus(message) {
49
+ const trimmed = message.trim();
50
+ if (FIVE_XX_RE.test(trimmed))
51
+ return true;
52
+ if (STATUS_408_RE.test(trimmed))
53
+ return true;
54
+ if (STATUS_425_RE.test(trimmed))
55
+ return true;
56
+ return false;
57
+ }
58
+ function isRetryableTransport(message) {
59
+ const lower = message.trim().toLowerCase();
60
+ for (const sub of TRANSPORT_SUBSTRINGS) {
61
+ if (lower.includes(sub))
62
+ return true;
63
+ }
64
+ return false;
65
+ }
66
+ function synthesizeRateLimitEvent(time, random) {
67
+ const r = EIGHT_MINUTES_MS + Math.round(random.random() * (TWELVE_MINUTES_MS - EIGHT_MINUTES_MS));
68
+ return { type: "rate_limit", waitUntilMs: time.now() + r };
69
+ }
70
+ function classifyToolFailure(message, time, random) {
71
+ if (isRateLimitMessage(message)) {
72
+ return synthesizeRateLimitEvent(time, random);
73
+ }
74
+ if (isRetryableHttpStatus(message) || isRetryableTransport(message)) {
75
+ return { type: "error", retryable: true, message };
76
+ }
77
+ return { type: "error", retryable: false, message };
78
+ }
package/lib/cli.js CHANGED
@@ -249,7 +249,9 @@ const ask = (() => {
249
249
  async askChoices(questions, output) {
250
250
  const out = output !== null && output !== void 0 ? output : outputContext;
251
251
  const total = questions.length;
252
- const answers = new Array(total).fill(undefined);
252
+ const answers = questions.map(q => q.multiSelect && q.defaultIndexes !== undefined && q.defaultIndexes.length > 0
253
+ ? { picked: q.defaultIndexes.map(i => q.options[i]) }
254
+ : undefined);
253
255
  let idx = 0;
254
256
  while (idx < total) {
255
257
  const q = questions[idx];
@@ -259,7 +261,7 @@ const ask = (() => {
259
261
  hints.push(q.multiSelect
260
262
  ? `[1-${q.options.length}, comma-separated; free-text OK]`
261
263
  : `[1-${q.options.length}; free-text OK]`);
262
- if (q.defaultIndex !== undefined) {
264
+ if (q.defaultIndex !== undefined || (q.multiSelect && existing !== undefined && existing.picked.length > 0)) {
263
265
  hints.push("Enter for configured");
264
266
  }
265
267
  if (idx > 0) {
@@ -297,6 +299,10 @@ const ask = (() => {
297
299
  idx++;
298
300
  continue;
299
301
  }
302
+ if (raw === "" && q.multiSelect && existing !== undefined && existing.picked.length > 0) {
303
+ idx++;
304
+ continue;
305
+ }
300
306
  const parsed = parseAnswer(raw, q.options.length, q.multiSelect);
301
307
  if (!parsed) {
302
308
  out.writeError("Invalid input. Pick a valid option number, type free-form text, or use '-' / '+' to navigate.\n");