@herbertgao/pi-subagents 0.17.1 → 0.18.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +427 -120
  3. package/docs/rpc.md +184 -0
  4. package/docs/workflows.md +466 -0
  5. package/examples/agent-tool-description.md +6 -6
  6. package/examples/workflows/compose.js +52 -0
  7. package/examples/workflows/fan-out-audit.js +56 -0
  8. package/examples/workflows/gated-fix.js +60 -0
  9. package/examples/workflows/lib/count-child.js +30 -0
  10. package/examples/workflows/review-panel.js +68 -0
  11. package/examples/workflows/structured-findings.js +81 -0
  12. package/package.json +11 -9
  13. package/src/agent-file-toggle.ts +52 -12
  14. package/src/agent-manager.ts +837 -146
  15. package/src/agent-runner.ts +213 -39
  16. package/src/cross-extension-rpc.ts +73 -14
  17. package/src/custom-agents.ts +101 -47
  18. package/src/index.ts +2249 -914
  19. package/src/invocation-config.ts +13 -0
  20. package/src/mention-clone.ts +215 -0
  21. package/src/mention.ts +147 -0
  22. package/src/model-resolver.ts +9 -1
  23. package/src/nested-tools.ts +40 -26
  24. package/src/output-file.ts +18 -8
  25. package/src/prompts.ts +46 -9
  26. package/src/schedule.ts +21 -16
  27. package/src/settings.ts +137 -7
  28. package/src/structured-output.ts +136 -0
  29. package/src/types.ts +126 -8
  30. package/src/ui/agent-mention.ts +274 -0
  31. package/src/ui/agent-widget.ts +20 -5
  32. package/src/ui/conversation-viewer.ts +10 -4
  33. package/src/ui/fleet-list.ts +167 -22
  34. package/src/ui/workflow-card.ts +555 -0
  35. package/src/ui/workflow-dialog.ts +1304 -0
  36. package/src/ui/workflow-menu.ts +226 -0
  37. package/src/workflow/collisions.ts +122 -0
  38. package/src/workflow/entry.ts +47 -0
  39. package/src/workflow/host.ts +463 -0
  40. package/src/workflow/journal.ts +164 -0
  41. package/src/workflow/json-schema.ts +142 -0
  42. package/src/workflow/meta.ts +401 -0
  43. package/src/workflow/progress.ts +622 -0
  44. package/src/workflow/runtime.ts +1399 -0
  45. package/src/workflow/saved.ts +230 -0
  46. package/src/workflow/task.ts +333 -0
  47. package/src/workflow/tool-description.ts +200 -0
  48. package/src/workflow/worker-source.ts +781 -0
  49. package/src/worktree.ts +97 -95
  50. package/src/xml.ts +13 -0
