@getpipher/armory-fleet 0.9.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.9.3",
3
+ "version": "0.10.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",
@@ -23,6 +23,12 @@ export interface RunRecord {
23
23
  forkedFrom?: string;
24
24
  /** SPEC-5b-2: cumulative real tokens (input+output+cacheRead+cacheWrite) — live, updated on each message_end. */
25
25
  tokenTotal?: number;
26
+ /** SPEC-6-1: cumulative $ (usage.cost.total) — live, updated on each message_end. */
27
+ costTotal?: number;
28
+ /** SPEC-6-1: latest context tokens (calcContextTokens(usage)) — live snapshot. */
29
+ contextTokens?: number;
30
+ /** SPEC-6-1: the tier name this run used (for Tiers-view "used by" + per-tier spend). */
31
+ tier?: string;
26
32
  /** SPEC-5b-4: live session handle while status === "running"; cleared by finishRun.
27
33
  * Transient, in-memory only — never written to RunLog (the journal append constructs
28
34
  * a plain object, not RunRecord). */
@@ -9,9 +9,16 @@ import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts";
9
9
  import type { SingleSlotLock } from "./concurrency-lock.ts";
10
10
  import type { RunLog } from "../runtime/run-log.ts";
11
11
  import { buildToolEvent } from "../runtime/run-log.ts";
12
+ import { resolveAgentModel, type ModelRegistryLike } from "../tiers/resolve.ts";
13
+ import { TierRegistry } from "../tiers/tier-registry.ts";
12
14
 
13
15
  const PI_DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
14
16
 
17
+ /** SPEC-6-1: derive context-token count from a usage object. Prefer totalTokens; fall back to sum. */
18
+ function calcContextTokens(u: { totalTokens?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number }): number {
19
+ return u.totalTokens || ((u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0));
20
+ }
21
+
15
22
  /** No-op ports used when a caller omits them (e.g. SPEC-1 unit tests). Production (index.ts) passes real ports. */
16
23
  const NOOP_MEMORY_PORT: MemoryHydratePort = { renderScopes: () => "" };
17
24
  const NOOP_VISION_PORT: VisionPort = {
@@ -123,6 +130,10 @@ export interface SpawnOptions {
123
130
  resumeLink?: string;
124
131
  /** SPEC-5b-1: when set, the new run is a fork of this prior runId (written to run:ended + RunRecord.forkedFrom). */
125
132
  forkLink?: string;
133
+ /** SPEC-6-1: tier registry for model-tier resolution. Optional — existing callers without it use agent.model/parent fallback. */
134
+ tierRegistry?: TierRegistry;
135
+ /** SPEC-6-1: model catalog for contextFloor filtering. Optional — absent means no catalog filtering. */
136
+ modelRegistry?: ModelRegistryLike;
126
137
  }
127
138
 
128
139
  export interface SpawnResult {
@@ -134,6 +145,10 @@ export interface SpawnResult {
134
145
  model: string;
135
146
  durationMs: number;
136
147
  tokenTotal: number;
148
+ /** SPEC-6-1: cumulative $ (usage.cost.total) for this run. */
149
+ costTotal?: number;
150
+ /** SPEC-6-1: final context tokens (calcContextTokens of the last usage). */
151
+ contextTokens?: number;
137
152
  error?: string;
138
153
  }
139
154
 
@@ -173,8 +188,18 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
173
188
  // skillsOverride (buildChildLoader reads agent.skills) loads the phase's bundle, not the agent's.
174
189
  const childAgent = opts.skillsOverride ? { ...agentDef, skills: opts.skillsOverride } : agentDef;
175
190
 
176
- // resolve model
177
- const model = opts.model ?? agentDef.model ?? `${opts.parentModel.provider}/${opts.parentModel.id}`;
191
+ // SPEC-6-1: resolve model via tier registry (Q4 precedence + Q5 contextFloor/catalog filter).
192
+ const resolved = resolveAgentModel(
193
+ agentDef, opts.model, opts.parentModel,
194
+ opts.tierRegistry ?? new TierRegistry({ tiers: [], agents: new Map() }),
195
+ opts.modelRegistry ?? { find: () => undefined },
196
+ );
197
+ if ("error" in resolved) {
198
+ return fail(runId, startedAt, resolved.error, opts.agent);
199
+ }
200
+ const model = resolved.model;
201
+ const tier = resolved.tier;
202
+ const candidates = resolved.candidates ?? [model];
178
203
 
179
204
  // child tools pass through UNFILTERED — the single-writer `todo`-exclusion is enforced
180
205
  // downstream by the child factory's `excludeTools: ["todo"]` (SPEC-2 §9.1 hardening).
@@ -186,6 +211,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
186
211
  opts.runRegistry.add({
187
212
  runId, agent: agentDef.name, model, task: opts.task, track,
188
213
  todoId: null, status: "running", startedAt,
214
+ tier: tier?.name, costTotal: 0, contextTokens: 0,
189
215
  });
190
216
  try {
191
217
  opts.runLog?.append(runId, { type: "run:meta", runId, agent: agentDef.name, model, task: opts.task, startedAt, track, todoId: null });
@@ -206,19 +232,30 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
206
232
  return await finishRun(opts, runId, startedAt, "failed", "", todoId, priorStatus, (e as Error).message, agentDef.name, model);
207
233
  }
208
234
 
209
- // spawn child
210
- const { session } = await backend.factory.create({
211
- cwd: opts.parentCwd,
212
- model,
213
- thinkingLevel: childAgent.thinkingLevel,
214
- tools,
215
- rolePrompt: childAgent.rolePrompt,
216
- skills: childAgent.skills ?? [],
217
- task: opts.task,
218
- agent: childAgent,
219
- memoryPort,
220
- visionPort,
221
- });
235
+ // SPEC-6-1: fallback retry loop — try candidates[0], on rejection retry candidates[1], etc.
236
+ let session: ChildSession | undefined;
237
+ let lastErr: Error | undefined;
238
+ for (const cand of candidates) {
239
+ try {
240
+ const result = await backend.factory.create({
241
+ cwd: opts.parentCwd,
242
+ model: cand,
243
+ thinkingLevel: childAgent.thinkingLevel,
244
+ tools,
245
+ rolePrompt: childAgent.rolePrompt,
246
+ skills: childAgent.skills ?? [],
247
+ task: opts.task,
248
+ agent: childAgent,
249
+ memoryPort,
250
+ visionPort,
251
+ });
252
+ session = result.session;
253
+ break;
254
+ } catch (e) { lastErr = e as Error; }
255
+ }
256
+ if (!session) {
257
+ return await finishRun(opts, runId, startedAt, "failed", "", todoId, priorStatus, `backend create failed: ${lastErr?.message ?? "unknown"}`, agentDef.name, model, 0, 0, 0);
258
+ }
222
259
 
223
260
  // SPEC-5b-4: retain a narrow live-session handle on the run record so the panel can
224
261
  // steer/abort mid-flight. Wrap abort so the local `aborted` flag is set when the panel
@@ -232,6 +269,8 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
232
269
  const budget = createTurnBudget(maxTurns);
233
270
  let finalText = "";
234
271
  let tokenTotal = 0;
272
+ let costTotal = 0;
273
+ let contextTokens = 0;
235
274
  let turnIdx = -1;
236
275
 
237
276
  const onSignalAbort = (): void => { aborted = true; void session.abort(); };
@@ -255,11 +294,20 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
255
294
  const turnTokens = (u?.input ?? 0) + (u?.output ?? 0) + (u?.cacheRead ?? 0) + (u?.cacheWrite ?? 0);
256
295
  if (turnTokens > 0) {
257
296
  tokenTotal += turnTokens;
258
- opts.runRegistry.update(runId, { tokenTotal });
259
297
  }
298
+ // SPEC-6-1: accumulate cost + context tokens.
299
+ const cost = u?.cost?.total ?? 0;
300
+ costTotal += cost;
301
+ contextTokens = calcContextTokens(u ?? {});
302
+ opts.runRegistry.update(runId, { costTotal, contextTokens, tokenTotal });
260
303
  try {
261
- opts.runLog?.append(runId, { type: "message", role: "assistant", text, usage: { total: turnTokens, input: u?.input, output: u?.output, cacheRead: u?.cacheRead, cacheWrite: u?.cacheWrite }, turnIndex: turnIdx });
304
+ opts.runLog?.append(runId, { type: "message", role: "assistant", text, usage: { total: turnTokens, input: u?.input, output: u?.output, cacheRead: u?.cacheRead, cacheWrite: u?.cacheWrite, cost: u?.cost }, turnIndex: turnIdx });
262
305
  } catch { /* best-effort */ }
306
+ // SPEC-6-1: cap abort — if costTotal exceeds tier.costCap, abort + flag budget_exceeded.
307
+ if (tier?.costCap && costTotal > tier.costCap) {
308
+ aborted = true;
309
+ void session.abort();
310
+ }
263
311
  } else if (e.type === "tool_execution_end") {
264
312
  try {
265
313
  opts.runLog?.append(runId, buildToolEvent((e as any).toolName, (e as any).args, (e as any).result, (e as any).isError ?? false, turnIdx));
@@ -283,7 +331,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
283
331
  let error: string | undefined;
284
332
  if (aborted) {
285
333
  status = "aborted";
286
- error = "aborted by user";
334
+ error = tier?.costCap && costTotal > tier.costCap ? `budget_exceeded (cost $${costTotal.toFixed(4)} > cap $${tier.costCap})` : "aborted by user";
287
335
  } else if (budget.count() >= maxTurns) {
288
336
  status = "failed";
289
337
  error = `hit turn budget (${maxTurns}) mid-task; partial result: ${finalText.slice(0, 200)}`;
@@ -294,7 +342,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
294
342
  status = "completed";
295
343
  }
296
344
 
297
- return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal);
345
+ return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens);
298
346
  } finally {
299
347
  opts.lock.release();
300
348
  }
@@ -310,18 +358,19 @@ function fail(runId: string, startedAt: number, message: string, agent: string):
310
358
  async function finishRun(
311
359
  opts: SpawnOptions, runId: string, startedAt: number,
312
360
  status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined,
313
- error: string | undefined, agentName: string, model: string, tokenTotal = 0,
361
+ error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0,
314
362
  ): Promise<SpawnResult> {
315
363
  const endedAt = Date.now();
316
364
  opts.runRegistry.update(runId, {
317
365
  status, endedAt, resultSummary: finalText.slice(0, 120),
318
366
  resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink,
319
367
  session: undefined, // SPEC-5b-4: clear the live handle (invariant: session ⟺ running)
368
+ costTotal, contextTokens, // SPEC-6-1: final cost/context on the terminal record
320
369
  });
321
370
  try {
322
371
  opts.runLog?.append(runId, {
323
372
  type: "run:ended", runId, status, endedAt,
324
- resultSummary: finalText.slice(0, 120), tokenTotal,
373
+ resultSummary: finalText.slice(0, 120), tokenTotal, costTotal, contextTokens,
325
374
  resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink,
326
375
  });
327
376
  } catch { /* best-effort: journal is the index, not the product */ }
@@ -340,6 +389,6 @@ async function finishRun(
340
389
  }
341
390
  return {
342
391
  status, finalText, runId, todoId, agent: agentName, model,
343
- durationMs: endedAt - startedAt, tokenTotal, error,
392
+ durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error,
344
393
  };
345
394
  }
@@ -1,5 +1,8 @@
1
1
  // src/engine/turn-budget.ts
2
- export const DEFAULT_MAX_TURNS = 20;
2
+ /** Default per-run turn budget. Effectively uncapped for normal work — a very complex
3
+ * multi-file task rarely exceeds ~100 turns; 1000 bounds runaway loops while never clipping
4
+ * legit work. Callers may pass a tighter `maxTurns` (e.g. 5) for trivial lookups. (v0.9.4: was 20.) */
5
+ export const DEFAULT_MAX_TURNS = 1000;
3
6
 
4
7
  export interface TurnBudget {
5
8
  /** Returns true when the just-consumed turn meets/exceeds the cap. */
package/src/index.ts CHANGED
@@ -43,6 +43,10 @@ import { Scheduler } from "./scheduling/scheduler.ts";
43
43
  import { createFleetResultsTool } from "./tools/fleet-results.ts";
44
44
  import { BgRunsStore } from "./panel/bg-runs-store.ts";
45
45
  import { FleetWidgetController } from "./panel/fleet-widget.ts";
46
+ import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts";
47
+ import { BUILTIN_TIERS } from "./tiers/builtin.ts";
48
+ import { TierStore } from "./tiers/tier-store.ts";
49
+ import { splitModel } from "./tiers/resolve.ts";
46
50
 
47
51
  /** The package builtin agents/ dir, resolved relative to this module. */
48
52
  function builtinAgentsDir(): string {
@@ -167,6 +171,12 @@ export default async function (pi: ExtensionAPI): Promise<void> {
167
171
  deps.lifecycleDeps.registry = deps.lifecycleRegistry;
168
172
  deps.lifecycleDeps.agentRegistry = deps.registry;
169
173
 
174
+ // SPEC-6-1: shared model registry for contextWindow lookups (contextFloor + ctx% widget).
175
+ const sharedModelRegistry = new ModelRegistry(modelRuntime);
176
+ deps.modelRegistry = sharedModelRegistry;
177
+ // Builtin-only placeholder tier registry so spawn works before session_start rebuilds with merged tiers.
178
+ deps.tierRegistry = new TierRegistry({ tiers: BUILTIN_TIERS, agents: deps.registry });
179
+
170
180
  // ── SPEC-5a: operational runtime (async/bg + scheduling + worktree isolation) ──
171
181
  const fleetDir = (cwd: string) => join(cwd, ".pi", "fleet");
172
182
  const bgRuns = new BgRunsStore();
@@ -195,7 +205,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
195
205
  agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model,
196
206
  skillsOverride: o.skills, backendOverride: o.backend,
197
207
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock,
198
- backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, runLog: deps.runLog, // child runs in the worktree
208
+ backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, runLog: deps.runLog,
209
+ tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
199
210
  }),
200
211
  };
201
212
  const res = await runLifecycle(task, lifecycleName, { deps: lifecycleFullDeps, mode: opts.mode, worktreePath: opts.worktreePath, baseRef: "HEAD", onCheckpoint: async (p) => p.status === "failed" ? { action: "abort" } : { action: "continue" } });
@@ -282,8 +293,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
282
293
  bgRuns,
283
294
  ui: ctx.ui as never,
284
295
  getTheme: () => ctx.ui.theme,
296
+ getModelContextWindow: (m: string) => {
297
+ const { provider, id } = splitModel(m, deps.parentModel.provider);
298
+ return sharedModelRegistry.find(provider, id)?.contextWindow;
299
+ },
285
300
  });
