@osolmaz/pi-workflows 0.8.1 → 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.
- package/README.md +19 -0
- package/dist/extension/herdr-viewer.d.ts +50 -0
- package/dist/extension/herdr-viewer.js +314 -0
- package/dist/extension/herdr-viewer.js.map +1 -0
- package/dist/extension/index.js +104 -1
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/widget.d.ts +1 -1
- package/dist/extension/widget.js +15 -6
- package/dist/extension/widget.js.map +1 -1
- package/dist/herdr/constants.d.ts +2 -0
- package/dist/herdr/constants.js +3 -0
- package/dist/herdr/constants.js.map +1 -0
- package/dist/herdr/setup.d.ts +8 -0
- package/dist/herdr/setup.js +78 -0
- package/dist/herdr/setup.js.map +1 -0
- package/dist/viewer/cli.d.ts +1 -0
- package/dist/viewer/cli.js +18 -1
- package/dist/viewer/cli.js.map +1 -1
- package/docs/2026-08-18-herdr-piw-plan.md +73 -0
- package/docs/MONITOR.md +4 -4
- package/docs/tui-viewer.md +14 -0
- package/herdr-plugin.toml +12 -0
- package/package.json +3 -1
- package/plugins/herdr/viewer.mjs +56 -0
- package/skills/monitor/SKILL.md +17 -4
- package/src/extension/herdr-viewer.ts +399 -0
- package/src/extension/index.ts +130 -0
- package/src/extension/widget.ts +23 -5
- package/src/herdr/constants.ts +2 -0
- package/src/herdr/setup.ts +93 -0
- package/src/viewer/cli.ts +20 -1
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { HERDR_PLUGIN_ENTRYPOINT, HERDR_PLUGIN_ID } from "../herdr/constants.js";
|
|
3
|
+
export const PIW_SHORTCUT = "ctrl+shift+r";
|
|
4
|
+
export const PIW_SHORTCUT_HINT = "Ctrl+Shift+R piw";
|
|
5
|
+
|
|
6
|
+
const COMMAND_TIMEOUT_MS = 5_000;
|
|
7
|
+
const MAX_JSON_CHARS = 1_000_000;
|
|
8
|
+
const VIEWER_LABEL_PREFIX = "piw · ";
|
|
9
|
+
|
|
10
|
+
export const VIEWER_PLACEMENTS = ["right", "below", "left", "above", "tab", "workspace"] as const;
|
|
11
|
+
|
|
12
|
+
export type ViewerPlacement = (typeof VIEWER_PLACEMENTS)[number];
|
|
13
|
+
|
|
14
|
+
export type WorkflowViewTarget = {
|
|
15
|
+
runId: string;
|
|
16
|
+
workflowName: string;
|
|
17
|
+
runDir: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type HerdrCapability = { available: true } | { available: false; reason: string };
|
|
21
|
+
|
|
22
|
+
export type ViewerOpenResult = {
|
|
23
|
+
paneId: string;
|
|
24
|
+
reused: boolean;
|
|
25
|
+
warning?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Exec = ExtensionAPI["exec"];
|
|
29
|
+
|
|
30
|
+
type HerdrPane = {
|
|
31
|
+
paneId: string;
|
|
32
|
+
tabId: string;
|
|
33
|
+
workspaceId: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type OpenedPane = {
|
|
38
|
+
paneId: string;
|
|
39
|
+
tabId: string;
|
|
40
|
+
workspaceId: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export class HerdrWorkflowViewer {
|
|
44
|
+
private readonly knownPanes = new Map<string, string>();
|
|
45
|
+
private readonly opening = new Map<string, Promise<ViewerOpenResult>>();
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
private readonly exec: Exec,
|
|
49
|
+
private readonly env: NodeJS.ProcessEnv = process.env,
|
|
50
|
+
) {}
|
|
51
|
+
|
|
52
|
+
async probe(): Promise<HerdrCapability> {
|
|
53
|
+
if (this.env.HERDR_ENV !== "1") {
|
|
54
|
+
return { available: false, reason: "Pi is not running in Herdr." };
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
await this.currentPane();
|
|
58
|
+
const plugin = await this.runJson("herdr", [
|
|
59
|
+
"plugin",
|
|
60
|
+
"list",
|
|
61
|
+
"--plugin",
|
|
62
|
+
HERDR_PLUGIN_ID,
|
|
63
|
+
"--json",
|
|
64
|
+
]);
|
|
65
|
+
if (!pluginIsEnabled(plugin)) {
|
|
66
|
+
return {
|
|
67
|
+
available: false,
|
|
68
|
+
reason: `Herdr plugin ${HERDR_PLUGIN_ID} is not linked and enabled.`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
await this.run("piw", ["--version"]);
|
|
72
|
+
return { available: true };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return { available: false, reason: errorMessage(error) };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async focusExisting(target: WorkflowViewTarget): Promise<boolean> {
|
|
79
|
+
return (await this.findAndFocus(target)) !== undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async open(
|
|
83
|
+
target: WorkflowViewTarget,
|
|
84
|
+
placement: ViewerPlacement,
|
|
85
|
+
cwd: string,
|
|
86
|
+
): Promise<ViewerOpenResult> {
|
|
87
|
+
const pending = this.opening.get(target.runId);
|
|
88
|
+
if (pending !== undefined) return await pending;
|
|
89
|
+
|
|
90
|
+
const opening = this.openOnce(target, placement, cwd).finally(() => {
|
|
91
|
+
if (this.opening.get(target.runId) === opening) this.opening.delete(target.runId);
|
|
92
|
+
});
|
|
93
|
+
this.opening.set(target.runId, opening);
|
|
94
|
+
return await opening;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async openOnce(
|
|
98
|
+
target: WorkflowViewTarget,
|
|
99
|
+
placement: ViewerPlacement,
|
|
100
|
+
cwd: string,
|
|
101
|
+
): Promise<ViewerOpenResult> {
|
|
102
|
+
const existingPaneId = await this.findAndFocus(target);
|
|
103
|
+
if (existingPaneId !== undefined) {
|
|
104
|
+
return { paneId: existingPaneId, reused: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const caller = await this.currentPane();
|
|
108
|
+
if (placement === "workspace") {
|
|
109
|
+
const opened = await this.openWorkspace(target, cwd);
|
|
110
|
+
this.knownPanes.set(target.runId, opened.paneId);
|
|
111
|
+
return { ...opened, reused: false };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const opened = await this.openPluginPane(target, placement, caller, cwd);
|
|
115
|
+
if (placement === "left" || placement === "above") {
|
|
116
|
+
try {
|
|
117
|
+
await this.run("herdr", [
|
|
118
|
+
"pane",
|
|
119
|
+
"swap",
|
|
120
|
+
"--source-pane",
|
|
121
|
+
opened.paneId,
|
|
122
|
+
"--target-pane",
|
|
123
|
+
caller.paneId,
|
|
124
|
+
]);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
await this.closePane(opened.paneId);
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
this.knownPanes.set(target.runId, opened.paneId);
|
|
131
|
+
return { paneId: opened.paneId, reused: false };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async findAndFocus(target: WorkflowViewTarget): Promise<string | undefined> {
|
|
135
|
+
const knownPaneId = this.knownPanes.get(target.runId);
|
|
136
|
+
if (knownPaneId !== undefined) {
|
|
137
|
+
try {
|
|
138
|
+
await this.run("herdr", ["plugin", "pane", "focus", knownPaneId]);
|
|
139
|
+
return knownPaneId;
|
|
140
|
+
} catch {
|
|
141
|
+
this.knownPanes.delete(target.runId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const existing = await this.find(target);
|
|
146
|
+
if (existing === undefined) return undefined;
|
|
147
|
+
try {
|
|
148
|
+
await this.run("herdr", ["plugin", "pane", "focus", existing.paneId]);
|
|
149
|
+
this.knownPanes.set(target.runId, existing.paneId);
|
|
150
|
+
return existing.paneId;
|
|
151
|
+
} catch {
|
|
152
|
+
// The pane can close between the snapshot and focus request.
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async find(target: WorkflowViewTarget): Promise<HerdrPane | undefined> {
|
|
158
|
+
const snapshot = await this.runJson("herdr", ["api", "snapshot"]);
|
|
159
|
+
return snapshotPanes(snapshot).find((pane) => pane.label === viewerPaneLabel(target.runId));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async currentPane(): Promise<HerdrPane> {
|
|
163
|
+
return parseCurrentPane(await this.runJson("herdr", ["pane", "current", "--current"]));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async openPluginPane(
|
|
167
|
+
target: WorkflowViewTarget,
|
|
168
|
+
placement: Exclude<ViewerPlacement, "workspace">,
|
|
169
|
+
caller: HerdrPane,
|
|
170
|
+
cwd: string,
|
|
171
|
+
): Promise<OpenedPane> {
|
|
172
|
+
const args = [
|
|
173
|
+
"plugin",
|
|
174
|
+
"pane",
|
|
175
|
+
"open",
|
|
176
|
+
"--plugin",
|
|
177
|
+
HERDR_PLUGIN_ID,
|
|
178
|
+
"--entrypoint",
|
|
179
|
+
HERDR_PLUGIN_ENTRYPOINT,
|
|
180
|
+
"--cwd",
|
|
181
|
+
cwd,
|
|
182
|
+
"--env",
|
|
183
|
+
`PI_WORKFLOWS_RUN_ID=${target.runId}`,
|
|
184
|
+
"--env",
|
|
185
|
+
`PI_WORKFLOWS_RUN_DIR=${target.runDir}`,
|
|
186
|
+
"--focus",
|
|
187
|
+
];
|
|
188
|
+
if (placement === "tab") {
|
|
189
|
+
args.push("--placement", "tab", "--workspace", caller.workspaceId);
|
|
190
|
+
} else {
|
|
191
|
+
args.push(
|
|
192
|
+
"--placement",
|
|
193
|
+
"split",
|
|
194
|
+
"--target-pane",
|
|
195
|
+
caller.paneId,
|
|
196
|
+
"--direction",
|
|
197
|
+
placement === "below" || placement === "above" ? "down" : "right",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return parseOpenedPane(await this.runJson("herdr", args));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async openWorkspace(
|
|
204
|
+
target: WorkflowViewTarget,
|
|
205
|
+
cwd: string,
|
|
206
|
+
): Promise<{ paneId: string; warning?: string }> {
|
|
207
|
+
const created = parseCreatedWorkspace(
|
|
208
|
+
await this.runJson("herdr", [
|
|
209
|
+
"workspace",
|
|
210
|
+
"create",
|
|
211
|
+
"--cwd",
|
|
212
|
+
cwd,
|
|
213
|
+
"--label",
|
|
214
|
+
workspaceLabel(target.workflowName),
|
|
215
|
+
"--no-focus",
|
|
216
|
+
]),
|
|
217
|
+
);
|
|
218
|
+
let opened: OpenedPane;
|
|
219
|
+
try {
|
|
220
|
+
opened = parseOpenedPane(
|
|
221
|
+
await this.runJson("herdr", [
|
|
222
|
+
"plugin",
|
|
223
|
+
"pane",
|
|
224
|
+
"open",
|
|
225
|
+
"--plugin",
|
|
226
|
+
HERDR_PLUGIN_ID,
|
|
227
|
+
"--entrypoint",
|
|
228
|
+
HERDR_PLUGIN_ENTRYPOINT,
|
|
229
|
+
"--placement",
|
|
230
|
+
"tab",
|
|
231
|
+
"--workspace",
|
|
232
|
+
created.workspaceId,
|
|
233
|
+
"--cwd",
|
|
234
|
+
cwd,
|
|
235
|
+
"--env",
|
|
236
|
+
`PI_WORKFLOWS_RUN_ID=${target.runId}`,
|
|
237
|
+
"--env",
|
|
238
|
+
`PI_WORKFLOWS_RUN_DIR=${target.runDir}`,
|
|
239
|
+
"--focus",
|
|
240
|
+
]),
|
|
241
|
+
);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
await this.run("herdr", ["workspace", "close", created.workspaceId]).catch(() => undefined);
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
await this.run("herdr", ["tab", "close", created.bootstrapTabId]);
|
|
249
|
+
return { paneId: opened.paneId };
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return {
|
|
252
|
+
paneId: opened.paneId,
|
|
253
|
+
warning: `The piw viewer opened, but its empty bootstrap tab could not be closed: ${errorMessage(error)}`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async closePane(paneId: string): Promise<void> {
|
|
259
|
+
await this.run("herdr", ["plugin", "pane", "close", paneId]).catch(() => undefined);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private async run(command: string, args: string[]): Promise<string> {
|
|
263
|
+
const result = await this.exec(command, args, { timeout: COMMAND_TIMEOUT_MS });
|
|
264
|
+
if (result.killed) {
|
|
265
|
+
throw new Error(`${command} timed out.`);
|
|
266
|
+
}
|
|
267
|
+
if (result.code !== 0) {
|
|
268
|
+
const message = result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`;
|
|
269
|
+
throw new Error(`${command} failed: ${boundedText(message)}`);
|
|
270
|
+
}
|
|
271
|
+
return result.stdout;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async runJson(command: string, args: string[]): Promise<unknown> {
|
|
275
|
+
const stdout = await this.run(command, args);
|
|
276
|
+
if (stdout.length > MAX_JSON_CHARS) {
|
|
277
|
+
throw new Error(`${command} returned too much data.`);
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
return JSON.parse(stdout) as unknown;
|
|
281
|
+
} catch {
|
|
282
|
+
throw new Error(`${command} returned invalid JSON.`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function parseViewerPlacement(value: string): ViewerPlacement | undefined {
|
|
288
|
+
return VIEWER_PLACEMENTS.find((placement) => placement === value);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function viewerPaneLabel(runId: string): string {
|
|
292
|
+
return `${VIEWER_LABEL_PREFIX}${runId}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function pluginIsEnabled(value: unknown): boolean {
|
|
296
|
+
const result = recordValue(value, "result");
|
|
297
|
+
const plugins = result === undefined ? undefined : arrayValue(result, "plugins");
|
|
298
|
+
return (
|
|
299
|
+
plugins?.some(
|
|
300
|
+
(plugin) =>
|
|
301
|
+
recordValue(plugin, "plugin_id") === HERDR_PLUGIN_ID &&
|
|
302
|
+
recordValue(plugin, "enabled") === true,
|
|
303
|
+
) === true
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function parseCurrentPane(value: unknown): HerdrPane {
|
|
308
|
+
const result = requiredRecord(value, "result", "Herdr pane response");
|
|
309
|
+
return paneFromValue(requiredRecord(result, "pane", "Herdr pane response"));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function parseOpenedPane(value: unknown): OpenedPane {
|
|
313
|
+
const result = requiredRecord(value, "result", "Herdr plugin pane response");
|
|
314
|
+
const pluginPane = requiredRecord(result, "plugin_pane", "Herdr plugin pane response");
|
|
315
|
+
return paneFromValue(requiredRecord(pluginPane, "pane", "Herdr plugin pane response"));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function parseCreatedWorkspace(value: unknown): {
|
|
319
|
+
workspaceId: string;
|
|
320
|
+
bootstrapTabId: string;
|
|
321
|
+
} {
|
|
322
|
+
const result = requiredRecord(value, "result", "Herdr workspace response");
|
|
323
|
+
const workspace = requiredRecord(result, "workspace", "Herdr workspace response");
|
|
324
|
+
const rootPane = requiredRecord(result, "root_pane", "Herdr workspace response");
|
|
325
|
+
return {
|
|
326
|
+
workspaceId: requiredString(workspace, "workspace_id", "Herdr workspace response"),
|
|
327
|
+
bootstrapTabId: requiredString(rootPane, "tab_id", "Herdr workspace response"),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function snapshotPanes(value: unknown): HerdrPane[] {
|
|
332
|
+
const result = requiredRecord(value, "result", "Herdr snapshot");
|
|
333
|
+
const snapshot = requiredRecord(result, "snapshot", "Herdr snapshot");
|
|
334
|
+
const panes = arrayValue(snapshot, "panes");
|
|
335
|
+
if (panes === undefined) throw new Error("Herdr snapshot has no panes.");
|
|
336
|
+
return panes.flatMap((pane) => {
|
|
337
|
+
try {
|
|
338
|
+
return [paneFromValue(pane)];
|
|
339
|
+
} catch {
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function paneFromValue(value: unknown): HerdrPane {
|
|
346
|
+
if (!isRecord(value)) throw new Error("Herdr returned an invalid pane.");
|
|
347
|
+
const label = recordValue(value, "label");
|
|
348
|
+
return {
|
|
349
|
+
paneId: requiredString(value, "pane_id", "Herdr pane"),
|
|
350
|
+
tabId: requiredString(value, "tab_id", "Herdr pane"),
|
|
351
|
+
workspaceId: requiredString(value, "workspace_id", "Herdr pane"),
|
|
352
|
+
...(typeof label === "string" ? { label } : {}),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function workspaceLabel(workflowName: string): string {
|
|
357
|
+
const compact = workflowName.replace(/[\r\n\t]+/gu, " ").trim();
|
|
358
|
+
return `piw · ${(compact || "workflow").slice(0, 60)}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function requiredRecord(value: unknown, key: string, label: string): Record<string, unknown> {
|
|
362
|
+
if (!isRecord(value)) throw new Error(`${label} is invalid.`);
|
|
363
|
+
const nested = value[key];
|
|
364
|
+
if (!isRecord(nested)) throw new Error(`${label} has no ${key}.`);
|
|
365
|
+
return nested;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function requiredString(value: Record<string, unknown>, key: string, label: string): string {
|
|
369
|
+
const nested = value[key];
|
|
370
|
+
if (typeof nested !== "string" || nested.length === 0) {
|
|
371
|
+
throw new Error(`${label} has no ${key}.`);
|
|
372
|
+
}
|
|
373
|
+
return nested;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function recordValue(value: unknown, key: string): unknown {
|
|
377
|
+
return isRecord(value) ? value[key] : undefined;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function arrayValue(value: unknown, key: string): unknown[] | undefined {
|
|
381
|
+
const nested = recordValue(value, key);
|
|
382
|
+
return Array.isArray(nested) ? nested : undefined;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
386
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function boundedText(value: string): string {
|
|
390
|
+
const compact = value
|
|
391
|
+
.replace(/[\r\n\t]+/gu, " ")
|
|
392
|
+
.replace(/ +/gu, " ")
|
|
393
|
+
.trim();
|
|
394
|
+
return compact.length <= 300 ? compact : `${compact.slice(0, 299)}…`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function errorMessage(error: unknown): string {
|
|
398
|
+
return error instanceof Error ? error.message : String(error);
|
|
399
|
+
}
|
package/src/extension/index.ts
CHANGED
|
@@ -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
|
}
|
package/src/extension/widget.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
|