@herbertgao/pi-subagents 0.15.2 → 0.15.4

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.
@@ -399,47 +399,38 @@ export function createNestedSubagentTools(
399
399
  // one earlier would silently give a grandchild the wrong worktree base, the
400
400
  // wrong conversation under inherit_context, and the wrong inherited model.
401
401
  //
402
- // spawn() throws on strict worktree-isolation failure and cwd validation
403
- // report it as a tool error, like the top-level Agent tool does, instead of
404
- // letting it escape into the child's turn.
405
- try {
406
- if (invocation.runInBackground) {
407
- const id = context.manager.spawn(
408
- context.pi,
409
- ctx,
410
- resolvedType,
411
- params.prompt,
412
- {
413
- ...options,
414
- isBackground: true,
415
- },
416
- )
417
- // Synchronous, before the event loop yields — onSessionCreated fires
418
- // asynchronously inside runAgent, so the file is attached in time.
419
- attachTranscript(id)
420
- return textResult(
421
- `Nested agent started in background. Agent ID: ${id}`,
422
- )
423
- }
424
-
425
- const { record } = await context.manager.spawnAndWait(
402
+ // Startup throws mean no child ran. Let them propagate so Pi marks the
403
+ // nested Agent tool call failed instead of presenting the message as a
404
+ // successful child result.
405
+ if (invocation.runInBackground) {
406
+ const id = context.manager.spawn(
426
407
  context.pi,
427
408
  ctx,
428
409
  resolvedType,
429
410
  params.prompt,
430
- { ...options, signal },
431
- attachTranscript,
432
- )
433
- return textResult(
434
- formatRecord(record, "inline"),
435
- record.status === "error",
436
- )
437
- } catch (err) {
438
- return textResult(
439
- err instanceof Error ? err.message : String(err),
440
- true,
411
+ {
412
+ ...options,
413
+ isBackground: true,
414
+ },
441
415
  )
416
+ // Synchronous, before the event loop yields — onSessionCreated fires
417
+ // asynchronously inside runAgent, so the file is attached in time.
418
+ attachTranscript(id)
419
+ return textResult(`Nested agent started in background. Agent ID: ${id}`)
442
420
  }
421
+
422
+ const { record } = await context.manager.spawnAndWait(
423
+ context.pi,
424
+ ctx,
425
+ resolvedType,
426
+ params.prompt,
427
+ { ...options, signal },
428
+ attachTranscript,
429
+ )
430
+ return textResult(
431
+ formatRecord(record, "inline"),
432
+ record.status === "error",
433
+ )
443
434
  },
444
435
  })
445
436
 
@@ -66,6 +66,23 @@ export function createOutputFilePath(
66
66
  return join(dir, `${agentId}.output`)
67
67
  }
68
68
 
69
+ /**
70
+ * Ensure a transcript file exists without disturbing what is already in it.
71
+ *
72
+ * A resume reuses the agent's existing transcript (same deterministic path), so
73
+ * it must never call `writeInitialEntry` — that truncates, discarding turns the
74
+ * completion notification still points the user at, and any history the session
75
+ * has since compacted away is gone for good. Appending nothing creates the file
76
+ * when this is the agent's first transcript and is a no-op when it is not.
77
+ */
78
+ export function ensureOutputFile(path: string): void {
79
+ try {
80
+ appendFileSync(path, "", "utf-8")
81
+ } catch {
82
+ /* ignore — streaming writes are best-effort too */
83
+ }
84
+ }
85
+
69
86
  /** Write the initial user prompt entry. */
