@herbertgao/pi-subagents 0.22.0-local.2 → 0.22.1-local.1

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.
@@ -42,7 +42,11 @@ import {
42
42
  getMaxSubagentDepth,
43
43
  type NestedAgentManager,
44
44
  } from "./nested-tools.js"
45
- import { buildAgentPrompt, type PromptExtras } from "./prompts.js"
45
+ import {
46
+ buildAgentPrompt,
47
+ inheritableParentPrompt,
48
+ type PromptExtras,
49
+ } from "./prompts.js"
46
50
  import { preloadSkills } from "./skill-loader.js"
47
51
  import {
48
52
  createStructuredCapture,
@@ -722,6 +726,91 @@ function finalTurnError(
722
726
  return undefined
723
727
  }
724
728
 
729
+ /** The per-run callbacks a spawn and a resume share, plus the resolved turn limit. */
730
+ type RunObserverOptions = Pick<
731
+ RunOptions,
732
+ | "onTurnEnd"
733
+ | "onTextDelta"
734
+ | "onToolActivity"
735
+ | "onAssistantUsage"
736
+ | "onCompaction"
737
+ > & {
738
+ /** Already resolved: undefined = unlimited. */
739
+ maxTurns?: number
740
+ }
741
+
742
+ /**
743
+ * Observe one prompt of a session — a spawn's or a resume's — and enforce its
744
+ * turn limit: steer once at `maxTurns`, hard-abort after the grace turns.
745
+ * `steered` / `aborted` are live fields, read while the prompt is still in
746
+ * flight (the structured-output retry) and after it settles (the result).
747
+ * Shared by `runAgent` and `resumeAgent` so a resumed run is bounded exactly
748
+ * like the first one (#368).
749
+ */
750
+ function observeRun(session: AgentSession, options: RunObserverOptions) {
751
+ let turnCount = 0
752
+ let currentMessageText = ""
753
+ const run = {
754
+ steered: false,
755
+ aborted: false,
756
+ unsubscribe: () => {},
757
+ }
758
+ run.unsubscribe = session.subscribe((event: AgentSessionEvent) => {
759
+ if (event.type === "turn_end") {
760
+ turnCount++
761
+ options.onTurnEnd?.(turnCount)
762
+ if (options.maxTurns != null) {
763
+ if (!run.steered && turnCount >= options.maxTurns) {
764
+ run.steered = true
765
+ session.steer(
766
+ "You have reached your turn limit. Wrap up immediately — provide your final answer now.",
767
+ )
768
+ } else if (run.steered && turnCount >= options.maxTurns + graceTurns) {
769
+ run.aborted = true
770
+ session.abort()
771
+ }
772
+ }
773
+ }
774
+ if (event.type === "message_start") {
775
+ currentMessageText = ""
776
+ }
777
+ if (
778
+ event.type === "message_update" &&
779
+ event.assistantMessageEvent.type === "text_delta"
780
+ ) {
781
+ currentMessageText += event.assistantMessageEvent.delta
782
+ options.onTextDelta?.(
783
+ event.assistantMessageEvent.delta,
784
+ currentMessageText,
785
+ )
786
+ }
787
+ if (event.type === "tool_execution_start") {
788
+ options.onToolActivity?.({ type: "start", toolName: event.toolName })
789
+ }
790
+ if (event.type === "tool_execution_end") {
791
+ options.onToolActivity?.({ type: "end", toolName: event.toolName })
792
+ }
793
+ if (event.type === "message_end" && event.message.role === "assistant") {
794
+ const u = (event.message as any).usage
795
+ if (u)
796
+ options.onAssistantUsage?.({
797
+ input: u.input ?? 0,
798
+ output: u.output ?? 0,
799
+ cacheWrite: u.cacheWrite ?? 0,
800
+ cacheRead: u.cacheRead ?? 0,
801
+ cost: u.cost?.total ?? 0,
802
+ })
803
+ }
804
+ if (event.type === "compaction_end" && !event.aborted && event.result) {
805
+ options.onCompaction?.({
806
+ reason: event.reason,
807
+ tokensBefore: event.result.tokensBefore,
808
+ })
809
+ }
810
+ })
811
+ return run
812
+ }
813
+
725
814
  /**
726
815
  * Wire an AbortSignal to abort a session.
727
816
  * Returns a cleanup function to remove the listener.
@@ -764,8 +853,12 @@ export async function runAgent(
764
853
 
765
854
  const env = await detectEnv(options.pi, effectiveCwd)
766
855
 
767
- // Get parent system prompt for append-mode agents
768
- const parentSystemPrompt = ctx.getSystemPrompt()
856
+ // Get parent system prompt for append-mode agents, without the sections the
857
+ // child session renders itself (skills, cwd, extension sections).
858
+ const parentSystemPrompt = inheritableParentPrompt(
859
+ ctx.getSystemPrompt(),
860
+ ctx.cwd,
861
+ )
769
862
 
770
863
  // Build prompt extras (memory, skill preloading)
771
864
  const extras: PromptExtras = {}
@@ -1072,8 +1165,12 @@ export async function runAgent(
1072
1165
  options.model ??
1073
1166
  resolveDefaultModel(ctx.model, ctx.modelRegistry, agentConfig?.model)
1074
1167
 
1075
- // Resolve thinking level: explicit option > agent config > undefined (inherit)
1076
- const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking
1168
+ // Resolve thinking level: explicit option > agent config > parent session level.
1169
+ // Without the parent step, an unset level falls through to pi's
1170
+ // defaultThinkingLevel (e.g. "max") instead of matching the spawning session.
1171
+ // Same idiom as mention-clone.ts.
1172
+ const thinkingLevel =
1173
+ options.thinkingLevel ?? agentConfig?.thinking ?? ctx.thinkingLevel
1077
1174
 
1078
1175
  const disallowedSet = agentConfig?.disallowedTools
1079
1176
  ? new Set(agentConfig.disallowedTools)
@@ -1316,65 +1413,9 @@ export async function runAgent(
1316
1413
 
1317
1414
  options.onSessionCreated?.(session)
1318
1415
 
1319
- // Track turns for graceful max_turns enforcement
1320
- let turnCount = 0
1321
- const maxTurns = resolveEffectiveMaxTurns(type, options.maxTurns)
1322
- let softLimitReached = false
1323
- let aborted = false
1324
-
1325
- let currentMessageText = ""
1326
- const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
1327
- if (event.type === "turn_end") {
1328
- turnCount++
1329
- options.onTurnEnd?.(turnCount)
1330
- if (maxTurns != null) {
1331
- if (!softLimitReached && turnCount >= maxTurns) {
1332
- softLimitReached = true
1333
- session.steer(
1334
- "You have reached your turn limit. Wrap up immediately — provide your final answer now.",
1335
- )
1336
- } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
1337
- aborted = true
1338
- session.abort()
1339
- }
1340
- }
1341
- }
1342
- if (event.type === "message_start") {
1343
- currentMessageText = ""
1344
- }
1345
- if (
1346
- event.type === "message_update" &&
1347
- event.assistantMessageEvent.type === "text_delta"
1348
- ) {
1349
- currentMessageText += event.assistantMessageEvent.delta
1350
- options.onTextDelta?.(
1351
- event.assistantMessageEvent.delta,
1352
- currentMessageText,
1353
- )
1354
- }
1355
- if (event.type === "tool_execution_start") {
1356
- options.onToolActivity?.({ type: "start", toolName: event.toolName })
1357
- }
1358
- if (event.type === "tool_execution_end") {
1359
- options.onToolActivity?.({ type: "end", toolName: event.toolName })
1360
- }
1361
- if (event.type === "message_end" && event.message.role === "assistant") {
1362
- const u = (event.message as any).usage
1363
- if (u)
1364
- options.onAssistantUsage?.({
1365
- input: u.input ?? 0,
1366
- output: u.output ?? 0,
1367
- cacheWrite: u.cacheWrite ?? 0,
1368
- cacheRead: u.cacheRead ?? 0,
1369
- cost: u.cost?.total ?? 0,
1370
- })
1371
- }
1372
- if (event.type === "compaction_end" && !event.aborted && event.result) {
1373
- options.onCompaction?.({
1374
- reason: event.reason,
1375
- tokensBefore: event.result.tokensBefore,
1376
- })
1377
- }
1416
+ const run = observeRun(session, {
1417
+ ...options,
1418
+ maxTurns: resolveEffectiveMaxTurns(type, options.maxTurns),
1378
1419
  })
1379
1420
 
1380
1421
  const collector = collectResponseText(session)
@@ -1407,14 +1448,14 @@ export async function runAgent(
1407
1448
  if (
1408
1449
  structuredCapture !== undefined &&
1409
1450
  structuredCapture.json === undefined &&
1410
- !aborted &&
1451
+ !run.aborted &&
1411
1452
  options.signal?.aborted !== true
1412
1453
  ) {
1413
1454
  structuredRetried = true
1414
1455
  await session.prompt(structuredRetryPrompt(structuredCapture))
1415
1456
  }
1416
1457
  } finally {
1417
- unsubTurns()
1458
+ run.unsubscribe()
1418
1459
  collector.unsubscribe()
1419
1460
  cleanupAbort()
1420
1461
  }
@@ -1433,8 +1474,8 @@ export async function runAgent(
1433
1474
  return {
1434
1475
  responseText,
1435
1476
  session,
1436
- aborted,
1437
- steered: softLimitReached,
1477
+ aborted: run.aborted,
1478
+ steered: run.steered,
1438
1479
  failure: finalTurnError(session, startLen) ?? structuredFailure,
1439
1480
  ...(structuredCapture?.json !== undefined
1440
1481
  ? { structuredJson: structuredCapture.json }
@@ -1444,77 +1485,54 @@ export async function runAgent(
1444
1485
  }
1445
1486
 
1446
1487
  /**
1447
- * Send a new prompt to an existing session (resume).
1488
+ * Send a new prompt to an existing session (resume). A fresh run of the same
1489
+ * agent: the turn limit is resolved as `runAgent` resolves it — an explicit
1490
+ * `maxTurns`, else the agent type's own `max_turns`, else the project default
1491
+ * — and counted from this prompt (#368).
1448
1492
  */
1449
1493
  export async function resumeAgent(
1450
1494
  session: AgentSession,
1451
1495
  prompt: string,
1452
- options: {
1453
- onToolActivity?: (activity: ToolActivity) => void
1454
- onAssistantUsage?: (usage: LifetimeUsage) => void
1455
- onCompaction?: (info: {
1456
- reason: "manual" | "threshold" | "overflow"
1457
- tokensBefore: number
1458
- }) => void
1459
- signal?: AbortSignal
1460
- } = {},
1461
- ): Promise<{ text: string; failure?: string }> {
1496
+ options: Pick<
1497
+ RunOptions,
1498
+ | "maxTurns"
1499
+ | "onTurnEnd"
1500
+ | "onToolActivity"
1501
+ | "onAssistantUsage"
1502
+ | "onCompaction"
1503
+ | "signal"
1504
+ > & {
1505
+ /** The agent's type, for its own `max_turns`. */
1506
+ type: SubagentType
1507
+ },
1508
+ ): Promise<
1509
+ { text: string; failure?: string } & Pick<RunResult, "aborted" | "steered">
1510
+ > {
1462
1511
  // Boundary for the history fallback: the session already holds prior turns,
1463
1512
  // so only assistant text produced by THIS resume prompt counts as its output
1464
1513
  // — a failed resume must not surface the previous turn's answer (#144).
1465
1514
  const startLen = session.messages.length
1466
1515
  const collector = collectResponseText(session)
1467
1516
  const cleanupAbort = forwardAbortSignal(session, options.signal)
1468
-
1469
- const unsubEvents =
1470
- options.onToolActivity || options.onAssistantUsage || options.onCompaction
1471
- ? session.subscribe((event: AgentSessionEvent) => {
1472
- if (event.type === "tool_execution_start")
1473
- options.onToolActivity?.({
1474
- type: "start",
1475
- toolName: event.toolName,
1476
- })
1477
- if (event.type === "tool_execution_end")
1478
- options.onToolActivity?.({ type: "end", toolName: event.toolName })
1479
- if (
1480
- event.type === "message_end" &&
1481
- event.message.role === "assistant"
1482
- ) {
1483
- const u = (event.message as any).usage
1484
- if (u)
1485
- options.onAssistantUsage?.({
1486
- input: u.input ?? 0,
1487
- output: u.output ?? 0,
1488
- cacheWrite: u.cacheWrite ?? 0,
1489
- cacheRead: u.cacheRead ?? 0,
1490
- cost: u.cost?.total ?? 0,
1491
- })
1492
- }
1493
- if (
1494
- event.type === "compaction_end" &&
1495
- !event.aborted &&
1496
- event.result
1497
- ) {
1498
- options.onCompaction?.({
1499
- reason: event.reason,
1500
- tokensBefore: event.result.tokensBefore,
1501
- })
1502
- }
1503
- })
1504
- : () => {}
1517
+ const run = observeRun(session, {
1518
+ ...options,
1519
+ maxTurns: resolveEffectiveMaxTurns(options.type, options.maxTurns),
1520
+ })
1505
1521
 
1506
1522
  try {
1507
1523
  // Already aborted: not prompted, as in `runAgent`.
1508
1524
  if (options.signal?.aborted !== true) await session.prompt(prompt)
1509
1525
  } finally {
1510
1526
  collector.unsubscribe()
1511
- unsubEvents()
1527
+ run.unsubscribe()
1512
1528
  cleanupAbort()
1513
1529
  }
1514
1530
 
1515
1531
  return {
1516
1532
  text: collector.getText().trim() || getLastAssistantText(session, startLen),
1517
1533
  failure: finalTurnError(session, startLen),
1534
+ aborted: run.aborted,
1535
+ steered: run.steered,
1518
1536
  }
1519
1537
  }
1520
1538
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cross-extension RPC handlers for the subagents extension.
3
3
  *
4
- * Exposes ping, spawn, stop, and consume RPCs over the pi.events event bus,
4
+ * Exposes ping, spawn, stop, consume, and steer RPCs over the pi.events event bus,
5
5
  * using per-request scoped reply channels.
6
6
  *
7
7
  * Reply envelope follows pi-mono convention:
@@ -58,6 +58,11 @@ export interface SpawnCapable {
58
58
  * one. False when there is no such agent, or it has not settled yet.
59
59
  */
60
60
  consumeResult(id: string): boolean
61
+ /**
62
+ * Deliver a message to a running or queued agent's conversation — what
63
+ * `steer_subagent` does. False when the agent is unknown or has settled.
64
+ */
65
+ steer(id: string, message: string): boolean
61
66
  }
62
67
 
63
68
  export interface RpcDeps {
@@ -72,6 +77,7 @@ export interface RpcHandle {
72
77
  unsubSpawn: () => void
73
78
  unsubStop: () => void
74
79
  unsubConsume: () => void
80
+ unsubSteer: () => void
75
81
  }
76
82
 
77
83
  /**
@@ -100,7 +106,7 @@ function handleRpc<P extends { requestId: string }>(
100
106
  }
101
107
 
102
108
  /**
103
- * Register ping, spawn, stop, and consume RPC handlers on the event bus.
109
+ * Register ping, spawn, stop, consume, and steer RPC handlers on the event bus.
104
110
  * Returns unsub functions for cleanup.
105
111
  */
106
112
  export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
@@ -184,21 +190,25 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
184
190
  return { id }
185
191
  })
