@getpipher/armory-fleet 0.9.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/engine/run-registry.ts +6 -0
- package/src/engine/spawnSubagent.ts +71 -22
- package/src/index.ts +32 -1
- package/src/panel/fleet-panel.ts +126 -4
- 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 +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getpipher/armory-fleet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "The armory suite's subagent orchestrator for the pi coding agent — a cross-harness, superpowers-native fleet where every agent is armory-native from birth.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,6 +23,12 @@ export interface RunRecord {
|
|
|
23
23
|
forkedFrom?: string;
|
|
24
24
|
/** SPEC-5b-2: cumulative real tokens (input+output+cacheRead+cacheWrite) — live, updated on each message_end. */
|
|
25
25
|
tokenTotal?: number;
|
|
26
|
+
/** SPEC-6-1: cumulative $ (usage.cost.total) — live, updated on each message_end. */
|
|
27
|
+
costTotal?: number;
|
|
28
|
+
/** SPEC-6-1: latest context tokens (calcContextTokens(usage)) — live snapshot. */
|
|
29
|
+
contextTokens?: number;
|
|
30
|
+
/** SPEC-6-1: the tier name this run used (for Tiers-view "used by" + per-tier spend). */
|
|
31
|
+
tier?: string;
|
|
26
32
|
/** SPEC-5b-4: live session handle while status === "running"; cleared by finishRun.
|
|
27
33
|
* Transient, in-memory only — never written to RunLog (the journal append constructs
|
|
28
34
|
* a plain object, not RunRecord). */
|
|
@@ -9,9 +9,16 @@ import { createTurnBudget, DEFAULT_MAX_TURNS } from "./turn-budget.ts";
|
|
|
9
9
|
import type { SingleSlotLock } from "./concurrency-lock.ts";
|
|
10
10
|
import type { RunLog } from "../runtime/run-log.ts";
|
|
11
11
|
import { buildToolEvent } from "../runtime/run-log.ts";
|
|
12
|
+
import { resolveAgentModel, type ModelRegistryLike } from "../tiers/resolve.ts";
|
|
13
|
+
import { TierRegistry } from "../tiers/tier-registry.ts";
|
|
12
14
|
|
|
13
15
|
const PI_DEFAULT_TOOLS = ["read", "bash", "edit", "write"];
|
|
14
16
|
|
|
17
|
+
/** SPEC-6-1: derive context-token count from a usage object. Prefer totalTokens; fall back to sum. */
|
|
18
|
+
function calcContextTokens(u: { totalTokens?: number; input?: number; output?: number; cacheRead?: number; cacheWrite?: number }): number {
|
|
19
|
+
return u.totalTokens || ((u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0));
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
/** No-op ports used when a caller omits them (e.g. SPEC-1 unit tests). Production (index.ts) passes real ports. */
|
|
16
23
|
const NOOP_MEMORY_PORT: MemoryHydratePort = { renderScopes: () => "" };
|
|
17
24
|
const NOOP_VISION_PORT: VisionPort = {
|
|
@@ -123,6 +130,10 @@ export interface SpawnOptions {
|
|
|
123
130
|
resumeLink?: string;
|
|
124
131
|
/** SPEC-5b-1: when set, the new run is a fork of this prior runId (written to run:ended + RunRecord.forkedFrom). */
|
|
125
132
|
forkLink?: string;
|
|
133
|
+
/** SPEC-6-1: tier registry for model-tier resolution. Optional — existing callers without it use agent.model/parent fallback. */
|
|
134
|
+
tierRegistry?: TierRegistry;
|
|
135
|
+
/** SPEC-6-1: model catalog for contextFloor filtering. Optional — absent means no catalog filtering. */
|
|
136
|
+
modelRegistry?: ModelRegistryLike;
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
export interface SpawnResult {
|
|
@@ -134,6 +145,10 @@ export interface SpawnResult {
|
|
|
134
145
|
model: string;
|
|
135
146
|
durationMs: number;
|
|
136
147
|
tokenTotal: number;
|
|
148
|
+
/** SPEC-6-1: cumulative $ (usage.cost.total) for this run. */
|
|
149
|
+
costTotal?: number;
|
|
150
|
+
/** SPEC-6-1: final context tokens (calcContextTokens of the last usage). */
|
|
151
|
+
contextTokens?: number;
|
|
137
152
|
error?: string;
|
|
138
153
|
}
|
|
139
154
|
|
|
@@ -173,8 +188,18 @@ export async function spawnSubagent(opts: SpawnOptions): Promise<SpawnResult> {
|
|
|
173
188
|
// skillsOverride (buildChildLoader reads agent.skills) loads the phase's bundle, not the agent's.
|
|
174
189
|
const childAgent = opts.skillsOverride ? { ...agentDef, skills: opts.skillsOverride } : agentDef;
|
|
175
190
|
|
|
176
|
-
// resolve model
|
|
177
|
-
const
|
|
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" } });
|
|
@@ -282,8 +293,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
282
293
|
bgRuns,
|
|
283
294
|
ui: ctx.ui as never,
|
|
284
295
|
getTheme: () => ctx.ui.theme,
|
|
296
|
+
getModelContextWindow: (m: string) => {
|
|
297
|
+
const { provider, id } = splitModel(m, deps.parentModel.provider);
|
|
298
|
+
return sharedModelRegistry.find(provider, id)?.contextWindow;
|
|
299
|
+
},
|
|
285
300
|
});
|
|
286
301
|
fleetWidget.start();
|
|
302
|
+
|
|
303
|
+
// SPEC-6-1: per-session TierStore (cwd-aware project path) + real TierRegistry (builtins + global + project).
|
|
304
|
+
const tierStore = new TierStore({
|
|
305
|
+
projectPath: join(dir, "tiers.json"),
|
|
306
|
+
globalPath: join(process.env.HOME ?? "", ".pi", "agent", "fleet", "tiers.json"),
|
|
307
|
+
});
|
|
308
|
+
deps.tierStore = tierStore;
|
|
309
|
+
const reloadTiers = (): void => {
|
|
310
|
+
deps.tierRegistry = new TierRegistry({
|
|
311
|
+
tiers: mergeTiers(BUILTIN_TIERS, tierStore.read("global"), tierStore.read("project")),
|
|
312
|
+
agents: deps.registry,
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
deps.reloadTiers = reloadTiers;
|
|
316
|
+
reloadTiers(); // build the real merged registry (replaces the builtin-only placeholder)
|
|
287
317
|
});
|
|
288
318
|
|
|
289
319
|
pi.on("session_shutdown", () => {
|
|
@@ -348,6 +378,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
348
378
|
skillsOverride: o.skills, backendOverride: o.backend,
|
|
349
379
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
|
|
350
380
|
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd,
|
|
381
|
+
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry, // SPEC-6-1
|
|
351
382
|
}),
|
|
352
383
|
};
|
|
353
384
|
const res = await runLifecycle(parsed.task, lcName, { deps: lifecycleFullDeps, mode: parsed.auto ? "auto" : "checkpointed", onCheckpoint });
|
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,12 @@ export interface FleetPanelDeps {
|
|
|
47
50
|
bgRuns?: BgRunsStore;
|
|
48
51
|
/** SPEC-5b-1: durable per-run conversation log. Optional — Runs tab degrades to empty when absent. */
|
|
49
52
|
runLog?: RunLog;
|
|
53
|
+
/** SPEC-6-1: tier registry for the Tiers view. Optional — panel degrades to empty list when absent. */
|
|
54
|
+
tierRegistry?: TierRegistry;
|
|
55
|
+
/** SPEC-6-1: tier store for inline edits. Optional — Tiers view action keys are no-ops when absent. */
|
|
56
|
+
tierStore?: TierStore;
|
|
57
|
+
/** SPEC-6-1: callback to rebuild the tier registry after a write. */
|
|
58
|
+
reloadTiers?: () => void;
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
export interface FleetPanelOpts {
|
|
@@ -94,6 +103,10 @@ export class FleetPanel extends Container {
|
|
|
94
103
|
// SPEC-5b-4: Steer inline input state (mid-run redirect; mirrors resumeMode/resumeInput).
|
|
95
104
|
private steerInput: Input | null = null;
|
|
96
105
|
private steerMode = false;
|
|
106
|
+
// SPEC-6-1: Tiers view inline-edit state (mirrors steerInput/steerMode).
|
|
107
|
+
private tiersInput: Input | null = null;
|
|
108
|
+
private tiersEditPhase: "models" | "costCap" | "contextFloor" | "add" | null = null;
|
|
109
|
+
private tiersScope: "project" | "global" = "project";
|
|
97
110
|
// SPEC-5b-3: full-message overlay (second level over the 5b-1 timeline) + stored SelectList refs
|
|
98
111
|
// so handleInput can forward keys to the active overlay (Container/TUI routes input only to the
|
|
99
112
|
// focused component = this panel; children receive keys only if we forward them).
|
|
@@ -138,6 +151,8 @@ export class FleetPanel extends Container {
|
|
|
138
151
|
? [...this.deps.registry.values()].map((a: AgentDef) => ({ value: a.name, label: agentsRow(a) }))
|
|
139
152
|
: this.view === "scheduled"
|
|
140
153
|
? (this.deps.scheduler?.list() ?? []).map((s: Schedule) => ({ value: s.id, label: scheduleRow(s) }))
|
|
154
|
+
: this.view === "tiers"
|
|
155
|
+
? (this.deps.tierRegistry ? buildTiersItems({ tierRegistry: this.deps.tierRegistry, runRegistry: this.deps.runRegistry }) : [])
|
|
141
156
|
: this.deps.backendRegistry.list().map((b: Backend) => ({ value: b.id, label: backendsRow(b) }));
|
|
142
157
|
const fresh = new SelectList(items, 12, {
|
|
143
158
|
selectedPrefix: (s: string) => this.theme.fg("accent", s),
|
|
@@ -176,7 +191,7 @@ export class FleetPanel extends Container {
|
|
|
176
191
|
this.children.length = 0;
|
|
177
192
|
this.children.push(...keep);
|
|
178
193
|
const accent = (s: string): string => this.theme.fg("accent", s);
|
|
179
|
-
const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled"] as View[])
|
|
194
|
+
const tabs = (["fleet", "lifecycle", "runs", "agents", "backends", "scheduled", "tiers"] as View[])
|
|
180
195
|
.map((v) => (v === this.view ? this.theme.fg("accent", this.theme.bold(`[${v}]`)) : this.theme.fg("dim", v)))
|
|
181
196
|
.join(" ");
|
|
182
197
|
this.addChild(new Text(accent(this.theme.bold(" FLEET")) + " " + tabs, 0, 0));
|
|
@@ -297,6 +312,17 @@ export class FleetPanel extends Container {
|
|
|
297
312
|
this.addChild(new Text(this.theme.fg("accent", " steer> "), 0, 0));
|
|
298
313
|
this.addChild(this.steerInput);
|
|
299
314
|
this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
|
|
315
|
+
} else if (this.tiersEditPhase && this.tiersInput) {
|
|
316
|
+
// SPEC-6-1: Tiers tab — inline edit input.
|
|
317
|
+
const sel = this.list.getSelectedItem();
|
|
318
|
+
const name = sel?.value ?? "";
|
|
319
|
+
const prompt = this.tiersEditPhase === "add" ? " new tier name> "
|
|
320
|
+
: this.tiersEditPhase === "models" ? ` models for ${name}> `
|
|
321
|
+
: this.tiersEditPhase === "costCap" ? ` costCap for ${name}> `
|
|
322
|
+
: ` contextFloor for ${name}> `;
|
|
323
|
+
this.addChild(new Text(this.theme.fg("accent", prompt), 0, 0));
|
|
324
|
+
this.addChild(this.tiersInput);
|
|
325
|
+
this.addChild(new Text(this.theme.fg("dim", " enter submit • esc cancel"), 0, 0));
|
|
300
326
|
} else if (this.resumeMode && this.resumeInput) {
|
|
301
327
|
// SPEC-5b-1: Runs tab — resume follow-up input.
|
|
302
328
|
this.addChild(new Text(this.theme.fg("accent", " follow-up> "), 0, 0));
|
|
@@ -344,7 +370,9 @@ export class FleetPanel extends Container {
|
|
|
344
370
|
: this.view === "agents"
|
|
345
371
|
? " r:Run e:Edit i:Info d:Reload tab:Backends q:Quit"
|
|
346
372
|
: this.view === "scheduled"
|
|
347
|
-
? " a:Add p:Pause/resume d:Delete i:Info tab:
|
|
373
|
+
? " a:Add p:Pause/resume d:Delete i:Info tab:Tiers q:Quit"
|
|
374
|
+
: this.view === "tiers"
|
|
375
|
+
? " m:Models c:costCap f:contextFloor a:Add d:Delete g:scope tab:Fleet q:Quit"
|
|
348
376
|
: " r:Refresh i:Info tab:Fleet q:Quit";
|
|
349
377
|
this.addChild(new Text(this.theme.fg("dim", hint), 0, 0));
|
|
350
378
|
this.addChild(new Spacer(1));
|
|
@@ -416,7 +444,7 @@ export class FleetPanel extends Container {
|
|
|
416
444
|
: this.view === "lifecycle" ? "runs"
|
|
417
445
|
: this.view === "runs" ? "agents"
|
|
418
446
|
: this.view === "agents" ? "backends"
|
|
419
|
-
: this.view === "backends" ? "scheduled" : "fleet";
|
|
447
|
+
: this.view === "backends" ? "scheduled" : this.view === "scheduled" ? "tiers" : "fleet";
|
|
420
448
|
this.selectedBackend = null;
|
|
421
449
|
this.selectedLifecycle = null;
|
|
422
450
|
this.selectedSchedule = null;
|
|
@@ -428,6 +456,8 @@ export class FleetPanel extends Container {
|
|
|
428
456
|
this.messageBodyList = null;
|
|
429
457
|
this.steerMode = false; // SPEC-5b-4: drop any in-flight steer input on tab switch
|
|
430
458
|
this.steerInput = null;
|
|
459
|
+
this.tiersEditPhase = null; // SPEC-6-1: drop any in-flight tiers edit on tab switch
|
|
460
|
+
this.tiersInput = null;
|
|
431
461
|
this.list = this.buildList();
|
|
432
462
|
this.renderShell();
|
|
433
463
|
}
|
|
@@ -469,6 +499,12 @@ export class FleetPanel extends Container {
|
|
|
469
499
|
this.invalidate();
|
|
470
500
|
return;
|
|
471
501
|
}
|
|
502
|
+
if (this.tiersEditPhase && this.tiersInput) {
|
|
503
|
+
if (matchesKey(data, "escape")) { this.cancelTiersEdit(); return; }
|
|
504
|
+
this.tiersInput.handleInput(data);
|
|
505
|
+
this.invalidate();
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
472
508
|
if (this.resumeMode && this.resumeInput) {
|
|
473
509
|
if (matchesKey(data, "escape")) { this.cancelResume(); return; }
|
|
474
510
|
this.resumeInput.handleInput(data);
|
|
@@ -576,6 +612,20 @@ export class FleetPanel extends Container {
|
|
|
576
612
|
return;
|
|
577
613
|
}
|
|
578
614
|
}
|
|
615
|
+
// SPEC-6-1: Tiers view — m:Models c:costCap f:contextFloor a:Add d:Delete g:scope
|
|
616
|
+
if (this.view === "tiers" && this.deps.tierStore && this.deps.tierRegistry) {
|
|
617
|
+
if (matchesKey(data, "m")) { this.startTiersEdit("models"); return; }
|
|
618
|
+
if (matchesKey(data, "c")) { this.startTiersEdit("costCap"); return; }
|
|
619
|
+
if (matchesKey(data, "f")) { this.startTiersEdit("contextFloor"); return; }
|
|
620
|
+
if (matchesKey(data, "a")) { this.startTiersEdit("add"); return; }
|
|
621
|
+
if (matchesKey(data, "d")) { this.executeTiersDelete(); return; }
|
|
622
|
+
if (matchesKey(data, "g")) {
|
|
623
|
+
this.tiersScope = this.tiersScope === "project" ? "global" : "project";
|
|
624
|
+
this.onNotify(`tiers scope: ${this.tiersScope}`, "info");
|
|
625
|
+
this.renderShell();
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
579
629
|
// SPEC-4: pending checkpoint keys (c/v/a)
|
|
580
630
|
if (this.pendingCheckpoint && !this.lcRevising) {
|
|
581
631
|
if (matchesKey(data, "c")) { this.pendingCheckpoint.resolve({ action: "continue" }); this.pendingCheckpoint = null; this.renderShell(); return; }
|
|
@@ -739,6 +789,78 @@ export class FleetPanel extends Container {
|
|
|
739
789
|
})();
|
|
740
790
|
}
|
|
741
791
|
|
|
792
|
+
// ────────────────────────────── SPEC-6-1: Tiers view inline edit ──────────────────────────────
|
|
793
|
+
|
|
794
|
+
private startTiersEdit(phase: "models" | "costCap" | "contextFloor" | "add"): void {
|
|
795
|
+
if (phase !== "add") {
|
|
796
|
+
const sel = this.list.getSelectedItem();
|
|
797
|
+
if (!sel) { this.onNotify("select a tier first", "warning"); return; }
|
|
798
|
+
}
|
|
799
|
+
this.tiersInput = new Input();
|
|
800
|
+
this.tiersInput.onSubmit = (value: string) => { void this.executeTiersEdit(value, phase); };
|
|
801
|
+
this.tiersInput.onEscape = () => this.cancelTiersEdit();
|
|
802
|
+
this.tiersEditPhase = phase;
|
|
803
|
+
this.renderShell();
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
private cancelTiersEdit(): void {
|
|
807
|
+
this.tiersEditPhase = null;
|
|
808
|
+
this.tiersInput = null;
|
|
809
|
+
this.renderShell();
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private async executeTiersEdit(value: string, phase: "models" | "costCap" | "contextFloor" | "add"): Promise<void> {
|
|
813
|
+
const store = this.deps.tierStore!;
|
|
814
|
+
const scope = this.tiersScope;
|
|
815
|
+
const sel = this.list.getSelectedItem();
|
|
816
|
+
const name = sel?.value ?? "";
|
|
817
|
+
if (phase === "add" && !value.trim()) { this.onNotify("tier name required", "error"); return; }
|
|
818
|
+
this.tiersEditPhase = null;
|
|
819
|
+
this.tiersInput = null;
|
|
820
|
+
this.renderShell();
|
|
821
|
+
let tiers = store.read(scope);
|
|
822
|
+
try {
|
|
823
|
+
if (phase === "add") {
|
|
824
|
+
tiers = addTier(tiers, value.trim(), ["<placeholder-model>"]);
|
|
825
|
+
} else if (phase === "models") {
|
|
826
|
+
tiers = setTierModels(tiers, name, value.split(/[,\s]+/).filter(Boolean));
|
|
827
|
+
} else if (phase === "costCap") {
|
|
828
|
+
const n = Number(value);
|
|
829
|
+
if (value.trim() !== "" && Number.isNaN(n)) { this.onNotify("costCap must be a number", "error"); return; }
|
|
830
|
+
tiers = setTierCostCap(tiers, name, value.trim() === "" ? undefined : n);
|
|
831
|
+
} else {
|
|
832
|
+
const n = Number(value);
|
|
833
|
+
if (value.trim() !== "" && Number.isNaN(n)) { this.onNotify("contextFloor must be a number", "error"); return; }
|
|
834
|
+
tiers = setTierContextFloor(tiers, name, value.trim() === "" ? undefined : n);
|
|
835
|
+
}
|
|
836
|
+
store.write(scope, tiers);
|
|
837
|
+
this.deps.reloadTiers?.();
|
|
838
|
+
this.list = this.buildList();
|
|
839
|
+
this.renderShell();
|
|
840
|
+
if (phase === "add") this.onNotify(`tier '${value.trim()}' added; press m to edit models`, "info");
|
|
841
|
+
} catch (e) {
|
|
842
|
+
this.onNotify(e instanceof Error ? e.message : String(e), "error");
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
private executeTiersDelete(): void {
|
|
848
|
+
const sel = this.list.getSelectedItem();
|
|
849
|
+
if (!sel) { this.onNotify("select a tier first", "warning"); return; }
|
|
850
|
+
const store = this.deps.tierStore!;
|
|
851
|
+
const scope = this.tiersScope;
|
|
852
|
+
const tiers = deleteTier(store.read(scope), sel.value);
|
|
853
|
+
try {
|
|
854
|
+
store.write(scope, tiers);
|
|
855
|
+
this.deps.reloadTiers?.();
|
|
856
|
+
this.list = this.buildList();
|
|
857
|
+
this.renderShell();
|
|
858
|
+
this.onNotify(`tier '${sel.value}' deleted`, "info");
|
|
859
|
+
} catch (e) {
|
|
860
|
+
this.onNotify(e instanceof Error ? e.message : String(e), "error");
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
742
864
|
private async executeResume(prior: RunMeta, followUp: string): Promise<void> {
|
|
743
865
|
this.resumeMode = false;
|
|
744
866
|
this.resumeInput = null;
|
|
@@ -39,6 +39,8 @@ export interface FleetWidgetDeps {
|
|
|
39
39
|
now?: () => number;
|
|
40
40
|
setInterval?: (fn: () => void, ms: number) => unknown;
|
|
41
41
|
clearInterval?: (id: unknown) => void;
|
|
42
|
+
/** SPEC-6-1: resolve a model's context window for the ctx% widget segment. Optional — absent → no ctx%. */
|
|
43
|
+
getModelContextWindow?: (model: string) => number | undefined;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export class FleetWidgetController {
|
|
@@ -64,7 +66,11 @@ export class FleetWidgetController {
|
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
private activeRuns() {
|
|
67
|
-
const fg = this.deps.runRegistry.list().map(
|
|
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,14 @@ export interface SubagentToolDeps {
|
|
|
48
48
|
bgRuns?: import("../panel/bg-runs-store.ts").BgRunsStore;
|
|
49
49
|
/** SPEC-5b-1: durable per-run conversation log. Optional — Runs tab + journaling disabled when absent. */
|
|
50
50
|
runLog?: import("../runtime/run-log.ts").RunLog;
|
|
51
|
+
/** SPEC-6-1: tier registry for cost-aware model routing. Optional. */
|
|
52
|
+
tierRegistry?: import("../tiers/tier-registry.ts").TierRegistry;
|
|
53
|
+
/** SPEC-6-1: model registry for contextWindow lookups (contextFloor + ctx%). Optional. */
|
|
54
|
+
modelRegistry?: import("../tiers/resolve.ts").ModelRegistryLike;
|
|
55
|
+
/** SPEC-6-1: tier store for the /fleet Tiers view writes. Optional. */
|
|
56
|
+
tierStore?: import("../tiers/tier-store.ts").TierStore;
|
|
57
|
+
/** SPEC-6-1: rebuild the tier registry after a panel write. */
|
|
58
|
+
reloadTiers?: () => void;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
/** Build the pi.registerTool definition. Thin wrapper over spawnSubagent. */
|
|
@@ -89,6 +97,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
89
97
|
registry: deps.registry, todoSync: deps.todoSync, runRegistry: deps.runRegistry, lock: deps.lock,
|
|
90
98
|
backendRegistry: deps.backendRegistry, parentModel: deps.parentModel, parentCwd: deps.parentCwd, runLog: deps.runLog, signal,
|
|
91
99
|
maxTurns: params.maxTurns,
|
|
100
|
+
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
92
101
|
}),
|
|
93
102
|
};
|
|
94
103
|
const res = await runLifecycle(params.task, params.lifecycle, {
|
|
@@ -120,6 +129,7 @@ export function createSubagentTool(deps: SubagentToolDeps) {
|
|
|
120
129
|
runLog: deps.runLog,
|
|
121
130
|
signal,
|
|
122
131
|
maxTurns: params.maxTurns,
|
|
132
|
+
tierRegistry: deps.tierRegistry, modelRegistry: deps.modelRegistry,
|
|
123
133
|
});
|
|
124
134
|
const isError = res.status === "failed" || res.status === "aborted";
|
|
125
135
|
return {
|