@marcoscale98/pi-extensible-workflows 5.19.1-fork.1 → 6.2.1-fork.1

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 (123) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +2 -2
  3. package/dist/src/agent-execution.d.ts +43 -10
  4. package/dist/src/agent-execution.js +365 -127
  5. package/dist/src/background-widget.d.ts +0 -1
  6. package/dist/src/background-widget.js +4 -9
  7. package/dist/src/decoders.d.ts +2 -3
  8. package/dist/src/decoders.js +33 -37
  9. package/dist/src/execution.d.ts +2 -0
  10. package/dist/src/execution.js +5 -5
  11. package/dist/src/host-delivery.js +4 -8
  12. package/dist/src/host-navigator.js +5 -2
  13. package/dist/src/host-phases.d.ts +1 -0
  14. package/dist/src/host-recovery.d.ts +6 -6
  15. package/dist/src/host-recovery.js +14 -22
  16. package/dist/src/host-runtime.js +56 -5
  17. package/dist/src/host-view.d.ts +1 -1
  18. package/dist/src/host-view.js +17 -15
  19. package/dist/src/host.js +62 -87
  20. package/dist/src/index.d.ts +1 -2
  21. package/dist/src/index.js +1523 -1630
  22. package/dist/src/index.js.map +3 -3
  23. package/dist/src/paths.d.ts +7 -6
  24. package/dist/src/paths.js +24 -15
  25. package/dist/src/persistence.d.ts +1 -0
  26. package/dist/src/persistence.js +1 -0
  27. package/dist/src/pi-runtime-runner.js +4 -1
  28. package/dist/src/registry.d.ts +9 -7
  29. package/dist/src/registry.js +28 -70
  30. package/dist/src/runtime/agent-runner.d.ts +2 -0
  31. package/dist/src/session-lease.d.ts +1 -1
  32. package/dist/src/session-lease.js +45 -11
  33. package/dist/src/settings.d.ts +6 -3
  34. package/dist/src/settings.js +36 -47
  35. package/dist/src/store.d.ts +3 -1
  36. package/dist/src/store.js +17 -1
  37. package/dist/src/trajectory.d.ts +0 -1
  38. package/dist/src/trajectory.js +2 -3
  39. package/dist/src/types.d.ts +100 -44
  40. package/dist/src/types.js +4 -7
  41. package/dist/src/utils.d.ts +3 -2
  42. package/dist/src/utils.js +20 -10
  43. package/dist/src/validation.d.ts +8 -2
  44. package/dist/src/validation.js +122 -86
  45. package/dist/starter/index.js +8 -9
  46. package/dist/starter/index.js.map +1 -1
  47. package/dist/starter/review-loop.js +8 -7
  48. package/dist/subagents/index.js +654 -814
  49. package/dist/subagents/index.js.map +3 -3
  50. package/dist/subagents/src/contracts.d.ts +4 -3
  51. package/dist/subagents/src/contracts.js +2 -2
  52. package/dist/subagents/src/index.d.ts +1 -1
  53. package/dist/subagents/src/index.js +1 -2
  54. package/dist/subagents/src/manager.js +49 -70
  55. package/dist/subagents/src/navigator.js +3 -14
  56. package/dist/subagents/src/view.d.ts +0 -1
  57. package/dist/subagents/src/view.js +2 -16
  58. package/dist/subagents/src/worktree.js +1 -1
  59. package/dist/trajectory/assets/index.html +8 -8
  60. package/dist/trajectory/index.js +487 -891
  61. package/dist/trajectory/index.js.map +3 -3
  62. package/dist/trajectory/src/assets/index.html +8 -8
  63. package/dist/trajectory/src/index.js +126 -86
  64. package/dist/trajectory/src/server.js +264 -25
  65. package/dist/trajectory/src/server.js.map +3 -3
  66. package/dist/trajectory/src/startup-mutex.d.ts +17 -0
  67. package/dist/trajectory/src/startup-mutex.js +172 -0
  68. package/examples/workflow-extension-template/README.md +4 -7
  69. package/examples/workflow-extension-template/extension.test.mjs +4 -8
  70. package/examples/workflow-extension-template/index.js +0 -3
  71. package/package.json +5 -5
  72. package/skills/pi-extensible-workflows/SKILL.md +10 -8
  73. package/src/agent-execution.ts +339 -127
  74. package/src/background-widget.ts +4 -10
  75. package/src/decoders.ts +31 -37
  76. package/src/execution.ts +5 -5
  77. package/src/host-delivery.ts +4 -8
  78. package/src/host-navigator.ts +4 -2
  79. package/src/host-phases.ts +1 -1
  80. package/src/host-recovery.ts +20 -26
  81. package/src/host-runtime.ts +44 -6
  82. package/src/host-view.ts +18 -11
  83. package/src/host.ts +62 -113
  84. package/src/index.ts +1 -2
  85. package/src/paths.ts +18 -12
  86. package/src/persistence.ts +1 -0
  87. package/src/pi-runtime-runner.ts +3 -1
  88. package/src/registry.ts +27 -63
  89. package/src/runtime/agent-runner.ts +2 -0
  90. package/src/session-lease.ts +34 -6
  91. package/src/settings.ts +35 -47
  92. package/src/store.ts +18 -2
  93. package/src/trajectory.ts +2 -4
  94. package/src/types.ts +71 -30
  95. package/src/utils.ts +18 -9
  96. package/src/validation.ts +102 -79
  97. package/starter/index.ts +0 -1
  98. package/starter/review-loop.ts +8 -7
  99. package/starter/roles/developer.md +0 -1
  100. package/starter/roles/oracle.md +0 -1
  101. package/starter/roles/researcher.md +0 -1
  102. package/starter/roles/reviewer.md +0 -1
  103. package/starter/roles/scout.md +0 -1
  104. package/subagents/README.md +8 -14
  105. package/subagents/src/contracts.ts +3 -4
  106. package/subagents/src/index.ts +2 -3
  107. package/subagents/src/manager.ts +45 -125
  108. package/subagents/src/navigator.ts +4 -13
  109. package/subagents/src/view.ts +4 -14
  110. package/subagents/src/worktree.ts +1 -1
  111. package/trajectory/src/assets/index.html +8 -8
  112. package/trajectory/src/index.ts +106 -82
  113. package/trajectory/src/server.ts +44 -8
  114. package/trajectory/src/startup-mutex.ts +127 -0
  115. package/dist/src/roles.d.ts +0 -70
  116. package/dist/src/roles.js +0 -326
  117. package/dist/starter/roles/developer.md +0 -6
  118. package/dist/starter/roles/oracle.md +0 -18
  119. package/dist/starter/roles/researcher.md +0 -20
  120. package/dist/starter/roles/reviewer.md +0 -19
  121. package/dist/starter/roles/scout.md +0 -18
  122. package/examples/workflow-extension-template/roles/reviewer.md +0 -4
  123. package/src/roles.ts +0 -327
