@getpipher/armory-fleet 0.13.0 → 0.15.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
@@ -123,22 +123,22 @@ export const meta = {
123
123
  }
124
124
 
125
125
  phase('Plan')
126
- const plan = await agent('Plan this feature: ' + args.task, { tier: 'low' })
126
+ const plan = await agent('Plan this feature: ' + args.task, { tier: 'economy' })
127
127
 
128
128
  phase('Implement')
129
- const impl = await agent(`Implement the plan:\n${plan}`, { tier: 'medium' })
129
+ const impl = await agent(`Implement the plan:\n${plan}`, { tier: 'standard' })
130
130
 
131
131
  phase('Review')
132
132
  const angles = ['security', 'performance', 'correctness']
133
133
  const reviews = await parallel(
134
- angles.map((a) => () => agent(`Review the implementation for ${a} issues.`, { tier: 'low' })),
134
+ angles.map((a) => () => agent(`Review the implementation for ${a} issues.`, { tier: 'economy' })),
135
135
  )
136
136
 
137
137
  // gate: revise the synthesis until it passes a validator
138
138
  const synthesis = await gate(
139
139
  async (_feedback, n) => n === 0
140
- ? agent(`Synthesize ${reviews.length} reviews.`, { tier: 'low' })
141
- : agent('Revise synthesis per feedback.', { tier: 'low' }),
140
+ ? agent(`Synthesize ${reviews.length} reviews.`, { tier: 'economy' })
141
+ : agent('Revise synthesis per feedback.', { tier: 'economy' }),
142
142
  (v) => typeof v === 'string' && v.length > 200 ? { ok: true } : { ok: false, feedback: 'more detail' },
143
143
  { attempts: 3 },
144
144
  )
@@ -236,9 +236,21 @@ Composite helpers (`src/workflows/helpers/`) — usable from any workflow:
236
236
 
237
237
  | Tier | Models | Cost cap | Context floor |
238
238
  |---|---|---|---|
239
- | `economy` | `Ollama/minimax-m3:cloud` | — | — |
240
- | `standard` | `Ollama/glm-5.2:cloud`, `Ollama/minimax-m3:cloud` | — | — |
241
- | `frontier` | `anthropic/claude-sonnet-4`, `Ollama/glm-5.2:cloud` | $5 | 200k ctx |
239
+ | `economy` | `inherit` | — | — |
240
+ | `standard` | `inherit` | — | — |
241
+ | `frontier` | `inherit` | | 200k ctx |
242
+
243
+ The shipped defaults use the **`inherit` sentinel** — each tier resolves to your **active session model**, so tier routing works on any provider out of the box. To route across models, override a tier by name with a concrete `provider/id` chain in `~/.pi/agent/fleet/tiers.json` (global) or `<project>/.pi/fleet/tiers.json` (project):
244
+
245
+ ```json
246
+ [
247
+ { "name": "economy", "models": ["Ollama/minimax-m3:cloud"] },
248
+ { "name": "standard", "models": ["Ollama/glm-5.2:cloud", "inherit"] },
249
+ { "name": "frontier", "models": ["anthropic/claude-sonnet-4"], "costCap": 5, "contextFloor": 200000 }
250
+ ]
251
+ ```
252
+
253
+ Models are an ordered fallback chain (primary first; a spawn retries the next candidate if model creation is rejected); the `inherit` sentinel (case-insensitive) may appear anywhere in the chain as a provider-agnostic fallback and always resolves to the session model without catalog or floor checks. `contextFloor` skips catalog models below the window size; `costCap` aborts a run whose live cost exceeds the cap (a no-op on flat subscriptions). Tier routing applies to pi-backend agents — `backend: "claude"` agents receive the resolved string via `--model` and the claude CLI expects its own model names, so route those by `model:` instead.
242
254
 
243
255
  Live cost $ and context % are tracked per run and surfaced in the Tiers view. Override per-run with `model`, or let the tier registry route based on the task class.
244
256
 
@@ -316,6 +328,25 @@ Every workflow run is **journaled** (`workflows/journal.ts`) and **resumable**.
316
328
  - **Panel Run-action:** a 3rd `cwd` input step (task → name → cwd), prefilled with the session cwd; Enter accepts, Escape cancels.
317
329
  - **Deferred:** bg/scheduled + worktree cwd-isolation (the `cwd` param is honored by foreground dispatches only for now) — tracked in #62.
318
330
 
