@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 +1 -1
- package/src/engine/run-registry.ts +6 -0
- package/src/engine/spawnSubagent.ts +71 -22
- package/src/index.ts +34 -1
- package/src/panel/fleet-panel.ts +129 -5
- package/src/panel/fleet-widget.ts +7 -1
- package/src/panel/rows.ts +8 -0
- package/src/panel/runs-rows.ts +8 -5
- package/src/panel/tiers-items.ts +63 -0
- package/src/panel/tiers-rows.ts +16 -0
- package/src/panel/widget-rows.ts +27 -6
- package/src/registry/frontmatter.ts +3 -0
- package/src/runtime/run-log.ts +10 -1
- package/src/tiers/builtin.ts +8 -0
- package/src/tiers/resolve.ts +50 -0
- package/src/tiers/tier-registry.ts +66 -0
- package/src/tiers/tier-store.ts +22 -0
- package/src/tools/subagent.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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
|
-
//
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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,
|
|
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 });
|
package/src/panel/fleet-panel.ts
CHANGED
|
@@ -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:
|
|
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(
|
|
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: "✓",
|
package/src/panel/runs-rows.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/panel/widget-rows.ts
CHANGED
|
@@ -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
|
|
66
|
-
const
|
|
67
|
-
|
|
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),
|
package/src/runtime/run-log.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tools/subagent.ts
CHANGED
|
@@ -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 {
|