pi-taskflow 0.0.12 → 0.0.14

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.
@@ -0,0 +1,607 @@
1
+ /**
2
+ * `/tf init` — single source of truth for model-role configuration.
3
+ *
4
+ * Exports:
5
+ * INIT_ROLES, RECOMMENDED_DEFAULTS – role catalog & recommended defaults
6
+ * readSettings, writeSettings – settings.json I/O (atomic writes)
7
+ * formatModelOption, buildRoleOptions – picker UI helpers
8
+ * parseCustomModel – custom model string validator
9
+ * modelExists – registry membership check (guards typos)
10
+ * diffRoles – diff engine for preview screen
11
+ * formatRolesReport, formatDiffReport – read-only report formatters
12
+ * runInteractiveInit – full interactive UX flow
13
+ */
14
+
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import type { Api, Model } from "@earendil-works/pi-ai";
18
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
19
+ import type { ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
20
+ import { writeFileAtomic } from "./store.ts";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Role catalog
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface InitRole {
27
+ role: string;
28
+ description: string;
29
+ defaultModel: string;
30
+ /** Filter the model registry to models usable for this role. */
31
+ filter?: (m: Model<Api>) => boolean;
32
+ /** Sort tiebreaker after recommended-first. */
33
+ sort?: (a: Model<Api>, b: Model<Api>) => number;
34
+ /** Prefer `reasoning: true` models in display order. */
35
+ preferReasoning?: boolean;
36
+ }
37
+
38
+ export const INIT_ROLES: readonly InitRole[] = [
39
+ {
40
+ role: "fast",
41
+ description:
42
+ "Cheap & quick — high-volume, low-stakes tasks (executor, scout, recover, verifier, doc-writer, test-engineer)",
43
+ defaultModel: "openrouter/deepseek/deepseek-v4-flash",
44
+ },
45
+ {
46
+ role: "strong",
47
+ description:
48
+ "Balanced — planning, review, moderate complexity (planner, reviewer, executor-code)",
49
+ defaultModel: "openrouter/xiaomi/mimo-v2.5-pro",
50
+ },
51
+ {
52
+ role: "thinker",
53
+ description:
54
+ "Deep analysis — requirements, ambiguity detection, critique (analyst, critic)",
55
+ defaultModel: "openrouter/deepseek/deepseek-v4-pro",
56
+ preferReasoning: true,
57
+ sort: (a, b) => (a.reasoning === b.reasoning ? 0 : a.reasoning ? -1 : 1),
58
+ },
59
+ {
60
+ role: "arbiter",
61
+ description:
62
+ "Final judgment — tiebreak, plan quality gates (plan-arbiter, final-arbiter)",
63
+ defaultModel: "openrouter/qwen/qwen3.7-max",
64
+ preferReasoning: true,
65
+ sort: (a, b) => (a.reasoning === b.reasoning ? 0 : a.reasoning ? -1 : 1),
66
+ },
67
+ {
68
+ role: "vision",
69
+ description:
70
+ "Multimodal — UI work, design reading, Figma analysis (executor-ui, visual-explorer)",
71
+ defaultModel: "minimax/MiniMax-M3",
72
+ filter: (m) => m.input.includes("image"),
73
+ },
74
+ {
75
+ role: "reasoner",
76
+ description:
77
+ "Cautious reasoning — security, risk review, sensitive changes (risk-reviewer, security-reviewer)",
78
+ defaultModel: "z-ai/glm-5.1",
79
+ preferReasoning: true,
80
+ sort: (a, b) => (a.reasoning === b.reasoning ? 0 : a.reasoning ? -1 : 1),
81
+ },
82
+ ];
83
+
84
+ /** Derived from INIT_ROLES — the catalog is the single source of truth. */
85
+ export const RECOMMENDED_DEFAULTS: Readonly<Record<string, string>> = Object.fromEntries(
86
+ INIT_ROLES.map((r) => [r.role, r.defaultModel]),
87
+ );
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Settings path
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /** Returns the current settings.json path (respects PI_CODING_AGENT_DIR). */
94
+ export function getSettingsPath(): string {
95
+ return path.join(getAgentDir(), "settings.json");
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Settings I/O
100
+ // ---------------------------------------------------------------------------
101
+
102
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
103
+ return typeof v === "object" && v !== null && !Array.isArray(v);
104
+ }
105
+
106
+ export function readSettings(): Record<string, unknown> {
107
+ const sp = getSettingsPath();
108
+ if (!fs.existsSync(sp)) return {};
109
+ const raw: unknown = JSON.parse(fs.readFileSync(sp, "utf-8"));
110
+ if (!isPlainObject(raw)) return {};
111
+ if ("modelRoles" in raw) {
112
+ if (!isPlainObject(raw.modelRoles)) {
113
+ console.warn("[taskflow] settings.json: modelRoles had unexpected shape, treating as empty.");
114
+ raw.modelRoles = {};
115
+ }
116
+ }
117
+ return raw as Record<string, unknown>;
118
+ }
119
+
120
+ /**
121
+ * Write settings safely.
122
+ *
123
+ * Strategy: read current on-disk state, merge our changes on top, then
124
+ * write atomically. This preserves keys written by pi's own SettingsManager
125
+ * flusher between our read and write (last-write-wins, but we don't clobber
126
+ * unrelated keys). Additionally creates a timestamped backup before any
127
+ * write to a non-trivial settings file as a belt-and-suspenders safeguard.
128
+ */
129
+ export function writeSettings(incoming: Record<string, unknown>): string {
130
+ const sp = getSettingsPath();
131
+ let current: Record<string, unknown> = {};
132
+
133
+ // 1. Read current on-disk state (so we can merge, not replace)
134
+ if (fs.existsSync(sp)) {
135
+ try {
136
+ const raw: unknown = JSON.parse(fs.readFileSync(sp, "utf-8"));
137
+ if (isPlainObject(raw)) current = raw;
138
+ } catch {
139
+ /* proceed with empty fallback */
140
+ }
141
+ }
142
+
143
+ // 2. Pre-write backup — only for non-trivial files
144
+ const existingKeys = Object.keys(current);
145
+ if (existingKeys.length > 3) {
146
+ try {
147
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
148
+ fs.copyFileSync(sp, `${sp}.bak-tf-${stamp}`);
149
+ } catch {
150
+ /* backup is best-effort */
151
+ }
152
+ }
153
+
154
+ // 3. Merge: disk state + our overrides. This is the key safety property —
155
+ // unrelated keys (packages, subagents, UI prefs, etc.) survive even if
156
+ // pi's SettingsManager flushed between our read and write.
157
+ const merged = { ...current, ...incoming };
158
+ writeFileAtomic(sp, JSON.stringify(merged, null, 2) + "\n");
159
+ return sp;
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Picker helpers (pure, fully testable)
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /** Build a display label for a model in the picker. */
167
+ export function formatModelOption(m: Model<Api>): string {
168
+ const tags: string[] = [];
169
+ if (m.input.includes("image")) tags.push("image ✓");
170
+ if (m.reasoning) tags.push("reasoning ✓");
171
+ const tagStr = tags.length > 0 ? ` · ${tags.join(" · ")}` : "";
172
+ return `${m.name} (${m.provider}/${m.id})${tagStr}`;
173
+ }
174
+
175
+ /** Build picker options for a single role. */
176
+ export function buildRoleOptions(
177
+ role: InitRole,
178
+ available: ReadonlyArray<Model<Api>>,
179
+ ctx: { current?: string; recommended?: string },
180
+ ): string[] {
181
+ const recommendedId = ctx.recommended;
182
+ const pool = role.filter ? available.filter(role.filter) : [...available];
183
+ if (role.sort) pool.sort(role.sort);
184
+ else if (role.preferReasoning) pool.sort((a, b) => (a.reasoning === b.reasoning ? 0 : a.reasoning ? -1 : 1));
185
+
186
+ const seen = new Set<string>();
187
+ const options: string[] = [];
188
+ for (const m of pool) {
189
+ const key = `${m.provider}/${m.id}`;
190
+ if (seen.has(key)) continue;
191
+ seen.add(key);
192
+ const isCurrent = key === ctx.current;
193
+ const isRecommended = key === recommendedId;
194
+ const suffix = isCurrent
195
+ ? " · (current)"
196
+ : isRecommended
197
+ ? " · (recommended)"
198
+ : "";
199
+ options.push(`${formatModelOption(m)}${suffix}`);
200
+ }
201
+ options.push("───────────────");
202
+ options.push("Custom (type your own)");
203
+ if (ctx.current !== undefined) options.push("Keep current");
204
+ options.push("Back to action menu");
205
+ return options;
206
+ }
207
+
208
+ /** Parse a custom model string like "provider/model-id" or "provider/a/b/c". */
209
+ export function parseCustomModel(input: string): { provider: string; id: string } | null {
210
+ const trimmed = input.trim();
211
+ if (!trimmed) return null;
212
+ const slashIdx = trimmed.indexOf("/");
213
+ if (slashIdx < 0) return null;
214
+ const provider = trimmed.slice(0, slashIdx).trim();
215
+ const id = trimmed.slice(slashIdx + 1).trim();
216
+ if (!provider || !id) return null;
217
+ return { provider, id };
218
+ }
219
+
220
+ /**
221
+ * Returns true if `provider/id` exists in the available model registry.
222
+ * Used to warn before persisting a hand-typed model that would never resolve
223
+ * at runtime (e.g. a typo or a copy-pasted example string).
224
+ */
225
+ export function modelExists(
226
+ provider: string,
227
+ id: string,
228
+ available: ReadonlyArray<Model<Api>>,
229
+ ): boolean {
230
+ return available.some((m) => m.provider === provider && m.id === id);
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Diff engine for preview screen
235
+ // ---------------------------------------------------------------------------
236
+
237
+ export type RoleDiffStatus = "unchanged" | "changed" | "new" | "stale-preserved";
238
+
239
+ export interface RoleDiffEntry {
240
+ role: string;
241
+ status: RoleDiffStatus;
242
+ before?: string;
243
+ after?: string;
244
+ }
245
+
246
+ export function diffRoles(
247
+ before: Record<string, string>,
248
+ after: Record<string, string>,
249
+ catalog: ReadonlyArray<{ role: string }>,
250
+ ): RoleDiffEntry[] {
251
+ const seen = new Set<string>();
252
+ const diffs: RoleDiffEntry[] = [];
253
+ for (const c of catalog) {
254
+ seen.add(c.role);
255
+ const b = before[c.role];
256
+ const a = after[c.role];
257
+ if (b === undefined) {
258
+ diffs.push({ role: c.role, status: "new", after: a });
259
+ } else if (b === a) {
260
+ diffs.push({ role: c.role, status: "unchanged", before: b, after: a });
261
+ } else {
262
+ diffs.push({ role: c.role, status: "changed", before: b, after: a });
263
+ }
264
+ }
265
+ // Append stale keys from `before` that are not in catalog
266
+ for (const key of Object.keys(before)) {
267
+ if (!seen.has(key)) {
268
+ diffs.push({ role: key, status: "stale-preserved", before: before[key], after: before[key] });
269
+ }
270
+ }
271
+ return diffs;
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Read-only report formatters
276
+ // ---------------------------------------------------------------------------
277
+
278
+ function formatSettingsPath(sp: string): string {
279
+ const home = process.env.HOME ?? "";
280
+ if (home && sp.startsWith(home)) return `~${sp.slice(home.length)}`;
281
+ return sp;
282
+ }
283
+
284
+ export function formatRolesReport(current: Record<string, string>): string {
285
+ const sp = formatSettingsPath(getSettingsPath());
286
+ if (Object.keys(current).length === 0) {
287
+ return `No modelRoles configured in ${sp}. Use /tf init interactively to select models.`;
288
+ }
289
+ const lines = [`Model roles configured in ${sp}:`, ""];
290
+ for (const role of INIT_ROLES) {
291
+ const val = current[role.role];
292
+ if (val) lines.push(` ${role.role.padEnd(10)} → ${val} (${role.description})`);
293
+ }
294
+ // Append stale keys
295
+ for (const key of Object.keys(current)) {
296
+ if (!INIT_ROLES.some((r) => r.role === key)) {
297
+ lines.push(` ${key.padEnd(10)} → ${current[key]} (stale — not in current role catalog)`);
298
+ }
299
+ }
300
+ lines.push("", "To reconfigure, run /tf init interactively.");
301
+ return lines.join("\n");
302
+ }
303
+
304
+ const STATUS_SYMBOL: Record<RoleDiffStatus, string> = {
305
+ unchanged: " ",
306
+ changed: "↔ ",
307
+ new: "+ ",
308
+ "stale-preserved": "⚠ ",
309
+ };
310
+
311
+ export function formatDiffReport(
312
+ before: Record<string, string>,
313
+ after: Record<string, string>,
314
+ ): string {
315
+ const diffs = diffRoles(before, after, INIT_ROLES);
316
+ const sp = formatSettingsPath(getSettingsPath());
317
+ const lines = [`Wrote model roles to ${sp}:`, ""];
318
+ for (const d of diffs) {
319
+ const sym = STATUS_SYMBOL[d.status];
320
+ if (d.status === "unchanged") {
321
+ lines.push(` ${sym}${d.role.padEnd(10)} → ${d.after} (unchanged)`);
322
+ } else if (d.status === "changed") {
323
+ lines.push(` ${sym}${d.role.padEnd(10)} → ${d.after} (was: ${d.before})`);
324
+ } else if (d.status === "new") {
325
+ lines.push(` ${sym}${d.role.padEnd(10)} → ${d.after} (new)`);
326
+ } else if (d.status === "stale-preserved") {
327
+ lines.push(` ${sym}${d.role.padEnd(10)} → ${d.before} (stale — preserved but not in catalog)`);
328
+ }
329
+ }
330
+ return lines.join("\n");
331
+ }
332
+
333
+ export function formatFlowResult(result: InitFlowResult): string {
334
+ if (result.kind === "cancelled") return "Init cancelled.";
335
+ if (result.kind === "no-change") {
336
+ return (
337
+ "No changes.\n" +
338
+ Object.entries(result.chosen)
339
+ .map(([k, v]) => ` ${k.padEnd(10)} → ${v}`)
340
+ .join("\n")
341
+ );
342
+ }
343
+ // kind === "saved"
344
+ const savedPath = formatSettingsPath(result.savedPath);
345
+ return (
346
+ `Saved model roles to ${savedPath}:\n` +
347
+ Object.entries(result.chosen)
348
+ .map(([k, v]) => ` ${k.padEnd(10)} → ${v}`)
349
+ .join("\n")
350
+ );
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Main interactive flow
355
+ // ---------------------------------------------------------------------------
356
+
357
+ export type InitFlowResult =
358
+ | { kind: "saved"; chosen: Record<string, string>; savedPath: string }
359
+ | { kind: "no-change"; chosen: Record<string, string> }
360
+ | { kind: "cancelled" };
361
+
362
+ export async function runInteractiveInit(ctx: {
363
+ hasUI: boolean;
364
+ signal: AbortSignal;
365
+ ui: ExtensionUIContext;
366
+ modelRegistry: ExtensionContext["modelRegistry"];
367
+ modelList: Model<Api>[];
368
+ currentRoles: Record<string, string>;
369
+ }): Promise<InitFlowResult> {
370
+ if (!ctx.hasUI) {
371
+ throw new Error("runInteractiveInit requires an interactive session (hasUI=true).");
372
+ }
373
+
374
+ const recommended = RECOMMENDED_DEFAULTS;
375
+ const current = ctx.currentRoles;
376
+ const hasCurrent = Object.keys(current).length > 0;
377
+
378
+ // ---- Action menu ----
379
+ const actionOptions = hasCurrent
380
+ ? [
381
+ "Use recommended defaults",
382
+ "Configure each role",
383
+ "Edit one role",
384
+ "Show current roles",
385
+ "Cancel",
386
+ ]
387
+ : ["Use recommended defaults", "Configure each role"];
388
+
389
+ const action = await ctx.ui.select(
390
+ "What do you want to do with model roles?",
391
+ actionOptions,
392
+ { signal: ctx.signal },
393
+ );
394
+
395
+ if (action === undefined) return { kind: "cancelled" };
396
+
397
+ // ---- Use recommended defaults ----
398
+ if (action === "Use recommended defaults") {
399
+ const merged: Record<string, string> = { ...recommended };
400
+ for (const key of Object.keys(current)) {
401
+ if (!(key in merged)) merged[key] = current[key];
402
+ }
403
+ const diff = diffRoles(current, merged, INIT_ROLES);
404
+ const noChange = diff.every((d) => d.status === "unchanged" || d.status === "stale-preserved");
405
+ if (noChange) return { kind: "no-change", chosen: merged };
406
+ const savedPath = writeSettings({ ...readSettings(), modelRoles: merged });
407
+ return { kind: "saved", chosen: merged, savedPath };
408
+ }
409
+
410
+ // ---- Show current roles ----
411
+ if (action === "Show current roles") {
412
+ ctx.ui.notify(formatRolesReport(current), "info");
413
+ return { kind: "cancelled" };
414
+ }
415
+
416
+ // ---- Cancel ----
417
+ if (action === "Cancel") return { kind: "cancelled" };
418
+
419
+ // ---- Configure each role ----
420
+ if (action === "Configure each role") {
421
+ const chosen = await collectRolePicks(ctx, current, recommended, undefined);
422
+ if (chosen === undefined) return { kind: "cancelled" };
423
+ return finalizeOrPreview(ctx, current, chosen, recommended);
424
+ }
425
+
426
+ // ---- Edit one role ----
427
+ if (action === "Edit one role") {
428
+ const chosen = await collectSingleRoleEdit(ctx, current, recommended);
429
+ if (chosen === undefined) return { kind: "cancelled" };
430
+ return finalizeOrPreview(ctx, current, chosen, recommended);
431
+ }
432
+
433
+ return { kind: "cancelled" };
434
+ }
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Internal helpers
438
+ // ---------------------------------------------------------------------------
439
+
440
+ /** Collect picks for all roles. Returns undefined if user escapes to action menu. */
441
+ async function collectRolePicks(
442
+ ctx: { signal: AbortSignal; ui: ExtensionUIContext; modelList: Model<Api>[] },
443
+ current: Record<string, string>,
444
+ recommended: Record<string, string>,
445
+ startAtRole: string | undefined,
446
+ ): Promise<Record<string, string> | undefined> {
447
+ const chosen: Record<string, string> = { ...current };
448
+ let startIdx = 0;
449
+ if (startAtRole) {
450
+ const idx = INIT_ROLES.findIndex((r) => r.role === startAtRole);
451
+ if (idx >= 0) startIdx = idx;
452
+ }
453
+ for (let i = startIdx; i < INIT_ROLES.length; i++) {
454
+ const role = INIT_ROLES[i];
455
+ const val = await pickOneRole(ctx, role, current, recommended, chosen);
456
+ if (val === "back") return undefined; // back to action menu
457
+ if (val !== undefined) chosen[role.role] = val;
458
+ // val === undefined → keep existing (selected "Keep current")
459
+ }
460
+ return chosen;
461
+ }
462
+
463
+ /** Collect a single-role edit. Returns undefined if user escapes. */
464
+ async function collectSingleRoleEdit(
465
+ ctx: { signal: AbortSignal; ui: ExtensionUIContext; modelList: Model<Api>[] },
466
+ current: Record<string, string>,
467
+ recommended: Record<string, string>,
468
+ ): Promise<Record<string, string> | undefined> {
469
+ const chosen: Record<string, string> = { ...current };
470
+ const roleOptions = INIT_ROLES.map((r) => {
471
+ const cur = current[r.role];
472
+ const suffix = cur ? ` (current: ${cur})` : "";
473
+ return `${r.role} — ${r.description}${suffix}`;
474
+ });
475
+ roleOptions.push("───────────────");
476
+ roleOptions.push("Back to action menu");
477
+ const picked = await ctx.ui.select("Which role to edit?", roleOptions, {
478
+ signal: ctx.signal,
479
+ });
480
+ if (picked === undefined || picked === "Back to action menu") return undefined;
481
+ const roleName = picked.split(" — ")[0];
482
+ const role = INIT_ROLES.find((r) => r.role === roleName);
483
+ if (!role) return undefined;
484
+ const val = await pickOneRole(ctx, role, current, recommended, chosen);
485
+ if (val === "back") return undefined;
486
+ if (val !== undefined) chosen[role.role] = val;
487
+ return chosen;
488
+ }
489
+
490
+ /** Pick a model for one role. Returns "back" to signal exit, undefined for "keep current". */
491
+ async function pickOneRole(
492
+ ctx: { signal: AbortSignal; ui: ExtensionUIContext; modelList: Model<Api>[] },
493
+ role: InitRole,
494
+ current: Record<string, string>,
495
+ recommended: Record<string, string>,
496
+ _partialChosen: Record<string, string>,
497
+ ): Promise<string | "back" | undefined> {
498
+ const cur = current[role.role];
499
+ const options = buildRoleOptions(role, ctx.modelList, {
500
+ current: cur,
501
+ recommended: recommended[role.role],
502
+ });
503
+ const title =
504
+ `Model for '${role.role}' — ${role.description}` +
505
+ (cur !== undefined ? `\nCurrent: ${cur}` : "");
506
+ const pick = await ctx.ui.select(title, options, { signal: ctx.signal });
507
+
508
+ if (pick === undefined) return "back"; // Esc = back to action menu
509
+ if (pick === "Back to action menu") return "back";
510
+ if (pick === "───────────────") return cur ?? recommended[role.role];
511
+ if (pick === "Custom (type your own)") {
512
+ const custom = await ctx.ui.input(
513
+ `Enter model identifier for '${role.role}'`,
514
+ "provider/model-id",
515
+ { signal: ctx.signal },
516
+ );
517
+ if (custom === undefined) return cur ?? recommended[role.role];
518
+ const parsed = parseCustomModel(custom);
519
+ if (!parsed) return cur ?? recommended[role.role];
520
+ const full = `${parsed.provider}/${parsed.id}`;
521
+ // Guard: a hand-typed model that isn't in the registry will fail at
522
+ // runtime with "Model not found" and silently break every flow that
523
+ // uses this role. Require explicit confirmation before accepting it.
524
+ if (!modelExists(parsed.provider, parsed.id, ctx.modelList)) {
525
+ const keep = await ctx.ui.confirm(
526
+ `'${full}' is not in the model registry`,
527
+ `This model was not found and may fail at runtime with "Model not found".\n` +
528
+ `Use it anyway?`,
529
+ { signal: ctx.signal },
530
+ );
531
+ if (!keep) return cur ?? recommended[role.role];
532
+ }
533
+ return full;
534
+ }
535
+ if (pick === "Keep current") return undefined;
536
+ return parseModelFromLabel(pick);
537
+ }
538
+
539
+ /**
540
+ * Recover the `provider/id` from a picker label like "Name (provider/id) · tags".
541
+ * Greedy `.*` anchors to the LAST parenthesized group and the `/` requirement
542
+ * ensures we capture the provider/id — not a parenthesized part of the model's
543
+ * display name (e.g. "GPT-4o (2024-08-06) (openai/gpt-4o-2024-08-06)"). Falls
544
+ * back to the raw label when no provider/id group is present.
545
+ */
546
+ export function parseModelFromLabel(label: string): string {
547
+ const match = label.match(/.*\(([^)]+\/[^)]+)\)/);
548
+ return match ? match[1] : label;
549
+ }
550
+
551
+ /** Check if two role maps are semantically identical. */
552
+ function rolesIdentical(
553
+ a: Record<string, string>,
554
+ b: Record<string, string>,
555
+ ): boolean {
556
+ const keysA = Object.keys(a).sort();
557
+ const keysB = Object.keys(b).sort();
558
+ if (keysA.length !== keysB.length) return false;
559
+ return keysA.every((k) => a[k] === b[k]);
560
+ }
561
+
562
+ /** Run the preview/save flow. Returns the InitFlowResult. */
563
+ async function finalizeOrPreview(
564
+ ctx: { signal: AbortSignal; ui: ExtensionUIContext; modelList: Model<Api>[] },
565
+ current: Record<string, string>,
566
+ chosen: Record<string, string>,
567
+ recommended: Record<string, string>,
568
+ ): Promise<InitFlowResult> {
569
+ // Short-circuit: no change
570
+ if (rolesIdentical(current, chosen)) return { kind: "no-change", chosen };
571
+
572
+ // Preview screen
573
+ const diffs = diffRoles(current, chosen, INIT_ROLES);
574
+ const previewLines = ["Review changes:", ""];
575
+ for (const d of diffs) {
576
+ if (d.status === "unchanged") {
577
+ previewLines.push(` ${d.role.padEnd(10)} ${d.after ?? ""} (unchanged)`);
578
+ } else if (d.status === "changed") {
579
+ previewLines.push(` ${d.role.padEnd(10)} ${d.after ?? ""} (changed ← was: ${d.before})`);
580
+ } else if (d.status === "new") {
581
+ previewLines.push(` ${d.role.padEnd(10)} ${d.after ?? ""} (new)`);
582
+ } else if (d.status === "stale-preserved") {
583
+ previewLines.push(` ${d.role.padEnd(10)} ${d.before ?? ""} (stale — preserved)`);
584
+ }
585
+ }
586
+ const previewTitle = previewLines.join("\n");
587
+ const previewAction = await ctx.ui.select(
588
+ previewTitle,
589
+ ["Save these changes", "Edit a role", "Cancel"],
590
+ { signal: ctx.signal },
591
+ );
592
+
593
+ if (previewAction === "Save these changes") {
594
+ const settings = readSettings();
595
+ const merged = { ...settings, modelRoles: chosen };
596
+ const savedPath = writeSettings(merged);
597
+ return { kind: "saved", chosen, savedPath };
598
+ }
599
+ if (previewAction === "Cancel" || previewAction === undefined) {
600
+ return { kind: "cancelled" };
601
+ }
602
+ // "Edit a role" — jump back into per-role loop
603
+ const changedRole = diffs.find((d) => d.status === "changed")?.role ?? INIT_ROLES[0].role;
604
+ const reChosen = await collectRolePicks(ctx, current, recommended, changedRole);
605
+ if (reChosen === undefined) return { kind: "cancelled" };
606
+ return finalizeOrPreview(ctx, current, reChosen, recommended);
607
+ }
@@ -164,6 +164,14 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
164
164
  }
165
165
 
166
166
  // done
167
+ // Cross-run cache hit: show a compact badge with age and the $0 cost.
168
+ if (ps.cacheHit === "cross-run") {
169
+ const ageMs = ps.endedAt ? Date.now() - ps.endedAt : 0;
170
+ let c = theme.fg("success", "✓") + " " + theme.fg("toolOutput", theme.bold("CACHED")) + theme.fg("dim", " cross-run");
171
+ if (ageMs > 1500) c += theme.fg("dim", ` · ${elapsed(ageMs)} ago`);
172
+ if (ps.warnings?.length) c += theme.fg("warning", ` ⚠${ps.warnings.length}`);
173
+ return c;
174
+ }
167
175
  if (isFanout) {
168
176
  const { done = 0, total = 0, failed = 0 } = ps.subProgress ?? {};
169
177
  let s = theme.fg("success", `${total}✓`);
@@ -201,6 +209,37 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
201
209
  if (ps.warnings?.length) g += theme.fg("warning", ` ⚠${ps.warnings.length}`);
202
210
  return g;
203
211
  }
212
+ if (ps.loop) {
213
+ const stopLabel =
214
+ ps.loop.stop === "until"
215
+ ? theme.fg("success", "done")
216
+ : ps.loop.stop === "converged"
217
+ ? theme.fg("toolOutput", "converged")
218
+ : ps.loop.stop === "maxIterations"
219
+ ? theme.fg("warning", "max")
220
+ : theme.fg("error", "failed");
221
+ let l = theme.fg("toolTitle", `↻${ps.loop.iterations}`) + " " + stopLabel;
222
+ const cost = costStr(ps.usage, theme);
223
+ if (cost) l += ` ${cost}`;
224
+ if (time) l += ` ${time}`;
225
+ if (ps.warnings?.length) l += theme.fg("warning", ` ⚠${ps.warnings.length}`);
226
+ return l;
227
+ }
228
+ if (ps.tournament) {
229
+ const { variants, winner, mode } = ps.tournament;
230
+ let w =
231
+ theme.fg("toolTitle", `⚑ ${variants}→`) +
232
+ theme.fg("success", mode === "aggregate" ? "aggregate" : `#${winner}`);
233
+ if (ps.tournament.reason) {
234
+ const r = ps.tournament.reason.replace(/\s+/g, " ");
235
+ w += theme.fg("dim", ` ${r.length > 36 ? `${r.slice(0, 36)}…` : r}`);
236
+ }
237
+ const cost = costStr(ps.usage, theme);
238
+ if (cost) w += ` ${cost}`;
239
+ if (time) w += ` ${time}`;
240
+ if (ps.warnings?.length) w += theme.fg("warning", ` ⚠${ps.warnings.length}`);
241
+ return w;
242
+ }
204
243
  let s = roleLabel;
205
244
  if (cost) s += ` ${cost}`;
206
245
  if (ps.attempts && ps.attempts > 1) s += theme.fg("warning", ` ↻${ps.attempts - 1}`);