331
+ ## Provider-agnostic tiers (v0.15.0)
332
+
333
+ Small-backlog batch (issues #57/#63/#64/#65):
334
+
335
+ - **Tier `inherit` sentinel (#64)** — builtin tiers no longer hardcode a provider: `economy`/`standard`/`frontier` now resolve to your **active session model** out of the box (frontier keeps its 200k context floor; the $5 cost cap moved to the override example — a no-op on flat subs). Override by name in `tiers.json` with concrete `provider/id` chains for real multi-model routing; `inherit` can appear mid-chain as a provider-agnostic fallback. See [Cost-aware tiers](#cost-aware-tiers).
336
+ - **Self-correcting model errors (#57)** — dispatching with a model the runtime doesn't have now lists the session's available (authed) models in the error, so the orchestrating model can pick a valid one on the retry instead of guessing.
337
+ - **Panel Escape semantics documented + dead code removed (#63)** — Escape always cancels the active panel flow; defaults are accepted via Enter-on-blank. (Also fixed: ctrl+c could trigger the never-documented "escape accepts default" callbacks.)
338
+ - **README example tier names fixed (#65)** — the `ship-feature` example now uses real tier names (`economy`/`standard`).
339
+
340
+ ## Dogfood reliability (v0.14.0)
341
+
342
+ Four fixes from dogfooding the fleet on itself (issues #58–#61):
343
+
344
+ - **`ARMORY_FLEET_MODEL_FALLBACK=auto`** — resolve the global fallback per session from the configured+available model snapshot: a different **provider** than the session model is preferred, else a different model id; unresolvable (single-model setup) stays off with a one-time warning. Non-`auto` env values are used verbatim; per-dispatch `modelFallback` still wins.
345
+ - **No-fallback hint** — a retryable provider failure (stopReason `error`) with neither a per-dispatch `modelFallback` nor the global default surfaces `no modelFallback configured — pass modelFallback or set ARMORY_FLEET_MODEL_FALLBACK` so silent no-retry failures are visible.
346
+ - **Masked primary errors fixed** — when a fallback retry also fails, the surfaced error now names **both** attempts (`primary '<model>' failed: …; fallback '<model>' failed: …`) instead of only the fallback's.
347
+ - **Zero-tool-call flag (#61)** — a run that "completes" without a single executed tool call (the premature-return shape: narrate a plan, end) is prefixed with `[FLEET] zero-tool-call run — likely a premature return` in the tool result; `details.toolCallCount` exposes the count. Verify (git status/log) before trusting such a result.
348
+ - **Richer run journal** — `run:ended` now carries `error` (failure reason), `filesTouched` (#49 parity in the durable journal — real SDK args are captured from `tool_execution_start`; the end event has none), and `toolCallCount`.
349
+
319
350
  ## Roadmap
320
351
 
321
352
  armory-fleet follows a PRD → SPEC-N (brainstorm → spec → plan → implementation) pipeline. **16/16 phases done through v0.12.0.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "private": false,
5
5
  "description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
6
6
  "license": "MIT",
@@ -1,39 +1,58 @@
1
- // src/backend/claude-events.ts — map one CC stream-json NDJSON line → ChildSessionEvent (SPEC-3 §4.2).
1
+ // src/backend/claude-events.ts — map one CC stream-json NDJSON line → ChildSessionEvent(s) (SPEC-3 §4.2).
2
2
  // Returns null for: filtered echoes (our own user writes), unknown types, malformed lines.
3
3
  // The caller logs null-but-parseable lines at debug (forward-compat: CC may add types we don't need).
4
4
  import type { ChildSessionEvent } from "../engine/spawnSubagent.ts";
5
5
 
6
- interface CCMessage { role?: string; content?: Array<{ type: string; text?: string }>; usage?: Record<string, unknown>; }
6
+ interface CCContentBlock { type: string; text?: string; id?: string; name?: string; input?: Record<string, unknown>; }
7
+ interface CCMessage { role?: string; content?: CCContentBlock[]; usage?: Record<string, unknown>; }
7
8
  interface CCEvent { type: string; subtype?: string; session_id?: string; message?: CCMessage; error?: { message?: string }; }
8
9
 
9
- export function mapClaudeEvent(line: string): ChildSessionEvent | null {
10
+ /** Map one line to ALL the ChildSessionEvents it implies. An assistant message carrying tool_use
11
+ * blocks yields the message_end PLUS one tool_execution_end per block (#61: the engine's
12
+ * zero-tool-call premature-return signal must count claude children too — without this, every
13
+ * completed claude run counted 0 tools and was falsely flagged). The per-block end event fires
14
+ * at message time, not per-tool completion (CC stream-json gives no finer granularity) — right
15
+ * enough for "did the child act", which is what the count is for. */
16
+ export function mapClaudeEvents(line: string): ChildSessionEvent[] {
10
17
  let ev: CCEvent;
11
18
  try {
12
19
  ev = JSON.parse(line) as CCEvent;
13
20
  } catch {
14
- return null; // malformed line — resilient
21
+ return []; // malformed line — resilient
15
22
  }
16
23
  switch (ev.type) {
17
- case "system":
24
+ case "system": {
18
25
  if (ev.subtype === "init" && typeof ev.session_id === "string") {
19
- return { type: "session_init", backendSessionId: ev.session_id };
26
+ return [{ type: "session_init", backendSessionId: ev.session_id }];
20
27
  }
21
- return null;
28
+ return [];
29
+ }
22
30
  case "assistant": {
23
31
  const msg = ev.message;
24
- if (!msg) return null;
32
+ if (!msg) return [];
25
33
  const content = (msg.content ?? []).map((c) => ({ type: c.type, text: c.text }));
26
34
  const usage = msg.usage as { cost?: { total?: number } } | undefined;
27
- return { type: "message_end", message: { role: msg.role ?? "assistant", content, usage } };
35
+ const events: ChildSessionEvent[] = [{ type: "message_end", message: { role: msg.role ?? "assistant", content, usage } }];
36
+ for (const block of msg.content ?? []) {
37
+ if (block.type === "tool_use") {
38
+ events.push({ type: "tool_execution_end", toolCallId: block.id ?? "", toolName: block.name ?? "unknown", args: block.input, result: "", isError: false });
39
+ }
40
+ }
41
+ return events;
28
42
  }
29
43
  case "result":
30
44
  // turn boundary (success or error_max_turns) → turn_end drives the budget
31
- return { type: "turn_end" };
45
+ return [{ type: "turn_end" }];
32
46
  case "error":
33
- return { type: "error", message: { role: "error", content: [{ type: "text", text: ev.error?.message ?? "claude error" }] } };
47
+ return [{ type: "error", message: { role: "error", content: [{ type: "text", text: ev.error?.message ?? "claude error" }] } }];
34
48
  case "user":
35
- return null; // echo of our own stdin write — filtered
49
+ return []; // echo of our own stdin write — filtered
36
50
  default:
37
- return null; // unknown — forward-compat, caller logs at debug
51
+ return []; // unknown — forward-compat, caller logs at debug
38
52
  }
53
+ }
54
+
55
+ /** Single-event compat wrapper (claude-detector + existing consumers read one event per line). */
56
+ export function mapClaudeEvent(line: string): ChildSessionEvent | null {
57
+ return mapClaudeEvents(line)[0] ?? null;
39
58
  }
@@ -2,7 +2,7 @@
2
2
  import type { ChildProcess } from "node:child_process";
3
3
  import { createInterface } from "node:readline";
4
4
  import type { ChildSession, ChildSessionEvent } from "../engine/spawnSubagent.ts";
5
- import { mapClaudeEvent } from "./claude-events.ts";
5
+ import { mapClaudeEvents } from "./claude-events.ts";
6
6
  import type { ResumeStore } from "./resume-store.ts";
7
7
 
8
8
  export class ClaudeChildSession implements ChildSession {
@@ -24,16 +24,16 @@ export class ClaudeChildSession implements ChildSession {
24
24
  }
25
25
 
26
26
  private onLine(line: string): void {
27
- const ev = mapClaudeEvent(line);
28
- if (!ev) return;
29
- if (ev.type === "session_init" && ev.backendSessionId && !this.initCaptured) {
30
- this.initCaptured = true;
31
- this.resumeStore.set("claude", this.sessionKey, ev.backendSessionId);
27
+ for (const ev of mapClaudeEvents(line)) {
28
+ if (ev.type === "session_init" && ev.backendSessionId && !this.initCaptured) {
29
+ this.initCaptured = true;
30
+ this.resumeStore.set("claude", this.sessionKey, ev.backendSessionId);
31
+ }
32
+ if (ev.type === "turn_end" || ev.type === "error") {
33
+ if (this.turnResolve) { this.turnResolve(); this.turnResolve = null; }
34
+ }
35
+ for (const h of this.handlers) h(ev);
32
36
  }
33
- if (ev.type === "turn_end" || ev.type === "error") {
34
- if (this.turnResolve) { const r = this.turnResolve; this.turnResolve = null; r(); }
35
- }
36
- for (const h of this.handlers) h(ev);
37
37
  }
38
38
 
39
39
  async prompt(text: string): Promise<void> {
@@ -0,0 +1,19 @@
1
+ // src/engine/auto-fallback.ts
2
+ // #58: resolve the ARMORY_FLEET_MODEL_FALLBACK=auto sentinel — pick a fallback model from the
3
+ // runtime's configured+available snapshot without the operator naming one. Prefers a model from
4
+ // a DIFFERENT provider than the session model (a real fallback family, per the "Ollama primary +
5
+ // OpenRouter fallback" pattern the feature was named for); if every available model shares the
6
+ // session's provider, a different model id on that provider. undefined when nothing differs
7
+ // (single-model setups) — the caller keeps auto-retry off and surfaces why.
8
+
9
+ export function resolveAutoFallback(
10
+ available: ReadonlyArray<{ provider: string; id: string }>,
11
+ parentModel: { provider: string; id: string },
12
+ ): string | undefined {
13
+ const parentProvider = parentModel.provider || "";
14
+ const parentKey = `${parentModel.provider}/${parentModel.id}`;
15
+ const differentProvider = available.filter((m) => m.provider !== parentProvider);
16
+ const pool = differentProvider.length > 0 ? differentProvider : available;
17
+ const pick = pool.find((m) => `${m.provider}/${m.id}` !== parentKey);
18
+ return pick ? `${pick.provider}/${pick.id}` : undefined;
19
+ }
@@ -19,7 +19,14 @@ export function withModelFallbackRetry(spawn: SpawnFn, fallback: string | undefi
19
19
  return async (opts) => {
20
20
  const first = await spawn(opts);
21
21
  if (first.status === "failed" && first.retryable && fallback !== first.model && !signal?.aborted) {
22
- return spawn({ ...opts, model: fallback });
22
+ const second = await spawn({ ...opts, model: fallback });
23
+ // #59: when the fallback also fails, compose the primary's failure into the surfaced error
24
+ // (same contract as the direct-foreground path in tools/subagent.ts) — the fallback's error
25
+ // alone masks why the primary failed.
26
+ if (second.status === "failed" && first.error && first.error !== second.error) {
27
+ second.error = `primary '${first.model}' failed: ${first.error}; fallback '${fallback}' failed: ${second.error ?? second.status}`;
28
+ }
29
+ return second;
23
30
  }
24
31
  return first;
25
32
  };
@@ -44,6 +44,13 @@ export interface ChildSessionEvent {
44
44
  };
45
45
  /** Emitted by a backend on session init (SPEC-3). Drives runRecord.backendSessionId. */
46
46
  backendSessionId?: string;
47
+ /** tool_execution_end fields (pi SDK native; claude mapper synthesizes from tool_use blocks, #61).
48
+ * Consumers currently cast — these make the real shape type-visible. */
49
+ toolCallId?: string;
50
+ toolName?: string;
51
+ args?: unknown;
52
+ result?: unknown;
53
+ isError?: boolean;
47
54
  }
48
55
 
49
56
  export interface ChildSession {
@@ -182,6 +189,9 @@ export interface SpawnResult {
182
189
  * or was it cut mid-tool-work? Surfaced on turn-budget exhaustion so the controller knows
183
190
  * whether finalText is a partial summary or a mid-thought. Undefined for non-turn-budget paths. */
184
191
  reachedSummary?: boolean;
192
+ /** #61: number of executed tool calls. A "completed" run with 0 is the premature-return
193
+ * shape (the child narrated and ended without acting) — the tool flags it in the result. */
194
+ toolCallCount?: number;
185
195
  }
186
196
 
187
197
  /** #49: extract file paths a tool event touched, for the structured partial-result report.
@@ -190,11 +200,13 @@ export interface SpawnResult {
190
200
  function extractTouchedFiles(toolName: string, args: unknown): string[] {
191
201
  if (!args || typeof args !== "object") return [];
192
202
  const a = args as Record<string, unknown>;
193
- if (toolName === "edit" || toolName === "write") {
203
+ // Case-insensitive: pi tools are lowercase ("edit"), claude's are capitalized ("Edit").
204
+ const name = toolName.toLowerCase();
205
+ if (name === "edit" || name === "write") {
194
206
  const p = typeof a.path === "string" ? a.path : typeof a.file_path === "string" ? a.file_path : undefined;
195
207
  return p ? [p] : [];
196
208
  }
197
- if (toolName === "bash") {
209
+ if (name === "bash") {
198
210
  const cmd = typeof a.command === "string" ? a.command : undefined;
199
211
  if (!cmd) return [];
200
212
  const out: string[] = [];
@@ -363,6 +375,12 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
363
375
  // a trailing assistant message after its last tool (a summary) vs being cut mid-tool-work.
364
376
  const filesTouched = new Set<string>();
365
377
  let sawAssistantAfterLastTool = true; // no tools yet = trivially "reached a summary"
378
+ // #61: executed-tool count — a "completed" run with zero tool calls is the premature-return
379
+ // shape (read/narrate/end without acting); the tool flags it so the controller verifies.
380
+ let toolCallCount = 0;
381
+ // #60: args live on tool_execution_start (the SDK's end event carries none) — capture per
382
+ // toolCallId so extractTouchedFiles + the journal see the real args on real runs.
383
+ const pendingToolArgs = new Map<string, unknown>();
366
384
 
367
385
  // #23: liveness — classify events into a short, content-free class string for the widget.
368
386
  // Names the tool (safe — tool name is not args/result) so the operator sees "what's happening"
@@ -430,12 +448,20 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
430
448
  aborted = true;
431
449
  void session.abort();
432
450
  }
451
+ } else if (e.type === "tool_execution_start") {
452
+ pendingToolArgs.set((e as { toolCallId?: string }).toolCallId ?? "", (e as { args?: unknown }).args); // #60
433
453
  } else if (e.type === "tool_execution_end") {
434
454
  // #49: track mutated files for the structured partial-result report.
455
+ toolCallCount += 1; // #61
456
+ const toolCallId = (e as { toolCallId?: string }).toolCallId ?? "";
457
+ // #60: real args from the start event; `?? e.args` keeps hand-rolled fakes that put args
458
+ // on the end event working (the SDK never does).
459
+ const args = pendingToolArgs.get(toolCallId) ?? (e as { args?: unknown }).args;
460
+ pendingToolArgs.delete(toolCallId);
435
461
  sawAssistantAfterLastTool = false; // cut mid-tool-work unless a message follows
436
- for (const f of extractTouchedFiles((e as { toolName?: string }).toolName ?? "", (e as { args?: unknown }).args)) filesTouched.add(f);
462
+ for (const f of extractTouchedFiles((e as { toolName?: string }).toolName ?? "", args)) filesTouched.add(f);
437
463
  try {
438
- opts.runLog?.append(runId, buildToolEvent((e as any).toolName, (e as any).args, (e as any).result, (e as any).isError ?? false, turnIdx));
464
+ opts.runLog?.append(runId, buildToolEvent((e as any).toolName, args, (e as any).result, (e as any).isError ?? false, turnIdx));
439
465
  } catch { /* best-effort */ }
440
466
  }
441
467
  // #23: liveness heartbeat — update the run record on meaningful events so the fleet widget
@@ -524,7 +550,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
524
550
  status = "completed";
525
551
  }
526
552
 
527
- return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined, filesTouchedList, reachedSummary);
553
+ return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens, modelError ? true : undefined, filesTouchedList, reachedSummary, toolCallCount);
528
554
  } finally {
529
555
  // #31: a readOnly dispatch never acquired the lock — don't release what it didn't take
530
556
  // (releasing a lock held by another concurrent write dispatch would corrupt serialization).
@@ -547,7 +573,7 @@ async function finishRun(
547
573
  status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
548
574
  error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
549
575
  retryable?: boolean,
550
- filesTouched?: string[], reachedSummary?: boolean,
576
+ filesTouched?: string[], reachedSummary?: boolean, toolCallCount = 0,
551
577
  ): Promise<SpawnResult> {
552
578
  if (finalizedRunIds.has(runId)) {
553
579
  // Already finalized — return the existing registry record's result without re-appending.
@@ -557,7 +583,7 @@ async function finishRun(
557
583
  runId, todoId, agent: agentName, model,
558
584
  durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
559
585
  tokenTotal, costTotal, contextTokens, error, retryable,
560
- filesTouched, reachedSummary,
586
+ filesTouched, reachedSummary, toolCallCount,
561
587
  };
562
588
  }
563
589
  finalizedRunIds.add(runId);
@@ -574,6 +600,13 @@ async function finishRun(
574
600
  type: "run:ended", runId, status, endedAt,
575
601
  resultSummary: finalText.slice(0, 120), tokenTotal, costTotal, contextTokens,
576
602
  resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink,
603
+ // #59: journal the failure reason — the archived failing runs had run:ended with an empty
604
+ // resultSummary and no error field, making post-hoc diagnosis from the journal impossible.
605
+ error,
606
+ // #61: executed-tool count (the zero-work premature-return signal, post-hoc too).
607
+ toolCallCount,
608
+ // #60: what the run mutated (was only on the SpawnResult; the durable journal lacked it).
609
+ filesTouched,
577
610
  });
578
611
  } catch { /* best-effort: journal is the index, not the product */ }
579
612
  // SPEC-4: lifecycle phase children skip the per-run todo reconciliation — the lifecycle
@@ -592,6 +625,6 @@ async function finishRun(
592
625
  return {
593
626
  status, finalText, runId, todoId, agent: agentName, model,
594
627
  durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable,
595
- filesTouched, reachedSummary,
628
+ filesTouched, reachedSummary, toolCallCount,
596
629
  };
597
630
  }
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import { ArmoryMemoryAdapter } from "./memory-hydrate/adapter.ts";
18
18
  import { ArmoryVisionAdapter } from "./vision/adapter.ts";
19
19
  import { buildChildLoader } from "./engine/child-loader.ts";
20
20
  import { withModelFallbackRetry } from "./engine/retry-fallback.ts";
21
+ import { resolveAutoFallback } from "./engine/auto-fallback.ts";
21
22
  import { createDescribeImageTool } from "./vision/describe-image-tool.ts";
22
23
  import type { MemoryHydratePort } from "./memory-hydrate/port.ts";
23
24
  import type { VisionPort } from "./vision/port.ts";
@@ -108,6 +109,26 @@ async function buildDefaultBackendRegistry(modelRuntime: ModelRuntime): Promise<
108
109
  return reg;
109
110
  }
110
111
 
112
+ /**
113
+ * Format the runtime's available models for the #57 self-correcting error message:
114
+ * dedup `provider/id` pairs, cap the list (min 1 — a non-empty input always lists
115
+ * something), actionable hint when empty.
116
+ */
117
+ export function formatAvailableModels(models: readonly { provider: string; id: string }[], cap = 12): string {
118
+ const effectiveCap = Math.max(1, cap);
119
+ const seen = new Set<string>();
120
+ const list: string[] = [];
121
+ for (const m of models) {
122
+ const ref = `${m.provider}/${m.id}`;
123
+ if (seen.has(ref)) continue;
124
+ seen.add(ref);
125
+ if (list.length < effectiveCap) list.push(ref);
126
+ }
127
+ if (list.length === 0) return "(none — check provider auth / models.json)";
128
+ const omitted = seen.size - list.length;
129
+ return list.join(", ") + (omitted > 0 ? ` … +${omitted} more` : "");
130
+ }
131
+
111
132
  export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: MemoryHydratePort, resumeStore: ResumeStore): ChildSessionFactory {
112
133
  return {
113
134
  async create(opts) {
@@ -118,7 +139,13 @@ export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort
118
139
  const provider = opts.model.slice(0, slash);
119
140
  const id = opts.model.slice(slash + 1);
120
141
  model = modelRuntime.getModel(provider, id);
121
- if (!model) throw new Error(`agent model '${opts.model}' not found in runtime (provider '${provider}', id '${id}')`);
142
+ // #57: name what IS usable so the orchestrating model can self-correct on retry.
143
+ if (!model) {
144
+ throw new Error(
145
+ `agent model '${opts.model}' not found in runtime (provider '${provider}', id '${id}'). ` +
146
+ `Available: ${formatAvailableModels(modelRuntime.getAvailableSnapshot())} — pick one of these for the model param`,
147
+ );
148
+ }
122
149
  }
123
150
  // Fleet CustomResourceLoader: noExtensions + composed systemPromptOverride (rolePrompt + memory + base) + scoped skills.
124
151
  const loader = buildChildLoader({ cwd: opts.cwd, agent: opts.agent, memoryPort });
@@ -204,7 +231,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
204
231
  // #39 tail: global default fallback model (env-driven for now; a settings.json field is a follow-up).
205
232
  // A retryable provider failure (stopReason "error") retries once on this model even without a
206
233
  // per-dispatch `modelFallback`. Per the AGENTS.md "Ollama primary + OpenRouter fallback" pattern.
207
- deps.defaultModelFallback = process.env.ARMORY_FLEET_MODEL_FALLBACK || undefined;
234
+ // #39 tail + #58: the global default fallback. Non-"auto" env values are used verbatim;
235
+ // "auto" is resolved per-session in session_start (it needs the session model to differ from).
236
+ const rawModelFallback = process.env.ARMORY_FLEET_MODEL_FALLBACK || undefined;
237
+ deps.defaultModelFallback = rawModelFallback === "auto" ? undefined : rawModelFallback;
208
238
  // SPEC-6-5: cross-cwd dispatch notify hook (wired per-session in session_start below).
209
239
  // Placeholder; the real wiring happens in session_start where ctx is in scope.
210
240
 
@@ -309,6 +339,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
309
339
  refreshLifecycles(ctx);
310
340
  const m = ctx.model;
311
341
  deps.parentModel = m ? { provider: m.provider, id: m.id } : { provider: "", id: "" };
342
+ // #58: ARMORY_FLEET_MODEL_FALLBACK=auto — pick a fallback from the configured+available
343
+ // snapshot that differs from the session model (different provider preferred). Unresolvable
344
+ // (single-model setup) → stay off + say why once per session.
345
+ if (rawModelFallback === "auto") {
346
+ deps.defaultModelFallback = resolveAutoFallback(modelRuntime.getAvailableSnapshot(), deps.parentModel);
347
+ if (!deps.defaultModelFallback) {
348
+ ctx.ui.notify("ARMORY_FLEET_MODEL_FALLBACK=auto, but no alternative configured model is available — auto-retry stays off", "warning");
349
+ }
350
+ }
312
351
  deps.parentCwd = ctx.cwd;
313
352
  deps.onNotify = (m, k) => ctx.ui.notify(m, k ?? "info");
314
353
  // SPEC-5a: build the per-session async runner + scheduler, start firing, scan for interrupted runs.
@@ -425,10 +425,8 @@ export class FleetPanel extends Container {
425
425
  this.linkInput.onSubmit = (todoIdRaw: string) => {
426
426
  void this.executeRun(agentName, task.trim(), todoIdRaw.trim() || undefined);
427
427
  };
428
- this.linkInput.onEscape = () => { void this.executeRun(agentName, task.trim(), undefined); };
429
428
  this.renderShell();
430
429
  };
431
- this.taskInput.onEscape = () => this.cancelRun();
432
430
  this.runMode = true;
433
431
  this.renderShell();
434
432
  }
@@ -492,6 +490,12 @@ export class FleetPanel extends Container {
492
490
  }
493
491
 
494
492
  handleInput(data: string): void {
493
+ // Escape policy (#63): every modal branch below intercepts Escape BEFORE the active Input
494
+ // sees it, so pi-tui's Input.onEscape never fires in this panel — Escape always cancels
495
+ // the active flow. Defaults are accepted via Enter-on-blank ("blank=default" prompts).
496
+ // Caveat: ctrl+c also matches pi-tui's tui.select.cancel but is NOT intercepted here —
497
+ // it forwards to the Input, which ignores control characters (silent no-op). An onEscape
498
+ // callback re-added later would fire on ctrl+c but never on Escape — do not re-add.
495
499
  if (this.infoAgent) {
496
500
  if (matchesKey(data, "escape")) { this.infoAgent = null; this.renderShell(); }
497
501
  return;
@@ -811,13 +815,13 @@ export class FleetPanel extends Container {
811
815
  this.pendingCheckpoint = null;
812
816
  this.renderShell();
813
817
  };
814
- this.lcReviseInput.onEscape = () => { this.lcRevising = false; this.lcReviseInput = null; this.renderShell(); };
815
818
  this.renderShell();
816
819
  return;
817
820
  }
818
821
  }
819
822
  if (this.lcRevising && this.lcReviseInput) {
820
- if (matchesKey(data, "escape")) { this.lcRevising = false; this.lcReviseInput = null; this.renderShell(); return; }
823
+ // Escape never reaches here the panel-level intercept above resolves the pending
824
+ // checkpoint as abort + closes the panel first (#63). Only printable input forwards.
821
825
  this.lcReviseInput.handleInput(data);
822
826
  this.invalidate();
823
827
  return;
@@ -842,13 +846,10 @@ export class FleetPanel extends Container {
842
846
  const lcName = name.trim() || "default";
843
847
  this.executeScheduleAdd(task.trim(), expr.trim(), lcName);
844
848
  };
845
- this.schedNameInput.onEscape = () => { this.executeScheduleAdd(task.trim(), expr.trim(), "default"); };
846
849
  this.renderShell();
847
850
  };
848
- this.schedExprInput.onEscape = () => this.cancelScheduleAdd();
849
851
  this.renderShell();
850
852
  };
851
- this.schedTaskInput.onEscape = () => this.cancelScheduleAdd();
852
853
  this.schedRunMode = true;
853
854
  this.renderShell();
854
855
  }
@@ -892,7 +893,6 @@ export class FleetPanel extends Container {
892
893
  if (!followUp.trim()) { this.cancelResume(); return; }
893
894
  void this.executeResume(run, followUp.trim());
894
895
  };
895
- this.resumeInput.onEscape = () => this.cancelResume();
896
896
  this.resumeMode = true;
897
897
  this.renderShell();
898
898
  }
@@ -916,7 +916,6 @@ export class FleetPanel extends Container {
916
916
  if (!text.trim()) { this.cancelSteer(); return; }
917
917
  void this.executeSteer(run.runId, text.trim());
918
918
  };
919
- this.steerInput.onEscape = () => this.cancelSteer();
920
919
  this.steerMode = true;
921
920
  this.renderShell();
922
921
  }
@@ -969,7 +968,6 @@ export class FleetPanel extends Container {
969
968
  }
970
969
  this.tiersInput = new Input();
971
970
  this.tiersInput.onSubmit = (value: string) => { void this.executeTiersEdit(value, phase); };
972
- this.tiersInput.onEscape = () => this.cancelTiersEdit();
973
971
  this.tiersEditPhase = phase;
974
972
  this.renderShell();
975
973
  }
@@ -995,7 +993,6 @@ export class FleetPanel extends Container {
995
993
  }
996
994
  this.cancelWorkflowRun();
997
995
  };