286
301
  fleetWidget.start();
302
+
303
+ // SPEC-6-1: per-session TierStore (cwd-aware project path) + real TierRegistry (builtins + global + project).
304
+ const tierStore = new TierStore({
305
+ projectPath: join(dir, "tiers.json"),
306
+ globalPath: join(process.env.HOME ?? "", ".pi", "agent", "fleet", "tiers.json"),
307
+ });
308
+ deps.tierStore = tierStore;
309
+ const reloadTiers = (): void => {
310
+ deps.tierRegistry = new TierRegistry({
311
+ tiers: mergeTiers(BUILTIN_TIERS, tierStore.read("global"), tierStore.read("project")),
312
+ agents: deps.registry,
313
+ });
314
+ };
315
+ deps.reloadTiers = reloadTiers;
316
+ reloadTiers(); // build the real merged registry (replaces the builtin-only placeholder)
287
317
  });
288
318
 
289
319
  pi.on("session_shutdown", () => {
@@ -348,6 +378,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
348
378
  skillsOverride: o.skills, backendOverride: o.backend,
349
379
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
350
380
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd,
381
+ tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
351
382
  }),
352
383
  };
353
384
  const res = await runLifecycle(parsed.task, lcName, { deps: lifecycleFullDeps, mode: parsed.auto ? "auto" : "checkpointed", onCheckpoint });
