@ferris1225/pi-subagents 4.1.1 → 4.1.3

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/src/models.ts CHANGED
@@ -1,189 +1,189 @@
1
- /*
2
- * Model routing, capability-aware thinking, and setup-picker helpers.
3
- *
4
- * Runtime has one explicit fallback only: a configured agent model hands
5
- * off directly to the current main-window model. Setup lists only currently
6
- * available models and derives thinking choices from Pi's model metadata.
7
- */
8
-
9
- import {
10
- clampThinkingLevel,
11
- getSupportedThinkingLevels,
12
- type Api,
13
- type Model,
14
- } from "@earendil-works/pi-ai";
15
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
- import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
17
-
18
- export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
19
- Partial<Pick<ExtensionContext, "scopedModels">>;
20
-
21
- export const CURRENT_MAIN_MODEL = "__current_main_model__";
22
-
23
- export interface ModelPickerItem {
24
- value: string;
25
- label: string;
26
- description?: string;
27
- }
28
-
29
- export type ModelListEntry = Pick<
30
- Model<Api>,
31
- "provider" | "id" | "name" | "input" | "reasoning" | "thinkingLevelMap"
32
- >;
33
-
34
- export interface ResolvedAgentModelRoute {
35
- /** Effective first candidate. Undefined means let Pi use its normal default. */
36
- primaryRef?: string;
37
- /** Current main-window model when it differs from the selection. */
38
- mainFallbackRef?: string;
39
- /** Runtime order, useful for status/tests. */
40
- candidateRefs: string[];
41
- /** Configured ref skipped because Pi does not currently report it available. */
42
- unavailableSelectedRef?: string;
43
- }
44
-
45
- export interface AgentModelRouteInput {
46
- selectedRef?: string;
47
- mainRef?: string;
48
- declaredDefaultRef?: string;
49
- /** When supplied, a configured selection outside this live set is skipped. */
50
- availableRefs?: readonly string[];
51
- }
52
-
53
- function cleanModelRef(ref: string | undefined): string | undefined {
54
- const trimmed = ref?.trim();
55
- return trimmed || undefined;
56
- }
57
-
58
- export function modelRef(model: { provider: string; id: string }): string {
59
- return `${model.provider}/${model.id}`;
60
- }
61
-
62
- export function currentModelRef(ctx: Pick<ModelContext, "model">): string | undefined {
63
- return ctx.model ? modelRef(ctx.model) : undefined;
64
- }
65
-
66
- /**
67
- * Current authenticated registry models narrowed by the session scope. Scope
68
- * entries are a session snapshot, so they act only as a whitelist; the live
69
- * registry remains the source of truth for availability and model metadata.
70
- */
71
- export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
72
- const models = ctx.modelRegistry.getAvailable();
73
- // scopedModels was added after the original Pi minimum. Treat a missing field
74
- // exactly like an empty scope and use the full live registry.
75
- const scopedModels = ctx.scopedModels ?? [];
76
- if (scopedModels.length === 0) return models;
77
- const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
78
- return models.filter((model) => scopedRefs.has(modelRef(model)));
79
- }
80
-
81
- export function findModelByRef(
82
- models: readonly Model<Api>[],
83
- ref: string | undefined,
84
- ): Model<Api> | undefined {
85
- const normalized = cleanModelRef(ref);
86
- return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
87
- }
88
-
89
- /**
90
- * Resolve one agent's runtime route:
91
- *
92
- * configured selection -> current main-window model
93
- *
94
- * Without an override, current main is primary; the agent-declared default is
95
- * used only when no main model exists. A configured selection that Pi no longer
96
- * reports as available is skipped immediately instead of spawning a doomed child.
97
- */
98
- export function resolveAgentModelRoute(input: AgentModelRouteInput): ResolvedAgentModelRoute {
99
- const selectedRef = cleanModelRef(input.selectedRef);
100
- const mainRef = cleanModelRef(input.mainRef);
101
- const declaredDefaultRef = cleanModelRef(input.declaredDefaultRef);
102
- const available = input.availableRefs
103
- ? new Set(input.availableRefs.map((ref) => ref.trim()).filter(Boolean))
104
- : undefined;
105
- const selectedAvailable = !selectedRef || !available || available.has(selectedRef);
106
- const usableSelectedRef = selectedAvailable ? selectedRef : undefined;
107
- const primaryRef = usableSelectedRef ?? mainRef ?? declaredDefaultRef;
108
- const ordered = [primaryRef, usableSelectedRef && usableSelectedRef !== mainRef ? mainRef : undefined];
109
- const candidateRefs = [...new Set(ordered.filter((ref): ref is string => Boolean(ref)))];
110
- return {
111
- primaryRef,
112
- ...(candidateRefs[1] ? { mainFallbackRef: candidateRefs[1] } : {}),
113
- candidateRefs,
114
- ...(!selectedAvailable && selectedRef ? { unavailableSelectedRef: selectedRef } : {}),
115
- };
116
- }
117
-
118
- /** The exact levels Pi exposes for this model, including `off` when supported. */
119
- export function supportedThinkingLevels(model: Model<Api> | undefined): ThinkingLevel[] {
120
- return model ? (getSupportedThinkingLevels(model) as ThinkingLevel[]) : [];
121
- }
122
-
123
- /** Clamp an agent preference to the effective model's actual capability map. */
124
- export function resolveThinkingLevel(
125
- model: Model<Api> | undefined,
126
- preferred: ThinkingLevel = DEFAULT_THINKING_LEVEL,
127
- ): ThinkingLevel {
128
- return model ? (clampThinkingLevel(model, preferred) as ThinkingLevel) : preferred;
129
- }
130
-
131
- function modelCapabilities(model: ModelListEntry): string {
132
- const input = model.input.includes("image") ? "vision" : "text-only";
133
- const thinking = getSupportedThinkingLevels(model as Model<Api>).join("/");
134
- return `${input} · thinking: ${thinking}`;
135
- }
136
-
137
- /** Build one searchable list for agent model selection. Only models Pi
138
- * currently reports as available are supplied by setup. */
139
- export function buildModelPickerItems(options: {
140
- models: readonly ModelListEntry[];
141
- configuredRef?: string;
142
- mainRef?: string;
143
- }): ModelPickerItem[] {
144
- const configuredRef = cleanModelRef(options.configuredRef);
145
- const mainRef = cleanModelRef(options.mainRef);
146
- const byRef = new Map<string, ModelListEntry>();
147
- for (const model of options.models) {
148
- const ref = modelRef(model);
149
- if (!byRef.has(ref)) byRef.set(ref, model);
150
- }
151
-
152
- const refs = [...byRef.keys()]
153
- .sort((left, right) => {
154
- const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
155
- const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
156
- return leftRank - rightRank || left.localeCompare(right);
157
- });
158
-
159
- const dynamic: ModelPickerItem = {
160
- value: CURRENT_MAIN_MODEL,
161
- label: "Current main model (dynamic)",
162
- description: "Clear agent override; use the current main model dynamically",
163
- };
164
- const items: ModelPickerItem[] = [dynamic];
165
- for (const ref of refs) {
166
- const model = byRef.get(ref)!;
167
- const tags = [ref === configuredRef ? "configured" : "", ref === mainRef ? "current main" : ""]
168
- .filter(Boolean);
169
- const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
170
- items.push({
171
- value: ref,
172
- label: ref,
173
- description: [name, modelCapabilities(model), ...tags].filter(Boolean).join(" · "),
174
- });
175
- }
176
- return items;
177
- }
178
-
179
- /** The dynamic choice removes the persisted per-agent override. */
180
- export function applyAgentModelChoice(
181
- current: Record<string, string>,
182
- agentName: string,
183
- choice: string,
184
- ): Record<string, string> {
185
- const next = { ...current };
186
- if (choice === CURRENT_MAIN_MODEL) delete next[agentName];
187
- else next[agentName] = choice.trim();
188
- return next;
189
- }
1
+ /*
2
+ * Model routing, capability-aware thinking, and setup-picker helpers.
3
+ *
4
+ * Runtime has one explicit fallback only: a configured agent model hands
5
+ * off directly to the current main-window model. Setup lists only currently
6
+ * available models and derives thinking choices from Pi's model metadata.
7
+ */
8
+
9
+ import {
10
+ clampThinkingLevel,
11
+ getSupportedThinkingLevels,
12
+ type Api,
13
+ type Model,
14
+ } from "@earendil-works/pi-ai";
15
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
17
+
18
+ export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
19
+ Partial<Pick<ExtensionContext, "scopedModels">>;
20
+
21
+ export const CURRENT_MAIN_MODEL = "__current_main_model__";
22
+
23
+ export interface ModelPickerItem {
24
+ value: string;
25
+ label: string;
26
+ description?: string;
27
+ }
28
+
29
+ export type ModelListEntry = Pick<
30
+ Model<Api>,
31
+ "provider" | "id" | "name" | "input" | "reasoning" | "thinkingLevelMap"
32
+ >;
33
+
34
+ export interface ResolvedAgentModelRoute {
35
+ /** Effective first candidate. Undefined means let Pi use its normal default. */
36
+ primaryRef?: string;
37
+ /** Current main-window model when it differs from the selection. */
38
+ mainFallbackRef?: string;
39
+ /** Runtime order, useful for status/tests. */
40
+ candidateRefs: string[];
41
+ /** Configured ref skipped because Pi does not currently report it available. */
42
+ unavailableSelectedRef?: string;
43
+ }
44
+
45
+ export interface AgentModelRouteInput {
46
+ selectedRef?: string;
47
+ mainRef?: string;
48
+ declaredDefaultRef?: string;
49
+ /** When supplied, a configured selection outside this live set is skipped. */
50
+ availableRefs?: readonly string[];
51
+ }
52
+
53
+ function cleanModelRef(ref: string | undefined): string | undefined {
54
+ const trimmed = ref?.trim();
55
+ return trimmed || undefined;
56
+ }
57
+
58
+ export function modelRef(model: { provider: string; id: string }): string {
59
+ return `${model.provider}/${model.id}`;
60
+ }
61
+
62
+ export function currentModelRef(ctx: Pick<ModelContext, "model">): string | undefined {
63
+ return ctx.model ? modelRef(ctx.model) : undefined;
64
+ }
65
+
66
+ /**
67
+ * Current authenticated registry models narrowed by the session scope. Scope
68
+ * entries are a session snapshot, so they act only as a whitelist; the live
69
+ * registry remains the source of truth for availability and model metadata.
70
+ */
71
+ export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
72
+ const models = ctx.modelRegistry.getAvailable();
73
+ // scopedModels was added after the original Pi minimum. Treat a missing field
74
+ // exactly like an empty scope and use the full live registry.
75
+ const scopedModels = ctx.scopedModels ?? [];
76
+ if (scopedModels.length === 0) return models;
77
+ const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
78
+ return models.filter((model) => scopedRefs.has(modelRef(model)));
79
+ }
80
+
81
+ export function findModelByRef(
82
+ models: readonly Model<Api>[],
83
+ ref: string | undefined,
84
+ ): Model<Api> | undefined {
85
+ const normalized = cleanModelRef(ref);
86
+ return normalized ? models.find((model) => modelRef(model) === normalized) : undefined;
87
+ }
88
+
89
+ /**
90
+ * Resolve one agent's runtime route:
91
+ *
92
+ * configured selection -> current main-window model
93
+ *
94
+ * Without an override, current main is primary; the agent-declared default is
95
+ * used only when no main model exists. A configured selection that Pi no longer
96
+ * reports as available is skipped immediately instead of spawning a doomed child.
97
+ */
98
+ export function resolveAgentModelRoute(input: AgentModelRouteInput): ResolvedAgentModelRoute {
99
+ const selectedRef = cleanModelRef(input.selectedRef);
100
+ const mainRef = cleanModelRef(input.mainRef);
101
+ const declaredDefaultRef = cleanModelRef(input.declaredDefaultRef);
102
+ const available = input.availableRefs
103
+ ? new Set(input.availableRefs.map((ref) => ref.trim()).filter(Boolean))
104
+ : undefined;
105
+ const selectedAvailable = !selectedRef || !available || available.has(selectedRef);
106
+ const usableSelectedRef = selectedAvailable ? selectedRef : undefined;
107
+ const primaryRef = usableSelectedRef ?? mainRef ?? declaredDefaultRef;
108
+ const ordered = [primaryRef, usableSelectedRef && usableSelectedRef !== mainRef ? mainRef : undefined];
109
+ const candidateRefs = [...new Set(ordered.filter((ref): ref is string => Boolean(ref)))];
110
+ return {
111
+ primaryRef,
112
+ ...(candidateRefs[1] ? { mainFallbackRef: candidateRefs[1] } : {}),
113
+ candidateRefs,
114
+ ...(!selectedAvailable && selectedRef ? { unavailableSelectedRef: selectedRef } : {}),
115
+ };
116
+ }
117
+
118
+ /** The exact levels Pi exposes for this model, including `off` when supported. */
119
+ export function supportedThinkingLevels(model: Model<Api> | undefined): ThinkingLevel[] {
120
+ return model ? (getSupportedThinkingLevels(model) as ThinkingLevel[]) : [];
121
+ }
122
+
123
+ /** Clamp an agent preference to the effective model's actual capability map. */
124
+ export function resolveThinkingLevel(
125
+ model: Model<Api> | undefined,
126
+ preferred: ThinkingLevel = DEFAULT_THINKING_LEVEL,
127
+ ): ThinkingLevel {
128
+ return model ? (clampThinkingLevel(model, preferred) as ThinkingLevel) : preferred;
129
+ }
130
+
131
+ function modelCapabilities(model: ModelListEntry): string {
132
+ const input = model.input.includes("image") ? "vision" : "text-only";
133
+ const thinking = getSupportedThinkingLevels(model as Model<Api>).join("/");
134
+ return `${input} · thinking: ${thinking}`;
135
+ }
136
+
137
+ /** Build one searchable list for agent model selection. Only models Pi
138
+ * currently reports as available are supplied by setup. */
139
+ export function buildModelPickerItems(options: {
140
+ models: readonly ModelListEntry[];
141
+ configuredRef?: string;
142
+ mainRef?: string;
143
+ }): ModelPickerItem[] {
144
+ const configuredRef = cleanModelRef(options.configuredRef);
145
+ const mainRef = cleanModelRef(options.mainRef);
146
+ const byRef = new Map<string, ModelListEntry>();
147
+ for (const model of options.models) {
148
+ const ref = modelRef(model);
149
+ if (!byRef.has(ref)) byRef.set(ref, model);
150
+ }
151
+
152
+ const refs = [...byRef.keys()]
153
+ .sort((left, right) => {
154
+ const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
155
+ const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
156
+ return leftRank - rightRank || left.localeCompare(right);
157
+ });
158
+
159
+ const dynamic: ModelPickerItem = {
160
+ value: CURRENT_MAIN_MODEL,
161
+ label: "Current main model (dynamic)",
162
+ description: "Clear agent override; use the current main model dynamically",
163
+ };
164
+ const items: ModelPickerItem[] = [dynamic];
165
+ for (const ref of refs) {
166
+ const model = byRef.get(ref)!;
167
+ const tags = [ref === configuredRef ? "configured" : "", ref === mainRef ? "current main" : ""]
168
+ .filter(Boolean);
169
+ const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
170
+ items.push({
171
+ value: ref,
172
+ label: ref,
173
+ description: [name, modelCapabilities(model), ...tags].filter(Boolean).join(" · "),
174
+ });
175
+ }
176
+ return items;
177
+ }
178
+
179
+ /** The dynamic choice removes the persisted per-agent override. */
180
+ export function applyAgentModelChoice(
181
+ current: Record<string, string>,
182
+ agentName: string,
183
+ choice: string,
184
+ ): Record<string, string> {
185
+ const next = { ...current };
186
+ if (choice === CURRENT_MAIN_MODEL) delete next[agentName];
187
+ else next[agentName] = choice.trim();
188
+ return next;
189
+ }
package/src/monitor.ts CHANGED
@@ -19,6 +19,7 @@ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
21
  export type RunStatus = "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed";
