@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
package/README.md CHANGED
@@ -124,7 +124,8 @@ boundary, including workflow subagents—not just through prompt instructions.
124
124
 
125
125
  **Turn repeatable tasks into coordinated agent runs.** Built-in reviews,
126
126
  diagnostics, research, and refactoring workflows support parallel agents,
127
- worktree isolation, replay, background execution, and usage accounting.
127
+ worktree isolation, replay, and usage accounting. Every run returns immediately;
128
+ results arrive when it finishes. There is no separate foreground/background mode.
128
129
 
129
130
  ```text
130
131
  Task → parallel agents → findings → result
@@ -135,8 +136,25 @@ worktree isolation, replay, background execution, and usage accounting.
135
136
  /workflow research "Compare the available approaches"
136
137
  ```
137
138
 
138
- **`/workflow`** opens the running agent board. Limits on concurrency, agents,
139
- time, and output tokens are optional; set them when needed.
139
+ **`/workflow`** opens the running agent board. Enter inspects, Esc goes back,
140
+ and X stops the selected agent from the list. Inspection needs at least 80×24.
141
+ The inspector uses pi's native message/tool rendering and editor. Enter steers;
142
+ Alt+Enter queues a follow-up. `/model provider/model` and `/thinking level`
143
+ affect only that agent. Other parent-session commands are not forwarded.
144
+
145
+ Every `api.agent()` call requires `label`, `model`, and `thinkingLevel`.
146
+ Built-ins resolve explicit routes with `api.modelProfile("small" | "medium")`;
147
+ configure both routes in `.pi/workflow-models.json` (or the agent-directory
148
+ `workflow-models.json`), each with `model: "provider/model-id"` and
149
+ `thinkingLevel`. Missing routes now fail rather than inheriting the host model.
150
+ The file shape is `{ "profiles": { "small": { "model": "provider/model-id",
151
+ "thinkingLevel": "low" }, "medium": { "model": "provider/model-id",
152
+ "thinkingLevel": "high" } } }`.
153
+
154
+ The main agent can use `workflow({ action: "list" })`, or `inspect`/`stop` with
155
+ `runId` and optionally `agentId`. Activity is fetched on demand, not injected
156
+ into every turn. Limits on concurrency, agents, time, and output tokens remain
157
+ optional.
140
158
 
141
159
  ## Remote Workers
142
160
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jameslovespancakes/pi-plus",
3
- "version": "1.0.20",
3
+ "version": "1.0.21",
4
4
  "type": "module",
5
5
  "description": "pi and more",
6
6
  "license": "MIT",
@@ -5,8 +5,7 @@ import { Text, type AutocompleteItem } from "@earendil-works/pi-tui";
5
5
  import { isAdvisoryReport } from "./runtime/advisory-schema.ts";
6
6
  import type { WorkflowProgressSnapshot } from "./runtime/progress-types.ts";
7
7
  import type { LoadedWorkflow, WorkflowProgressSource, WorkflowRef, WorkflowRunMetadata, WorkflowRunOptions } from "./runtime/types.ts";
8
- import { WorkflowInspector } from "./runtime/ui/workflow-inspector.ts";
9
- import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./runtime/ui/workflow-viewer-layout.ts";
8
+ import { WorkflowInspector, WORKFLOW_INSPECTOR_OVERLAY_OPTIONS } from "./runtime/ui/workflow-inspector.ts";
10
9
  import type { PerfSink } from "./runtime/perf.ts";
11
10
  import type { WorkflowUsageSnapshot } from "./runtime/usage.ts";
12
11
  import { ADAPTIVE_WORKFLOW_GUIDANCE, registerDynamax } from "./runtime/dynamax.ts";
@@ -37,8 +36,7 @@ import {
37
36
  WORKFLOW_USAGE_LIMIT_DELAY_MIN_MS,
38
37
  } from "./runtime/options.ts";
39
38
  import { executeWorkflowInvocation, type WorkflowExecution, type WorkflowPerfDetails } from "./runtime/workflow-execution.ts";