@@ -2,10 +2,11 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { type AgentProviderFailure, type AgentProviderRecovery, WorkflowAgentExecutor } from "./agent-execution.js";
3
3
  import { type WorkflowBudgetRuntime } from "./budget.js";
4
4
  import { type PersistedRun, type RunStore, type WorktreeReference } from "./persistence.js";
5
- import { deepFreeze, errorCode, errorText, fail, isWorkflowErrorCode, jsonValue, object } from "./utils.js";
5
+ import { FUNCTION_HANDLE_PREFIX } from "./execution.js";
6
+ import { deepFreeze, errorCode, errorText, fail, isWorkflowErrorCode, jsonDigest, jsonValue, object } from "./utils.js";
6
7
  import { validateAgentOptions, validateShellOptions, workflowPrompt } from "./validation.js";
7
8
  import { type WorkflowRegistryApi } from "./registry.js";
8
- import { HARD_TERMINAL_RUN_STATES, WORKFLOW_AGENT_STATE_CHANGED_EVENT, WORKFLOW_BUDGET_EVENT, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, WORKFLOW_PHASE_CHANGED_EVENT, WORKFLOW_RUN_COMPLETED_EVENT, WORKFLOW_RUN_FAILED_EVENT, WORKFLOW_RUN_RESUMED_EVENT, WORKFLOW_RUN_STARTED_EVENT, WORKFLOW_RUN_STATE_CHANGED_EVENT, WORKFLOW_WORKTREE_CREATED_EVENT, WorkflowError, type AgentOptions, type AgentRecord, type BudgetEvent, type FunctionIdentity, type JsonValue, type ModelSpec, type ParallelResult, type ParallelTasks, type PipelineItems, type PipelineResult, type PipelineStages, type RunState, type WorkflowBridge, type WorkflowCheckpointState, type WorkflowErrorShape, type WorkflowEventBase, type WorkflowExecution, type WorkflowExtensionSettings, type WorkflowFunctionContext, type WorkflowMetadata, type WorkflowRunContext, type WorkflowWorktreeCallback, type WorkflowWorktreeReference } from "./types.js";
9
+ import { HARD_TERMINAL_RUN_STATES, WORKFLOW_AGENT_STATE_CHANGED_EVENT, WORKFLOW_BUDGET_EVENT, WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT, WORKFLOW_PHASE_CHANGED_EVENT, WORKFLOW_RUN_COMPLETED_EVENT, WORKFLOW_RUN_FAILED_EVENT, WORKFLOW_RUN_RESUMED_EVENT, WORKFLOW_RUN_STARTED_EVENT, WORKFLOW_RUN_STATE_CHANGED_EVENT, WORKFLOW_WORKTREE_CREATED_EVENT, WorkflowError, type AgentHandle, type AgentOptions, type AgentRecord, type BudgetEvent, type FunctionIdentity, type JsonValue, type ModelSpec, type ParallelResult, type ParallelTasks, type PipelineItems, type PipelineResult, type PipelineStages, type RunState, type WorkflowBridge, type WorkflowCheckpointState, type WorkflowErrorShape, type WorkflowEventBase, type WorkflowExecution, type WorkflowExtensionSettings, type WorkflowFunctionContext, type WorkflowMetadata, type WorkflowRunContext, type WorkflowWorktreeCallback, type WorkflowWorktreeReference } from "./types.js";
9
10
  import { structuralPath as operationPath } from "./persistence.js";