998
- this.wfPromptInput.onEscape = () => this.cancelWorkflowRun();
999
996
  this.wfRunMode = true;
1000
997
  this.renderShell();
1001
998
  }
@@ -1097,10 +1094,8 @@ export class FleetPanel extends Container {
1097
1094
  this.linkInput.onSubmit = (todoIdRaw: string) => {
1098
1095
  void this.executeFork(run.agent, finalTask, todoIdRaw.trim() || undefined, run.runId);
1099
1096
  };
1100
- this.linkInput.onEscape = () => { void this.executeFork(run.agent, finalTask, undefined, run.runId); };
1101
1097
  this.renderShell();
1102
1098
  };
1103
- this.taskInput.onEscape = () => this.cancelRun();
1104
1099
  this.runMode = true;
1105
1100
  this.renderShell();
1106
1101
  }
@@ -1134,19 +1129,16 @@ export class FleetPanel extends Container {
1134
1129
  const lcName = name.trim() || "default";
1135
1130
  this.lcPhase = "cwd";
1136
1131
  this.lcCwdInput = new Input();
1137
- // SPEC-6-5: 3rd input step — the dispatch cwd. Prefilled with the session cwd; Enter
1138
- // accepts it, Escape accepts the default (mirrors the name step's Escape-accepts-default).
1132
+ // SPEC-6-5: 3rd input step — the dispatch cwd. Enter accepts the session cwd (blank)
1133
+ // or a typed path; Escape cancels the run (panel-level intercept #63).
1139
1134
  this.lcCwdInput.onSubmit = (cwd: string) => {
1140
1135
  const picked = cwd.trim() || this.deps.parentCwd;
1141
1136
  void this.executeLifecycleRun(task.trim(), lcName, picked);
1142
1137
  };
1143
- this.lcCwdInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), lcName, this.deps.parentCwd); };
1144
1138
  this.renderShell();
