@jameslovespancakes/pi-plus 1.0.20 → 1.0.21

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.
Files changed (34) hide show
  1. package/README.md +21 -3
  2. package/package.json +1 -1
  3. package/src/domains/workflows/index.ts +56 -104
  4. package/src/domains/workflows/runtime/advisory-challenge.ts +3 -3
  5. package/src/domains/workflows/runtime/agent-attempt.ts +3 -3
  6. package/src/domains/workflows/runtime/agent-options.ts +18 -0
  7. package/src/domains/workflows/runtime/agent-runner-types.ts +7 -3
  8. package/src/domains/workflows/runtime/agent-runner.ts +14 -6
  9. package/src/domains/workflows/runtime/agent-session.ts +34 -3
  10. package/src/domains/workflows/runtime/cancellation.ts +5 -0
  11. package/src/domains/workflows/runtime/engine.ts +19 -40
  12. package/src/domains/workflows/runtime/journal.ts +4 -4
  13. package/src/domains/workflows/runtime/live-agent.ts +37 -0
  14. package/src/domains/workflows/runtime/model-profiles.ts +2 -6
  15. package/src/domains/workflows/runtime/progress-types.ts +3 -1
  16. package/src/domains/workflows/runtime/progress.ts +54 -14
  17. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +3 -3
  18. package/src/domains/workflows/runtime/types.ts +16 -15
  19. package/src/domains/workflows/runtime/ui/agent-transcript.ts +59 -0
  20. package/src/domains/workflows/runtime/ui/workflow-format.ts +6 -2
  21. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +75 -61
  22. package/src/domains/workflows/runtime/ui/workflow-widget.ts +26 -66
  23. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +5 -5
  24. package/src/domains/workflows/runtime/{background-workflows.ts → workflow-lifecycle.ts} +91 -75
  25. package/src/domains/workflows/runtime/workflow-management.ts +66 -0
  26. package/src/domains/workflows/runtime/workflow-run-controller.ts +16 -20
  27. package/src/domains/workflows/runtime/{workflow-run-background.ts → workflow-run-delivery.ts} +3 -3
  28. package/src/domains/workflows/runtime/workflow-run-record.ts +7 -4
  29. package/src/domains/workflows/workflows/code-review.ts +1 -1
  30. package/src/domains/workflows/workflows/diagnose.ts +1 -1
  31. package/src/domains/workflows/workflows/perf-review.ts +1 -1
  32. package/src/domains/workflows/workflows/refactor-scout.ts +1 -1
  33. package/src/domains/workflows/workflows/research.ts +4 -4
  34. package/src/domains/workflows/runtime/background-workflow-tool.ts +0 -75
@@ -3,6 +3,7 @@ import type { Static, TSchema } from "typebox";
3
3
  import { bindParallel, bindPipeline, Semaphore } from "./concurrency.ts";
4
4
  import { WorkflowAgentLimiter } from "./agent-limits.ts";
5
5
  import { defaultAgentRetryScheduler, type AgentRetryScheduler } from "./agent-retry.ts";
6
+ import { assertAgentOptions } from "./agent-options.ts";
6
7
  import { resolveWorkflowModelProfiles, type ResolvedWorkflowModelProfiles } from "./model-profiles.ts";
7
8
  import { abortReason, isWorkflowPauseError, linkAbortSignal, throwIfAborted } from "./cancellation.ts";
8
9
  import { createBudget } from "./budget.ts";
@@ -15,8 +16,6 @@ import {
15
16
  type ResolvedWorkflowRunOptions,
16
17
  } from "./options.ts";
17
18
  import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
18
- import { WorkflowInspector } from "./ui/workflow-inspector.ts";
19
- import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./ui/workflow-viewer-layout.ts";
20
19
  import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
21
20
  import { WorktreeRegistry } from "./worktree.ts";
22
21
  import { runFinalizers } from "./finalizers.ts";