40
- import { BackgroundWorkflowCoordinator } from "./runtime/background-workflows.ts";
41
- import { backgroundUnavailableResult, startBackgroundWorkflowTool } from "./runtime/background-workflow-tool.ts";
39
+ import { WorkflowLifecycle, workflowUnavailableResult } from "./runtime/workflow-lifecycle.ts";
42
40
  import { WorkflowRunController } from "./runtime/workflow-run-controller.ts";
43
41
  import { completeCurrentArgument, splitArgumentPrefix } from "./runtime/command-completions.ts";
44
42
  import { assertSupportedPiVersion } from "./runtime/pi-compat.ts";
@@ -132,7 +130,6 @@ export async function resolveWorkflowRef(ref: WorkflowRef, perf?: PerfSink): Pro
132
130
  }
133
131
 
134
132
  const WORKFLOW_OPTION_COMPLETIONS = [
135
- { value: "--inspect", description: "Open the live workflow inspector" },
136
133
  { value: "--refresh", description: "Refresh dynamic workflow discovery" },
137
134
  { value: "--perf", description: "Collect workflow performance metrics" },
138
135
  { value: "--result-viewer", description: "Open supported result viewers" },
@@ -195,9 +192,10 @@ export async function openWorkflowInspector(ctx: ExtensionContext, inspection: A
195
192
  () => done(undefined),
196
193
  undefined,
197
194
  source,
195
+ _keybindings,
198
196
  );
199
197
  },
200
- WORKFLOW_VIEWER_OVERLAY_OPTIONS,
198
+ WORKFLOW_INSPECTOR_OVERLAY_OPTIONS,
201
199
  );
