pi-subagents 0.40.0 → 0.41.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.
Files changed (119) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +246 -525
  3. package/agents/oracle.md +1 -0
  4. package/package.json +12 -4
  5. package/prompts/parallel-context-build.md +1 -1
  6. package/prompts/parallel-handoff-plan.md +1 -1
  7. package/prompts/review-loop.md +1 -1
  8. package/skills/pi-subagents/SKILL.md +6 -6
  9. package/skills/pi-subagents/references/constraints-and-recipes.md +19 -26
  10. package/skills/pi-subagents/references/execution-controls.md +98 -97
  11. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  12. package/skills/pi-subagents/references/prompting-and-roles.md +18 -27
  13. package/src/agents/agent-management.ts +155 -65
  14. package/src/agents/agent-serializer.ts +19 -0
  15. package/src/agents/agents.ts +154 -71
  16. package/src/agents/chain-serializer.ts +10 -7
  17. package/src/agents/frontmatter.ts +5 -3
  18. package/src/agents/identity.ts +1 -1
  19. package/src/agents/proactive-skills.ts +13 -10
  20. package/src/agents/skills.ts +23 -6
  21. package/src/api/control-channel.ts +4 -0
  22. package/src/api/delegation.ts +26 -194
  23. package/src/api/external-runs.ts +129 -0
  24. package/src/api/intercom-bridge.ts +3 -0
  25. package/src/api/pi-args.ts +5 -0
  26. package/src/api/preflight.ts +3 -3
  27. package/src/api/shared-types.ts +19 -0
  28. package/src/extension/config.ts +10 -0
  29. package/src/extension/control-notices.ts +5 -39
  30. package/src/extension/doctor.ts +10 -9
  31. package/src/extension/fanout-child.ts +7 -4
  32. package/src/extension/index.ts +232 -68
  33. package/src/extension/rpc.ts +18 -12
  34. package/src/extension/schemas.ts +48 -37
  35. package/src/extension/tool-description.ts +36 -86
  36. package/src/inspectors/herdr/actions.ts +229 -0
  37. package/src/inspectors/herdr/client.ts +130 -0
  38. package/src/inspectors/herdr/inspector-runner.ts +141 -0
  39. package/src/inspectors/herdr/project-panes.ts +154 -0
  40. package/src/integrations/herdr-status.ts +330 -0
  41. package/src/intercom/intercom-bridge.ts +3 -2
  42. package/src/intercom/result-intercom.ts +5 -1
  43. package/src/missions/actions.ts +372 -0
  44. package/src/missions/lifecycle.ts +314 -0
  45. package/src/missions/store.ts +442 -0
  46. package/src/missions/types.ts +135 -0
  47. package/src/policy/authority.ts +46 -0
  48. package/src/profiles/profiles.ts +29 -3
  49. package/src/runs/background/async-execution.ts +98 -49
  50. package/src/runs/background/async-job-tracker.ts +10 -2
  51. package/src/runs/background/async-resume.ts +6 -6
  52. package/src/runs/background/async-status.ts +29 -1
  53. package/src/runs/background/auto-drain.ts +3 -3
  54. package/src/runs/background/chain-append.ts +3 -2
  55. package/src/runs/background/control-channel.ts +9 -7
  56. package/src/runs/background/fleet-view.ts +3 -4
  57. package/src/runs/background/notify.ts +2 -1
  58. package/src/runs/background/process-terminal.ts +5 -5
  59. package/src/runs/background/result-watcher.ts +13 -5
  60. package/src/runs/background/run-id-resolver.ts +3 -3
  61. package/src/runs/background/run-status.ts +35 -8
  62. package/src/runs/background/scheduled-runs.ts +602 -375
  63. package/src/runs/background/stale-run-reconciler.ts +3 -3
  64. package/src/runs/background/subagent-runner.ts +608 -445
  65. package/src/runs/background/subagent-wait.ts +50 -9
  66. package/src/runs/background/wait-subscriptions.ts +253 -0
  67. package/src/runs/background/wait-tool.ts +12 -4
  68. package/src/runs/foreground/async-steering-action.ts +3 -3
  69. package/src/runs/foreground/chain-clarify.ts +8 -4
  70. package/src/runs/foreground/chain-execution.ts +56 -30
  71. package/src/runs/foreground/execution.ts +15 -2
  72. package/src/runs/foreground/subagent-executor.ts +1023 -273
  73. package/src/runs/shared/acceptance.ts +28 -6
  74. package/src/runs/shared/child-protocol.ts +302 -22
  75. package/src/runs/shared/dynamic-fanout.ts +1 -1
  76. package/src/runs/shared/external-cli-runner.ts +130 -0
  77. package/src/runs/shared/long-running-guard.ts +42 -1
  78. package/src/runs/shared/nested-events.ts +59 -5
  79. package/src/runs/shared/nested-render.ts +9 -4
  80. package/src/runs/shared/parallel-handoff.ts +86 -2
  81. package/src/runs/shared/parallel-utils.ts +11 -2
  82. package/src/runs/shared/permissions.ts +95 -0
  83. package/src/runs/shared/pi-args.ts +11 -1
  84. package/src/runs/shared/pi-spawn.ts +11 -1
  85. package/src/runs/shared/run-history.ts +1 -1
  86. package/src/runs/shared/subagent-prompt-runtime.ts +36 -5
  87. package/src/runs/shared/subagent-startup-retry.ts +5 -2
  88. package/src/runs/shared/turn-budget.ts +6 -6
  89. package/src/runs/shared/worktree.ts +122 -12
  90. package/src/shared/accessible-dir.ts +29 -7
  91. package/src/shared/artifacts.ts +18 -1
  92. package/src/shared/fork-context.ts +3 -2
  93. package/src/shared/launch-contract.ts +1 -0
  94. package/src/shared/settings.ts +10 -0
  95. package/src/shared/types.ts +156 -14
  96. package/src/shared/utils.ts +8 -6
  97. package/src/slash/delegation-adapters.ts +32 -194
  98. package/src/slash/delegation-request.ts +43 -126
  99. package/src/slash/prompt-template-bridge.ts +158 -205
  100. package/src/slash/prompt-workflows.ts +21 -57
  101. package/src/slash/slash-bridge.ts +14 -0
  102. package/src/slash/slash-commands.ts +31 -632
  103. package/src/slash/subagents-admin.ts +18 -14
  104. package/src/tui/fleet-status.ts +156 -21
  105. package/src/tui/fleet-transcript.ts +110 -5
  106. package/src/tui/fleet.ts +56 -24
  107. package/src/tui/render.ts +291 -109
  108. package/src/types/pi-runtime-compat.d.ts +14 -0
  109. package/src/watchdog/lsp-diagnostics.ts +12 -7
  110. package/src/watchdog/model-selection.ts +2 -2
  111. package/src/watchdog/permission-arbiter.ts +145 -0
  112. package/src/watchdog/register-child.ts +1 -1
  113. package/src/watchdog/register-main.ts +1 -1
  114. package/src/watchdog/review.ts +4 -1
  115. package/src/watchdog/runtime.ts +3 -2
  116. package/src/workflows/chat-progress.ts +140 -0
  117. package/src/workflows/scripted-workflow.ts +415 -0
  118. package/agents/advisor.md +0 -73
  119. package/src/extension/chain-validation.ts +0 -181