70
87
  export function writeInitialEntry(
71
88
  path: string,
@@ -93,8 +110,14 @@ export function streamToOutputFile(
93
110
  path: string,
94
111
  agentId: string,
95
112
  cwd: string,
113
+ startIndex?: number,
96
114
  ): () => void {
97
- let writtenCount = 1 // initial user prompt already written
115
+ // Index of the first message this stream is responsible for. A spawn writes
116
+ // messages[0] as the initial prompt entry, so it starts at 1. A resume hands
117
+ // in the session's length as of just before the run: the session already
118
+ // holds every prior turn, and re-emitting those would duplicate history that
119
+ // is already in the file.
120
+ let writtenCount = startIndex ?? 1
98
121
 
99
122
  const flush = () => {
100
123
  const messages = session.messages
package/src/prompts.ts CHANGED
@@ -10,6 +10,8 @@ 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. */
14
+ worktreeBase?: string
13
15
  }
14
16
 
15
17
  /**
@@ -55,6 +57,12 @@ Platform: ${env.platform}`
55
57
  }
56
58
  const extrasSuffix =
57
59
  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
+ : ""
58
66
 
59
67
  if (config.promptMode === "append") {
60
68
  const identity = parentSystemPrompt || genericBase
@@ -88,6 +96,7 @@ You are operating as a sub-agent invoked to handle a specific task.
88
96
  "\n\n" +
89
97
  activeAgentTag +
90
98
  envBlock +
99
+ worktreeSection +
91
100
  customSection +
92
101
  extrasSuffix
93
102
  )
@@ -100,7 +109,12 @@ You have been invoked to handle a specific task autonomously.
100
109
  ${envBlock}`
101
110
 
102
111
  return (
103
- activeAgentTag + replaceHeader + "\n\n" + config.systemPrompt + extrasSuffix
112
+ activeAgentTag +
113
+ replaceHeader +
114
+ worktreeSection +
115
+ "\n\n" +
116
+ config.systemPrompt +
117
+ extrasSuffix
104
118
  )
105
119
  }
106
120
 
@@ -358,6 +358,17 @@ export class AgentWidget {
358
358
  }
359
359
  }
360
360
 
361
+ /**
362
+ * Drop an agent's finished-age (call when a settled agent starts running
363
+ * again, i.e. a background resume). markFinished only seeds an age it has not
364
+ * seen before, so a resumed agent would otherwise keep the age from its
365
+ * previous run — already past the linger limit, hiding the new run's
366
+ * completion line entirely.
367
+ */
368
+ markRunning(agentId: string) {
369
+ this.finishedTurnAge.delete(agentId)
370
+ }
371
+
361
372
  /** Render a finished agent line. */
362
373
  private renderFinishedLine(
363
374
  a: {
@@ -536,6 +547,16 @@ export class AgentWidget {
536
547
  let hiddenRunning = 0
537
548
  let hiddenFinished = 0
538
549
 
550
+ // Reserve the queued line's row up front. It is a single summary of N
551
+ // waiting agents, so it cannot be folded into the "+N more" count (which
552
+ // is denominated in agents) without either under-reporting it as 1 or
553
+ // inflating the total with agents that were never getting their own rows.
554
+ // Reserving costs at most one running agent — which IS counted below —
555
+ // and makes the drop unreachable. It matters most exactly when it used to
556
+ // vanish: the pool is saturated and the queue is what the user needs to see.
557
+ const queuedReserve = queuedLine ? 1 : 0
558
+ budget -= queuedReserve
559
+
539
560
  // 1. Running agents (2 lines each)
540
561
  for (const pair of runningLines) {
541
562
  if (budget >= 2) {
@@ -546,8 +567,9 @@ export class AgentWidget {
546
567
  }
547
568
  }
548
569
 
549
- // 2. Queued line
550
- if (queuedLine && budget >= 1) {
570
+ // 2. Queued line (always fits — its row was reserved above)
571
+ if (queuedLine) {
572
+ budget += queuedReserve
551
573
  lines.push(queuedLine)
552
574
  budget--
553
575
  }
@@ -11,6 +11,7 @@
11
11
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent"
12
12
  import type { SubagentScheduler } from "../schedule.js"
13
13
  import type { ScheduledSubagent } from "../types.js"
14
+ import { selectItem } from "./select-item.js"
14
15
 
15
16
  /** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */
16
17
  function relTime(iso: string | undefined, now = Date.now()): string {
@@ -88,16 +89,16 @@ export async function showSchedulesMenu(
88
89
  return
89
90
  }
90
91
 
91
- const labels = jobs.map((j) => formatJob(j, scheduler))
92
- const choice = await ctx.ui.select(
92
+ // Numbered + item-paired: two jobs whose names agree in the first 18
93
+ // characters format identically, and matching the returned string back
94
+ // against a parallel label array cancelled whichever came first.
95
+ const job = await selectItem(
96
+ ctx.ui,
93
97
  `Scheduled jobs (${jobs.length}) — select to cancel`,
94
- labels,
98
+ jobs,
99
+ (j) => formatJob(j, scheduler),
95
100
  )
96
- if (!choice) return
97
-
98
- const idx = labels.indexOf(choice)
99
- if (idx < 0) return
100
- const job = jobs[idx]
101
+ if (!job) return
101
102
 
102
103
  const ok = await ctx.ui.confirm(
103
104
  `Cancel "${job.name}"?`,
@@ -0,0 +1,48 @@
1
+ /**
2
+ * select-item.ts — pick an item from a list via `ctx.ui.select`, safely.
3
+ *
4
+ * Pi's dialog API is `select(title, options: string[]) => Promise<string | undefined>`:
5
+ * strings in, string out, with no index or value form. Callers therefore have to
6
+ * map the returned string back to the item it came from, and the obvious way —
7
+ * `labels.indexOf(choice)` over a parallel array — silently resolves to the
8
+ * FIRST match whenever two rows format identically. Row formatters here truncate
9
+ * (job names to 18 chars, agent descriptions to whatever fits), and the text they
10
+ * truncate is LLM-authored, so collisions are ordinary rather than exotic.
11
+ *
12
+ * This numbers every row, which makes the labels unique by construction — no
13
+ * data-dependent branch that only executes in the case nobody exercises — and
14
+ * keeps each label paired with its item so a later edit that sorts or filters
15
+ * between building and resolving cannot desync them.
16
+ */
17
+
18
+ /** Minimal shape of the `ctx.ui` surface this needs. */
19
+ export interface SelectUI {
20
+ select(title: string, options: string[]): Promise<string | undefined>
21
+ }
22
+
23
+ /**
24
+ * Show a numbered picker and return the chosen item (not its label).
25
+ *
26
+ * Returns undefined when the user escapes, or when the returned string is not
27
+ * one we offered.
28
+ */
29
+ export async function selectItem<T>(
30
+ ui: SelectUI,
31
+ title: string,
32
+ items: readonly T[],
33
+ format: (item: T, index: number) => string,
34
+ ): Promise<T | undefined> {
35
+ // Pad the number so a 10+ item list keeps its columns aligned.
36
+ const width = String(items.length).length
37
+ const rows = items.map((item, i) => ({
38
+ item,
39
+ label: `${String(i + 1).padStart(width)}. ${format(item, i)}`,
40
+ }))
41
+
42
+ const choice = await ui.select(
43
+ title,
44
+ rows.map((r) => r.label),
45
+ )
46
+ if (!choice) return undefined
47
+ return rows.find((r) => r.label === choice)?.item
48
+ }