@@ -26,8 +26,11 @@ import type { TodoSyncPort } from "../todo-sync/port.ts";
26
26
  import type { LifecycleDef, LifecycleRunRecord, CheckpointDecision, PhaseRecord } from "../lifecycle/lifecycle-types.ts";
27
27
  import type { LifecycleRunDeps, CheckpointFn } from "../lifecycle/run-lifecycle.ts";
28
28
  import { runLifecycle } from "../lifecycle/run-lifecycle.ts";
29
+ import type { TierRegistry } from "../tiers/tier-registry.ts";
30
+ import type { TierStore } from "../tiers/tier-store.ts";
31
+ import { buildTiersItems, setTierCostCap, setTierModels, setTierContextFloor, addTier, deleteTier } from "./tiers-items.ts";
29
32
 
30
- type View = "fleet" | "lifecycle" | "runs" | "agents" | "backends" | "scheduled";
33
+ type View = "fleet" | "lifecycle" | "runs" | "agents" | "backends" | "scheduled" | "tiers";
31
34
 
32
35
  export interface FleetPanelDeps {
33
36
  registry: Map<string, AgentDef>;
@@ -47,6 +50,12 @@ export interface FleetPanelDeps {
47
50
  bgRuns?: BgRunsStore;
48
51
  /** SPEC-5b-1: durable per-run conversation log. Optional — Runs tab degrades to empty when absent. */
49
52
  runLog?: RunLog;
53
+ /** SPEC-6-1: tier registry for the Tiers view. Optional — panel degrades to empty list when absent. */
54
+ tierRegistry?: TierRegistry;
55
+ /** SPEC-6-1: tier store for inline edits. Optional — Tiers view action keys are no-ops when absent. */
56
+ tierStore?: TierStore;
57
+ /** SPEC-6-1: callback to rebuild the tier registry after a write. */
58
+ reloadTiers?: () => void;
50
59
  }
51
60
 
52
61
  export interface FleetPanelOpts {
@@ -94,6 +103,10 @@ export class FleetPanel extends Container {
94
103
  // SPEC-5b-4: Steer inline input state (mid-run redirect; mirrors resumeMode/resumeInput).
95
104
  private steerInput: Input | null = null;
96
105
  private steerMode = false;
106
+ // SPEC-6-1: Tiers view inline-edit state (mirrors steerInput/steerMode).
107
+ private tiersInput: Input | null = null;
108
+ private tiersEditPhase: "models" | "costCap" | "contextFloor" | "add" | null = null;
109
+ private tiersScope: "project" | "global" = "project";
97
110
  // SPEC-5b-3: full-message overlay (second level over the 5b-1 timeline) + stored SelectList refs
98
111
  // so handleInput can forward keys to the active overlay (Container/TUI routes input only to the
99
112
  // focused component = this panel; children receive keys only if we forward them).
@@ -138,6 +151,8 @@ export class FleetPanel extends Container {
138
151
  ? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }))
139
152
  : this.view === "scheduled"
140
153
  ? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s) }))
154
+ : this.view === "tiers"
155
+ ? (this.deps.tierRegistry ? buildTiersItems({ tierRegistry: this.deps.tierRegistry, runRegistry: this.deps.runRegistry }) : [])
141
156
  : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