202
200
  } finally {
203
201
  unsubscribe?.();
@@ -263,7 +261,7 @@ function parseWorkflowOptions(input: string): { args: string; options: WorkflowR
263
261
  for (let i = 0; i < tokens.length; i++) {
264
262
  const token = tokens[i];
265
263
  if (token === "--inspect") {
266
- options.inspect = true;
264
+ optionErrors.push("--inspect was removed; open the running workflow with /workflow");
267
265
  continue;
268
266
  }
269
267
  if (token === "--refresh") {
@@ -413,7 +411,6 @@ export interface WorkflowToolRequestParams {
413
411
  readonly name?: string;
414
412
  readonly script?: string;
415
413
  readonly resumeFromRunId?: string;
416
- readonly background?: boolean;
417
414
  }
418
415
 
419
416
  export type WorkflowToolRequest =
@@ -445,44 +442,6 @@ export function inlineCompileErrorResult(message: string): WorkflowToolErrorResu
445
442
  return { content: [{ type: "text", text: `Inline workflow did not compile: ${message}` }], details: { error: "inline_compile_error", message } };
446
443
  }
447
444
 
448
- export async function sendWorkflowResult(
449
- pi: ExtensionAPI,
450
- ctx: ExtensionContext,
451
- name: string,
452
- mod: LoadedWorkflow,
453
- args: string,
454
- options: WorkflowRunOptions,
455
- perfRecorder?: PerfSink,
456
- reviewSessions: ReviewSessionCoordinator = createReviewSessionCoordinator(pi),
457
- ): Promise<void> {
458
- await sendResolvedWorkflowResult(
459
- pi,
460
- ctx,
461
- name,
462
- mod,
463
- args,
464
- resolveWorkflowRunOptions(options),
465
- perfRecorder,
466
- reviewSessions,
467
- );
468
- }
469
-
470
- async function sendResolvedWorkflowResult(
471
- pi: ExtensionAPI,
472
- ctx: ExtensionContext,
473
- name: string,
474
- mod: LoadedWorkflow,
475
- args: string,
476
- options: ResolvedWorkflowRunOptions,
477
- perfRecorder: PerfSink | undefined,
478
- reviewSessions: ReviewSessionCoordinator,
479
- ): Promise<void> {
480
- const execution = await executeResolvedWorkflow(pi, ctx, name, mod, args, options, perfRecorder);
481
- reviewSessions.remember(ctx, execution, options);
482
- sendWorkflowExecution(pi, execution);
483
- await reviewSessions.present(ctx, execution, options);
484
- }
485
-
486
445
  async function executeResolvedWorkflow(
487
446
  pi: ExtensionAPI,
488
447
  ctx: ExtensionContext,
@@ -548,8 +507,8 @@ function createReviewSessionCoordinator(pi: ExtensionAPI): ReviewSessionCoordina
548
507
  export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShortcuts = resolveDynamaxShortcuts()): void {
549
508
  assertSupportedPiVersion(VERSION);
550
509
  const reviewSessions = createReviewSessionCoordinator(pi);
551
- const backgroundWorkflows = new BackgroundWorkflowCoordinator(pi);
552
- const workflowRuns = new WorkflowRunController(backgroundWorkflows, {
510
+ const lifecycle = new WorkflowLifecycle(pi);
511
+ const workflowRuns = new WorkflowRunController(lifecycle, {
553
512
  async resolveWorkflow(name) {
554
513
  const { discoverWorkflows } = await loadDiscovery();
555
514
  return (await discoverWorkflows(EXTENSION_DIR)).get(name);
@@ -560,18 +519,18 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
560
519
  reviewSessions.remember(ctx, execution, options);
561
520
  },
562
521
  });
563
- backgroundWorkflows.onRunSettled((ctx, runId) => workflowRuns.runSettled(ctx, runId));
522
+ lifecycle.onRunSettled((ctx, runId) => workflowRuns.runSettled(ctx, runId));
564
523
  registerDynamax(pi, shortcuts, { openInspector: (ctx) => openAvailableWorkflowInspector(pi, ctx) });
565
524
  pi.on("session_start", async (_event, ctx) => {
566
- await backgroundWorkflows.sessionStarted(ctx);
525
+ await lifecycle.sessionStarted(ctx);
567
526
  await workflowRuns.sessionStarted(ctx);
568
527
  });
569
528
  pi.on("agent_settled", async (_event, ctx) => {
570
- await backgroundWorkflows.agentSettled(ctx);
529
+ await lifecycle.agentSettled(ctx);
571
530
  });
572
531
  pi.on("session_shutdown", async (_event, ctx) => {
573
532
  workflowRuns.sessionShutdown(ctx);
574
- await backgroundWorkflows.sessionShutdown(ctx);
533
+ await lifecycle.sessionShutdown(ctx);
575
534
  const key = sessionKey(ctx);
576
535
  workflowInspections.get(pi)?.delete(key);
577
536
  reviewSessions.dispose(ctx);
@@ -619,24 +578,36 @@ export default function workflowEngine(pi: ExtensionAPI, shortcuts: DynamaxShort
619
578
  ctx.ui.notify(`Unknown workflow "${direct.name}". Available: ${available}`, "error");
620
579
  return;
621
580
  }
622
- await sendResolvedWorkflowResult(pi, ctx, direct.name, mod, direct.args, directOptions, perfRecorder, reviewSessions);
581
+ const unavailable = workflowUnavailableResult(ctx.mode);
582
+ if (unavailable) {
583
+ ctx.ui.notify(unavailable.content[0].text, "warning");
584
+ return;
585
+ }
586
+ const started = await lifecycle.launch({
587
+ ctx, name: direct.name, options: directOptions,
588
+ async execute(runCtx, options) {
589
+ const execution = await executeResolvedWorkflow(pi, runCtx, direct.name, mod, direct.args, options, perfRecorder);
590
+ reviewSessions.remember(ctx, execution, options);
591
+ },
592
+ });
593
+ ctx.ui.notify(started.content[0].text, started.details.error ? "error" : "info");
623
594
  },
624
595
  });
625
596
 
626
- registerWorkflowTool(pi, reviewSessions, backgroundWorkflows);
597
+ registerWorkflowTool(pi, reviewSessions, lifecycle);
627
598
  }
628
599
 
629
600
  /** Register the host-facing workflow tool independently from command and lifecycle surfaces. */
630
601
  function registerWorkflowTool(
631
602
  pi: ExtensionAPI,
632
603
  reviewSessions: ReviewSessionCoordinator,
633
- backgroundWorkflows: BackgroundWorkflowCoordinator,
604
+ lifecycle: WorkflowLifecycle,
634
605
  ): void {
635
606
  pi.registerTool({
636
607
  name: "workflow",
637
608
  label: "Workflow",
638
609
  description:
639
- "ONLY call workflow when the user opted in with the literal token `dynamax`, explicitly requested a workflow, or invoked a command or skill that requires one. Runs named or inline multi-agent workflows synchronously or in the background.",
610
+ "ONLY call workflow when the user opted in with the literal token `dynamax`, explicitly requested a workflow, or invoked a command or skill that requires one. Starts named or inline multi-agent workflows and returns a run ID immediately. Use list, inspect, or stop to manage runs and individual agents.",
640
611
  promptSnippet: "Run an existing named workflow or an inline one-off workflow script",
641
612
  promptGuidelines: [
642
613
  "Use workflow only after a `dynamax` opt-in, an explicit workflow request, or a command or skill instruction.",
@@ -650,12 +621,16 @@ function registerWorkflowTool(
650
621
  "If an inline subagent needs grep/find/code-search helpers, use `tools: [\"read\", \"bash\", \"grep\", \"find\", \"ls\"]` plus `toolHints: [\"search\"]` so installed tools such as ast-grep, mgrep, ffgrep, or fffind are discovered dynamically.",
651
622
  "`api.budget` exposes `{ total, spent(), remaining() }` (output tokens). When the run is budgeted, scale fleets from `budget.total` and guard loops with `while (budget.total && budget.remaining() > N) { await api.agent(...) }`; `api.agent()` throws once the ceiling is reached.",
652
623
  ADAPTIVE_WORKFLOW_GUIDANCE,
653
- "Set background: true only when the user explicitly wants the workflow to continue after this tool call; the tool returns a durable run ID and completion is delivered later.",
654
- "Set autoResumeOnUsageLimit: true only for an explicitly backgrounded workflow when the user wants bounded automatic continuation after a recognized provider usage window.",
624
+ "All runs return a durable run ID immediately; completion is delivered later. Use action list/inspect/stop to observe or cancel without launching another workflow.",
625
+ "Every api.agent() call must explicitly supply label, model, and thinkingLevel; no implicit host model or thinking defaults.",
626
+ "Set autoResumeOnUsageLimit: true only when the user wants bounded automatic continuation after a recognized provider usage window.",
655
627
  "Set resumeEditedWorkflow: true only with resumeFromRunId when the user explicitly accepts reusing behaviorally identical calls after workflow source edits.",
656
- "Every workflow tool call must provide exactly one of `name` or `script`, never both.",
628
+ "Launch calls must provide exactly one of name or script. Management calls use action, runId, and optionally agentId instead.",
657
629
  ],
658
630
  parameters: Type.Object({
631
+ action: Type.Optional(Type.Union([Type.Literal("start"), Type.Literal("list"), Type.Literal("inspect"), Type.Literal("stop")], { description: "Defaults to start. Management actions do not launch a workflow." })),
632
+ runId: Type.Optional(Type.String({ minLength: 1, description: "Run to inspect or stop" })),
633
+ agentId: Type.Optional(Type.Integer({ minimum: 1, description: "Inspect or stop only this agent" })),
659
634
  name: Type.Optional(Type.String({ description: "Workflow name, e.g. code-review. Provide exactly one of name or script." })),
660
635
  script: Type.Optional(Type.String({ description: "Inline workflow script. Provide exactly one of script or name." })),
661
636
  args: Type.Optional(Type.String({ description: "Arguments for the workflow (e.g. target or focus)" })),
@@ -677,7 +652,7 @@ function registerWorkflowTool(
677
652
  }),
678
653
  ),
679
654
  autoResumeOnUsageLimit: Type.Optional(
680
- Type.Boolean({ description: "For a background run, opt into bounded automatic resume after a recognized provider usage limit" }),
655
+ Type.Boolean({ description: "Opt into bounded automatic resume after a recognized provider usage limit" }),
681
656
  ),
682
657
  usageLimitMaxAttempts: Type.Optional(
683
658
  Type.Integer({
@@ -705,17 +680,16 @@ function registerWorkflowTool(
705
680
  resumeEditedWorkflow: Type.Optional(
706
681
  Type.Boolean({ description: "With resumeFromRunId, ignore only workflow-source fingerprint changes while retaining all other replay checks" }),
707
682
  ),
708
- background: Type.Optional(Type.Boolean({ description: "Return a durable run ID immediately and deliver completion to this conversation later" })),
709
683
  }),
710
684
  renderCall(args, theme) {
711
685
  const suffix = args.args ? ` ${theme.fg("dim", args.args)}` : "";
712
- const background = args.background ? ` ${theme.fg("dim", "(background)")}` : "";
686
+ if (args.action && args.action !== "start") return new Text(`▸ ${theme.fg("toolTitle", "workflow")} ${args.action} ${args.runId ?? ""}`, 0, 0);
713
687
  if (args.name?.trim()) {
714
- return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name.trim())}${background}${suffix}`, 0, 0);
688
+ return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name.trim())}${suffix}`, 0, 0);
715
689
  }
716
690
  const preview = compactInlinePreview(args.script);
717
691
  const previewSuffix = preview ? ` ${theme.fg("dim", preview)}` : "";
718
- return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", "inline")}${background}${suffix}${previewSuffix}`, 0, 0);
692
+ return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", "inline")}${suffix}${previewSuffix}`, 0, 0);
719
693
  },
720
694
  renderResult(result, { expanded, isPartial }, theme) {
721
695
  if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
@@ -731,6 +705,13 @@ function registerWorkflowTool(
731
705
  return new Text(theme.fg("muted", text), 0, 0);
732
706
  },
733
707
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
708
+ if (params.action && params.action !== "start") {
709
+ const { manageWorkflow } = await import("./runtime/workflow-management.ts");
710
+ return await manageWorkflow({ ...params, action: params.action }, ctx, lifecycle, workflowInspectionState(pi, ctx).active);
711
+ }
712
+ if (params.runId !== undefined || params.agentId !== undefined) {
713
+ return { content: [{ type: "text", text: "runId and agentId require inspect or stop." }], details: { error: "invalid_workflow_invocation" } };
714
+ }
734
715
  const request = normalizeWorkflowToolRequest(params);
735
716
  if (request.kind === "error") return invalidWorkflowInvocationResult();
736
717
  const resumeFromRunId = params.resumeFromRunId?.trim();
@@ -746,13 +727,10 @@ function registerWorkflowTool(
746
727
  details: { error: "invalid_edited_workflow_resume" },
747
728
  };
748
729
  }
749
- if (params.background) {
750
- const unavailable = backgroundUnavailableResult(ctx.mode);
751
- if (unavailable) return unavailable;
752
- }
730
+ const unavailable = workflowUnavailableResult(ctx.mode);
731
+ if (unavailable) return unavailable;
753
732
 
754
733
  const runOptions = resolveWorkflowRunOptions({
755
- inspect: ctx.hasUI && ctx.mode === "tui",
756
734
  concurrency: params.concurrency,
757
735
  parallelSubmissionLimit: params.parallelSubmissionLimit,
758
736
  maxAgents: params.maxAgents,
@@ -796,41 +774,15 @@ function registerWorkflowTool(
796
774
  }
797
775
 
798
776
  const resultArgs = params.args ?? "";
799
- if (params.background) {
800
- return await startBackgroundWorkflowTool({
801
- coordinator: backgroundWorkflows,
802
- ctx,
803
- name: resultName,
804
- options: runOptions,
805
- async execute(backgroundCtx, backgroundOptions) {
806
- const execution = await executeResolvedWorkflow(
807
- pi,
808
- backgroundCtx,
809
- resultName,
810
- mod,
811
- resultArgs,
812
- backgroundOptions,
813
- perfRecorder,
814
- );
815
- reviewSessions.remember(ctx, execution, backgroundOptions);
816
- },
817
- });
818
- }
819
- const execution = await executeResolvedWorkflow(pi, ctx, resultName, mod, resultArgs, runOptions, perfRecorder);
820
- reviewSessions.remember(ctx, execution, runOptions);
821
- return {
822
- content: [{
823
- type: "text",
824
- text: formatMessageContent(
825
- resultName,
826
- execution.envelope.result,
827
- execution.envelope.usage,
828
- execution.envelope.perf,
829
- execution.metadata,
830
- ),
831
- }],
832
- details: execution.envelope,
833
- };
777
+ return await lifecycle.launch({
778
+ ctx,
779
+ name: resultName,
780
+ options: runOptions,
781
+ async execute(runCtx, options) {
782
+ const execution = await executeResolvedWorkflow(pi, runCtx, resultName, mod, resultArgs, options, perfRecorder);
783
+ reviewSessions.remember(ctx, execution, options);
784
+ },
785
+ });
834
786
  },
835
787
  });
836
788
  }
@@ -41,7 +41,7 @@ export function needsChallenge(finding: AdvisoryVerified): boolean {
41
41
 
42
42
  /** A bounded recipe using existing agent/parallel calls, not a new runtime primitive. */
43
43
  export async function challengeFindings<T extends AdvisoryVerified>(
44
- api: Pick<WorkflowApi, "agent" | "parallel">,
44
+ api: Pick<WorkflowApi, "agent" | "modelProfile" | "parallel">,
45
45
  findings: T[],
46
46
  context: string,
47
47
  options: AdvisoryChallengeOptions,
@@ -56,12 +56,12 @@ export async function challengeFindings<T extends AdvisoryVerified>(
56
56
  replacements.set(finding.candidateId, { ...finding, verdict: "NOT_SUBSTANTIATED", challenge: { status: "failed" } });
57
57
  const challenge = await api.agent(
58
58
  `Assume this finding is a false positive. Try to DISPROVE it. Find the strongest concrete counterexample or alternative root cause. Inspect callers, invariants, tests and control flow. For a repair, seek an input, race or error path that still fails. State the smallest experiment distinguishing explanations. Do not edit files or claim tests you did not run. No counterexample found is not proof.\n\nExact review context:\n${context}\n\nCandidate and verifier evidence:\n${JSON.stringify(finding)}`,
59
- { label: `challenge:${finding.candidateId}`, phase: "Challenge", profile: "medium", tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS, schema: ChallengeSchema },
59
+ { label: `challenge:${finding.candidateId}`, phase: "Challenge", ...api.modelProfile("medium"), tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS, schema: ChallengeSchema },
60
60
  );
61
61
  replacements.set(finding.candidateId, { ...finding, verdict: "NOT_SUBSTANTIATED", challenge: { status: "failed", challenge } });
62
62
  const adjudication = await api.agent(
63
63
  `Adjudicate the original finding, independent verifier evidence and falsification attempt below. Preserve unresolved conflict; do not force consensus. A missing counterexample alone cannot upgrade a plausible claim. Cite concrete evidence and observed test results; never invent experiments.\nContext:\n${context}\nOriginal and verifier:\n${JSON.stringify(finding)}\nChallenger:\n${JSON.stringify(challenge)}`,
64
- { label: `adjudicate:${finding.candidateId}`, phase: "Challenge", profile: "medium", tools: [], schema: AdjudicationSchema },
64
+ { label: `adjudicate:${finding.candidateId}`, phase: "Challenge", ...api.modelProfile("medium"), tools: [], schema: AdjudicationSchema },
65
65
  );
66
66
  replacements.set(finding.candidateId, {
67
67
  ...finding,
@@ -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);