22
+ export type ContinuationKind = "resume-retained" | "resume-appended" | "fork-retained" | "fork-appended" | "retarget";
22
23
 
23
24
  export function isRunActiveStatus(status: RunStatus): boolean {
24
25
  return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
@@ -45,25 +46,35 @@ export interface RunView {
45
46
  usage: UsageStats;
46
47
  /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
47
48
  activity?: string;
48
- /** Epoch ms when the run started executing (set on first "running" status). */
49
+ /** Epoch ms when this logical run first started executing. */
49
50
  startedAt?: number;
50
- /** Epoch ms when the run finished (set on "done"/"failed"). */
51
+ /** Epoch ms when the current active segment started. */
52
+ activeSince?: number;
53
+ /** Cumulative active execution time from closed segments; parked time is excluded. */
54
+ elapsedMs: number;
55
+ /** Epoch ms when the latest active segment stopped. */
51
56
  endedAt?: number;
52
- /** When set, this run belongs to an auto-fix chain (e.g. worker fixing a reviewer's findings). */
57
+ /** Why this generation reused retained context, shown in the widget/status. */
58
+ continuationKind?: ContinuationKind;
59
+ /** When set, this is an internal managed-workflow step. */
53
60
  groupId?: string;
54
61
  /** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
55
62
  relationLabel?: string;
56
- /** Owning run for chain children: the triggering reviewer whose row represents the chain. */
63
+ /** Stable owning run whose row represents the whole managed workflow. */
57
64
  parentRunId?: number;
65
+ /** This stable top-level row currently owns a multi-stage managed workflow.
66
+ * Its elapsed time is workflow-wide; active child rows own stage telemetry. */
67
+ managedWorkflow?: boolean;
58
68
  }
59
69
 
60
- /** Optional chain metadata for runs spawned by an auto-fix loop. */
70
+ /** Optional metadata for documenter/reviewer/fix children of a stable parent run. */
61
71
  export interface RunChainMeta {
62
72
  groupId?: string;
63
73
  relationLabel?: string;
64
74
  parentRunId?: number;
65
75
  isolation?: IsolationMode;
66
76
  forkedFromRunId?: number;
77
+ continuationKind?: ContinuationKind;
67
78
  }
68
79
 
69
80
  // ---------------------------------------------------------------------------
@@ -276,10 +287,32 @@ export function formatDuration(ms: number): string {
276
287
  return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
277
288
  }
278
289
 
279
- /** Elapsed wall time of a run: live while running, final once finished. */
290
+ /** Cumulative active time across generations; parked gaps never count. */
291
+ export function elapsedMilliseconds(run: RunView, now: number = Date.now()): number {
292
+ let elapsed = run.elapsedMs ?? 0;
293
+ if (run.activeSince !== undefined) elapsed += Math.max(0, now - run.activeSince);
294
+ // Keep formatting tolerant of older/synthetic RunView values that predate
295
+ // segmented timing and carry only startedAt/endedAt.
296
+ if (elapsed === 0 && run.startedAt !== undefined && run.activeSince === undefined) {
297
+ elapsed = Math.max(0, (run.endedAt ?? now) - run.startedAt);
298
+ }
299
+ return elapsed;
300
+ }
301
+
280
302
  export function formatElapsed(run: RunView, now: number = Date.now()): string {
281
- if (run.startedAt === undefined) return "";
282
- return formatDuration((run.endedAt ?? now) - run.startedAt);
303
+ if (run.startedAt === undefined && run.elapsedMs <= 0) return "";
304
+ return formatDuration(elapsedMilliseconds(run, now));
305
+ }
306
+
307
+ export function continuationLabel(kind: ContinuationKind | undefined, sourceRunId?: number): string | undefined {
308
+ switch (kind) {
309
+ case "resume-retained": return "resume: current objective";
310
+ case "resume-appended": return "resume: appended objective";
311
+ case "fork-retained": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: current objective`;
312
+ case "fork-appended": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: appended objective`;
313
+ case "retarget": return "retarget: replacement objective";
314
+ default: return undefined;
315
+ }
283
316
  }
284
317
 
285
318
  /** Max length of the argument target inside a formatted activity line. */
@@ -409,11 +442,13 @@ export class MonitorStore {
409
442
  thinking,
410
443
  status: "queued",
411
444
  usage: emptyUsage(),
445
+ elapsedMs: 0,
412
446
  ...(meta?.groupId ? { groupId: meta.groupId } : {}),
413
447
  ...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
414
448
  ...(meta?.parentRunId !== undefined ? { parentRunId: meta.parentRunId } : {}),
415
449
  ...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
416
450
  ...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
451
+ ...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
417
452
  });