@@ -0,0 +1,226 @@
1
+ /**
2
+ * workflow-menu.ts — `/agents → Workflows`, and the run inspector behind it.
3
+ *
4
+ * The same shape `schedule-menu.ts` has for `/agents → Scheduled jobs`: the
5
+ * submenu and the overlay it opens live here, and everything they need arrives
6
+ * as {@link WorkflowMenuDeps} rather than through a closure. The inspector is
7
+ * reached from two places — this menu and a `workflow` row in the fleet list —
8
+ * and both go through `showWorkflowDialog`, so the two entry points cannot
9
+ * drift apart on what the keys do.
10
+ *
11
+ * Lives in the agents menu rather than as a top-level `/workflows` command: it
12
+ * is one more view of the same fleet, and a second command name would only add
13
+ * a collision surface (pi renames duplicate commands to `/workflows:1` and
14
+ * `/workflows:2`, which breaks the bare name for both).
15
+ */
16
+
17
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"
18
+ import type { AgentRecord } from "../types.js"
19
+ import {
20
+ pauseWorkflowTask,
21
+ resumeWorkflowTask,
22
+ type WorkflowTask,
23
+ } from "../workflow/task.js"
24
+ import { WorkflowDialog } from "./workflow-dialog.js"
25
+
26
+ /** Everything the menu and the inspector need from the extension around them. */
27
+ export interface WorkflowMenuDeps {
28
+ /**
29
+ * Live runs by id, read on every use rather than snapshotted: a run that
30
+ * settled and was swept between render and keypress must be a no-op, not a
31
+ * crash.
32
+ */
33
+ tasks: ReadonlyMap<string, WorkflowTask>
34
+ /** The record behind an agent id, or undefined once it has been swept. */
35
+ getRecord(id: string): AgentRecord | undefined
36
+ /** The conversation overlay `c` opens on an agent row. */
37
+ viewAgentConversation(
38
+ ctx: ExtensionCommandContext,
39
+ record: AgentRecord,
40
+ ): Promise<void>
41
+ /**
42
+ * The session context, for the fleet-list entry point — that one is a
43
+ * keypress in a list that holds no `ctx` of its own. Undefined between
44
+ * sessions, which is a no-op rather than an error.
45
+ */
46
+ getCtx(): ExtensionCommandContext | undefined
47
+ }
48
+
49
+ /**
50
+ * Open the inspector for a workflow run.
51
+ *
52
+ * All six controls are wired: `onKill` aborts the run's controller, while
53
+ * pause/resume and per-agent skip/retry go through `task.control`, the handle
54
+ * `runWorkflow` hands back. `onOpenAgent` is the odd one out — it opens the
55
+ * child's conversation rather than changing the run. The dialog derives its key
56
+ * hints from the actions it is handed, so the footer advertises exactly what
57
+ * works — see `WorkflowDialogActions`.
58
+ */
59
+ export async function showWorkflowDialog(
60
+ ctx: ExtensionCommandContext,
61
+ task: WorkflowTask,
62
+ deps: WorkflowMenuDeps,
63
+ ): Promise<void> {
64
+ // Overlaid on the same terms as the conversation viewer, because they are
65
+ // reached the same way: both are rows of the fleet list, and opening one
66
+ // must not behave unlike opening the other. Inline, the frame would render
67
+ // into the conversation and stay in the scrollback after it closed.
68
+ const { VIEWPORT_HEIGHT_PCT } = await import("./conversation-viewer.js")
69
+ /**
70
+ * This dialog's own overlay, so `c` can hide it while the conversation is
71
+ * up. Overlays stack, so the viewer would render *over* it either way —
72
+ * but the two frames size themselves to different content, and the taller
73
+ * one's edges show around the shorter. Hidden, there is nothing to peek
74
+ * out, and un-hiding puts the focus back on the dialog when the viewer
75
+ * closes.
76
+ */
77
+ let overlay: { setHidden(hidden: boolean): void } | undefined
78
+ await ctx.ui.custom<undefined>(
79
+ (tui, theme, _keybindings, done) =>
80
+ new WorkflowDialog(
81
+ tui,
82
+ // Re-read on every render: the run is in the background, so the
83
+ // dialog has to follow it rather than snapshot it at open time.
84
+ () => ({
85
+ progress: task.workflowProgress,
86
+ task: {
87
+ status: task.status,
88
+ workflowName: task.workflowName,
89
+ startTime: task.startTime,
90
+ endTime: task.endTime,
91
+ totalPausedMs: task.totalPausedMs,
92
+ },
93
+ meta: task.meta,
94
+ agentCount: task.agentCount,
95
+ }),
96
+ theme,
97
+ done,
98
+ {
99
+ onKill: () => {
100
+ if (task.abortController.signal.aborted) return
101
+ task.abortController.abort()
102
+ ctx.ui.notify(
103
+ `Stopped workflow "${task.meta?.name ?? task.id}".`,
104
+ "info",
105
+ )
106
+ },
107
+ onPause: () => {
108
+ if (pauseWorkflowTask(task)) {
109
+ // Named rather than implied: "paused" on a run whose agents are
110
+ // still finishing reads as a stronger promise than it is.
111
+ ctx.ui.notify(
112
+ "Paused — running agents finish, no new ones start.",
113
+ "info",
114
+ )
115
+ }
116
+ },
117
+ onResume: () => {
118
+ if (resumeWorkflowTask(task)) ctx.ui.notify("Resumed.", "info")
119
+ },
120
+ onSkipAgent: (index) => {
121
+ if (task.control?.skip(index) !== true) {
122
+ ctx.ui.notify(
123
+ "Nothing to skip — that agent has already finished.",
124
+ "info",
125
+ )
126
+ }
127
+ },
128
+ onRetryAgent: (index) => {
129
+ if (task.control?.retry(index) !== true) {
130
+ // The window is exactly "while it is running": before that
131
+ // there is nothing to stop, after it the script has its answer.
132
+ ctx.ui.notify("Only a running agent can be retried.", "info")
133
+ }
134
+ },
135
+ onOpenAgent: (recordId) => {
136
+ const record = deps.getRecord(recordId)
137
+ // A run's children are records like any other, so they are swept
138
+ // ten minutes after they finish — the row outlives the
139
+ // conversation it points at, and saying why beats an overlay that
140
+ // opens empty.
141
+ if (record === undefined) {
142
+ ctx.ui.notify(
143
+ "No conversation left — agent records are dropped ten minutes after they finish.",
144
+ "info",
145
+ )
146
+ return
147
+ }
148
+ overlay?.setHidden(true)
149
+ // Caught before the `finally`, so a viewer that fails to open
150
+ // still un-hides the dialog and cannot surface as an unhandled
151
+ // rejection out of a detached promise.
152
+ void deps
153
+ .viewAgentConversation(ctx, record)
154
+ .catch((err) =>
155
+ ctx.ui.notify(
156
+ `Could not open the conversation: ${err instanceof Error ? err.message : String(err)}`,
157
+ "warning",
158
+ ),
159
+ )
160
+ .finally(() => overlay?.setHidden(false))
161
+ },
162
+ },
163
+ ),
164
+ {
165
+ overlay: true,
166
+ overlayOptions: {
167
+ anchor: "center",
168
+ width: "90%",
169
+ maxHeight: `${VIEWPORT_HEIGHT_PCT}%`,
170
+ },
171
+ onHandle: (handle) => {
172
+ overlay = handle
173
+ },
174
+ },
175
+ )
176
+ }
177
+
178
+ /**
179
+ * Open a run from the fleet list.
180
+ *
181
+ * The list hands back an id rather than a task, so a run that settled and was
182
+ * swept between render and keypress is a no-op instead of a crash. `esc` in the
183
+ * dialog closes it and control returns to the list — which is why the promise
184
+ * is handed back: the list puts the cursor back on the run rather than dropping
185
+ * the reader at `main`.
186
+ */
187
+ export function openWorkflowFromFleet(
188
+ id: string,
189
+ deps: WorkflowMenuDeps,
190
+ ): Promise<void> | void {
191
+ const task = deps.tasks.get(id)
192
+ const ctx = deps.getCtx()
193
+ if (task === undefined || ctx === undefined) return
194
+ return showWorkflowDialog(ctx, task, deps)
195
+ }
196
+
197
+ /** `/agents → Workflows` — list this session's runs, open one. */
198
+ export async function showWorkflowsMenu(
199
+ ctx: ExtensionCommandContext,
200
+ deps: WorkflowMenuDeps,
201
+ ): Promise<void> {
202
+ const tasks = [...deps.tasks.values()].sort(
203
+ (a, b) => b.startTime - a.startTime,
204
+ )
205
+ if (tasks.length === 0) {
206
+ ctx.ui.notify("No workflows in this session.", "info")
207
+ return
208
+ }
209
+ if (tasks.length === 1) {
210
+ await showWorkflowDialog(ctx, tasks[0], deps)
211
+ return
212
+ }
213
+ // More than one: pick first. Newest at the top, since that is almost
214
+ // always the one being asked about. `select` deals in plain strings and
215
+ // hands back the string, so the label has to be unique or `indexOf` maps
216
+ // the second run of a workflow onto the first — the run id makes it so.
217
+ const labels = tasks.map(
218
+ (task) =>
219
+ `${task.meta?.name ?? task.id} — ${task.status}, ${task.agentCount} agent${
220
+ task.agentCount === 1 ? "" : "s"
221
+ } · ${task.id}`,
222
+ )
223
+ const picked = await ctx.ui.select("Workflows", labels)
224
+ const index = picked !== undefined ? labels.indexOf(picked) : -1
225
+ if (index >= 0) await showWorkflowDialog(ctx, tasks[index], deps)
226
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * collisions.ts — deciding what to do when another extension already offers a
3
+ * workflow tool.
4
+ *
5
+ * Workflows are on by default, so this extension can be the *second*
6
+ * orchestrator in a session rather than the only one. Two workflow tools in one
7
+ * spec is worse than either alone: the model has to guess which to call, and
8
+ * pays for both descriptions to find out. The other extension was installed
9
+ * deliberately; a default of ours should not compete with it.
10
+ *
11
+ * ## What counts as a conflict
12
+ *
13
+ * An exact name match against {@link FOREIGN_WORKFLOW_TOOL_NAMES}, from a tool
14
+ * that is not ours. Exact and not a substring on purpose: `Workflow` is a
15
+ * common word in tool names that have nothing to do with orchestration
16
+ * (`github_workflow_run`, `list_workflows`), and silently disabling the feature
17
+ * against one of those would be a bug nobody could see.
18
+ *
19
+ * Two shapes, both decided here:
20
+ *
21
+ * 1. **A foreign tool took our name.** Registration is first-wins across
22
+ * extensions (`getAllRegisteredTools` skips a name it already has), and the
23
+ * winner also overwrites a built-in of the same name. Nothing throws, and
24
+ * there is no tool-conflict diagnostic the way there is for shortcuts. Ours
25
+ * never reached the registry, so there is nothing to withdraw — but the rest
26
+ * of the feature (the menu, the CLI flag) is still live and would drive a
27
+ * tool the model cannot call. It comes down with it.
28
+ * 2. **A foreign tool sits beside ours** under a different name, typically
29
+ * Claude Code's bare `Workflow`. Both are registered and both are offered.
30
+ * That one the caller can actually withdraw — `withdraw` says so.
31
+ *
32
+ * Only one direction of case 1 is detectable. If ours registered first, the
33
+ * other extension's tool is the one dropped, and pi exposes no way to see a
34
+ * tool that lost — the registry keeps winners only.
35
+ *
36
+ * Split from the acting half deliberately: everything here is a pure function
37
+ * of the tool list, so the policy can be tested without a host that can be made
38
+ * to register a competing extension. The caller owns `getAllTools`, the notify
39
+ * and the `setActiveTools` — see `resolveWorkflowCollisions` in index.ts.
40
+ */
41
+
42
+ import { SUBAGENT_TOOL_NAMES } from "../agent-runner.js"
43
+
44
+ /**
45
+ * Tool names that mean "another extension already orchestrates subagents".
46
+ *
47
+ * Our own name, because pi resolves a duplicate registration silently, and
48
+ * Claude Code's bare `Workflow`, because a port of that tool is what a second
49
+ * workflow extension most likely calls itself.
50
+ */
51
+ export const FOREIGN_WORKFLOW_TOOL_NAMES: ReadonlySet<string> = new Set([
52
+ SUBAGENT_TOOL_NAMES.WORKFLOW,
53
+ "Workflow",
54
+ ])
55
+
56
+ /** The fields of a registered tool this decision reads. */
57
+ export interface RegisteredToolInfo {
58
+ name: string
59
+ description?: string
60
+ sourceInfo?: { source?: string }
61
+ }
62
+
63
+ export type WorkflowCollision =
64
+ /** Nobody else is offering one. Carry on. */
65
+ | { kind: "none" }
66
+ /**
67
+ * A foreign tool took our name, but the user pinned `workflowsEnabled: true`.
68
+ * Nothing changes — pi has already dropped our registration — but it is worth
69
+ * reporting, because pi resolved it silently.
70
+ */
71
+ | { kind: "report"; message: string }
72
+ /**
73
+ * Stand down for this session. `withdraw` is false in case 1, where ours
74
+ * never reached the registry and there is nothing to take out of the active
75
+ * set.
76
+ */
77
+ | { kind: "standDown"; message: string; withdraw: boolean }
78
+
79
+ /**
80
+ * Decide, from the registered tools alone.
81
+ *
82
+ * `ownDescription` identifies our own registration: this extension does not
83
+ * know its install path, and the description is the one field that is certainly
84
+ * ours. `pinned` is an explicit `workflowsEnabled` — a default yields to
85
+ * evidence, a choice does not.
86
+ */
87
+ export function decideWorkflowCollision(input: {
88
+ tools: readonly RegisteredToolInfo[]
89
+ ownDescription: string
90
+ pinned: boolean
91
+ }): WorkflowCollision {
92
+ const foreign = input.tools.find(
93
+ (tool) =>
94
+ FOREIGN_WORKFLOW_TOOL_NAMES.has(tool.name) &&
95
+ tool.description !== input.ownDescription,
96
+ )
97
+ if (foreign === undefined) return { kind: "none" }
98
+
99
+ const source = foreign.sourceInfo?.source ?? "unknown source"
100
+ const tookOurName = foreign.name === SUBAGENT_TOOL_NAMES.WORKFLOW
101
+
102
+ if (input.pinned) {
103
+ if (!tookOurName) return { kind: "none" }
104
+ return {
105
+ kind: "report",
106
+ message:
107
+ `Another extension (${source}) already registers a "${SUBAGENT_TOOL_NAMES.WORKFLOW}" tool. ` +
108
+ "Pi keeps the first registration, so this extension's workflow tool is not offered to the " +
109
+ "model. Disable one of the two.",
110
+ }
111
+ }
112
+
113
+ return {
114
+ kind: "standDown",
115
+ message:
116
+ `Another extension (${source}) already provides a "${foreign.name}" tool, so this extension's ` +
117
+ "workflows are disabled for this session to avoid offering the model two orchestrators. " +
118
+ 'Set `"workflowsEnabled": true` in .pi/subagents.json to keep both.',
119
+ // Case 1: ours never reached the registry, so there is nothing to withdraw.
120
+ withdraw: !tookOurName,
121
+ }
122
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * entry.ts — what a finished workflow leaves behind in the session transcript.
3
+ *
4
+ * A workflow started from `--subagents-workflow-file` has no tool call to hang
5
+ * its result card on, so it appends a custom session entry instead. That entry
6
+ * has to survive a reload, which is why this is a plain-JSON snapshot rather
7
+ * than the live {@link WorkflowTask}: the task holds an `AbortController`, the
8
+ * script source and the run's control handle, none of which belongs in a
9
+ * session file.
10
+ *
11
+ * Deliberately free of any renderer import. The card this data renders through
12
+ * lives in `ui/workflow-card.ts` (`renderWorkflowEntryCard`), so the shape a
13
+ * session file stores does not depend on the code that draws it.
14
+ */
15
+
16
+ import type { WorkflowMeta } from "./meta.js"
17
+ import type { WorkflowEntry, WorkflowRunStatus } from "./progress.js"
18
+ import type { WorkflowTask } from "./task.js"
19
+
20
+ /** `customType` of the session entry a flag-launched workflow renders through. */
21
+ export const WORKFLOW_ENTRY_TYPE = "subagents:workflow"
22
+
23
+ /** The persisted snapshot of a settled run. */
24
+ export interface WorkflowEntryData {
25
+ name: string
26
+ status: WorkflowRunStatus
27
+ startTime: number
28
+ endTime?: number
29
+ progress: WorkflowEntry[]
30
+ agentCount: number
31
+ totalTokens: number
32
+ meta?: WorkflowMeta
33
+ }
34
+
35
+ /** Snapshot a settled task for {@link WORKFLOW_ENTRY_TYPE}. */
36
+ export function workflowEntryData(task: WorkflowTask): WorkflowEntryData {
37
+ return {
38
+ name: task.workflowName ?? task.id,
39
+ status: task.status,
40
+ startTime: task.startTime,
41
+ endTime: task.endTime,
42
+ progress: task.workflowProgress,
43
+ agentCount: task.agentCount,
44
+ totalTokens: task.totalTokens,
45
+ ...(task.meta !== undefined ? { meta: task.meta } : {}),
46
+ }
47
+ }