@jameslovespancakes/pi-plus 1.0.19 → 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 (45) hide show
  1. package/README.md +133 -246
  2. package/package.json +3 -2
  3. package/src/core/claude-remote/LICENSE.md +22 -0
  4. package/src/core/claude-remote/UPSTREAM.md +40 -0
  5. package/src/core/claude-remote/bridge.ts +392 -0
  6. package/src/core/claude-remote/protocol.ts +83 -0
  7. package/src/core/env.ts +7 -1
  8. package/src/domains/claude-remote/auth.ts +25 -0
  9. package/src/domains/claude-remote/index.ts +183 -0
  10. package/src/domains/claude-remote/picker.ts +36 -0
  11. package/src/domains/models/provider-picker.ts +3 -46
  12. package/src/domains/setup/index.ts +12 -1
  13. package/src/domains/workflows/index.ts +56 -104
  14. package/src/domains/workflows/runtime/advisory-challenge.ts +3 -3
  15. package/src/domains/workflows/runtime/agent-attempt.ts +3 -3
  16. package/src/domains/workflows/runtime/agent-options.ts +18 -0
  17. package/src/domains/workflows/runtime/agent-runner-types.ts +7 -3
  18. package/src/domains/workflows/runtime/agent-runner.ts +14 -6
  19. package/src/domains/workflows/runtime/agent-session.ts +34 -3
  20. package/src/domains/workflows/runtime/cancellation.ts +5 -0
  21. package/src/domains/workflows/runtime/engine.ts +19 -40
  22. package/src/domains/workflows/runtime/journal.ts +4 -4
  23. package/src/domains/workflows/runtime/live-agent.ts +37 -0
  24. package/src/domains/workflows/runtime/model-profiles.ts +2 -6
  25. package/src/domains/workflows/runtime/progress-types.ts +3 -1
  26. package/src/domains/workflows/runtime/progress.ts +69 -42
  27. package/src/domains/workflows/runtime/review/review-fix-workflow.ts +3 -3
  28. package/src/domains/workflows/runtime/types.ts +16 -15
  29. package/src/domains/workflows/runtime/ui/agent-transcript.ts +59 -0
  30. package/src/domains/workflows/runtime/ui/workflow-format.ts +6 -2
  31. package/src/domains/workflows/runtime/ui/workflow-inspector.ts +75 -61
  32. package/src/domains/workflows/runtime/ui/workflow-widget.ts +26 -66
  33. package/src/domains/workflows/runtime/workflow-advisory-utils.ts +5 -5
  34. package/src/domains/workflows/runtime/{background-workflows.ts → workflow-lifecycle.ts} +91 -75
  35. package/src/domains/workflows/runtime/workflow-management.ts +66 -0
  36. package/src/domains/workflows/runtime/workflow-run-controller.ts +16 -20
  37. package/src/domains/workflows/runtime/{workflow-run-background.ts → workflow-run-delivery.ts} +3 -3
  38. package/src/domains/workflows/runtime/workflow-run-record.ts +7 -4
  39. package/src/domains/workflows/workflows/code-review.ts +1 -1
  40. package/src/domains/workflows/workflows/diagnose.ts +1 -1
  41. package/src/domains/workflows/workflows/perf-review.ts +1 -1
  42. package/src/domains/workflows/workflows/refactor-scout.ts +1 -1
  43. package/src/domains/workflows/workflows/research.ts +4 -4
  44. package/src/ui/settings-picker.ts +26 -0
  45. package/src/domains/workflows/runtime/background-workflow-tool.ts +0 -75
@@ -1,6 +1,6 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import { assertWorkflowBudgetAvailable } from "./budget.ts";
3
- import { isWorkflowPauseError } from "./cancellation.ts";
3
+ import { isWorkflowPauseError, WorkflowAgentStoppedError } from "./cancellation.ts";
4
4
  import type { WorkflowAgentReservation } from "./agent-limits.ts";