418
453
  this.notify();
419
454
  return id;
@@ -422,17 +457,33 @@ export class MonitorStore {
422
457
  setStatus(id: number, status: RunStatus): void {
423
458
  const run = this.find(id);
424
459
  if (!run) return;
460
+ const previousStatus = run.status;
461
+ const wasExecuting = previousStatus === "running" || previousStatus === "steering" || previousStatus === "interrupting";
462
+ const isExecuting = status === "running" || status === "steering" || status === "interrupting";
463
+ const now = Date.now();
425
464
  run.status = status;
426
- if (status === "running" || status === "steering" || status === "interrupting") {
427
- if (run.startedAt === undefined) run.startedAt = Date.now();
428
- // A selected-to-main handoff or resumed generation restarts the clock; a
429
- // stale endedAt would freeze the elapsed display at the first attempt.
430
- if (run.endedAt !== undefined) run.endedAt = undefined;
431
- } else if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
432
- run.endedAt = Date.now();
465
+ if (isExecuting && !wasExecuting) {
466
+ run.startedAt ??= now;
467
+ run.activeSince = now;
468
+ run.endedAt = undefined;
469
+ } else if (!isExecuting && wasExecuting && run.activeSince !== undefined) {
470
+ run.elapsedMs += Math.max(0, now - run.activeSince);
471
+ run.activeSince = undefined;
472
+ }
473
+ if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
474
+ run.endedAt = now;
433
475
  }
434
476
  this.notify();
435
477
  }