1145
1139
  };
1146
- this.lcNameInput.onEscape = () => { void this.executeLifecycleRun(task.trim(), "default", this.deps.parentCwd); };
1147
1140
  this.renderShell();
1148
1141
  };
1149
- this.lcTaskInput.onEscape = () => this.cancelLifecycleRun();
1150
1142
  this.lcRunMode = true;
1151
1143
  this.renderShell();
1152
1144
  }
@@ -33,6 +33,12 @@ export interface RunEndedEvent {
33
33
  costTotal?: number;
34
34
  /** SPEC-6-1: latest context-token snapshot at run end. */
35
35
  contextTokens?: number;
36
+ /** #59: the failure reason on failed runs (post-hoc diagnosability from the journal). */
37
+ error?: string;
38
+ /** #61: executed-tool count (the zero-work premature-return signal, post-hoc too). */
39
+ toolCallCount?: number;
40
+ /** #60: file paths the run mutated (post-hoc — was previously SpawnResult-only). */
41
+ filesTouched?: string[];
36
42
  }
37
43
  export type RunLogEvent = RunMetaEvent | MessageEvent | ToolEvent | RunEndedEvent;
38
44
 
@@ -1,8 +1,17 @@
1
1
  import type { Tier } from "./tier-registry.ts";
2
2
 
