@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
@@ -0,0 +1,183 @@
1
+ import { basename } from "node:path";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { ClaudeRemoteBridge, type BridgeOptions } from "../../core/claude-remote/bridge.ts";
4
+ import { mirrorMessage } from "../../core/claude-remote/protocol.ts";
5
+ import { env, setEnv } from "../../core/env.ts";
6
+ import { createTokenSource } from "./auth.ts";
7
+ import { remoteControlPicker } from "./picker.ts";
8
+
9
+ interface Bridge {
10
+ start(): Promise<void>;
11
+ stop(): void;
12
+ send: ClaudeRemoteBridge["send"];
13
+ reportState: ClaudeRemoteBridge["reportState"];
14
+ }
15
+
16
+ /** Dependency seams allow lifecycle tests without network access or real credentials. */
17
+ export interface RemoteDependencies {
18
+ bridge(options: BridgeOptions): Bridge;
19
+ tokenSource(): BridgeOptions["getAccessToken"];
20
+ }
21
+
22
+ const defaults: RemoteDependencies = {
23
+ bridge: (options) => new ClaudeRemoteBridge(options),
24
+ tokenSource: () => createTokenSource(),
25
+ };
26
+
27
+ export function registerClaudeRemote(pi: ExtensionAPI, deps: RemoteDependencies = defaults): void {
28
+ let active: Bridge | undefined;
29
+ let status = "off";
30
+ let enabled = false;
31
+ let current: ExtensionContext | undefined;
32
+ let generation = 0;
33
+ // Counts rather than a TTL: follow-ups can wait longer than 30 seconds.
34
+ const echoes: string[] = [];
35
+
36
+ function notify(ctx: ExtensionContext, message: string, warning = false): void {
37
+ ctx.ui.notify(message, warning ? "warning" : "info");
38
+ }
39
+
40
+ function setConnectionStatus(value: string): void {
41
+ status = value;
42
+ if (!current?.hasUI) return;
43
+ const connected = value.startsWith("connected");
44
+ const label = connected ? (value.includes("read-only") ? "Read-only" : "Active")
45
+ : value === "connecting" ? "Connecting" : "Offline";
46
+ current.ui.setStatus("claude-remote",
47
+ current.ui.theme.fg(connected ? "success" : "error", `● Remote Control ${label}`));
48
+ }
49
+
50
+ function stop(): void {
51
+ ++generation;
52
+ active?.stop();
53
+ active = undefined;
54
+ echoes.length = 0;
55
+ setConnectionStatus("off");
56
+ }
57
+
58
+ function start(ctx: ExtensionContext): void {
59
+ if (active) {
60
+ notify(ctx, `Claude Remote: ${status}. Open https://claude.ai/code`);
61
+ return;
62
+ }
63
+ current = ctx;
64
+ const gen = ++generation;
65
+ const title = `pi: ${pi.getSessionName() || basename(ctx.cwd) || "session"}`.slice(0, 100);
66
+ setConnectionStatus("connecting");
67
+ try {
68
+ const allowInbound = !/^(0|false|off|no)$/i.test(env("PI_CLAUDE_REMOTE_ALLOW_INBOUND") ?? "1");
69
+ const bridge = deps.bridge({
70
+ title, allowInbound,
71
+ trustedDeviceToken: env("CLAUDE_TRUSTED_DEVICE_TOKEN"),
72
+ getAccessToken: deps.tokenSource(),
73
+ onText(text) {
74
+ if (generation !== gen || !current || !allowInbound) return;
75
+ if (echoes.length >= 256) throw new Error("Too many remote follow-ups");
76
+ echoes.push(text);
77
+ try {
78
+ pi.sendUserMessage(text, current.isIdle() ? undefined : { deliverAs: "followUp" });
79
+ } catch (error) {
80
+ echoes.splice(echoes.lastIndexOf(text), 1);
81
+ throw error;
82
+ }
83
+ },
84
+ onInterrupt() {
85
+ if (generation === gen && allowInbound) current?.abort();
86
+ },
87
+ onConnect(id) {
88
+ if (generation !== gen) return;
89
+ setConnectionStatus(allowInbound ? "connected" : "connected (read-only)");
90
+ notify(ctx, `Claude Remote: ${title} is live at https://claude.ai/code (${id}).`);
91
+ },
92
+ onConnectionChange(connected) {
93
+ if (generation !== gen) return;
94
+ setConnectionStatus(connected ? (allowInbound ? "connected" : "connected (read-only)") : "connecting");
95
+ },
96
+ onError(message) {
97
+ if (generation !== gen) return;
98
+ stop();
99
+ setConnectionStatus("disconnected");
100
+ notify(ctx, `${message}. Pi continues locally.`, true);
101
+ },
102
+ });
103
+ active = bridge;
104
+ bridge.reportState(ctx.isIdle() ? "idle" : "running");
105
+ void bridge.start().catch(() => {
106
+ if (generation !== gen) return;
107
+ stop();
108
+ setConnectionStatus("disconnected");
109
+ notify(ctx, "Claude Remote could not connect. Run /login for Anthropic, then /claude-remote on.", true);
110
+ });
111
+ } catch {
112
+ stop();
113
+ notify(ctx, "Claude Remote needs an Anthropic OAuth login. Use /login, then /claude-remote on.", true);
114
+ }
115
+ }
116
+
117
+ function setEnabled(next: boolean, ctx: ExtensionContext): boolean {
118
+ enabled = next;
119
+ const saved = setEnv("PI_CLAUDE_REMOTE", next ? "1" : "0");
120
+ if (next) start(ctx);
121
+ else stop();
122
+ if (!saved) notify(ctx, "Could not save preference; changed this session only.", true);
123
+ else if ((env("PI_CLAUDE_REMOTE") === "1") !== next) {
124
+ notify(ctx, "PI_CLAUDE_REMOTE overrides this preference after reload.", true);
125
+ }
126
+ return enabled;
127
+ }
128
+
129
+ pi.registerCommand("claude-remote", {
130
+ description: "Remote Control on/off",
131
+ getArgumentCompletions: (prefix) => ["on", "off"]
132
+ .filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value })),
133
+ handler: async (args, ctx) => {
134
+ const action = args.trim().toLowerCase();
135
+ if (!action && ctx.mode === "tui") {
136
+ await ctx.ui.custom((_tui, theme, _keys, done) => remoteControlPicker(
137
+ theme, enabled, (next) => setEnabled(next, ctx), () => done(undefined),
138
+ ));
139
+ } else if (action === "on" || action === "off") {
140
+ if (action === "on" && ctx.hasUI && !enabled && !await ctx.ui.confirm("Enable Remote Control?",
141
+ "Share sessions with Anthropic and control pi from the Claude app. Auto-starts in interactive sessions.")) return;
142
+ setEnabled(action === "on", ctx);
143
+ } else notify(ctx, "Usage: /claude-remote [on|off]", true);
144
+ },
145
+ });
146
+
147
+ pi.on("session_start", (_event, ctx) => {
148
+ stop();
149
+ current = ctx;
150
+ setConnectionStatus("off");
151
+ enabled = env("PI_CLAUDE_REMOTE") === "1";
152
+ // Never spawn remote mirrors for workflow/SDK/print subagents by default.
153
+ if (ctx.mode === "tui" && enabled) start(ctx);
154
+ });
155
+ pi.on("session_shutdown", (_event, ctx) => {
156
+ stop();
157
+ if (ctx.hasUI) ctx.ui.setStatus("claude-remote", undefined);
158
+ current = undefined;
159
+ });
160
+ pi.on("session_tree", (_event, ctx) => {
161
+ const wasActive = !!active;
162
+ stop();
163
+ current = ctx;
164
+ if (wasActive) start(ctx); // an old branch's remote input must not control a new branch
165
+ });
166
+ pi.on("message_end", (event, ctx) => {
167
+ current = ctx;
168
+ if (!active) return;
169
+ const message = event.message;
170
+ if (message.role === "user") {
171
+ const text = typeof message.content === "string" ? message.content
172
+ : message.content.filter((block) => block.type === "text").map((block) => block.text).join("");
173
+ const index = echoes.indexOf(text);
174
+ if (index !== -1) { echoes.splice(index, 1); return; }
175
+ }
176
+ const outbound = mirrorMessage(message);
177
+ if (outbound) active.send(outbound);
178
+ });
179
+ pi.on("agent_start", (_event, ctx) => { current = ctx; active?.reportState("running"); });
180
+ pi.on("agent_end", (_event, ctx) => { current = ctx; active?.reportState("idle"); });
181
+ }
182
+
183
+ export default function claudeRemote(pi: ExtensionAPI): void { registerClaudeRemote(pi); }
@@ -0,0 +1,36 @@
1
+ import { SettingsList, type Component } from "@earendil-works/pi-tui";
2
+ import { hasTruecolor, levelColor } from "../../ui/format.ts";
3
+ import { frameSettings, settingsTheme } from "../../ui/settings-picker.ts";
4
+
5
+ /** Same dot, colors and in-place SettingsList toggle as /provider. */
6
+ export function remoteControlPicker(
7
+ theme: any,
8
+ initial: boolean,
9
+ toggle: (enabled: boolean) => boolean,
10
+ done: () => void,
11
+ ): Component {
12
+ let enabled = initial;
13
+ const color = (value: boolean, text: string) => hasTruecolor()
14
+ ? levelColor(value ? 100 : 0)(text) : theme.fg(value ? "success" : "error", text);
15
+ const label = () => `${color(enabled, "●")} Remote Control`;
16
+ const value = () => color(enabled, enabled ? "On" : "Off");
17
+ const item = {
18
+ id: "remote-control", label: label(), currentValue: value(),
19
+ values: [color(true, "On"), color(false, "Off")],
20
+ };
21
+ const list = new SettingsList([item], 1, settingsTheme(theme), () => {
22
+ enabled = toggle(!enabled);
23
+ item.label = label();
24
+ list.updateValue(item.id, value());
25
+ }, done, { enableSearch: false });
26
+ const frame = frameSettings(theme, list, "Remote Control");
27
+ return {
28
+ ...frame,
29
+ invalidate() {
30
+ item.values = [color(true, "On"), color(false, "Off")];
31
+ item.label = label();
32
+ list.updateValue(item.id, value());
33
+ frame.invalidate();
34
+ },
35
+ };
36
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Component } from "@earendil-works/pi-tui";
2
2
  import { hasTruecolor, levelColor } from "../../ui/format.ts";