478
+ /** Switch a stable top-level row from one model run to workflow ownership.
479
+ * The original role remains for identity; child rows show stage telemetry. */
480
+ setManagedWorkflow(id: number, active: boolean): void {
481
+ const run = this.find(id);
482
+ if (!run) return;
483
+ run.managedWorkflow = active || undefined;
484
+ this.notify();
485
+ }
486
+
436
487
  setUsage(id: number, usage: UsageStats, model?: string): void {
437
488
  const run = this.find(id);
438
489
  if (!run) return;
@@ -512,8 +563,29 @@ export class MonitorStore {
512
563
  this.notify();
513
564
  }
514
565
 
515
- /** Reuse a stable logical run id for a resumed generation. */
516
- restartRun(id: number, agent: string, task: string, model?: string, thinking?: string, isolation?: IsolationMode): void {
566
+ setContinuationKind(id: number, kind: ContinuationKind): void {
567
+ const run = this.find(id);
568
+ if (!run) return;
569
+ run.continuationKind = kind;
570
+ this.notify();
571
+ }
572
+
573
+ getElapsedMs(id: number, now: number = Date.now()): number | undefined {
574
+ const run = this.find(id);
575
+ return run ? elapsedMilliseconds(run, now) : undefined;
576
+ }
577
+
578
+ /** Reuse a stable logical run id for a resumed generation without discarding
579
+ * active time accumulated by earlier generations. */
580
+ restartRun(
581
+ id: number,
582
+ agent: string,
583
+ task: string,
584
+ model?: string,
585
+ thinking?: string,
586
+ isolation?: IsolationMode,
587
+ meta?: { elapsedMs?: number; continuationKind?: ContinuationKind },
588
+ ): void {
517
589
  const run = this.find(id);
518
590
  if (!run) {
519
591
  this.runs.push({
@@ -526,6 +598,8 @@ export class MonitorStore {
526
598
  ...(isolation ? { isolation, integrationStatus: isolation === "worktree" ? "pending" as const : undefined } : {}),
527
599
  status: "queued",
528
600
  usage: emptyUsage(),
601
+ elapsedMs: meta?.elapsedMs ?? 0,
602
+ continuationKind: meta?.continuationKind,
529
603
  });
530
604
  this.notify();
531
605
  return;
@@ -540,8 +614,11 @@ export class MonitorStore {
540
614
  run.status = "queued";
541
615
  run.usage = emptyUsage();
542
616
  run.activity = undefined;
543
- run.startedAt = undefined;
617
+ run.managedWorkflow = undefined;
618
+ run.activeSince = undefined;
544
619
  run.endedAt = undefined;
620
+ run.elapsedMs = Math.max(run.elapsedMs, meta?.elapsedMs ?? 0);
621
+ run.continuationKind = meta?.continuationKind;
545
622
  this.notify();
546
623
  }
547
624
 
@@ -580,12 +657,14 @@ export class MonitorStore {
580
657
 
581
658
  summarize(run: RunView): string {
582
659
  const usage = formatUsageCompact(run.usage);
583
- const parts = [run.agent];
660
+ const parts = [run.managedWorkflow ? `${run.agent} workflow` : run.agent];
661
+ const continuation = continuationLabel(run.continuationKind, run.forkedFromRunId);
662
+ if (continuation) parts.push(continuation);
584
663
  if (run.relationLabel) parts.push(run.relationLabel);
585
- if (run.model) parts.push(run.model);
586
- if (run.thinking) parts.push(`thinking ${run.thinking}`);
664
+ if (!run.managedWorkflow && run.model) parts.push(run.model);
665
+ if (!run.managedWorkflow && run.thinking) parts.push(`thinking ${run.thinking}`);
587
666
  if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
588
- if (usage) parts.push(usage);
667
+ if (!run.managedWorkflow && usage) parts.push(usage);
589
668
  const elapsed = formatElapsed(run);
590
669
  if (elapsed) parts.push(elapsed);
591
670
  return parts.join(" · ");