3
- /** Shipped default tiers (Q10). Overridable via global/project tiers.json. */
3
+ /**
4
+ * Shipped default tiers (Q10). Overridable via global/project tiers.json.
5
+ *
6
+ * Provider-agnostic since #64: the `inherit` sentinel resolves to the parent/active
7
+ * session model, so zero-config fleets work on ANY provider. Users who want real
8
+ * multi-model cost routing override these by name with concrete `provider/id`
9
+ * chains (`inherit` may also appear mid-chain as a fallback). `contextFloor`
10
+ * guards concrete candidates; `costCap` is a per-run $ abort (configure where
11
+ * meaningful — it is a no-op on flat subscriptions).
12
+ */
4
13
  export const BUILTIN_TIERS: Tier[] = [
5
- { name: "economy", models: ["Ollama/minimax-m3:cloud"] },
6
- { name: "standard", models: ["Ollama/glm-5.2:cloud", "Ollama/minimax-m3:cloud"] },
7
- { name: "frontier", models: ["anthropic/claude-sonnet-4", "Ollama/glm-5.2:cloud"], costCap: 5, contextFloor: 200000 },
8
- ];
14
+ { name: "economy", models: ["inherit"] },
15
+ { name: "standard", models: ["inherit"] },
16
+ { name: "frontier", models: ["inherit"], contextFloor: 200000 },
17
+ ];
@@ -34,6 +34,20 @@ export function resolveAgentModel(
34
34
  if (!tier) return { error: `tier '${agent.tier}' not found; available: ${tiers.list().map((t) => t.name).join(", ")}` };
35
35
  const candidates: string[] = [];
36
36
  for (const m of tier.models) {
37
+ if (m.trim().toLowerCase() === "inherit") {
38
+ // #64: "inherit" = use the parent/active model — always eligible, no catalog or
39
+ // contextFloor check (the session model is presumed appropriate; the floor guards
40
+ // concrete candidates). Participates in the chain: eligible concrete models listed
41
+ // before it win; after them it acts as the provider-agnostic fallback.
42
+ // Sentinel is case-insensitive/trimmed; concrete model strings stay verbatim.
43
+ // Empty parentModel (dispatch before a session model exists) is NOT pushed — a
44
+ // pure-inherit tier then falls to the descriptive no-eligible-model error below.
45
+ if (parentModel.provider || parentModel.id) {
46
+ const parent = `${parentModel.provider}/${parentModel.id}`;
47
+ if (!candidates.includes(parent)) candidates.push(parent);
48
+ }
49
+ continue;
50
+ }
37
51
  const { provider, id } = splitModel(m, parentModel.provider);
38
52
  const model = modelRegistry.find(provider, id);
39
53
  if (!model) continue; // not in catalog → skip
@@ -231,14 +231,43 @@ export function createSubagentTool(deps: SubagentToolDeps) {
231
231
  });
232
232
  retriedWithModel = fallback;
233
233
  }
