@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.
@@ -144,6 +144,38 @@ interface SpawnOptions {
144
144
  rootSessionId?: string
145
145
  }
146
146
 
147
+ interface ResumeOptions {
148
+ /**
149
+ * Run the resumed turn detached in the background: return immediately with
150
+ * the record still "running" (or "queued" at the concurrency limit) and
151
+ * notify on completion via onComplete, exactly like a background spawn.
152
+ * Default (false/undefined) runs the resume inline and returns the settled
153
+ * record — the historical behavior.
154
+ */
155
+ isBackground?: boolean
156
+ /** Called on tool start/end with activity info (for streaming progress to UI). */
157
+ onToolActivity?: (activity: ToolActivity) => void
158
+ /** Called at the end of each resumed agentic turn with the cumulative count. */
159
+ onTurnEnd?: (turnCount: number) => void
160
+ /** Called once per assistant message_end with that message's usage delta. */
161
+ onAssistantUsage?: (usage: {
162
+ input: number
163
+ output: number
164
+ cacheWrite: number
165
+ }) => void
166
+ /** Called when the session successfully compacts. */
167
+ onCompaction?: (info: CompactionInfo) => void
168
+ /**
169
+ * Background resume only: called synchronously when the run actually starts —
170
+ * immediately, or later from drainQueue. Callers wire per-run side effects
171
+ * (output-file streaming) here rather than at the call site, so a resume that
172
+ * is stopped while still queued never leaves a subscription behind: `abort()`
173
+ * drops a queued record without reaching `settle()`, which is what would have
174
+ * torn that subscription down.
175
+ */
176
+ onStarted?: () => void
177
+ }
178
+
147
179
  export class AgentManager {
148
180
  private agents = new Map<string, AgentRecord>()
149
181
  private cleanupInterval: ReturnType<typeof setInterval>
@@ -156,7 +188,7 @@ export class AgentManager {
156
188
  private worktreeRepos = new Set<string>()
157
189
 
158
190
  /** Queue of background agents waiting to start. */
159
- private queue: { id: string; args: SpawnArgs }[] = []
191
+ private queue: { id: string; start: () => void }[] = []
160
192
  /** Number of currently running background agents. */
161
193
  private runningBackground = 0
162
194
 
@@ -236,7 +268,7 @@ export class AgentManager {
236
268
  this.runningBackground >= this.maxConcurrent
237
269
  ) {
238
270
  // Queue it — will be started when a running agent completes
239
- this.queue.push({ id, args })
271
+ this.queue.push({ id, start: () => this.startAgent(id, record, args) })
240
272
  return id
241
273
  }
242
274
 
@@ -321,6 +353,7 @@ export class AgentManager {
321
353
  // stay undefined otherwise so plain worktree runs keep resolving config
322
354
  // (incl. relative extension paths and memory) inside the worktree copy.
323
355
  cwd: worktreeCwd ?? customCwd,
356
+ worktreeBase: worktreeCwd ? baseCwd : undefined,
324
357
  configCwd:
325
358
  options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
326
359
  signal: record.abortController!.signal,
@@ -507,7 +540,7 @@ export class AgentManager {
507
540
  const record = this.agents.get(next.id)
508
541
  if (record?.status !== "queued") continue
509
542
  try {
510
- this.startAgent(next.id, record, next.args)
543
+ next.start()
511
544
  } catch (err) {
512
545
  // Late failure (e.g. strict worktree-isolation) — surface on the record
513
546
  // so the user/agent can see it via /agents, then keep draining.
@@ -569,10 +602,51 @@ export class AgentManager {
569
602
  id: string,
570
603
  prompt: string,
571
604
  signal?: AbortSignal,
605
+ options?: ResumeOptions,
572
606
  ): Promise<AgentRecord | undefined> {
573
607
  const record = this.agents.get(id)
574
608
  if (!record?.session) return undefined
575
609
 
610
+ // Background resume: settle asynchronously and notify on completion exactly
611
+ // like a background spawn, returning immediately with the record still
612
+ // "running" — or "queued" when at the concurrency limit. Previously
613
+ // run_in_background was ignored on resume (the Agent tool's resume branch
614
+ // returned before its background branch, and resume() only ever awaited
615
+ // inline), so a resumed agent always blocked the caller until it finished.
616
+ if (options?.isBackground) {
617
+ // Never re-enter a run that is still in flight. Detaching means the caller
618
+ // gets control back while the record stays "running", so nothing stops the
619
+ // model from resuming the same agent again. Starting a second run would
620
+ // overwrite record.abortController — orphaning the live run beyond the
621
+ // reach of `/agents` stop and abortAll() — double-count the pool slot, and
622
+ // then reject from session.prompt() with "Agent is already processing",
623
+ // whose settle path would abort the LIVE run's children and report a
624
+ // failure for a run that is still going. Refuse instead, leaving the
625
+ // record untouched; the caller decides whether to wait or steer.
626
+ if (record.status === "running" || record.status === "queued")
627
+ return undefined
628
+
629
+ record.isBackground = true
630
+ record.resultConsumed = false
631
+ record.result = undefined
632
+ record.error = undefined
633
+ record.completedAt = undefined
634
+ record.status = "queued"
635
+
636
+ const start = () => this.startResume(id, record, prompt, signal, options)
637
+ if (
638
+ occupiesPoolSlot(record) &&
639
+ this.runningBackground >= this.maxConcurrent
640
+ ) {
641
+ // At the concurrency limit — queue it, drains when a slot frees.
642
+ this.queue.push({ id, start })
643
+ } else {
644
+ start()
645
+ }
646
+ return record
647
+ }
648
+
649
+ // Foreground resume: run inline and return the settled record.
576
650
  record.status = "running"
577
651
  record.startedAt = Date.now()
578
652
  record.completedAt = undefined
@@ -583,13 +657,17 @@ export class AgentManager {
583
657
  const { text, failure } = await resumeAgent(record.session, prompt, {
584
658
  onToolActivity: (activity) => {
585
659
  if (activity.type === "end") record.toolUses++
660
+ options?.onToolActivity?.(activity)
586
661
  },
662
+ onTurnEnd: options?.onTurnEnd,
587
663
  onAssistantUsage: (usage) => {
588
664
  addUsage(record.lifetimeUsage, usage)
665
+ options?.onAssistantUsage?.(usage)
589
666
  },
590
667
  onCompaction: (info) => {
591
668
  record.compactionCount++
592
669
  this.onCompact?.(record, info)
670
+ options?.onCompaction?.(info)
593
671
  },
594
672
  signal,
595
673
  })
@@ -612,6 +690,117 @@ export class AgentManager {
612
690
  return record
613
691
  }
614
692
 
693
+ /**
694
+ * Start a background resume run: detached, settling and notifying like
695
+ * startAgent's background path. Invoked immediately, or from drainQueue when
696
+ * a concurrency slot frees. The session already exists (resume reuses it), so
697
+ * there is no onSessionCreated to hang per-run wiring off — callers use
698
+ * `options.onStarted`, which fires on both the immediate and the drained path.
699
+ */
700
+ private startResume(
701
+ id: string,
702
+ record: AgentRecord,
703
+ prompt: string,
704
+ parentSignal: AbortSignal | undefined,
705
+ options: ResumeOptions,
706
+ ) {
707
+ if (!record.session) return
708
+
709
+ record.status = "running"
710
+ record.startedAt = Date.now()
711
+ if (occupiesPoolSlot(record)) this.runningBackground++
712
+ this.onStart?.(record)
713
+
714
+ // Fresh abort controller so /agents stop and steering target THIS run rather
715
+ // than the previous one's settled controller.
716
+ const abortController = new AbortController()
717
+ record.abortController = abortController
718
+ // Optional, and NOT what the Agent tool passes for a detached resume: a
719
+ // parent signal aborts on the parent's own interrupt (user Esc), which is
720
+ // right for a foreground run whose result the caller is awaiting, and wrong
721
+ // for a detached one — background spawns omit it for exactly this reason.
722
+ let detachParentSignal: (() => void) | undefined
723
+ if (parentSignal) {
724
+ const onParentAbort = () => this.abort(id)
725
+ parentSignal.addEventListener("abort", onParentAbort, { once: true })
726
+ detachParentSignal = () =>
727
+ parentSignal.removeEventListener("abort", onParentAbort)
728
+ }
729
+
730
+ // Per-run side effects (output streaming) — see ResumeOptions.onStarted.
731
+ // After the record is in its running shape, before the run is kicked off.
732
+ try {
733
+ options.onStarted?.()
734
+ } catch {
735
+ /* ignore caller wiring errors */
736
+ }
737
+
738
+ const settle = () => {
739
+ detachParentSignal?.()
740
+ detachParentSignal = undefined
741
+ // Final flush of streaming output file
742
+ if (record.outputCleanup) {
743
+ try {
744
+ record.outputCleanup()
745
+ } catch {
746
+ /* ignore */
747
+ }
748
+ record.outputCleanup = undefined
749
+ }
750
+ // Children spawned during the resumed turn must not outlive it.
751
+ this.abortOwnedChildren(id)
752
+ if (occupiesPoolSlot(record)) this.runningBackground--
753
+ try {
754
+ this.onComplete?.(record)
755
+ } catch {
756
+ /* ignore completion side-effect errors */
757
+ }
758
+ this.drainQueue()
759
+ }
760
+
761
+ const promise = resumeAgent(record.session, prompt, {
762
+ onToolActivity: (activity) => {
763
+ if (activity.type === "end") record.toolUses++
764
+ options.onToolActivity?.(activity)
765
+ },
766
+ onTurnEnd: options.onTurnEnd,
767
+ onAssistantUsage: (usage) => {
768
+ addUsage(record.lifetimeUsage, usage)
769
+ options.onAssistantUsage?.(usage)
770
+ },
771
+ onCompaction: (info) => {
772
+ record.compactionCount++
773
+ this.onCompact?.(record, info)
774
+ options.onCompaction?.(info)
775
+ },
776
+ signal: abortController.signal,
777
+ })
778
+ .then(({ text, failure }) => {
779
+ // Don't overwrite status if externally stopped via abort().
780
+ if (record.status !== "stopped") {
781
+ // Same contract as the spawn path (#144): a failed final turn is an
782
+ // error, not a completion — but the resumed text stays available.
783
+ record.status = failure ? "error" : "completed"
784
+ if (failure) record.error = failure
785
+ }
786
+ record.result = text
787
+ record.completedAt ??= Date.now()
788
+ settle()
789
+ return text
790
+ })
791
+ .catch((err) => {
792
+ if (record.status !== "stopped") {
793
+ record.status = "error"
794
+ record.error = err instanceof Error ? err.message : String(err)
795
+ }
796
+ record.completedAt ??= Date.now()
797
+ settle()
798
+ return ""
799
+ })
800
+
801
+ record.promise = promise
802
+ }
803
+
615
804
  /**
616
805
  * Send a steering message to an agent from the UI (mirrors the steer_subagent
617
806
  * tool). A live session delivers it now — it interrupts the agent after its
@@ -365,7 +365,7 @@ export function setGraceTurns(n: number): void {
365
365
  * Try to find the right model for an agent type.
366
366
  * Priority: explicit option > config.model > parent model.
367
367
  */
368
- function resolveDefaultModel(
368
+ export function resolveDefaultModel(
369
369
  parentModel: Model<any> | undefined,
370
370
  registry: {
371
371
  find(provider: string, modelId: string): Model<any> | undefined
@@ -414,6 +414,8 @@ export interface RunOptions {
414
414
  thinkingLevel?: ThinkingLevel
415
415
  /** Override working directory (e.g. for worktree isolation). */
416
416
  cwd?: string
417
+ /** Original checkout path when cwd is an isolated worktree copy. */
418
+ worktreeBase?: string
417
419
  /**
418
420
  * Where .pi config is discovered (project extensions, skills, pi settings,
419
421
  * agent memory). Default: same as the working directory. The manager sets
@@ -602,7 +604,7 @@ export async function runAgent(
602
604
  const parentSystemPrompt = ctx.getSystemPrompt()
603
605
 
604
606
  // Build prompt extras (memory, skill preloading)
605
- const extras: PromptExtras = {}
607
+ const extras: PromptExtras = { worktreeBase: options.worktreeBase }
606
608
 
607
609
  // Resolve extensions/skills: isolated overrides to false
608
610
  const extensions = options.isolated ? false : config.extensions
@@ -944,6 +946,9 @@ export async function runAgent(
944
946
  ? SessionManager.create(
945
947
  effectiveCwd,
946
948
  configuredSessionDir ?? defaultSessionDir,
949
+ {
950
+ parentSession: ctx.sessionManager.getSessionFile(),
951
+ },
947
952
  )
948
953
  : SessionManager.inMemory(effectiveCwd)
949
954
 
@@ -1123,6 +1128,8 @@ export async function resumeAgent(
1123
1128
  prompt: string,
1124
1129
  options: {
1125
1130
  onToolActivity?: (activity: ToolActivity) => void
1131
+ /** Called at the end of each resumed agentic turn with the 1-based count. */
1132
+ onTurnEnd?: (turnCount: number) => void
1126
1133
  onAssistantUsage?: (usage: {
1127
1134
  input: number
1128
1135
  output: number
@@ -1141,10 +1148,18 @@ export async function resumeAgent(
1141
1148
  const startLen = session.messages.length
1142
1149
  const collector = collectResponseText(session)
1143
1150
  const cleanupAbort = forwardAbortSignal(session, options.signal)
1151
+ let turnCount = 0
1144
1152
 
1145
1153
  const unsubEvents =
1146
- options.onToolActivity || options.onAssistantUsage || options.onCompaction
1154
+ options.onToolActivity ||
1155
+ options.onTurnEnd ||
1156
+ options.onAssistantUsage ||
1157
+ options.onCompaction
1147
1158
  ? session.subscribe((event: AgentSessionEvent) => {
1159
+ if (event.type === "turn_end") {
1160
+ turnCount++
1161
+ options.onTurnEnd?.(turnCount)
1162
+ }
1148
1163
  if (event.type === "tool_execution_start")
1149
1164
  options.onToolActivity?.({
1150
1165
  type: "start",