From 05542e558b3d6507ad4d93a6115a97ea998271cd Mon Sep 17 00:00:00 2001 From: RECTOR Date: Mon, 27 Jul 2026 11:28:18 +0700 Subject: [PATCH 01/13] feat(spec-6-1): Tier type + builtin tiers + registry + discovery fns --- src/tiers/builtin.ts | 8 +++++ src/tiers/tier-registry.ts | 66 +++++++++++++++++++++++++++++++++++++ test/tier-registry.test.mts | 57 ++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 src/tiers/builtin.ts create mode 100644 src/tiers/tier-registry.ts create mode 100644 test/tier-registry.test.mts diff --git a/src/tiers/builtin.ts b/src/tiers/builtin.ts new file mode 100644 index 0000000..5a8fda2 --- /dev/null +++ b/src/tiers/builtin.ts @@ -0,0 +1,8 @@ +import type { Tier } from "./tier-registry.ts"; + +/** Shipped default tiers (Q10). Overridable via global/project tiers.json. */ +export const BUILTIN_TIERS: Tier[] = [ + { name: "economy", models: ["Ollama/minimax-m3:cloud"] }, + { name: "standard", models: ["Ollama/glm-5.2:cloud", "Ollama/minimax-m3:cloud"] }, + { name: "frontier", models: ["anthropic/claude-sonnet-4", "Ollama/glm-5.2:cloud"], costCap: 5, contextFloor: 200000 }, +]; \ No newline at end of file diff --git a/src/tiers/tier-registry.ts b/src/tiers/tier-registry.ts new file mode 100644 index 0000000..56d4fae --- /dev/null +++ b/src/tiers/tier-registry.ts @@ -0,0 +1,66 @@ +export interface Tier { + name: string; + models: string[]; // ordered fallback chain, primary first + costCap?: number; // $ per-run; abort when run.costTotal exceeds + contextFloor?: number; // min contextWindow; skip models below it at spawn +} + +export class TierFileError extends Error { + override name = "TierFileError" as const; +} + +/** Parse a raw JSON string into validated Tier[]. Empty/blank → []. */ +export function parseTiersFile(raw: string): Tier[] { + const trimmed = raw.trim(); + if (!trimmed) return []; + let parsed: unknown; + try { parsed = JSON.parse(trimmed); } catch { throw new TierFileError("malformed tiers file (invalid JSON)"); } + if (!Array.isArray(parsed)) throw new TierFileError("tiers file must be a JSON array of tier objects"); + const seen = new Set(); + return parsed.map((t) => { + const obj = t as Record; + const name = obj.name; + const models = obj.models; + if (typeof name !== "string" || !name.trim()) throw new TierFileError("tier missing name"); + if (!Array.isArray(models)) throw new TierFileError("tier missing models"); + if (models.length === 0) throw new TierFileError(`tier '${name}' has empty models`); + if (seen.has(name)) throw new TierFileError(`duplicate tier name '${name}'`); + seen.add(name); + return { + name, models: models.map(String), + ...(typeof obj.costCap === "number" ? { costCap: obj.costCap } : {}), + ...(typeof obj.contextFloor === "number" ? { contextFloor: obj.contextFloor } : {}), + }; + }); +} + +/** Merge tiers by name: builtins < global < project (later scopes win by name). */ +export function mergeTiers(builtin: Tier[], globalTiers: Tier[], project: Tier[]): Tier[] { + const map = new Map(); + for (const t of builtin) map.set(t.name, t); + for (const t of globalTiers) map.set(t.name, t); + for (const t of project) map.set(t.name, t); + return [...map.values()]; +} + +export interface TierRegistryOpts { + tiers: Tier[]; + /** Agent defs keyed by agent name — only the `tier` field is read, for `usedBy`. */ + agents: Map; +} + +export class TierRegistry { + private readonly byName = new Map(); + private readonly agents: Map; + constructor(opts: TierRegistryOpts) { + for (const t of opts.tiers) this.byName.set(t.name, t); + this.agents = opts.agents; + } + get(name: string): Tier | undefined { return this.byName.get(name); } + list(): Tier[] { return [...this.byName.values()]; } + usedBy(name: string): string[] { + const out: string[] = []; + for (const [agentName, def] of this.agents) if (def.tier === name) out.push(agentName); + return out.sort(); + } +} \ No newline at end of file diff --git a/test/tier-registry.test.mts b/test/tier-registry.test.mts new file mode 100644 index 0000000..353c134 --- /dev/null +++ b/test/tier-registry.test.mts @@ -0,0 +1,57 @@ +import { test } from "node:test"; +import { strictEqual, deepStrictEqual, throws } from "node:assert"; +import { TierRegistry, parseTiersFile, mergeTiers, type Tier } from "../src/tiers/tier-registry.ts"; +import { BUILTIN_TIERS } from "../src/tiers/builtin.ts"; + +const good = (n: string, models: string[] = [`${n}-model`]): Tier => ({ name: n, models }); + +test("parseTiersFile: valid array of tiers", () => { + deepStrictEqual(parseTiersFile(`[{ "name": "x", "models": ["m1"] }]`), [{ name: "x", models: ["m1"] }]); +}); + +test("parseTiersFile: empty input → empty array", () => { + deepStrictEqual(parseTiersFile(""), []); + deepStrictEqual(parseTiersFile("[]"), []); +}); + +test("parseTiersFile: malformed JSON → throws", () => { + throws(() => parseTiersFile("{ not json"), /malformed tiers file/); +}); + +test("parseTiersFile: duplicate name → throws", () => { + throws(() => parseTiersFile(`[{ "name": "x", "models": ["m"] }, { "name": "x", "models": ["m2"] }]`), /duplicate tier name/); +}); + +test("parseTiersFile: missing name/models → throws", () => { + throws(() => parseTiersFile(`[{ "models": ["m"] }]`), /tier missing name/); + throws(() => parseTiersFile(`[{ "name": "x" }]`), /tier missing models/); + throws(() => parseTiersFile(`[{ "name": "x", "models": [] }]`), /tier 'x' has empty models/); +}); + +test("mergeTiers: builtins < global < project (project wins by name)", () => { + const merged = mergeTiers( + [good("economy"), good("standard")], + [good("standard", ["global-std"]), good("custom")], + [good("standard", ["proj-std"]), good("local")], + ); + const byName = new Map(merged.map((t) => [t.name, t])); + strictEqual(byName.get("economy")!.models[0], "economy-model", "builtin economy kept"); + strictEqual(byName.get("standard")!.models[0], "proj-std", "project wins over global + builtin"); + strictEqual(byName.get("custom")!.models[0], "custom-model", "global-only tier kept"); + strictEqual(byName.get("local")!.models[0], "local-model", "project-only tier kept"); +}); + +test("TierRegistry.get/list/usedBy", () => { + const reg = new TierRegistry({ + tiers: mergeTiers(BUILTIN_TIERS, [], []), + agents: new Map([["coder", { tier: "standard" } as any], ["oracle", { tier: "frontier" } as any]]), + }); + strictEqual(reg.get("economy")!.models[0], "Ollama/minimax-m3:cloud"); + strictEqual(reg.get("frontier")!.costCap, 5); + strictEqual(reg.get("frontier")!.contextFloor, 200000); + deepStrictEqual(reg.list().map((t) => t.name), ["economy", "standard", "frontier"]); + deepStrictEqual(reg.usedBy("standard"), ["coder"]); + deepStrictEqual(reg.usedBy("frontier"), ["oracle"]); + deepStrictEqual(reg.usedBy("economy"), []); + strictEqual(reg.get("nope"), undefined, "missing tier → undefined"); +}); \ No newline at end of file From eb950c069ed1669841362d9a8ea2d6a27dbbe977 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Mon, 27 Jul 2026 11:42:09 +0700 Subject: [PATCH 02/13] feat(spec-6-1): splitModel + resolveAgentModel (tier resolution + contextFloor filter) --- src/registry/frontmatter.ts | 2 ++ src/tiers/resolve.ts | 50 ++++++++++++++++++++++++++++++++ test/resolve-model.test.mts | 58 +++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 src/tiers/resolve.ts create mode 100644 test/resolve-model.test.mts diff --git a/src/registry/frontmatter.ts b/src/registry/frontmatter.ts index 65f577c..8825529 100644 --- a/src/registry/frontmatter.ts +++ b/src/registry/frontmatter.ts @@ -22,6 +22,8 @@ export interface AgentDef { sessionKey: string; source: AgentSource; filePath: string; + /** SPEC-6-1: cost-aware model tier (overrides agent.model when set). */ + tier?: string; } export class FrontmatterError extends Error { diff --git a/src/tiers/resolve.ts b/src/tiers/resolve.ts new file mode 100644 index 0000000..eb8402c --- /dev/null +++ b/src/tiers/resolve.ts @@ -0,0 +1,50 @@ +import type { AgentDef } from "../registry/frontmatter.ts"; +import type { Tier, TierRegistry } from "./tier-registry.ts"; + +/** Narrow port over pi's ModelRegistry — only the lookup 6-1 needs. */ +export interface ModelRegistryLike { + find(provider: string, modelId: string): { contextWindow: number } | undefined; +} + +export interface ResolvedModel { + model: string; + tier?: Tier; + /** Pre-filtered eligible candidates (primary first); spawnSubagent retries these on create() rejection. */ + candidates?: string[]; +} + +export interface ResolveError { error: string; model?: undefined; } + +/** Split "provider/modelId" on the first "/". Bare id → { parentProvider, id }. */ +export function splitModel(model: string, parentProvider = ""): { provider: string; id: string } { + const i = model.indexOf("/"); + if (i < 0) return { provider: parentProvider, id: model }; + return { provider: model.slice(0, i), id: model.slice(i + 1) }; +} + +/** Q4 precedence: optsModel > agent.tier > agent.model > parent. Q5: contextFloor + catalog filter. */ +export function resolveAgentModel( + agent: AgentDef, optsModel: string | undefined, + parentModel: { provider: string; id: string }, + tiers: TierRegistry, modelRegistry: ModelRegistryLike, +): ResolvedModel | ResolveError { + if (optsModel) return { model: optsModel }; + if (agent.tier) { + const tier = tiers.get(agent.tier); + if (!tier) return { error: `tier '${agent.tier}' not found; available: ${tiers.list().map((t) => t.name).join(", ")}` }; + const candidates: string[] = []; + for (const m of tier.models) { + const { provider, id } = splitModel(m, parentModel.provider); + const model = modelRegistry.find(provider, id); + if (!model) continue; // not in catalog → skip + if (tier.contextFloor && (model.contextWindow ?? 0) < tier.contextFloor) continue; // below floor → skip + candidates.push(m); + } + if (candidates.length === 0) { + return { error: `tier '${tier.name}': no eligible model (all missing or below contextFloor ${tier.contextFloor ?? "—"})` }; + } + return { model: candidates[0]!, tier, candidates }; + } + if (agent.model) return { model: agent.model }; + return { model: `${parentModel.provider}/${parentModel.id}` }; +} \ No newline at end of file diff --git a/test/resolve-model.test.mts b/test/resolve-model.test.mts new file mode 100644 index 0000000..2e908db --- /dev/null +++ b/test/resolve-model.test.mts @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import { strictEqual, deepStrictEqual, ok } from "node:assert"; +import { splitModel, resolveAgentModel, type ModelRegistryLike } from "../src/tiers/resolve.ts"; +import { TierRegistry } from "../src/tiers/tier-registry.ts"; +import type { AgentDef } from "../src/registry/frontmatter.ts"; + +const agent = (over: Partial = {}): AgentDef => ({ + name: "g", description: "d", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, + backend: "pi", sessionKey: "g", source: "builtin", filePath: "/x", ...over, +}); +const PARENT = { provider: "p", id: "m" }; +const fakeReg = (windows: Record): ModelRegistryLike => ({ + find: (provider, id) => { const w = windows[`${provider}/${id}`]; return w != null ? { contextWindow: w } : undefined; }, +}); +const tiers = (list: any[]) => new TierRegistry({ tiers: list, agents: new Map() }); + +test("splitModel: first-slash split; colon in id preserved", () => { + deepStrictEqual(splitModel("Ollama/glm-5.2:cloud"), { provider: "Ollama", id: "glm-5.2:cloud" }); + deepStrictEqual(splitModel("anthropic/claude-sonnet-4"), { provider: "anthropic", id: "claude-sonnet-4" }); + deepStrictEqual(splitModel("bare-id", "Ollama"), { provider: "Ollama", id: "bare-id" }); +}); + +test("resolveAgentModel: opts.model beats tier beats agent.model beats parent", () => { + const reg = tiers([{ name: "t", models: ["Ollama/tm"] }]); + const mr = fakeReg({ "Ollama/tm": 128000 }); + strictEqual(resolveAgentModel(agent({ tier: "t", model: "agent-model" }), "caller-model", PARENT, reg, mr).model, "caller-model"); + strictEqual(resolveAgentModel(agent({ tier: "t", model: "agent-model" }), undefined, PARENT, reg, mr).model, "Ollama/tm", "tier beats agent.model"); + strictEqual(resolveAgentModel(agent({ model: "agent-model" }), undefined, PARENT, reg, mr).model, "agent-model", "no tier → agent.model"); + strictEqual(resolveAgentModel(agent({}), undefined, PARENT, reg, mr).model, "p/m", "no tier, no model → parent"); +}); + +test("resolveAgentModel: tier not found → error", () => { + const res = resolveAgentModel(agent({ tier: "nope" }), undefined, PARENT, tiers([]), fakeReg({})); + strictEqual((res as any).error, "tier 'nope' not found; available: "); +}); + +test("resolveAgentModel: contextFloor skips a too-small model, lands on next", () => { + const reg = tiers([{ name: "big", models: ["Ollama/small", "Ollama/big"], contextFloor: 200000 }]); + const mr = fakeReg({ "Ollama/small": 32000, "Ollama/big": 200000 }); + const res = resolveAgentModel(agent({ tier: "big" }), undefined, PARENT, reg, mr); + strictEqual(res.model, "Ollama/big", "small skipped (below floor), big chosen"); + deepStrictEqual((res as any).candidates, ["Ollama/big"]); +}); + +test("resolveAgentModel: undefined contextWindow → treated as 0 → below any floor → skipped", () => { + const reg = tiers([{ name: "big", models: ["Ollama/unknown", "Ollama/big"], contextFloor: 100000 }]); + const mr = fakeReg({ "Ollama/big": 200000 }); // unknown not in catalog at all → find returns undefined → skip + const res = resolveAgentModel(agent({ tier: "big" }), undefined, PARENT, reg, mr); + strictEqual(res.model, "Ollama/big"); +}); + +test("resolveAgentModel: all models below contextFloor → error", () => { + const reg = tiers([{ name: "big", models: ["Ollama/a", "Ollama/b"], contextFloor: 500000 }]); + const mr = fakeReg({ "Ollama/a": 128000, "Ollama/b": 200000 }); + const res = resolveAgentModel(agent({ tier: "big" }), undefined, PARENT, reg, mr); + ok((res as any).error.includes("no eligible model"), (res as any).error); + ok((res as any).error.includes("500000"), "error names the floor"); +}); \ No newline at end of file From 427ac0cf36e9d8e19c4a94917ec3311664cf4f43 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Mon, 27 Jul 2026 11:45:24 +0700 Subject: [PATCH 03/13] feat(spec-6-1): AgentDef.tier frontmatter parsing --- src/registry/frontmatter.ts | 1 + test/frontmatter.test.mts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/registry/frontmatter.ts b/src/registry/frontmatter.ts index 8825529..9fa1a36 100644 --- a/src/registry/frontmatter.ts +++ b/src/registry/frontmatter.ts @@ -69,6 +69,7 @@ export function parseAgentFile(content: string, filePath: string, source: AgentS name, description, model: typeof raw.model === "string" ? raw.model : undefined, + tier: typeof raw.tier === "string" ? raw.tier : undefined, thinkingLevel: typeof raw.thinkingLevel === "string" ? (raw.thinkingLevel as ThinkingLevel) : undefined, tools: strList(raw.tools), skills: strList(raw.skills), diff --git a/test/frontmatter.test.mts b/test/frontmatter.test.mts index 10f57c1..3f005e2 100644 --- a/test/frontmatter.test.mts +++ b/test/frontmatter.test.mts @@ -64,4 +64,22 @@ name: g body `; throws(() => parseAgentFile(noDesc, "/x/g.md", "project"), { name: "FrontmatterError" }); +}); + +const WITH_TIER = `--- +name: scout +description: Recon agent +tier: standard +--- +You are a scout. +`; + +test("parses optional tier field (SPEC-6-1)", () => { + const a = parseAgentFile(WITH_TIER, "/x/.pi/agents/scout.md", "project"); + strictEqual(a.tier, "standard"); +}); + +test("tier absent → undefined (back-compat)", () => { + const a = parseAgentFile(BASE, "/x/.pi/agents/scout.md", "project"); + strictEqual(a.tier, undefined); }); \ No newline at end of file From a7faad9f3e237fdf27b3360e5d9ef62f28859301 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Mon, 27 Jul 2026 11:46:55 +0700 Subject: [PATCH 04/13] feat(panel): compact token counts with K suffix (fmtTokens) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX: token counts in the live widget + Runs tab + conversation overlay now render compact (<1K as-is, >=1K with K suffix). 2027001 tok → 2027K tok; 265055 tok → 265K tok; 142 tok → 142 tok (unchanged). fmtTokens(n) helper in rows.ts alongside fmtDuration. --- src/panel/rows.ts | 8 ++++++++ src/panel/runs-rows.ts | 6 +++--- src/panel/widget-rows.ts | 4 ++-- test/widget-rows.test.mts | 9 ++++++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/panel/rows.ts b/src/panel/rows.ts index 1fac05b..b57e1c7 100644 --- a/src/panel/rows.ts +++ b/src/panel/rows.ts @@ -11,6 +11,14 @@ export function fmtDuration(ms: number): string { return `${m}m${s % 60}s`; } +/** Compact token count: <1K as-is, >=1K with K suffix (1 decimal under 10K, 0 decimals above). + * 142 → "142"; 1300 → "1.3K"; 265055 → "265K"; 2027001 → "2027K". */ +export function fmtTokens(n: number): string { + if (n < 1000) return `${n}`; + const k = n / 1000; + return `${k.toFixed(k < 10 ? 1 : 0)}K`; +} + const STATUS_GLYPH: Record = { running: "▶", completed: "✓", diff --git a/src/panel/runs-rows.ts b/src/panel/runs-rows.ts index 15c2952..5a3c41b 100644 --- a/src/panel/runs-rows.ts +++ b/src/panel/runs-rows.ts @@ -1,7 +1,7 @@ // src/panel/runs-rows.ts // SPEC-5b-1 — pure renderers for the Runs tab + per-turn timeline. Reuses the glyph // language (▶ ✓ ✗) so the Runs tab is visually consistent with Fleet/Lifecycle. -import { fmtDuration } from "./rows.ts"; +import { fmtDuration, fmtTokens } from "./rows.ts"; import type { RunMeta, MessageEvent, ToolEvent } from "../runtime/run-log.ts"; const STATUS_GLYPH: Record = { @@ -10,7 +10,7 @@ const STATUS_GLYPH: Record = { export function runsRow(r: RunMeta): string { const dur = r.endedAt ? fmtDuration(r.endedAt - r.startedAt) : "—"; - const tok = r.tokenTotal > 0 ? ` ${r.tokenTotal} tok` : ""; + const tok = r.tokenTotal > 0 ? ` ${fmtTokens(r.tokenTotal)} tok` : ""; const summary = r.resultSummary ? ` "${r.resultSummary}"` : ""; const prov = r.resumedFrom ? ` ← resumed:${r.resumedFrom}` : r.forkedFrom ? ` ← forked:${r.forkedFrom}` : ""; return `${STATUS_GLYPH[r.status]} ${r.runId} ${r.agent} ${r.status} ${dur}${tok}${summary}${prov}`; @@ -20,7 +20,7 @@ export function runTimelineRow(e: MessageEvent | ToolEvent): string { const turn = Math.max(0, e.turnIndex); if (e.type === "message") { const text = e.text.length > 80 ? e.text.slice(0, 79) + "…" : e.text; - const tok = e.usage?.total != null ? ` ${e.usage.total} tok` : ""; + const tok = e.usage?.total != null ? ` ${fmtTokens(e.usage.total)} tok` : ""; return `[a] "${text}"${tok} ·t${turn}`; } const glyph = e.isError ? "✗" : "✓"; diff --git a/src/panel/widget-rows.ts b/src/panel/widget-rows.ts index 25b1be2..f7b0e90 100644 --- a/src/panel/widget-rows.ts +++ b/src/panel/widget-rows.ts @@ -7,7 +7,7 @@ // mirror of this same renderer (same `widgetLine`, cap 8 vs 5), and the PRD §5 "navigable agent // list below editor" intent was never achievable via pi widgets (editor keeps keyboard focus). // `/fleet` is the navigable action surface; this one above-editor widget is the glance surface. -import { fmtDuration } from "./rows.ts"; +import { fmtDuration, fmtTokens } from "./rows.ts"; import type { RunRecord } from "../engine/run-registry.ts"; import type { BgRunStatus } from "./rows.ts"; @@ -61,7 +61,7 @@ const STATUS_GLYPH: Record = { function widgetLine(r: WidgetRun, now: number): string { const glyph = STATUS_GLYPH[r.status]; const dur = typeof r.startedAt === "number" ? ` ${fmtDuration(now - r.startedAt)}` : ""; - const tok = r.tokenTotal ? ` ${r.tokenTotal} tok` : ""; + const tok = r.tokenTotal ? ` ${fmtTokens(r.tokenTotal)} tok` : ""; const phase = r.phase ? ` ●${r.phase} ${r.phaseIndex ?? 0}/${r.phaseTotal ?? 0}` : ""; const be = r.backend ? ` ${r.backend}` : ""; return `${glyph} ${r.runId} ${r.agent}${dur}${tok}${phase}${be}`; diff --git a/test/widget-rows.test.mts b/test/widget-rows.test.mts index 0ba0f4c..6224ab2 100644 --- a/test/widget-rows.test.mts +++ b/test/widget-rows.test.mts @@ -5,6 +5,7 @@ import { toWidgetRun, toWidgetRunFromBg, filterActive, renderWidgetLines, type WidgetRun, } from "../src/panel/widget-rows.ts"; +import { fmtTokens } from "../src/panel/rows.ts"; import type { RunRecord } from "../src/engine/run-registry.ts"; import type { BgRunStatus } from "../src/panel/rows.ts"; @@ -83,4 +84,10 @@ test("renderWidgetLines: bg row shows phase segment, no duration", () => { test("renderWidgetLines: empty input → empty array", () => { deepStrictEqual(renderWidgetLines([], 1000), []); -}); \ No newline at end of file +}); +test("fmtTokens: K formatting for large counts (SPEC-6-1 UX)", () => { + strictEqual(fmtTokens(142), "142", "<1K as-is"); + strictEqual(fmtTokens(1300), "1.3K", "1 decimal under 10K"); + strictEqual(fmtTokens(265055), "265K", "0 decimals >=10K"); + strictEqual(fmtTokens(2027001), "2027K", "millions in K"); +}); From 605c393075da62df539d47b0514d4489e9a15a33 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Mon, 27 Jul 2026 11:49:09 +0700 Subject: [PATCH 05/13] feat(spec-6-1): RunRecord costTotal/contextTokens/tier + RunLog usage.cost widening --- src/engine/run-registry.ts | 6 ++++++ src/runtime/run-log.ts | 2 +- test/run-registry.test.mts | 11 +++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/engine/run-registry.ts b/src/engine/run-registry.ts index d795398..5306910 100644 --- a/src/engine/run-registry.ts +++ b/src/engine/run-registry.ts @@ -23,6 +23,12 @@ export interface RunRecord { forkedFrom?: string; /** SPEC-5b-2: cumulative real tokens (input+output+cacheRead+cacheWrite) — live, updated on each message_end. */ tokenTotal?: number; + /** SPEC-6-1: cumulative $ (usage.cost.total) — live, updated on each message_end. */ + costTotal?: number; + /** SPEC-6-1: latest context tokens (calcContextTokens(usage)) — live snapshot. */ + contextTokens?: number; + /** SPEC-6-1: the tier name this run used (for Tiers-view "used by" + per-tier spend). */ + tier?: string; /** SPEC-5b-4: live session handle while status === "running"; cleared by finishRun. * Transient, in-memory only — never written to RunLog (the journal append constructs * a plain object, not RunRecord). */ diff --git a/src/runtime/run-log.ts b/src/runtime/run-log.ts index d67e775..3c9e1cb 100644 --- a/src/runtime/run-log.ts +++ b/src/runtime/run-log.ts @@ -14,7 +14,7 @@ export interface RunMetaEvent { } export interface MessageEvent { type: "message"; role: string; text: string; - usage?: { total?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number }; + usage?: { total?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number; cost?: { total?: number } }; turnIndex: number; } export interface ToolEvent { diff --git a/test/run-registry.test.mts b/test/run-registry.test.mts index 4d0500d..7919184 100644 --- a/test/run-registry.test.mts +++ b/test/run-registry.test.mts @@ -90,3 +90,14 @@ test("update clears session handle (finishRun sets session: undefined)", () => { strictEqual(r.get("fl-s2")!.status, "completed"); strictEqual(r.get("fl-s2")!.session, undefined, "handle cleared by finishRun patch"); }); + +test("RunRecord carries costTotal/contextTokens/tier (SPEC-6-1, additive)", () => { + const r = new RunRegistry(); + r.add({ runId: "fl-c1", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1, tier: "standard", costTotal: 0, contextTokens: 0 } as any); + strictEqual(r.get("fl-c1")!.tier, "standard"); + strictEqual(r.get("fl-c1")!.costTotal, 0); + strictEqual(r.get("fl-c1")!.contextTokens, 0); + r.update("fl-c1", { costTotal: 0.01, contextTokens: 50000 } as any); + strictEqual(r.get("fl-c1")!.costTotal, 0.01); + strictEqual(r.get("fl-c1")!.contextTokens, 50000); +}); From 037a6f7eb1a22f505d0d0504a8a201489ae379d9 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Mon, 27 Jul 2026 13:04:43 +0700 Subject: [PATCH 06/13] feat(spec-6-1): spawnSubagent tier resolution + fallback + cost/context + cap abort Wires the tier layer into the engine: - resolveAgentModel (Q4 precedence: opts.model > agent.tier > agent.model > parent) + Q5 fallback: walk tier.models[], skip not-in-catalog OR contextWindow < contextFloor, retry backend.factory.create() on rejection with the next candidate. - On message_end: accumulate costTotal (usage.cost.total) + contextTokens (calcContextTokens) into RunRecord (live) + RunLog message.usage.cost. - Cap abort: if tier.costCap && costTotal > costCap -> abort + finishRun reports 'aborted' + 'budget_exceeded (cost $X > cap $Y)'. - finishRun threads costTotal/contextTokens into the terminal RunRecord + run:ended event + SpawnResult. - SpawnOptions.tierRegistry?/modelRegistry? optional (existing tests unaffected). 4 new tests: tier cost/context accumulation, fallback retry, cap abort, no-tier no-cap. 334/334 passing, typecheck clean. --- src/engine/spawnSubagent.ts | 93 +++++++++++++++++++++++-------- src/runtime/run-log.ts | 9 +++ test/spawn-subagent-tier.test.mts | 85 ++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 22 deletions(-) create mode 100644 test/spawn-subagent-tier.test.mts diff --git a/src/engine/spawnSubagent.ts b/src/engine/spawnSubagent.ts index dc92e36..a62ad5e 100644 --- a/src/engine/spawnSubagent.ts +++ b/src/engine/spawnSubagent.ts @@ -9,9 +9,16 @@ import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts"; import type { SingleSlotLock } from "./concurrency-lock.ts"; import type { RunLog } from "../runtime/run-log.ts"; import { buildToolEvent } from "../runtime/run-log.ts"; +import { resolveAgentModel, type ModelRegistryLike } from "../tiers/resolve.ts"; +import { TierRegistry } from "../tiers/tier-registry.ts"; const PI_DEFAULT_TOOLS = ["read", "bash", "edit", "write"]; +/** SPEC-6-1: derive context-token count from a usage object. Prefer totalTokens; fall back to sum. */ +function calcContextTokens(u: { totalTokens?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number }): number { + return u.totalTokens || ((u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0)); +} + /** No-op ports used when a caller omits them (e.g. SPEC-1 unit tests). Production (index.ts) passes real ports. */ const NOOP_MEMORY_PORT: MemoryHydratePort = { renderScopes: () => "" }; const NOOP_VISION_PORT: VisionPort = { @@ -123,6 +130,10 @@ export interface SpawnOptions { resumeLink?: string; /** SPEC-5b-1: when set, the new run is a fork of this prior runId (written to run:ended + RunRecord.forkedFrom). */ forkLink?: string; + /** SPEC-6-1: tier registry for model-tier resolution. Optional — existing callers without it use agent.model/parent fallback. */ + tierRegistry?: TierRegistry; + /** SPEC-6-1: model catalog for contextFloor filtering. Optional — absent means no catalog filtering. */ + modelRegistry?: ModelRegistryLike; } export interface SpawnResult { @@ -134,6 +145,10 @@ export interface SpawnResult { model: string; durationMs: number; tokenTotal: number; + /** SPEC-6-1: cumulative $ (usage.cost.total) for this run. */ + costTotal?: number; + /** SPEC-6-1: final context tokens (calcContextTokens of the last usage). */ + contextTokens?: number; error?: string; } @@ -173,8 +188,18 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { // skillsOverride (buildChildLoader reads agent.skills) loads the phase's bundle, not the agent's. const childAgent = opts.skillsOverride ? { ...agentDef, skills: opts.skillsOverride } : agentDef; - // resolve model - const model = opts.model ?? agentDef.model ?? `${opts.parentModel.provider}/${opts.parentModel.id}`; + // SPEC-6-1: resolve model via tier registry (Q4 precedence + Q5 contextFloor/catalog filter). + const resolved = resolveAgentModel( + agentDef, opts.model, opts.parentModel, + opts.tierRegistry ?? new TierRegistry({ tiers: [], agents: new Map() }), + opts.modelRegistry ?? { find: () => undefined }, + ); + if ("error" in resolved) { + return fail(runId, startedAt, resolved.error, opts.agent); + } + const model = resolved.model; + const tier = resolved.tier; + const candidates = resolved.candidates ?? [model]; // child tools pass through UNFILTERED — the single-writer `todo`-exclusion is enforced // downstream by the child factory's `excludeTools: ["todo"]` (SPEC-2 §9.1 hardening). @@ -186,6 +211,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { opts.runRegistry.add({ runId, agent: agentDef.name, model, task: opts.task, track, todoId: null, status: "running", startedAt, + tier: tier?.name, costTotal: 0, contextTokens: 0, }); try { 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 { return await finishRun(opts, runId, startedAt, "failed", "", todoId, priorStatus, (e as Error).message, agentDef.name, model); } - // spawn child - const { session } = await backend.factory.create({ - cwd: opts.parentCwd, - model, - thinkingLevel: childAgent.thinkingLevel, - tools, - rolePrompt: childAgent.rolePrompt, - skills: childAgent.skills ?? [], - task: opts.task, - agent: childAgent, - memoryPort, - visionPort, - }); + // SPEC-6-1: fallback retry loop — try candidates[0], on rejection retry candidates[1], etc. + let session: ChildSession | undefined; + let lastErr: Error | undefined; + for (const cand of candidates) { + try { + const result = await backend.factory.create({ + cwd: opts.parentCwd, + model: cand, + thinkingLevel: childAgent.thinkingLevel, + tools, + rolePrompt: childAgent.rolePrompt, + skills: childAgent.skills ?? [], + task: opts.task, + agent: childAgent, + memoryPort, + visionPort, + }); + session = result.session; + break; + } catch (e) { lastErr = e as Error; } + } + if (!session) { + return await finishRun(opts, runId, startedAt, "failed", "", todoId, priorStatus, `backend create failed: ${lastErr?.message ?? "unknown"}`, agentDef.name, model, 0, 0, 0); + } // SPEC-5b-4: retain a narrow live-session handle on the run record so the panel can // 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 { const budget = createTurnBudget(maxTurns); let finalText = ""; let tokenTotal = 0; + let costTotal = 0; + let contextTokens = 0; let turnIdx = -1; const onSignalAbort = (): void => { aborted = true; void session.abort(); }; @@ -255,11 +294,20 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { const turnTokens = (u?.input ?? 0) + (u?.output ?? 0) + (u?.cacheRead ?? 0) + (u?.cacheWrite ?? 0); if (turnTokens > 0) { tokenTotal += turnTokens; - opts.runRegistry.update(runId, { tokenTotal }); } + // SPEC-6-1: accumulate cost + context tokens. + const cost = u?.cost?.total ?? 0; + costTotal += cost; + contextTokens = calcContextTokens(u ?? {}); + opts.runRegistry.update(runId, { costTotal, contextTokens, tokenTotal }); try { - 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 }); + 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 }); } catch { /* best-effort */ } + // SPEC-6-1: cap abort — if costTotal exceeds tier.costCap, abort + flag budget_exceeded. + if (tier?.costCap && costTotal > tier.costCap) { + aborted = true; + void session.abort(); + } } else if (e.type === "tool_execution_end") { try { 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 { let error: string | undefined; if (aborted) { status = "aborted"; - error = "aborted by user"; + error = tier?.costCap && costTotal > tier.costCap ? `budget_exceeded (cost $${costTotal.toFixed(4)} > cap $${tier.costCap})` : "aborted by user"; } else if (budget.count() >= maxTurns) { status = "failed"; error = `hit turn budget (${maxTurns}) mid-task; partial result: ${finalText.slice(0, 200)}`; @@ -294,7 +342,7 @@ export async function spawnSubagent(opts: SpawnOptions): Promise { status = "completed"; } - return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal); + return await finishRun(opts, runId, startedAt, status, finalText, todoId, priorStatus, error, agentDef.name, model, tokenTotal, costTotal, contextTokens); } finally { opts.lock.release(); } @@ -310,18 +358,19 @@ function fail(runId: string, startedAt: number, message: string, agent: string): async function finishRun( opts: SpawnOptions, runId: string, startedAt: number, status: FleetRunStatus, finalText: string, todoId: string | null, priorStatus: string | undefined, - error: string | undefined, agentName: string, model: string, tokenTotal = 0, + error: string | undefined, agentName: string, model: string, tokenTotal = 0, costTotal = 0, contextTokens = 0, ): Promise { const endedAt = Date.now(); opts.runRegistry.update(runId, { status, endedAt, resultSummary: finalText.slice(0, 120), resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink, session: undefined, // SPEC-5b-4: clear the live handle (invariant: session ⟺ running) + costTotal, contextTokens, // SPEC-6-1: final cost/context on the terminal record }); try { opts.runLog?.append(runId, { type: "run:ended", runId, status, endedAt, - resultSummary: finalText.slice(0, 120), tokenTotal, + resultSummary: finalText.slice(0, 120), tokenTotal, costTotal, contextTokens, resumedFrom: opts.resumeLink, forkedFrom: opts.forkLink, }); } catch { /* best-effort: journal is the index, not the product */ } @@ -340,6 +389,6 @@ async function finishRun( } return { status, finalText, runId, todoId, agent: agentName, model, - durationMs: endedAt - startedAt, tokenTotal, error, + durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, }; } \ No newline at end of file diff --git a/src/runtime/run-log.ts b/src/runtime/run-log.ts index 3c9e1cb..ac49096 100644 --- a/src/runtime/run-log.ts +++ b/src/runtime/run-log.ts @@ -23,6 +23,10 @@ export interface ToolEvent { export interface RunEndedEvent { type: "run:ended"; runId: string; status: FleetRunStatus; endedAt: number; resultSummary?: string; tokenTotal: number; resumedFrom?: string; forkedFrom?: string; + /** SPEC-6-1: cumulative $ at run end. */ + costTotal?: number; + /** SPEC-6-1: latest context-token snapshot at run end. */ + contextTokens?: number; } export type RunLogEvent = RunMetaEvent | MessageEvent | ToolEvent | RunEndedEvent; @@ -33,6 +37,10 @@ export interface RunMeta { backendSessionId?: string; sessionKey?: string; status: FleetRunStatus; endedAt?: number; resultSummary?: string; tokenTotal: number; resumedFrom?: string; forkedFrom?: string; + /** SPEC-6-1: cumulative $ at run end. */ + costTotal?: number; + /** SPEC-6-1: latest context-token snapshot at run end. */ + contextTokens?: number; } const ARGS_LIMIT = 200; @@ -103,6 +111,7 @@ export class RunLog { meta.status = ended.status; meta.endedAt = ended.endedAt; meta.resultSummary = ended.resultSummary; meta.tokenTotal = ended.tokenTotal; meta.resumedFrom = ended.resumedFrom; meta.forkedFrom = ended.forkedFrom; + meta.costTotal = ended.costTotal; meta.contextTokens = ended.contextTokens; } out.push(meta); } diff --git a/test/spawn-subagent-tier.test.mts b/test/spawn-subagent-tier.test.mts new file mode 100644 index 0000000..4022d9a --- /dev/null +++ b/test/spawn-subagent-tier.test.mts @@ -0,0 +1,85 @@ +import { test, beforeEach, afterEach } from "node:test"; +import { strictEqual, ok } from "node:assert"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSubagent, type ChildSession, type ChildSessionFactory } from "../src/engine/spawnSubagent.ts"; +import { RunRegistry } from "../src/engine/run-registry.ts"; +import { createSingleSlotLock } from "../src/engine/concurrency-lock.ts"; +import { ArmoryTodoAdapter } from "../src/todo-sync/adapter.ts"; +import { BackendRegistry, PI_HOOK_PARITY, type Backend } from "../src/backend/port.ts"; +import { TierRegistry } from "../src/tiers/tier-registry.ts"; +import type { ModelRegistryLike } from "../src/tiers/resolve.ts"; +import type { AgentDef } from "../src/registry/frontmatter.ts"; + +let tmpDir: string, logDir: string; +beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "fleet-tier-")); logDir = mkdtempSync(join(tmpdir(), "fleet-tlog-")); process.env.TODO_DIR = tmpDir; }); +afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); rmSync(logDir, { recursive: true, force: true }); delete process.env.TODO_DIR; }); + +const agent = (over: Partial = {}): AgentDef => ({ name: "g", description: "d", rolePrompt: "r", todoSync: true, memoryHydrate: true, vision: true, backend: "pi", sessionKey: "g", source: "builtin", filePath: "/x", ...over }); +const PARENT = { provider: "p", id: "m" }; +const mr = (windows: Record): ModelRegistryLike => ({ find: (pr, id) => { const w = windows[`${pr}/${id}`]; return w != null ? { contextWindow: w } : undefined; } }); +function regWith(factory: ChildSessionFactory): BackendRegistry { const r = new BackendRegistry(); r.register({ id: "pi", factory, available: () => true, versionInfo: () => null, hookParity: PI_HOOK_PARITY }); return r; } + +function fakeChild(usage: { input: number; output: number; cost: number; totalTokens?: number }, assistantText = "done"): ChildSession { + const handlers: Array<(e: any) => void> = []; + return { + prompt: async () => { + for (const h of handlers) h({ type: "turn_start", turnIndex: 0 }); + for (const h of handlers) h({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: assistantText }], usage: { input: usage.input, output: usage.output, cacheRead: 0, cacheWrite: 0, totalTokens: usage.totalTokens ?? (usage.input + usage.output), cost: { total: usage.cost } } } }); + for (const h of handlers) h({ type: "turn_end", turnIndex: 0 }); + }, + subscribe: (h) => { handlers.push(h); return () => {}; }, abort: async () => {}, dispose: () => {}, + }; +} + +test("tier run: costTotal + contextTokens accumulated; tier recorded on RunRecord", async () => { + const factory: ChildSessionFactory = { create: async () => ({ session: fakeChild({ input: 100, output: 42, cost: 0.001 }), model: "Ollama/glm-5.2:cloud" }) }; + const tiers = new TierRegistry({ tiers: [{ name: "standard", models: ["Ollama/glm-5.2:cloud"] }], agents: new Map() }); + const runReg = new RunRegistry(); + const res = await spawnSubagent({ + agent: "g", task: "t", track: true, registry: new Map([["g", agent({ tier: "standard" })]]), + todoSync: new ArmoryTodoAdapter(), runRegistry: runReg, lock: createSingleSlotLock(), backendRegistry: regWith(factory), + parentModel: PARENT, parentCwd: tmpDir, tierRegistry: tiers, modelRegistry: mr({ "Ollama/glm-5.2:cloud": 256000 }), + } as any); + strictEqual(res.status, "completed"); + strictEqual(runReg.get(res.runId)!.tier, "standard"); + strictEqual(runReg.get(res.runId)!.costTotal, 0.001, "$ accumulated from usage.cost.total"); + strictEqual(runReg.get(res.runId)!.contextTokens, 142, "contextTokens = calcContextTokens(usage)"); +}); + +test("fallback: models[0] create() rejects → retry models[1]", async () => { + let calls = 0; + const factory: ChildSessionFactory = { create: async () => { calls++; if (calls === 1) throw new Error("provider down"); return { session: fakeChild({ input: 10, output: 5, cost: 0 }), model: "Ollama/minimax-m3:cloud" }; } }; + const tiers = new TierRegistry({ tiers: [{ name: "std", models: ["Ollama/primary", "Ollama/fallback"] }], agents: new Map() }); + const res = await spawnSubagent({ + agent: "g", task: "t", track: true, registry: new Map([["g", agent({ tier: "std" })]]), + todoSync: new ArmoryTodoAdapter(), runRegistry: new RunRegistry(), lock: createSingleSlotLock(), backendRegistry: regWith(factory), + parentModel: PARENT, parentCwd: tmpDir, tierRegistry: tiers, modelRegistry: mr({ "Ollama/primary": 128000, "Ollama/fallback": 128000 }), + } as any); + strictEqual(res.status, "completed", "fallback model succeeded"); + strictEqual(calls, 2, "create() called twice (primary rejected, fallback ok)"); +}); + +test("cap abort: costTotal > tier.costCap → aborted + budget_exceeded", async () => { + const factory: ChildSessionFactory = { create: async () => ({ session: fakeChild({ input: 100, output: 42, cost: 0.01 }), model: "anthropic/claude-sonnet-4" }) }; + const tiers = new TierRegistry({ tiers: [{ name: "frontier", models: ["anthropic/claude-sonnet-4"], costCap: 0.001 }], agents: new Map() }); + const res = await spawnSubagent({ + agent: "g", task: "t", track: true, registry: new Map([["g", agent({ tier: "frontier" })]]), + todoSync: new ArmoryTodoAdapter(), runRegistry: new RunRegistry(), lock: createSingleSlotLock(), backendRegistry: regWith(factory), + parentModel: PARENT, parentCwd: tmpDir, tierRegistry: tiers, modelRegistry: mr({ "anthropic/claude-sonnet-4": 200000 }), + } as any); + strictEqual(res.status, "aborted"); + ok(res.error?.includes("budget_exceeded"), `error mentions budget_exceeded: ${res.error}`); +}); + +test("no tier (agent.model path): cost still tracked, no cap enforcement", async () => { + const factory: ChildSessionFactory = { create: async () => ({ session: fakeChild({ input: 100, output: 42, cost: 0.5 }), model: "m" }) }; + const res = await spawnSubagent({ + agent: "g", task: "t", track: true, registry: new Map([["g", agent({ model: "m" })]]), + todoSync: new ArmoryTodoAdapter(), runRegistry: new RunRegistry(), lock: createSingleSlotLock(), backendRegistry: regWith(factory), + parentModel: PARENT, parentCwd: tmpDir, tierRegistry: new TierRegistry({ tiers: [], agents: new Map() }), modelRegistry: mr({}), + } as any); + strictEqual(res.status, "completed", "no cap → runs to completion even at $0.5"); + strictEqual(res.tokenTotal, 142); +}); \ No newline at end of file From a428dbf6e077f9ac370e248a5806eb782f93a285 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Mon, 27 Jul 2026 13:12:09 +0700 Subject: [PATCH 07/13] feat(spec-6-1): widget-rows ctx% + $ segments + task-excerpt row format (hide runId, agent-if-named) --- src/panel/widget-rows.ts | 29 +++++++++++++++++--- test/fleet-widget.test.mts | 2 +- test/widget-rows.test.mts | 56 +++++++++++++++++++++++++++++++++----- 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/src/panel/widget-rows.ts b/src/panel/widget-rows.ts index f7b0e90..95e9c3f 100644 --- a/src/panel/widget-rows.ts +++ b/src/panel/widget-rows.ts @@ -24,6 +24,14 @@ export interface WidgetRun { phaseTotal?: number; kind: "fg" | "bg"; backend?: string; + /** SPEC-6-1: task excerpt for the primary label (fg runs). */ + task?: string; + /** SPEC-6-1: latest context-token snapshot (for ctx% segment). */ + contextTokens?: number; + /** SPEC-6-1: max context window for the resolved model (set by controller — Task 7). */ + maxContext?: number; + /** SPEC-6-1: cumulative $ (for the $ segment). */ + costTotal?: number; } export function toWidgetRun(r: RunRecord): WidgetRun { @@ -31,6 +39,7 @@ export function toWidgetRun(r: RunRecord): WidgetRun { runId: r.runId, agent: r.agent, status: r.status, startedAt: r.startedAt, endedAt: r.endedAt, tokenTotal: r.tokenTotal, kind: "fg", + task: r.task, costTotal: r.costTotal, contextTokens: r.contextTokens, }; } @@ -57,14 +66,26 @@ const STATUS_GLYPH: Record = { running: "▶", queued: "⏳", paused: "⏸", completed: "✓", failed: "✗", aborted: "✗", }; -/** One compact line per active run. */ +/** One compact line per active run. + * fg: `▶ "task excerpt" · agent 5s 265K tok 42% $0.01` (runId hidden; agent hidden when general-purpose). + * bg: `▶ ●plan 2/4 pi` (phase as primary label; no runId, no task excerpt). */ function widgetLine(r: WidgetRun, now: number): string { const glyph = STATUS_GLYPH[r.status]; const dur = typeof r.startedAt === "number" ? ` ${fmtDuration(now - r.startedAt)}` : ""; const tok = r.tokenTotal ? ` ${fmtTokens(r.tokenTotal)} tok` : ""; - const phase = r.phase ? ` ●${r.phase} ${r.phaseIndex ?? 0}/${r.phaseTotal ?? 0}` : ""; - const be = r.backend ? ` ${r.backend}` : ""; - return `${glyph} ${r.runId} ${r.agent}${dur}${tok}${phase}${be}`; + const ctx = (r.contextTokens != null && r.maxContext != null && r.maxContext > 0) ? ` ${Math.round(r.contextTokens / r.maxContext * 100)}%` : ""; + const cost = r.costTotal ? ` $${r.costTotal.toFixed(4)}` : ""; + + if (r.kind === "bg") { + const phase = r.phase ? `●${r.phase} ${r.phaseIndex ?? 0}/${r.phaseTotal ?? 0}` : r.runId; + const be = r.backend ? ` ${r.backend}` : ""; + return `${glyph} ${phase}${tok}${be}`; + } + + // fg: task excerpt as primary label (fallback to runId if no task) + const label = r.task ? `"${r.task.slice(0, 40)}"` : r.runId; + const agentSeg = r.agent && r.agent !== "general-purpose" ? ` · ${r.agent}` : ""; + return `${glyph} ${label}${agentSeg}${dur}${tok}${ctx}${cost}`; } /** Above-editor widget: one line per active run, cap 5, overflow → "+N more in /fleet". */ diff --git a/test/fleet-widget.test.mts b/test/fleet-widget.test.mts index 5c3ad38..fab7b24 100644 --- a/test/fleet-widget.test.mts +++ b/test/fleet-widget.test.mts @@ -37,7 +37,7 @@ test("active fg run → widget set; completion → cleared", () => { rr.add({ runId: "fl-1", agent: "coder", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 1000 }); const lastActive = calls.filter((c2) => c2.key === "fleet-active").at(-1)!; - ok(lastActive.content!.length === 1 && lastActive.content![0]!.includes("fl-1"), "above widget shows the run"); + ok(lastActive.content!.length === 1 && lastActive.content![0]!.includes('"t"'), "above widget shows the run (task excerpt)"); rr.update("fl-1", { status: "completed", endedAt: now }); const after = calls.filter((c2) => c2.key === "fleet-active").at(-1)!; diff --git a/test/widget-rows.test.mts b/test/widget-rows.test.mts index 6224ab2..95749c2 100644 --- a/test/widget-rows.test.mts +++ b/test/widget-rows.test.mts @@ -62,24 +62,25 @@ test("renderWidgetLines: one line per active run, capped at 5 + overflow", () => ok(lines[5]!.includes("+2 more in /fleet"), "overflow line"); }); -test("renderWidgetLines: fg row shows glyph + runId + agent + live duration + tokens", () => { - const w = toWidgetRun(fg({ runId: "fl-x", agent: "coder", startedAt: 1000, tokenTotal: 142 })); +test("renderWidgetLines: fg row shows glyph + task excerpt + named agent + live duration + tokens", () => { + const w = toWidgetRun(fg({ runId: "fl-x", agent: "coder", task: "refactor the module", startedAt: 1000, tokenTotal: 142 })); const lines = renderWidgetLines([w], 3000); strictEqual(lines.length, 1); ok(lines[0]!.includes("▶"), "running glyph"); - ok(lines[0]!.includes("fl-x"), "runId"); - ok(lines[0]!.includes("coder"), "agent"); - ok(lines[0]!.includes("2s"), `live duration (now-startedAt): ${lines[0]}`); + ok(lines[0]!.includes('"refactor the module"'), `task excerpt shown: ${lines[0]}`); + ok(!lines[0]!.includes("fl-x"), "runId hidden from widget row"); + ok(lines[0]!.includes("· coder"), `named agent shown: ${lines[0]}`); + ok(lines[0]!.includes("2s"), `live duration: ${lines[0]}`); ok(lines[0]!.includes("142 tok"), "token segment"); ok(!lines[0]!.includes("●"), "fg row has no phase segment"); }); -test("renderWidgetLines: bg row shows phase segment, no duration", () => { +test("renderWidgetLines: bg row shows phase segment, no runId, no duration", () => { const w = toWidgetRunFromBg(bg({ runId: "fl-bg", phase: "plan", phaseIndex: 1, phaseTotal: 4 })); const lines = renderWidgetLines([w], 1000); strictEqual(lines.length, 1); ok(lines[0]!.includes("●plan 1/4"), "phase segment"); - ok(lines[0]!.includes("fl-bg"), "runId"); + ok(!lines[0]!.includes("fl-bg"), "runId hidden from bg widget row"); }); test("renderWidgetLines: empty input → empty array", () => { @@ -91,3 +92,44 @@ test("fmtTokens: K formatting for large counts (SPEC-6-1 UX)", () => { strictEqual(fmtTokens(265055), "265K", "0 decimals >=10K"); strictEqual(fmtTokens(2027001), "2027K", "millions in K"); }); + +test("widgetLine: ctx% shown when contextTokens + maxContext present (SPEC-6-1)", () => { + const w = toWidgetRun(fg({ runId: "fl-x", contextTokens: 128000, costTotal: 0.0123 } as any)); + w.maxContext = 256000; + const lines = renderWidgetLines([w], 3000); + ok(lines[0]!.includes("50%"), `ctx% shown: ${lines[0]}`); + ok(lines[0]!.includes("$0.0123"), `cost shown: ${lines[0]}`); +}); + +test("widgetLine: ctx% hidden when maxContext absent; $ hidden when costTotal 0", () => { + const w = toWidgetRun(fg({ runId: "fl-y", contextTokens: 100, costTotal: 0 } as any)); + const lines = renderWidgetLines([w], 3000); + ok(!lines[0]!.includes("%"), `no ctx% without maxContext: ${lines[0]}`); + ok(!lines[0]!.includes("$"), `no $ when costTotal 0: ${lines[0]}`); +}); + +test("widgetLine: general-purpose agent hidden from row; named agent shown", () => { + const wGeneric = toWidgetRun(fg({ runId: "fl-g", agent: "general-purpose", task: "do stuff", startedAt: 1000 })); + const linesG = renderWidgetLines([wGeneric], 2000); + ok(linesG[0]!.includes('"do stuff"'), `task excerpt shown: ${linesG[0]}`); + ok(!linesG[0]!.includes("general-purpose"), `generic agent hidden: ${linesG[0]}`); + ok(!linesG[0]!.includes("fl-g"), "runId hidden"); + + const wNamed = toWidgetRun(fg({ runId: "fl-n", agent: "scout", task: "recon", startedAt: 1000 })); + const linesN = renderWidgetLines([wNamed], 2000); + ok(linesN[0]!.includes("· scout"), `named agent shown: ${linesN[0]}`); +}); + +test("widgetLine: task excerpt truncated to 40 chars", () => { + const longTask = "delegate to scout and recon the entire repository structure and report back"; + const w = toWidgetRun(fg({ runId: "fl-l", agent: "scout", task: longTask, startedAt: 1000 })); + const lines = renderWidgetLines([w], 2000); + ok(lines[0]!.includes('"delegate to scout and recon the entire r"'), `truncated to 40 chars: ${lines[0]}`); + ok(!lines[0]!.includes("repository structure"), "beyond 40 chars not shown"); +}); + +test("widgetLine: fg run with no task falls back to runId", () => { + const w = toWidgetRun(fg({ runId: "fl-notask", agent: "coder", task: "", startedAt: 1000 })); + const lines = renderWidgetLines([w], 2000); + ok(lines[0]!.includes("fl-notask"), `runId fallback when no task: ${lines[0]}`); +}); From 8224c5a3effd00729cd646b352aac78fdcfe4063 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Mon, 27 Jul 2026 13:20:28 +0700 Subject: [PATCH 08/13] feat(spec-6-1): fleet-widget maxContext threading + runs-rows ctx%/$ --- src/panel/fleet-widget.ts | 8 +++++++- src/panel/runs-rows.ts | 7 +++++-- test/fleet-widget.test.mts | 15 +++++++++++++++ test/runs-rows.test.mts | 13 +++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/panel/fleet-widget.ts b/src/panel/fleet-widget.ts index b082fc4..9749f9d 100644 --- a/src/panel/fleet-widget.ts +++ b/src/panel/fleet-widget.ts @@ -39,6 +39,8 @@ export interface FleetWidgetDeps { now?: () => number; setInterval?: (fn: () => void, ms: number) => unknown; clearInterval?: (id: unknown) => void; + /** SPEC-6-1: resolve a model's context window for the ctx% widget segment. Optional — absent → no ctx%. */ + getModelContextWindow?: (model: string) => number | undefined; } export class FleetWidgetController { @@ -64,7 +66,11 @@ export class FleetWidgetController { } private activeRuns() { - const fg = this.deps.runRegistry.list().map(toWidgetRun); + const fg = this.deps.runRegistry.list().map((r) => { + const w = toWidgetRun(r); + w.maxContext = this.deps.getModelContextWindow?.(r.model); + return w; + }); const bg = this.deps.bgRuns ? [...this.deps.bgRuns.values()].map(toWidgetRunFromBg) : []; return [...fg, ...bg]; } diff --git a/src/panel/runs-rows.ts b/src/panel/runs-rows.ts index 5a3c41b..0358606 100644 --- a/src/panel/runs-rows.ts +++ b/src/panel/runs-rows.ts @@ -8,12 +8,15 @@ const STATUS_GLYPH: Record = { running: "▶", completed: "✓", failed: "✗", aborted: "✗", }; -export function runsRow(r: RunMeta): string { +export function runsRow(r: RunMeta, getModelContextWindow?: (model: string) => number | undefined): string { const dur = r.endedAt ? fmtDuration(r.endedAt - r.startedAt) : "—"; const tok = r.tokenTotal > 0 ? ` ${fmtTokens(r.tokenTotal)} tok` : ""; + const maxCtx = getModelContextWindow?.(r.model); + const ctx = (r.contextTokens != null && maxCtx != null && maxCtx > 0) ? ` ${Math.round(r.contextTokens / maxCtx * 100)}%` : ""; + const cost = r.costTotal ? ` $${r.costTotal.toFixed(4)}` : ""; const summary = r.resultSummary ? ` "${r.resultSummary}"` : ""; const prov = r.resumedFrom ? ` ← resumed:${r.resumedFrom}` : r.forkedFrom ? ` ← forked:${r.forkedFrom}` : ""; - return `${STATUS_GLYPH[r.status]} ${r.runId} ${r.agent} ${r.status} ${dur}${tok}${summary}${prov}`; + return `${STATUS_GLYPH[r.status]} ${r.runId} ${r.agent} ${r.status} ${dur}${tok}${ctx}${cost}${summary}${prov}`; } export function runTimelineRow(e: MessageEvent | ToolEvent): string { diff --git a/test/fleet-widget.test.mts b/test/fleet-widget.test.mts index fab7b24..8ab8a44 100644 --- a/test/fleet-widget.test.mts +++ b/test/fleet-widget.test.mts @@ -110,4 +110,19 @@ test("store emits after dispose are no-op (disposed guard)", () => { const callsBefore = calls.length; rr.add({ runId: "fl-1", agent: "a", model: "m", task: "t", track: true, todoId: null, status: "running", startedAt: 0 }); strictEqual(calls.length, callsBefore, "no render after dispose"); +}); + +test("maxContext threaded via getModelContextWindow (SPEC-6-1)", () => { + const rr = new RunRegistry(); + const { calls, ui } = fakeUi(); + const c = new FleetWidgetController({ + runRegistry: rr, ui, getTheme: () => ({}) as any, now: () => 1000, + setInterval: () => 1 as any, clearInterval: () => {}, + getModelContextWindow: (model) => model === "big-model" ? 256000 : undefined, + }); + c.start(); + rr.add({ runId: "fl-ctx", agent: "coder", model: "big-model", task: "task", track: true, todoId: null, status: "running", startedAt: 0, contextTokens: 128000 }); + const last = calls.filter((c2) => c2.key === "fleet-active").at(-1)!; + ok(last.content![0]!.includes("50%"), `ctx% shown via maxContext threading: ${last.content![0]}`); + c.dispose(); }); \ No newline at end of file diff --git a/test/runs-rows.test.mts b/test/runs-rows.test.mts index fd8527e..ee4e3cd 100644 --- a/test/runs-rows.test.mts +++ b/test/runs-rows.test.mts @@ -54,4 +54,17 @@ test("runTimelineRow renders [t] + ✗ + full error text for failed tools", () = test("runTimelineRow clamps turnIndex -1 to 0 (cosmetic; no crash)", () => { const line = runTimelineRow({ type: "message", role: "assistant", text: "x", turnIndex: -1 }); assert.match(line, /\[a\]/); // does not throw +}); + +test("runsRow: ctx% + $ shown when contextTokens/costTotal/maxContext present (SPEC-6-1)", () => { + const resolver = (model: string) => (model === "m" ? 256000 : undefined); + const line = runsRow(meta({ contextTokens: 128000, costTotal: 0.0123 }), resolver); + assert.match(line, /50%/, `ctx% shown: ${line}`); + assert.match(line, /\$0\.0123/, `cost shown: ${line}`); +}); + +test("runsRow: ctx% hidden when maxContext unresolved; $ hidden when costTotal 0 (SPEC-6-1)", () => { + const line = runsRow(meta({ contextTokens: 100, costTotal: 0 }), () => undefined); + assert.doesNotMatch(line, /%/, `no ctx% without maxContext: ${line}`); + assert.doesNotMatch(line, /\$/, `no $ when costTotal 0: ${line}`); }); \ No newline at end of file From 83a5bbd9909b42d8619da25c1f364bebae834011 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Mon, 27 Jul 2026 13:24:35 +0700 Subject: [PATCH 09/13] feat(spec-6-1): tiers-rows pure render fns --- src/panel/tiers-rows.ts | 16 ++++++++++++++++ test/tiers-rows.test.mts | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 src/panel/tiers-rows.ts create mode 100644 test/tiers-rows.test.mts diff --git a/src/panel/tiers-rows.ts b/src/panel/tiers-rows.ts new file mode 100644 index 0000000..fd27152 --- /dev/null +++ b/src/panel/tiers-rows.ts @@ -0,0 +1,16 @@ +import type { Tier } from "../tiers/tier-registry.ts"; + +function fmtFloor(n: number | undefined): string { + if (n == null) return "—"; + if (n >= 1000) return `${Math.round(n / 1000)}k`; + return String(n); +} + +/** One row per tier for the /fleet Tiers view. Pure (unit-tested). */ +export function renderTierRow(tier: Tier, spend: number, usedBy: string[], runCount: number): string { + const cap = tier.costCap != null ? `$${tier.costCap}` : "—"; + const floor = fmtFloor(tier.contextFloor); + const spendStr = spend > 0 ? `$${spend.toFixed(4)}` : "$0.00"; + const used = usedBy.length ? `used by: ${usedBy.join(", ")}` : "used by: —"; + return `${tier.name} ${tier.models.join("→")} ${cap} ${floor} ${spendStr} ${runCount} runs ${used}`; +} \ No newline at end of file diff --git a/test/tiers-rows.test.mts b/test/tiers-rows.test.mts new file mode 100644 index 0000000..52ad21f --- /dev/null +++ b/test/tiers-rows.test.mts @@ -0,0 +1,21 @@ +import { test } from "node:test"; +import { strictEqual, ok } from "node:assert"; +import { renderTierRow } from "../src/panel/tiers-rows.ts"; +import type { Tier } from "../src/tiers/tier-registry.ts"; + +const t = (over: Partial = {}): Tier => ({ name: "standard", models: ["Ollama/glm-5.2:cloud", "Ollama/minimax-m3:cloud"], ...over }); + +test("renderTierRow: models chain arrow, spend, used-by", () => { + const row = renderTierRow(t(), 0.12, ["coder", "scout"], 3); + ok(row.includes("standard"), "name"); + ok(row.includes("Ollama/glm-5.2:cloud→Ollama/minimax-m3:cloud"), "models chain: " + row); + ok(row.includes("$0.12"), "spend"); + ok(row.includes("3 runs"), "run count"); + ok(row.includes("used by: coder, scout"), "used-by list"); +}); + +test("renderTierRow: costCap + contextFloor shown; — when absent", () => { + ok(renderTierRow(t({ costCap: 5, contextFloor: 200000 }), 0, [], 0).includes("$5"), "cap shown"); + ok(renderTierRow(t({ costCap: 5, contextFloor: 200000 }), 0, [], 0).includes("200k"), "floor shown"); + ok(renderTierRow(t(), 0, [], 0).includes("—"), "— when cap/floor absent"); +}); \ No newline at end of file From 199f8aa0fb9efe6367ef1a5777774ca2e5ff7057 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Mon, 27 Jul 2026 13:28:17 +0700 Subject: [PATCH 10/13] feat(spec-6-1): tier-store read/write (atomic + validated) --- src/tiers/tier-store.ts | 22 ++++++++++++++++++++++ test/tier-store.test.mts | 29 +++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/tiers/tier-store.ts create mode 100644 test/tier-store.test.mts diff --git a/src/tiers/tier-store.ts b/src/tiers/tier-store.ts new file mode 100644 index 0000000..d69eda6 --- /dev/null +++ b/src/tiers/tier-store.ts @@ -0,0 +1,22 @@ +import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { parseTiersFile, type Tier } from "./tier-registry.ts"; + +export interface TierStoreOpts { projectPath: string; globalPath: string; } + +export class TierStore { + constructor(private readonly opts: TierStoreOpts) {} + read(scope: "project" | "global"): Tier[] { + const path = scope === "project" ? this.opts.projectPath : this.opts.globalPath; + try { return parseTiersFile(readFileSync(path, "utf8")); } catch { return []; } + } + write(scope: "project" | "global", tiers: Tier[]): void { + const json = JSON.stringify(tiers, null, 2); + parseTiersFile(json); // throws on invalid — no file change + const path = scope === "project" ? this.opts.projectPath : this.opts.globalPath; + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + writeFileSync(tmp, json, "utf8"); + renameSync(tmp, path); // atomic + } +} \ No newline at end of file diff --git a/test/tier-store.test.mts b/test/tier-store.test.mts new file mode 100644 index 0000000..44eb776 --- /dev/null +++ b/test/tier-store.test.mts @@ -0,0 +1,29 @@ +import { test, beforeEach, afterEach } from "node:test"; +import { strictEqual, ok, throws, deepStrictEqual } from "node:assert"; +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TierStore } from "../src/tiers/tier-store.ts"; + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "fleet-tstore-")); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + +test("read: missing file → [] (scope degrades cleanly)", () => { + const store = new TierStore({ projectPath: join(dir, "tiers.json"), globalPath: join(dir, "global-tiers.json") }); + deepStrictEqual(store.read("project"), []); +}); + +test("write+read roundtrip: project scope", () => { + const store = new TierStore({ projectPath: join(dir, "tiers.json"), globalPath: join(dir, "g.json") }); + store.write("project", [{ name: "x", models: ["m"] }]); + deepStrictEqual(store.read("project"), [{ name: "x", models: ["m"] }]); + ok(existsSync(join(dir, "tiers.json")), "file written"); +}); + +test("write rejects duplicates → no file change", () => { + const store = new TierStore({ projectPath: join(dir, "tiers.json"), globalPath: join(dir, "g.json") }); + store.write("project", [{ name: "x", models: ["m"] }]); + throws(() => store.write("project", [{ name: "x", models: ["m1"] }, { name: "x", models: ["m2"] }]), /duplicate/); + deepStrictEqual(store.read("project"), [{ name: "x", models: ["m"] }], "file unchanged after rejected write"); +}); \ No newline at end of file From 673c8fe8ff89a86897f3993a95792876de2eed9a Mon Sep 17 00:00:00 2001 From: RECTOR Date: Tue, 28 Jul 2026 09:27:52 +0700 Subject: [PATCH 11/13] feat(spec-6-1): /fleet Tiers view + inline edit actions --- src/panel/fleet-panel.ts | 130 ++++++++++++++++++++++++++++++++++++-- src/panel/tiers-items.ts | 63 ++++++++++++++++++ test/tiers-items.test.mts | 111 ++++++++++++++++++++++++++++++++ 3 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 src/panel/tiers-items.ts create mode 100644 test/tiers-items.test.mts diff --git a/src/panel/fleet-panel.ts b/src/panel/fleet-panel.ts index 180e402..b3de023 100644 --- a/src/panel/fleet-panel.ts +++ b/src/panel/fleet-panel.ts @@ -26,8 +26,11 @@ import type { TodoSyncPort } from "../todo-sync/port.ts"; import type { LifecycleDef, LifecycleRunRecord, CheckpointDecision, PhaseRecord } from "../lifecycle/lifecycle-types.ts"; import type { LifecycleRunDeps, CheckpointFn } from "../lifecycle/run-lifecycle.ts"; import { runLifecycle } from "../lifecycle/run-lifecycle.ts"; +import type { TierRegistry } from "../tiers/tier-registry.ts"; +import type { TierStore } from "../tiers/tier-store.ts"; +import { buildTiersItems, setTierCostCap, setTierModels, setTierContextFloor, addTier, deleteTier } from "./tiers-items.ts"; -type View = "fleet" | "lifecycle" | "runs" | "agents" | "backends" | "scheduled"; +type View = "fleet" | "lifecycle" | "runs" | "agents" | "backends" | "scheduled" | "tiers"; export interface FleetPanelDeps { registry: Map; @@ -47,6 +50,12 @@ export interface FleetPanelDeps { bgRuns?: BgRunsStore; /** SPEC-5b-1: durable per-run conversation log. Optional — Runs tab degrades to empty when absent. */ runLog?: RunLog; + /** SPEC-6-1: tier registry for the Tiers view. Optional — panel degrades to empty list when absent. */ + tierRegistry?: TierRegistry; + /** SPEC-6-1: tier store for inline edits. Optional — Tiers view action keys are no-ops when absent. */ + tierStore?: TierStore; + /** SPEC-6-1: callback to rebuild the tier registry after a write. */ + reloadTiers?: () => void; } export interface FleetPanelOpts { @@ -94,6 +103,10 @@ export class FleetPanel extends Container { // SPEC-5b-4: Steer inline input state (mid-run redirect; mirrors resumeMode/resumeInput). private steerInput: Input | null = null; private steerMode = false; + // SPEC-6-1: Tiers view inline-edit state (mirrors steerInput/steerMode). + private tiersInput: Input | null = null; + private tiersEditPhase: "models" | "costCap" | "contextFloor" | "add" | null = null; + private tiersScope: "project" | "global" = "project"; // SPEC-5b-3: full-message overlay (second level over the 5b-1 timeline) + stored SelectList refs // so handleInput can forward keys to the active overlay (Container/TUI routes input only to the // focused component = this panel; children receive keys only if we forward them). @@ -138,6 +151,8 @@ export class FleetPanel extends Container { ? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) })) : this.view === "scheduled" ? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s) })) + : this.view === "tiers" + ? (this.deps.tierRegistry ? buildTiersItems({ tierRegistry: this.deps.tierRegistry, runRegistry: this.deps.runRegistry }) : []) : this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) })); const fresh = new SelectList(items, 12, { selectedPrefix: (s: string) => this.theme.fg("accent", s), @@ -176,7 +191,7 @@ export class FleetPanel extends Container { this.children.length = 0; this.children.push(...keep); const accent = (s: string): string => this.theme.fg("accent", s); - const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled"] as View[]) + const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled", "tiers"] as View[]) .map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v))) .join(" "); this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0)); @@ -297,6 +312,17 @@ export class FleetPanel extends Container { this.addChild(new Text(this.theme.fg("accent", " steer> "), 0, 0)); this.addChild(this.steerInput); this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0)); + } else if (this.tiersEditPhase && this.tiersInput) { + // SPEC-6-1: Tiers tab — inline edit input. + const sel = this.list.getSelectedItem(); + const name = sel?.value ?? ""; + const prompt = this.tiersEditPhase === "add" ? " new tier name> " + : this.tiersEditPhase === "models" ? ` models for ${name}> ` + : this.tiersEditPhase === "costCap" ? ` costCap for ${name}> ` + : ` contextFloor for ${name}> `; + this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0)); + this.addChild(this.tiersInput); + this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0)); } else if (this.resumeMode && this.resumeInput) { // SPEC-5b-1: Runs tab — resume follow-up input. this.addChild(new Text(this.theme.fg("accent", " follow-up> "), 0, 0)); @@ -344,7 +370,9 @@ export class FleetPanel extends Container { : this.view === "agents" ? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit" : this.view === "scheduled" - ? " a:Add p:Pause/resume d:Delete i:Info tab:Fleet q:Quit" + ? " a:Add p:Pause/resume d:Delete i:Info tab:Tiers q:Quit" + : this.view === "tiers" + ? " m:Models c:costCap f:contextFloor a:Add d:Delete g:scope tab:Fleet q:Quit" : " r:Refresh i:Info tab:Fleet q:Quit"; this.addChild(new Text(this.theme.fg("dim", hint), 0, 0)); this.addChild(new Spacer(1)); @@ -416,7 +444,7 @@ export class FleetPanel extends Container { : this.view === "lifecycle" ? "runs" : this.view === "runs" ? "agents" : this.view === "agents" ? "backends" - : this.view === "backends" ? "scheduled" : "fleet"; + : this.view === "backends" ? "scheduled" : this.view === "scheduled" ? "tiers" : "fleet"; this.selectedBackend = null; this.selectedLifecycle = null; this.selectedSchedule = null; @@ -428,6 +456,8 @@ export class FleetPanel extends Container { this.messageBodyList = null; this.steerMode = false; // SPEC-5b-4: drop any in-flight steer input on tab switch this.steerInput = null; + this.tiersEditPhase = null; // SPEC-6-1: drop any in-flight tiers edit on tab switch + this.tiersInput = null; this.list = this.buildList(); this.renderShell(); } @@ -469,6 +499,12 @@ export class FleetPanel extends Container { this.invalidate(); return; } + if (this.tiersEditPhase && this.tiersInput) { + if (matchesKey(data, "escape")) { this.cancelTiersEdit(); return; } + this.tiersInput.handleInput(data); + this.invalidate(); + return; + } if (this.resumeMode && this.resumeInput) { if (matchesKey(data, "escape")) { this.cancelResume(); return; } this.resumeInput.handleInput(data); @@ -576,6 +612,20 @@ export class FleetPanel extends Container { return; } } + // SPEC-6-1: Tiers view — m:Models c:costCap f:contextFloor a:Add d:Delete g:scope + if (this.view === "tiers" && this.deps.tierStore && this.deps.tierRegistry) { + if (matchesKey(data, "m")) { this.startTiersEdit("models"); return; } + if (matchesKey(data, "c")) { this.startTiersEdit("costCap"); return; } + if (matchesKey(data, "f")) { this.startTiersEdit("contextFloor"); return; } + if (matchesKey(data, "a")) { this.startTiersEdit("add"); return; } + if (matchesKey(data, "d")) { this.executeTiersDelete(); return; } + if (matchesKey(data, "g")) { + this.tiersScope = this.tiersScope === "project" ? "global" : "project"; + this.onNotify(`tiers scope: ${this.tiersScope}`, "info"); + this.renderShell(); + return; + } + } // SPEC-4: pending checkpoint keys (c/v/a) if (this.pendingCheckpoint && !this.lcRevising) { if (matchesKey(data, "c")) { this.pendingCheckpoint.resolve({ action: "continue" }); this.pendingCheckpoint = null; this.renderShell(); return; } @@ -739,6 +789,78 @@ export class FleetPanel extends Container { })(); } + // ────────────────────────────── SPEC-6-1: Tiers view inline edit ────────────────────────────── + + private startTiersEdit(phase: "models" | "costCap" | "contextFloor" | "add"): void { + if (phase !== "add") { + const sel = this.list.getSelectedItem(); + if (!sel) { this.onNotify("select a tier first", "warning"); return; } + } + this.tiersInput = new Input(); + this.tiersInput.onSubmit = (value: string) => { void this.executeTiersEdit(value, phase); }; + this.tiersInput.onEscape = () => this.cancelTiersEdit(); + this.tiersEditPhase = phase; + this.renderShell(); + } + + private cancelTiersEdit(): void { + this.tiersEditPhase = null; + this.tiersInput = null; + this.renderShell(); + } + + private async executeTiersEdit(value: string, phase: "models" | "costCap" | "contextFloor" | "add"): Promise { + const store = this.deps.tierStore!; + const scope = this.tiersScope; + const sel = this.list.getSelectedItem(); + const name = sel?.value ?? ""; + if (phase === "add" && !value.trim()) { this.onNotify("tier name required", "error"); return; } + this.tiersEditPhase = null; + this.tiersInput = null; + this.renderShell(); + let tiers = store.read(scope); + try { + if (phase === "add") { + tiers = addTier(tiers, value.trim(), [""]); + } else if (phase === "models") { + tiers = setTierModels(tiers, name, value.split(/[,\s]+/).filter(Boolean)); + } else if (phase === "costCap") { + const n = Number(value); + if (value.trim() !== "" && Number.isNaN(n)) { this.onNotify("costCap must be a number", "error"); return; } + tiers = setTierCostCap(tiers, name, value.trim() === "" ? undefined : n); + } else { + const n = Number(value); + if (value.trim() !== "" && Number.isNaN(n)) { this.onNotify("contextFloor must be a number", "error"); return; } + tiers = setTierContextFloor(tiers, name, value.trim() === "" ? undefined : n); + } + store.write(scope, tiers); + this.deps.reloadTiers?.(); + this.list = this.buildList(); + this.renderShell(); + if (phase === "add") this.onNotify(`tier '${value.trim()}' added; press m to edit models`, "info"); + } catch (e) { + this.onNotify(e instanceof Error ? e.message : String(e), "error"); + return; + } + } + + private executeTiersDelete(): void { + const sel = this.list.getSelectedItem(); + if (!sel) { this.onNotify("select a tier first", "warning"); return; } + const store = this.deps.tierStore!; + const scope = this.tiersScope; + const tiers = deleteTier(store.read(scope), sel.value); + try { + store.write(scope, tiers); + this.deps.reloadTiers?.(); + this.list = this.buildList(); + this.renderShell(); + this.onNotify(`tier '${sel.value}' deleted`, "info"); + } catch (e) { + this.onNotify(e instanceof Error ? e.message : String(e), "error"); + } + } + private async executeResume(prior: RunMeta, followUp: string): Promise { this.resumeMode = false; this.resumeInput = null; diff --git a/src/panel/tiers-items.ts b/src/panel/tiers-items.ts new file mode 100644 index 0000000..06ea315 --- /dev/null +++ b/src/panel/tiers-items.ts @@ -0,0 +1,63 @@ +import type { Tier } from "../tiers/tier-registry.ts"; +import type { TierRegistry } from "../tiers/tier-registry.ts"; +import type { RunRegistry } from "../engine/run-registry.ts"; +import { renderTierRow } from "./tiers-rows.ts"; +import type { SelectItem } from "@earendil-works/pi-tui"; + +export interface TiersItemSources { + tierRegistry: TierRegistry; + runRegistry: RunRegistry; +} + +/** Build the /fleet Tiers view list items. One per tier, labeled via `renderTierRow`. */ +export function buildTiersItems(src: TiersItemSources): SelectItem[] { + const runs = src.runRegistry.list(); + return src.tierRegistry.list().map((tier) => { + const tierRuns = runs.filter((r) => r.tier === tier.name); + const spend = tierRuns.reduce((sum, r) => sum + (r.costTotal ?? 0), 0); + const runCount = tierRuns.length; + const usedBy = src.tierRegistry.usedBy(tier.name); + return { value: tier.name, label: renderTierRow(tier, spend, usedBy, runCount) }; + }); +} + +/** Set the costCap on a tier. `undefined` removes the field. Throws if tier not found. */ +export function setTierCostCap(tiers: Tier[], name: string, cap: number | undefined): Tier[] { + const idx = tiers.findIndex((t) => t.name === name); + if (idx < 0) throw new Error(`tier '${name}' not found`); + const updated = { ...tiers[idx]! }; + if (cap != null) updated.costCap = cap; + else delete updated.costCap; + return [...tiers.slice(0, idx), updated, ...tiers.slice(idx + 1)]; +} + +/** Replace the models array on a tier. Empty array → throws. Throws if tier not found. */ +export function setTierModels(tiers: Tier[], name: string, models: string[]): Tier[] { + if (models.length === 0) throw new Error(`tier '${name}': models must be non-empty`); + const idx = tiers.findIndex((t) => t.name === name); + if (idx < 0) throw new Error(`tier '${name}' not found`); + const updated = { ...tiers[idx]!, models }; + return [...tiers.slice(0, idx), updated, ...tiers.slice(idx + 1)]; +} + +/** Set/unset the contextFloor on a tier. Throws if tier not found. */ +export function setTierContextFloor(tiers: Tier[], name: string, floor: number | undefined): Tier[] { + const idx = tiers.findIndex((t) => t.name === name); + if (idx < 0) throw new Error(`tier '${name}' not found`); + const updated = { ...tiers[idx]! }; + if (floor != null) updated.contextFloor = floor; + else delete updated.contextFloor; + return [...tiers.slice(0, idx), updated, ...tiers.slice(idx + 1)]; +} + +/** Add a new tier. Throws on duplicate name or empty models. */ +export function addTier(tiers: Tier[], name: string, models: string[]): Tier[] { + if (models.length === 0) throw new Error(`tier '${name}': models must be non-empty`); + if (tiers.some((t) => t.name === name)) throw new Error(`tier '${name}' already exists`); + return [...tiers, { name, models }]; +} + +/** Remove a tier by name. No-op if absent (no throw). */ +export function deleteTier(tiers: Tier[], name: string): Tier[] { + return tiers.filter((t) => t.name !== name); +} \ No newline at end of file diff --git a/test/tiers-items.test.mts b/test/tiers-items.test.mts new file mode 100644 index 0000000..6281f7f --- /dev/null +++ b/test/tiers-items.test.mts @@ -0,0 +1,111 @@ +import { test } from "node:test"; +import { strictEqual, ok, throws } from "node:assert"; +import { buildTiersItems, setTierCostCap, setTierModels, setTierContextFloor, addTier, deleteTier } from "../src/panel/tiers-items.ts"; +import { TierRegistry } from "../src/tiers/tier-registry.ts"; +import type { Tier } from "../src/tiers/tier-registry.ts"; +import { RunRegistry } from "../src/engine/run-registry.ts"; + +const t = (over: Partial = {}): Tier => ({ name: "standard", models: ["Ollama/glm-5.2:cloud"], ...over }); +const tiers = (list: Tier[]) => new TierRegistry({ tiers: list, agents: new Map() }); + +test("buildTiersItems: one item per tier", () => { + const reg = tiers([t({ name: "economy" }), t({ name: "standard" }), t({ name: "frontier" })]); + const items = buildTiersItems({ tierRegistry: reg, runRegistry: new RunRegistry() }); + strictEqual(items.length, 3); + strictEqual(items[0]!.value, "economy"); + strictEqual(items[1]!.value, "standard"); + strictEqual(items[2]!.value, "frontier"); +}); + +test("buildTiersItems: spend = sum of run.costTotal for runs with matching tier", () => { + const reg = tiers([t({ name: "standard" })]); + const rr = new RunRegistry(); + rr.add({ runId: "r1", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "completed", startedAt: 1, tier: "standard", costTotal: 0.05 }); + rr.add({ runId: "r2", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "completed", startedAt: 2, tier: "standard", costTotal: 0.03 }); + rr.add({ runId: "r3", agent: "g", model: "m", task: "t", track: true, todoId: null, status: "completed", startedAt: 3, tier: "frontier", costTotal: 0.5 }); + const items = buildTiersItems({ tierRegistry: reg, runRegistry: rr }); + ok(items[0]!.label.includes("$0.0800"), `spend summed: ${items[0]!.label}`); + ok(items[0]!.label.includes("2 runs"), `run count: ${items[0]!.label}`); +}); + +test("buildTiersItems: no-runs tier → $0.00 + 0 runs", () => { + const reg = tiers([t({ name: "economy" })]); + const items = buildTiersItems({ tierRegistry: reg, runRegistry: new RunRegistry() }); + ok(items[0]!.label.includes("$0.00"), `zero spend: ${items[0]!.label}`); + ok(items[0]!.label.includes("0 runs"), `zero runs: ${items[0]!.label}`); +}); + +test("buildTiersItems: usedBy populated from agents map", () => { + const reg = new TierRegistry({ tiers: [t({ name: "standard" })], agents: new Map([["coder", { tier: "standard" }]]) }); + const items = buildTiersItems({ tierRegistry: reg, runRegistry: new RunRegistry() }); + ok(items[0]!.label.includes("used by: coder"), `usedBy shown: ${items[0]!.label}`); +}); + +test("setTierCostCap: set cap to 1", () => { + const result = setTierCostCap([t({ name: "x" })], "x", 1); + strictEqual(result[0]!.costCap, 1); +}); + +test("setTierCostCap: undefined removes the field", () => { + const result = setTierCostCap([t({ name: "x", costCap: 5 })], "x", undefined); + strictEqual(result[0]!.costCap, undefined); + ok(!("costCap" in result[0]!), "costCap field removed"); +}); + +test("setTierCostCap: missing tier → throws", () => { + throws(() => setTierCostCap([t({ name: "x" })], "y", 1), /tier 'y' not found/); +}); + +test("setTierModels: replaces models array", () => { + const result = setTierModels([t({ name: "x", models: ["old"] })], "x", ["a", "b"]); + strictEqual(result[0]!.models.length, 2); + strictEqual(result[0]!.models[0], "a"); +}); + +test("setTierModels: empty array → throws", () => { + throws(() => setTierModels([t({ name: "x" })], "x", []), /models must be non-empty/); +}); + +test("setTierModels: missing tier → throws", () => { + throws(() => setTierModels([t({ name: "x" })], "y", ["m"]), /tier 'y' not found/); +}); + +test("setTierContextFloor: set floor", () => { + const result = setTierContextFloor([t({ name: "x" })], "x", 200000); + strictEqual(result[0]!.contextFloor, 200000); +}); + +test("setTierContextFloor: undefined removes the field", () => { + const result = setTierContextFloor([t({ name: "x", contextFloor: 200000 })], "x", undefined); + ok(!("contextFloor" in result[0]!), "contextFloor field removed"); +}); + +test("setTierContextFloor: missing tier → throws", () => { + throws(() => setTierContextFloor([t({ name: "x" })], "y", 200000), /tier 'y' not found/); +}); + +test("addTier: creates a new tier", () => { + const result = addTier([t({ name: "x" })], "y", ["model-y"]); + strictEqual(result.length, 2); + strictEqual(result[1]!.name, "y"); + strictEqual(result[1]!.models[0], "model-y"); +}); + +test("addTier: duplicate name → throws", () => { + throws(() => addTier([t({ name: "x" })], "x", ["m"]), /tier 'x' already exists/); +}); + +test("addTier: empty models → throws", () => { + throws(() => addTier([t({ name: "x" })], "y", []), /models must be non-empty/); +}); + +test("deleteTier: removes the named tier", () => { + const result = deleteTier([t({ name: "x" }), t({ name: "y" })], "x"); + strictEqual(result.length, 1); + strictEqual(result[0]!.name, "y"); +}); + +test("deleteTier: absent tier → no-op, no throw", () => { + const result = deleteTier([t({ name: "x" })], "y"); + strictEqual(result.length, 1); +}); \ No newline at end of file From 0230548ad44d64bbe6e74a2a6e35e1923468ced9 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Tue, 28 Jul 2026 09:41:34 +0700 Subject: [PATCH 12/13] feat(spec-6-1): wire TierRegistry + ModelRegistry + Tiers view into the extension --- src/index.ts | 33 ++++++++++++++++++++++++++++++++- src/tools/subagent.ts | 10 ++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 46c75b3..38953af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,10 @@ import { Scheduler } from "./scheduling/scheduler.ts"; import { createFleetResultsTool } from "./tools/fleet-results.ts"; import { BgRunsStore } from "./panel/bg-runs-store.ts"; import { FleetWidgetController } from "./panel/fleet-widget.ts"; +import { TierRegistry, mergeTiers } from "./tiers/tier-registry.ts"; +import { BUILTIN_TIERS } from "./tiers/builtin.ts"; +import { TierStore } from "./tiers/tier-store.ts"; +import { splitModel } from "./tiers/resolve.ts"; /** The package builtin agents/ dir, resolved relative to this module. */ function builtinAgentsDir(): string { @@ -167,6 +171,12 @@ export default async function (pi: ExtensionAPI): Promise { deps.lifecycleDeps.registry = deps.lifecycleRegistry; deps.lifecycleDeps.agentRegistry = deps.registry; + // SPEC-6-1: shared model registry for contextWindow lookups (contextFloor + ctx% widget). + const sharedModelRegistry = new ModelRegistry(modelRuntime); + deps.modelRegistry = sharedModelRegistry; + // Builtin-only placeholder tier registry so spawn works before session_start rebuilds with merged tiers. + deps.tierRegistry = new TierRegistry({ tiers: BUILTIN_TIERS, agents: deps.registry }); + // ── SPEC-5a: operational runtime (async/bg + scheduling + worktree isolation) ── const fleetDir = (cwd: string) => join(cwd, ".pi", "fleet"); const bgRuns = new BgRunsStore(); @@ -195,7 +205,8 @@ export default async function (pi: ExtensionAPI): Promise { agent: o.agent, task: o.task, lifecycleTodoId: o.lifecycleTodoId, model: o.model, skillsOverride: o.skills, backendOverride: o.backend, registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: bgLock, - backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, runLog: deps.runLog, // child runs in the worktree + backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: opts.worktreePath, runLog: deps.runLog, + tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1 }), }; 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 { bgRuns, ui: ctx.ui as never, getTheme: () => ctx.ui.theme, + getModelContextWindow: (m: string) => { + const { provider, id } = splitModel(m, deps.parentModel.provider); + return sharedModelRegistry.find(provider, id)?.contextWindow; + }, }); fleetWidget.start(); + + // SPEC-6-1: per-session TierStore (cwd-aware project path) + real TierRegistry (builtins + global + project). + const tierStore = new TierStore({ + projectPath: join(dir, "tiers.json"), + globalPath: join(process.env.HOME ?? "", ".pi", "agent", "fleet", "tiers.json"), + }); + deps.tierStore = tierStore; + const reloadTiers = (): void => { + deps.tierRegistry = new TierRegistry({ + tiers: mergeTiers(BUILTIN_TIERS, tierStore.read("global"), tierStore.read("project")), + agents: deps.registry, + }); + }; + deps.reloadTiers = reloadTiers; + reloadTiers(); // build the real merged registry (replaces the builtin-only placeholder) }); pi.on("session_shutdown", () => { @@ -348,6 +378,7 @@ export default async function (pi: ExtensionAPI): Promise { skillsOverride: o.skills, backendOverride: o.backend, registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock, backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, + tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1 }), }; const res = await runLifecycle(parsed.task, lcName, { deps: lifecycleFullDeps, mode: parsed.auto ? "auto" : "checkpointed", onCheckpoint }); diff --git a/src/tools/subagent.ts b/src/tools/subagent.ts index 07412ed..adb51ae 100644 --- a/src/tools/subagent.ts +++ b/src/tools/subagent.ts @@ -48,6 +48,14 @@ export interface SubagentToolDeps { bgRuns?: import("../panel/bg-runs-store.ts").BgRunsStore; /** SPEC-5b-1: durable per-run conversation log. Optional — Runs tab + journaling disabled when absent. */ runLog?: import("../runtime/run-log.ts").RunLog; + /** SPEC-6-1: tier registry for cost-aware model routing. Optional. */ + tierRegistry?: import("../tiers/tier-registry.ts").TierRegistry; + /** SPEC-6-1: model registry for contextWindow lookups (contextFloor + ctx%). Optional. */ + modelRegistry?: import("../tiers/resolve.ts").ModelRegistryLike; + /** SPEC-6-1: tier store for the /fleet Tiers view writes. Optional. */ + tierStore?: import("../tiers/tier-store.ts").TierStore; + /** SPEC-6-1: rebuild the tier registry after a panel write. */ + reloadTiers?: () => void; } /** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */ @@ -89,6 +97,7 @@ export function createSubagentTool(deps: SubagentToolDeps) { registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock, backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, runLog: deps.runLog, signal, maxTurns: params.maxTurns, + tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, }), }; const res = await runLifecycle(params.task, params.lifecycle, { @@ -120,6 +129,7 @@ export function createSubagentTool(deps: SubagentToolDeps) { runLog: deps.runLog, signal, maxTurns: params.maxTurns, + tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, }); const isError = res.status === "failed" || res.status === "aborted"; return { From db85f0da413666f46136c6a88e10eda0c2d08da9 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Tue, 28 Jul 2026 09:47:45 +0700 Subject: [PATCH 13/13] =?UTF-8?q?chore(spec-6-1):=20bump=20version=200.9.4?= =?UTF-8?q?=20=E2=86=92=200.10.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 65cce02..f0abdc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@getpipher/armory-fleet", - "version": "0.9.4", + "version": "0.10.0", "private": false, "description": "The armory suite's subagent orchestrator for the pi coding agent \u2014 a cross-harness, superpowers-native fleet where every agent is armory-native from birth.", "license": "MIT",