@getpipher/armory-fleet 0.9.4 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getpipher/armory-fleet",
3
- "version": "0.9.4",
3
+ "version": "0.10.1",
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
  }
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" } });
@@ -277,13 +288,34 @@ export default async function (pi: ExtensionAPI): Promise<void> {
277
288
  }
278
289
  // SPEC-5b-2: live widget (above editor). Display-only, independent
279
290
  // of the /fleet panel. getTheme is a live getter (EditorTheme gotcha). Disposed on session end.
291
+ const getModelContextWindow = (m: string): number | undefined => {
292
+ const { provider, id } = splitModel(m, deps.parentModel.provider);
293
+ return sharedModelRegistry.find(provider, id)?.contextWindow;
294
+ };
295
+ deps.getModelContextWindow = getModelContextWindow;
280
296
  fleetWidget = new FleetWidgetController({
281
297
  runRegistry: deps.runRegistry,
282
298
  bgRuns,
283
299
  ui: ctx.ui as never,
284
300
  getTheme: () => ctx.ui.theme,
301
+ getModelContextWindow,
285
302
  });
286
303
  fleetWidget.start();
304
+
305
+ // SPEC-6-1: per-session TierStore (cwd-aware project path) + real TierRegistry (builtins + global + project).
306
+ const tierStore = new TierStore({
307
+ projectPath: join(dir, "tiers.json"),
308
+ globalPath: join(process.env.HOME ?? "", ".pi", "agent", "fleet", "tiers.json"),
309
+ });
310
+ deps.tierStore = tierStore;
311
+ const reloadTiers = (): void => {
312
+ deps.tierRegistry = new TierRegistry({
313
+ tiers: mergeTiers(BUILTIN_TIERS, tierStore.read("global"), tierStore.read("project")),
314
+ agents: deps.registry,
315
+ });
316
+ };
317
+ deps.reloadTiers = reloadTiers;
318
+ reloadTiers(); // build the real merged registry (replaces the builtin-only placeholder)
287
319
  });
288
320
 
289
321
  pi.on("session_shutdown", () => {
@@ -348,6 +380,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
348
380
  skillsOverride: o.skills, backendOverride: o.backend,
349
381
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
350
382
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd,
383
+ tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
351
384
  }),
352
385
  };
353
386
  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,14 @@ 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;
59
+ /** SPEC-6-1: model contextWindow resolver for Runs-tab ctx% (Surface C). Optional — ctx% hidden when absent. */
60
+ getModelContextWindow?: (model: string) => number | undefined;
50
61
  }
51
62
 
52
63
  export interface FleetPanelOpts {
@@ -94,6 +105,10 @@ export class FleetPanel extends Container {
94
105
  // SPEC-5b-4: Steer inline input state (mid-run redirect; mirrors resumeMode/resumeInput).
95
106
  private steerInput: Input | null = null;
96
107
  private steerMode = false;
108
+ // SPEC-6-1: Tiers view inline-edit state (mirrors steerInput/steerMode).
109
+ private tiersInput: Input | null = null;
110
+ private tiersEditPhase: "models" | "costCap" | "contextFloor" | "add" | null = null;
111
+ private tiersScope: "project" | "global" = "project";
97
112
  // SPEC-5b-3: full-message overlay (second level over the 5b-1 timeline) + stored SelectList refs
98
113
  // so handleInput can forward keys to the active overlay (Container/TUI routes input only to the
99
114
  // focused component = this panel; children receive keys only if we forward them).
@@ -133,11 +148,13 @@ export class FleetPanel extends Container {
133
148
  : this.view === "lifecycle"
134
149
  ? [...this.deps.lifecycleRuns.values()].map((l: LifecycleRunRecord) => ({ value: l.runId, label: lifecycleRow(l) }))
135
150
  : this.view === "runs"
136
- ? buildRunsIndex(this.deps.runLog?.dir ?? "").map((r: RunMeta) => ({ value: r.runId, label: runsRow(r) }))
151
+ ? buildRunsIndex(this.deps.runLog?.dir ?? "").map((r: RunMeta) => ({ value: r.runId, label: runsRow(r, this.deps.getModelContextWindow) }))
137
152
  : this.view === "agents"
138
153
  ? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }))
139
154
  : this.view === "scheduled"
140
155
  ? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s) }))
156
+ : this.view === "tiers"
157
+ ? (this.deps.tierRegistry ? buildTiersItems({ tierRegistry: this.deps.tierRegistry, runRegistry: this.deps.runRegistry }) : [])
141
158
  : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