10
11
 
11
12
  export type WorkflowEventSink = { emit: (name: string, payload: unknown) => unknown };
@@ -150,7 +151,7 @@ export class WorkflowEventPublisher {
150
151
  }
151
152
 
152
153
  async agentState(store: RunStore, metadata: WorkflowMetadata, previous: AgentRecord | undefined, agent: AgentRecord): Promise<void> {
153
- await this.#publish(store, metadata, WORKFLOW_AGENT_STATE_CHANGED_EVENT, { agentId: agent.id, displayLabel: agent.label ?? agent.name, ...(agent.role ? { role: agent.role } : {}), structuralPath: [...(agent.structuralPath ?? [])], ...(agent.parentId ? { parentId: agent.parentId } : {}), ...(agent.parentBreadcrumb ? { parentBreadcrumb: agent.parentBreadcrumb } : {}), ...(agent.worktreeOwner ? { worktreeOwner: agent.worktreeOwner } : {}), ...(previous ? { previousState: previous.state } : {}), state: agent.state, attempt: agent.attempts });
154
+ await this.#publish(store, metadata, WORKFLOW_AGENT_STATE_CHANGED_EVENT, { agentId: agent.id, displayLabel: agent.label ?? agent.name, structuralPath: [...(agent.structuralPath ?? [])], ...(agent.parentId ? { parentId: agent.parentId } : {}), ...(agent.parentBreadcrumb ? { parentBreadcrumb: agent.parentBreadcrumb } : {}), ...(agent.worktreeOwner ? { worktreeOwner: agent.worktreeOwner } : {}), ...(previous ? { previousState: previous.state } : {}), state: agent.state, attempt: agent.attempts });
154
155
  }
155
156
 
