@herbertgao/pi-subagents 0.15.3 → 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 +8 -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 +191 -3
- package/src/agent-runner.ts +15 -2
- package/src/index.ts +289 -113
- package/src/output-file.ts +24 -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
|
|
|
@@ -508,7 +540,7 @@ export class AgentManager {
|
|
|
508
540
|
const record = this.agents.get(next.id)
|
|
509
541
|
if (record?.status !== "queued") continue
|
|
510
542
|
try {
|
|
511
|
-
|
|
543
|
+
next.start()
|
|
512
544
|
} catch (err) {
|
|
513
545
|
// Late failure (e.g. strict worktree-isolation) — surface on the record
|
|
514
546
|
// so the user/agent can see it via /agents, then keep draining.
|
|
@@ -570,10 +602,51 @@ export class AgentManager {
|
|
|
570
602
|
id: string,
|
|
571
603
|
prompt: string,
|
|
572
604
|
signal?: AbortSignal,
|
|
605
|
+
options?: ResumeOptions,
|
|
573
606
|
): Promise<AgentRecord | undefined> {
|
|
574
607
|
const record = this.agents.get(id)
|
|
575
608
|
if (!record?.session) return undefined
|
|
576
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.
|
|
577
650
|
record.status = "running"
|
|
578
651
|
record.startedAt = Date.now()
|
|
579
652
|
record.completedAt = undefined
|
|
@@ -584,13 +657,17 @@ export class AgentManager {
|
|
|
584
657
|
const { text, failure } = await resumeAgent(record.session, prompt, {
|
|
585
658
|
onToolActivity: (activity) => {
|
|
586
659
|
if (activity.type === "end") record.toolUses++
|
|
660
|
+
options?.onToolActivity?.(activity)
|
|
587
661
|
},
|
|
662
|
+
onTurnEnd: options?.onTurnEnd,
|
|
588
663
|
onAssistantUsage: (usage) => {
|
|
589
664
|
addUsage(record.lifetimeUsage, usage)
|
|
665
|
+
options?.onAssistantUsage?.(usage)
|
|
590
666
|
},
|
|
591
667
|
onCompaction: (info) => {
|
|
592
668
|
record.compactionCount++
|
|
593
669
|
this.onCompact?.(record, info)
|
|
670
|
+
options?.onCompaction?.(info)
|
|
594
671
|
},
|
|
595
672
|
signal,
|
|
596
673
|
})
|
|
@@ -613,6 +690,117 @@ export class AgentManager {
|
|
|
613
690
|
return record
|
|
614
691
|
}
|
|
615
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
|
+
|
|
616
804
|
/**
|
|
617
805
|
* Send a steering message to an agent from the UI (mirrors the steer_subagent
|
|
618
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
|
|
@@ -946,6 +946,9 @@ export async function runAgent(
|
|
|
946
946
|
? SessionManager.create(
|
|
947
947
|
effectiveCwd,
|
|
948
948
|
configuredSessionDir ?? defaultSessionDir,
|
|
949
|
+
{
|
|
950
|
+
parentSession: ctx.sessionManager.getSessionFile(),
|
|
951
|
+
},
|
|
949
952
|
)
|
|
950
953
|
: SessionManager.inMemory(effectiveCwd)
|
|
951
954
|
|
|
@@ -1125,6 +1128,8 @@ export async function resumeAgent(
|
|
|
1125
1128
|
prompt: string,
|
|
1126
1129
|
options: {
|
|
1127
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
|
|
1128
1133
|
onAssistantUsage?: (usage: {
|
|
1129
1134
|
input: number
|
|
1130
1135
|
output: number
|
|
@@ -1143,10 +1148,18 @@ export async function resumeAgent(
|
|
|
1143
1148
|
const startLen = session.messages.length
|
|
1144
1149
|
const collector = collectResponseText(session)
|
|
1145
1150
|
const cleanupAbort = forwardAbortSignal(session, options.signal)
|
|
1151
|
+
let turnCount = 0
|
|
1146
1152
|
|
|
1147
1153
|
const unsubEvents =
|
|
1148
|
-
options.onToolActivity ||
|
|
1154
|
+
options.onToolActivity ||
|
|
1155
|
+
options.onTurnEnd ||
|
|
1156
|
+
options.onAssistantUsage ||
|
|
1157
|
+
options.onCompaction
|
|
1149
1158
|
? session.subscribe((event: AgentSessionEvent) => {
|
|
1159
|
+
if (event.type === "turn_end") {
|
|
1160
|
+
turnCount++
|
|
1161
|
+
options.onTurnEnd?.(turnCount)
|
|
1162
|
+
}
|
|
1150
1163
|
if (event.type === "tool_execution_start")
|
|
1151
1164
|
options.onToolActivity?.({
|
|
1152
1165
|
type: "start",
|