pi-workflow-engine 0.5.0 → 0.7.0

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.
@@ -1,10 +1,14 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { Type } from "typebox";
3
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { Text } from "@earendil-works/pi-tui";
5
- import type { WorkflowModule, WorkflowRunOptions } from "./src/types.ts";
5
+ import type { WorkflowProgressSnapshot } from "./src/progress.ts";
6
+ import type { WorkflowModule, WorkflowProgressSource, WorkflowRef, WorkflowRunOptions } from "./src/types.ts";
7
+ import { WorkflowInspector } from "./src/ui/workflow-inspector.ts";
6
8
  import type { PerfSink, PerfSnapshot } from "./src/perf.ts";
7
9
  import { registerDynamax } from "./src/dynamax.ts";
10
+ import { handleReviewViewerAction } from "./src/review/review-actions.ts";
11
+ import { decideReviewResultsPresentation, extensionContextMode, maybeShowReviewResultsViewer } from "./src/review/review-results-flow.ts";
8
12
  import { isWorkflowResult, renderWorkflowResult, type WorkflowPerfDetails, type WorkflowResultEnvelope } from "./src/ui/workflow-result-renderer.ts";
9
13
 
10
14
  /** Extension root (this file lives in <repo>/.pi/extensions/pi-workflow-engine/index.ts). */
@@ -60,8 +64,76 @@ async function createInvocationPerf(options: WorkflowRunOptions): Promise<PerfSi
60
64
  return createPerfRecorder(true);
61
65
  }
62
66
 
67
+ /**
68
+ * Resolve an `api.workflow()` reference to a registered workflow module. Throws on an unknown name.
69
+ */
70
+ export async function resolveWorkflowRef(ref: WorkflowRef, perf?: PerfSink): Promise<WorkflowModule> {
71
+ const { discoverWorkflows } = await loadDiscovery();
72
+ const workflows = await discoverWorkflows(EXTENSION_DIR, { perf });
73
+ const mod = workflows.get(ref);
74
+ if (!mod) {
75
+ const available = [...workflows.keys()].join(", ") || "(none)";
76
+ throw new Error(`Unknown workflow "${ref}". Available: ${available}`);
77
+ }
78
+ return mod;
79
+ }
80
+
63
81
  const AUTHOR_TEMP_WORKFLOW_CHOICE = "✍ Author temporary one-shot workflow…";
64
82
 