142
157
  const fresh = new SelectList(items, 12, {
143
158
  selectedPrefix: (s: string) => this.theme.fg("accent", s),
@@ -176,7 +191,7 @@ export class FleetPanel extends Container {
176
191
  this.children.length = 0;
177
192
  this.children.push(...keep);
178
193
  const accent = (s: string): string => this.theme.fg("accent", s);
179
- const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled"] as View[])
194
+ const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled", "tiers"] as View[])
180
195
  .map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
181
196
  .join(" ");
182
197
  this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
@@ -297,6 +312,17 @@ export class FleetPanel extends Container {
297
312
  this.addChild(new Text(this.theme.fg("accent", " steer> "), 0, 0));
298
313
  this.addChild(this.steerInput);
299
314
  this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
315
+ } else if (this.tiersEditPhase && this.tiersInput) {
316
+ // SPEC-6-1: Tiers tab — inline edit input.
317
+ const sel = this.list.getSelectedItem();
318
+ const name = sel?.value ?? "";
319
+ const prompt = this.tiersEditPhase === "add" ? " new tier name> "
320
+ : this.tiersEditPhase === "models" ? ` models for ${name}> `
321
+ : this.tiersEditPhase === "costCap" ? ` costCap for ${name}> `
322
+ : ` contextFloor for ${name}> `;
323
+ this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
324
+ this.addChild(this.tiersInput);
325
+ this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
300
326
  } else if (this.resumeMode && this.resumeInput) {
301
327
  // SPEC-5b-1: Runs tab — resume follow-up input.
302
328
  this.addChild(new Text(this.theme.fg("accent", " follow-up> "), 0, 0));
@@ -344,7 +370,9 @@ export class FleetPanel extends Container {
344
370
  : this.view === "agents"
345
371
  ? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit"
346
372
  : this.view === "scheduled"
347
- ? " a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit"
373
+ ? " a:Add p:Pause/resume d:Delete i:Info tab:Tiers q:Quit"
374
+ : this.view === "tiers"
375
+ ? " m:Models c:costCap f:contextFloor a:Add d:Delete g:scope tab:Fleet q:Quit"
348
376
  : " r:Refresh i:Info tab:Fleet q:Quit";
349
377
  this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
350
378
  this.addChild(new Spacer(1));
@@ -416,7 +444,7 @@ export class FleetPanel extends Container {
416
444
  : this.view === "lifecycle" ? "runs"
417
445
  : this.view === "runs" ? "agents"
418
446
  : this.view === "agents" ? "backends"
419
- : this.view === "backends" ? "scheduled" : "fleet";
447
+ : this.view === "backends" ? "scheduled" : this.view === "scheduled" ? "tiers" : "fleet";
420
448
  this.selectedBackend = null;
421
449
  this.selectedLifecycle = null;
422
450
  this.selectedSchedule = null;
@@ -428,6 +456,8 @@ export class FleetPanel extends Container {
428
456
  this.messageBodyList = null;
429
457
  this.steerMode = false; // SPEC-5b-4: drop any in-flight steer input on tab switch
430
458
  this.steerInput = null;
459
+ this.tiersEditPhase = null; // SPEC-6-1: drop any in-flight tiers edit on tab switch
460
+ this.tiersInput = null;
431
461
  this.list = this.buildList();
432
462
  this.renderShell();
433
463
  }
@@ -469,6 +499,12 @@ export class FleetPanel extends Container {
469
499
  this.invalidate();
470
500
  return;
471
501
  }
502
+ if (this.tiersEditPhase && this.tiersInput) {
503
+ if (matchesKey(data, "escape")) { this.cancelTiersEdit(); return; }
504
+ this.tiersInput.handleInput(data);
505
+ this.invalidate();
506
+ return;
507
+ }
472
508
  if (this.resumeMode && this.resumeInput) {
473
509
  if (matchesKey(data, "escape")) { this.cancelResume(); return; }
474
510
  this.resumeInput.handleInput(data);
@@ -576,6 +612,20 @@ export class FleetPanel extends Container {
576
612
  return;
577
613
  }
578
614
  }
615
+ // SPEC-6-1: Tiers view — m:Models c:costCap f:contextFloor a:Add d:Delete g:scope
616
+ if (this.view === "tiers" && this.deps.tierStore && this.deps.tierRegistry) {
617
+ if (matchesKey(data, "m")) { this.startTiersEdit("models"); return; }
618
+ if (matchesKey(data, "c")) { this.startTiersEdit("costCap"); return; }
619
+ if (matchesKey(data, "f")) { this.startTiersEdit("contextFloor"); return; }
620
+ if (matchesKey(data, "a")) { this.startTiersEdit("add"); return; }
621
+ if (matchesKey(data, "d")) { this.executeTiersDelete(); return; }
622
+ if (matchesKey(data, "g")) {
623
+ this.tiersScope = this.tiersScope === "project" ? "global" : "project";
624
+ this.onNotify(`tiers scope: ${this.tiersScope}`, "info");
625
+ this.renderShell();
626
+ return;
627
+ }
628
+ }
579
629
  // SPEC-4: pending checkpoint keys (c/v/a)
580
630
  if (this.pendingCheckpoint && !this.lcRevising) {
581
631
  if (matchesKey(data, "c")) { this.pendingCheckpoint.resolve({ action: "continue" }); this.pendingCheckpoint = null; this.renderShell(); return; }
@@ -739,6 +789,78 @@ export class FleetPanel extends Container {
739
789
  })();
740
790
  }
741
791
 
792
+ // ────────────────────────────── SPEC-6-1: Tiers view inline edit ──────────────────────────────
793
+
794
+ private startTiersEdit(phase: "models" | "costCap" | "contextFloor" | "add"): void {
795
+ if (phase !== "add") {
796
+ const sel = this.list.getSelectedItem();
797
+ if (!sel) { this.onNotify("select a tier first", "warning"); return; }
798
+ }
799
+ this.tiersInput = new Input();
800
+ this.tiersInput.onSubmit = (value: string) => { void this.executeTiersEdit(value, phase); };
801
+ this.tiersInput.onEscape = () => this.cancelTiersEdit();
802
+ this.tiersEditPhase = phase;
803
+ this.renderShell();
804
+ }
805
+
806
+ private cancelTiersEdit(): void {
807
+ this.tiersEditPhase = null;
808
+ this.tiersInput = null;
809
+ this.renderShell();
810
+ }
811
+
812
+ private async executeTiersEdit(value: string, phase: "models" | "costCap" | "contextFloor" | "add"): Promise<void> {
813
+ const store = this.deps.tierStore!;
814
+ const scope = this.tiersScope;
815
+ const sel = this.list.getSelectedItem();
816
+ const name = sel?.value ?? "";
817
+ if (phase === "add" && !value.trim()) { this.onNotify("tier name required", "error"); return; }
818
+ this.tiersEditPhase = null;
819
+ this.tiersInput = null;
820
+ this.renderShell();
821
+ let tiers = store.read(scope);
822
+ try {
823
+ if (phase === "add") {
824
+ tiers = addTier(tiers, value.trim(), ["<placeholder-model>"]);
825
+ } else if (phase === "models") {
826
+ tiers = setTierModels(tiers, name, value.split(/[,\s]+/).filter(Boolean));
827
+ } else if (phase === "costCap") {
828
+ const n = Number(value);
829
+ if (value.trim() !== "" && Number.isNaN(n)) { this.onNotify("costCap must be a number", "error"); return; }
830
+ tiers = setTierCostCap(tiers, name, value.trim() === "" ? undefined : n);
831
+ } else {
832
+ const n = Number(value);
833
+ if (value.trim() !== "" && Number.isNaN(n)) { this.onNotify("contextFloor must be a number", "error"); return; }
834
+ tiers = setTierContextFloor(tiers, name, value.trim() === "" ? undefined : n);
835
+ }
836
+ store.write(scope, tiers);
837
+ this.deps.reloadTiers?.();
838
+ this.list = this.buildList();
839
+ this.renderShell();
840
+ if (phase === "add") this.onNotify(`tier '${value.trim()}' added; press m to edit models`, "info");
841
+ } catch (e) {
842
+ this.onNotify(e instanceof Error ? e.message : String(e), "error");
843
+ return;
844
+ }
845
+ }
846
+
847
+ private executeTiersDelete(): void {
848
+ const sel = this.list.getSelectedItem();
849
+ if (!sel) { this.onNotify("select a tier first", "warning"); return; }
850
+ const store = this.deps.tierStore!;
851
+ const scope = this.tiersScope;
852
+ const tiers = deleteTier(store.read(scope), sel.value);
853
+ try {
854
+ store.write(scope, tiers);
855
+ this.deps.reloadTiers?.();
856
+ this.list = this.buildList();
857
+ this.renderShell();
858
+ this.onNotify(`tier '${sel.value}' deleted`, "info");
859
+ } catch (e) {
860
+ this.onNotify(e instanceof Error ? e.message : String(e), "error");
861
+ }
862
+ }
863
+
742
864
  private async executeResume(prior: RunMeta, followUp: string): Promise<void> {
743
865
  this.resumeMode = false;
744
866
  this.resumeInput = null;
@@ -39,6 +39,8 @@ export interface FleetWidgetDeps {
39
39
  now?: () => number;
40
40
  setInterval?: (fn: () => void, ms: number) => unknown;
41
41
  clearInterval?: (id: unknown) => void;
42
+ /** SPEC-6-1: resolve a model's context window for the ctx% widget segment. Optional — absent → no ctx%. */
43
+ getModelContextWindow?: (model: string) => number | undefined;
42
44
  }
43
45
 
44
46
  export class FleetWidgetController {
@@ -64,7 +66,11 @@ export class FleetWidgetController {
64
66
  }
65
67
 
66
68
  private activeRuns() {
67
- const fg = this.deps.runRegistry.list().map(toWidgetRun);
69
+ const fg = this.deps.runRegistry.list().map((r) => {
70
+ const w = toWidgetRun(r);
71
+ w.maxContext = this.deps.getModelContextWindow?.(r.model);
72
+ return w;
73
+ });
68
74
  const bg = this.deps.bgRuns ? [...this.deps.bgRuns.values()].map(toWidgetRunFromBg) : [];
69
75
  return [...fg, ...bg];
70
76
  }
package/src/panel/rows.ts CHANGED
@@ -11,6 +11,14 @@ export function fmtDuration(ms: number): string {
11
11
  return `${m}m${s % 60}s`;
12
12
  }
13
13
 
14
+ /** Compact token count: <1K as-is, >=1K with K suffix (1 decimal under 10K, 0 decimals above).
15
+ * 142 → "142"; 1300 → "1.3K"; 265055 → "265K"; 2027001 → "2027K". */
16
+ export function fmtTokens(n: number): string {
17
+ if (n < 1000) return `${n}`;
18
+ const k = n / 1000;
19
+ return `${k.toFixed(k < 10 ? 1 : 0)}K`;
20
+ }
21
+
14
22
  const STATUS_GLYPH: Record<FleetRunStatus, string> = {
15
23
  running: "▶",
16
24
  completed: "✓",
@@ -1,26 +1,29 @@
1
1
  // src/panel/runs-rows.ts
2
2
  // SPEC-5b-1 — pure renderers for the Runs tab + per-turn timeline. Reuses the glyph
3
3
  // language (▶ ✓ ✗) so the Runs tab is visually consistent with Fleet/Lifecycle.
4
- import { fmtDuration } from "./rows.ts";
4
+ import { fmtDuration, fmtTokens } from "./rows.ts";
5
5
  import type { RunMeta, MessageEvent, ToolEvent } from "../runtime/run-log.ts";
6
6
 
7
7
  const STATUS_GLYPH: Record<RunMeta["status"], string> = {
8
8
  running: "▶", completed: "✓", failed: "✗", aborted: "✗",
9
9
  };
10
10
 
11
- export function runsRow(r: RunMeta): string {
11
+ export function runsRow(r: RunMeta, getModelContextWindow?: (model: string) => number | undefined): string {
12
12
  const dur = r.endedAt ? fmtDuration(r.endedAt - r.startedAt) : "—";
13
- const tok = r.tokenTotal > 0 ? ` ${r.tokenTotal} tok` : "";
13
+ const tok = r.tokenTotal > 0 ? ` ${fmtTokens(r.tokenTotal)} tok` : "";
14
+ const maxCtx = getModelContextWindow?.(r.model);
15
+ const ctx = (r.contextTokens != null && maxCtx != null && maxCtx > 0) ? ` ${Math.round(r.contextTokens / maxCtx * 100)}%` : "";
16
+ const cost = r.costTotal ? ` $${r.costTotal.toFixed(4)}` : "";
14
17
  const summary = r.resultSummary ? ` "${r.resultSummary}"` : "";
15
18
  const prov = r.resumedFrom ? ` ← resumed:${r.resumedFrom}` : r.forkedFrom ? ` ← forked:${r.forkedFrom}` : "";
16
- return `${STATUS_GLYPH[r.status]} ${r.runId} ${r.agent} ${r.status} ${dur}${tok}${summary}${prov}`;
19
+ return `${STATUS_GLYPH[r.status]} ${r.runId} ${r.agent} ${r.status} ${dur}${tok}${ctx}${cost}${summary}${prov}`;
17
20
  }
18
21
 
19
22
  export function runTimelineRow(e: MessageEvent | ToolEvent): string {
20
23
  const turn = Math.max(0, e.turnIndex);
21
24
  if (e.type === "message") {
22
25
  const text = e.text.length > 80 ? e.text.slice(0, 79) + "…" : e.text;
23
- const tok = e.usage?.total != null ? ` ${e.usage.total} tok` : "";
26
+ const tok = e.usage?.total != null ? ` ${fmtTokens(e.usage.total)} tok` : "";
24
27
  return `[a] "${text}"${tok} ·t${turn}`;
25
28
  }
26
29
  const glyph = e.isError ? "✗" : "✓";
@@ -0,0 +1,63 @@
1
+ import type { Tier } from "../tiers/tier-registry.ts";
2
+ import type { TierRegistry } from "../tiers/tier-registry.ts";
3
+ import type { RunRegistry } from "../engine/run-registry.ts";
4
+ import { renderTierRow } from "./tiers-rows.ts";
5
+ import type { SelectItem } from "@earendil-works/pi-tui";
6
+
7
+ export interface TiersItemSources {
8
+ tierRegistry: TierRegistry;
9
+ runRegistry: RunRegistry;
10
+ }
11
+
12
+ /** Build the /fleet Tiers view list items. One per tier, labeled via `renderTierRow`. */
13
+ export function buildTiersItems(src: TiersItemSources): SelectItem[] {
14
+ const runs = src.runRegistry.list();
15
+ return src.tierRegistry.list().map((tier) => {
16
+ const tierRuns = runs.filter((r) => r.tier === tier.name);
17
+ const spend = tierRuns.reduce((sum, r) => sum + (r.costTotal ?? 0), 0);
18
+ const runCount = tierRuns.length;
19
+ const usedBy = src.tierRegistry.usedBy(tier.name);
20
+ return { value: tier.name, label: renderTierRow(tier, spend, usedBy, runCount) };
21
+ });
22
+ }
23
+
24
+ /** Set the costCap on a tier. `undefined` removes the field. Throws if tier not found. */
25
+ export function setTierCostCap(tiers: Tier[], name: string, cap: number | undefined): Tier[] {
26
+ const idx = tiers.findIndex((t) => t.name === name);
27
+ if (idx < 0) throw new Error(`tier '${name}' not found`);
28
+ const updated = { ...tiers[idx]! };
29
+ if (cap != null) updated.costCap = cap;
30
+ else delete updated.costCap;
31
+ return [...tiers.slice(0, idx), updated, ...tiers.slice(idx + 1)];
32
+ }
33
+
34
+ /** Replace the models array on a tier. Empty array → throws. Throws if tier not found. */
35
+ export function setTierModels(tiers: Tier[], name: string, models: string[]): Tier[] {
36
+ if (models.length === 0) throw new Error(`tier '${name}': models must be non-empty`);
37
+ const idx = tiers.findIndex((t) => t.name === name);
38
+ if (idx < 0) throw new Error(`tier '${name}' not found`);
39
+ const updated = { ...tiers[idx]!, models };
40
+ return [...tiers.slice(0, idx), updated, ...tiers.slice(idx + 1)];
41
+ }
42
+
43
+ /** Set/unset the contextFloor on a tier. Throws if tier not found. */
44
+ export function setTierContextFloor(tiers: Tier[], name: string, floor: number | undefined): Tier[] {
45
+ const idx = tiers.findIndex((t) => t.name === name);
46
+ if (idx < 0) throw new Error(`tier '${name}' not found`);
47
+ const updated = { ...tiers[idx]! };
48
+ if (floor != null) updated.contextFloor = floor;
49
+ else delete updated.contextFloor;
50
+ return [...tiers.slice(0, idx), updated, ...tiers.slice(idx + 1)];
51
+ }
52
+
53
+ /** Add a new tier. Throws on duplicate name or empty models. */
54
+ export function addTier(tiers: Tier[], name: string, models: string[]): Tier[] {
55
+ if (models.length === 0) throw new Error(`tier '${name}': models must be non-empty`);
56
+ if (tiers.some((t) => t.name === name)) throw new Error(`tier '${name}' already exists`);
57
+ return [...tiers, { name, models }];
58
+ }
59
+
60
+ /** Remove a tier by name. No-op if absent (no throw). */
61
+ export function deleteTier(tiers: Tier[], name: string): Tier[] {
62
+ return tiers.filter((t) => t.name !== name);
63
+ }
@@ -0,0 +1,16 @@
1
+ import type { Tier } from "../tiers/tier-registry.ts";
2
+
3
+ function fmtFloor(n: number | undefined): string {
4
+ if (n == null) return "—";
5
+ if (n >= 1000) return `${Math.round(n / 1000)}k`;
6
+ return String(n);
7
+ }
8
+
9
+ /** One row per tier for the /fleet Tiers view. Pure (unit-tested). */
10
+ export function renderTierRow(tier: Tier, spend: number, usedBy: string[], runCount: number): string {
11
+ const cap = tier.costCap != null ? `$${tier.costCap}` : "—";
12
+ const floor = fmtFloor(tier.contextFloor);
13
+ const spendStr = spend > 0 ? `$${spend.toFixed(4)}` : "$0.00";
14
+ const used = usedBy.length ? `used by: ${usedBy.join(", ")}` : "used by: —";
15
+ return `${tier.name} ${tier.models.join("→")} ${cap} ${floor} ${spendStr} ${runCount} runs ${used}`;
16
+ }
@@ -7,7 +7,7 @@
7
7
  // mirror of this same renderer (same `widgetLine`, cap 8 vs 5), and the PRD §5 "navigable agent
8
8
  // list below editor" intent was never achievable via pi widgets (editor keeps keyboard focus).
9
9
  // `/fleet` is the navigable action surface; this one above-editor widget is the glance surface.
10
- import { fmtDuration } from "./rows.ts";
10
+ import { fmtDuration, fmtTokens } from "./rows.ts";
11
11
  import type { RunRecord } from "../engine/run-registry.ts";
12
12
  import type { BgRunStatus } from "./rows.ts";
13
13
 
@@ -24,6 +24,14 @@ export interface WidgetRun {
24
24
  phaseTotal?: number;
25
25
  kind: "fg" | "bg";
26
26
  backend?: string;
27
+ /** SPEC-6-1: task excerpt for the primary label (fg runs). */
28
+ task?: string;
29
+ /** SPEC-6-1: latest context-token snapshot (for ctx% segment). */
30
+ contextTokens?: number;
31
+ /** SPEC-6-1: max context window for the resolved model (set by controller — Task 7). */
32
+ maxContext?: number;
33
+ /** SPEC-6-1: cumulative $ (for the $ segment). */
34
+ costTotal?: number;
27
35
  }
28
36
 
29
37
  export function toWidgetRun(r: RunRecord): WidgetRun {
@@ -31,6 +39,7 @@ export function toWidgetRun(r: RunRecord): WidgetRun {
31
39
  runId: r.runId, agent: r.agent, status: r.status,
32
40
  startedAt: r.startedAt, endedAt: r.endedAt, tokenTotal: r.tokenTotal,
33
41
  kind: "fg",
42
+ task: r.task, costTotal: r.costTotal, contextTokens: r.contextTokens,
34
43
  };
35
44
  }
36
45
 
@@ -57,14 +66,26 @@ const STATUS_GLYPH: Record<WidgetRun["status"], string> = {
57
66
  running: "▶", queued: "⏳", paused: "⏸", completed: "✓", failed: "✗", aborted: "✗",
58
67
  };
59
68
 
60
- /** One compact line per active run. */
69
+ /** One compact line per active run.
70
+ * fg: `▶ "task excerpt" · agent 5s 265K tok 42% $0.01` (runId hidden; agent hidden when general-purpose).
71
+ * bg: `▶ ●plan 2/4 pi` (phase as primary label; no runId, no task excerpt). */
61
72
  function widgetLine(r: WidgetRun, now: number): string {
62
73
  const glyph = STATUS_GLYPH[r.status];
63
74
  const dur = typeof r.startedAt === "number" ? ` ${fmtDuration(now - r.startedAt)}` : "";
64
- const tok = r.tokenTotal ? ` ${r.tokenTotal} tok` : "";
65
- const phase = r.phase ? ` ●${r.phase} ${r.phaseIndex ?? 0}/${r.phaseTotal ?? 0}` : "";
66
- const be = r.backend ? ` ${r.backend}` : "";
67
- return `${glyph} ${r.runId} ${r.agent}${dur}${tok}${phase}${be}`;
75
+ const tok = r.tokenTotal ? ` ${fmtTokens(r.tokenTotal)} tok` : "";
76
+ const ctx = (r.contextTokens != null && r.maxContext != null && r.maxContext > 0) ? ` ${Math.round(r.contextTokens / r.maxContext * 100)}%` : "";
77
+ const cost = r.costTotal ? ` $${r.costTotal.toFixed(4)}` : "";
78
+
79
+ if (r.kind === "bg") {
80
+ const phase = r.phase ? `●${r.phase} ${r.phaseIndex ?? 0}/${r.phaseTotal ?? 0}` : r.runId;
81
+ const be = r.backend ? ` ${r.backend}` : "";
82
+ return `${glyph} ${phase}${tok}${be}`;
83
+ }
84
+
85
+ // fg: task excerpt as primary label (fallback to runId if no task)
86
+ const label = r.task ? `"${r.task.slice(0, 40)}"` : r.runId;
87
+ const agentSeg = r.agent && r.agent !== "general-purpose" ? ` · ${r.agent}` : "";
88
+ return `${glyph} ${label}${agentSeg}${dur}${tok}${ctx}${cost}`;
68
89
  }
69
90
 
70
91
  /** Above-editor widget: one line per active run, cap 5, overflow → "+N more in /fleet". */
@@ -22,6 +22,8 @@ export interface AgentDef {
22
22
  sessionKey: string;
23
23
  source: AgentSource;
24
24
  filePath: string;
25
+ /** SPEC-6-1: cost-aware model tier (overrides agent.model when set). */
26
+ tier?: string;
25
27
  }
26
28
 
27
29
  export class FrontmatterError extends Error {
@@ -67,6 +69,7 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS
67
69
  name,
68
70
  description,
69
71
  model: typeof raw.model === "string" ? raw.model : undefined,
72
+ tier: typeof raw.tier === "string" ? raw.tier : undefined,
70
73
  thinkingLevel: typeof raw.thinkingLevel === "string" ? (raw.thinkingLevel as ThinkingLevel) : undefined,
71
74
  tools: strList(raw.tools),
72
75
  skills: strList(raw.skills),
@@ -14,7 +14,7 @@ export interface RunMetaEvent {
14
14
  }
15
15
  export interface MessageEvent {
16
16
  type: "message"; role: string; text: string;
17
- usage?: { total?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number };
17
+ usage?: { total?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number; cost?: { total?: number } };
18
18
  turnIndex: number;
19
19
  }
20
20
  export interface ToolEvent {
@@ -23,6 +23,10 @@ export interface ToolEvent {
23
23
  export interface RunEndedEvent {
24
24
  type: "run:ended"; runId: string; status: FleetRunStatus; endedAt: number;
25
25
  resultSummary?: string; tokenTotal: number; resumedFrom?: string; forkedFrom?: string;
26
+ /** SPEC-6-1: cumulative $ at run end. */
27
+ costTotal?: number;
28
+ /** SPEC-6-1: latest context-token snapshot at run end. */
29
+ contextTokens?: number;
26
30
  }
27
31
  export type RunLogEvent = RunMetaEvent | MessageEvent | ToolEvent | RunEndedEvent;
28
32
 
@@ -33,6 +37,10 @@ export interface RunMeta {
33
37
  backendSessionId?: string; sessionKey?: string;
34
38
  status: FleetRunStatus; endedAt?: number; resultSummary?: string; tokenTotal: number;
35
39
  resumedFrom?: string; forkedFrom?: string;
40
+ /** SPEC-6-1: cumulative $ at run end. */
41
+ costTotal?: number;
42
+ /** SPEC-6-1: latest context-token snapshot at run end. */
43
+ contextTokens?: number;
36
44
  }
37
45
 
38
46
  const ARGS_LIMIT = 200;
@@ -103,6 +111,7 @@ export class RunLog {
103
111
  meta.status = ended.status; meta.endedAt = ended.endedAt;
104
112
  meta.resultSummary = ended.resultSummary; meta.tokenTotal = ended.tokenTotal;
105
113
  meta.resumedFrom = ended.resumedFrom; meta.forkedFrom = ended.forkedFrom;
114
+ meta.costTotal = ended.costTotal; meta.contextTokens = ended.contextTokens;
106
115
  }
107
116
  out.push(meta);
108
117
  }
@@ -0,0 +1,8 @@
1
+ import type { Tier } from "./tier-registry.ts";
2
+
3
+ /** Shipped default tiers (Q10). Overridable via global/project tiers.json. */
4
+ 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
+ ];
@@ -0,0 +1,50 @@
1
+ import type { AgentDef } from "../registry/frontmatter.ts";
2
+ import type { Tier, TierRegistry } from "./tier-registry.ts";
3
+
4
+ /** Narrow port over pi's ModelRegistry — only the lookup 6-1 needs. */
5
+ export interface ModelRegistryLike {
6
+ find(provider: string, modelId: string): { contextWindow: number } | undefined;
7
+ }
8
+
9
+ export interface ResolvedModel {
10
+ model: string;
11
+ tier?: Tier;
12
+ /** Pre-filtered eligible candidates (primary first); spawnSubagent retries these on create() rejection. */
13
+ candidates?: string[];
14
+ }
15
+
16
+ export interface ResolveError { error: string; model?: undefined; }
17
+
18
+ /** Split "provider/modelId" on the first "/". Bare id → { parentProvider, id }. */
19
+ export function splitModel(model: string, parentProvider = ""): { provider: string; id: string } {
20
+ const i = model.indexOf("/");
21
+ if (i < 0) return { provider: parentProvider, id: model };
22
+ return { provider: model.slice(0, i), id: model.slice(i + 1) };
23
+ }
24
+
25
+ /** Q4 precedence: optsModel > agent.tier > agent.model > parent. Q5: contextFloor + catalog filter. */
26
+ export function resolveAgentModel(
27
+ agent: AgentDef, optsModel: string | undefined,
28
+ parentModel: { provider: string; id: string },
29
+ tiers: TierRegistry, modelRegistry: ModelRegistryLike,
30
+ ): ResolvedModel | ResolveError {
31
+ if (optsModel) return { model: optsModel };
32
+ if (agent.tier) {
33
+ const tier = tiers.get(agent.tier);
34
+ if (!tier) return { error: `tier '${agent.tier}' not found; available: ${tiers.list().map((t) => t.name).join(", ")}` };
35
+ const candidates: string[] = [];
36
+ for (const m of tier.models) {
37
+ const { provider, id } = splitModel(m, parentModel.provider);
38
+ const model = modelRegistry.find(provider, id);
39
+ if (!model) continue; // not in catalog → skip
40
+ if (tier.contextFloor && (model.contextWindow ?? 0) < tier.contextFloor) continue; // below floor → skip
41
+ candidates.push(m);
42
+ }
43
+ if (candidates.length === 0) {
44
+ return { error: `tier '${tier.name}': no eligible model (all missing or below contextFloor ${tier.contextFloor ?? "—"})` };
45
+ }
46
+ return { model: candidates[0]!, tier, candidates };
47
+ }
48
+ if (agent.model) return { model: agent.model };
49
+ return { model: `${parentModel.provider}/${parentModel.id}` };
50
+ }
@@ -0,0 +1,66 @@
1
+ export interface Tier {
2
+ name: string;
3
+ models: string[]; // ordered fallback chain, primary first
4
+ costCap?: number; // $ per-run; abort when run.costTotal exceeds
5
+ contextFloor?: number; // min contextWindow; skip models below it at spawn
6
+ }
7
+
8
+ export class TierFileError extends Error {
9
+ override name = "TierFileError" as const;
10
+ }
11
+
12
+ /** Parse a raw JSON string into validated Tier[]. Empty/blank → []. */
13
+ export function parseTiersFile(raw: string): Tier[] {
14
+ const trimmed = raw.trim();
15
+ if (!trimmed) return [];
16
+ let parsed: unknown;
17
+ try { parsed = JSON.parse(trimmed); } catch { throw new TierFileError("malformed tiers file (invalid JSON)"); }
18
+ if (!Array.isArray(parsed)) throw new TierFileError("tiers file must be a JSON array of tier objects");
19
+ const seen = new Set<string>();
20
+ return parsed.map((t) => {
21
+ const obj = t as Record<string, unknown>;
22
+ const name = obj.name;
23
+ const models = obj.models;
24
+ if (typeof name !== "string" || !name.trim()) throw new TierFileError("tier missing name");
25
+ if (!Array.isArray(models)) throw new TierFileError("tier missing models");
26
+ if (models.length === 0) throw new TierFileError(`tier '${name}' has empty models`);
27
+ if (seen.has(name)) throw new TierFileError(`duplicate tier name '${name}'`);
28
+ seen.add(name);
29
+ return {
30
+ name, models: models.map(String),
31
+ ...(typeof obj.costCap === "number" ? { costCap: obj.costCap } : {}),
32
+ ...(typeof obj.contextFloor === "number" ? { contextFloor: obj.contextFloor } : {}),
33
+ };
34
+ });
35
+ }
36
+
37
+ /** Merge tiers by name: builtins < global < project (later scopes win by name). */
38
+ export function mergeTiers(builtin: Tier[], globalTiers: Tier[], project: Tier[]): Tier[] {
39
+ const map = new Map<string, Tier>();
40
+ for (const t of builtin) map.set(t.name, t);
41
+ for (const t of globalTiers) map.set(t.name, t);
42
+ for (const t of project) map.set(t.name, t);
43
+ return [...map.values()];
44
+ }
45
+
46
+ export interface TierRegistryOpts {
47
+ tiers: Tier[];
48
+ /** Agent defs keyed by agent name — only the `tier` field is read, for `usedBy`. */
49
+ agents: Map<string, { tier?: string }>;
50
+ }
51
+
52
+ export class TierRegistry {
53
+ private readonly byName = new Map<string, Tier>();
54
+ private readonly agents: Map<string, { tier?: string }>;
55
+ constructor(opts: TierRegistryOpts) {
56
+ for (const t of opts.tiers) this.byName.set(t.name, t);
57
+ this.agents = opts.agents;
58
+ }
59
+ get(name: string): Tier | undefined { return this.byName.get(name); }
60
+ list(): Tier[] { return [...this.byName.values()]; }
61
+ usedBy(name: string): string[] {
62
+ const out: string[] = [];
63
+ for (const [agentName, def] of this.agents) if (def.tier === name) out.push(agentName);
64
+ return out.sort();
65
+ }
66
+ }
@@ -0,0 +1,22 @@
1
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { parseTiersFile, type Tier } from "./tier-registry.ts";
4
+
5
+ export interface TierStoreOpts { projectPath: string; globalPath: string; }
6
+
7
+ export class TierStore {
8
+ constructor(private readonly opts: TierStoreOpts) {}
9
+ read(scope: "project" | "global"): Tier[] {
10
+ const path = scope === "project" ? this.opts.projectPath : this.opts.globalPath;
11
+ try { return parseTiersFile(readFileSync(path, "utf8")); } catch { return []; }
12
+ }
13
+ write(scope: "project" | "global", tiers: Tier[]): void {
14
+ const json = JSON.stringify(tiers, null, 2);
15
+ parseTiersFile(json); // throws on invalid — no file change
16
+ const path = scope === "project" ? this.opts.projectPath : this.opts.globalPath;
17
+ mkdirSync(dirname(path), { recursive: true });
18
+ const tmp = `${path}.tmp`;
19
+ writeFileSync(tmp, json, "utf8");
20
+ renameSync(tmp, path); // atomic
21
+ }
22
+ }
@@ -48,6 +48,14 @@ export interface SubagentToolDeps {
48
48
  bgRuns?: import("../panel/bg-runs-store.ts").BgRunsStore;
49
49
  /** SPEC-5b-1: durable per-run conversation log. Optional — Runs tab + journaling disabled when absent. */
50
50
  runLog?: import("../runtime/run-log.ts").RunLog;
51
+ /** SPEC-6-1: tier registry for cost-aware model routing. Optional. */
52
+ tierRegistry?: import("../tiers/tier-registry.ts").TierRegistry;
53
+ /** SPEC-6-1: model registry for contextWindow lookups (contextFloor + ctx%). Optional. */
54
+ modelRegistry?: import("../tiers/resolve.ts").ModelRegistryLike;
55
+ /** SPEC-6-1: tier store for the /fleet Tiers view writes. Optional. */
56
+ tierStore?: import("../tiers/tier-store.ts").TierStore;
57
+ /** SPEC-6-1: rebuild the tier registry after a panel write. */
58
+ reloadTiers?: () => void;
51
59
  }
52
60
 
53
61
  /** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
@@ -89,6 +97,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
89
97
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
90
98
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, runLog: deps.runLog, signal,
91
99
  maxTurns: params.maxTurns,
100
+ tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
92
101
  }),
93
102
  };
94
103
  const res = await runLifecycle(params.task, params.lifecycle, {
@@ -120,6 +129,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
120
129
  runLog: deps.runLog,
121
130
  signal,
122
131
  maxTurns: params.maxTurns,
132
+ tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
123
133
  });
124
134
  const isError = res.status === "failed" || res.status === "aborted";
125
135
  return {