3
+ import { frameSettings, settingsTheme } from "../../ui/settings-picker.ts";
3
4
 
4
5
  /**
5
6
  * The `/provider` picker.
@@ -79,50 +80,6 @@ function labelFor(theme: any, row: ProviderRow): string {
79
80
  return `${dot} ${name}`;
80
81
  }
81
82
 
82
- /**
83
- * The chrome pi uses for `/model` and its other in-chat pickers.
84
- *
85
- * Not a box: a full-width accent rule, a bold title, the body, then a closing
86
- * rule. Reproduced from pi's own `frame(theme, title, body, footer)` helper so
87
- * this reads as part of the chat flow rather than as a floating dialog.
88
- *
89
- * Input and mouse events pass straight through, so the frame is presentation
90
- * only and does not disturb the in-place updates.
91
- */
92
- function framed(theme: any, list: any, title: string): Component {
93
- const rule = (width: number) => theme.fg("accent", "─".repeat(Math.max(1, width)));
94
-
95
- return {
96
- invalidate: () => list.invalidate?.(),
97
- handleInput: (data: string) => list.handleInput(data),
98
- handleMouse: (event: any) => list.handleMouse?.(event),
99
- render(width: number): string[] {
100
- const inner = Math.max(1, width);
101
- // pi pads title and footer by one column; the list renders flush.
102
- return [
103
- rule(inner),
104
- ` ${theme.fg("accent", theme.bold(title))}`,
105
- ...list.render(inner),
106
- rule(inner),
107
- ];
108
- },
109
- } as Component;
110
- }
111
-
112
- /**
113
- * Theme for the picker. Every callback takes `selected` so the highlighted row
114
- * can be emphasised without the caller tracking cursor position.
115
- */
116
- function pickerTheme(theme: any) {
117
- return {
118
- label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
119
- value: (text: string, _selected: boolean) => text,
120
- description: (text: string) => theme.fg("dim", text),
121
- cursor: theme.fg("accent", "›"),
122
- hint: (text: string) => theme.fg("dim", text),
123
- };
124
- }
125
-
126
83
  export interface PickerDeps {
127
84
  /** Re-reads the current rows, so the picker never shows stale state. */
128
85
  rows: () => Promise<ProviderRow[]>;
@@ -195,13 +152,13 @@ export async function openProviderPicker(ctx: any, deps: PickerDeps): Promise<vo
195
152
  list = new SettingsList(
196
153
  items,
197
154
  12,
198
- pickerTheme(theme),
155
+ settingsTheme(theme),
199
156
  onChange,
200
157
  () => done(undefined),
201
158
  { enableSearch: false },
202
159
  );
203
160
 
204
- return framed(theme, list, "Providers") as Component & { dispose?(): void };
161
+ return frameSettings(theme, list, "Providers") as Component & { dispose?(): void };
205
162
  });