83
+ export interface LastWorkflowInspection {
84
+ readonly name: string;
85
+ readonly args: string;
86
+ readonly completedAt: number;
87
+ readonly snapshot: WorkflowProgressSnapshot;
88
+ }
89
+
90
+ export interface ActiveWorkflowInspection {
91
+ readonly name: string;
92
+ readonly args: string;
93
+ readonly startedAt: number;
94
+ readonly snapshot: () => WorkflowProgressSnapshot;
95
+ }
96
+
97
+ let lastWorkflowInspection: LastWorkflowInspection | undefined;
98
+ let activeWorkflowInspection: ActiveWorkflowInspection | undefined;
99
+
100
+ export function getLastWorkflowInspection(): LastWorkflowInspection | undefined {
101
+ return lastWorkflowInspection;
102
+ }
103
+
104
+ export function getActiveWorkflowInspection(): ActiveWorkflowInspection | undefined {
105
+ return activeWorkflowInspection;
106
+ }
107
+
108
+ export async function openWorkflowInspector(ctx: ExtensionContext, inspection: LastWorkflowInspection | ActiveWorkflowInspection): Promise<void> {
109
+ await ctx.ui.custom<void>(
110
+ (tui, theme, _keybindings, done) => new WorkflowInspector(snapshotGetter(inspection), tui, theme, () => done(undefined)),
111
+ { overlay: true, overlayOptions: { anchor: "right-center", width: "60%", maxHeight: "80%", margin: 1 } },
112
+ );
113
+ }
114
+
115
+ async function openAvailableWorkflowInspector(ctx: ExtensionContext): Promise<void> {
116
+ if (!ctx.hasUI) {
117
+ ctx.ui.notify("Workflow inspector requires the TUI", "warning");
118
+ return;
119
+ }
120
+ const inspection = activeWorkflowInspection ?? lastWorkflowInspection;
121
+ if (!inspection) {
122
+ ctx.ui.notify("No workflow inspector is available yet", "warning");
123
+ return;
124
+ }
125
+ await openWorkflowInspector(ctx, inspection);
126
+ }
127
+
128
+ function snapshotGetter(inspection: LastWorkflowInspection | ActiveWorkflowInspection): () => WorkflowProgressSnapshot {
129
+ const snapshot = inspection.snapshot;
130
+ return typeof snapshot === "function" ? snapshot : () => snapshot;
131
+ }
132
+
133
+ function bindActiveWorkflowInspection(name: string, args: string, source: WorkflowProgressSource): ActiveWorkflowInspection {
134
+ return { name, args, startedAt: Date.now(), snapshot: () => source.snapshot() };
135
+ }
136
+
65
137
  export interface WorkflowInvocation {
66
138
  name: string;
67
139
  args: string;
@@ -98,6 +170,14 @@ function parseWorkflowOptions(input: string): { args: string; options: WorkflowR
98
170
  options.perf = true;
99
171
  continue;
100
172
  }
173
+ if (token === "--result-viewer" || token === "--review-viewer") {
174
+ options.resultViewer = "open";
175
+ continue;
176
+ }
177
+ if (token === "--no-result-viewer" || token === "--no-review-viewer") {
178
+ options.resultViewer = "skip";
179
+ continue;
180
+ }
101
181
  if (token.startsWith("--concurrency=")) {
102
182
  options.concurrency = parseNumericOption(token.slice("--concurrency=".length));
103
183
  continue;
@@ -182,7 +262,7 @@ export function inlineCompileErrorResult(message: string): WorkflowToolErrorResu
182
262
  return { content: [{ type: "text", text: `Inline workflow did not compile: ${message}` }], details: { error: "inline_compile_error", message } };
183
263
  }
184
264
 
185
- async function pickWorkflow(
265
+ export async function pickWorkflow(
186
266
  workflows: ReadonlyMap<string, WorkflowModule>,
187
267
  ctx: ExtensionCommandContext,
188
268
  ): Promise<WorkflowInvocation | undefined> {
@@ -203,11 +283,10 @@ async function pickWorkflow(
203
283
  const separator = choice.indexOf(" — ");
204
284
  const name = separator === -1 ? choice : choice.slice(0, separator);
205
285
  const args = name === "code-review" ? (await ctx.ui.input("Code-review target/instructions", "Blank = auto-detect diff"))?.trim() ?? "" : "";
206
- const inspect = await ctx.ui.confirm("Open inspector?", "Open a live workflow inspector while this workflow runs?");
207
- return { name, args, options: { inspect } };
286
+ return { name, args, options: {} };
208
287
  }
209
288
 
210
- async function sendWorkflowResult(
289
+ export async function sendWorkflowResult(
211
290
  pi: ExtensionAPI,
212
291
  ctx: ExtensionCommandContext,
213
292
  name: string,
@@ -218,16 +297,44 @@ async function sendWorkflowResult(
218
297
  ): Promise<void> {
219
298
  const { runWorkflow } = await loadEngine();
220
299
  let perfSnapshot: PerfSnapshot | undefined;
300
+ let liveInspection: ActiveWorkflowInspection | undefined;
221
301
  const result = await runWorkflow(ctx, mod, args, {
222
302
  ...options,
223
303
  perf: options.perf ?? perfRecorder !== undefined,
224
304
  perfRecorder,
305
+ resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
306
+ onProgressSource(source) {
307
+ if (source) {
308
+ liveInspection = bindActiveWorkflowInspection(name, args, source);
309
+ activeWorkflowInspection = liveInspection;
310
+ } else if (activeWorkflowInspection === liveInspection) {
311
+ activeWorkflowInspection = undefined;
312
+ liveInspection = undefined;
313
+ }
314
+ options.onProgressSource?.(source);
315
+ },
225
316
  onPerfSnapshot(snapshot) {
226
317
  perfSnapshot = snapshot;
227
318
  options.onPerfSnapshot?.(snapshot);
228
319
  },
320
+ onProgressSnapshot(snapshot) {
321
+ lastWorkflowInspection = { name, args, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
322
+ options.onProgressSnapshot?.(snapshot);
323
+ },
229
324
  });
230
325
  const perf = compactPerfSnapshot(perfSnapshot);
326
+ const reviewDecision = decideReviewResultsPresentation({
327
+ workflowName: name,
328
+ result,
329
+ mode: extensionContextMode(ctx),
330
+ hasUI: ctx.hasUI,
331
+ resultViewer: options.resultViewer,
332
+ invocationKind: "command",
333
+ });
334
+ const reviewAction = await maybeShowReviewResultsViewer(ctx, reviewDecision);
335
+ if (reviewDecision.kind !== "send") {
336
+ await handleReviewViewerAction(pi, ctx, reviewAction, reviewDecision.issues, reviewDecision.report.reviewContext);
337
+ }
231
338
  pi.sendMessage(
232
339
  { customType: "workflow-result", content: formatMessageContent(name, result, perf), display: true, details: workflowEnvelope(name, result, perf) },
233
340
  { triggerTurn: false },
@@ -235,7 +342,7 @@ async function sendWorkflowResult(
235
342
  }
236
343
 
237
344
  export default function workflowEngine(pi: ExtensionAPI): void {
238
- registerDynamax(pi);
345
+ registerDynamax(pi, undefined, { openInspector: openAvailableWorkflowInspector });
239
346
 
240
347
  pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
241
348
  const details = message.details;
@@ -243,6 +350,18 @@ export default function workflowEngine(pi: ExtensionAPI): void {
243
350
  return renderWorkflowResult("workflow", details ?? message.content, expanded, theme);
244
351
  });
245
352
 
353
+ pi.registerCommand("workflow:inspector", {
354
+ description: "Open the current or last workflow inspector",
355
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
356
+ const trimmed = args.trim();
357
+ if (trimmed && trimmed !== "last") {
358
+ ctx.ui.notify("Usage: /workflow:inspector [last]", "warning");
359
+ return;
360
+ }
361
+ await openAvailableWorkflowInspector(ctx);
362
+ },
363
+ });
364
+
246
365
  // /workflow <name> [args] — user-invoked.
247
366
  pi.registerCommand("workflow", {
248
367
  description: "Run a multi-agent workflow: /workflow <name> [args]",
@@ -279,13 +398,14 @@ export default function workflowEngine(pi: ExtensionAPI): void {
279
398
  name: "workflow",
280
399
  label: "Workflow",
281
400
  description:
282
- "ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize) and returns its structured result.",
401
+ "ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/workflow:dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize) and returns its structured result.",
283
402
  promptSnippet: "Run an existing named workflow or an inline one-off workflow script",
284
403
  promptGuidelines: [
285
- "Use workflow only when the user opted into workflow orchestration via `dynamax`, `/dynamax on`, an explicit request to run/author a workflow, or a command/skill instruction.",
404
+ "Use workflow only when the user opted into workflow orchestration via `dynamax`, `/workflow:dynamax on`, an explicit request to run/author a workflow, or a command/skill instruction.",
286
405
  "Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.",
287
406
  "Use workflow with `script` for a new one-off inline workflow; the script must start with `export const meta = { ... }` and default-export an async workflow function.",
288
407
  "Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
408
+ "Inline scripts may compose registered workflows in-process via `api.workflow(\"<name>\", args)` (e.g. `await api.workflow(\"code-review\", \"HEAD~3\")`); it returns the sub-workflow's result and nests one level only.",
289
409
  "Every workflow tool call must provide exactly one of `name` or `script`, never both.",
290
410
  ],
291
411
  parameters: Type.Object({
@@ -318,6 +438,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
318
438
  if (request.kind === "error") return invalidWorkflowInvocationResult();
319
439
 
320
440
  const runOptions: WorkflowRunOptions = {
441
+ inspect: ctx.hasUI,
321
442
  concurrency: params.concurrency,
322
443
  parallelSubmissionLimit: params.parallelSubmissionLimit,
323
444
  perf: params.perf,
@@ -353,13 +474,30 @@ export default function workflowEngine(pi: ExtensionAPI): void {
353
474
 
354
475
  const { runWorkflow } = await loadEngine();
355
476
  let perfSnapshot: PerfSnapshot | undefined;
356
- const result = await runWorkflow(ctx, mod, params.args ?? "", {
477
+ let liveInspection: ActiveWorkflowInspection | undefined;
478
+ const resultArgs = params.args ?? "";
479
+ const result = await runWorkflow(ctx, mod, resultArgs, {
357
480
  ...runOptions,
358
481
  perf: runOptions.perf ?? perfRecorder !== undefined,
359
482
  perfRecorder,
483
+ resolveWorkflow: (ref) => resolveWorkflowRef(ref, perfRecorder),
484
+ onProgressSource(source) {
485
+ if (source) {
486
+ liveInspection = bindActiveWorkflowInspection(resultName, resultArgs, source);
487
+ activeWorkflowInspection = liveInspection;
488
+ } else if (activeWorkflowInspection === liveInspection) {
489
+ activeWorkflowInspection = undefined;
490
+ liveInspection = undefined;
491
+ }
492
+ },
360
493
  onPerfSnapshot: (snapshot) => {
361
494
  perfSnapshot = snapshot;
362
495
  },
496
+ onProgressSnapshot: (snapshot) => {
497
+ // Record the run so /workflow:inspector can reopen it — tool-invoked (dynamax) runs were
498
+ // previously uninspectable, unlike the /workflow command path.
499
+ lastWorkflowInspection = { name: resultName, args: resultArgs, completedAt: snapshot.doneAt ?? Date.now(), snapshot };
500
+ },
363
501
  });
364
502
  const perf = compactPerfSnapshot(perfSnapshot);
365
503
  const perfLine = formatPerfLine(perf);
@@ -0,0 +1,75 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import type { KeyId } from "@earendil-works/pi-tui";
5
+
6
+ export const DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT = "ctrl+shift+m" satisfies KeyId;
7
+
8
+ const CONFIG_FILE_NAME = "pi-workflow-engine.json";
9
+
10
+ export interface DynamaxShortcuts {
11
+ inspector: KeyId | null;
12
+ }
13
+
14
+ interface DynamaxShortcutConfig {
15
+ inspector?: unknown;
16
+ }
17
+
18
+ export function dynamaxShortcutsConfigPath(agentDir: string = getAgentDir()): string {
19
+ return join(agentDir, "extensions", CONFIG_FILE_NAME);
20
+ }
21
+
22
+ export function resolveDynamaxShortcuts(configPath: string = dynamaxShortcutsConfigPath()): DynamaxShortcuts {
23
+ if (!existsSync(configPath)) return defaultDynamaxShortcuts();
24
+
25
+ const config = readShortcutConfig(configPath);
26
+ if (!config) return defaultDynamaxShortcuts();
27
+
28
+ return {
29
+ inspector: shortcutFromConfig(config.inspector, DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT),
30
+ };
31
+ }
32
+
33
+ function readShortcutConfig(configPath: string): DynamaxShortcutConfig | null {
34
+ let parsed: unknown;
35
+ try {
36
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
37
+ } catch {
38
+ warnUsingDefaults("Could not read", configPath);
39
+ return null;
40
+ }
41
+
42
+ const shortcuts = isRecord(parsed) ? parsed.shortcuts : undefined;
43
+ if (shortcuts === undefined) return {};
44
+ if (!isRecord(shortcuts) || !hasValidShortcutValues(shortcuts)) {
45
+ warnUsingDefaults("Invalid", configPath);
46
+ return null;
47
+ }
48
+
49
+ return shortcuts;
50
+ }
51
+
52
+ function hasValidShortcutValues(shortcuts: Record<string, unknown>): boolean {
53
+ return isValidShortcutConfigValue(shortcuts.inspector);
54
+ }
55
+
56
+ function isValidShortcutConfigValue(value: unknown): value is string | null | undefined {
57
+ return value === undefined || value === null || (typeof value === "string" && value.trim().length > 0);
58
+ }
59
+
60
+ function shortcutFromConfig(configured: unknown, fallback: KeyId): KeyId | null {
61
+ if (configured === null) return null;
62
+ return typeof configured === "string" ? (configured.trim() as KeyId) : fallback;
63
+ }
64
+
65
+ function defaultDynamaxShortcuts(): DynamaxShortcuts {
66
+ return { inspector: DEFAULT_DYNAMAX_INSPECTOR_SHORTCUT };
67
+ }
68
+
69
+ function isRecord(value: unknown): value is Record<string, unknown> {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
+ }
72
+
73
+ function warnUsingDefaults(reason: "Could not read" | "Invalid", configPath: string): void {
74
+ console.warn(`${reason} pi-workflow-engine config at ${configPath}; using default Dynamax shortcuts.`);
75
+ }
@@ -1,12 +1,27 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { KeyId } from "@earendil-works/pi-tui";
4
+ import { resolveDynamaxShortcuts, type DynamaxShortcuts } from "./dynamax-shortcuts.ts";
3
5
 
4
6
  export interface DynamaxState {
5
7
  sticky: boolean;
6
8
  oneShotPending: boolean;
9
+ turnActive: boolean;
10
+ }
11
+
12
+ export interface DynamaxRuntime {
13
+ state: DynamaxState;
14
+ runningWorkflow?: string;
15
+ }
16
+
17
+ export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
18
+ export interface DynamaxRegistrationOptions {
19
+ openInspector?: (ctx: ExtensionContext) => Promise<void> | void;
7
20
  }
8
21
 
9
22
  export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
23
+ export const DYNAMAX_STATUS_KEY = "dynamax";
24
+ export const DYNAMAX_WIDGET_KEY = "workflow-dynamax";
10
25
 
11
26
  export const DYNAMAX_REMINDER = `
12
27
  ## dynamax workflow opt-in
@@ -23,7 +38,24 @@ Inline workflow rules:
23
38
  const DYNAMAX_CONTEXT_CUSTOM_TYPE = "workflow-dynamax-reminder";
24
39
 
25
40
  export function createDynamaxState(): DynamaxState {
26
- return { sticky: false, oneShotPending: false };
41
+ return { sticky: false, oneShotPending: false, turnActive: false };
42
+ }
43
+
44
+ export function createDynamaxRuntime(): DynamaxRuntime {
45
+ return { state: createDynamaxState() };
46
+ }
47
+
48
+ export function dynamaxSessionKey(ctx: Pick<ExtensionContext, "sessionManager">): string {
49
+ return ctx.sessionManager.getSessionFile() ?? ctx.sessionManager.getSessionId();
50
+ }
51
+
52
+ export function getDynamaxRuntime(store: DynamaxRuntimeStore, ctx: Pick<ExtensionContext, "sessionManager">): DynamaxRuntime {
53
+ const key = dynamaxSessionKey(ctx);
54
+ const existing = store.get(key);
55
+ if (existing) return existing;
56
+ const runtime = createDynamaxRuntime();
57
+ store.set(key, runtime);
58
+ return runtime;
27
59
  }
28
60
 
29
61
  export function hasDynamaxToken(text: string): boolean {
@@ -36,12 +68,16 @@ export function markDynamaxOneShot(state: DynamaxState): void {
36
68
 
37
69
  export function setDynamaxSticky(state: DynamaxState, sticky: boolean): void {
38
70
  state.sticky = sticky;
39
- if (!sticky) state.oneShotPending = false;
71
+ if (!sticky) {
72
+ state.oneShotPending = false;
73
+ state.turnActive = false;
74
+ }
40
75
  }
41
76
 
42
77
  export function clearDynamax(state: DynamaxState): void {
43
78
  state.sticky = false;
44
79
  state.oneShotPending = false;
80
+ state.turnActive = false;
45
81
  }
46
82
 
47
83
  export function consumeDynamaxOneShot(state: DynamaxState): boolean {
@@ -51,12 +87,32 @@ export function consumeDynamaxOneShot(state: DynamaxState): boolean {
51
87
  }
52
88
 
53
89
  export function isDynamaxActive(state: DynamaxState): boolean {
54
- return state.sticky || state.oneShotPending;
90
+ return state.sticky || state.oneShotPending || state.turnActive;
91
+ }
92
+
93
+ export function describeDynamaxState(state: DynamaxState): string {
94
+ const sticky = state.sticky ? "on" : "off";
95
+ const oneShot = state.oneShotPending ? "pending" : "clear";
96
+ const turn = state.turnActive ? "; active for current turn" : "";
97
+ return `sticky ${sticky}; one-shot ${oneShot}${turn}`;
98
+ }
99
+
100
+ export function dynamaxWidgetLine(state: DynamaxState, shortcut: KeyId | null, runningWorkflow?: string): string {
101
+ const modes = [
102
+ runningWorkflow ? `running ${runningWorkflow}` : undefined,
103
+ state.sticky ? "sticky on" : undefined,
104
+ !state.sticky && state.turnActive ? "active this turn" : undefined,
105
+ state.oneShotPending ? "one-shot pending" : undefined,
106
+ ].filter((value): value is string => value !== undefined);
107
+ const mode = modes.join(" + ") || "off";
108
+ const inspectorHint = shortcut ? `${shortcut} inspector` : "/workflow:inspector";
109
+ return `dynamax: ${mode} | /workflow:dynamax on|off | ${inspectorHint}`;
55
110
  }
56
111
 
57
112
  export function appendDynamaxSystemReminder(systemPrompt: string, state: DynamaxState): string {
58
- if (!isDynamaxActive(state)) return systemPrompt;
113
+ if (!state.sticky && !state.oneShotPending) return systemPrompt;
59
114
  state.oneShotPending = false;
115
+ state.turnActive = true;
60
116
  return `${systemPrompt}\n\n${DYNAMAX_REMINDER.trim()}`;
61
117
  }
62
118
 
@@ -65,52 +121,174 @@ export function appendDynamaxContextReminder(messages: AgentMessage[], state: Dy
65
121
  return [...messages, createDynamaxContextMessage()];
66
122
  }
67
123
 
68
- export function registerDynamax(pi: ExtensionAPI): void {
69
- const state = createDynamaxState();
124
+ export function registerDynamax(
125
+ pi: ExtensionAPI,
126
+ shortcuts: DynamaxShortcuts = resolveDynamaxShortcuts(),
127
+ options: DynamaxRegistrationOptions = {},
128
+ ): void {
129
+ const runtimes: DynamaxRuntimeStore = new Map();
130
+ const openInspector =
131
+ options.openInspector ??
132
+ ((ctx: ExtensionContext): void => {
133
+ ctx.ui.notify("No workflow inspector is available yet", "warning");
134
+ });
135
+ const refreshIfVisible = (ctx: ExtensionContext): void => {
136
+ const runtime = getDynamaxRuntime(runtimes, ctx);
137
+ if (isDynamaxActive(runtime.state) || runtime.runningWorkflow) {
138
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
139
+ }
140
+ };
141
+
142
+ pi.on("session_start", (_event, ctx) => {
143
+ updateDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx), shortcuts);
144
+ });
145
+
146
+ pi.on("session_shutdown", (_event, ctx) => {
147
+ clearDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx));
148
+ });
70
149
 
71
- pi.on("input", (event) => {
150
+ pi.on("input", (event, ctx) => {
72
151
  if (event.source === "extension") return { action: "continue" };
73
- if (hasDynamaxToken(event.text)) markDynamaxOneShot(state);
152
+ const runtime = getDynamaxRuntime(runtimes, ctx);
153
+ if (hasDynamaxToken(event.text)) {
154
+ markDynamaxOneShot(runtime.state);
155
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
156
+ }
74
157
  return { action: "continue" };
75
158
  });
76
159
 
77
- pi.on("before_agent_start", (event) => {
78
- const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, state);
160
+ pi.on("before_agent_start", (event, ctx) => {
161
+ const runtime = getDynamaxRuntime(runtimes, ctx);
162
+ const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, runtime.state);
79
163
  if (systemPrompt === event.systemPrompt) return undefined;
164
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
80
165
  return { systemPrompt };
81
166
  });
82
167
 
83
- pi.on("context", (event) => {
84
- const messages = appendDynamaxContextReminder(event.messages, state);
168
+ pi.on("agent_start", (_event, ctx) => {
169
+ refreshIfVisible(ctx);
170
+ });
171
+
172
+ pi.on("message_start", (_event, ctx) => {
173
+ refreshIfVisible(ctx);
174
+ });
175
+
176
+ pi.on("message_update", (_event, ctx) => {
177
+ refreshIfVisible(ctx);
178
+ });
179
+
180
+ pi.on("message_end", (_event, ctx) => {
181
+ refreshIfVisible(ctx);
182
+ });
183
+
184
+ pi.on("turn_end", (_event, ctx) => {
185
+ refreshIfVisible(ctx);
186
+ });
187
+
188
+ pi.on("agent_end", (_event, ctx) => {
189
+ const runtime = getDynamaxRuntime(runtimes, ctx);
190
+ runtime.runningWorkflow = undefined;
191
+ if (!runtime.state.sticky) runtime.state.turnActive = false;
192
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
193
+ });
194
+
195
+ pi.on("tool_execution_start", (event, ctx) => {
196
+ const runtime = getDynamaxRuntime(runtimes, ctx);
197
+ if (event.toolName === "workflow") {
198
+ runtime.runningWorkflow = workflowLabel(event.args);
199
+ }
200
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
201
+ return undefined;
202
+ });
203
+
204
+ pi.on("tool_execution_update", (_event, ctx) => {
205
+ refreshIfVisible(ctx);
206
+ });
207
+
208
+ pi.on("tool_execution_end", (event, ctx) => {
209
+ const runtime = getDynamaxRuntime(runtimes, ctx);
210
+ if (event.toolName === "workflow") {
211
+ runtime.runningWorkflow = undefined;
212
+ }
213
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
214
+ return undefined;
215
+ });
216
+
217
+ pi.on("context", (event, ctx) => {
218
+ const runtime = getDynamaxRuntime(runtimes, ctx);
219
+ const messages = appendDynamaxContextReminder(event.messages, runtime.state);
85
220
  if (messages === event.messages) return undefined;
86
221
  return { messages };
87
222
  });
88
223
 
89
- pi.registerCommand("dynamax", {
90
- description: "Toggle dynamax workflow orchestration opt-in: /dynamax on|off|status",
224
+ if (shortcuts.inspector) {
225
+ pi.registerShortcut(shortcuts.inspector, {
226
+ description: "Open workflow inspector",
227
+ handler: async (ctx) => {
228
+ await openInspector(ctx);
229
+ },
230
+ });
231
+ }
232
+
233
+ pi.registerCommand("workflow:dynamax", {
234
+ description: "Toggle Dynamax workflow orchestration: /workflow:dynamax [on|off|status]",
91
235
  handler: async (args, ctx) => {
236
+ const runtime = getDynamaxRuntime(runtimes, ctx);
92
237
  const action = args.trim().toLowerCase();
238
+ if (action === "") {
239
+ ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}. Usage: /workflow:dynamax [on|off|status]`, "info");
240
+ return;
241
+ }
93
242
  if (action === "on") {
94
- setDynamaxSticky(state, true);
95
- ctx.ui.notify("dynamax workflow orchestration is on for this session", "info");
243
+ setDynamaxSticky(runtime.state, true);
244
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
245
+ ctx.ui.notify("Dynamax workflow orchestration is on for this session", "info");
96
246
  return;
97
247
  }
98
248
  if (action === "off") {
99
- clearDynamax(state);
100
- ctx.ui.notify("dynamax workflow orchestration is off", "info");
249
+ clearDynamax(runtime.state);
250
+ updateDynamaxSurfaces(ctx, runtime, shortcuts);
251
+ ctx.ui.notify("Dynamax workflow orchestration is off", "info");
101
252
  return;
102
253
  }
103
254
  if (action === "status") {
104
- const sticky = state.sticky ? "on" : "off";
105
- const oneShot = state.oneShotPending ? "; one-shot opt-in pending" : "";
106
- ctx.ui.notify(`dynamax sticky mode is ${sticky}${oneShot}`, "info");
255
+ ctx.ui.notify(`Dynamax ${describeDynamaxState(runtime.state)}`, "info");
107
256
  return;
108
257
  }
109
- ctx.ui.notify("Usage: /dynamax on|off|status", "warning");
258
+ ctx.ui.notify("Usage: /workflow:dynamax [on|off|status]", "warning");
110
259
  },
111
260
  });
112
261
  }
113
262
 
263
+ export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime, shortcuts: DynamaxShortcuts): void {
264
+ if (!ctx.hasUI) return;
265
+ ctx.ui.setWidget(DYNAMAX_WIDGET_KEY, undefined);
266
+ ctx.ui.setStatus(DYNAMAX_WIDGET_KEY, undefined);
267
+ if (!isDynamaxActive(runtime.state) && !runtime.runningWorkflow) {
268
+ clearDynamaxSurfaces(ctx, runtime);
269
+ return;
270
+ }
271
+
272
+ const status = dynamaxWidgetLine(runtime.state, shortcuts.inspector, runtime.runningWorkflow);
273
+ ctx.ui.setStatus(DYNAMAX_STATUS_KEY, status);
274
+ }
275
+
276
+ export function clearDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime): void {
277
+ if (!ctx.hasUI) return;
278
+ ctx.ui.setWidget(DYNAMAX_WIDGET_KEY, undefined);
279
+ ctx.ui.setStatus(DYNAMAX_STATUS_KEY, undefined);
280
+ ctx.ui.setStatus(DYNAMAX_WIDGET_KEY, undefined);
281
+ }
282
+
283
+ function workflowLabel(args: unknown): string {
284
+ if (!isRecord(args)) return "workflow";
285
+ const name = args.name;
286
+ if (typeof name === "string" && name.trim()) return name.trim();
287
+ const script = args.script;
288
+ if (typeof script === "string" && script.trim()) return "inline workflow";
289
+ return "workflow";
290
+ }
291
+
114
292
  function createDynamaxContextMessage(): AgentMessage {
115
293
  return {
116
294
  role: "custom",
@@ -121,3 +299,7 @@ function createDynamaxContextMessage(): AgentMessage {
121
299
  timestamp: Date.now(),
122
300
  };
123
301
  }
302
+
303
+ function isRecord(value: unknown): value is Record<string, unknown> {
304
+ return typeof value === "object" && value !== null;
305
+ }