pi-extensible-workflows 1.0.0 → 1.0.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.
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { fork, type ChildProcess } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { basename, dirname, extname, join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -12,13 +12,13 @@ import { Value } from "typebox/value";
12
12
  import { copyToClipboard, getAgentDir, parseFrontmatter, highlightCode, SessionManager, truncateToVisualLines, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import { createNativeAgentSession, FairAgentScheduler, WorkflowAgentExecutor, type AgentActivity, type AgentAccounting, type AgentAttempt, type AgentBudgetHooks, type AgentDefinition, type AgentProgress, type AgentSetupHook, type RegisteredAgentSetupHook, type SessionFactory } from "./agent-execution.js";
14
14
  import { transcriptLines } from "./session-inspector.js";
15
- import { acquireSessionLease, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
15
+ import { acquireSessionLease, atomicWriteFile, listRunIds, RunStore, SessionLease, structuralPath as operationPath } from "./persistence.js";
16
16
  import type { AwaitingCheckpoint, PersistedRun, WorktreeReference } from "./persistence.js";
17
17
 
18
18
  export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted", "budget_exhausted"] as const;
19
19
  export const AGENT_STATES = ["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"] as const;
20
- export const WORKFLOW_ASYNC_STARTED_EVENT = "workflow:async-started";
21
- export const WORKFLOW_ASYNC_COMPLETE_EVENT = "workflow:async-complete";
20
+ export const WORKFLOW_CALL_KINDS = ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"] as const;
21
+ export type WorkflowCallKind = (typeof WORKFLOW_CALL_KINDS)[number];
22
22
  export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
23
23
  export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
24
24
  export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
@@ -70,7 +70,7 @@ export interface AgentSetupSummary { hookNames: readonly string[]; model: ModelS
70
70
  export interface AgentAttemptSummary { attempt: number; sessionId: string; sessionFile: string; error?: { code: string; message: string }; accounting: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }; setup?: AgentSetupSummary }
71
71
  export interface WorkflowWorktreeCreatedEvent extends WorkflowEventBase { owner: string; branch: string; path: string; base: string }
72
72
  export interface AgentRecord { id: string; name: string; label?: string; path: string; state: AgentState; parentId?: string; structuralPath?: readonly string[]; parentBreadcrumb?: string; worktreeOwner?: string; role?: string; requestedModel?: string; model: ModelSpec; tools: readonly string[]; attempts: number; attemptDetails?: readonly AgentAttemptSummary[]; accounting?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }; toolCalls?: readonly { id: string; name: string; state: "running" | "completed" | "failed" }[]; activity?: AgentActivity | undefined }
73
- export interface WorkflowRunEvent { type: "warning"; message: string }
73
+ export interface WorkflowRunEvent { type: string; message: string }
74
74
  export interface RunRecord { id: string; workflowName: string; cwd: string; sessionId: string; state: RunState; phase?: string; agents: readonly AgentRecord[]; error?: WorkflowErrorShape; budget?: WorkflowBudget; budgetVersion?: number; usage?: WorkflowBudgetUsage; budgetEvents?: readonly BudgetEvent[]; events?: readonly WorkflowRunEvent[] }
75
75
  export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 3;
76
76
  export interface LaunchSnapshot { identityVersion?: number; script: string; args: JsonValue; metadata: WorkflowMetadata; settings: WorkflowSettings; budget?: WorkflowBudget; settingsPath?: string; modelAliases?: Readonly<Record<string, string>>; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[]; roles?: Readonly<Record<string, AgentDefinition>>; projectRoles?: readonly string[]; schemas: readonly JsonSchema[] }
@@ -87,7 +87,10 @@ export interface WorkflowOrchestrationContext {
87
87
  log: (message: string) => void;
88
88
  }
89
89
  export interface WorkflowRunContext { cwd: string; sessionId: string; runId: string; workflow: Readonly<WorkflowMetadata>; args: JsonValue; signal: AbortSignal }
90
- export interface WorkflowFunctionContext extends WorkflowOrchestrationContext { run: Readonly<WorkflowRunContext> }
90
+ export interface WorkflowFunctionContext extends WorkflowOrchestrationContext {
91
+ run: Readonly<WorkflowRunContext>;
92
+ invoke: (name: string, input: Readonly<Record<string, JsonValue>>) => Promise<JsonValue>;
93
+ }
91
94
  export interface WorkflowFunction { description: string; input: JsonSchema; output: JsonSchema; run: (input: Readonly<Record<string, JsonValue>>, context: Readonly<WorkflowFunctionContext>) => Promise<JsonValue> | JsonValue }
92
95
  export interface WorkflowVariable { description: string; schema: JsonSchema; resolve: (run: Readonly<WorkflowRunContext>) => Promise<JsonValue> | JsonValue }
93
96
  export interface WorkflowScriptDefinition { description: string; script: string }
@@ -234,6 +237,7 @@ export const DEFAULT_SETTINGS: Readonly<WorkflowSettings> = Object.freeze({ conc
234
237
 
235
238
  function fail(code: WorkflowErrorCode, message: string): never { throw new WorkflowError(code, message); }
236
239
  function object(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
240
+ export { object as isObject };
237
241
  function nonNegativeInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) >= 0; }
238
242
  function nonNegativeFinite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value >= 0; }
239
243
  export function validateBudget(value: unknown): WorkflowBudget | undefined {
@@ -496,9 +500,7 @@ export function saveModelAliases(path = workflowSettingsPath(), aliases: Readonl
496
500
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
497
501
  }
498
502
  mkdirSync(dirname(path), { recursive: true });
499
- const temporary = `${path}.${String(process.pid)}.${randomUUID()}.tmp`;
500
- writeFileSync(temporary, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, { mode: 0o600 });
501
- renameSync(temporary, path);
503
+ atomicWriteFile(path, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, true);
502
504
  }
503
505
 
504
506
  export function parseRoleMarkdown(content: string, strict = false, rolePath?: string): AgentDefinition {
@@ -613,15 +615,25 @@ type WorkflowCall = acorn.CallExpression & { callee: acorn.Identifier };
613
615
  function astNode(value: unknown): value is acorn.AnyNode {
614
616
  return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
615
617
  }
618
+ function astChildren(node: acorn.AnyNode): acorn.AnyNode[] {
619
+ const children: acorn.AnyNode[] = [];
620
+ for (const value of Object.values(node) as unknown[]) {
621
+ if (Array.isArray(value)) {
622
+ for (const child of value) if (astNode(child)) children.push(child);
623
+ } else if (astNode(value)) children.push(value);
624
+ }
625
+ return children;
626
+ }
627
+ function workflowCallKind(node: acorn.AnyNode): WorkflowCallKind | undefined {
628
+ if (node.type !== "CallExpression" || node.callee.type !== "Identifier") return undefined;
629
+ const kind = node.callee.name as WorkflowCallKind;
630
+ return WORKFLOW_CALL_KINDS.includes(kind) ? kind : undefined;
631
+ }
616
632
  function workflowCalls(program: acorn.Program): WorkflowCall[] {
617
633
  const calls: WorkflowCall[] = [];
618
634
  const visit = (node: acorn.AnyNode): void => {
619
- if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) calls.push(node as WorkflowCall);
620
- for (const value of Object.values(node) as unknown[]) {
621
- if (Array.isArray(value)) {
622
- for (const child of value as unknown[]) if (astNode(child)) visit(child);
623
- } else if (astNode(value)) visit(value);
624
- }
635
+ if (workflowCallKind(node)) calls.push(node as WorkflowCall);
636
+ for (const child of astChildren(node)) visit(child);
625
637
  };
626
638
  visit(program);
627
639
  return calls.sort((left, right) => left.start - right.start);
@@ -638,9 +650,9 @@ function workflowCallsWithStructure(program: acorn.Program): Array<{ call: Workf
638
650
  const key = node.key.type === "Identifier" ? node.key.name : node.key.type === "Literal" ? String(node.key.value) : undefined;
639
651
  if (scope?.key === null && key) current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
640
652
  }
641
- if (node.type === "CallExpression" && node.callee.type === "Identifier" && ["agent", "conversation", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"].includes(node.callee.name)) {
653
+ const operation = workflowCallKind(node);
654
+ if (operation) {
642
655
  const call = node as WorkflowCall;
643
- const operation = call.callee.name as StaticWorkflowCall["kind"];
644
656
  const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
645
657
  calls.push({ call, execution, structure: current.structure });
646
658
  for (const [index, argument] of call.arguments.entries()) {
@@ -650,16 +662,11 @@ function workflowCallsWithStructure(program: acorn.Program): Array<{ call: Workf
650
662
  }
651
663
  return;
652
664
  }
653
- for (const value of Object.values(node) as unknown[]) {
654
- if (Array.isArray(value)) {
655
- for (const child of value as unknown[]) if (astNode(child)) visit(child, current);
656
- } else if (astNode(value)) visit(value, current);
657
- }
665
+ for (const child of astChildren(node)) visit(child, current);
658
666
  };
659
667
  visit(program, { execution: "sequential", structure: [] });
660
668
  return calls.sort((left, right) => left.call.start - right.call.start);
661
669
  }
662
-
663
670
  function validateDirectPrimitiveReferences(program: acorn.AnyNode, name: string): void {
664
671
  const visit = (node: acorn.AnyNode, parent?: acorn.AnyNode): void => {
665
672
  if (node.type === "Identifier" && node.name === name) {
@@ -667,18 +674,13 @@ function validateDirectPrimitiveReferences(program: acorn.AnyNode, name: string)
667
674
  const propertyKey = parent?.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand;
668
675
  if (!directCall && !propertyKey) fail("INVALID_METADATA", `${name} calls must use a direct ${name}(...) call; aliases and indirect calls are unsupported`);
669
676
  }
670
- for (const value of Object.values(node) as unknown[]) {
671
- if (Array.isArray(value)) {
672
- for (const child of value as unknown[]) if (astNode(child)) visit(child, node);
673
- } else if (astNode(value)) visit(value, node);
674
- }
677
+ for (const child of astChildren(node)) visit(child, node);
675
678
  };
676
679
  visit(program);
677
680
  }
678
-
679
681
  function hasIdentifier(node: acorn.AnyNode, name: string): boolean {
680
682
  if (node.type === "Identifier" && node.name === name) return true;
681
- return Object.values(node).some((value) => Array.isArray(value) ? value.some((child) => astNode(child) && hasIdentifier(child, name)) : astNode(value) && hasIdentifier(value, name));
683
+ return astChildren(node).some((child) => hasIdentifier(child, name));
682
684
  }
683
685
 
684
686
  const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
@@ -854,7 +856,7 @@ function staticValue(node: acorn.AnyNode | undefined): StaticValue {
854
856
  }
855
857
 
856
858
  export interface StaticWorkflowCall {
857
- kind: "agent" | "conversation" | "parallel" | "pipeline" | "checkpoint" | "phase" | "withWorktree";
859
+ kind: WorkflowCallKind;
858
860
  start: number;
859
861
  end: number;
860
862
  name: string | null;
@@ -1022,7 +1024,7 @@ export class WorkflowRegistry {
1022
1024
  if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${name}`);
1023
1025
  return structuredClone(replayed);
1024
1026
  }
1025
- const result: unknown = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, agent: context.agent, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
1027
+ const result: unknown = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, invoke: context.invoke, agent: context.agent, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
1026
1028
  if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
1027
1029
  const stored = structuredClone(result);
1028
1030
  journal.put(path, stored);
@@ -1212,7 +1214,6 @@ export function createLaunchSnapshot(input: LaunchSnapshotInput): Readonly<Launc
1212
1214
  }
1213
1215
 
1214
1216
  export function loadLaunchSnapshot(input: LaunchSnapshot): Readonly<LaunchSnapshot> {
1215
- if (object(input.settings) && Object.prototype.hasOwnProperty.call(input.settings, "maxAgentLaunches")) fail("RESUME_INCOMPATIBLE", "Persisted workflow settings contain removed maxAgentLaunches");
1216
1217
  return deepFreeze(structuredClone(input));
1217
1218
  }
1218
1219
 
@@ -1953,13 +1954,7 @@ class WorkflowEventPublisher {
1953
1954
  this.#budgetEvents.set(runId, seen);
1954
1955
  }
1955
1956
 
1956
- async runStarted(store: RunStore, metadata: WorkflowMetadata, background: boolean): Promise<void> {
1957
- await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {});
1958
- if (background) await this.legacyStarted(store, metadata);
1959
- }
1960
-
1961
- async legacyStarted(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_ASYNC_STARTED_EVENT, { id: store.runId, runId: store.runId, pid: process.pid, sessionId: store.sessionId, asyncDir: store.directory, agent: metadata.name }); }
1962
-
1957
+ async runStarted(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_STARTED_EVENT, {}); }
1963
1958
  async runResumed(store: RunStore, metadata: WorkflowMetadata): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_RESUMED_EVENT, {}); }
1964
1959
 
1965
1960
  async runState(store: RunStore, metadata: WorkflowMetadata, previousState: RunState, state: RunState, reason?: string): Promise<void> {
@@ -1967,16 +1962,9 @@ class WorkflowEventPublisher {
1967
1962
  if ((previousState === "paused" || previousState === "interrupted" || previousState === "budget_exhausted") && state === "running") await this.runResumed(store, metadata);
1968
1963
  }
1969
1964
 
1970
- async runCompleted(store: RunStore, metadata: WorkflowMetadata, resultPath: string, background: boolean): Promise<void> {
1971
- await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath });
1972
- if (background) await this.#publish(store, metadata, WORKFLOW_ASYNC_COMPLETE_EVENT, { id: store.runId, runId: store.runId, sessionId: store.sessionId, asyncDir: store.directory, success: true, state: "complete" });
1973
- }
1974
- async runFailed(store: RunStore, metadata: WorkflowMetadata, error: unknown, background: boolean, state: "failed" | "stopped" | "interrupted" | "budget_exhausted"): Promise<void> {
1965
+ async runCompleted(store: RunStore, metadata: WorkflowMetadata, resultPath: string): Promise<void> { await this.#publish(store, metadata, WORKFLOW_RUN_COMPLETED_EVENT, { resultPath }); }
1966
+ async runFailed(store: RunStore, metadata: WorkflowMetadata, error: unknown, state: "failed" | "stopped" | "interrupted" | "budget_exhausted"): Promise<void> {
1975
1967
  if (state === "failed") await this.#publish(store, metadata, WORKFLOW_RUN_FAILED_EVENT, { error: safeEventError(error) });
1976
- if (background) {
1977
- const legacyState = state === "interrupted" ? "failed" : state;
1978
- await this.#publish(store, metadata, WORKFLOW_ASYNC_COMPLETE_EVENT, { id: store.runId, runId: store.runId, sessionId: store.sessionId, asyncDir: store.directory, success: false, state: legacyState, ...(state === "stopped" ? { stopped: true } : {}), ...(legacyState === "budget_exhausted" || legacyState === "failed" ? { error: safeEventError(error).message } : {}) });
1979
- }
1980
1968
  }
1981
1969
 
1982
1970
  async agentState(store: RunStore, metadata: WorkflowMetadata, previous: AgentRecord | undefined, agent: AgentRecord): Promise<void> {
@@ -2133,16 +2121,26 @@ function nextNamedOccurrence(counters: Map<string, number>, label: string): stri
2133
2121
  return count === 1 ? label : `${label}#${String(count)}`;
2134
2122
  }
2135
2123
 
2136
-
2137
2124
  function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runContext: Readonly<WorkflowRunContext>, variables: Readonly<Record<string, JsonValue>>, registry: WorkflowRegistryApi): WorkflowBridge {
2138
2125
  const functionAgentOccurrences = new Map<string, number>();
2139
2126
  const functionWorktreeOccurrences = new Map<string, number>();
2140
- return { ...bridge, functions: registry.globals(), variables, function: async (name, input, path, signal, worktreeOwner, structuralPath = []) => {
2127
+ const functionInvokeOccurrences = new Map<string, number>();
2128
+ const invokeFunction = async (name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string, structuralPath: readonly string[] = [], breadcrumb?: string): Promise<JsonValue> => {
2141
2129
  const replayed = await store.replay(path);
2142
2130
  let stored: JsonValue | undefined;
2143
2131
  const sideEffects: Promise<void>[] = [];
2132
+ const functionBreadcrumb = breadcrumb ?? name;
2144
2133
  const context: WorkflowFunctionContext = {
2145
2134
  run: runContext,
2135
+ invoke: async (targetName, targetInput) => {
2136
+ const inherited = inheritedHostAgentPath.getStore() ?? structuralPath;
2137
+ const scopedWorktreeOwner = inheritedHostWorktreeOwner.getStore() ?? worktreeOwner;
2138
+ const key = JSON.stringify([path, inherited, targetName]);
2139
+ const occurrence = (functionInvokeOccurrences.get(key) ?? 0) + 1;
2140
+ functionInvokeOccurrences.set(key, occurrence);
2141
+ const nestedPath = operationPath("function", "nested", path, ...inherited, targetName, `occurrence:${String(occurrence)}`);
2142
+ return invokeFunction(targetName, targetInput, nestedPath, signal, scopedWorktreeOwner, inherited, `${functionBreadcrumb} > ${targetName}`);
2143
+ },
2146
2144
  agent: async (...args: readonly unknown[]) => {
2147
2145
  if (!bridge.agent || typeof args[0] !== "string") fail("AGENT_FAILED", "No agent bridge is available");
2148
2146
  const options = validateAgentOptions(args[1] === undefined ? {} : args[1]);
@@ -2151,7 +2149,7 @@ function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runConte
2151
2149
  const key = `${path}\0${JSON.stringify(inherited)}`;
2152
2150
  const occurrence = (functionAgentOccurrences.get(key) ?? 0) + 1;
2153
2151
  functionAgentOccurrences.set(key, occurrence);
2154
- return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: name, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
2152
+ return bridge.agent(args[0], options, signal, { structuralPath: [...inherited], callSite: `function:${path}`, occurrence, parentBreadcrumb: functionBreadcrumb, ...(scopedWorktreeOwner ? { worktreeOwner: scopedWorktreeOwner } : {}) });
2155
2153
  },
2156
2154
  prompt: workflowPrompt,
2157
2155
  parallel: (...args: readonly unknown[]) => hostParallel(args[0], args[1]),
@@ -2168,14 +2166,62 @@ function withWorkflowFunctions(bridge: WorkflowBridge, store: RunStore, runConte
2168
2166
  await Promise.all(sideEffects);
2169
2167
  if (!replayed) await store.complete(path, stored ?? result);
2170
2168
  return result;
2171
- } };
2169
+ };
2170
+ return { ...bridge, functions: registry.globals(), variables, function: invokeFunction };
2172
2171
  }
2173
2172
 
2174
2173
  function projectTrusted(ctx: unknown): boolean {
2175
2174
  const check = object(ctx) ? ctx.isProjectTrusted : undefined;
2176
2175
  return typeof check === "function" ? Boolean(Reflect.apply(check, ctx, [])) : true;
2177
2176
  }
2178
-
2177
+ type PiHostCapabilities = { registerEntryRenderer?: ExtensionAPI["registerEntryRenderer"]; events?: WorkflowEventSink };
2178
+ function isEntryRenderer(value: unknown): value is NonNullable<PiHostCapabilities["registerEntryRenderer"]> { return typeof value === "function"; }
2179
+ function isWorkflowEventSink(value: unknown): value is WorkflowEventSink { return object(value) && typeof value.emit === "function"; }
2180
+ function piHostCapabilities(pi: unknown): PiHostCapabilities {
2181
+ if (!object(pi)) return {};
2182
+ const registerEntryRenderer = pi.registerEntryRenderer;
2183
+ const events = pi.events;
2184
+ return { ...(isEntryRenderer(registerEntryRenderer) ? { registerEntryRenderer } : {}), ...(isWorkflowEventSink(events) ? { events } : {}) };
2185
+ }
2186
+ type ContextHostCapabilities = { modelRegistry?: ModelRegistryCapability };
2187
+ type ModelSummary = { provider: string; id: string };
2188
+ type ModelRegistryGetter = () => readonly ModelSummary[];
2189
+ type ModelRegistryCapability = { getAll?: ModelRegistryGetter; getAvailable?: ModelRegistryGetter };
2190
+ function isModelRegistryGetter(value: unknown): value is ModelRegistryGetter { return typeof value === "function"; }
2191
+ function contextHostCapabilities(ctx: unknown): ContextHostCapabilities {
2192
+ if (!object(ctx) || !object(ctx.modelRegistry)) return {};
2193
+ const registry = ctx.modelRegistry;
2194
+ const getAll = registry.getAll;
2195
+ const getAvailable = registry.getAvailable;
2196
+ return { modelRegistry: { ...(isModelRegistryGetter(getAll) ? { getAll: () => getAll.call(registry) } : {}), ...(isModelRegistryGetter(getAvailable) ? { getAvailable: () => getAvailable.call(registry) } : {}) } };
2197
+ }
2198
+ type UiSelect = (title: string, options: string[]) => Promise<string | undefined>;
2199
+ type UiInput = (title: string, placeholder?: string) => Promise<string | undefined>;
2200
+ type UiSetStatus = (key: string, text?: string) => void;
2201
+ type UiHostCapabilities = { select?: UiSelect; input?: UiInput; setStatus?: UiSetStatus };
2202
+ function isUiSelect(value: unknown): value is UiSelect { return typeof value === "function"; }
2203
+ function isUiInput(value: unknown): value is UiInput { return typeof value === "function"; }
2204
+ function isUiSetStatus(value: unknown): value is UiSetStatus { return typeof value === "function"; }
2205
+ function uiHostCapabilities(ui: unknown): UiHostCapabilities | undefined {
2206
+ if (!object(ui)) return undefined;
2207
+ const select = ui.select;
2208
+ const input = ui.input;
2209
+ const setStatus = ui.setStatus;
2210
+ return { ...(isUiSelect(select) ? { select } : {}), ...(isUiInput(input) ? { input } : {}), ...(isUiSetStatus(setStatus) ? { setStatus } : {}) };
2211
+ }
2212
+ type TuiHostCapabilities = { terminal?: { rows?: unknown } };
2213
+ function tuiHostCapabilities(tui: unknown): TuiHostCapabilities {
2214
+ if (!object(tui) || !object(tui.terminal)) return {};
2215
+ return { terminal: { ...(tui.terminal.rows === undefined ? {} : { rows: tui.terminal.rows }) } };
2216
+ }
2217
+ function tuiRows(tui: unknown): number { const rows = tuiHostCapabilities(tui).terminal?.rows; return typeof rows === "number" && Number.isFinite(rows) ? rows : 24; }
2218
+ type KeybindingsHostCapabilities = { getKeys?: (name: string) => readonly string[] };
2219
+ function isKeybindingGetter(value: unknown): value is NonNullable<KeybindingsHostCapabilities["getKeys"]> { return typeof value === "function"; }
2220
+ function keybindingsHostCapabilities(keybindings: unknown): KeybindingsHostCapabilities {
2221
+ if (!object(keybindings) || !isKeybindingGetter(keybindings.getKeys)) return {};
2222
+ return { getKeys: keybindings.getKeys };
2223
+ }
2224
+ function keybindingKeys(keybindings: unknown, name: string): readonly string[] | undefined { const getKeys = keybindingsHostCapabilities(keybindings).getKeys; return typeof getKeys === "function" ? getKeys.call(keybindings, name) : undefined; }
2179
2225
  function parseThinking(value: unknown): ModelSpec["thinking"] | undefined {
2180
2226
  switch (value) {
2181
2227
  case "off": case "minimal": case "low": case "medium": case "high": case "xhigh": case "max": return value;
@@ -2186,7 +2232,7 @@ function parseThinking(value: unknown): ModelSpec["thinking"] | undefined {
2186
2232
  export default function workflowExtension(pi: ExtensionAPI, home?: string, clipboard = copyToClipboard, createSession: SessionFactory = createNativeAgentSession) {
2187
2233
  beginWorkflowExtensionLoading();
2188
2234
  const registry = loadingRegistry();
2189
- const registerEntryRenderer = (pi as unknown as Partial<Pick<ExtensionAPI, "registerEntryRenderer">>).registerEntryRenderer;
2235
+ const registerEntryRenderer = piHostCapabilities(pi).registerEntryRenderer;
2190
2236
  registerEntryRenderer?.<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, (entry) => {
2191
2237
  const data = entry.data;
2192
2238
  return textBlock(data ? `Workflow ${data.workflowName}: ${data.message}` : "");
@@ -2196,7 +2242,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2196
2242
  try { pi.appendEntry<WorkflowLogEntry>(WORKFLOW_LOG_ENTRY, { workflowName, message: utf8Prefix(message, DELIVERY_LIMIT_BYTES) }); }
2197
2243
  finally { await lifecycle.leave(); }
2198
2244
  };
2199
- const eventPublisher = new WorkflowEventPublisher((pi as unknown as { events?: WorkflowEventSink }).events);
2245
+ const eventPublisher = new WorkflowEventPublisher(piHostCapabilities(pi).events);
2200
2246
  pi.on("resources_discover", () => {
2201
2247
  if (!pi.getActiveTools().includes("workflow")) return;
2202
2248
  const extensionDir = dirname(fileURLToPath(import.meta.url));
@@ -2205,6 +2251,24 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2205
2251
  });
2206
2252
  type BudgetDecisionResult = { state: "running" | "budget_exhausted"; approved: boolean };
2207
2253
  const runs = new Map<string, { executor: WorkflowAgentExecutor; store: RunStore; metadata: WorkflowMetadata; model: ModelSpec; lifecycle: RunLifecycle; budget: WorkflowBudgetRuntime; abortController: AbortController; projectTrusted: () => boolean; execution?: WorkflowExecution; completion?: Promise<unknown>; checkpointResolvers: Map<string, (value: boolean) => void>; budgetResolvers: Map<string, (result: BudgetDecisionResult) => void>; update?: (result: WorkflowToolUpdate) => void }>();
2254
+ const liveActivities = new Map<string, Map<string, AgentActivity>>();
2255
+ const setLiveActivity = (runId: string, agentId: string, activity?: AgentActivity) => {
2256
+ const activities = liveActivities.get(runId);
2257
+ if (activity) {
2258
+ if (activities) activities.set(agentId, activity);
2259
+ else liveActivities.set(runId, new Map([[agentId, activity]]));
2260
+ } else {
2261
+ activities?.delete(agentId);
2262
+ if (activities?.size === 0) liveActivities.delete(runId);
2263
+ }
2264
+ };
2265
+ const withLiveActivities = (run: PersistedRun): PersistedRun => {
2266
+ const activities = liveActivities.get(run.id);
2267
+ return activities?.size ? { ...run, agents: run.agents.map((agent) => {
2268
+ const activity = activities.get(agent.id);
2269
+ return activity ? { ...agent, activity } : agent;
2270
+ }) } : run;
2271
+ };
2208
2272
  const conversationLocks = new Set<string>();
2209
2273
  const terminalRunStates = new Map<string, "completed" | "failed" | "stopped">();
2210
2274
  let sessionLease: SessionLease | undefined;
@@ -2240,7 +2304,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2240
2304
  return nextRun;
2241
2305
  });
2242
2306
  await eventPublisher.runState(store, metadata, previous, next, reason);
2243
- runs.get(store.runId)?.update?.(workflowToolUpdate(persisted));
2307
+ runs.get(store.runId)?.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2244
2308
  });
2245
2309
  const scheduler = new FairAgentScheduler(async ({ id, runId, parentId, prompt, options, signal, setSteer }) => {
2246
2310
  const run = runs.get(runId);
@@ -2257,7 +2321,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2257
2321
  runState = { ...loaded.run, ...run.budget.snapshot(), agents: loaded.run.agents.map((agent) => agent.id === id ? { ...agent, accounting: progress.accounting, toolCalls: progress.toolCalls, activity: progress.activity } : agent) };
2258
2322
  }
2259
2323
  if (!runState.agents.some((agent) => agent.id === id)) return;
2260
- run.update?.(workflowToolUpdate(runState));
2324
+ setLiveActivity(runId, id, progress.activity);
2325
+ run.update?.(workflowToolUpdate(withLiveActivities(runState)));
2261
2326
  };
2262
2327
  const onAttempt = async (attempt: Pick<AgentAttempt, "attempt" | "sessionId" | "sessionFile" | "setup">) => {
2263
2328
  await scheduler.flush();
@@ -2268,7 +2333,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2268
2333
  const active = (await run.store.load()).run;
2269
2334
  await eventPublisher.agentStates(run.store, run.metadata, before.agents, active.agents);
2270
2335
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2271
- run.update?.(workflowToolUpdate(persisted));
2336
+ run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2272
2337
  };
2273
2338
  const result = await run.executor.execute(prompt, { label: options.label, workflowName: run.metadata.name, onProgress, onAttempt, budget, ...(parentId ? { parent: parentId, cwd: options.cwd, ...(options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}) } : options.worktreeOwner ? { worktreeOwner: options.worktreeOwner } : {}), ...(options.model ? { model: options.model } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.role ? { role: options.role } : {}), ...(options.role ? {} : { tools: options.tools }), effectiveTools: options.tools, ...(options.schema ? { schema: options.schema } : {}), ...(options.retries === undefined ? {} : { retries: options.retries }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.agentOptions ? { agentOptions: options.agentOptions } : {}), ...(options.agentIdentity ? { agentIdentity: options.agentIdentity } : {}), ...(options.conversation ? { conversation: options.conversation } : {}) }, signal, scheduler.toolsFor(id, (role, tools, model, inheritedTools, thinking) => run.executor.resolve({ label: "child", workflowName: run.metadata.name, ...(model ? { model } : {}), ...(thinking ? { thinking } : {}), ...(role ? { role } : {}), ...(tools !== undefined ? { tools } : {}) }, inheritedTools).tools), setSteer, () => { scheduler.cancelChildren(id); scheduler.retry(id); });
2274
2339
  const before = (await run.store.load()).run;
@@ -2276,7 +2341,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2276
2341
  const completed = (await run.store.load()).run;
2277
2342
  await eventPublisher.agentStates(run.store, run.metadata, before.agents, completed.agents);
2278
2343
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2279
- run.update?.(workflowToolUpdate(persisted));
2344
+ setLiveActivity(runId, id);
2345
+ run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2280
2346
  return result.value;
2281
2347
  } catch (error) {
2282
2348
  const attempts = (error as WorkflowError & { attempts?: readonly AgentAttempt[] }).attempts;
@@ -2287,7 +2353,8 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2287
2353
  await eventPublisher.agentStates(run.store, run.metadata, before.agents, failed.agents);
2288
2354
  }
2289
2355
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot() }));
2290
- run.update?.(workflowToolUpdate(persisted));
2356
+ setLiveActivity(runId, id);
2357
+ run.update?.(workflowToolUpdate(withLiveActivities(persisted)));
2291
2358
  throw error;
2292
2359
  }