186
192
 
193
+ // Only the session's own agents are this RPC's to stop or steer. A nested
194
+ // child or a workflow's agent is owned by something that is *waiting on it*,
195
+ // and aborting or redirecting it out from under that owner turns another
196
+ // extension's request into a failed step here. Defence in depth rather than
197
+ // a live hole: no RPC hands out agent ids, so a caller has no ordinary way to
198
+ // name one it does not own — but the guard is cheap and the id may leak some
199
+ // other way. Same refuse-what-we-should-not-touch stance as `consume` below.
200
+ const assertOwned = (agentId: string): void => {
201
+ const record = manager.getRecord(agentId)
202
+ if (!record) throw new Error("Agent not found")
203
+ if (!isTopLevelAgent(record))
204
+ throw new Error("Agent is owned by another agent or workflow")
205
+ }
206
+
187
207
  const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
188
208
  events,
189
209
  "subagents:rpc:stop",
190
210
  ({ agentId }) => {
191
- const record = manager.getRecord(agentId)
192
- if (!record) throw new Error("Agent not found")
193
- // Only the session's own agents are this RPC's to stop. A nested child or
194
- // a workflow's agent is owned by something that is *waiting on it*, and
195
- // aborting it out from under that owner turns another extension's stop
196
- // into a failed step here. Defence in depth rather than a live hole: no
197
- // RPC hands out agent ids, so a caller has no ordinary way to name one it
198
- // does not own — but the guard is cheap and the id may leak some other
199
- // way. Same refuse-what-we-should-not-touch stance as `consume` below.
200
- if (!isTopLevelAgent(record))
201
- throw new Error("Agent is owned by another agent or workflow")
211
+ assertOwned(agentId)
202
212
  // Not "not found" — the lookup above already proved it exists. `abort`
203
213
  // returns false only for a record that is neither running nor queued,
204
214
  // which is an agent that has already finished.
@@ -220,5 +230,22 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
220
230
  },
