@fyeeme/pi-dynamic-workflows 0.1.1 → 1.1.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.
package/README.md CHANGED
@@ -29,7 +29,7 @@ npm install --ignore-scripts # hydrate (the package is a workspace dep)
29
29
  ```
30
30
 
31
31
  This resolves [`@fyeeme/pi-subagent-core`](https://www.npmjs.com/package/@fyeeme/pi-subagent-core)
32
- (`^0.3.0`, from the npm registry — no sibling-repo layout requirement).
32
+ (`^0.5.0`, from the npm registry — no sibling-repo layout requirement).
33
33
 
34
34
  Then import the public API from the package root module (a TypeScript barrel; the package ships `.ts` source):
35
35
 
@@ -37,9 +37,11 @@ Then import the public API from the package root module (a TypeScript barrel; th
37
37
  import { defineWorkflow, runWorkflow } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
38
38
  ```
39
39
 
40
- > The package's `pi.extensions` entry (`./index.ts`) registers the `run_workflow`
41
- > tool and the `/wf-inspect` command. The engine is also fully usable via the
42
- > imports shown here.
40
+ > The package's `pi.extensions` entry registers the `run_workflow` tool plus
41
+ > the shared sub-agent UI from pi-subagent-core (live agent widget above the
42
+ > editor, FleetView below it, and the `/agents` transcript viewer — every
43
+ > spawned workflow agent appears there under its step id). The engine is also
44
+ > fully usable via the imports shown here.
43
45
 
44
46
  ---