156
157
  async agentStates(store: RunStore, metadata: WorkflowMetadata, previous: readonly AgentRecord[], current: readonly AgentRecord[]): Promise<void> {
@@ -288,6 +289,7 @@ function functionBreadcrumb(name: string, occurrence: number): string { return o
288
289
 
289
290
  export function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runContext: Readonly<WorkflowRunContext>, registry: WorkflowRegistryApi, settings: Readonly<WorkflowExtensionSettings> = Object.freeze({})): WorkflowBridge {
290
291
  const functionAgentOccurrences = new Map<string, number>();
292
+ const functionHandles = new Set<string>();
291
293
  const functionShellOccurrences = new Map<string, number>();
292
294
  const functionInvokeOccurrences = new Map<string, number>();
293
295
  const functionInvokeBreadcrumbOccurrences = new Map<string, number>();
@@ -309,6 +311,7 @@ export function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, r
309
311
  // Own rejection immediately; Promise.all below still propagates it after the function completes.
310
312
  void sideEffect.catch(() => undefined);
311
313
  };
314
+ let closed = false;
312
315
  const parentBreadcrumb = breadcrumb ?? functionBreadcrumb(name, identity.occurrence);
313
316
  const context: WorkflowFunctionContext = {
314
317
  settings: deepFreeze(structuredClone(settings)),
@@ -328,7 +331,7 @@ export function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, r
328
331
  const nestedIdentity: FunctionIdentity = { path: nestedPath, structuralPath: [...inherited], occurrence, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) };
329
332
  return invokeFunction(targetName, targetInput, signal, nestedIdentity, `${parentBreadcrumb} > ${functionBreadcrumb(breadcrumbName, breadcrumbOccurrence)}`);
330
333
  },