221
231
  )
222
232
 
223
- return { unsubPing, unsubSpawn, unsubStop, unsubConsume }
233
+ // An extension that spawned an agent over the bus and then learns something
234
+ // that agent needs — a second CI failure on the head it is already fixing, a
235
+ // review that landed mid-run — can hand it over instead of spawning a second
236
+ // agent for the same work or dropping the fact. Outside the ping version
237
+ // handshake, like consume: additive and best-effort.
238
+ const unsubSteer = handleRpc<{
239
+ requestId: string
240
+ agentId: string
241
+ message: unknown
242
+ }>(events, "subagents:rpc:steer", ({ agentId, message }) => {
243
+ if (typeof message !== "string" || message.trim().length === 0)
244
+ throw new Error("Steer message must be a non-empty string")
245
+ assertOwned(agentId)
246
+ if (!manager.steer(agentId, message))
247
+ throw new Error("Agent is not running")
248
+ })
249
+
250
+ return { unsubPing, unsubSpawn, unsubStop, unsubConsume, unsubSteer }
224
251
  }
package/src/index.ts CHANGED
@@ -965,12 +965,59 @@ export default function (pi: ExtensionAPI) {
965
965
  compactionCount: record.compactionCount,
966
966
  })
967
967
  },
968
- (_record, usage) => {
968
+ (record, usage) => {
969
969
  // Every assistant message from every agent — nested included, exactly once.
970
970
  // Parked here until a tool result can carry it back to the parent session;
971
971
  // see `PendingUsagePool`. Skipped entirely when the feature is off, so no
972
972
  // pool grows in a session that will never drain it.
973
973
  if (reportUsage) pendingUsage.add(usage)
974
+ // The same message, live, for anyone keeping their own ledger — a footer
975
+ // that breaks spend down by agent type and thinking level, say. This is the
976
+ // one place every agent's spend passes through, which is why it is emitted
977
+ // here and not from the lifecycle events: those fire for top-level agents
978
+ // only, once, at the end, so a workflow's children and nested agents would
979
+ // never be counted, and a running agent would read as free until it
980
+ // stopped. Deliberately ungated by `reportUsage` — that decides whether the
981
+ // parent session's own totals include this spend, not whether it happened.
982
+ //
983
+ // `model` and `thinking` are read off the record per message, not captured
984
+ // at spawn: the record is authoritative only once the session exists and pi
985
+ // has clamped the level to the model (see `onSessionCreated`).
986
+ const reported = toReportedUsage(usage)
987
+ if (!reported) return
988
+ // A key whose value is unknown is omitted, never `undefined`: `model` and
989
+ // `thinking` until the session has reported them, `parentAgentId` and
990
+ // `workflowId` on a top-level agent.
991
+ const invocation = record.invocation
992
+ pi.events.emit("subagents:usage", {
993
+ id: record.id,
994
+ type: record.type,
995
+ description: record.description,
996
+ ...(invocation?.modelId !== undefined
997
+ ? { model: invocation.modelId }
998
+ : {}),
999
+ ...(invocation?.thinking !== undefined
1000
+ ? { thinking: invocation.thinking }
1001
+ : {}),
1002
+ // Set only when the caller did not get the level it asked for (pi
1003
+ // clamped it to the model, or an agent file outranked it), so a ledger
1004
+ // can show `low (asked minimal)` instead of silently relabelling.
1005
+ ...(invocation?.requestedThinking !== undefined
1006
+ ? { requestedThinking: invocation.requestedThinking }
1007
+ : {}),
1008
+ depth: record.depth ?? 1,
1009
+ ...(record.parentAgentId !== undefined
1010
+ ? { parentAgentId: record.parentAgentId }
1011
+ : {}),
1012
+ ...(record.workflowId !== undefined
1013
+ ? { workflowId: record.workflowId }
1014
+ : {}),
1015
+ usage: reported,
1016
+ })
1017
+ },
1018
+ (record, message) => {
1019
+ if (!isTopLevelAgent(record)) return
1020
+ pi.events.emit("subagents:steered", { id: record.id, message })
974
1021
  },
975
1022
  )
