@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
@@ -113,6 +113,15 @@ export function resolveAgentInvocationConfig(
113
113
  runInBackground: boolean
114
114
  isolated: boolean
115
115
  isolation?: IsolationMode
116
+ /**
117
+ * Caller parameters an agent file's frontmatter outranked, so the surfaces can
118
+ * say "(asked X)" instead of presenting the effective value as the requested
119
+ * one (#182). Populated only where both sides named something and they
120
+ * disagree — a caller who asked for what they got was still honored.
121
+ *
122
+ * `max_turns` is deliberately absent: no surface renders a requested-vs-
123
+ * effective turn limit, so recording one would be dead data.
124
+ */
116
125
  overridden?: { thinking?: ThinkingLevel; model?: string }
117
126
  } {
118
127
  // Precedence first, collapse second — reversing these loses the veto, since
@@ -123,6 +132,7 @@ export function resolveAgentInvocationConfig(
123
132
  requested === "worktree" && opts?.worktreeAllowed !== false
124
133
  ? "worktree"
125
134
  : undefined
135
+
126
136
  const overriddenThinking =
127
137
  agentConfig?.thinking != null &&
128
138
  params.thinking != null &&
@@ -152,6 +162,9 @@ export function resolveAgentInvocationConfig(
152
162
  false,
153
163
  isolated: agentConfig?.isolated ?? params.isolated ?? false,
154
164
  isolation,
165
+ // Undefined rather than an empty object when nothing was overridden: callers
166
+ // spread this into the invocation snapshot, and an always-present key would
167
+ // put `requestedThinking: undefined` on every record.
155
168
  overridden:
156
169
  overriddenThinking || overriddenModel
157
170
  ? { thinking: overriddenThinking, model: overriddenModel }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * mention-clone.ts — start a mentioned agent through a clone of this
3
+ * conversation, without putting anything in the chat.
4
+ *
5
+ * Claude Code routes `@agent-<type>` through the main model: the mention
6
+ * becomes a `<system-reminder>` appended to the prompt and the model makes the
7
+ * tool call (see `agentMentionReminder`). That buys the spawned agent a prompt
8
+ * written with conversation context, and costs a visible turn — the model's
9
+ * reasoning and its tool block land in the transcript, for a decision the user
10
+ * already made when they typed the handle.
11
+ *
12
+ * So the turn happens somewhere else. The conversation is cloned into a
13
+ * throwaway in-memory session — same messages, same system prompt, same model —
14
+ * and that copy takes the turn off-screen. A literal clone: the session's own
15
+ * entries, projected by pi's own `sessionEntryToContextMessages`, not
16
+ * `inherit_context`'s text rendering of them.
17
+ *
18
+ * Cloned from memory rather than from the session file, which cannot be relied
19
+ * on: `SessionManager._persist` withholds every write until the first assistant
20
+ * message lands, so a fork taken before then reads an empty file and throws.
21
+ * `buildSessionContext()` has no such timing, and is compaction-aware — it walks
22
+ * the leaf path and substitutes the summary for entries folded into it, so a
23
+ * long conversation clones as what the main model is actually working from. A
24
+ * conversation with nothing in it yet clones to nothing in it yet, which is the
25
+ * correct answer rather than a failure.
26
+ *
27
+ * It is also the oldest of the equivalent Pi APIs — `buildContextEntries` on
28
+ * ReadonlySessionManager and the `sessionEntryToContextMessages` export both
29
+ * arrived in 0.80.5 — where this one has been exported unchanged from before
30
+ * the declared peer floor, and is the same code path (`byId` is only an index
31
+ * cache, so passing it or not cannot change the result). Keeping the floor
32
+ * honest costs nothing here: see the `compat-floor-pi` job.
33
+ *
34
+ * Its `thinkingLevel` is NOT used, and is the one place the newer API would be
35
+ * better. `getSessionContextSettings` starts at "off" and moves only on an
36
+ * explicit `thinking_level_change` entry, so a session where nobody ran
37
+ * `/think` reports "off" rather than the level it is really using. Omitting the
38
+ * field instead lets `createAgentSession` resolve it from settings, which is
39
+ * that real level.
40
+ *
41
+ * Three details make the spawn belong to the real session rather than the
42
+ * clone:
43
+ *
44
+ * - the clone is handed the *registered* `Agent` tool, whose handler closes
45
+ * over the main activation, so it spawns top-level: widget, fleet row,
46
+ * handle, completion notification, all as if the main model had called it;
47
+ * - that tool is re-bound to the main `ExtensionContext`, because the handler
48
+ * reads `cwd`, `model` and `sessionManager.getSessionId()` off it to place
49
+ * the transcript and the `rootSessionId`. The clone's own context would
50
+ * file both under the throwaway fork;
51
+ * - it is called with no tool-call id. The clone's turn produces one, but the
52
+ * real session never issued it, and a `<tool-use-id>` pointing at nothing
53
+ * is exactly the bug the mention-resume path had to fix;
54
+ * - and it is forced into the background. A foreground agent returns its
55
+ * answer as the tool result and is marked `resultConsumed` so no completion
56
+ * notification is sent — correct when the caller is the real conversation,
57
+ * silent loss when the caller is a fork about to be discarded. Background
58
+ * delivery is the only route from a mention back to the main model.
59
+ *
60
+ * The clone gets one tool and one job. It cannot read, write or run anything —
61
+ * an invisible turn with the full toolset could do invisible work.
62
+ */
63
+
64
+ import type { Model } from "@earendil-works/pi-ai"
65
+ import {
66
+ buildSessionContext,
67
+ createAgentSession,
68
+ type ExtensionContext,
69
+ SessionManager,
70
+ type ToolDefinition,
71
+ } from "@earendil-works/pi-coding-agent"
72
+ import { runInChildSessionContext } from "./child-context.js"
73
+ import { agentMentionReminder } from "./mention.js"
74
+ import type { SubagentType, ThinkingLevel } from "./types.js"
75
+
76
+ export interface MentionCloneOptions {
77
+ /** The MAIN session's context — what the spawn is attributed to, and the
78
+ * source of both the conversation and the live system prompt. */
79
+ ctx: ExtensionContext
80
+ /** Agent type the handle resolved to. */
81
+ type: SubagentType
82
+ /** What the user typed after the handle. */
83
+ message: string
84
+ /** The registered `Agent` tool, reused so the spawn is an ordinary one. */
85
+ agentTool: ToolDefinition
86
+ }
87
+
88
+ export interface MentionCloneResult {
89
+ /** True once the clone actually called `Agent`. */
90
+ spawned: boolean
91
+ /** Why not, when it didn't. Absent on success. */
92
+ error?: string
93
+ }
94
+
95
+ /**
96
+ * Fork the conversation, let the copy make the tool call, throw the copy away.
97
+ * Never rejects: a clone that cannot run is reported so the caller can fall
98
+ * back to starting the agent directly.
99
+ */
100
+ export async function runMentionClone(
101
+ opts: MentionCloneOptions,
102
+ ): Promise<MentionCloneResult> {
103
+ const { ctx, type, message, agentTool } = opts
104
+
105
+ let spawned = false
106
+ const cloneAgentTool: ToolDefinition = {
107
+ ...agentTool,
108
+ execute: (_cloneToolCallId, params, signal, onUpdate, _cloneCtx) => {
109
+ // One spawn per mention. The clone has a single tool and every reason to
110
+ // stop after using it, but a model that decides to "also" launch a second
111
+ // agent would do it where nobody can see and nobody asked.
112
+ if (spawned) {
113
+ return Promise.resolve({
114
+ content: [
115
+ {
116
+ type: "text" as const,
117
+ text: "Already started an agent for this mention. Stop here.",
118
+ },
119
+ ],
120
+ details: undefined,
121
+ isError: true,
122
+ })
123
+ }
124
+ spawned = true
125
+ // undefined tool-call id + the main ctx: see the header. Background is
126
+ // forced rather than left to the clone: `run_in_background` defaults to
127
+ // false, and a foreground agent answers through its TOOL RESULT — which
128
+ // here is delivered into a session that is disposed moments later, so the
129
+ // agent would run, appear in the widget and the fleet, and reach nobody.
130
+ return agentTool.execute(
131
+ undefined as never,
132
+ {
133
+ ...(params as Record<string, unknown>),
134
+ run_in_background: true,
135
+ } as typeof params,
136
+ signal,
137
+ onUpdate,
138
+ ctx,
139
+ )
140
+ },
141
+ }
142
+
143
+ let session:
144
+ | Awaited<ReturnType<typeof createAgentSession>>["session"]
145
+ | undefined
146
+ try {
147
+ // Pi 0.80.8 moved createAgentSession from modelRegistry to modelRuntime;
148
+ // agent-runner.ts carries the same shim for the same reason — pass both so
149
+ // the clone keeps the parent's providers across the supported range.
150
+ // SAFETY: The supported Pi versions expose runtime through this facade at
151
+ // runtime; the optional property keeps the pre-modelRuntime fallback safe.
152
+ const parentModelRuntime = (
153
+ ctx.modelRegistry as unknown as { runtime?: unknown }
154
+ ).runtime
155
+ // The conversation as the main session resolves it: compaction applied,
156
+ // branch summaries substituted.
157
+ const conversation = buildSessionContext(
158
+ ctx.sessionManager.getEntries(),
159
+ ctx.sessionManager.getLeafId(),
160
+ )
161
+ // Pi 0.82.0 added this; below it the field is absent and the clone takes
162
+ // the settings level instead, which is what a session that never ran
163
+ // `/think` is on anyway. Same shim shape as `modelRuntime` below.
164
+ const thinkingLevel = (ctx as { thinkingLevel?: ThinkingLevel })
165
+ .thinkingLevel
166
+ const created = await runInChildSessionContext(() =>
167
+ createAgentSession({
168
+ cwd: ctx.cwd,
169
+ // Nothing about the copy is worth persisting, and an in-memory manager
170
+ // is also what keeps the real session untouched.
171
+ sessionManager: SessionManager.inMemory(ctx.cwd),
172
+ model: ctx.model as Model<never> | undefined,
173
+ ...(thinkingLevel && { thinkingLevel }),
174
+ modelRegistry: ctx.modelRegistry,
175
+ ...(parentModelRuntime !== undefined && {
176
+ modelRuntime: parentModelRuntime as never,
177
+ }),
178
+ // An allowlist naming exactly the clone's own tool. NOT `noTools:
179
+ // "all"`, whose doc comment ("start with no tools enabled") reads like
180
+ // it spares custom tools and does not: it resolves to an EMPTY
181
+ // allowlist, and `isAllowedTool` then drops every tool from the
182
+ // registry — the custom one included. The clone would be prompted with
183
+ // nothing to call, answer in prose, and every mention would fall
184
+ // through to the direct start with a warning. Same idiom as
185
+ // agent-runner's `tools: sessionTools` beside its nested `customTools`.
186
+ tools: [cloneAgentTool.name],
187
+ customTools: [cloneAgentTool],
188
+ } as Parameters<typeof createAgentSession>[0]),
189
+ )
190
+ session = created.session
191
+
192
+ // The clone rebuilds a system prompt from cwd and agentDir, which is close
193
+ // but not the live one — extensions contribute to it per turn. Copy the
194
+ // real thing, so the copy reasons under the instructions the user's model
195
+ // is actually working under.
196
+ const systemPrompt = ctx.getSystemPrompt?.()
197
+ if (systemPrompt) session.agent.state.systemPrompt = systemPrompt
198
+
199
+ // The conversation itself. Pushed rather than assigned so the array the
200
+ // session was built around stays the one it goes on using.
201
+ session.agent.state.messages.push(...conversation.messages)
202
+
203
+ // User text first, reminder after — the order Claude Code's attachment
204
+ // renderer produces, where the reminder trails the message it is about.
205
+ await session.prompt(`${message}\n\n${agentMentionReminder(type)}`)
206
+ } catch (err) {
207
+ return { spawned, error: err instanceof Error ? err.message : String(err) }
208
+ } finally {
209
+ session?.dispose?.()
210
+ }
211
+
212
+ return spawned
213
+ ? { spawned: true }
214
+ : { spawned: false, error: "the conversation clone did not start it" }
215
+ }
package/src/mention.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * mention.ts — the `@handle` grammar for messaging a subagent from the prompt.
3
+ *
4
+ * Claude Code lets you type `@code-review take another look` at the prompt and
5
+ * routes the message to that agent instead of the main model. Its grammar is
6
+ * reproduced here so the two behave identically:
7
+ *
8
+ * - suggestions fire on `@` at the start of the input or after whitespace,
9
+ * followed by `[\w-]*` (so `@src/foo.ts` is a file, never an agent);
10
+ * - a send is recognized only at the START of the input, and only with a
11
+ * non-empty message after the handle. That is why a bare `@code-review`
12
+ * goes to the main model rather than anywhere near the agent.
13
+ *
14
+ * A record's own identity is a UUID plus a deliberately non-unique description,
15
+ * neither of which is typeable, so the handle is derived from the agent type.
16
+ * Colliding handles are numbered (`explore`, `explore-2`), which is also what
17
+ * Claude Code's `allocateName` does — it recycles a name only once the task
18
+ * behind it is gone. Its SendMessage prompt describes the *registry* as
19
+ * latest-wins, which is a different thing and not how names are allocated.
20
+ */
21
+
22
+ /**
23
+ * Suggestion trigger: `@` at a token boundary plus the partial handle typed so
24
+ * far. Ported from Claude Code, including the CJK sentence-ending punctuation
25
+ * it accepts as a boundary.
26
+ */
27
+ export const MENTION_TRIGGER = /(^|[\s。、?!])@([\w-]*)$/
28
+
29
+ /** Send grammar: leading `@handle`, then a non-empty message. */
30
+ const MENTION_SEND = /^@([\w-]+)\s+([\s\S]+)$/
31
+
32
+ /**
33
+ * Upper bound on a handle, matching Claude Code's `dSS`. Nothing here generates
34
+ * a name this long, but an agent type or a model-supplied name can be arbitrary
35
+ * text, and an unbounded handle would wrap the suggestion popup.
36
+ */
37
+ const MAX_HANDLE_LENGTH = 64
38
+
39
+ /**
40
+ * Handles that address something other than a subagent, and so can never be
41
+ * allocated to one. Claude Code reserves exactly this name (`Vq = "main"`),
42
+ * refusing it at spawn and routing it to the main conversation instead.
43
+ */
44
+ const RESERVED_HANDLES: ReadonlySet<string> = new Set(["main"])
45
+
46
+ /** Whether `@handle` names the main conversation rather than any subagent. */
47
+ export function isReservedHandle(handle: string): boolean {
48
+ return RESERVED_HANDLES.has(handle.toLowerCase())
49
+ }
50
+
51
+ /** Slug of an agent type or name, restricted to the `[\w-]` the grammar allows. */
52
+ export function handleBase(type: string): string {
53
+ const slug = type
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9_-]+/g, "-")
56
+ .replace(/^-+|-+$/g, "")
57
+ .slice(0, MAX_HANDLE_LENGTH)
58
+ // The slice can land mid-run and leave the trailing hyphen back.
59
+ .replace(/-+$/, "")
60
+ return slug || "agent"
61
+ }
62
+
63
+ /**
64
+ * `base`, else `base-2`, `base-3`, … — the first form that is neither `taken`
65
+ * nor reserved. Callers pass one shared `taken` set covering type-derived
66
+ * handles and model-supplied aliases alike, so the two can never collide.
67
+ */
68
+ export function assignHandle(base: string, taken: ReadonlySet<string>): string {
69
+ let candidate = base
70
+ let n = 1
71
+ while (taken.has(candidate) || RESERVED_HANDLES.has(candidate)) {
72
+ n++
73
+ candidate = `${base}-${n}`
74
+ }
75
+ return candidate
76
+ }
77
+
78
+ /**
79
+ * Map a typed handle back to a registered agent type, so `@explore fix it`
80
+ * reaches the Explore agent even when no instance has ever run. `handleBase` is
81
+ * the single source of truth in both directions, so a type is addressable by
82
+ * exactly the handle its instances would be given.
83
+ */
84
+ export function resolveHandleToType(
85
+ handle: string,
86
+ types: readonly string[],
87
+ ): string | undefined {
88
+ const wanted = handle.toLowerCase()
89
+ // A type slugging to a reserved name is unaddressable rather than shadowing
90
+ // it — `assignHandle` refuses that name too, so its instances never hold one.
91
+ if (RESERVED_HANDLES.has(wanted)) return undefined
92
+ return types.find((type) => handleBase(type) === wanted)
93
+ }
94
+
95
+ /**
96
+ * Claude Code documents `@agent-<name>` as the form you type by hand when the
97
+ * picker isn't involved. Accepted here as an exact synonym: the caller tries the
98
+ * handle as written first, so an agent genuinely called `agent-foo` still wins
99
+ * over `@agent-` + `foo`, and only falls back to this when that finds nothing.
100
+ * Returns undefined when the prefix is absent or is the whole handle.
101
+ */
102
+ export function stripAgentPrefix(handle: string): string | undefined {
103
+ const rest = /^agent-(.+)$/i.exec(handle)?.[1]
104
+ return rest || undefined
105
+ }
106
+
107
+ /**
108
+ * A spawn needs the short description every agent surface renders. A mention
109
+ * carries no separate label, so the message itself becomes one: first line,
110
+ * whitespace collapsed, clipped to roughly the 3-5 words the Agent tool asks of
111
+ * the model.
112
+ */
113
+ export function describeMention(message: string): string {
114
+ const oneLine = message.split("\n", 1)[0].replace(/\s+/g, " ").trim()
115
+ return oneLine.length > 40 ? `${oneLine.slice(0, 39).trimEnd()}…` : oneLine
116
+ }
117
+
118
+ /**
119
+ * What Claude Code sends the main model when a mention names an agent it could
120
+ * start. Its `@agent-<type>` mention is not a spawn at all: it becomes an
121
+ * `agent_mention` attachment, which renders to a synthetic `isMeta` user
122
+ * message placed after the user's own untouched text — no tool forcing, no
123
+ * allowed-tools narrowing, and the Task tool is not even named. The model reads
124
+ * this and calls the tool itself.
125
+ *
126
+ * Ported verbatim from the 2.1.233 bundle's attachment renderer, trailing space
127
+ * before the closing newline included, so the wording the model was trained
128
+ * against is the wording it gets. The one substitution is ours: pi's equivalent
129
+ * of Task is the `Agent` tool, and the agent listing that teaches valid
130
+ * `subagent_type` values is the tool spec rather than a separate attachment.
131
+ */
132
+ export function agentMentionReminder(type: string): string {
133
+ return `<system-reminder>\nThe user has expressed a desire to invoke the agent "${type}". Please invoke the agent appropriately, passing in the required context to it. \n</system-reminder>`
134
+ }
135
+
136
+ /**
137
+ * Split `@handle message` into its parts, or null when the text isn't a send —
138
+ * a bare handle, a leading file path, or a mention that isn't at the start.
139
+ */
140
+ export function parseMention(
141
+ text: string,
142
+ ): { handle: string; message: string } | null {
143
+ const match = MENTION_SEND.exec(text)
144
+ if (!match) return null
145
+ const message = match[2].trim()
146
+ return message ? { handle: match[1], message } : null
147
+ }
@@ -14,7 +14,15 @@ export interface ModelRegistry {
14
14
  getAvailable?(): any[]
15
15
  }
16
16
 
17
- /** Both display forms of a resolved model. */
17
+ /**
18
+ * Both display forms of a model. The short one goes on tight rows (the widget,
19
+ * the Agent tool result), the canonical one where there is room to disambiguate
20
+ * two providers serving a similarly-named model (the conversation viewer).
21
+ *
22
+ * One function, because `index.ts` labels the model it resolved before the run
23
+ * and `agent-manager.ts` relabels it from the live session afterwards — the two
24
+ * must agree or the label would visibly change the moment the session starts.
25
+ */
18
26
  export function describeModel(model: {
19
27
  provider: string
20
28
  id: string
@@ -96,6 +96,8 @@ export interface NestedAgentManager {
96
96
  prompt: string,
97
97
  options: NestedSpawnOptions,
98
98
  ): string
99
+ /** Resolves once the spawned agent is running; rejects on a startup failure. */
100
+ awaitStartup(id: string): Promise<void>
99
101
  spawnAndWait(
100
102
  pi: ExtensionAPI,
101
103
  ctx: ExtensionContext,
@@ -413,38 +415,50 @@ export function createNestedSubagentTools(
413
415
  // one earlier would silently give a grandchild the wrong worktree base, the
414
416
  // wrong conversation under inherit_context, and the wrong inherited model.
415
417
  //
416
- // Startup throws mean no child ran. Let them propagate so Pi marks the
417
- // nested Agent tool call failed instead of presenting the message as a
418
- // successful child result.
419
- if (invocation.runInBackground) {
420
- const id = context.manager.spawn(
418
+ // spawn() throws on strict worktree-isolation failure and cwd validation —
419
+ // report it as a tool error, like the top-level Agent tool does, instead of
420
+ // letting it escape into the child's turn.
421
+ try {
422
+ if (invocation.runInBackground) {
423
+ const id = context.manager.spawn(
424
+ context.pi,
425
+ ctx,
426
+ resolvedType,
427
+ params.prompt,
428
+ {
429
+ ...options,
430
+ isBackground: true,
431
+ },
432
+ )
433
+ // Synchronous, before the event loop yields — onSessionCreated fires
434
+ // asynchronously inside runAgent, so the file is attached in time.
435
+ attachTranscript(id)
436
+ // Worktree isolation starts the agent asynchronously; surface its
437
+ // failure as a tool error, like the synchronous throw used to.
438
+ await context.manager.awaitStartup(id)
439
+ return textResult(
440
+ `Nested agent started in background. Agent ID: ${id}`,
441
+ )
442
+ }
443
+
444
+ const { record } = await context.manager.spawnAndWait(
421
445
  context.pi,
422
446
  ctx,
423
447
  resolvedType,
424
448
  params.prompt,
425
- {
426
- ...options,
427
- isBackground: true,
428
- },
449
+ { ...options, signal },
450
+ attachTranscript,
451
+ )
452
+ return textResult(
453
+ formatRecord(record, "inline"),
454
+ record.status === "error",
455
+ )
456
+ } catch (err) {
457
+ return textResult(
458
+ err instanceof Error ? err.message : String(err),
459
+ true,
429
460
  )
430
- // Synchronous, before the event loop yields — onSessionCreated fires
431
- // asynchronously inside runAgent, so the file is attached in time.
432
- attachTranscript(id)
433
- return textResult(`Nested agent started in background. Agent ID: ${id}`)
434
461
  }
435
-
436
- const { record } = await context.manager.spawnAndWait(
437
- context.pi,
438
- ctx,
439
- resolvedType,
440
- params.prompt,
441
- { ...options, signal },
442
- attachTranscript,
443
- )
444
- return textResult(
445
- formatRecord(record, "inline"),
446
- record.status === "error",
447
- )
448
462
  },
449
463
  })
450
464
 
@@ -44,13 +44,14 @@ export function encodeCwd(cwd: string): string {
44
44
  .replace(/^-+/, "") // strip leading dashes (POSIX root, UNC)
45
45
  }
46
46
 
47
- /** Create the output file path, ensuring the directory exists.
48
- * Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks/{agentId}.output */
49
- export function createOutputFilePath(
50
- cwd: string,
51
- agentId: string,
52
- sessionId: string,
53
- ): string {
47
+ /**
48
+ * The per-session scratch directory, created if missing.
49
+ * Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks
50
+ *
51
+ * Shared with the workflow tool, which persists each invocation's script here so
52
+ * iterating on one is edit-file-then-rerun — the same convention, one directory.
53
+ */
54
+ export function sessionTaskDir(cwd: string, sessionId: string): string {
54
55
  const encoded = encodeCwd(cwd)
55
56
  const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`)
56
57
  mkdirSync(root, { recursive: true, mode: 0o700 })
@@ -63,7 +64,16 @@ export function createOutputFilePath(
63
64
  }
64
65
  const dir = join(root, encoded, sessionId, "tasks")
65
66
  mkdirSync(dir, { recursive: true })
66
- return join(dir, `${agentId}.output`)
67
+ return dir
68
+ }
69
+
70
+ /** Create the output file path, ensuring the directory exists. */
71
+ export function createOutputFilePath(
72
+ cwd: string,
73
+ agentId: string,
74
+ sessionId: string,
75
+ ): string {
76
+ return join(sessionTaskDir(cwd, sessionId), `${agentId}.output`)
67
77
  }
68
78
 
69
79
  /**
package/src/prompts.ts CHANGED
@@ -10,8 +10,29 @@ export interface PromptExtras {
10
10
  memoryBlock?: string
11
11
  /** Preloaded skill contents to inject. */
12
12
  skillBlocks?: { name: string; content: string }[]
13
- /** Original checkout path when cwd is an isolated worktree copy. */
13
+ /**
14
+ * Parent directory the worktree copy was created from. Set only for
15
+ * `isolation: "worktree"` spawns — triggers the block that tells the agent
16
+ * to stay in the copy.
17
+ */
14
18
  worktreeBase?: string
19
+ /**
20
+ * Set only for a workflow's own children, and only when they have no
21
+ * `StructuredOutput` tool to answer through.
22
+ *
23
+ * A workflow child's final text is not read by a human — it is the value
24
+ * `agent()` resolves to, and the script interpolates it straight into the
25
+ * next stage's prompt. Without this, children answer the way every other
26
+ * subagent does (a report addressed to a reader), and the padding becomes
27
+ * input tokens for the stage downstream. Claude Code's `Workflow` tool
28
+ * documents this contract to the script-writing model; this is the end of it
29
+ * that makes the documentation true.
30
+ *
31
+ * Deliberately NOT applied to every subagent. In pi an ordinary agent's
32
+ * output IS read by a human — through FleetView, the conversation viewer and
33
+ * `get_subagent_result` — so terse raw data would be the wrong answer there.
34
+ */
35
+ workflowChild?: boolean
15
36
  }
16
37
 
17
38
  /**
@@ -45,6 +66,26 @@ Working directory: ${cwd}
45
66
  ${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
46
67
  Platform: ${env.platform}`
47
68
 
69
+ // A worktree agent is told its cwd twice: by the env block above (the copy)
70
+ // and by whatever names the main checkout — the inherited parent prompt in
71
+ // append mode, or the task prompt in either mode. It follows the latter and
72
+ // works in the shared tree (#187), so resolve the contradiction explicitly.
73
+ const worktreeBlock = extras?.worktreeBase
74
+ ? `\n\n<worktree_isolation>
75
+ Your working directory is an isolated git worktree copy of ${extras.worktreeBase}.
76
+ Work only inside it — never in ${extras.worktreeBase}, even if other instructions name that path as your working directory.
77
+ </worktree_isolation>`
78
+ : ""
79
+
80
+ // The script, not a person, reads what this child returns — see
81
+ // `PromptExtras.workflowChild` for why only workflow children get this.
82
+ const workflowBlock = extras?.workflowChild
83
+ ? `\n\n<workflow_child>
84
+ Your final message IS the return value of this task. A workflow script captures it and passes it to the next stage; no person reads it.
85
+ Return only the answer, in exactly the shape the prompt asks for — no preamble, no summary of what you did, no offer to continue.
86
+ </workflow_child>`
87
+ : ""
88
+
48
89
  // Build optional extras suffix
49
90
  const extraSections: string[] = []
50
91
  if (extras?.memoryBlock) {
@@ -57,12 +98,6 @@ Platform: ${env.platform}`
57
98
  }
58
99
  const extrasSuffix =
59
100
  extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : ""
60
- const worktreeSection = extras?.worktreeBase
61
- ? `\n\n<worktree_isolation>
62
- Your working directory is an isolated git worktree copy of ${extras.worktreeBase}.
63
- Work only inside it — never in ${extras.worktreeBase}, even if other instructions name that path as your working directory.
64
- </worktree_isolation>`
65
- : ""
66
101
 
67
102
  if (config.promptMode === "append") {
68
103
  const identity = parentSystemPrompt || genericBase
@@ -96,7 +131,8 @@ You are operating as a sub-agent invoked to handle a specific task.
96
131
  "\n\n" +
97
132
  activeAgentTag +
98
133
  envBlock +
99
- worktreeSection +
134
+ worktreeBlock +
135
+ workflowBlock +
100
136
  customSection +
101
137
  extrasSuffix
102
138
  )
@@ -111,7 +147,8 @@ ${envBlock}`
111
147
  return (
112
148
  activeAgentTag +
113
149
  replaceHeader +
114
- worktreeSection +
150
+ worktreeBlock +
151
+ workflowBlock +
115
152
  "\n\n" +
116
153
  config.systemPrompt +
117
154
  extrasSuffix