@osolmaz/pi-workflows 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import path from "node:path";
2
3
  import { isDeepStrictEqual } from "node:util";
3
4
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
5
  import { builtinWorkflowCatalog } from "../builtins/catalog.js";
@@ -37,6 +38,16 @@ import {
37
38
  type PiChildWorkflowStarter,
38
39
  } from "./controller-host.js";
39
40
  import { ConversationStepExecutor } from "./executor.js";
41
+ import {
42
+ HerdrWorkflowViewer,
43
+ parseViewerPlacement,
44
+ PIW_SHORTCUT,
45
+ PIW_SHORTCUT_HINT,
46
+ VIEWER_PLACEMENTS,
47
+ type HerdrCapability,
48
+ type ViewerPlacement,
49
+ type WorkflowViewTarget,
50
+ } from "./herdr-viewer.js";
40
51
  import { SessionRecorder } from "./recorder.js";
41
52
  import {
42
53
  registerWorkflowAgentStepMessageRenderer,
@@ -61,6 +72,18 @@ const MAX_WORKFLOW_LIST_ITEMS = 50;
61
72
  const MAX_WORKFLOW_LIST_NAME_CHARS = 3_500;
62
73
  const PRESENTATION_TIMEOUT_MS = 30_000;
63
74
 
75
+ const PIW_PLACEMENT_LABELS: Readonly<Record<ViewerPlacement, string>> = {
76
+ right: "Split right",
77
+ below: "Split below",
78
+ left: "Split left",
79
+ above: "Split above",
80
+ tab: "New tab",
81
+ workspace: "New workspace",
82
+ };
83
+ const PIW_PLACEMENT_BY_LABEL = new Map(
84
+ VIEWER_PLACEMENTS.map((placement) => [PIW_PLACEMENT_LABELS[placement], placement] as const),
85
+ );
86
+
64
87
  class PresentationSupersededError extends Error {}
65
88
  class PresentationTimeoutError extends Error {}
66
89
 
@@ -229,6 +252,17 @@ type WorkflowWidgetContent = string[] | WorkflowWidgetFactory;
229
252
  export default function piWorkflows(pi: ExtensionAPI) {
230
253
  registerWorkflowAgentStepMessageRenderer(pi);
231
254
 
255
+ const herdrEnabled = process.env.HERDR_ENV === "1";
256
+ const herdrViewer = new HerdrWorkflowViewer((command, args, options) =>
257
+ pi.exec(command, args, options),
258
+ );
259
+ let herdrCapability: HerdrCapability = {
260
+ available: false,
261
+ reason: "Herdr integration has not been checked.",
262
+ };
263
+ let workflowViewTarget: WorkflowViewTarget | null = null;
264
+ let herdrProbeGeneration = 0;
265
+
232
266
  // One runner identity per session; it names this session in run claims.
233
267
  const runnerId = randomUUID();
234
268
  let runQueueStore: SqliteControllerStore | null = null;
@@ -408,6 +442,12 @@ export default function piWorkflows(pi: ExtensionAPI) {
408
442
  width,
409
443
  theme,
410
444
  widgetSource.updateHistory,
445
+ ctx.mode === "tui" &&
446
+ herdrEnabled &&
447
+ herdrCapability.available &&
448
+ workflowViewTarget?.runId === widgetSource.state.runId
449
+ ? PIW_SHORTCUT_HINT
450
+ : undefined,
411
451
  );
412
452
  widgetShownScroll = view.scroll;
413
453
  widgetMaxScroll = view.maxScroll;
@@ -444,16 +484,76 @@ export default function piWorkflows(pi: ExtensionAPI) {
444
484
  snapshot,
445
485
  ...(updateHistory !== undefined ? { updateHistory: [...updateHistory] } : {}),
446
486
  };
487
+ workflowViewTarget = {
488
+ runId: state.runId,
489
+ workflowName: state.workflowName,
490
+ runDir: path.resolve(new WorkflowRunStore().runDirFor(state.runId)),
491
+ };
447
492
  renderWidget(ctx);
448
493
  };
449
494
 
450
495
  const clearWidget = (ctx: ExtensionContext) => {
451
496
  widgetSource = null;
497
+ workflowViewTarget = null;
452
498
  widgetScroll = null;
453
499
  setWidget(ctx, undefined);
454
500
  setStatus(ctx, undefined);
455
501
  };
456
502
 
503
+ const refreshHerdrCapability = async (ctx: ExtensionContext): Promise<HerdrCapability> => {
504
+ const generation = ++herdrProbeGeneration;
505
+ const capability = await herdrViewer.probe();
506
+ if (!sessionClosed && generation === herdrProbeGeneration) {
507
+ herdrCapability = capability;
508
+ renderWidget(ctx);
509
+ }
510
+ return capability;
511
+ };
512
+
513
+ const selectPiwPlacement = async (
514
+ ctx: ExtensionContext,
515
+ ): Promise<ViewerPlacement | undefined> => {
516
+ if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
517
+ const label = await ctx.ui.select(
518
+ "Open workflow in piw",
519
+ VIEWER_PLACEMENTS.map((placement) => PIW_PLACEMENT_LABELS[placement]),
520
+ );
521
+ return label === undefined ? undefined : PIW_PLACEMENT_BY_LABEL.get(label);
522
+ };
523
+
524
+ const openPiw = async (
525
+ ctx: ExtensionContext,
526
+ requestedPlacement?: ViewerPlacement,
527
+ ): Promise<void> => {
528
+ const target = workflowViewTarget;
529
+ if (target === null) {
530
+ notify(ctx, "No workflow run is available to open in piw.", "warning");
531
+ return;
532
+ }
533
+ const capability = await refreshHerdrCapability(ctx);
534
+ if (!capability.available) {
535
+ notify(ctx, capability.reason, "warning");
536
+ return;
537
+ }
538
+ try {
539
+ if (await herdrViewer.focusExisting(target)) {
540
+ notify(ctx, `Focused the piw viewer for ${target.workflowName}.`);
541
+ return;
542
+ }
543
+ const placement = requestedPlacement ?? (await selectPiwPlacement(ctx));
544
+ if (placement === undefined) {
545
+ if (!ctx.hasUI || ctx.mode !== "tui") {
546
+ notify(ctx, "Specify a piw placement: right, below, left, above, tab, or workspace.");
547
+ }
548
+ return;
549
+ }
550
+ const opened = await herdrViewer.open(target, placement, ctx.cwd);
551
+ if (opened.warning !== undefined) notify(ctx, opened.warning, "warning");
552
+ } catch (error) {
553
+ notify(ctx, `Could not open piw: ${errorMessage(error)}`, "error");
554
+ }
555
+ };
556
+
457
557
  const scrollWidget = (ctx: ExtensionContext, delta: number) => {
458
558
  if (!widgetSource || widgetMaxScroll === 0) {
459
559
  return;
@@ -890,6 +990,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
890
990
  run.renewTimer.unref?.();
891
991
  }
892
992
  clearWidgetTimer();
993
+ workflowViewTarget = null;
893
994
  startWidgetTicker(ctx, run);
894
995
  if (!options.quiet) {
895
996
  notify(ctx, `Workflow ${workflow.name} started. Follow it live with: pi-workflows view`);
@@ -1462,6 +1563,25 @@ export default function piWorkflows(pi: ExtensionAPI) {
1462
1563
  }
1463
1564
  };
1464
1565
 
1566
+ pi.registerCommand("piw", {
1567
+ description: "Open the current workflow run in piw through Herdr",
1568
+ getArgumentCompletions: async (prefix: string) => {
1569
+ const items = VIEWER_PLACEMENTS.filter((placement) => placement.startsWith(prefix)).map(
1570
+ (placement) => ({ value: placement, label: placement }),
1571
+ );
1572
+ return items.length > 0 ? items : null;
1573
+ },
1574
+ handler: async (args, ctx) => {
1575
+ const value = args.trim();
1576
+ const placement = value.length === 0 ? undefined : parseViewerPlacement(value);
1577
+ if (value.length > 0 && placement === undefined) {
1578
+ notify(ctx, "piw placement must be right, below, left, above, tab, or workspace.", "error");
1579
+ return;
1580
+ }
1581
+ await openPiw(ctx, placement);
1582
+ },
1583
+ });
1584
+
1465
1585
  pi.registerCommand("workflow", {
1466
1586
  description:
1467
1587
  "Run or manage a workflow: /workflow <name-or-path> [task | --input-json {…}]; also: status, pause, resume, cancel, answer",
@@ -1698,6 +1818,13 @@ export default function piWorkflows(pi: ExtensionAPI) {
1698
1818
  },
1699
1819
  });
1700
1820
 
1821
+ if (herdrEnabled) {
1822
+ pi.registerShortcut(PIW_SHORTCUT, {
1823
+ description: "Open the current workflow run in piw",
1824
+ handler: async (ctx) => await openPiw(ctx),
1825
+ });
1826
+ }
1827
+
1701
1828
  pi.registerShortcut("shift+up", {
1702
1829
  description: "Scroll the workflow widget up",
1703
1830
  handler: (ctx) => scrollWidget(ctx, -WIDGET_SCROLL_STEP),
@@ -1711,6 +1838,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1711
1838
  pi.on("session_start", async (_event, ctx) => {
1712
1839
  sessionClosed = false;
1713
1840
  controllerContext = ctx;
1841
+ if (herdrEnabled) void refreshHerdrCapability(ctx);
1714
1842
  try {
1715
1843
  const queue = ensureRunQueueStore(ctx.cwd);
1716
1844
  const migration = await migrateLegacyWorkflowSources({
@@ -1859,6 +1987,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1859
1987
 
1860
1988
  pi.on("session_shutdown", async () => {
1861
1989
  sessionClosed = true;
1990
+ herdrProbeGeneration += 1;
1862
1991
  systemTurnAbort = null;
1863
1992
  suppressWorkflowAssistantTail = false;
1864
1993
  supersedePresentation();
@@ -1889,6 +2018,7 @@ export default function piWorkflows(pi: ExtensionAPI) {
1889
2018
  clearWidgetTimer();
1890
2019
  stopWidgetTicker();
1891
2020
  widgetSource = null;
2021
+ workflowViewTarget = null;
1892
2022
  widgetScroll = null;
1893
2023
  });
1894
2024
  }
@@ -78,6 +78,7 @@ export function buildWidgetView(
78
78
  width = Number.POSITIVE_INFINITY,
79
79
  theme?: WidgetTheme,
80
80
  updateHistory?: WorkflowUpdateRecord[],
81
+ actionHint?: string,
81
82
  ): WidgetView {
82
83
  const availableWidth = Number.isFinite(width) ? Math.max(0, Math.floor(width)) : width;
83
84
  if (availableWidth === 0) return { lines: [], scroll: 0, maxScroll: 0 };
@@ -102,12 +103,18 @@ export function buildWidgetView(
102
103
  paint(theme, "warning", ` waiting on checkpoint: ${sanitizeText(state.waitingOn)}`),
103
104
  );
104
105
  }
105
-
106
+ const hint = actionHint?.trim() ? sanitizeText(actionHint) : undefined;
106
107
  const progress = progressLines(state, now, updateHistory).slice(0, 4);
107
- const budget = PI_MAX_WIDGET_LINES - 1 - footer.length - progress.length;
108
+ const baseBudget = PI_MAX_WIDGET_LINES - 1 - footer.length - progress.length;
108
109
  const nodes = displayNodeIds(snapshot).map((nodeId) =>
109
110
  compactNodeLine(state, snapshot, nodeId, now, paused, theme),
110
111
  );
112
+ const combineHintWithWindow =
113
+ hint !== undefined && nodes.length > 0 && nodes.length + 1 > baseBudget;
114
+ if (hint !== undefined && !combineHintWithWindow) {
115
+ footer.push(paint(theme, "dim", ` ${hint}`));
116
+ }
117
+ const budget = PI_MAX_WIDGET_LINES - 1 - footer.length - progress.length;
111
118
  if (nodes.length === 0 || budget <= 0) {
112
119
  return {
113
120
  lines: fitLines(
@@ -120,7 +127,14 @@ export function buildWidgetView(
120
127
  }
121
128
 
122
129
  const anchor = scroll ?? compactFocusIndex(state, snapshot);
123
- const windowed = windowLines(nodes, budget, anchor, scroll !== null, theme);
130
+ const windowed = windowLines(
131
+ nodes,
132
+ budget,
133
+ anchor,
134
+ scroll !== null,
135
+ theme,
136
+ combineHintWithWindow ? hint : undefined,
137
+ );
124
138
  const indentation = availableWidth >= 3 ? " " : "";
125
139
  return {
126
140
  lines: fitLines(
@@ -356,8 +370,9 @@ function windowLines(
356
370
  anchor: number,
357
371
  anchorIsStart: boolean,
358
372
  theme?: WidgetTheme,
373
+ actionHint?: string,
359
374
  ): { lines: string[]; scroll: number; maxScroll: number } {
360
- if (lines.length <= budget) {
375
+ if (lines.length <= budget && actionHint === undefined) {
361
376
  return { lines, scroll: 0, maxScroll: 0 };
362
377
  }
363
378
  const inner = Math.max(1, budget - 1);
@@ -369,7 +384,10 @@ function windowLines(
369
384
  const directions = [above > 0 ? `↑ ${above}` : "", below > 0 ? `↓ ${below}` : ""]
370
385
  .filter(Boolean)
371
386
  .join(" · ");
372
- out.push(paint(theme, "dim", `${directions} more · shift+↑/↓ scroll`));
387
+ const controls = [`${directions} more`, "shift+↑/↓ scroll", actionHint]
388
+ .filter((item): item is string => Boolean(item))
389
+ .join(" · ");
390
+ out.push(paint(theme, "dim", controls));
373
391
  return { lines: out, scroll: start, maxScroll: Math.max(0, lines.length - inner) };
374
392
  }
375
393
 
@@ -0,0 +1,2 @@
1
+ export const HERDR_PLUGIN_ID = "osolmaz.pi-workflows";
2
+ export const HERDR_PLUGIN_ENTRYPOINT = "piw";
@@ -0,0 +1,93 @@
1
+ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { HERDR_PLUGIN_ID } from "./constants.js";
5
+
6
+ export type HerdrSetupResult = {
7
+ changed: boolean;
8
+ message: string;
9
+ };
10
+
11
+ type Spawn = (command: string, args: readonly string[]) => SpawnSyncReturns<string>;
12
+
13
+ export function setupHerdrPlugin(packageRoot: string, spawn: Spawn = runCommand): HerdrSetupResult {
14
+ const root = path.resolve(packageRoot);
15
+ const manifest = path.join(root, "herdr-plugin.toml");
16
+ if (!fs.existsSync(manifest)) {
17
+ throw new Error(`Herdr plugin manifest not found: ${manifest}`);
18
+ }
19
+
20
+ const listed = spawn("herdr", ["plugin", "list", "--plugin", HERDR_PLUGIN_ID, "--json"]);
21
+ if (listed.error) throw new Error(`Could not run Herdr: ${listed.error.message}`);
22
+ if (listed.status !== 0) {
23
+ throw new Error(`Could not inspect Herdr plugins: ${bounded(listed.stderr || listed.stdout)}`);
24
+ }
25
+ const installed = installedPlugin(listed.stdout);
26
+ if (installed !== undefined) {
27
+ if (path.resolve(installed.root) !== root) {
28
+ throw new Error(
29
+ `Herdr plugin ${HERDR_PLUGIN_ID} is already registered from ${installed.root}. Unlink it before linking ${root}.`,
30
+ );
31
+ }
32
+ if (installed.enabled) {
33
+ return { changed: false, message: `Herdr plugin ${HERDR_PLUGIN_ID} is already linked.` };
34
+ }
35
+ const enabled = spawn("herdr", ["plugin", "enable", HERDR_PLUGIN_ID]);
36
+ if (enabled.error) throw new Error(`Could not run Herdr: ${enabled.error.message}`);
37
+ if (enabled.status !== 0) {
38
+ throw new Error(
39
+ `Could not enable the Herdr plugin: ${bounded(enabled.stderr || enabled.stdout)}`,
40
+ );
41
+ }
42
+ return { changed: true, message: `Enabled Herdr plugin ${HERDR_PLUGIN_ID}.` };
43
+ }
44
+
45
+ const linked = spawn("herdr", ["plugin", "link", root]);
46
+ if (linked.error) throw new Error(`Could not run Herdr: ${linked.error.message}`);
47
+ if (linked.status !== 0) {
48
+ throw new Error(`Could not link the Herdr plugin: ${bounded(linked.stderr || linked.stdout)}`);
49
+ }
50
+ return { changed: true, message: `Linked Herdr plugin ${HERDR_PLUGIN_ID} from ${root}.` };
51
+ }
52
+
53
+ function installedPlugin(stdout: string): { root: string; enabled: boolean } | undefined {
54
+ let value: unknown;
55
+ try {
56
+ value = JSON.parse(stdout) as unknown;
57
+ } catch {
58
+ throw new Error("Herdr returned invalid plugin JSON.");
59
+ }
60
+ if (!isRecord(value) || !isRecord(value.result) || !Array.isArray(value.result.plugins)) {
61
+ throw new Error("Herdr returned an invalid plugin list.");
62
+ }
63
+ for (const plugin of value.result.plugins) {
64
+ if (
65
+ isRecord(plugin) &&
66
+ plugin.plugin_id === HERDR_PLUGIN_ID &&
67
+ typeof plugin.plugin_root === "string" &&
68
+ typeof plugin.enabled === "boolean"
69
+ ) {
70
+ return { root: plugin.plugin_root, enabled: plugin.enabled };
71
+ }
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ function runCommand(command: string, args: readonly string[]): SpawnSyncReturns<string> {
77
+ return spawnSync(command, [...args], {
78
+ encoding: "utf8",
79
+ stdio: ["ignore", "pipe", "pipe"],
80
+ });
81
+ }
82
+
83
+ function bounded(value: string): string {
84
+ const compact = value
85
+ .replace(/[\r\n\t]+/gu, " ")
86
+ .replace(/ +/gu, " ")
87
+ .trim();
88
+ return compact.length <= 300 ? compact : `${compact.slice(0, 299)}…`;
89
+ }
90
+
91
+ function isRecord(value: unknown): value is Record<string, unknown> {
92
+ return typeof value === "object" && value !== null && !Array.isArray(value);
93
+ }
package/src/viewer/cli.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import fs, { realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { pathToFileURL } from "node:url";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { SqliteControllerStore } from "../controllers/sqlite.js";
6
6
  import { projectControllerStoreBaseDir } from "../controllers/store.js";
7
+ import { setupHerdrPlugin } from "../herdr/setup.js";
7
8
  import { sanitizeText } from "../render/ansi.js";
8
9
  import { listRunBundles, readRunBundle, workflowRunsBaseDir } from "../workflows/store.js";
9
10
  import {
@@ -23,6 +24,7 @@ Usage:
23
24
  pi-workflows controllers [--controller-dir <dir>]
24
25
  pi-workflows controller <controller> <key> [--controller-dir <dir>]
25
26
  pi-workflows host [--project <dir>] [-- <extra pi args>]
27
+ pi-workflows herdr setup
26
28
 
27
29
  Commands:
28
30
  view Open the live workflow TUI. With --once, print a snapshot.
@@ -30,6 +32,7 @@ Commands:
30
32
  controllers List durable controller resources.
31
33
  controller Show one resource, its effects, child workflows, and events.
32
34
  host Run the always-on workflow host in the foreground.
35
+ herdr Set up the bundled Herdr plugin.
33
36
 
34
37
  Options:
35
38
  --dir <runsDir> Runs directory (default: ~/.pi/agent/workflows/runs)
@@ -43,6 +46,7 @@ export type CliArgs = {
43
46
  runId?: string;
44
47
  controllerName?: string;
45
48
  resourceKey?: string;
49
+ herdrAction?: string;
46
50
  dir: string;
47
51
  controllerDir: string;
48
52
  once: boolean;
@@ -98,6 +102,12 @@ export function parseCliArgs(argv: string[]): CliArgs {
98
102
  once,
99
103
  };
100
104
  }
105
+ if (command === "herdr") {
106
+ if (positionals.length !== 1 || positionals[0] !== "setup") {
107
+ throw new Error("herdr requires the setup action");
108
+ }
109
+ return { command, herdrAction: positionals[0], dir, controllerDir, once };
110
+ }
101
111
  if (positionals.length > 1) {
102
112
  throw new Error(`Unexpected argument: ${positionals[1]}`);
103
113
  }
@@ -229,6 +239,11 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<numb
229
239
  if (args.command === "host") {
230
240
  return await runHost(args.project ?? process.cwd(), args.piArgs);
231
241
  }
242
+ if (args.command === "herdr") {
243
+ const result = setupHerdrPlugin(packageRoot());
244
+ process.stdout.write(`${result.message}\n`);
245
+ return 0;
246
+ }
232
247
  if (args.command === "view") {
233
248
  if (args.once || !process.stdout.isTTY) {
234
249
  await printOnce(args.dir, args.runId);
@@ -271,6 +286,10 @@ function openControllerStore(controllerDir: string): SqliteControllerStore | und
271
286
  return new SqliteControllerStore(file, { readOnly: true });
272
287
  }
273
288
 
289
+ function packageRoot(): string {
290
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
291
+ }
292
+
274
293
  function requiredValue(args: string[], option: string): string {
275
294
  const value = args.shift();
276
295
  if (!value) {