2293
2360
  }, 16, async (runId, ownership) => {
@@ -2309,7 +2376,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2309
2376
  return { ...current, agents };
2310
2377
  });
2311
2378
  await eventPublisher.agentStates(run.store, run.metadata, previousAgents, runState.agents);
2312
- run.update?.(workflowToolUpdate(runState));
2379
+ run.update?.(workflowToolUpdate(withLiveActivities(runState)));
2313
2380
  });
2314
2381
  type WorkflowStopResult = { runId: string; state: RunState | "unknown"; stopped: boolean; reason?: "unknown_run" | "already_terminal" };
2315
2382
  const stopWorkflowRun = async (runId: string): Promise<WorkflowStopResult> => {
@@ -2503,7 +2570,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2503
2570
  try { variables = await resolveWorkflowVariables(runContext, controller, registry); }
2504
2571
  catch (error) {
2505
2572
  const typed = asWorkflowError(error);
2506
- if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, true, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); }
2573
+ if (!["completed", "failed", "stopped"].includes(run.lifecycle.state)) { await run.lifecycle.terminal("failed", typed.code).catch(() => undefined); const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, error: { code: typed.code, message: typed.message } })); await eventPublisher.runFailed(run.store, run.metadata, typed, run.lifecycle.state === "interrupted" ? "interrupted" : "failed"); run.update?.(workflowToolUpdate(persisted)); }
2507
2574
  throw typed;