45
47
 
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **为 [pi](https://github.com/earendil-works/pi-mono) 打造的确定性 TypeScript 工作流编排。**
4
4
 
5
- 把工作流定义成一份类型化的声明式步骤列表,运行后即可获得**可恢复、受预算约束、可中止**的执行。融合 pi-dynamic-workflows 设计(9 个步骤原语 + 启发式 planner + outcome 收集器)与 Claude Code 工作流引擎的协调机制(确定性沙箱、缓存键恢复、按 agent 中止、动态预算、失控上限)。
5
+ 把工作流定义成一份类型化的声明式步骤列表,运行后即可获得**可恢复、受预算约束、可中止**的执行。融合 pi-dynamic-workflows 设计(10 个步骤原语 + 启发式 planner + outcome 收集器)与 Claude Code 工作流引擎的协调机制(确定性沙箱、缓存键恢复、按 agent 中止、动态预算、失控上限)。
6
6
 
7
7
  语言:[English](README.md) | **中文**
8
8
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  ## 为什么需要它
12
12
 
13
- 一次运行 = 一份步骤列表(`agent` / `code` / `fan_out` / `loop_until` / `adversarial` / `tournament` / `classify_route`)。引擎保证:
13
+ 一次运行 = 一份步骤列表(`agent` / `code` / `log` / `fan_out` / `loop_until` / `loop_until_dry` / `adversarial` / `tournament` / `classify_route` / `sub_workflow`)。引擎保证:
14
14
 
15
15
  - **确定性** —— workflow `.ts` 文件经 AST 守卫,禁止 `Date.now()` / `Math.random()` / `new Date()`;run id 是 `(timestamp, sequence)` 的纯函数。
16
16
  - **恢复即不重派** —— 每个 agent 调用以 `sha256(workflow + prompt + signature)` 为键写入 journal;重跑同一 workflow 会回放缓存的 agent(零子进程派发)。
@@ -28,7 +28,7 @@
28
28
  npm install --ignore-scripts # 水合(本包是 workspace 依赖)
29
29
  ```
30
30
 
31
- 这会解析 npm registry 上的 [`@fyeeme/pi-subagent-core`](https://www.npmjs.com/package/@fyeeme/pi-subagent-core)(`^0.3.0`,无需保持同级仓库目录结构)。
31
+ 这会解析 npm registry 上的 [`@fyeeme/pi-subagent-core`](https://www.npmjs.com/package/@fyeeme/pi-subagent-core)(`^0.5.0`,无需保持同级仓库目录结构)。
32
32
 
33
33
  随后从包根模块导入公共 API(TypeScript barrel,包直接以 `.ts` 源码分发):
34
34
 
@@ -36,7 +36,7 @@ npm install --ignore-scripts # 水合(本包是 workspace 依赖)
36
36
  import { defineWorkflow, runWorkflow } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
37
37
  ```
38
38
 
39
- > 包的 `pi.extensions` 入口(`./index.ts`)目前仍是脚手架——把 `run_workflow` 工具接进 pi 是后续工作。引擎本身已可经上述导入直接使用。
39
+ > 包的 `pi.extensions` 入口注册 `run_workflow` 工具,并接入 pi-subagent-core 的共享子代理 UI(编辑器上方实时 agent widget、下方 FleetView、`/agents` 转录查看器——每个工作流 agent 以其 step id 出现在其中)。引擎本身也可经上述导入直接使用。
40
40
 
41
41
  ---
42
42
 
@@ -298,6 +298,8 @@ const result = await runWorkflow({ workflow: wf, cwd: tempDir, now: 1000, dispat
298
298
  | `adversarial` | `produce`、`rubric[]`、`judges?`、`minPass?` | `{ candidate, passed, passCount, judges }` |
299
299
  | `tournament` | `candidates`、`judges`、`produce` | `{ candidates, winner, judges }` |
300
300
  | `classify_route` | `classifier`、`routes: Record<cat, Step[]>`、`fallback?` | `{ category, matched, route, routeStatus }` |
301
+ | `sub_workflow` | `workflow: WorkflowDefinition`、`input?`、`inheritBudget?` | `{ steps, status, workflowName, error }` |
302
+ | `loop_until_dry` | `agent(item, i)`、`keyOf?`、`merge?`、`maxRounds?`、`dryThreshold?` | 发现项组成的数组 |
301
303
 
302
304
  每个步骤都接受 `id`、`retry?: { maxRetries }` 与
303
305
  `onBudgetExhaust?: "throw" | "null"`——`"null"` 下预算耗尽时该步骤返回 `null`
package/index.ts CHANGED
@@ -5,27 +5,20 @@
5
5
  * workflow from within pi. The engine (src/runner) does the work; this entry
6
6
  * only adapts the agent's JSON args into the code-form WorkflowDefinition and
7
7
  * runs it with the default dispatch (real `pi --mode json` subprocesses).
8
+ *
9
+ * Live progress UI is delegated to the shared `@fyeeme/pi-subagent-core`
10
+ * extension (registered via the `pi.extensions` manifest alongside this
11
+ * entry): every spawned workflow agent notifies the process-global monitor
12
+ * through spawnAgent, rendering in the shared above-editor agent widget, the
13
+ * below-editor FleetView, and the `/agents` transcript viewer. This package
14
+ * ships no widget/command of its own — a former `wf:progress` widget and
15
+ * `/wf-inspect` command were removed in favor of that shared surface.
8
16
  */
9
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
18
  import { Type } from "typebox";
11
- import { buildRenderGroups, type PhaseDef } from "./src/ui-groups.ts";
12
- import { BOLD, CYAN, DIM, GREEN, RED, YELLOW, fmtTokens, stepIdOf } from "./src/format.ts";
13
19
  import { defineWorkflow, runWorkflow } from "./src/index.ts";
14
- import type { AgentCallId, Budget, RunResult, StageType, StepContext, StepDefinition, StepResult, StepStats, WorkflowDefinition } from "./src/types.ts";
20
+ import type { Budget, StepContext, StepDefinition, WorkflowDefinition } from "./src/types.ts";
15
21
  import { WorkflowError } from "./src/errors.ts";
16
- import type { AgentLifecycleListeners } from "./src/lifecycle.ts";
17
- import { WorkflowInspect } from "./src/inspect.ts";
18
-
19
- /** Last completed run, exposed to /wf-inspect for interactive review. */
20
- let lastRunResult: RunResult | null = null;
21
-
22
- /** Phases of the most recent run — handed to /wf-inspect so the post-run view
23
- * groups steps the same way the live widget did (C1 consistency). */
24
- let lastPhases: readonly PhaseDef[] | undefined;
25
-
26
- /** Active widget during a run — lets /wf-inspect show a live snapshot
27
- * before the run completes (lastRunResult is only set post-run). */
28
- let activeWidget: { snapshot(): RunResult } | null = null;
29
22
 
30
23
  // ---------------------------------------------------------------------------
31
24
  // Parameter schema (the JSON-serializable workflow subset)
@@ -49,7 +42,7 @@ const StepSchema = Type.Union([
49
42
  Type.Object({
50
43
  id: Type.String(),
51
44
  type: Type.Literal("log"),
52
- message: Type.String({ description: "Narrative line emitted into the progress widget (zero dispatch / zero tokens)" }),
45
+ message: Type.String({ description: "Narrative line fired via the onLog lifecycle listener (zero dispatch / zero tokens)" }),
53
46
  onBudgetExhaust: BudgetExhaustPolicy,
54
47
  }),
55
48
  Type.Object({
@@ -102,18 +95,11 @@ const BudgetSchema = Type.Object({
102
95
  maxDurationMs: Type.Optional(Type.Number()),
103
96
  });
104
97
 
105
- const PhaseSchema = Type.Object({
106
- title: Type.String({ description: "Phase display name" }),
107
- detail: Type.Optional(Type.String({ description: "Short detail shown next to the phase title" })),
108
- stepIds: Type.Array(Type.String(), { description: "Step ids belonging to this phase" }),
109
- });
110
-
111
98
  const WorkflowSchema = Type.Object({
112
99
  name: Type.String(),
113
100
  description: Type.Optional(Type.String()),
114
101
  steps: Type.Array(StepSchema),
115
102
  budget: Type.Optional(BudgetSchema),
116
- phases: Type.Optional(Type.Array(PhaseSchema, { description: "Group steps into phases for progress-tree UI" })),
117
103
  });
118
104
 
119
105
  const RunWorkflowParams = Type.Object({
@@ -287,234 +273,6 @@ type StepData =
287
273
  onBudgetExhaust?: "throw" | "null";
288
274
  };
289
275
 
290
- // ---------------------------------------------------------------------------
291
- // Progress widget — bridges lifecycle events → TUI setWidget
292
- // ---------------------------------------------------------------------------
293
-
294
- type CallStatus = "running" | "done" | "failed" | "skipped" | "retried" | "cached";
295
-
296
- interface CallInfo {
297
- readonly stepId: string;
298
- readonly status: CallStatus;
299
- readonly tokens: number;
300
- readonly model?: string;
301
- }
302
-
303
- export function buildProgressWidget(
304
- steps: readonly { id: string; type: string }[],
305
- setWidget: (lines: string[] | undefined) => void,
306
- setStatus: (text: string | undefined) => void,
307
- phases?: readonly PhaseDef[],
308
- ): AgentLifecycleListeners & { cleanup(): void; snapshot(): RunResult } {
309
- const start = Date.now();
310
- let lastStreamRender = 0;
311
- // Once cleanup() runs (run finished), late events from the abort window — the
312
- // SIGTERM→SIGKILL grace period during which a child can still emit streamed
313
- // deltas — must not re-create the panel via render()/setWidget.
314
- let disposed = false;
315
- const calls = new Map<AgentCallId, CallInfo>();
316
- // C2: narrative lines emitted by `log` steps, keyed by step id.
317
- const logLines = new Map<string, string>();
318
- // C3: accumulated streaming text per in-flight call (delta chunks from onUpdate).
319
- const streamText = new Map<string, string>();
320
- // Live output capture: callId → the settled agent's final text (from onAgentEnd
321
- // `output`), so a live snapshot shows REAL results, not fabricated progress text.
322
- const callOutputs = new Map<string, string>();
323
- // Every step starts at 0 expected agents; onAgentStart/onAgentCacheHit
324
- // increment as calls fire (fan_out totals emerge at runtime). Pre-seeding
325
- // non-fan_out steps to 1 double-counted (1→2 on start), leaving completed
326
- // single-agent steps stuck showing [1/2].
327
- const expected = new Map(steps.map((s) => [s.id, 0]));
328
-
329
- // C1+D1: phase grouping — shared pure builder (also used by /wf-inspect).
330
- const renderGroups = buildRenderGroups(steps, (s) => s.id, phases);
331
-
332
- // callId format: `${stepId}#${n}` (e.g. "fan#2", "adv#produce") — see src/format.ts stepIdOf.
333
-
334
- function render(): void {
335
- const picons: Record<string, string> = {
336
- done: GREEN("✓"),
337
- failed: RED("✗"),
338
- skipped: YELLOW("⏭"),
339
- running: YELLOW("⏳"),
340
- cached: GREEN("↻"),
341
- };
342
-
343
- const lines: string[] = [];
344
- const renderStep = (s: { id: string; type: string }, indent: boolean): void => {
345
- // C2: a `log` step renders as a distinct narrative line, not an agent row.
346
- if (s.type === "log") {
347
- const msg = logLines.get(s.id);
348
- if (msg !== undefined) lines.push(`${indent ? " " : " "}${DIM(msg)}`);
349
- return;
350
- }
351
- const total = expected.get(s.id) ?? 0;
352
- const entries = [...calls.values()].filter((c) => c.stepId === s.id);
353
- const running = entries.some((c) => c.status === "running");
354
- const failed = entries.filter((c) => c.status === "failed").length;
355
- const skipped = entries.filter((c) => c.status === "skipped").length;
356
- const done = entries.filter((c) => c.status === "done" || c.status === "cached").length;
357
- const cached = entries.filter((c) => c.status === "cached").length;
358
- const tokens = entries.reduce((sum, c) => sum + c.tokens, 0);
359
- const models = new Set(entries.map((c) => c.model).filter((m): m is string => Boolean(m)));
360
-
361
- const icon = failed > 0 ? picons.failed
362
- : skipped > 0 && done === 0 ? picons.skipped
363
- : total > 0 && done >= total ? (cached > 0 ? picons.cached : picons.done)
364
- : running ? picons.running
365
- : DIM("○");
366
-
367
- const progress = total > 1 ? ` [${done}/${total}]` : "";
368
- const tok = tokens > 0 ? ` · ${fmtTokens(tokens)} tok` : "";
369
- // A8/C4: show the serving model when known (single model shown; mixed
370
- // fan_out models collapse to a count to avoid a noisy line).
371
- const modelTag = models.size === 1 ? ` · ${[...models][0]}` : models.size > 1 ? ` · ${models.size} models` : "";
372
- const pad = indent ? " " : " ";
373
- lines.push(`${pad}${icon} ${s.id}${progress}${tok}${modelTag}`);
374
- };
375
-
376
- // Every phase group renders its header — a phase interrupted by ungrouped
377
- // items produces two groups; deduplicating the second header would leave
378
- // an indented, header-less orphan row (review L2).
379
- for (const g of renderGroups) {
380
- if (g.kind === "phase" && g.title) {
381
- lines.push(` ${BOLD(g.title)}${g.detail ? DIM(` — ${g.detail}`) : ""}`);
382
- }
383
- for (const s of g.items) renderStep(s, g.kind === "phase");
384
- }
385
-
386
- // C3: streaming tail — the most-recently-started running call's accumulated
387
- // text, truncated to the last 3 lines, so concurrent fan-out previews only
388
- // the active call instead of flooding the widget.
389
- const running = [...calls.entries()].reverse().find(([id, c]) => c.status === "running" && streamText.has(id));
390
- if (running) {
391
- const [id] = running;
392
- const text = streamText.get(id) ?? "";
393
- const tail = text.split("\n").slice(-3);
394
- for (const ln of tail) {
395
- const clipped = ln.length > 100 ? `${ln.slice(0, 99)}…` : ln;
396
- if (clipped) lines.push(DIM(` ↳ ${clipped}`));
397
- }
398
- }
399
-
400
-
401
- setWidget(lines.length > 0 ? lines : void 0);
402
-
403
- const all = [...calls.values()];
404
- const allDone = all.filter((c) => c.status !== "running").length;
405
- const totalTokens = all.reduce((sum, c) => sum + c.tokens, 0);
406
- const elapsed = ((Date.now() - start) / 1000).toFixed(0);
407
- setStatus(`wf ${allDone}/${calls.size} agents · ${fmtTokens(totalTokens)} tok · ${elapsed}s`);
408
- }
409
-
410
- const record = (callId: string, status: CallStatus, tokens = 0, model?: string): void => {
411
- if (disposed) return; // late settle after cleanup — do not re-create the panel
412
- calls.set(callId, { stepId: stepIdOf(callId), status, tokens, model });
413
- render();
414
- };
415
-
416
- return {
417
- cleanup() {
418
- disposed = true;
419
- calls.clear();
420
- streamText.clear();
421
- setWidget(void 0);
422
- setStatus(void 0);
423
- },
424
- /** Build a synthetic RunResult from the live calls map, so /wf-inspect
425
- * can show in-progress agents before the run finishes. */
426
- snapshot(): RunResult {
427
- const snapSteps: StepResult[] = steps.map((s) => {
428
- const entries = [...calls.values()].filter((c) => c.stepId === s.id);
429
- const total = expected.get(s.id) ?? 0;
430
- const done = entries.filter((c) => c.status === "done" || c.status === "cached").length;
431
- const failed = entries.filter((c) => c.status === "failed").length;
432
- const running = entries.filter((c) => c.status === "running").length;
433
- const cached = entries.filter((c) => c.status === "cached").length;
434
- const tokens = entries.reduce((sum, c) => sum + c.tokens, 0);
435
- const status: StepResult["status"] = s.type === "log"
436
- ? "done" // narrative line, no agent call — never "skipped"
437
- : failed > 0 ? "failed" : done >= total && total > 0 ? "done" : running > 0 ? "running" : "skipped";
438
- // Real outputs from settled calls — NOT the fabricated `[n/m] running`
439
- // progress string (that leaked into the detail pane as fake results).
440
- const settled = [...calls.entries()]
441
- .filter(([callId, c]) => c.stepId === s.id)
442
- .map(([callId]) => callOutputs.get(callId))
443
- .filter((o): o is string => Boolean(o));
444
- const results = settled.length > 0
445
- ? (s.type === "fan_out" ? settled : settled[0])
446
- : undefined;
447
- return {
448
- id: s.id,
449
- type: s.type as StageType,
450
- status,
451
- results,
452
- stats: { tokens, cost: 0, durationMs: 0, agents: entries.length, failures: failed },
453
- };
454
- });
455
- const all = [...calls.values()];
456
- const stats: StepStats = {
457
- tokens: all.reduce((sum, c) => sum + c.tokens, 0),
458
- cost: 0,
459
- durationMs: Date.now() - start,
460
- agents: all.length,
461
- failures: all.filter((c) => c.status === "failed").length,
462
- };
463
- return { runId: "live", status: "completed", steps: snapSteps, stats };
464
- },
465
- onAgentStart(callId) {
466
- const stepId = stepIdOf(callId);
467
- expected.set(stepId, (expected.get(stepId) ?? 0) + 1);
468
- record(callId, "running");
469
- },
470
- onAgentEnd(callId, ok, stats, model, output) {
471
- // A skipped/retried/cached call's subprocess still settles (abort →
472
- // notifyEnd(false)); do not overwrite the already-recorded terminal
473
- // state with a "failed" stamp — the run's own bookkeeping marks the
474
- // step skipped/retried, so the widget must show the same.
475
- const cur = calls.get(callId);
476
- if (cur && cur.status !== "running") {
477
- streamText.delete(callId);
478
- return;
479
- }
480
- record(callId, ok ? "done" : "failed", stats?.tokens ?? 0, model);
481
- streamText.delete(callId); // free the accumulated tail once the call settles
482
- if (output) callOutputs.set(callId, output);
483
- },
484
- onAgentSkip(callId) {
485
- record(callId, "skipped");
486
- },
487
- onAgentRetry(callId) {
488
- record(callId, "retried");
489
- },
490
- onAgentCacheHit(callId) {
491
- const stepId = stepIdOf(callId);
492
- expected.set(stepId, (expected.get(stepId) ?? 0) + 1);
493
- record(callId, "cached");
494
- },
495
- onLog(stepId, message) {
496
- if (disposed) return;
497
- logLines.set(stepId, message);
498
- render();
499
- },
500
- onUpdate(callId, partial) {
501
- if (disposed) return;
502
- // Bound the accumulated tail: the widget only ever renders the last 3
503
- // lines (each clipped to ~100 chars), so keeping the full stream alive
504
- // for the call's duration is pure memory growth on long generations.
505
- streamText.set(callId, ((streamText.get(callId) ?? "") + partial).slice(-4096));
506
- // Throttle: a high-frequency stream (fan_out × many deltas) would otherwise
507
- // trigger a full O(steps×calls) render() per chunk. Bound to ~20fps; the
508
- // final onAgentEnd render always fires, so the settled state is exact.
509
- const nowMs = Date.now();
510
- if (nowMs - lastStreamRender >= 50) {
511
- lastStreamRender = nowMs;
512
- render();
513
- }
514
- },
515
- };
516
- }
517
-
518
276
  // ---------------------------------------------------------------------------
519
277
  // Extension
520
278
  // ---------------------------------------------------------------------------
@@ -573,34 +331,15 @@ export default function (pi: ExtensionAPI): void {
573
331
  ctx.ui.notify(`Invalid model(s) dropped, using default: ${dropped.join(", ")}`, "warning");
574
332
  }
575
333
  const sanitizedWorkflow = { ...params.workflow, steps: sanitizedSteps };
576
- const widget = buildProgressWidget(
577
- sanitizedWorkflow.steps,
578
- (lines) => ctx.ui.setWidget("wf:progress", lines),
579
- (text) => ctx.ui.setStatus("wf:summary", text),
580
- sanitizedWorkflow.phases,
581
- );
582
- activeWidget = widget;
583
- lastPhases = sanitizedWorkflow.phases;
584
334
  try {
585
335
  const workflow = buildWorkflow(sanitizedWorkflow);
586
- const listeners: AgentLifecycleListeners = {
587
- onAgentStart: widget.onAgentStart,
588
- onAgentEnd: widget.onAgentEnd,
589
- onAgentSkip: widget.onAgentSkip,
590
- onAgentRetry: widget.onAgentRetry,
591
- onAgentCacheHit: widget.onAgentCacheHit,
592
- onLog: widget.onLog,
593
- onUpdate: widget.onUpdate,
594
- };
595
336
  const result = await runWorkflow({
596
337
  workflow,
597
338
  input: params.input,
598
339
  cwd: params.cwd ?? ctx.cwd,
599
340
  now: params.now ?? Date.now(),
600
341
  signal,
601
- listeners,
602
342
  });
603
- lastRunResult = result;
604
343
 
605
344
  const lines = [
606
345
  `workflow "${workflow.name}" → ${result.status} (run ${result.runId})`,
@@ -616,29 +355,7 @@ export default function (pi: ExtensionAPI): void {
616
355
  } catch (e) {
617
356
  const msg = e instanceof Error ? e.message : String(e);
618
357
  return { content: [{ type: "text" as const, text: `run_workflow failed: ${msg}` }], details: { error: msg }, isError: true };
619
- } finally {
620
- activeWidget = null;
621
- ctx.ui.setWidget("wf:progress", void 0);
622
- widget.cleanup();
623
- }
624
- },
625
- });
626
-
627
- pi.registerCommand("wf-inspect", {
628
- description: "Inspect the current/last workflow run (↑↓ select, enter detail, esc exit)",
629
- handler: async (_args, ctx) => {
630
- // Prefer a live snapshot while a run is in progress; fall back to
631
- // the last completed result once the run has finished.
632
- const r = activeWidget?.snapshot() ?? lastRunResult;
633
- if (!r) {
634
- ctx.ui.notify("No workflow run yet — run run_workflow first", "warning");
635
- return;
636
358
  }
637
- await ctx.ui.custom(
638
- (tui, _theme, _kb, done) =>
639
- new WorkflowInspect(r, tui, () => done(undefined), lastPhases),
640
- { overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "80%" } },
641
- );
642
359
  },
643
360
  });
644
361
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fyeeme/pi-dynamic-workflows",
3
- "version": "0.1.1",
3
+ "version": "1.1.0",
4
4
  "description": "Deterministic TypeScript workflow orchestration for pi. Fuses the pi-dynamic-workflows design (declarative graph, 10 step primitives, heuristic planner, outcome collectors) with Claude Code's workflow engine coordination mechanisms (deterministic sandbox, cache-key resume, per-agent abort map, dynamic budget, runaway caps).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,7 +28,8 @@
28
28
  ],
29
29
  "pi": {
30
30
  "extensions": [
31
- "./index.ts"
31
+ "./index.ts",
32
+ "./node_modules/@fyeeme/pi-subagent-core/sub-agent.ts"
32
33
  ]
33
34
  },
34
35
  "scripts": {
@@ -36,7 +37,7 @@
36
37
  "typecheck": "tsc"
37
38
  },
38
39
  "dependencies": {
39
- "@fyeeme/pi-subagent-core": "^0.3.2"
40
+ "@fyeeme/pi-subagent-core": "^0.5.0"
40
41
  },
41
42
  "peerDependencies": {
42
43
  "@earendil-works/pi-ai": ">=0.84.1",
package/src/format.ts CHANGED
@@ -1,22 +1,13 @@
1
1
  /**
2
- * src/format.ts — shared display/parsing helpers (progress widget + /wf-inspect + runner).
2
+ * src/format.ts — shared parsing helper used by the runner.
3
3
  *
4
- * fmtTokens + ANSI color helpers: used by `buildProgressWidget` (index.ts) and
5
- * `WorkflowInspect` (src/inspect.ts) — one copy so the two UIs cannot drift.
6
4
  * stepIdOf: callId → step-id attribution, used by the runner (degraded-step
7
- * accounting) and the widget (grouping by step). Extracted from the verbatim
8
- * duplicates that used to live in each file.
5
+ * accounting, sibling abort scoping) and by the engine's dispatchOpts (the
6
+ * monitor `displayName` for the shared sub-agent UI). The former ANSI color
7
+ * helpers + fmtTokens were progress-widget/`/wf-inspect` rendering aids and
8
+ * were removed together with those surfaces (live progress is now rendered by
9
+ * the shared @fyeeme/pi-subagent-core extension).
9
10
  */
10
- export const GREEN = (s: string): string => `\x1b[32m${s}\x1b[0m`;
11
- export const RED = (s: string): string => `\x1b[31m${s}\x1b[0m`;
12
- export const YELLOW = (s: string): string => `\x1b[33m${s}\x1b[0m`;
13
- export const DIM = (s: string): string => `\x1b[2m${s}\x1b[0m`;
14
- export const CYAN = (s: string): string => `\x1b[36m${s}\x1b[0m`;
15
- export const BOLD = (s: string): string => `\x1b[1m${s}\x1b[0m`;
16
-
17
- export function fmtTokens(n: number): string {
18
- return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
19
- }
20
11
 
21
12
  /** Extract the step id from a callId of the form `${stepId}#${n}` (e.g.
22
13
  * "fan#2", "adv#produce", "cr#classify"). Falls back to the whole callId when
@@ -985,6 +985,11 @@ function dispatchOpts(
985
985
  systemPrompt: spec.systemPrompt,
986
986
  signal,
987
987
  allowChildRecursion,
988
+ // UI display name for the shared sub-agent widget/FleetView (purely
989
+ // observational metadata consumed by the pi-subagent-core monitor): the
990
+ // step id ("fan", "adv"), so workflow rows are distinguishable from other
991
+ // agents. Cache hits never spawn, so they never appear — zero dispatch.
992
+ displayName: stepIdOf(callId),
988
993
  // C3: bridge the spawn's streamed deltas to the lifecycle onUpdate listener,
989
994
  // attributed to this callId. When no listener is registered, the subprocess
990
995
  // drops the deltas (its onUpdate stays undefined — same as before).
package/src/types.ts CHANGED
@@ -247,23 +247,11 @@ export interface StepRetry {
247
247
  * spec revision; add `retryStage` back with implementation when ready. */
248
248
  }
249
249
 
250
- /** A phase groups related steps for UI progress-tree rendering.
251
- * Steps not assigned to any phase render under an implicit default group. */
252
- export interface PhaseDefinition {
253
- readonly title: string;
254
- readonly detail?: string;
255
- readonly stepIds: readonly string[];
256
- /** Optional model override for all agents in this phase. */
257
- readonly model?: string;
258
- }
259
-
260
250
  export interface WorkflowDefinition {
261
251
  readonly name: string;
262
252
  readonly description?: string;
263
253
  readonly steps: readonly StepDefinition[];
264
254
  readonly budget?: Budget;
265
- /** Optional phase groupings for progress-tree UI rendering. */
266
- readonly phases?: readonly PhaseDefinition[];
267
255
  }
268
256
 
269
257
  /** Typed identity helper: gives a workflow literal full union checking. */
package/src/inspect.ts DELETED
@@ -1,237 +0,0 @@
1
- /**
2
- * WorkflowInspect — fullscreen overlay viewer with a master-detail split.
3
- *
4
- * Opened via `/wf-inspect` as an overlay (`ctx.ui.custom(..., { overlay: true })`)
5
- * sized to ~90% × 80% of the terminal. The body is split into two panes that
6
- * fill the wide overlay instead of wasting it on a single narrow column:
7
- *
8
- * ┌─ STEPS (left ~30%) ──────┬─ DETAIL (right ~70%) ──────────────┐
9
- * │ ✓ gather · 1.2k tok │ collected 3 sources on topic X │
10
- * │▸✓ fan · 8.4k tok │ [fan#1] Research alpha. │
11
- * │ ○ refine · pending │ 8.4k tok · 3 agent(s) · 1240ms │
12
- * └──────────────────────────┴────────────────────────────────────┘
13
- *
14
- * Left: compact step list (always visible, ↑↓/j/k selects, auto-scrolls).
15
- * Right: the selected step's full results + stats (PgUp/PgDn/Shift+↑↓/Home/End
16
- * scrolls the detail). No toggle — detail is always shown for the selection.
17
- * esc/q exits.
18
- */
19
- import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
20
- import type { RunResult, StepResult } from "./types.ts";
21
- import { buildRenderGroups, type PhaseDef } from "./ui-groups.ts";
22
- import { BOLD, CYAN, DIM, GREEN, RED, YELLOW, fmtTokens } from "./format.ts";
23
-
24
- /** Overlay maxHeight percentage — keep in sync with the index.ts overlayOptions. */
25
- const VIEWPORT_HEIGHT_PCT = 80;
26
- /** header(1) + sep(1) + col-header(1) + sep(1) + [body] + sep(1) + footer(1). */
27
- const CHROME_LINES = 6;
28
- const MIN_VIEWPORT = 3;
29
- /** Left pane share of the width (rest goes to the detail pane + separator). */
30
- const LEFT_PCT = 0.3;
31
- const MIN_LEFT = 16;
32
-
33
- /** Minimal TUI surface WorkflowInspect needs. Narrower than full `TUI`. */
34
- export interface InspectTUI {
35
- requestRender(): void;
36
- terminal: { readonly rows: number };
37
- }
38
-
39
- function statusIcon(status: StepResult["status"]): string {
40
- switch (status) {
41
- case "done":
42
- return GREEN("✓");
43
- case "failed":
44
- return RED("✗");
45
- case "skipped":
46
- return YELLOW("⏭");
47
- default:
48
- return YELLOW("⏳");
49
- }
50
- }
51
-
52
- /** Fit a (possibly ANSI-colored) string into exactly `w` visible columns:
53
- * truncate with … if too long, pad with spaces if too short. */
54
- function field(s: string, w: number): string {
55
- return truncateToWidth(s, w, "…", true);
56
- }
57
-
58
- export class WorkflowInspect {
59
- private readonly result: RunResult;
60
- private readonly tui: InspectTUI;
61
- private readonly close: () => void;
62
- private readonly phases?: readonly PhaseDef[];
63
- private selected = 0;
64
- /** Right-pane (detail) scroll offset; reset to 0 whenever selection changes. */
65
- private detailScroll = 0;
66
- /** Left-pane scroll offset; auto-adjusted to keep the selection visible. */
67
- private leftScroll = 0;
68
- /** Last known detail content height (set during render). Lets PgUp/PgDn
69
- * clamp an Infinity detailScroll (set by End) before arithmetic, so
70
- * End→PgUp is not swallowed by Infinity - vp = Infinity. */
71
- private lastMaxDetailScroll = 0;
72
- /** step index → row index within buildLeftLines() (phase title lines shift
73
- * step rows past their raw array index — selection must scroll by row). */
74
- private rowOfStep = new Map<number, number>();
75
-
76
- constructor(result: RunResult, tui: InspectTUI, close: () => void, phases?: readonly PhaseDef[]) {
77
- this.result = result;
78
- this.tui = tui;
79
- this.close = close;
80
- this.phases = phases;
81
- }
82
-
83
- private viewportHeight(): number {
84
- const rows = this.tui.terminal.rows > 0 ? this.tui.terminal.rows : 24;
85
- return Math.max(MIN_VIEWPORT, Math.floor((rows * VIEWPORT_HEIGHT_PCT) / 100) - CHROME_LINES);
86
- }
87
-
88
- handleInput(data: string): void {
89
- if (matchesKey(data, "escape") || data === "q" || data === "Q") {
90
- this.close();
91
- return;
92
- }
93
- const n = this.result.steps.length;
94
- if (n === 0) return;
95
- const vp = this.viewportHeight();
96
- // Right-pane (detail) scroll.
97
- if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
98
- // End may have left detailScroll as the Infinity sentinel (clamped to a
99
- // finite value only on the next render) — clamp before arithmetic so
100
- // Infinity - vp stays Infinity and PgUp appears dead.
101
- const cur = Math.min(this.detailScroll, this.lastMaxDetailScroll);
102
- this.detailScroll = Math.max(0, cur - vp);
103
- this.tui.requestRender();
104
- return;
105
- }
106
- if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
107
- const cur = Math.min(this.detailScroll, this.lastMaxDetailScroll);
108
- this.detailScroll = cur + vp;
109
- this.tui.requestRender();
110
- return;
111
- }
112
- if (matchesKey(data, "home")) {
113
- this.detailScroll = 0;
114
- this.tui.requestRender();
115
- return;
116
- }
117
- if (matchesKey(data, "end")) {
118
- this.detailScroll = Number.POSITIVE_INFINITY;
119
- this.tui.requestRender();
120
- return;
121
- }
122
- // Selection moves — reset the detail pane to the top of the new step.
123
- if (matchesKey(data, "up") || data === "k") {
124
- this.selected = (this.selected - 1 + n) % n;
125
- this.detailScroll = 0;
126
- this.tui.requestRender();
127
- } else if (matchesKey(data, "down") || data === "j") {
128
- this.selected = (this.selected + 1) % n;
129
- this.detailScroll = 0;
130
- this.tui.requestRender();
131
- }
132
- }
133
-
134
- invalidate(): void {
135
- // No cached render state.
136
- }
137
-
138
- render(width: number): string[] {
139
- const r = this.result;
140
- const w = Math.max(width, 40);
141
- const leftW = Math.max(MIN_LEFT, Math.floor(w * LEFT_PCT));
142
- const rightW = Math.max(20, w - leftW - 1); // -1 for the "│" separator
143
- const sep = DIM("│");
144
- const hr = DIM("─".repeat(w));
145
- const out: string[] = [];
146
-
147
- // --- Header (full width) ---
148
- const degraded = r.degradedSteps?.length ? ` · ${r.degradedSteps.length} degraded` : "";
149
- const errTag = r.errorCategory ? ` [${r.errorCategory}]` : "";
150
- const head = `${BOLD(`workflow ${CYAN(r.runId)} → ${r.status}`)} ${DIM(`${r.stats.agents} agents · ${fmtTokens(r.stats.tokens)} tok · ${(r.stats.durationMs / 1000).toFixed(1)}s${degraded}`)}`;
151
- out.push(field(r.error ? RED(`${head} ${r.error}${errTag}`) : head, w));
152
- out.push(hr);
153
-
154
- // --- Column header ---
155
- const selStep = r.steps[this.selected];
156
- const colLeft = BOLD("STEPS");
157
- const colRight = DIM(`DETAIL${selStep ? ` · ${selStep.id} (${selStep.type})` : ""}`);
158
- out.push(field(colLeft, leftW) + sep + field(colRight, rightW));
159
- out.push(hr);
160
-
161
- // --- Body: two panes zipped row-by-row ---
162
- const vp = this.viewportHeight();
163
- const leftLines = this.buildLeftLines(leftW);
164
- // Keep the selected step visible in the left pane. leftLines may start
165
- // with phase title rows, so the selection's ROW (not its raw step index)
166
- // drives the window — otherwise phases push the selected step off-screen.
167
- const selRow = this.rowOfStep.get(this.selected) ?? this.selected;
168
- if (selRow < this.leftScroll) this.leftScroll = selRow;
169
- else if (selRow >= this.leftScroll + vp) this.leftScroll = selRow - vp + 1;
170
- this.leftScroll = Math.max(0, Math.min(this.leftScroll, Math.max(0, leftLines.length - vp)));
171
-
172
- const rightLines = selStep ? this.buildRightLines(selStep) : [DIM("(no step)")];
173
- const maxDetailScroll = Math.max(0, rightLines.length - vp);
174
- this.lastMaxDetailScroll = maxDetailScroll;
175
- this.detailScroll = Math.max(0, Math.min(this.detailScroll, maxDetailScroll));
176
-
177
- for (let i = 0; i < vp; i++) {
178
- const l = field(leftLines[this.leftScroll + i] ?? "", leftW);
179
- const rr = field(rightLines[this.detailScroll + i] ?? "", rightW);
180
- out.push(l + sep + rr);
181
- }
182
-
183
- // --- Footer ---
184
- out.push(hr);
185
- const footL = DIM("↑↓/j/k select · PgUp/PgDn/Shift+↑↓ scroll detail · Home/End · esc exit");
186
- const dPct = rightLines.length <= vp ? "all" : `${Math.round(((this.detailScroll + vp) / rightLines.length) * 100)}%`;
187
- const footR = DIM(`${this.detailScroll}/${rightLines.length} (${dPct}) · ${this.selected + 1}/${r.steps.length} steps`);
188
- out.push(field(footL, leftW) + sep + field(footR, rightW));
189
- return out;
190
- }
191
-
192
- /** Left pane: one compact line per step (status · id · type · tokens). */
193
- private buildLeftLines(w: number): string[] {
194
- const idxOf = new Map(this.result.steps.map((s, i) => [s.id, i]));
195
- const groups = buildRenderGroups(this.result.steps, (s) => s.id, this.phases);
196
- const lines: string[] = [];
197
- const rowOf = new Map<number, number>();
198
- // Every phase group renders its header (a phase interrupted by ungrouped
199
- // items produces two groups; deduplicating the second header would leave
200
- // an indented, header-less orphan row).
201
- for (const g of groups) {
202
- if (g.kind === "phase" && g.title) {
203
- lines.push(field(BOLD(g.title), w));
204
- }
205
- for (const s of g.items) {
206
- const i = idxOf.get(s.id) ?? 0;
207
- rowOf.set(i, lines.length);
208
- const sel = i === this.selected;
209
- const tok = s.stats.tokens > 0 ? ` · ${fmtTokens(s.stats.tokens)} tok` : "";
210
- const body = `${statusIcon(s.status)} ${s.id} ${DIM(`(${s.type})${tok}`)}`;
211
- lines.push(field(sel ? `${CYAN("▸")}${BOLD(body)}` : ` ${body}`, w));
212
- }
213
- }
214
- this.rowOfStep = rowOf;
215
- return lines;
216
- }
217
-
218
- /** Right pane: the selected step's full results + a stats footer line.
219
- * `results === undefined` means a live snapshot with nothing settled yet —
220
- * show an honest "in progress" marker instead of JSON.stringify(undefined). */
221
- private buildRightLines(s: StepResult): string[] {
222
- const lines: string[] = [];
223
- if (s.results === undefined) {
224
- lines.push(DIM("(in progress — no agent output settled yet)"));
225
- } else {
226
- const body = typeof s.results === "string" ? s.results : JSON.stringify(s.results, null, 2);
227
- for (const ln of body.split("\n")) lines.push(DIM(ln));
228
- }
229
- lines.push("");
230
- lines.push(DIM(`${fmtTokens(s.stats.tokens)} tok · ${s.stats.agents} agent(s) · ${s.stats.durationMs}ms · ${s.stats.failures} fail`));
231
- return lines;
232
- }
233
-
234
- dispose(): void {
235
- // Nothing to release.
236
- }
237
- }
package/src/ui-groups.ts DELETED
@@ -1,43 +0,0 @@
1
- /**
2
- * Shared phase-grouping logic (C1+D1) — used by the live progress widget
3
- * (index.ts) and the `/wf-inspect` view (src/inspect.ts) so both render with
4
- * identical grouping. Extracted as a pure function so it is unit-testable.
5
- *
6
- * Declared phases carry a header; ungrouped items collect into a header-less
7
- * default group interleaved at declaration order. When no phases are declared
8
- * (or none match), the whole item list is one default group (flat render).
9
- */
10
-
11
- export interface PhaseDef {
12
- readonly title: string;
13
- readonly detail?: string;
14
- readonly stepIds: readonly string[];
15
- }
16
-
17
- export interface RenderGroup<T> {
18
- readonly kind: "phase" | "default";
19
- readonly title?: string;
20
- readonly detail?: string;
21
- readonly items: T[];
22
- }
23
-
24
- export function buildRenderGroups<T>(
25
- items: readonly T[],
26
- getId: (item: T) => string,
27
- phases?: readonly PhaseDef[],
28
- ): RenderGroup<T>[] {
29
- const stepPhase = new Map<string, { title: string; detail?: string }>();
30
- if (phases && phases.length > 0) {
31
- for (const ph of phases) for (const sid of ph.stepIds) stepPhase.set(sid, { title: ph.title, detail: ph.detail });
32
- }
33
- const groups: RenderGroup<T>[] = [];
34
- for (const item of items) {
35
- const ph = stepPhase.get(getId(item));
36
- const key = ph ? `phase:${ph.title}` : "default";
37
- const last = groups[groups.length - 1];
38
- const lastKey = last ? (last.kind === "phase" ? `phase:${last.title}` : "default") : "";
39
- if (last && lastKey === key) last.items.push(item);
40
- else groups.push(ph ? { kind: "phase", title: ph.title, detail: ph.detail, items: [item] } : { kind: "default", items: [item] });
41
- }
42
- return groups;
43
- }