5
5
  import {
6
6
  type AgentExecutionOptions,
@@ -132,7 +132,7 @@ export async function executeAgentAttempt(input: {
132
132
  tags,
133
133
  });
134
134
  const result = await workspace.wrapResult(rawResult);
135
- if (!identity) return { kind: "live-unrecordable", result };
135
+ if (!identity || handle.interacted) return { kind: "live-unrecordable", result };
136
136
  if (!isReplayEnabled(replay) || !evidence) throw new Error("Replay identity produced without complete replay evidence.");
137
137
 
138
138
  const contract = await validateReplayIdentity({
@@ -156,7 +156,7 @@ export async function executeAgentAttempt(input: {
156
156
  if (
157
157
  workspace?.kind === "isolated"
158
158
  && liveStarted
159
- && !rc.signal?.aborted
159
+ && (!rc.signal?.aborted || rc.signal.reason instanceof WorkflowAgentStoppedError)
160
160
  && !isWorkflowPauseError(error)
161
161
  ) {
162
162
  rc.worktrees.preserve(workspace.cwd);
@@ -0,0 +1,18 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { AgentOptions } from "./types.ts";
3
+
4
+ export const WORKFLOW_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const satisfies readonly ThinkingLevel[];
5
+
6
+ export function isWorkflowThinkingLevel(value: string): value is ThinkingLevel {
7
+ return (WORKFLOW_THINKING_LEVELS as readonly string[]).includes(value);
8
+ }
9
+
10
+ /** Validate before admission or any model session can be created. */
11
+ export function assertAgentOptions(options: unknown): asserts options is AgentOptions {
12
+ const opts = options as Partial<AgentOptions> | undefined;
13
+ if (!opts || typeof opts.label !== "string" || !opts.label.trim()
14
+ || typeof opts.model !== "string" || !opts.model.trim()
15
+ || typeof opts.thinkingLevel !== "string" || !isWorkflowThinkingLevel(opts.thinkingLevel)) {
16
+ throw new Error("Every agent() requires explicit label, model, and thinkingLevel.");
17
+ }
18
+ }
@@ -13,6 +13,7 @@ import type { WorkflowJournal } from "./journal.ts";
13
13
  import type { PerfSink } from "./perf.ts";
14
14
  import type { AgentOptions, WorkflowProgressEvent } from "./types.ts";
15
15
  import type { AgentChatRole } from "./progress-types.ts";
16
+ import type { AgentTranscript } from "./live-agent.ts";
16
17
  import type { WorkflowUsageSink } from "./usage.ts";
17
18
  import type { WorktreeBaseline, WorktreeRegistry } from "./worktree.ts";
18
19
 
@@ -34,16 +35,19 @@ export type AgentRunnerSession = Pick<
34
35
  | "getLastAssistantText"
35
36
  | "isStreaming"
36
37
  | "followUp"
37
- >;
38
+ > & Partial<Pick<AgentSession, "setModel" | "setThinkingLevel" | "steer" | "getSteeringMessages" | "getFollowUpMessages">>;
38
39
 
39
40
  export type CreateAgentSession = (options: CreateAgentSessionOptions) => Promise<{ session: AgentRunnerSession }>;
40
41
 
41
42
  export interface AgentProgress {
42
- agentQueued(phase: string | undefined, label: string, model?: string): number;
43
+ agentQueued(phase: string | undefined, label: string, model?: string, modelName?: string, thinkingLevel?: string): number;
44
+ bindAgentStop?(id: number, stop: () => void): () => void;
43
45
  agentStart(phase: string | undefined, label: string, id?: number, model?: string): void;
44
46
  agentTool(label: string, tool: string, id?: number): void;
45
47
  agentMessage(id: number, role: AgentChatRole, text: string): void;
46
- bindAgentFollowUp(id: number, send: (message: string) => Promise<void>): () => void;
48
+ bindAgentFollowUp(id: number, send: (message: string, steer?: boolean) => Promise<void>): () => void;
49
+ bindAgentTranscript?(id: number, read: () => AgentTranscript): () => void;
50
+ agentChanged?(id: number, model?: string, modelName?: string, thinkingLevel?: string): void;
47
51
  agentDone(label: string, id?: number): void;
48
52
  agentFailed(label: string, error: unknown, id?: number): void;
49
53
  event(event: WorkflowProgressEvent): void;
@@ -1,7 +1,8 @@
1
+ import { assertAgentOptions } from "./agent-options.ts";
1
2
  import { assertWorkflowBudgetAvailable } from "./budget.ts";
2
3
  import { combinedAgentAttemptError } from "./agent-failure.ts";
3
4
  import { WorkflowAgentTimeoutError } from "./agent-limits.ts";
4
- import { abortReason, linkAbortSignal, throwIfAborted } from "./cancellation.ts";
5
+ import { abortReason, linkAbortSignal, throwIfAborted, WorkflowAgentStoppedError } from "./cancellation.ts";
5
6
  import { executeAgentAttempt } from "./agent-attempt.ts";
6
7
  import {
7
8
  createAgentReplayPlan,
@@ -53,7 +54,8 @@ export async function runAgent(
53
54
  throw new Error(`agent() prompt must be a string; received ${describeAgentPrompt(prompt)}`);
54
55
  }
55
56
 
56
- const label = opts.label ?? "agent";
57
+ assertAgentOptions(opts);
58
+ const label = opts.label;
57
59
  const phase = opts.phase ?? "Workflow";
58
60
  const tags: AgentRunTags = { label, phase };
59
61
 
@@ -62,14 +64,15 @@ export async function runAgent(
62
64
  const routing = resolveAgentRouting(rc, opts, label);
63
65
  const effectiveOpts = routing.thinkingLevel === opts.thinkingLevel
64
66
  ? opts
65
- : { ...opts, thinkingLevel: routing.thinkingLevel };
67
+ : { ...opts, thinkingLevel: routing.thinkingLevel ?? opts.thinkingLevel };
66
68
  const replay = createAgentReplayPlan(prompt, effectiveOpts);
67
69
  if (!isReplayEnabled(replay)) assertWorkflowBudgetAvailable(rc.budget);
68
70
 
69
71
  const modelLabel = describeAgentModel(routing.model);
70
- const rowId = rc.progress.agentQueued(opts.phase, label, modelLabel);
72
+ const rowId = rc.progress.agentQueued(opts.phase, label, modelLabel, routing.model?.name, effectiveOpts.thinkingLevel);
71
73
  rc.progress.agentMessage(rowId, "task", prompt);
72
74
  const liveScope = createAgentLiveScope(rc, label);
75
+ const unbindStop = rc.progress.bindAgentStop?.(rowId, liveScope.stop);
73
76
  try {
74
77
  return await rc.semaphore.run(
75
78
  async () => {
@@ -129,18 +132,20 @@ export async function runAgent(
129
132
  }
130
133
  continue;
131
134
  }
135
+ throwIfAborted(agentRc.signal);
132
136
  const settlement = await settleAgentAttempt({ rc: agentRc, label, tags, replay: attemptPlan, outcome });
133
137
  if (settlement.kind === "retry-live") {
134
138
  attemptPlan = { kind: "off" };
135
139
  continue;
136
140
  }
141
+ throwIfAborted(agentRc.signal);
137
142
  rc.progress.agentDone(label, rowId);
138
143
  return settlement.result;
139
144
  }
140
145
  },
141
146
  {
142
147
  onQueueWaitMs: (durationMs) => rc.perf.observe("agent.queue_wait_ms", durationMs, tags),
143
- signal: rc.signal,
148
+ signal: liveScope.signal,
144
149
  },
145
150
  );
146
151
  } catch (error) {
@@ -151,6 +156,7 @@ export async function runAgent(
151
156
  rc.progress.log(`${label} failed: ${unknownErrorMessage(failure)}`);
152
157
  throw failure;
153
158
  } finally {
159
+ unbindStop?.();
154
160
  liveScope.dispose();
155
161
  }
156
162
  }, tags);
@@ -164,6 +170,8 @@ function createAgentLiveScope(rc: RunContext, label: string) {
164
170
 
165
171
  return {
166
172
  signal: controller.signal,
173
+ // A local stop is recoverable by parallel(); it must not abort sibling agents.
174
+ stop: () => controller.abort(new WorkflowAgentStoppedError(`Agent ${label} stopped by user.`)),
167
175
  reserve() {
168
176
  const reservation = rc.agentLimiter.reserve(controller.signal);
169
177
  let committed = false;
@@ -194,7 +202,7 @@ function resolveAgentRouting(
194
202
  rc: RunContext,
195
203
  opts: AgentExecutionOptions,
196
204
  label: string,
197
- ): { readonly model: ResolvedAgentModel["model"]; readonly thinkingLevel: AgentExecutionOptions["thinkingLevel"] } {
205
+ ): { readonly model: ResolvedAgentModel["model"]; readonly thinkingLevel: AgentExecutionOptions["thinkingLevel"] | undefined } {
198
206
  try {
199
207
  return resolveAgentModelProfile(
200
208
  {
@@ -1,3 +1,5 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { sendAgentInput, type AgentTranscript } from "./live-agent.ts";
1
3
  import type { Api, Model } from "@earendil-works/pi-ai";
2
4
  import {
3
5
  createAgentSessionFromServices,
@@ -45,7 +47,9 @@ export interface ResolvedAgentModel {
45
47
  }
46
48
 
47
49
  export interface AgentSessionHandle {
50
+ interacted?: boolean;
48
51
  readonly session: AgentRunnerSession;
52
+ readonly cwd?: string;
49
53
  readonly selectedSkills: readonly Skill[];
50
54
  hasStructuredResult(): boolean;
51
55
  structuredResult(): unknown;
@@ -124,6 +128,7 @@ export async function openAgentSession(input: {
124
128
  throwIfAborted(rc.signal);
125
129
  return {
126
130
  session,
131
+ cwd,
127
132
  selectedSkills: resources.selectedSkills,
128
133
  hasStructuredResult: () => captured,
129
134
  structuredResult: () => structuredResult,
@@ -149,13 +154,38 @@ export async function promptAgentSession(input: {
149
154
  }): Promise<unknown> {
150
155
  const { rc, handle, prompt, opts, label, rowId, tags } = input;
151
156
  const { session } = handle;
152
- const unbindFollowUp = rc.progress.bindAgentFollowUp(rowId, async (message) => {
153
- if (!session.isStreaming) throw new Error("This agent has already finished its current turn.");
154
- await session.followUp(message);
157
+ let streaming: AgentMessage | undefined;
158
+ const toolUpdates = new Map<string, NonNullable<AgentTranscript["toolUpdates"]> extends ReadonlyMap<string, infer V> ? V : never>();
159
+ const refresh = () => rc.progress.agentChanged?.(rowId,
160
+ session.model ? `${session.model.provider}/${session.model.id}` : undefined,
161
+ session.model?.name, session.thinkingLevel);
162
+ const unbindTranscript = rc.progress.bindAgentTranscript?.(rowId, () => ({
163
+ messages: session.messages, streaming, cwd: handle.cwd, toolUpdates,
164
+ steering: session.getSteeringMessages?.() ?? [],
165
+ followUp: session.getFollowUpMessages?.() ?? [],
166
+ }));
167
+ const unbindFollowUp = rc.progress.bindAgentFollowUp(rowId, async (message, steer) => {
168
+ await sendAgentInput(session, rc, message, steer);
169
+ handle.interacted = true;
170
+ refresh();
155
171
  });
156
172
  const unsubscribe = session.subscribe((event) => {
173
+ if (event.type === "message_update" || event.type === "message_start") streaming = event.message;
174
+ if (event.type === "message_end") streaming = undefined;
175
+ if (event.type === "tool_execution_update") {
176
+ toolUpdates.set(event.toolCallId, { result: event.partialResult, isPartial: true, isError: false });
177
+ } else if (event.type === "tool_execution_end") {
178
+ toolUpdates.set(event.toolCallId, { result: event.result, isPartial: false, isError: event.isError });
179
+ }
180
+ refresh();
181
+ if (event.type === "tool_execution_end") {
182
+ const output = event.result.content.filter((part: { type: string }) => part.type === "text")
183
+ .map((part: { text?: string }) => part.text ?? "").join("\n");
184
+ rc.progress.agentMessage(rowId, "tool", `${event.toolName}: ${output}`);
185
+ }
157
186
  if (event.type === "tool_execution_start" && event.toolName !== undefined && event.toolName !== FINAL_TOOL) {
158
187
  rc.progress.agentTool(label, event.toolName, rowId);
188
+ rc.progress.agentMessage(rowId, "tool", `${event.toolName} ${JSON.stringify(event.args)}`);
159
189
  return;
160
190
  }
161
191
  if (event.type === "message_end" && event.message.role === "assistant") {
@@ -217,6 +247,7 @@ export async function promptAgentSession(input: {
217
247
  unlinkPromptAbort();
218
248
  }
219
249
  } finally {
250
+ unbindTranscript?.();
220
251
  unbindFollowUp();
221
252
  unsubscribe();
222
253
  }
@@ -1,3 +1,8 @@
1
+ /** Agent-local cancellation is recoverable and must not stop sibling tasks. */
2
+ export class WorkflowAgentStoppedError extends Error {
3
+ override readonly name = "WorkflowAgentStoppedError";
4
+ }
5
+
1
6
  export class WorkflowAbortError extends Error {
2
7
  constructor(message = "Workflow aborted") {
3
8
  super(message);
@@ -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;