2508
2575
  }
2509
2576
  await scheduler.cancelRun(run.store.runId);
@@ -2553,7 +2620,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2553
2620
  if (run.budget.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
2554
2621
  const resultPath = await run.store.saveResult(value);
2555
2622
  await run.lifecycle.terminal("completed", "completed");
2556
- await eventPublisher.runCompleted(run.store, run.metadata, resultPath, true);
2623
+ await eventPublisher.runCompleted(run.store, run.metadata, resultPath);
2557
2624
  return { value, resultPath };
2558
2625
  }).catch(async (error: unknown) => {
2559
2626
  await scheduler.flush();
@@ -2561,7 +2628,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2561
2628
  if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) await run.lifecycle.terminal(typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
2562
2629
  const persisted = await persistRunState(run.store, run.metadata, (current) => ({ ...current, ...run.budget.snapshot(), error: { code: typed.code, message: typed.message } }));
2563
2630
  const state = run.lifecycle.state === "stopped" || run.lifecycle.state === "interrupted" || run.lifecycle.state === "budget_exhausted" ? run.lifecycle.state : "failed";
2564
- await eventPublisher.runFailed(run.store, run.metadata, typed, true, state);
2631
+ await eventPublisher.runFailed(run.store, run.metadata, typed, state);
2565
2632
  run.update?.(workflowToolUpdate(persisted));
2566
2633
  if (!["stopped", "interrupted", "budget_exhausted"].includes(run.lifecycle.state)) deliverFailure(pi, run.metadata.name, error);
2567
2634
  });