331
- agent: async (prompt: string, options?: Readonly<AgentOptions>) => {
334
+ agent: Object.assign(async (prompt: string, options?: Readonly<AgentOptions>) => {
332
335
  if (!bridge.agent || typeof prompt !== "string") fail("AGENT_FAILED", "No agent bridge is available");
333
336
  const validatedOptions = validateAgentOptions(options === undefined ? {} : options);
334
337
  const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
@@ -337,7 +340,42 @@ export function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, r
337
340
  const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
338
341
  functionAgentOccurrences.set(key, occurrence);
339
342
  return bridge.agent(prompt, validatedOptions, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
340
- },
343
+ }, {
344
+ create: (...created: readonly unknown[]): AgentHandle => {
345
+ if (created.length !== 1 || !object(created[0])) fail("INVALID_METADATA", "agent.create requires one options object");
346
+ const { name, ...rest } = created[0];
347
+ if (typeof name !== "string" || !name.trim()) fail("INVALID_METADATA", "agent.create requires a stable explicit name");
348
+ const config = deepFreeze(structuredClone(validateAgentOptions(rest)));
349
+ const scopedStructuralPath = [...(inheritedHostAgentPath.getStore() ?? [])];
350
+ const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
351
+ const scope = JSON.stringify([scopedStructuralPath, scopedWorktreeOwner ?? null]);
352
+ // The registered-function path makes repeated and nested invocations distinct; scope separates branches and worktrees. The digest bounds the journal and turn-copy file names.
353
+ const handle = operationPath(FUNCTION_HANDLE_PREFIX, jsonDigest([path, scopedStructuralPath, scopedWorktreeOwner ?? null]), name);
354
+ if (functionHandles.has(handle)) fail("INVALID_METADATA", `Duplicate agent handle name: ${name}`);
355
+ functionHandles.add(handle);
356
+ let turn = 0;
357
+ let inflight = false;
358
+ // Not a nested promise chain: the caller owns the one returned promise, and inflight clears in finally on every outcome.
359
+ const send = async (...sent: readonly unknown[]): Promise<JsonValue> => {
360
+ if (!bridge.agent) fail("AGENT_FAILED", "No agent bridge is available");
361
+ if (sent.length < 1 || sent.length > 2) fail("INVALID_METADATA", "send requires a prompt and optional options");
362
+ if (closed) fail("INVALID_METADATA", `Agent handle outlived the function invocation that created it: ${name}`);
363
+ if (typeof sent[0] !== "string") fail("INVALID_METADATA", "send prompt must be a string");
364
+ const sendOptions = sent[1] === undefined ? {} : sent[1];
365
+ if (!object(sendOptions) || Object.keys(sendOptions).some((key) => key !== "outputSchema" && key !== "timeoutMs")) fail("INVALID_METADATA", "send options must contain only outputSchema and timeoutMs");
366
+ const sendStructuralPath = [...(inheritedHostAgentPath.getStore() ?? [])];
367
+ if (JSON.stringify([sendStructuralPath, inheritedHostWorktreeOwner.getStore() ?? worktreeOwner ?? null]) !== scope) fail("INVALID_METADATA", `Agent handle sends must run in the scope that created the handle: ${name}`);
368
+ if (inflight) fail("INVALID_METADATA", "Concurrent send(...) calls on one agent handle are unsupported; await each turn");
369
+ const validatedOptions = validateAgentOptions({ ...config, ...sendOptions });
370
+ inflight = true;
371
+ turn += 1;
372
+ try {
373
+ return await bridge.agent(sent[0], validatedOptions, signal, { structuralPath: sendStructuralPath, callSite: `function:${path}:handle:${name}`, occurrence: turn, handle, turn, parentBreadcrumb, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
374
+ } finally { inflight = false; }
375
+ };
376
+ return Object.freeze({ name, send }) as AgentHandle;
377
+ },
378
+ }),
341
379
  shell: async (...args: readonly unknown[]) => {
342
380
  if (!bridge.shell) fail("SHELL_FAILED", "No shell bridge is available");
343
381
  if (typeof args[0] !== "string") fail("INVALID_METADATA", "shell command must be a string");
@@ -360,7 +398,7 @@ export function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, r
360
398
  phase: (name: string) => { ownSideEffect(() => bridge.phase?.(name)); },
361
399
  log: (message: string) => { ownSideEffect(() => bridge.log?.(message)); },
362
400
  };
363
- const result = await inheritedHostAgentPath.run([...structuralPath], async () => registry.invokeFunction(name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } }));
401
+ const result = await inheritedHostAgentPath.run([...structuralPath], async () => registry.invokeFunction(name, input, context, path, { get: () => replayed?.value, put: (_path, value) => { stored = value; } })).finally(() => { closed = true; });
364
402
  await Promise.all(sideEffects);
365
403
  if (!replayed) await store.complete(path, stored ?? result);
366
404
  return result;
package/src/host-view.ts CHANGED
@@ -639,7 +639,6 @@ export interface AgentDetailPresentation {
639
639
  readonly lastEventAt?: number;
640
640
  readonly structuralPath?: readonly string[];
641
641
  readonly model?: AgentRecord["model"];
642
- readonly role?: string;
643
642
  readonly tools?: readonly string[];
644
643
  readonly attempts?: number;
645
644
  readonly startedAt?: number;
@@ -664,7 +663,6 @@ export function formatAgentDetail(agent: Readonly<AgentDetailPresentation>, styl
664
663
  `State: ${state(agent.state)}`,
665
664
  ...(agent.structuralPath?.length ? [`Structural path: ${agent.structuralPath.join(" > ")}`] : []),
666
665
  ...(model === undefined ? [] : [`Model: ${model}`]),
667
- `Role: ${agent.role ?? "(none)"}`,
668
666
  `Tools: ${tools}`,
669
667
  ...(agent.attempts !== undefined && Number.isFinite(agent.attempts) && agent.attempts > 1 ? [`Attempts: ${String(agent.attempts)}`] : []),
670
668
  ...(duration === undefined ? [] : [`Duration: ${formatWorkflowRuntime(duration)}`]),
@@ -778,11 +776,10 @@ export function formatNavigatorRun(loaded: { run: PersistedRun; snapshot: Readon
778
776
  const byId = new Map(run.agents.map((agent) => [agent.id, agent]));
779
777
  lines.push(...renderGroupedAgents(run.agents, ({ agent, index, depth }, grouped) => {
780
778
  const model = `${agent.model.provider}/${agent.model.model}${agent.model.thinking ? `:${agent.model.thinking}` : ""}`;
781
- const role = agent.role ? ` role=${agent.role}` : "";
782
779
  const tools = ` tools=${agent.tools.join(",") || "(none)"}`;
783
780
  const accounting = agent.accounting ? ` input=${String(agent.accounting.input)} output=${String(agent.accounting.output)} cache-read=${String(agent.accounting.cacheRead)} cache-write=${String(agent.accounting.cacheWrite)} cost=${formatCost(agent.accounting.cost) || "$0.00"}` : "";
784
781
  const indent = " ".repeat((grouped ? 2 : 1) + depth);
785
- const result = [`${indent}#${String(index + 1)} ${grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId)} state=${agent.state} model=${model}${agent.requestedModel ? ` requested=${agent.requestedModel}` : ""}${role}${tools} attempts=${String(agent.attempts)} retries=${String(Math.max(0, agent.attempts - 1))}${accounting}`];
782
+ const result = [`${indent}#${String(index + 1)} ${grouped ? agent.label ?? agent.name : agentBreadcrumb(agent, byId)} state=${agent.state} model=${model}${agent.requestedModel ? ` requested=${agent.requestedModel}` : ""}${tools} attempts=${String(agent.attempts)} retries=${String(Math.max(0, agent.attempts - 1))}${accounting}`];
786
783
  for (const attempt of agent.attemptDetails ?? []) result.push(`${indent} attempt ${String(attempt.attempt)}${attempt.error ? ` error=${attempt.error.code}: ${attempt.error.message}` : ""}`);
787
784
  for (const call of agent.toolCalls ?? []) result.push(`${indent} tool ${call.name} state=${call.state}`);
788
785
  const activity = !SETTLED_AGENT_STATES.has(agent.state) ? formatAgentActivity(agent, "⠦", PLAIN_WORKFLOW_PROGRESS_STYLES, now) : "";
@@ -814,11 +811,17 @@ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readon
814
811
  const visit = (id: string): void => {
815
812
  const child = tree.byId.get(id);
816
813
  if (!child) return;
817
- if (child.agent) agents.push(child.agent); else for (const childId of child.children) visit(childId);
814
+ if (child.agent) agents.push(child.agent);
815
+ for (const childId of child.children) visit(childId);
818
816
  };
819
- if (node.agent) agents.push(node.agent); else for (const childId of node.children) visit(childId);
817
+ visit(node.id);
820
818
  return agents;
821
819
  };
820
+ const usageLines = (agents: readonly AgentRecord[]): string[] => {
821
+ const spans = agents.flatMap((agent) => agent.startedAt === undefined ? [] : [[agent.startedAt, agent.startedAt + (elapsedDurationMs(agent, now) ?? 0)] as const]);
822
+ const elapsed = spans.length ? formatWorkflowRuntime(Math.max(...spans.map(([, end]) => end)) - Math.min(...spans.map(([start]) => start))) : "";
823
+ return [...formatAgentAccounting(sumAccounting(agents.map((agent) => agent.accounting ?? zeroAccounting()))), ...(elapsed ? [`Elapsed: ${elapsed}`] : [])];
824
+ };
822
825
  const nodeStatus = (node: WorkflowPhaseTreeNode): string => phaseStyle(node.state)(node.state);
823
826
  const nodeIcon = (node: WorkflowPhaseTreeNode): string => node.children.length ? expanded.has(node.id) ? "▾" : "▸" : node.kind === "agent" ? "•" : node.kind === "shell" ? "◇" : " ";
824
827
  const treeLine = (node: WorkflowPhaseTreeNode): string => {
@@ -838,12 +841,12 @@ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readon
838
841
  const selected = node.phase;
839
842
  const counts = selected?.counts ?? phaseAgentCounts(agents);
840
843
  const hint = node.children.length ? "enter expand/collapse" : "enter phase details";
841
- return [styles.bold(`Selected phase: ${node.label}`), `Status: ${nodeStatus(node)}`, `agents completed=${String(counts.completed)} running=${String(counts.running)} failed=${String(counts.failed)} cancelled=${String(counts.cancelled)} pending=${String(counts.pending)}`, `Agents: ${String(agents.length)}`, ...(selection.actions ? [] : [styles.muted(hint)])];
844
+ return [styles.bold(`Selected phase: ${node.label}`), `Status: ${nodeStatus(node)}`, `agents completed=${String(counts.completed)} running=${String(counts.running)} failed=${String(counts.failed)} cancelled=${String(counts.cancelled)} pending=${String(counts.pending)}`, `Agents: ${String(agents.length)}`, ...usageLines(agents), ...(selection.actions ? [] : [styles.muted(hint)])];
842
845
  }
843
846
  if (node.kind === "operation") {
844
847
  const states = phaseAgentCounts(agents);
845
848
  const hint = node.children.length ? "enter expand/collapse" : "enter phase details";
846
- return [styles.bold(`Selected operation: ${node.operationPath.join(" > ")}`), `Phase: ${node.phase?.name ?? node.phaseId}`, `Status: ${nodeStatus(node)}`, `agents completed=${String(states.completed)} running=${String(states.running)} failed=${String(states.failed)} cancelled=${String(states.cancelled)} pending=${String(states.pending)}`, `Agents: ${String(agents.length)}`, ...(selection.actions ? [] : [styles.muted(hint)])];
849
+ return [styles.bold(`Selected operation: ${node.operationPath.join(" > ")}`), `Phase: ${node.phase?.name ?? node.phaseId}`, `Status: ${nodeStatus(node)}`, `agents completed=${String(states.completed)} running=${String(states.running)} failed=${String(states.failed)} cancelled=${String(states.cancelled)} pending=${String(states.pending)}`, `Agents: ${String(agents.length)}`, ...usageLines(agents), ...(selection.actions ? [] : [styles.muted(hint)])];
847
850
  }
848
851
  if (node.kind === "shell") {
849
852
  return [styles.bold("Selected shell"), formatShellActivity(node.shellActivity?.active, node.shellActivity?.startedAt, "⠦", styles, now) ?? "shell [running]"];
@@ -873,7 +876,8 @@ export function formatWorkflowPhaseDashboard(run: PersistedRun, snapshot: Readon
873
876
  return ["", styles.bold(actions.title), ...actions.options.map((option, index) => `${index === actions.index ? "→ " : " "}${index === actions.index ? styles.accent(option) : option}`)];
874
877
  };
875
878
  const treeRows = [styles.bold("Tree"), ...(visibleNodes.length ? visibleNodes.map(treeLine) : [styles.muted("(empty)")])];
876
- lines.push(...formatNavigatorColumns(treeRows, [...details(selectedNode), ...actionRows()], safeWidth, selection));
879
+ const detailOffset = Math.max(0, (selection.scrollTop ?? 0) - lines.flatMap(wrap).length);
880
+ lines.push(...formatNavigatorColumns(treeRows, [...details(selectedNode), ...actionRows()], safeWidth, { ...selection, detailOffset }));
877
881
  if (model.unassignedAgents?.length && !tree.nodes.some((node) => node.phaseId === "unassigned")) lines.push(...wrap(styles.muted(`Unassigned agents: ${String(model.unassignedAgents.length)}`)));
878
882
  return lines.flatMap((line) => wrap(line));
879
883
  }
@@ -881,12 +885,15 @@ function wrapNavigatorLine(text: string, width: number): string[] {
881
885
  return truncateToVisualLines(text, Number.MAX_SAFE_INTEGER, Math.max(1, width), 0).visualLines.map((line) => line.trimEnd());
882
886
  }
883
887
  /** The navigator layout: a sidebar beside details at 80 columns or wider, otherwise one of them (or both stacked). */
884
- export function formatNavigatorColumns(sidebar: readonly string[], detail: readonly string[], width: number, layout: Readonly<{ treeOnly?: boolean | undefined; detailsOnly?: boolean | undefined }> = {}): string[] {
888
+ export function formatNavigatorColumns(sidebar: readonly string[], detail: readonly string[], width: number, layout: Readonly<{ treeOnly?: boolean | undefined; detailsOnly?: boolean | undefined; detailOffset?: number | undefined }> = {}): string[] {
885
889
  const safeWidth = Math.max(1, width);
886
890
  if (safeWidth >= 80) {
887
891
  const sidebarWidth = Math.min(42, Math.max(24, Math.floor((safeWidth - 3) * 0.38)));
888
892
  const left = sidebar.flatMap((line) => wrapNavigatorLine(line, sidebarWidth));
889
- const right = detail.flatMap((line) => wrapNavigatorLine(line, safeWidth - sidebarWidth - 3));
893
+ const wrapped = detail.flatMap((line) => wrapNavigatorLine(line, safeWidth - sidebarWidth - 3));
894
+ // Keep the details in view when the tree is scrolled, without making the block taller than the tree.
895
+ const shift = Math.min(Math.max(0, layout.detailOffset ?? 0), Math.max(0, left.length - wrapped.length));
896
+ const right = [...Array<string>(shift).fill(""), ...wrapped];
890
897
  return Array.from({ length: Math.max(left.length, right.length) }, (_, index) => {
891
898
  const cell = left[index] ?? "";
892
899
  return `${cell}${" ".repeat(Math.max(0, sidebarWidth - visibleWidth(cell)))} | ${right[index] ?? ""}`;