206
163
  // No `overlay` option: the picker renders inline in the chat flow rather than
207
164
  // floating over it.
@@ -131,6 +131,17 @@ async function inspect(ctx: any): Promise<Feature[]> {
131
131
  open: "/remote setup",
132
132
  });
133
133
 
134
+ /* Claude app remote control (preference only; never connect from the hub). */
135
+ const claudeRemote = env("PI_CLAUDE_REMOTE") === "1";
136
+ features.push({
137
+ name: "Claude Remote",
138
+ ready: claudeRemote,
139
+ detail: claudeRemote ? "auto-start enabled for interactive sessions" : "opt-in Claude app mirror; requires Anthropic OAuth",
140
+ commands: ["/claude-remote", "/claude-remote on", "/claude-remote off"],
141
+ setup: "/claude-remote",
142
+ open: "/claude-remote",
143
+ });
144
+
134
145
  return features;
135
146
  }
136
147
 
@@ -166,7 +177,7 @@ function buildBrief(features: Feature[]): string {
166
177
  `Config file: ${configPath()}${existsSync(configPath()) ? "" : " (not created yet)"}`,
167
178
  "",
168
179
  "Write the reply yourself, in chat. Requirements:",
169
- "1. One short sentence on what pi-plus is: four capabilities in one package.",
180
+ "1. One short sentence on what pi-plus is: extensions for subscriptions, models, workflows, and remote collaboration.",
170
181
  "2. A compact list of the capabilities, each with one line on what it does and the command to try. Mark which are already working.",
171
182
  pending.length > 0
172
183
  ? `3. Then a short 'Set these up next' section covering ONLY the ones marked NOT SET UP (${pending.map((f) => f.name).join(", ")}), each with the single command to run and one line on what it will ask for.`
@@ -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,