@@ -132,13 +132,13 @@ function findFamilyMatch(family: StrongWatchdogFamily, availableModels: ModelInf
132
132
  function resolveStrongCandidate(ctx: ExtensionContext, family: StrongWatchdogFamily): WatchdogModelRecommendation | undefined {
133
133
  const availableModels = modelRegistryEntries(ctx);
134
134
  const preference = STRONG_WATCHDOG_MODELS[family];
135
- const queries = [...preference.queries];
135
+ const queries: string[] = [...preference.queries];
136
136
  const familyMatch = findFamilyMatch(family, availableModels);
137
137
  if (familyMatch) queries.push(familyMatch);
138
138
  for (const query of queries) {
139
139
  let resolved: ResolvedWatchdogModelInput;
140
140
  try {
141
- resolved = resolveWatchdogModelInput(ctx, query);
141
+ resolved = resolveWatchdogModelInput(ctx, query as Parameters<typeof resolveWatchdogModelInput>[1]);
142
142
  } catch {
143
143
  continue;
144
144
  }
@@ -0,0 +1,145 @@
1
+ import { Agent, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
2
+ import { convertToLlm, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { streamSimple } from "@earendil-works/pi-ai/compat";
4
+ import { Type, type Static } from "typebox";
5
+ import { appendPermissionAudit, permissionArgsPreview } from "../runs/shared/permissions.ts";
6
+ import { decodeChildWatchdogConfig } from "./child-status.ts";
7
+ import { childResolvedConfig } from "./register-child.ts";
8
+ import { resolveWatchdogReviewModel } from "./review.ts";
9
+
10
+ const PermissionDecisionParams = Type.Object({
11
+ decision: Type.String({ enum: ["approve", "deny"] }),
12
+ reason: Type.String({ description: "One concise reason for this exact decision." }),
13
+ }, { additionalProperties: false });
14
+
15
+ type PermissionDecisionParams = Static<typeof PermissionDecisionParams>;
16
+
17
+ export interface WatchdogPermissionResult {
18
+ approved: boolean;
19
+ reason: string;
20
+ source: "watchdog";
21
+ }
22
+
23
+ export interface WatchdogPermissionRequest {
24
+ ctx: ExtensionContext;
25
+ toolName: string;
26
+ args: unknown;
27
+ rawWatchdogConfig?: string;
28
+ auditPath?: string;
29
+ signal?: AbortSignal;
30
+ }
31
+
32
+ export interface WatchdogPermissionArbiterOptions {
33
+ streamFn?: StreamFn;
34
+ }
35
+
36
+ function conciseReason(value: string): string {
37
+ const trimmed = value.trim();
38
+ return trimmed ? trimmed.slice(0, 500) : "Watchdog returned an empty reason.";
39
+ }
40
+
41
+ export function createWatchdogPermissionArbiter(options: WatchdogPermissionArbiterOptions = {}) {
42
+ return async (request: WatchdogPermissionRequest): Promise<WatchdogPermissionResult> => {
43
+ const preview = permissionArgsPreview(request.args);
44
+ const createdAt = Date.now();
45
+ const auditBase = { type: "permission.request", createdAt, toolName: request.toolName, preview, matchedRule: "ask", decisionSource: "watchdog" };
46
+ appendPermissionAudit(request.auditPath, auditBase);
47
+ const finish = (approved: boolean, reason: string, decision: string): WatchdogPermissionResult => {
48
+ appendPermissionAudit(request.auditPath, {
49
+ type: "permission.decision",
50
+ createdAt: Date.now(),
51
+ requestCreatedAt: createdAt,
52
+ toolName: request.toolName,
53
+ decision,
54
+ approved,
55
+ decisionSource: "watchdog",
56
+ reason: conciseReason(reason),
57
+ });
58
+ return { approved, reason: conciseReason(reason), source: "watchdog" };
59
+ };
60
+
61
+ let childConfig;
62
+ try {
63
+ childConfig = decodeChildWatchdogConfig(request.rawWatchdogConfig);
64
+ } catch (error) {
65
+ const reason = error instanceof Error ? error.message : String(error);
66
+ return finish(false, `Watchdog permission arbiter configuration is invalid: ${reason}`, "unavailable");
67
+ }
68
+ if (!childConfig) return finish(false, "Watchdog permission arbiter is unavailable because the child watchdog is disabled.", "unavailable");
69
+ if (request.signal?.aborted || request.ctx.signal?.aborted) return finish(false, "Watchdog permission decision was cancelled.", "cancelled");
70
+
71
+ let decision: PermissionDecisionParams | undefined;
72
+ const tool: AgentTool<typeof PermissionDecisionParams, { recorded: boolean }> = {
73
+ name: "watchdog_permission_decision",
74
+ label: "Watchdog permission decision",
75
+ description: "Approve or deny this exact child tool call. Call exactly once.",
76
+ parameters: PermissionDecisionParams,
77
+ executionMode: "sequential",
78
+ async execute(_toolCallId, params) {
79
+ if (!decision) decision = params;
80
+ return { content: [{ type: "text", text: "Permission decision recorded." }], details: { recorded: true } };
81
+ },
82
+ };
83
+
84
+ let timeout: ReturnType<typeof setTimeout> | undefined;
85
+ let agent: Agent | undefined;
86
+ try {
87
+ const config = childResolvedConfig(childConfig);
88
+ const selection = await resolveWatchdogReviewModel(request.ctx, config);
89
+ const auth = selection.auth;
90
+ const registeredProvider = (request.ctx.modelRegistry as {
91
+ getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
92
+ }).getRegisteredProviderConfig?.(selection.model.provider);
93
+ const baseStreamFn = options.streamFn ?? (registeredProvider?.streamSimple && registeredProvider.api === selection.model.api
94
+ ? registeredProvider.streamSimple
95
+ : streamSimple);
96
+ const streamFn: StreamFn = (model, context, streamOptions) => baseStreamFn(model, context, {
97
+ ...streamOptions,
98
+ ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
99
+ env: auth.env || streamOptions?.env ? { ...(auth.env ?? {}), ...(streamOptions?.env ?? {}) } : undefined,
100
+ headers: { ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
101
+ });
102
+ agent = new Agent({
103
+ initialState: {
104
+ systemPrompt: [
105
+ "You are the pi-subagents watchdog permission arbiter.",
106
+ "Decide only whether this exact non-bash child tool call should proceed.",
107
+ "Call watchdog_permission_decision exactly once with approve or deny and a concise reason.",
108
+ "Deny when uncertain. Do not produce freeform advice or ask the parent orchestrator.",
109
+ ].join("\n"),
110
+ model: selection.model,
111
+ thinkingLevel: selection.thinkingLevel,
112
+ tools: [tool],
113
+ },
114
+ convertToLlm,
115
+ streamFunction: streamFn,
116
+ getApiKey: (providerName) => providerName === selection.model.provider ? auth.apiKey : undefined,
117
+ beforeToolCall: async ({ toolCall }) => toolCall.name === tool.name ? undefined : { block: true, reason: `Permission arbiter tool '${toolCall.name}' is not allowed.` },
118
+ toolExecution: "sequential",
119
+ });
120
+ const abort = () => agent?.abort();
121
+ request.signal?.addEventListener("abort", abort, { once: true });
122
+ request.ctx.signal?.addEventListener("abort", abort, { once: true });
123
+ try {
124
+ const prompt = `Tool: ${request.toolName}\nRedacted arguments: ${preview}`;
125
+ await Promise.race([
126
+ agent.prompt(prompt),
127
+ new Promise<never>((_, reject) => { timeout = setTimeout(() => { agent?.abort(); reject(new Error("Watchdog permission decision timed out.")); }, childConfig.agentEndTimeoutMs); }),
128
+ ]);
129
+ } finally {
130
+ request.signal?.removeEventListener("abort", abort);
131
+ request.ctx.signal?.removeEventListener("abort", abort);
132
+ }
133
+ if (!decision) return finish(false, "Watchdog permission arbiter returned no decision.", "malformed");
134
+ const approved = decision.decision === "approve";
135
+ return finish(approved, decision.reason, decision.decision);
136
+ } catch (error) {
137
+ const reason = error instanceof Error ? error.message : String(error);
138
+ return finish(false, `Watchdog permission arbiter failed closed: ${reason}`, reason.includes("timed out") ? "timeout" : "error");
139
+ } finally {
140
+ if (timeout) clearTimeout(timeout);
141
+ }
142
+ };
143
+ }
144
+
145
+ export const requestWatchdogPermission = createWatchdogPermissionArbiter();
@@ -12,7 +12,7 @@ import {
12
12
  } from "./child-status.ts";
13
13
  import type { ResolvedWatchdogConfig, WatchdogWarningDetails } from "./types.ts";
14
14
 
15
- function childResolvedConfig(config: ChildWatchdogConfig): ResolvedWatchdogConfig {
15
+ export function childResolvedConfig(config: ChildWatchdogConfig): ResolvedWatchdogConfig {
16
16
  return {
17
17
  ...DEFAULT_WATCHDOG_CONFIG,
18
18
  enabled: true,
@@ -184,7 +184,7 @@ function resolveModelCommandValue(ctx: ExtensionCommandContext, raw: string): {
184
184
  const resolved = resolveWatchdogModelInput(ctx as ExtensionContext, value);
185
185
  return {
186
186
  model: resolved.model,
187
- thinking: resolved.thinking,
187
+ thinking: resolved.thinking ?? null,
188
188
  description: `${resolved.model}${resolved.thinking ? `:${resolved.thinking}` : ""}`,
189
189
  };
190
190
  }
@@ -93,6 +93,9 @@ function resolveConfiguredModel(ctx: ExtensionContext, rawModel: string): { mode
93
93
  const availableModels = ctx.modelRegistry.getAvailable().map(toModelInfo);
94
94
  const preferredProvider = typeof ctx.model?.provider === "string" ? ctx.model.provider : undefined;
95
95
  const resolved = resolveModelCandidate(rawModel, availableModels, preferredProvider);
96
+ if (!resolved) {
97
+ throw new Error(`Configured watchdog model '${rawModel}' did not match exactly one authenticated available model. Use provider/model or configure credentials for the intended provider.`);
98
+ }
96
99
  const { baseModel } = splitKnownThinkingSuffix(resolved);
97
100
  const named = splitProviderModel(baseModel);
98
101
  if (!named) {
@@ -109,7 +112,7 @@ function resolveConfiguredModel(ctx: ExtensionContext, rawModel: string): { mode
109
112
 
110
113
  async function resolveReviewAuth(ctx: ExtensionContext, model: RegistryModel): Promise<WatchdogReviewAuth> {
111
114
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
112
- if (!auth.ok) throw new Error(`Watchdog model auth failed for ${fullModelId(model)}: ${auth.error}`);
115
+ if (auth.ok === false) throw new Error(`Watchdog model auth failed for ${fullModelId(model)}: ${auth.error}`);
113
116
  return {
114
117
  ...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
115
118
  ...(auth.headers ? { headers: auth.headers } : {}),
@@ -602,8 +602,9 @@ export class MainWatchdogRuntime {
602
602
  return "timeout";
603
603
  }
604
604
  if (!this.isCurrent(reviewEpoch, reviewId)) return "stale";
605
- for (const warning of result?.warnings ?? []) this.acceptWarning(reviewEpoch, reviewId, warning);
606
- if (result?.stopReason && result.stopReason !== "stop") {
605
+ if (!result) return "stale";
606
+ for (const warning of result.warnings ?? []) this.acceptWarning(reviewEpoch, reviewId, warning);
607
+ if (result.stopReason && result.stopReason !== "stop") {
607
608
  this.fail(`Watchdog review ended with stop reason '${result.stopReason}'.`);
608
609
  return "completed";
609
610
  }
@@ -0,0 +1,140 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import type { Details } from "../shared/types.ts";
5
+
6
+ export const WORKFLOW_CHAT_PROGRESS_MODES = ["auto", "off", "terminal", "milestones", "live-card"] as const;
7
+ export type WorkflowChatProgressMode = typeof WORKFLOW_CHAT_PROGRESS_MODES[number];
8
+ export type ResolvedWorkflowChatProgressMode = Exclude<WorkflowChatProgressMode, "auto">;
9
+
10
+ export interface GitRepositoryIdentity {
11
+ root: string;
12
+ commonDir: string;
13
+ }
14
+
15
+ export interface WorkflowChatProgressProjection {
16
+ mode: ResolvedWorkflowChatProgressMode;
17
+ repoRelation: "same" | "other";
18
+ repoLabel?: string;
19
+ }
20
+
21
+ interface ResolveWorkflowChatProgressInput {
22
+ requested: unknown;
23
+ parentCwd: string;
24
+ workflowCwd: string;
25
+ background: boolean;
26
+ }
27
+
28
+ function git(cwd: string, args: string[]): string | undefined {
29
+ const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8" });
30
+ if (result.status !== 0) return undefined;
31
+ const output = result.stdout.trim();
32
+ return output || undefined;
33
+ }
34
+
35
+ function realPath(value: string): string {
36
+ try {
37
+ return fs.realpathSync.native(value);
38
+ } catch {
39
+ return path.resolve(value);
40
+ }
41
+ }
42
+
43
+ export function resolveGitRepositoryIdentity(cwd: string): GitRepositoryIdentity | undefined {
44
+ if (git(cwd, ["rev-parse", "--is-inside-work-tree"]) !== "true") return undefined;
45
+ const root = git(cwd, ["rev-parse", "--show-toplevel"]);
46
+ const commonDir = git(cwd, ["rev-parse", "--git-common-dir"]);
47
+ if (!root || !commonDir) return undefined;
48
+ const commonDirPath = path.isAbsolute(commonDir)
49
+ ? commonDir
50
+ : [path.resolve(cwd, commonDir), path.resolve(root, commonDir)].find((candidate) => fs.existsSync(candidate)) ?? path.resolve(root, commonDir);
51
+ return {
52
+ root: realPath(root),
53
+ commonDir: realPath(commonDirPath),
54
+ };
55
+ }
56
+
57
+ function isSameGitRepositoryIdentity(left: GitRepositoryIdentity | undefined, right: GitRepositoryIdentity | undefined): boolean {
58
+ if (!left || !right) return false;
59
+ return left.commonDir === right.commonDir || left.root === right.root;
60
+ }
61
+
62
+ export function isSameGitRepository(leftCwd: string, rightCwd: string): boolean {
63
+ return isSameGitRepositoryIdentity(resolveGitRepositoryIdentity(leftCwd), resolveGitRepositoryIdentity(rightCwd));
64
+ }
65
+
66
+ function normalizeRequestedMode(value: unknown): { mode?: WorkflowChatProgressMode; error?: string } {
67
+ if (value === undefined) return { mode: "auto" };
68
+ if (typeof value !== "string" || !WORKFLOW_CHAT_PROGRESS_MODES.includes(value as WorkflowChatProgressMode)) {
69
+ return { error: `chatProgress must be one of: ${WORKFLOW_CHAT_PROGRESS_MODES.join(", ")}.` };
70
+ }
71
+ return { mode: value as WorkflowChatProgressMode };
72
+ }
73
+
74
+ export function resolveWorkflowChatProgress(input: ResolveWorkflowChatProgressInput): { projection?: WorkflowChatProgressProjection; error?: string } {
75
+ const requested = normalizeRequestedMode(input.requested);
76
+ if (requested.error) return { error: requested.error };
77
+ const parentIdentity = resolveGitRepositoryIdentity(input.parentCwd);
78
+ const workflowIdentity = resolveGitRepositoryIdentity(input.workflowCwd);
79
+ const sameRepo = !!(
80
+ parentIdentity
81
+ && workflowIdentity
82
+ && (parentIdentity.commonDir === workflowIdentity.commonDir || parentIdentity.root === workflowIdentity.root)
83
+ );
84
+ const repoLabel = workflowIdentity ? path.basename(workflowIdentity.root) : undefined;
85
+ const repoRelation = sameRepo ? "same" : "other";
86
+
87
+ const requestedMode = requested.mode ?? "auto";
88
+ let mode: ResolvedWorkflowChatProgressMode;
89
+ if (requestedMode === "auto") mode = sameRepo ? (input.background ? "milestones" : "live-card") : "terminal";
90
+ else mode = requestedMode;
91
+
92
+ if (mode === "live-card" && !sameRepo) return { error: "chatProgress: 'live-card' is only available for workflowScript runs in the same Git repository." };
93
+ if (mode === "live-card" && input.background) return { error: "chatProgress: 'live-card' requires a watched foreground workflow; pass async:false." };
94
+ return { projection: { mode, repoRelation, ...(repoLabel ? { repoLabel } : {}) } };
95
+ }
96
+
97
+ export interface WorkflowChatProgressRow {
98
+ key: string;
99
+ state: "running" | "complete" | "failed";
100
+ label?: string;
101
+ phase?: string;
102
+ runId?: string;
103
+ durationMs?: number;
104
+ error?: string;
105
+ }
106
+
107
+ function cleanLabel(value: unknown): string | undefined {
108
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
109
+ }
110
+
111
+ export function buildWorkflowChatProgressRows(trace: NonNullable<Details["workflow"]>["trace"]): WorkflowChatProgressRow[] {
112
+ const rows = new Map<string, WorkflowChatProgressRow>();
113
+ for (const entry of trace) {
114
+ if (entry.operation !== "run") continue;
115
+ const existing = rows.get(entry.key);
116
+ if (entry.state === "reused") {
117
+ if (existing) {
118
+ const label = cleanLabel(entry.label);
119
+ const phase = cleanLabel(entry.phase);
120
+ if (label) existing.label = label;
121
+ if (phase) existing.phase = phase;
122
+ }
123
+ continue;
124
+ }
125
+ const next: WorkflowChatProgressRow = existing ?? { key: entry.key, state: "running" };
126
+ next.state = entry.state === "completed" ? "complete" : entry.state === "failed" ? "failed" : "running";
127
+ const label = cleanLabel(entry.label);
128
+ const phase = cleanLabel(entry.phase);
129
+ if (label) next.label = label;
130
+ if (phase) next.phase = phase;
131
+ if (entry.runId === undefined) delete next.runId;
132
+ else next.runId = entry.runId;
133
+ if (entry.durationMs === undefined) delete next.durationMs;
134
+ else next.durationMs = entry.durationMs;
135
+ if (entry.error === undefined) delete next.error;
136
+ else next.error = entry.error;
137
+ rows.set(entry.key, next);
138
+ }
139
+ return [...rows.values()];
140
+ }