@getpipher/armory-fleet 0.14.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,15 @@ 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
+
319
340
  ## Dogfood reliability (v0.14.0)
320
341
 
321
342
  Four fixes from dogfooding the fleet on itself (issues #58–#61):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.14.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",
package/src/index.ts CHANGED
@@ -109,6 +109,26 @@ async function buildDefaultBackendRegistry(modelRuntime: ModelRuntime): Promise<
109
109
  return reg;
110
110
  }
111
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
+
112
132
  export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort: MemoryHydratePort, resumeStore: ResumeStore): ChildSessionFactory {
113
133
  return {
114
134
  async create(opts) {
@@ -119,7 +139,13 @@ export function createChildSessionFactory(modelRuntime: ModelRuntime, memoryPort
119
139
  const provider = opts.model.slice(0, slash);
120
140
  const id = opts.model.slice(slash + 1);
121
141
  model = modelRuntime.getModel(provider, id);
122
- 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
+ }
123
149
  }
124
150
  // Fleet CustomResourceLoader: noExtensions + composed systemPromptOverride (rolePrompt + memory + base) + scoped skills.
125
151
  const loader = buildChildLoader({ cwd: opts.cwd, agent: opts.agent, memoryPort });
@@ -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
  }
@@ -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