@@ -2650,7 +2717,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2650
2717
  for (const decision of await store.pendingWorkflowDecisions()) deliver(pi, budgetDecisionDelivery(loaded.snapshot.metadata, decision));
2651
2718
  scheduler.restoreRun(runId, loaded.snapshot.settings.concurrency, loaded.snapshot.identityVersion === LAUNCH_SNAPSHOT_IDENTITY_VERSION ? await store.loadOwnership() : [], () => runs.get(runId)?.budget.checkAgentLaunch());
2652
2719
  }
2653
- const resumeSelect = (ctx.ui as unknown as { select?: (prompt: string, options: string[]) => Promise<string | undefined> } | undefined)?.select;
2720
+ const resumeSelect = uiHostCapabilities(ctx.ui)?.select;
2654
2721
  if (ctx.hasUI && resumeSelect) {
2655
2722
  const interrupted = [...runs.values()].filter((r) => r.lifecycle.state === "interrupted");
2656
2723
  if (interrupted.length > 0) {
@@ -2690,7 +2757,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2690
2757
  const budget = validateBudget(params.budget);
2691
2758
  const rootModel: ModelSpec = { provider: ctx.model.provider, model: ctx.model.id, thinking: pi.getThinkingLevel() };
2692
2759
  const rootModelName = `${rootModel.provider}/${rootModel.model}`;
2693
- const modelRegistry = (ctx as unknown as { modelRegistry?: { getAll?: () => Array<{ provider: string; id: string }>; getAvailable?: () => Array<{ provider: string; id: string }> } }).modelRegistry;
2760
+ const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
2694
2761
  const knownModels = new Set((modelRegistry?.getAll?.() ?? modelRegistry?.getAvailable?.() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
2695
2762
  knownModels.add(rootModelName);
2696
2763
  const availableModels = knownModels;
@@ -2771,13 +2838,13 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2771
2838
  } finally { await lifecycle.leave(); }
2772
2839
  }, log: logBridge(lifecycle, checked.metadata.name) }, store, runContext, variables, registry), runController.signal);