234
+ // #59: when the fallback retry ALSO fails, surface the PRIMARY's failure too — returning
235
+ // only the fallback's error masked why the primary (e.g. an explicit model string) failed
236
+ // at all, making provider diagnosis impossible from the controller's seat.
237
+ if (retriedWithModel && finalRes.status === "failed" && res.error && res.error !== finalRes.error) {
238
+ finalRes = {
239
+ ...finalRes,
240
+ error: `primary '${res.model}' failed: ${res.error}; fallback '${retriedWithModel}' failed: ${finalRes.error ?? finalRes.status}`,
241
+ };
242
+ }
243
+ // #58: a retryable failure with NO fallback configured (neither per-dispatch nor global)
244
+ // means the auto-retry silently didn't fire — surface that, and how to enable it, exactly
245
+ // when it matters. Mutually exclusive with the #59 composition above (a retry implies a
246
+ // fallback was configured).
247
+ if (finalRes.status === "failed" && finalRes.retryable && !fallback) {
248
+ finalRes = {
249
+ ...finalRes,
250
+ error: `${finalRes.error ?? finalRes.status}\n(no modelFallback configured — pass modelFallback or set ARMORY_FLEET_MODEL_FALLBACK to enable one-shot auto-retry)`,
251
+ };
252
+ }
234
253
  const isError = finalRes.status === "failed" || finalRes.status === "aborted";
