@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.
- package/CHANGELOG.md +14 -0
- package/package.json +8 -4
- package/src/agent-color.ts +72 -67
- package/src/agent-file-toggle.ts +255 -0
- package/src/agent-manager.ts +192 -3
- package/src/agent-runner.ts +18 -3
- package/src/index.ts +322 -148
- package/src/nested-tools.ts +26 -35
- package/src/output-file.ts +24 -1
- package/src/prompts.ts +15 -1
- package/src/ui/agent-widget.ts +24 -2
- package/src/ui/schedule-menu.ts +9 -8
- package/src/ui/select-item.ts +48 -0
package/src/agent-manager.ts
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
package/src/agent-runner.ts
CHANGED
|
@@ -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 ||
|
|
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",
|