2773
2840
  (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).execution = execution;
2774
- await eventPublisher.runStarted(store, checked.metadata, background);
2841
+ await eventPublisher.runStarted(store, checked.metadata);
2775
2842
  const finish = execution.result.then(async (value) => {
2776
2843
  await scheduler.flush();
2777
2844
  if (budgetRuntime.hardExhausted) throw new WorkflowError("BUDGET_EXHAUSTED", "Budgeted work was attempted after hard exhaustion");
2778
2845
  const resultPath = await store.saveResult(value);
2779
2846
  await lifecycle.terminal("completed", "completed");
2780
- await eventPublisher.runCompleted(store, checked.metadata, resultPath, background);
2847
+ await eventPublisher.runCompleted(store, checked.metadata, resultPath);
2781
2848
  return { value, resultPath };
2782
2849
  }).catch(async (error: unknown) => {
2783
2850
  await scheduler.flush();
@@ -2785,7 +2852,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2785
2852
  if (!["stopped", "interrupted", "budget_exhausted"].includes(lifecycle.state)) await lifecycle.terminal(typed.code === "CANCELLED" ? "stopped" : typed.code === "BUDGET_EXHAUSTED" ? "budget_exhausted" : "failed", typed.code);
2786
2853
  await persistRunState(store, checked.metadata, (current) => ({ ...current, ...budgetRuntime.snapshot(), error: { code: typed.code, message: typed.message } }));
2787
2854
  const state = lifecycle.state === "stopped" || lifecycle.state === "interrupted" || lifecycle.state === "budget_exhausted" ? lifecycle.state : "failed";
2788
- await eventPublisher.runFailed(store, checked.metadata, typed, background, state);
2855
+ await eventPublisher.runFailed(store, checked.metadata, typed, state);
2789
2856
  throw typed;
2790
2857
  });