254
+ // #61: a run that "completed" without a single tool call is usually a premature return
255
+ // (the child narrated a plan and ended without acting) — flag it in-band so the controller
256
+ // verifies (git status/log) instead of trusting a terse planning statement as a completion.
257
+ const zeroToolRun = !isError && (finalRes.toolCallCount ?? 0) === 0;
258
+ const resultText = isError
259
+ ? (finalRes.error ?? finalRes.status)
260
+ : zeroToolRun
261
+ ? `[FLEET] zero-tool-call run — likely a premature return (#61); verify with git status/log before trusting this result.\n\n${finalRes.finalText}`
262
+ : finalRes.finalText;
235
263
  return {
236
- content: [{ type: "text" as const, text: isError ? (finalRes.error ?? finalRes.status) : finalRes.finalText }],
264
+ content: [{ type: "text" as const, text: resultText }],
237
265
  details: {
238
266
  runId: finalRes.runId, todoId: finalRes.todoId, agent: finalRes.agent, model: finalRes.model,
239
267
  status: finalRes.status, durationMs: finalRes.durationMs, tokenTotal: finalRes.tokenTotal,
240
268
  retriedWithModel,
241
269
  filesTouched: finalRes.filesTouched, reachedSummary: finalRes.reachedSummary,
270
+ toolCallCount: finalRes.toolCallCount, // #61: zero = the premature-return signal
242
271
  },
243
272
  isError,
244
273
  };