976
1023
 
@@ -1180,6 +1227,7 @@ export default function (pi: ExtensionAPI) {
1180
1227
  outbox.cancel(record.id)
1181
1228
  return true
1182
1229
  },
1230
+ steer: (id, message) => manager.steer(id, message),
1183
1231
  },
1184
1232
  })
1185
1233
  // Broadcast readiness so extensions loaded alongside us can discover us.
@@ -1295,10 +1343,6 @@ export default function (pi: ExtensionAPI) {
1295
1343
  // this message is still relayed even if the LLM read its last answer.
1296
1344
  record.resultConsumed = false
1297
1345
  manager.steer(record.id, mention.message)
1298
- pi.events.emit("subagents:steered", {
1299
- id: record.id,
1300
- message: mention.message,
1301
- })
1302
1346
  ctx.ui.notify(`Sent to ${target}`, "info")
1303
1347
  return { action: "handled" }
1304
1348
  }
@@ -1308,17 +1352,15 @@ export default function (pi: ExtensionAPI) {
1308
1352
  // agent, so its frontmatter is what governs — `output_transcript: false`
1309
1353
  // must keep holding, since record.outputFile is the sole gate every
1310
1354
  // downstream consumer keys off and a resume must not re-open it.
1311
- const config = getAgentConfig(record.type)
1312
1355
  const resumedRecord = await startBackgroundResume(
1313
1356
  ctx,
1314
1357
  record,
1315
1358
  mention.message,
1316
1359
  {
1317
1360
  outputTranscript:
1318
- config?.outputTranscript ?? getOutputTranscriptDefault(),
1319
- maxTurns: normalizeMaxTurns(
1320
- config?.maxTurns ?? getDefaultMaxTurns(),
1321
- ),
1361
+ getAgentConfig(record.type)?.outputTranscript ??
1362
+ getOutputTranscriptDefault(),
1363
+ maxTurns: resumeTurnLimit(record),
1322
1364
  },
1323
1365
  )
1324
1366
  ctx.ui.notify(
@@ -1505,6 +1547,7 @@ export default function (pi: ExtensionAPI) {
1505
1547
  rpcHandle?.unsubStop()
1506
1548
  rpcHandle?.unsubPing()
1507
1549
  rpcHandle?.unsubConsume()
1550
+ rpcHandle?.unsubSteer()
1508
1551
  rpcHandle = undefined
1509
1552
  currentCtx = undefined
1510
1553
  // Only release the global slot if this activation claimed it — a child
@@ -1539,6 +1582,13 @@ export default function (pi: ExtensionAPI) {
1539
1582
  // pi awaits this handler, and the process exits right after — unawaited, those
1540
1583
  // handlers would never run. Internally bounded, so a hung one can't strand quit.
1541
1584
  await manager.dispose(pi)
1585
+ // Every agent is stopped and every child session has shut down, so no more
1586
+ // `subagents:usage` can arrive. A ledger flushing in its own
1587
+ // `session_shutdown` may have run before this handler aborted the agents
1588
+ // (handlers run in load order) and missed their last messages; this is the
1589
+ // point after which a synchronous final flush is complete. Synchronous:
1590
+ // `emit` does not await listeners, and pi exits once this handler returns.
1591
+ pi.events.emit("subagents:disposed", {})
1542
1592
  })
1543
1593
 
1544
1594
  // Live widget: show running agents above editor.
@@ -1831,6 +1881,24 @@ export default function (pi: ExtensionAPI) {
1831
1881
  }
1832
1882
  }
1833
1883
 
1884
+ /**
1885
+ * The turn budget for resuming `record`, from its OWN type — resume ignores
1886
+ * `subagent_type`. Same precedence as a spawn, frontmatter authoritative:
1887
+ * the agent file's `max_turns`, else the call's, else the limit the agent
1888
+ * was spawned with, else the default (#368).
1889
+ */
1890
+ function resumeTurnLimit(
1891
+ record: AgentRecord,
1892
+ explicit?: number,
1893
+ ): number | undefined {
1894
+ return normalizeMaxTurns(
1895
+ getAgentConfig(record.type)?.maxTurns ??
1896
+ explicit ??
1897
+ record.invocation?.maxTurns ??
1898
+ getDefaultMaxTurns(),
1899
+ )
1900
+ }
1901
+
1834
1902
  /**
1835
1903
  * Launch a detached resume of an existing agent and wire everything a
1836
1904
  * re-running agent needs: transcript anchoring, activity tracking, join-mode
@@ -1886,6 +1954,8 @@ export default function (pi: ExtensionAPI) {
1886
1954
  // run_in_background in that same turn keep going.
1887
1955
  const record = await manager.resume(id, prompt, undefined, {
1888
1956
  isBackground: true,
1957
+ maxTurns: opts.maxTurns,
1958
+ onTurnEnd: bgCallbacks.onTurnEnd,
1889
1959
  onToolActivity: bgCallbacks.onToolActivity,
1890
1960
  onAssistantUsage: bgCallbacks.onAssistantUsage,
1891
1961
  // Fires when the run actually starts — immediately, or on queue
@@ -2240,7 +2310,7 @@ Terse command-style prompts produce shallow, generic work.
2240
2310
  resume: Type.Optional(
2241
2311
  Type.String({
2242
2312
  description:
2243
- "Optional agent ID to resume from. Continues from previous context. Resumes detached like any other spawn; pass run_in_background: false to block and get the result inline. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run.",
2313
+ "Optional agent ID to resume from. Continues from previous context. Resumes detached like any other spawn; pass run_in_background: false to block and get the result inline. The resumed run gets a fresh max_turns budget. An agent can only be resumed once its current run has finished — use steer_subagent to reach one mid-run.",
2244
2314
  }),
2245
2315
  ),
2246
2316
  isolated: Type.Optional(
@@ -2692,6 +2762,9 @@ Terse command-style prompts produce shallow, generic work.
2692
2762
  `Agent "${params.resume}" has no active session to resume.`,
2693
2763
  )
2694
2764
  }
2765
+ // Not `effectiveMaxTurns`: that was resolved for `subagent_type`, which
2766
+ // a resume ignores, so it may describe another agent.
2767
+ const resumeMaxTurns = resumeTurnLimit(existing, params.max_turns)
2695
2768
 
2696
2769
  // Background resume: detached run that notifies on completion, mirroring
2697
2770
  // a background spawn. Previously run_in_background was silently ignored
@@ -2717,7 +2790,7 @@ Terse command-style prompts produce shallow, generic work.
2717
2790
  params.prompt,
2718
2791
  {
2719
2792
  outputTranscript,
2720
- maxTurns: effectiveMaxTurns,
2793
+ maxTurns: resumeMaxTurns,
2721
2794
  toolCallId,
2722
2795
  },
2723
2796
  )
@@ -2747,11 +2820,29 @@ Terse command-style prompts produce shallow, generic work.
2747
2820
  )
2748
2821
  }
2749
2822
 
2750
- const record = await manager.resume(
2751
- params.resume,
2752
- params.prompt,
2753
- signal,
2754
- )
2823
+ // Tracked like a foreground spawn, so the widget row and the result
2824
+ // card show THIS run's `↻N≤M` and tool activity rather than the
2825
+ // previous run's. The session predates this run, so it is seeded here.
2826
+ const { state: resumeState, callbacks: resumeCallbacks } =
2827
+ createActivityTracker(resumeMaxTurns)
2828
+ resumeState.session = existing.session
2829
+ agentActivity.set(existing.id, resumeState)
2830
+ widget.markRunning(existing.id)
2831
+ widget.ensureTimer()
2832
+ fleet.ensureTimer()
2833
+ fleet.update()
2834
+ let record: AgentRecord | undefined
2835
+ try {
2836
+ record = await manager.resume(params.resume, params.prompt, signal, {
2837
+ maxTurns: resumeMaxTurns,
2838
+ onTurnEnd: resumeCallbacks.onTurnEnd,
2839
+ onToolActivity: resumeCallbacks.onToolActivity,
2840
+ })
2841
+ } finally {
2842
+ agentActivity.delete(existing.id)
2843
+ widget.markFinished(existing.id)
2844
+ fleet.onAgentFinished(existing.id)
2845
+ }
2755
2846
  if (!record) {
2756
2847
  return textResult(`Failed to resume agent "${params.resume}".`)
2757
2848
  }
@@ -2760,12 +2851,12 @@ Terse command-style prompts produce shallow, generic work.
2760
2851
  if (record.status === "error") {
2761
2852
  return textResult(
2762
2853
  `Agent failed: ${record.error}${partialOutputSuffix(record)}`,
2763
- buildDetails(detailBaseFor(record), record),
2854
+ buildDetails(detailBaseFor(record), record, resumeState),
2764
2855
  )
2765
2856
  }
2766
2857
  return textResult(
2767
2858
  record.result?.trim() || "No output.",
2768
- buildDetails(detailBaseFor(record), record),
2859
+ buildDetails(detailBaseFor(record), record, resumeState),
2769
2860
  )
2770
2861
  }
2771
2862
 
@@ -112,6 +112,7 @@ export interface NestedAgentManager {
112
112
  id: string,
113
113
  prompt: string,
114
114
  signal?: AbortSignal,
115
+ options?: { maxTurns?: number },
115
116
  ): Promise<AgentRecord | undefined>
116
117
  }
117
118
 
@@ -242,10 +243,17 @@ export function createNestedSubagentTools(
242
243
  true,
243
244
  )
244
245
  }
246
+ // Frontmatter authoritative, as on the spawn path; the manager falls
247
+ // back to the limit the child was spawned with, then the default.
245
248
  const resumed = await context.manager.resume(
246
249
  params.resume,
247
250
  params.prompt,
248
251
  signal,
252
+ {
253
+ maxTurns:
254
+ getAgentConfigIn(loadRegistry(), existing.type)?.maxTurns ??
255
+ params.max_turns,
256
+ },
249
257
  )
250
258
  return resumed
251
259
  ? textResult(