142
159
  const fresh = new SelectList(items, 12, {
143
160
  selectedPrefix: (s: string) => this.theme.fg("accent", s),
@@ -176,7 +193,7 @@ export class FleetPanel extends Container {
176
193
  this.children.length = 0;
177
194
  this.children.push(...keep);
178
195
  const accent = (s: string): string => this.theme.fg("accent", s);
179
- const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled"] as View[])
196
+ const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled", "tiers"] as View[])
180
197
  .map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
181
198
  .join(" ");
182
199
  this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
@@ -297,6 +314,17 @@ export class FleetPanel extends Container {
297
314
  this.addChild(new Text(this.theme.fg("accent", " steer> "), 0, 0));
298
315
  this.addChild(this.steerInput);
299
316
  this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
317
+ } else if (this.tiersEditPhase && this.tiersInput) {
318
+ // SPEC-6-1: Tiers tab — inline edit input.
319
+ const sel = this.list.getSelectedItem();
320
+ const name = sel?.value ?? "";
321
+ const prompt = this.tiersEditPhase === "add" ? " new tier name> "
322
+ : this.tiersEditPhase === "models" ? ` models for ${name}> `
323
+ : this.tiersEditPhase === "costCap" ? ` costCap for ${name}> `
324
+ : ` contextFloor for ${name}> `;
325
+ this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
326
+ this.addChild(this.tiersInput);
327
+ this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
300
328
  } else if (this.resumeMode && this.resumeInput) {
301
329
  // SPEC-5b-1: Runs tab — resume follow-up input.
302
330
  this.addChild(new Text(this.theme.fg("accent", " follow-up> "), 0, 0));
@@ -344,7 +372,9 @@ export class FleetPanel extends Container {
344
372
  : this.view === "agents"
345
373
  ? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit"
346
374
  : this.view === "scheduled"
347
- ? " a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit"
375
+ ? " a:Add p:Pause/resume d:Delete i:Info tab:Tiers q:Quit"
376
+ : this.view === "tiers"
377
+ ? " m:Models c:costCap f:contextFloor a:Add d:Delete g:scope tab:Fleet q:Quit"
348
378
  : " r:Refresh i:Info tab:Fleet q:Quit";
349
379
  this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
350
380
  this.addChild(new Spacer(1));
@@ -416,7 +446,7 @@ export class FleetPanel extends Container {
416
446
  : this.view === "lifecycle" ? "runs"
417
447
  : this.view === "runs" ? "agents"
418
448
  : this.view === "agents" ? "backends"
419
- : this.view === "backends" ? "scheduled" : "fleet";
449
+ : this.view === "backends" ? "scheduled" : this.view === "scheduled" ? "tiers" : "fleet";
420
450
  this.selectedBackend = null;
421
451
  this.selectedLifecycle = null;
422
452
  this.selectedSchedule = null;
@@ -428,6 +458,8 @@ export class FleetPanel extends Container {
428
458
  this.messageBodyList = null;
429
459
  this.steerMode = false; // SPEC-5b-4: drop any in-flight steer input on tab switch
430
460
  this.steerInput = null;
461
+ this.tiersEditPhase = null; // SPEC-6-1: drop any in-flight tiers edit on tab switch
462
+ this.tiersInput = null;
431
463
  this.list = this.buildList();
432
464
  this.renderShell();
433
465
  }
@@ -469,6 +501,12 @@ export class FleetPanel extends Container {
469
501
  this.invalidate();
470
502
  return;
471
503
  }
504
+ if (this.tiersEditPhase && this.tiersInput) {
505
+ if (matchesKey(data, "escape")) { this.cancelTiersEdit(); return; }
506
+ this.tiersInput.handleInput(data);
507
+ this.invalidate();
508
+ return;
509
+ }
472
510
  if (this.resumeMode && this.resumeInput) {
473
511
  if (matchesKey(data, "escape")) { this.cancelResume(); return; }
474
512
  this.resumeInput.handleInput(data);
@@ -576,6 +614,20 @@ export class FleetPanel extends Container {
576
614
  return;
577
615
  }
578
616
  }
617
+ // SPEC-6-1: Tiers view — m:Models c:costCap f:contextFloor a:Add d:Delete g:scope
618
+ if (this.view === "tiers" && this.deps.tierStore && this.deps.tierRegistry) {
619
+ if (matchesKey(data, "m")) { this.startTiersEdit("models"); return; }
620
+ if (matchesKey(data, "c")) { this.startTiersEdit("costCap"); return; }
621
+ if (matchesKey(data, "f")) { this.startTiersEdit("contextFloor"); return; }
622
+ if (matchesKey(data, "a")) { this.startTiersEdit("add"); return; }
623
+ if (matchesKey(data, "d")) { this.executeTiersDelete(); return; }
624
+ if (matchesKey(data, "g")) {
625
+ this.tiersScope = this.tiersScope === "project" ? "global" : "project";
626
+ this.onNotify(`tiers scope: ${this.tiersScope}`, "info");
627
+ this.renderShell();
628
+ return;
629
+ }
630
+ }
579
631
  // SPEC-4: pending checkpoint keys (c/v/a)
580
632
  if (this.pendingCheckpoint && !this.lcRevising) {
581
633
  if (matchesKey(data, "c")) { this.pendingCheckpoint.resolve({ action: "continue" }); this.pendingCheckpoint = null; this.renderShell(); return; }
@@ -739,6 +791,78 @@ export class FleetPanel extends Container {
739
791
  })();
740
792
  }
741
793
 
794
+ // ────────────────────────────── SPEC-6-1: Tiers view inline edit ──────────────────────────────
795
+
796
+ private startTiersEdit(phase: "models" | "costCap" | "contextFloor" | "add"): void {
797
+ if (phase !== "add") {
798
+ const sel = this.list.getSelectedItem();
799
+ if (!sel) { this.onNotify("select a tier first", "warning"); return; }
800
+ }
801
+ this.tiersInput = new Input();
802
+ this.tiersInput.onSubmit = (value: string) => { void this.executeTiersEdit(value, phase); };
803
+ this.tiersInput.onEscape = () => this.cancelTiersEdit();
804
+ this.tiersEditPhase = phase;
805
+ this.renderShell();
806
+ }
807
+
808
+ private cancelTiersEdit(): void {
809
+ this.tiersEditPhase = null;
810
+ this.tiersInput = null;
811
+ this.renderShell();
812
+ }
813
+
814
+ private async executeTiersEdit(value: string, phase: "models" | "costCap" | "contextFloor" | "add"): Promise<void> {
815
+ const store = this.deps.tierStore!;
816
+ const scope = this.tiersScope;
817
+ const sel = this.list.getSelectedItem();
818
+ const name = sel?.value ?? "";
819
+ if (phase === "add" && !value.trim()) { this.onNotify("tier name required", "error"); return; }
820
+ this.tiersEditPhase = null;
821
+ this.tiersInput = null;
822
+ this.renderShell();
823
+ let tiers = store.read(scope);
824
+ try {
825
+ if (phase === "add") {
826
+ tiers = addTier(tiers, value.trim(), ["<placeholder-model>"]);
827
+ } else if (phase === "models") {
828
+ tiers = setTierModels(tiers, name, value.split(/[,\s]+/).filter(Boolean));
829
+ } else if (phase === "costCap") {
830
+ const n = Number(value);
831
+ if (value.trim() !== "" && Number.isNaN(n)) { this.onNotify("costCap must be a number", "error"); return; }
832
+ tiers = setTierCostCap(tiers, name, value.trim() === "" ? undefined : n);
833
+ } else {
834
+ const n = Number(value);
835
+ if (value.trim() !== "" && Number.isNaN(n)) { this.onNotify("contextFloor must be a number", "error"); return; }
836
+ tiers = setTierContextFloor(tiers, name, value.trim() === "" ? undefined : n);
837
+ }
838
+ store.write(scope, tiers);
839
+ this.deps.reloadTiers?.();
840
+ this.list = this.buildList();
841
+ this.renderShell();
842
+ if (phase === "add") this.onNotify(`tier '${value.trim()}' added; press m to edit models`, "info");
843
+ } catch (e) {
844
+ this.onNotify(e instanceof Error ? e.message : String(e), "error");
845
+ return;
846
+ }
847
+ }
848
+
849
+ private executeTiersDelete(): void {
850
+ const sel = this.list.getSelectedItem();
851
+ if (!sel) { this.onNotify("select a tier first", "warning"); return; }
852
+ const store = this.deps.tierStore!;
853
+ const scope = this.tiersScope;
854
+ const tiers = deleteTier(store.read(scope), sel.value);
855
+ try {
856
+ store.write(scope, tiers);
857
+ this.deps.reloadTiers?.();
858
+ this.list = this.buildList();
859
+ this.renderShell();
860
+ this.onNotify(`tier '${sel.value}' deleted`, "info");
861
+ } catch (e) {
862
+ this.onNotify(e instanceof Error ? e.message : String(e), "error");
863
+ }
864
+ }
865
+
742
866
  private async executeResume(prior: RunMeta, followUp: string): Promise<void> {
743
867
  this.resumeMode = false;
744
868
  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,16 @@ 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;
59
+ /** SPEC-6-1: model contextWindow resolver for Runs-tab ctx% (Surface C). Optional — ctx% hidden when absent. */
60
+ getModelContextWindow?: (model: string) => number | undefined;
51
61
  }
52
62
 
53
63
  /** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
@@ -89,6 +99,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
89
99
  registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
90
100
  backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, runLog: deps.runLog, signal,
91
101
  maxTurns: params.maxTurns,
102
+ tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
92
103
  }),
93
104
  };
94
105
  const res = await runLifecycle(params.task, params.lifecycle, {
@@ -120,6 +131,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
120
131
  runLog: deps.runLog,
121
132
  signal,
122
133
  maxTurns: params.maxTurns,
134
+ tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
123
135
  });
124
136
  const isError = res.status === "failed" || res.status === "aborted";
125
137
  return {