@@ -101,7 +100,9 @@ export async function runResolvedWorkflow(
101
100
  const progressSource = {
102
101
  snapshot: () => progress.snapshot(),
103
102
  conversation: (agentId: number) => progress.conversation(agentId),
104
- followUp: (agentId: number, message: string) => progress.followUp(agentId, message),
103
+ stopAgent: (agentId: number) => progress.stopAgent(agentId),
104
+ transcript: (agentId: number) => progress.transcript(agentId),
105
+ followUp: (agentId: number, message: string, steer?: boolean) => progress.followUp(agentId, message, steer),
105
106
  subscribe: (listener: () => void) => progress.subscribe(listener),
106
107
  };
107
108
  const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
@@ -133,33 +134,6 @@ export async function runResolvedWorkflow(
133
134
  const unlinkOptionAbortSignal = linkAbortSignal(resolvedOptions.signal, runAbortController);
134
135
  const workflowOutcome = await captureOutcome(async () => {
135
136
  await notifyLifecycleObserver(progress, "progress source callback", () => resolvedOptions.onProgressSource?.(progressSource));
136
- if (resolvedOptions.inspect && ctx.hasUI && ctx.mode === "tui") {
137
- let unsubscribe: (() => void) | undefined;
138
- void ctx.ui
139
- .custom<void>(
140
- (tui, theme, _keybindings, done) => {
141
- unsubscribe = progressSource.subscribe(() => tui.requestRender());
142
- return new WorkflowInspector(
143
- () => progress.snapshot(),
144
- tui,
145
- theme,
146
- () => done(undefined),
147
- undefined,
148
- progressSource,
149
- );
150
- },
151
- WORKFLOW_VIEWER_OVERLAY_OPTIONS,
152
- )
153
- .catch((error: unknown) => {
154
- try {
155
- progress.log(`inspector failed: ${unknownErrorMessage(error)}`);
156
- } catch {
157
- // Inspector reporting is detached and must never become another rejection.
158
- }
159
- })
160
- .finally(() => unsubscribe?.());
161
- }
162
-
163
137
  const journal = await createWorkflowJournal({ resumePath, writePath: journalPath });
164
138
  durableRun.transition({ state: "running", progress: progress.snapshot() });
165
139
  await durableRun.flush().catch(() => undefined);
@@ -185,7 +159,7 @@ export async function runResolvedWorkflow(
185
159
  agentLimiter: new WorkflowAgentLimiter(resolvedOptions.maxAgents),
186
160
  agentTimeoutMs: resolvedOptions.agentTimeoutMs,
187
161
  agentRetries: resolvedOptions.agentRetries,
188
- pauseOnProviderUsageLimit: resolvedOptions.background !== undefined,
162
+ pauseOnProviderUsageLimit: resolvedOptions.origin !== undefined,
189
163
  resumeEditedWorkflow: resolvedOptions.resumeEditedWorkflow,
190
164
  retryScheduler: dependencies.retryScheduler ?? defaultAgentRetryScheduler,
191
165
  modelProfiles,
@@ -214,11 +188,8 @@ export async function runResolvedWorkflow(
214
188
  ? undefined
215
189
  : backgroundPauseError(workflowOutcome.error, ctx.signal, resolvedOptions.signal);
216
190
  const willPauseWorkflow = workflowPause !== undefined
217
- && (!(workflowPause instanceof WorkflowProviderUsageLimitError) || resolvedOptions.background !== undefined);
218
- const preserveFailedWorktrees = !workflowOutcome.ok
219
- && !willPauseWorkflow
220
- && !ctx.signal?.aborted
221
- && !resolvedOptions.signal?.aborted;
191
+ && (!(workflowPause instanceof WorkflowProviderUsageLimitError) || resolvedOptions.origin !== undefined);
192
+ const preserveFailedWorktrees = !workflowOutcome.ok && !willPauseWorkflow;
222
193
  if (preserveFailedWorktrees) worktrees.preserveRecoverable();
223
194
  const finalizationOutcome = await captureOutcome(() =>
224
195
  finalizeWorkflowRun({
@@ -229,7 +200,7 @@ export async function runResolvedWorkflow(
229
200
  progress,
230
201
  runStore,
231
202
  worktrees,
232
- preserveWorktrees: preserveFailedWorktrees,
203
+ preserveWorktrees: preserveFailedWorktrees || worktrees.preservedPaths.length > 0,
233
204
  unlinkSignals: [unlinkContextAbortSignal, unlinkOptionAbortSignal],
234
205
  }),
235
206
  );
@@ -259,7 +230,7 @@ export async function runResolvedWorkflow(
259
230
  const pauseError = backgroundPauseError(pauseCandidate, ctx.signal, resolvedOptions.signal);
260
231
  if (pauseError) {
261
232
  if (pauseError instanceof WorkflowProviderUsageLimitError) {
262
- if (resolvedOptions.background === undefined) {
233
+ if (resolvedOptions.origin === undefined) {
263
234
  durableRun.transition({
264
235
  state: "failed",
265
236
  progress: progress.snapshot(),
@@ -428,6 +399,7 @@ function createWorkflowAgent(
428
399
  function agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
429
400
  function agent(prompt: string, opts?: AgentOptions): Promise<string>;
430
401
  function agent(prompt: string, agentOpts?: AgentOptions): Promise<unknown> {
402
+ assertAgentOptions(agentOpts);
431
403
  const scopedOptions = scope.agentOptions(agentOpts);
432
404
  const executionOptions: AgentExecutionOptions =
433
405
  scopedOptions.isolation === "worktree" && mod.isolatedWorktreeBaseline !== undefined
@@ -486,6 +458,13 @@ export async function runWorkflowWithContext(
486
458
  };
487
459
 
488
460
  const api: WorkflowApi = {
461
+ modelProfile(name) {
462
+ const profile = rc.modelProfiles[name];
463
+ if (!profile || profile.source === "host" || !profile.model || !profile.thinkingLevel) {
464
+ throw new Error(`Configure workflow profile ${name} with an explicit model and thinkingLevel in workflow-models.json.`);
465
+ }
466
+ return { model: `${profile.model.provider}/${profile.model.id}`, thinkingLevel: profile.thinkingLevel };
467
+ },
489
468
  agent,
490
469
  workflow,
491
470
  parallel: bindParallel({
@@ -510,7 +489,7 @@ export async function runWorkflowWithContext(
510
489
  }
511
490
 
512
491
  interface WorkflowScope {
513
- agentOptions(opts: AgentOptions | undefined): AgentOptions;
492
+ agentOptions(opts: AgentOptions): AgentOptions;
514
493
  phase(title: string): void;
515
494
  log(message: string): void;
516
495
  event(event: WorkflowProgressEvent): void;
@@ -523,7 +502,7 @@ function createWorkflowScope(progress: WorkflowProgress, prefix: string, namespa
523
502
  return {
524
503
  agentOptions(opts) {
525
504
  const phase = opts?.phase ? display(opts.phase) : currentPhase;
526
- return opts ? { ...opts, phase } : { phase };
505
+ return { ...opts, phase };
527
506
  },
528
507
  phase(title) {
529
508
  currentPhase = display(title);
@@ -81,12 +81,12 @@ export function workflowJournalPath(cwd: string, runId: string): string {
81
81
  return join(workflowRunsDir(cwd), `${validateWorkflowRunId(runId)}.jsonl`);
82
82
  }
83
83
 
84
- export function agentJournalKey(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
84
+ export function agentJournalKey(prompt: string, opts: Partial<AgentOptions> = {}, worktreeBaseline?: WorktreeBaseline): string {
85
85
  const capture = captureAgentJournalKey(prompt, opts, worktreeBaseline);
86
86
  return capture.kind === "verified" ? capture.key : `agent:unverifiable:${randomUUID()}`;
87
87
  }
88
88
 
89
- export function hashAgentCall(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
89
+ export function hashAgentCall(prompt: string, opts: Partial<AgentOptions> = {}, worktreeBaseline?: WorktreeBaseline): string {
90
90
  const capture = captureAgentCallHash(prompt, opts, worktreeBaseline);
91
91
  return capture.kind === "verified"
92
92
  ? capture.hash
@@ -96,7 +96,7 @@ export function hashAgentCall(prompt: string, opts: AgentOptions = {}, worktreeB
96
96
  /** Capture a replay key without allowing hostile or oversized schemas to escape as exceptions. */
97
97
  export function captureAgentJournalKey(
98
98
  prompt: string,
99
- opts: AgentOptions = {},
99
+ opts: Partial<AgentOptions> = {},
100
100
  worktreeBaseline?: WorktreeBaseline,
101
101
  ): AgentJournalKeyCapture {
102
102
  const behavior = captureAgentCallHash(prompt, opts, worktreeBaseline);
@@ -118,7 +118,7 @@ type AgentCallHashCapture =
118
118
 
119
119
  function captureAgentCallHash(
120
120
  prompt: string,
121
- opts: AgentOptions,
121
+ opts: Partial<AgentOptions>,
122
122
  worktreeBaseline: WorktreeBaseline | undefined,
123
123
  ): AgentCallHashCapture {
124
124
  try {
@@ -0,0 +1,37 @@
1
+ import type { AgentMessage, AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import type { AgentRunnerSession, RunContext } from "./agent-runner-types.ts";
3
+ import { isWorkflowThinkingLevel } from "./agent-options.ts";
4
+ import { resolveAgentModel } from "./agent-session.ts";
5
+
6
+ export interface AgentTranscript {
7
+ readonly cwd?: string;
8
+ readonly messages: readonly AgentMessage[];
9
+ readonly streaming?: AgentMessage;
10
+ readonly steering: readonly string[];
11
+ readonly followUp: readonly string[];
12
+ readonly toolUpdates?: ReadonlyMap<string, { result: AgentToolResult<unknown>; isPartial: boolean; isError: boolean }>;
13
+ }
14
+
15
+ /** Commands are scoped to the child SDK session; parent command handlers never run here. */
16
+ export async function sendAgentInput(session: AgentRunnerSession, rc: RunContext, text: string, steer = false): Promise<void> {
17
+ const [command, ...rest] = text.trim().split(/\s+/);
18
+ const argument = rest.join(" ");
19
+ if (command === "/model") {
20
+ if (!argument || !session.setModel) throw new Error("Usage: /model provider/model");
21
+ const model = resolveAgentModel(argument, rc.modelRegistry, rc.hostModel).model;
22
+ if (!model) throw new Error("Model is unavailable.");
23
+ await session.setModel(model);
24
+ return;
25
+ }
26
+ if (command === "/thinking") {
27
+ if (!isWorkflowThinkingLevel(argument) || !session.setThinkingLevel) {
28
+ throw new Error("Usage: /thinking off|minimal|low|medium|high|xhigh|max");
29
+ }
30
+ session.setThinkingLevel(argument);
31
+ return;
32
+ }
33
+ if (command.startsWith("/")) throw new Error("Supported agent commands: /model provider/model, /thinking level.");
34
+ if (!session.isStreaming) throw new Error("This agent has finished its current turn.");
35
+ if (steer && session.steer) await session.steer(text);
36
+ else await session.followUp(text);
37
+ }
@@ -6,6 +6,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
6
6
  import { getAgentDir, type ModelRegistry } from "@earendil-works/pi-coding-agent";
7
7
  import { isMissingPathError } from "./filesystem-error.ts";
8
8
  import { unknownErrorMessage } from "./unknown-error.ts";
9
+ import { isWorkflowThinkingLevel } from "./agent-options.ts";
9
10
 
10
11
  export const WORKFLOW_MODEL_PROFILE_NAMES = ["small", "medium", "big"] as const;
11
12
  export type WorkflowModelProfileName = (typeof WORKFLOW_MODEL_PROFILE_NAMES)[number];
@@ -56,8 +57,7 @@ export class WorkflowModelProfileConfigError extends Error {
56
57
  }
57
58
  }
58
59
 
59
- export const WORKFLOW_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[];
60
- const THINKING_LEVELS = new Set<ThinkingLevel>(WORKFLOW_THINKING_LEVELS);
60
+ export { WORKFLOW_THINKING_LEVELS, isWorkflowThinkingLevel } from "./agent-options.ts";
61
61
  const HOST_FALLBACK_THINKING: Record<WorkflowModelProfileName, ThinkingLevel> = {
62
62
  small: "low",
63
63
  medium: "medium",
@@ -184,10 +184,6 @@ export function isWorkflowModelProfileName(value: string): value is WorkflowMode
184
184
  return WORKFLOW_MODEL_PROFILE_NAMES.includes(value as WorkflowModelProfileName);
185
185
  }
186
186
 
187
- export function isWorkflowThinkingLevel(value: string): value is ThinkingLevel {
188
- return THINKING_LEVELS.has(value as ThinkingLevel);
189
- }
190
-
191
187
  function parseWorkflowModelProfileFile(value: unknown, configPath: string): WorkflowModelProfileFile {
192
188
  if (!isRecord(value)) throw invalidConfig(configPath, "the root must be an object");
193
189
  assertOnlyKeys(value, ["profiles"], configPath);
@@ -1,6 +1,6 @@
1
1
  import type { WorkflowUsageSnapshot } from "./usage.ts";
2
2
 
3
- export type AgentRowStatus = "queued" | "running" | "done" | "failed";
3
+ export type AgentRowStatus = "queued" | "running" | "stopping" | "stopped" | "done" | "failed";
4
4
  export type AgentChatRole = "task" | "user" | "assistant" | "tool" | "status";
5
5
  export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
6
6
 
@@ -15,6 +15,8 @@ export interface AgentRowSnapshot {
15
15
  readonly label: string;
16
16
  /** provider/id of the model actually routed to this agent. */
17
17
  readonly model?: string;
18
+ readonly modelName?: string;
19
+ readonly thinkingLevel?: string;
18
20
  readonly status: AgentRowStatus;
19
21
  readonly startedAt?: number;
20
22
  readonly doneAt?: number;
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentTranscript } from "./live-agent.ts";
2
3
  import type { WorkflowProgressEvent } from "./types.ts";
3
4
  import type { AgentChatMessage, AgentChatRole, AgentRowStatus, WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progress-types.ts";
4
5
  import type { WorkflowUsageSnapshot } from "./usage.ts";
@@ -23,6 +24,8 @@ interface AgentRow {
23
24
  id: number;
24
25
  label: string;
25
26
  model?: string;
27
+ modelName?: string;
28
+ thinkingLevel?: string;
26
29
  status: AgentRowStatus;
27
30
  startedAt?: number;
28
31
  doneAt?: number;
@@ -73,9 +76,11 @@ export class ProgressTracker {
73
76
  private readonly laneOverflow = new Map<string, number>();
74
77
  private readonly rowsById = new Map<number, AgentRow>();
75
78
  private readonly agentChats = new Map<number, AgentChatMessage[]>();
76
- private readonly agentFollowUps = new Map<number, (message: string) => Promise<void>>();
79
+ private readonly agentTranscripts = new Map<number, () => AgentTranscript>();
80
+ private readonly agentStops = new Map<number, () => void>();
81
+ private readonly agentFollowUps = new Map<number, (message: string, steer?: boolean) => Promise<void>>();
77
82
  private readonly listeners = new Set<() => void>();
78
- private readonly agentCounts: Record<AgentRowStatus, number> = { queued: 0, running: 0, done: 0, failed: 0 };
83
+ private readonly agentCounts: Record<AgentRowStatus, number> = { queued: 0, running: 0, stopping: 0, stopped: 0, done: 0, failed: 0 };
79
84
  private readonly startedAt = Date.now();
80
85
  private readonly laneItemLimit = laneItemLimitFromEnv();
81
86
  private doneAt: number | undefined;
@@ -161,9 +166,9 @@ export class ProgressTracker {
161
166
  this.publish();
162
167
  }
163
168
 
164
- agentQueued(phase: string | undefined, label: string, model?: string): number {
169
+ agentQueued(phase: string | undefined, label: string, model?: string, modelName?: string, thinkingLevel?: string): number {
165
170
  const id = this.nextAgentId++;
166
- const row = { label, model, id, status: "queued" as const, toolUses: 0 };
171
+ const row = { label, model, modelName, thinkingLevel, id, status: "queued" as const, toolUses: 0 };
167
172
  this.ensurePhase(phase ?? this.currentPhase).agents.push(row);
168
173
  this.rowsById.set(id, row);
169
174
  this.agentCounts.queued++;
@@ -208,7 +213,27 @@ export class ProgressTracker {
208
213
  this.publish();
209
214
  }
210
215
 
211
- bindAgentFollowUp(id: number, send: (message: string) => Promise<void>): () => void {
216
+ bindAgentTranscript(id: number, read: () => AgentTranscript): () => void {
217
+ this.agentTranscripts.set(id, read);
218
+ return () => {
219
+ const last = read();
220
+ const snapshot = { ...last, messages: [...last.messages], streaming: undefined };
221
+ this.agentTranscripts.set(id, () => snapshot);
222
+ };
223
+ }
224
+
225
+ transcript(id: number): AgentTranscript | undefined {
226
+ return this.agentTranscripts.get(id)?.();
227
+ }
228
+
229
+ agentChanged(id: number, model?: string, modelName?: string, thinkingLevel?: string): void {
230
+ const row = this.rowsById.get(id);
231
+ if (row) Object.assign(row, { model, modelName, thinkingLevel });
232
+ // Stream updates are UI-only; do not rewrite the durable run on every token.
233
+ for (const listener of this.listeners) listener();
234
+ }
235
+
236
+ bindAgentFollowUp(id: number, send: (message: string, steer?: boolean) => Promise<void>): () => void {
212
237
  this.agentFollowUps.set(id, send);
213
238
  this.publish();
214
239
  return () => {
@@ -217,16 +242,32 @@ export class ProgressTracker {
217
242
  };
218
243
  }
219
244
 
245
+ bindAgentStop(id: number, stop: () => void): () => void {
246
+ this.agentStops.set(id, stop);
247
+ return () => this.agentStops.delete(id);
248
+ }
249
+
250
+ stopAgent(id: number): void {
251
+ const row = this.rowsById.get(id);
252
+ if (!row) throw new Error(`Unknown agent ${id}.`);
253
+ if (row.status !== "running" && row.status !== "queued") return;
254
+ const stop = this.agentStops.get(id);
255
+ if (!stop) throw new Error("Agent cannot be stopped right now.");
256
+ this.transitionAgentStatus(row, "stopping");
257
+ stop();
258
+ this.publish();
259
+ }
260
+
220
261
  conversation(id: number): readonly AgentChatMessage[] {
221
262
  return (this.agentChats.get(id) ?? []).map((message) => ({ ...message }));
222
263
  }
223
264
 
224
- async followUp(id: number, message: string): Promise<void> {
225
- const text = toDisplayLine(message, AGENT_CHAT_TEXT_LIMIT);
265
+ async followUp(id: number, message: string, steer = false): Promise<void> {
266
+ const text = message.trim();
226
267
  if (!text) throw new Error("Enter a follow-up message.");
227
268
  const send = this.agentFollowUps.get(id);
228
269
  if (!send) throw new Error("This agent is no longer accepting follow-ups.");
229
- await send(text);
270
+ await send(text, steer);
230
271
  this.appendAgentChat(id, "user", text);
231
272
  this.publish();
232
273
  }
@@ -238,7 +279,7 @@ export class ProgressTracker {
238
279
 
239
280
  agentDone(label: string, id?: number): void {
240
281
  const row = this.findRow(label, id);
241
- if (row && row.status !== "failed") {
282
+ if (row && (row.status === "running" || row.status === "queued")) {
242
283
  this.transitionAgentStatus(row, "done");
243
284
  row.doneAt = Date.now();
244
285
  }
@@ -248,7 +289,7 @@ export class ProgressTracker {
248
289
  agentFailed(label: string, error: unknown, id?: number): void {
249
290
  const row = this.findRow(label, id);
250
291
  if (row) {
251
- this.transitionAgentStatus(row, "failed");
292
+ this.transitionAgentStatus(row, row.status === "stopping" ? "stopped" : "failed");
252
293
  row.doneAt = Date.now();
253
294
  row.error = toDisplayLine(unknownErrorMessage(error), AGENT_ERROR_DISPLAY_LIMIT) || "agent failed";
254
295
  this.appendAgentChat(row.id, "status", row.error);
@@ -288,10 +329,10 @@ export class ProgressTracker {
288
329
  private statusCountsSnapshot(): WorkflowStatusCounts {
289
330
  return {
290
331
  queued: this.agentCounts.queued,
291
- running: this.agentCounts.running,
332
+ running: this.agentCounts.running + this.agentCounts.stopping,
292
333
  done: this.agentCounts.done,
293
- failed: this.agentCounts.failed,
294
- total: this.agentCounts.queued + this.agentCounts.running + this.agentCounts.done + this.agentCounts.failed,
334
+ failed: this.agentCounts.failed + this.agentCounts.stopped,
335
+ total: Object.values(this.agentCounts).reduce((sum, count) => sum + count, 0),
295
336
  };
296
337
  }
297
338
 
@@ -371,7 +412,6 @@ export class ProgressTracker {
371
412
  this.publishSnapshot();
372
413
  if (!this.ctx.hasUI) return;
373
414
  this.ctx.ui.setWidget(this.surfaceKey, undefined);
374
- this.ctx.ui.setStatus(this.surfaceKey, undefined);
375
415
  }
376
416
 
377
417
  private publishSnapshot(): void {
@@ -32,7 +32,7 @@ export interface ReviewFixWorkflowResult {
32
32
  readonly fixes: readonly ReviewFixOutcome[];
33
33
  }
34
34
 
35
- export type ReviewFixWorkflowApi = Pick<WorkflowApi, "agent" | "parallel" | "phase" | "cwd" | "signal">;
35
+ export type ReviewFixWorkflowApi = Pick<WorkflowApi, "agent" | "modelProfile" | "parallel" | "phase" | "cwd" | "signal">;
36
36
 
37
37
  /** Build an ephemeral workflow that generates one isolated patch preview per finding. */
38
38
  export function createReviewFixWorkflow(
@@ -89,7 +89,7 @@ export async function runReviewFixWorkflow(
89
89
  isolation: "worktree",
90
90
  label: `fix:${issue.id}`,
91
91
  phase: REVIEW_FIX_PHASE,
92
- profile: "medium",
92
+ ...api.modelProfile("medium"),
93
93
  cacheKey: `review-fix:${issue.id}`,
94
94
  tools: [...REVIEW_FIX_TOOLS],
95
95
  toolHints: ["search"],
@@ -138,7 +138,7 @@ async function evaluateReviewFix(api: ReviewFixWorkflowApi, input: {
138
138
  const evaluated = await api.agent(
139
139
  `Independently evaluate a candidate repair. Your fresh worktree contains the exact reviewed baseline plus the captured patch. The implementer's report is not validation evidence. Inspect the finding, callers and tests. Reject incorrect repairs; return blocked if required validation is unavailable. Select at most six focused deterministic checks with executable and argument arrays. Require at least one meaningful behavior check. If a regression test is applicable, supply a test-only baselinePatch and specific expectedFailure so the engine can prove it fails before the repair and passes after. Do not edit, install dependencies, commit or change branches. The engine will execute checks independently.\nFinding: ${JSON.stringify(serializeReviewIssue(issue))}\nBaseline: ${isolated.baselineOid}\nPatch SHA-256: ${validation.patchHash}\nPatch:\n${isolated.patch}`,
140
140
  { isolation: "worktree", candidatePatch: { baselineOid: isolated.baselineOid, patch: isolated.patch },
141
- label: `evaluate:${issue.id}`, phase: "Validate patch previews", profile: "medium", resume: "off",
141
+ label: `evaluate:${issue.id}`, phase: "Validate patch previews", ...api.modelProfile("medium"), resume: "off",
142
142
  tools: ["read", "bash", "grep", "find", "ls"], toolHints: ["search"], schema: PatchEvaluationSchema },
143
143
  );
144
144
  if (evaluated.baselineOid !== isolated.baselineOid || evaluated.patch !== isolated.patch) {
@@ -1,5 +1,6 @@
1
1
  import type { Static, TSchema } from "typebox";
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
+ import type { AgentTranscript } from "./live-agent.ts";
3
4
  import type { WorkflowBudget } from "./budget.ts";
4
5
  import type { Pipeline, WorkflowParallel } from "./concurrency.ts";
5
6
  import type { PerfSink, PerfSnapshot } from "./perf.ts";
@@ -35,14 +36,13 @@ export interface WorkflowRunMetadata {
35
36
  readonly recordPath: string;
36
37
  }
37
38
 
38
- export interface WorkflowBackgroundOrigin {
39
+ export interface WorkflowOrigin {
39
40
  /** Stable pi session id used to route and deduplicate completion delivery. */
40
41
  readonly sessionId: string;
41
42
  readonly requestedAt: number;
42
43
  }
43
44
 
44
45
  export interface WorkflowRunOptions {
45
- inspect?: boolean;
46
46
  perf?: boolean;
47
47
  concurrency?: number;
48
48
  parallelSubmissionLimit?: number;
@@ -64,8 +64,8 @@ export interface WorkflowRunOptions {
64
64
  budget?: number;
65
65
  /** Internal/test override for the generated run id. Omit to generate a new id. */
66
66
  runId?: string;
67
- /** Internal origin metadata for an explicitly backgrounded tool invocation. */
68
- background?: WorkflowBackgroundOrigin;
67
+ /** Owning session for durable completion delivery. */
68
+ origin?: WorkflowOrigin;
69
69
  /** Replay completed agent results from this prior run id when call and execution context still match. */
70
70
  resumeFromRunId?: string;
71
71
  /** Explicitly allow resume to ignore only a workflow-source fingerprint mismatch. */
@@ -92,7 +92,9 @@ export interface WorkflowRunOptions {
92
92
  export interface WorkflowProgressSource {
93
93
  snapshot(): WorkflowProgressSnapshot;
94
94
  conversation(agentId: number): readonly AgentChatMessage[];
95
- followUp(agentId: number, message: string): Promise<void>;
95
+ stopAgent?(agentId: number): void;
96
+ transcript?(agentId: number): AgentTranscript | undefined;
97
+ followUp(agentId: number, message: string, steer?: boolean): Promise<void>;
96
98
  subscribe(listener: () => void): () => void;
97
99
  }
98
100
 
@@ -122,19 +124,16 @@ export type WorkflowProgressEvent =
122
124
 
123
125
  export interface AgentOptions<S extends TSchema = TSchema> {
124
126
  /** Label shown in the progress tree (e.g. "find:logic-bugs"). */
125
- label?: string;
127
+ label: string;
126
128
  /** Phase to group this agent under in the progress tree. */
127
129
  phase?: string;
128
130
  /**
129
- * Optional model id. Overrides profile routing; when both model and profile are
130
- * omitted, inherit the host model. Explicit refs are strict: bare ids resolve as
131
- * Anthropic shorthand; use "provider/id" for other providers.
131
+ * Required model id. Bare ids resolve as Anthropic shorthand;
132
+ * use "provider/id" for other providers. No implicit host fallback.
132
133
  */
133
- model?: string;
134
- /** Reasoning effort for this agent. Overrides the profile's configured effort. */
135
- thinkingLevel?: ThinkingLevel;
136
- /** Exact configured model route to use when model/thinkingLevel do not override it. */
137
- profile?: WorkflowModelProfileName;
134
+ model: string;
135
+ /** Required reasoning effort for this agent. */
136
+ thinkingLevel: ThinkingLevel;
138
137
  /**
139
138
  * Stable identity hint for resume replay. Use this for repeated logical calls
140
139
  * with identical prompts/options, e.g. `${stage}:${item.id}`.
@@ -187,6 +186,8 @@ export interface AgentOptions<S extends TSchema = TSchema> {
187
186
  * exports `meta` plus a default `async (api: WorkflowApi) => result`.
188
187
  */
189
188
  export interface WorkflowApi {
189
+ /** Resolve an explicitly configured route; throws instead of inheriting the host. */
190
+ modelProfile(name: WorkflowModelProfileName): Pick<AgentOptions, "model" | "thinkingLevel">;
190
191
  /** Run a schema subagent in an isolated worktree and return its structured result plus patch. */
191
192
  agent<S extends TSchema>(
192
193
  prompt: string,
@@ -197,7 +198,7 @@ export interface WorkflowApi {
197
198
  /** Run a subagent and return validated structured output; rejects with a recoverable typed error on repair exhaustion. */
198
199
  agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
199
200
  /** Run a subagent and return its final assistant text. */
200
- agent(prompt: string, opts?: AgentOptions): Promise<string>;
201
+ agent(prompt: string, opts: AgentOptions): Promise<string>;
201
202
  /**
202
203
  * Run another registered workflow inline as a sub-step and return its result. The child shares
203
204
  * this run's concurrency cap, abort signal, and perf sink. Nests one level only: calling
@@ -0,0 +1,59 @@
1
+ import { AssistantMessageComponent, ToolExecutionComponent, UserMessageComponent } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, TUI } from "@earendil-works/pi-tui";
3
+ import type { AgentTranscript } from "../live-agent.ts";
4
+
5
+ /** Native pi transcript components, driven by the child session's own messages. */
6
+ export class AgentTranscriptView {
7
+ private readonly components = new WeakMap<object, Component>();
8
+ private readonly tools = new Map<string, ToolExecutionComponent>();
9
+ private readonly results = new Map<string, object>();
10
+
11
+ render(transcript: AgentTranscript, width: number, tui: TUI, cwd: string): string[] {
12
+ const messages = transcript.streaming && !transcript.messages.includes(transcript.streaming)
13
+ ? [...transcript.messages, transcript.streaming] : transcript.messages;
14
+ const rows: string[] = [];
15
+ for (const message of messages) {
16
+ if (message.role === "user") {
17
+ let component = this.components.get(message);
18
+ if (!component) {
19
+ const text = typeof message.content === "string" ? message.content
20
+ : message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
21
+ component = new UserMessageComponent(text);
22
+ this.components.set(message, component);
23
+ }
24
+ rows.push(...component.render(width));
25
+ } else if (message.role === "assistant") {
26
+ // Streaming messages can be mutated by pi; update the native component every render.
27
+ let component = this.components.get(message) as AssistantMessageComponent | undefined;
28
+ if (!component) {
29
+ component = new AssistantMessageComponent(message, false);
30
+ this.components.set(message, component);
31
+ }
32
+ component.updateContent(message, message === transcript.streaming);
33
+ rows.push(...component.render(width));
34
+ for (const part of message.content) {
35
+ if (part.type !== "toolCall") continue;
36
+ let tool = this.tools.get(part.id);
37
+ if (!tool) {
38
+ tool = new ToolExecutionComponent(part.name, part.id, part.arguments, { showImages: false }, undefined, tui, cwd);
39
+ this.tools.set(part.id, tool);
40
+ }
41
+ tool.updateArgs(part.arguments);
42
+ if (message !== transcript.streaming) tool.setArgsComplete();
43
+ const update = transcript.toolUpdates?.get(part.id);
44
+ if (update && this.results.get(part.id) !== update) {
45
+ tool.updateResult({ ...update.result, isError: update.isError }, update.isPartial);
46
+ this.results.set(part.id, update);
47
+ }
48
+ const result = messages.find((candidate) => candidate.role === "toolResult" && candidate.toolCallId === part.id);
49
+ if (result?.role === "toolResult" && this.results.get(part.id) !== result) {
50
+ tool.updateResult(result);
51
+ this.results.set(part.id, result);
52
+ }
53
+ rows.push(...tool.render(width));
54
+ }
55
+ }
56
+ }
57
+ return rows;
58
+ }
59
+ }
@@ -4,7 +4,7 @@ import type { AgentRowSnapshot, WorkflowLaneItemStatus, WorkflowProgressSnapshot
4
4
  import { toDisplayLine } from "./display-text.ts";
5
5
  import { formatWorkflowUsageLine } from "../usage.ts";
6
6
 
7
- export type WorkflowDisplayStatus = WorkflowLaneItemStatus | "queued" | "done" | "failed";
7
+ export type WorkflowDisplayStatus = WorkflowLaneItemStatus | "queued" | "done" | "failed" | "stopping" | "stopped";
8
8
  export type WorkflowThemeColor = Parameters<Theme["fg"]>[0];
9
9
 
10
10
  export function formatDuration(ms: number): string {
@@ -42,6 +42,10 @@ export function statusIcon(status: WorkflowDisplayStatus, theme: Theme): string
42
42
  case "error":
43
43
  case "failed":
44
44
  return theme.fg("error", "✗");
45
+ case "stopped":
46
+ return theme.fg("dim", "■");
47
+ case "stopping":
48
+ return theme.fg("warning", "◌");
45
49
  case "running":
46
50
  return theme.fg("accent", "●");
47
51
  case "queued":
@@ -151,7 +155,7 @@ function countSnapshotAgents(snapshot: WorkflowProgressSnapshot): WorkflowStatus
151
155
  const counts = { queued: 0, running: 0, done: 0, failed: 0, total: 0 };
152
156
  for (const phase of snapshot.phases) {
153
157
  for (const agent of phase.agents) {
154
- counts[agent.status]++;
158
+ counts[agent.status === "stopped" ? "failed" : agent.status === "stopping" ? "running" : agent.status]++;
155
159
  counts.total++;
156
160
  }
157
161
  }