2791
2858
  (runs.get(runId) as NonNullable<ReturnType<typeof runs.get>>).completion = finish;
@@ -2842,7 +2909,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2842
2909
  let stores = await loadStores();
2843
2910
  const usage = "Usage: /workflow [doctor|model-aliases], or /workflow pause|resume|stop|approve|reject|delete <run-id> [checkpoint or proposal-id]. Use workflow_resume for budget patches."
2844
2911
  const setWorkflowStatus = (text: string | undefined) => {
2845
- const setStatus = (ctx.ui as unknown as { setStatus?: (key: string, text?: string) => void }).setStatus;
2912
+ const setStatus = uiHostCapabilities(ctx.ui)?.setStatus;
2846
2913
  setStatus?.call(ctx.ui, "workflow-stop", text);
2847
2914
  };
2848
2915
  const runAction = async (actionCommand: string, keepContext: boolean, status: (text: string | undefined) => void = setWorkflowStatus): Promise<"dashboard" | "picker" | "done"> => {
@@ -2883,7 +2950,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2883
2950
  return keepContext ? "dashboard" : "done";
2884
2951
  }
2885
2952
  if (action === "adjust" && run?.lifecycle.state === "budget_exhausted") {
2886
- const input = await (ctx.ui as unknown as { input?: (title: string, placeholder?: string) => Promise<string | undefined> }).input?.("Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
2953
+ const input = await uiHostCapabilities(ctx.ui)?.input?.call(ctx.ui, "Budget patch (JSON)", "{\"tokens\":{\"hard\":null}}" );
2887
2954
  if (input === undefined) return keepContext ? "dashboard" : "done";
2888
2955
  const result = await resumeWorkflowRun(run.store.runId, JSON.parse(input));
2889
2956
  ctx.ui.notify(result.state === "running" ? `Resumed workflow ${run.store.runId}.` : `Budget adjustment for ${run.store.runId} is awaiting approval.`, result.state === "running" ? "info" : "warning");
@@ -2910,7 +2977,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
2910
2977
  };
2911
2978
  const manageAliases = async (): Promise<void> => {
2912
2979
  const settingsPath = workflowSettingsPath();
2913
- const modelRegistry = (ctx as unknown as { modelRegistry?: { getAvailable?: () => Array<{ provider: string; id: string }> } }).modelRegistry;
2980
+ const modelRegistry = contextHostCapabilities(ctx).modelRegistry;
2914
2981
  const available = () => [...new Set((modelRegistry?.getAvailable?.() ?? []).map((model) => `${model.provider}/${model.id}`))].sort();
2915
2982
  const selectTarget = async (): Promise<string | undefined> => {
2916
2983
  const models = available();
@@ -3016,7 +3083,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3016
3083
  await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
3017
3084
  let offset = 0;
3018
3085
  let renderedLines: string[] = [];
3019
- const viewport = () => Math.max(1, (((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24) - 3);
3086
+ const viewport = () => Math.max(1, tuiRows(tui) - 3);
3020
3087
  const move = (delta: number) => { offset = Math.max(0, Math.min(Math.max(0, renderedLines.length - viewport()), offset + delta)); };
3021
3088
  return {
3022
3089
  render(width: number) {
@@ -3137,11 +3204,10 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3137
3204
  let disposed = false;
3138
3205
  let stopRequested = false;
3139
3206
  let stopStatus: string | undefined;
3140
- const terminalRows = () => Math.max(1, ((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24);
3207
+ const terminalRows = () => Math.max(1, tuiRows(tui));
3141
3208
  const keyLabels: Record<string, string> = { up: "↑", down: "↓", pageUp: "pgup", pageDown: "pgdn", escape: "esc" };
3142
3209
  const keyLabel = (binding: string, fallback: string) => {
3143
- const getKeys = (keybindings as unknown as { getKeys?: (name: string) => readonly string[] }).getKeys;
3144
- const keys = getKeys?.call(keybindings, binding);
3210
+ const keys = keybindingKeys(keybindings, binding);
3145
3211
  return keys?.length ? keys.map((key) => keyLabels[key] ?? key).join("/") : fallback;
3146
3212
  };
3147
3213
  const dashboardLayout = () => {
@@ -3235,7 +3301,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3235
3301
  const highlighted = highlightCode(view.script, "javascript");
3236
3302
  let offset = 0;
3237
3303
  let renderedLines: string[] = [];
3238
- const viewport = () => Math.max(1, (((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24) - 3);
3304
+ const viewport = () => Math.max(1, tuiRows(tui) - 3);
3239
3305
  const move = (delta: number) => {
3240
3306
  const maxOffset = Math.max(0, renderedLines.length - viewport());
3241
3307
  offset = Math.max(0, Math.min(maxOffset, offset + delta));
@@ -3289,7 +3355,7 @@ export default function workflowExtension(pi: ExtensionAPI, home?: string, clipb
3289
3355
  let offset = 0;
3290
3356
  let renderedLines: string[] = [];
3291
3357
  const layout = () => {
3292
- const rows = Math.max(1, (((tui as unknown as { terminal?: { rows?: number } }).terminal?.rows) ?? 24));
3358
+ const rows = Math.max(1, tuiRows(tui));
3293
3359
  const compactControls = rows < 4;
3294
3360
  const titleRows = rows >= 5 ? 1 : 0;
3295
3361
  const hintRows = rows >= 8 ? 1 : 0;
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
+ import { renameSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { access, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
4
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
6
  import { homedir } from "node:os";
@@ -158,10 +159,28 @@ export function structuralPath(...names: string[]): string {
158
159
  return names.map((name) => encodeURIComponent(name)).join("/");
159
160
  }
160
161
 
161
- async function atomicJson(path: string, value: unknown): Promise<void> {
162
+ export function atomicWriteFile(path: string, content: string): Promise<void>;
163
+ export function atomicWriteFile(path: string, content: string, sync: true): void;
164
+ export function atomicWriteFile(path: string, content: string, sync = false): Promise<void> | void {
162
165
  const temporary = `${path}.${String(process.pid)}.${randomUUID()}.tmp`;
163
- await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: "utf8", mode: 0o600 });
164
- await rename(temporary, path);
166
+ if (sync) {
167
+ try {
168
+ writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600 });
169
+ renameSync(temporary, path);
170
+ } catch (error) {
171
+ try { rmSync(temporary, { force: true }); } catch { /* Preserve the original write error. */ }
172
+ throw error;
173
+ }
174
+ return;
175
+ }
176
+ return writeFile(temporary, content, { encoding: "utf8", mode: 0o600 }).then(() => rename(temporary, path)).catch(async (error: unknown) => {
177
+ try { await rm(temporary, { force: true }); } catch { /* Preserve the original write error. */ }
178
+ throw error;
179
+ });
180
+ }
181
+
182
+ async function atomicJson(path: string, value: unknown): Promise<void> {
183
+ await atomicWriteFile(path, `${JSON.stringify(value)}\n`);
165
184
  }
166
185
 
167
186
  async function json<T>(path: string): Promise<T> { return JSON.parse(await readFile(path, "utf8")) as T; }
@@ -245,7 +264,7 @@ export class RunStore {
245
264
  }
246
265
 
247
266
  async appendEvent(event: WorkflowRunEvent): Promise<void> {
248
- await this.updateState((run) => ({ ...run, events: [...(run.events ?? []), ...(run.events?.some((current) => current.message === event.message) ? [] : [event])] }));
267
+ await this.updateState((run) => ({ ...run, events: [...(run.events ?? []), ...(run.events?.some((current) => current.type === event.type && current.message === event.message) ? [] : [event])] }));
249
268
  }
250
269
 
251
270
  async saveOwnership(nodes: readonly PersistedOwnershipNode[]): Promise<void> {