@signalridge/pi-subagents 1.10.2 → 1.11.0

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/src/index.ts CHANGED
@@ -119,6 +119,7 @@ import {
119
119
  setFallbackSubagent,
120
120
  } from "./agent-types.js";
121
121
  import { inChildSessionContext } from "./child-context.js";
122
+ import { INHERIT_CONTEXT_UNAVAILABLE } from "./context-boundary.js";
122
123
  import {
123
124
  PROTOCOL_CAPABILITIES,
124
125
  PROTOCOL_VERSION,
@@ -143,8 +144,13 @@ import {
143
144
  resolveHandleToType,
144
145
  stripAgentPrefix,
145
146
  } from "./mention.js";
146
- import { runMentionClone } from "./mention-clone.js";
147
- import { type ModelRegistry, resolveModel, shortModelLabel } from "./model-resolver.js";
147
+ import { MENTION_SPAWNED, runMentionClone } from "./mention-clone.js";
148
+ import {
149
+ type ModelRegistry,
150
+ resolveModel,
151
+ shortModelLabel,
152
+ } from "./model-resolver.js";
153
+ import { parentModelSessionOptions } from "./model-runtime-bridge.js";
148
154
  import {
149
155
  checkModelScope,
150
156
  isScopeModelsEnabled,
@@ -227,7 +233,10 @@ import {
227
233
  getSessionContextPercent,
228
234
  type LifetimeUsage,
229
235
  } from "./usage.js";
230
- import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js";
236
+ import {
237
+ isWorktreeIsolationEnabled,
238
+ setWorktreeIsolationEnabled,
239
+ } from "./worktree.js";
231
240
 
232
241
  // ---- Shared helpers ----
233
242
 
@@ -804,8 +813,12 @@ function activateRootRuntime(
804
813
  durationMs,
805
814
  tokens,
806
815
  ...(record.outputFile ? { outputFile: record.outputFile } : {}),
807
- ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
808
- ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: record.invocation.agentTierSnapshot } : {}),
816
+ ...(record.invocation?.agentTier === undefined
817
+ ? {}
818
+ : { tier: record.invocation.agentTier }),
819
+ ...(record.invocation?.agentTierSnapshot
820
+ ? { tierSnapshot: record.invocation.agentTierSnapshot }
821
+ : {}),
809
822
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
810
823
  };
811
824
  }
@@ -843,7 +856,9 @@ function activateRootRuntime(
843
856
  ? { tokens: { total: terminal.tokenCount } }
844
857
  : {}),
845
858
  ...(tombstone.tier === undefined ? {} : { tier: tombstone.tier }),
846
- ...(tombstone.tierSnapshot ? { tierSnapshot: tombstone.tierSnapshot } : {}),
859
+ ...(tombstone.tierSnapshot
860
+ ? { tierSnapshot: tombstone.tierSnapshot }
861
+ : {}),
847
862
  owner: snapshotOwner(tombstone.owner),
848
863
  };
849
864
  }
@@ -868,7 +883,9 @@ function activateRootRuntime(
868
883
  ...(terminal.outputFile ? { outputFile: terminal.outputFile } : {}),
869
884
  ...(terminal.tokenCount ? { tokens: terminal.tokenCount } : {}),
870
885
  ...(tombstone.tier === undefined ? {} : { tier: tombstone.tier }),
871
- ...(tombstone.tierSnapshot ? { tierSnapshot: tombstone.tierSnapshot } : {}),
886
+ ...(tombstone.tierSnapshot
887
+ ? { tierSnapshot: tombstone.tierSnapshot }
888
+ : {}),
872
889
  owner: snapshotOwner(tombstone.owner),
873
890
  });
874
891
  }
@@ -934,8 +951,12 @@ function activateRootRuntime(
934
951
  ...(record.lifetimeUsage.input + record.lifetimeUsage.output > 0
935
952
  ? { tokens: record.lifetimeUsage.input + record.lifetimeUsage.output }
936
953
  : {}),
937
- ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
938
- ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: record.invocation.agentTierSnapshot } : {}),
954
+ ...(record.invocation?.agentTier === undefined
955
+ ? {}
956
+ : { tier: record.invocation.agentTier }),
957
+ ...(record.invocation?.agentTierSnapshot
958
+ ? { tierSnapshot: record.invocation.agentTierSnapshot }
959
+ : {}),
939
960
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
940
961
  });
941
962
 
@@ -980,7 +1001,9 @@ function activateRootRuntime(
980
1001
  id: record.id,
981
1002
  type: record.type,
982
1003
  description: record.description,
983
- ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
1004
+ ...(record.invocation?.agentTier === undefined
1005
+ ? {}
1006
+ : { tier: record.invocation.agentTier }),
984
1007
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
985
1008
  });
986
1009
  },
@@ -994,8 +1017,12 @@ function activateRootRuntime(
994
1017
  reason: info.reason,
995
1018
  tokensBefore: info.tokensBefore,
996
1019
  compactionCount: record.compactionCount,
997
- ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
998
- ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: record.invocation.agentTierSnapshot } : {}),
1020
+ ...(record.invocation?.agentTier === undefined
1021
+ ? {}
1022
+ : { tier: record.invocation.agentTier }),
1023
+ ...(record.invocation?.agentTierSnapshot
1024
+ ? { tierSnapshot: record.invocation.agentTierSnapshot }
1025
+ : {}),
999
1026
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
1000
1027
  });
1001
1028
  },
@@ -1009,7 +1036,9 @@ function activateRootRuntime(
1009
1036
  type: record.type,
1010
1037
  description: record.description,
1011
1038
  isBackground: record.isBackground,
1012
- ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
1039
+ ...(record.invocation?.agentTier === undefined
1040
+ ? {}
1041
+ : { tier: record.invocation.agentTier }),
1013
1042
  owner: snapshotOwner(record.owner),
1014
1043
  });
1015
1044
  },
@@ -1067,8 +1096,7 @@ function activateRootRuntime(
1067
1096
  const globalRegistry = globalThis as any;
1068
1097
  const predecessor =
1069
1098
  (globalRegistry[MANAGER_TAKEOVER_LOCK_KEY] as
1070
- | Promise<void>
1071
- | undefined) ?? Promise.resolve();
1099
+ Promise<void> | undefined) ?? Promise.resolve();
1072
1100
  let release!: () => void;
1073
1101
  const gate = new Promise<void>((resolve) => {
1074
1102
  release = resolve;
@@ -1152,6 +1180,33 @@ function activateRootRuntime(
1152
1180
 
1153
1181
  // --- Cross-extension RPC via pi.events ---
1154
1182
  let currentCtx: ExtensionContext | undefined;
1183
+ // A session ID alone cannot distinguish a branch change or a resume of the
1184
+ // same session. Invalidate pending off-screen mention turns only when the
1185
+ // replacement commits: before-switch/tree handlers can still cancel it.
1186
+ let sessionGeneration = 0;
1187
+ // Public synchronous bus preflight works in either extension load order. A
1188
+ // filtered-out activation has no bound context and must not veto navigation.
1189
+ pi.events.on("pi:navigation-preflight", (raw: unknown) => {
1190
+ if (!currentCtx || !manager.hasUnsettledWork()) return;
1191
+ const report = (raw as { report?: unknown } | null)?.report;
1192
+ if (typeof report === "function") report("subagents");
1193
+ });
1194
+ const navigationPreflight = (
1195
+ ctx: ExtensionContext,
1196
+ ): { cancel: true } | undefined => {
1197
+ let busy = false;
1198
+ pi.events.emit("pi:navigation-preflight", {
1199
+ report: () => {
1200
+ busy = true;
1201
+ },
1202
+ });
1203
+ if (!busy) return undefined;
1204
+ const notice =
1205
+ "Navigation blocked while agents or workflows are active or cleaning up. Wait for them to finish, or stop them explicitly and retry.";
1206
+ if (ctx.hasUI) ctx.ui.notify(notice, "warning");
1207
+ else console.warn(`[pi-subagents] ${notice}`);
1208
+ return { cancel: true };
1209
+ };
1155
1210
  // RPC handlers + the `subagents:ready` broadcast are wired on `session_start`
1156
1211
  // (a bound lifecycle event), not at factory time. pi runs every extension
1157
1212
  // factory before the `extensions:` filter and only fires lifecycle events for
@@ -1192,6 +1247,14 @@ function activateRootRuntime(
1192
1247
  event: { reason?: string },
1193
1248
  ctx: ExtensionContext,
1194
1249
  ): Promise<void> {
1250
+ // A successful session_start is the commit point for a reused runtime. A
1251
+ // cancelled session_before_switch leaves the old context and scheduler live.
1252
+ if (currentCtx) {
1253
+ scheduler.stop();
1254
+ manager.detachForBranchChange();
1255
+ manager.resetManagedSpawns();
1256
+ }
1257
+ sessionGeneration++;
1195
1258
  sessionCwd = ctx.cwd;
1196
1259
  boundSessionId = ctx.sessionManager?.getSessionId?.();
1197
1260
  currentCtx = ctx;
@@ -1233,7 +1296,8 @@ function activateRootRuntime(
1233
1296
  abortOwned: (id, owner) => manager.abortOwned(id, owner),
1234
1297
  quiesceOwned: (runId, agentIds, timeoutMs, owners) =>
1235
1298
  manager.quiesceOwned(runId, agentIds, timeoutMs, owners),
1236
- reconcileManaged: (spawnKey, owner) => manager.reconcileManaged(spawnKey, owner),
1299
+ reconcileManaged: (spawnKey, owner) =>
1300
+ manager.reconcileManaged(spawnKey, owner),
1237
1301
  // The live pool size, not a snapshot: `/subagents` can change it
1238
1302
  // mid-session, and a peer that sizes its fan-out from this must see
1239
1303
  // the value that is actually throttling it now.
@@ -1297,38 +1361,32 @@ function activateRootRuntime(
1297
1361
  startScheduler(ctx);
1298
1362
  }
1299
1363
 
1300
- pi.on("session_before_switch", async () => {
1301
- currentCtx = undefined;
1302
- scheduler.stop();
1303
- const quiesced = await manager.quiesceAll(5_000);
1304
- if (!quiesced.settled) manager.detachForBranchChange();
1305
- manager.clearCompleted(true);
1306
- manager.resetManagedSpawns();
1307
- });
1308
-
1309
- // Tree navigation keeps the same root session but replaces its active branch.
1310
- // Stop-and-wait happens before lifecycle suspension so workflow consumers can
1311
- // journal terminal callbacks on the old branch. A timeout is conservative:
1312
- // records are detached on session_tree and late completions cannot write into
1313
- // the replacement branch.
1314
- pi.on("session_before_tree", async () => {
1315
- currentCtx = undefined;
1316
- scheduler.stop();
1317
- pi.events.emit("subagents:session_before_tree", {});
1318
- const quiesced = await manager.quiesceAll(5_000);
1319
- if (!quiesced.settled) {
1320
- // waitForTerminalRecords quarantines timed-out records, and this second
1321
- // guard covers handler-order races where tree preparation is delivered
1322
- // after an abort but before the provider promise has settled.
1323
- manager.detachForBranchChange();
1324
- // The manager retains pending records and recovery state; avoid writing
1325
- // directly to the teardown event loop.
1326
- }
1364
+ // Both before-events are cancellable. A later handler may veto, and tree
1365
+ // summarization can still abort after every handler has returned. Never stop
1366
+ // live work on an attempted navigation.
1367
+ pi.on("session_before_switch", (_event, ctx) => navigationPreflight(ctx));
1368
+ pi.on("session_before_tree", (_event, ctx) => navigationPreflight(ctx));
1369
+ let detachedTreeEvent: unknown;
1370
+ const detachCommittedTree = (event: unknown): void => {
1371
+ if (!currentCtx || detachedTreeEvent === event) return;
1372
+ detachedTreeEvent = event;
1373
+ manager.detachForBranchChange();
1374
+ };
1375
+ // If workflows' committed-tree handler runs first, it must detach our old
1376
+ // records before aborting its controller: abort listeners can synchronously
1377
+ // issue stop-owned RPC and otherwise append a managed tombstone on the new leaf.
1378
+ pi.events.on("pi-workflows:session_tree_committed", (raw: unknown) => {
1379
+ const event = (raw as { event?: unknown } | null)?.event;
1380
+ if (event) detachCommittedTree(event);
1327
1381
  });
1328
- pi.on("session_tree", (_event, ctx) => {
1382
+ pi.on("session_tree", (event, ctx) => {
1383
+ sessionGeneration++;
1384
+ detachCommittedTree(event);
1385
+ // Pi has already changed the leaf. Fence workflow listeners synchronously
1386
+ // even when subagents' session_tree handler runs first.
1387
+ pi.events.emit("subagents:session_tree_committed", { event });
1329
1388
  currentCtx = ctx;
1330
- manager.abortAll();
1331
- manager.detachForBranchChange();
1389
+ scheduler.stop();
1332
1390
  manager.clearCompleted(true);
1333
1391
  manager.resetManagedSpawns();
1334
1392
  const entries =
@@ -1350,6 +1408,52 @@ function activateRootRuntime(
1350
1408
  let runtimeShutdown: Promise<void> | undefined;
1351
1409
  const shutdownRuntime = (): Promise<void> => {
1352
1410
  runtimeShutdown ??= (async () => {
1411
+ // session_shutdown is confirmed, unlike either before-event. Start the
1412
+ // workflow's owned-agent cleanup while its RPC responder and the old leaf
1413
+ // are still available, regardless of which extension loads first.
1414
+ const pending: Array<
1415
+ Promise<{ settled: boolean; pending: string[]; diagnostic?: string }>
1416
+ > = [];
1417
+ pi.events.emit("pi-workflows:shutdown-quiesce", {
1418
+ respond: (
1419
+ operation: Promise<{
1420
+ settled: boolean;
1421
+ pending: string[];
1422
+ diagnostic?: string;
1423
+ }>,
1424
+ ) => {
1425
+ pending.push(operation);
1426
+ },
1427
+ });
1428
+ scheduler.stop();
1429
+ currentCtx = undefined; // reject new spawns; owned cleanup RPC remains bound
1430
+ if (pending.length > 0) {
1431
+ let timer: ReturnType<typeof setTimeout> | undefined;
1432
+ const results = await Promise.race([
1433
+ Promise.allSettled(pending),
1434
+ new Promise<undefined>((resolve) => {
1435
+ timer = setTimeout(() => resolve(undefined), 6_000);
1436
+ }),
1437
+ ]);
1438
+ if (timer) clearTimeout(timer);
1439
+ if (
1440
+ !results ||
1441
+ results.some(
1442
+ (result) => result.status === "rejected" || !result.value.settled,
1443
+ )
1444
+ ) {
1445
+ console.warn(
1446
+ "[pi-subagents] workflow shutdown quiescence timed out or failed; pending work will be quarantined",
1447
+ );
1448
+ }
1449
+ }
1450
+ const quiesced = await manager.quiesceAll(5_000);
1451
+ if (!quiesced.settled) {
1452
+ console.warn(
1453
+ `[pi-subagents] shutdown quiescence timed out; quarantined: ${quiesced.pending.join(", ")}`,
1454
+ );
1455
+ }
1456
+ sessionGeneration++;
1353
1457
  unsubscribeRootContext();
1354
1458
  rpcHandle?.unsubSpawn();
1355
1459
  rpcHandle?.unsubSpawnManaged();
@@ -1474,7 +1578,11 @@ function activateRootRuntime(
1474
1578
  const effectiveTier = (() => {
1475
1579
  try {
1476
1580
  return selectAgentTier(
1477
- { requestedTier: request.tier, requireTier: true, agentConfig: customConfig },
1581
+ {
1582
+ requestedTier: request.tier,
1583
+ requireTier: true,
1584
+ agentConfig: customConfig,
1585
+ },
1478
1586
  agentTiers,
1479
1587
  )?.tier;
1480
1588
  } catch {
@@ -1496,7 +1604,12 @@ function activateRootRuntime(
1496
1604
  resolvedConfig.maxTurns ?? getDefaultMaxTurns(),
1497
1605
  );
1498
1606
  const effectiveIsolation = request.isolation ?? resolvedConfig.isolation;
1499
- const effectiveExcludeTools = [...new Set([...(customConfig?.disallowedTools ?? []), ...(request.excludeTools ?? [])])];
1607
+ const effectiveExcludeTools = [
1608
+ ...new Set([
1609
+ ...(customConfig?.disallowedTools ?? []),
1610
+ ...(request.excludeTools ?? []),
1611
+ ]),
1612
+ ];
1500
1613
  const managedPolicy: ManagedSpawnPolicy = {
1501
1614
  maxTurns: effectiveMaxTurns,
1502
1615
  isolated: resolvedConfig.isolated,
@@ -1963,18 +2076,25 @@ function activateRootRuntime(
1963
2076
  }
1964
2077
 
1965
2078
  if (record.session) {
1966
- const resumed = await manager.resume(
1967
- record.id,
1968
- mention.message,
1969
- undefined,
1970
- { isBackground: true },
1971
- );
1972
- ctx.ui.notify(
1973
- resumed
1974
- ? `Resuming ${target}`
1975
- : `Could not resume ${target} — it is still running.`,
1976
- resumed ? "info" : "warning",
1977
- );
2079
+ try {
2080
+ const resumed = await manager.resume(
2081
+ record.id,
2082
+ mention.message,
2083
+ undefined,
2084
+ { isBackground: true },
2085
+ );
2086
+ ctx.ui.notify(
2087
+ resumed
2088
+ ? `Resuming ${target}`
2089
+ : `Could not resume ${target} — it is still running.`,
2090
+ resumed ? "info" : "warning",
2091
+ );
2092
+ } catch (error) {
2093
+ ctx.ui.notify(
2094
+ `Could not resume ${target}: ${error instanceof Error ? error.message : String(error)}`,
2095
+ "warning",
2096
+ );
2097
+ }
1978
2098
  return { action: "handled" };
1979
2099
  }
1980
2100
  // A live record with no session never got far enough to continue, so it
@@ -2053,37 +2173,82 @@ function activateRootRuntime(
2053
2173
  if (!type) return { action: "continue" };
2054
2174
 
2055
2175
  const label = `@${handleBase(type)}`;
2176
+ const originGeneration = sessionGeneration;
2177
+ const originSessionId = ctx.sessionManager.getSessionId();
2178
+ const isOriginCurrent = (): boolean =>
2179
+ currentCtx !== undefined &&
2180
+ sessionGeneration === originGeneration &&
2181
+ boundSessionId === originSessionId &&
2182
+ ctx.sessionManager.getSessionId() === originSessionId;
2056
2183
  const startDirectly = (): void => {
2057
- spawnMention(ctx, type, mention.message, {
2184
+ // The hidden model turn may outlive a change to agent files or settings.
2185
+ // Revalidate the handle rather than bypassing Agent's dispatch policy
2186
+ // with a type that has since been disabled or removed.
2187
+ reloadCustomAgents();
2188
+ const current = resolveSpawnType(type);
2189
+ if (
2190
+ !current.ok ||
2191
+ current.fellBackFrom !== undefined ||
2192
+ current.type !== type
2193
+ ) {
2194
+ throw new Error(`Agent type "${type}" is no longer available`);
2195
+ }
2196
+ spawnMention(ctx, current.type, mention.message, {
2058
2197
  description: describeMention(mention.message),
2059
2198
  });
2060
2199
  };
2061
2200
 
2062
- // In `model` mode the turn is taken by an off-screen clone of this
2063
- // conversation, so the agent is started with a prompt written from context
2064
- // rather than from the words after the handle alone. Nothing reaches the
2065
- // chat, and what it starts is an ordinary top-level agent.
2201
+ // In `model` mode an off-screen mention-only turn rewrites the typed task
2202
+ // without copying parent history or its request-local context hooks.
2203
+ // Nothing reaches the chat, and it starts an ordinary top-level agent.
2066
2204
  const registeredAgentTool = agentToolRef;
2067
2205
  if (getAgentMentionMode() === "model" && registeredAgentTool) {
2206
+ // The hidden turn is itself a provider call. Refuse before saying
2207
+ // "Starting" or spending that call when its inherited parent model is
2208
+ // unavailable. A shape-only check would let the hidden clone refuse, then
2209
+ // let its direct fallback allocate an ID and announce an agent that fails.
2210
+ try {
2211
+ parentModelSessionOptions(ctx, ctx.model);
2212
+ } catch (err) {
2213
+ ctx.ui.notify(
2214
+ `Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`,
2215
+ "error",
2216
+ );
2217
+ return { action: "handled" };
2218
+ }
2068
2219
  ctx.ui.notify(`Starting ${label}…`, "info");
2069
2220
  // Not awaited: the clone runs a full model turn and `prompt()` is blocked
2070
2221
  // until this hook returns. The user gets their prompt back immediately.
2071
- void runMentionClone({ ctx, type, message: mention.message, agentTool: registeredAgentTool }).then(
2072
- (result) => {
2073
- if (result.spawned) return;
2074
- // A clone that could not run must not swallow the mention: start the
2075
- // agent the direct way rather than leaving a toast and nothing running.
2076
- try {
2077
- startDirectly();
2078
- ctx.ui.notify(`Started ${label} directly — ${result.error}`, "warning");
2079
- } catch (err) {
2080
- ctx.ui.notify(
2081
- `Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`,
2082
- "error",
2083
- );
2084
- }
2085
- },
2086
- );
2222
+ void runMentionClone({
2223
+ ctx,
2224
+ type,
2225
+ message: mention.message,
2226
+ agentTool: registeredAgentTool,
2227
+ isOriginCurrent,
2228
+ }).then((result) => {
2229
+ if (result.spawned || !isOriginCurrent()) return;
2230
+ if (result.refused) {
2231
+ ctx.ui.notify(
2232
+ `Could not start ${label} — ${result.error}`,
2233
+ "warning",
2234
+ );
2235
+ return;
2236
+ }
2237
+ // A clone that could not run must not swallow the mention: start the
2238
+ // agent the direct way rather than leaving a toast and nothing running.
2239
+ try {
2240
+ startDirectly();
2241
+ ctx.ui.notify(
2242
+ `Started ${label} directly — ${result.error}`,
2243
+ "warning",
2244
+ );
2245
+ } catch (err) {
2246
+ ctx.ui.notify(
2247
+ `Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`,
2248
+ "error",
2249
+ );
2250
+ }
2251
+ });
2087
2252
  return { action: "handled" };
2088
2253
  }
2089
2254
 
@@ -2146,7 +2311,8 @@ Notes:
2146
2311
  - Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
2147
2312
  - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
2148
2313
  - resume continues a previous agent by ID; steer_subagent messages a running or queued one.
2149
- - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
2314
+ - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.
2315
+ - Raw inherit_context is unavailable; put only an explicitly sanitized summary in the task prompt.`;
2150
2316
 
2151
2317
  const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
2152
2318
 
@@ -2174,7 +2340,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
2174
2340
  - Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
2175
2341
  - If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
2176
2342
  - Use tier to pick the model profile for this spawn, by name. A tier overrides the agent's own default tier. Model and thinking are not callable parameters — they are what a tier resolves to.
2177
- - Use inherit_context if the agent needs the parent conversation history.
2343
+ - Raw inherit_context is unavailable: put only an explicitly sanitized summary in the task prompt, or persist a context_edit before starting a new agent.
2178
2344
  - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications). The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.${scheduleGuideline}
2179
2345
 
2180
2346
  ## Writing the prompt
@@ -2253,787 +2419,800 @@ Terse command-style prompts produce shallow, generic work.
2253
2419
  // its handler closes over this activation, which is what makes a clone-driven
2254
2420
  // spawn an ordinary top-level agent rather than something the fork owns.
2255
2421
  const agentTool = defineTool({
2256
- name: SUBAGENT_TOOL_NAMES.AGENT,
2257
- label: "Agent",
2258
- description: agentToolDescription,
2259
- promptSnippet:
2260
- "Launch autonomous sub-agents for complex multi-step tasks",
2261
- promptGuidelines: [
2262
- "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
2263
- "For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
2264
- "When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
2265
- "Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
2266
- ],
2267
- parameters: Type.Object({
2268
- prompt: Type.String({
2269
- description: "The task for the agent to perform.",
2422
+ name: SUBAGENT_TOOL_NAMES.AGENT,
2423
+ label: "Agent",
2424
+ description: agentToolDescription,
2425
+ promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
2426
+ promptGuidelines: [
2427
+ "Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
2428
+ "For broad codebase exploration or research, spawn Agent with an appropriate subagent_type (e.g. Explore). Otherwise use direct tools (read, grep, find) when the target is already known.",
2429
+ "When an agent runs in the background, you will be notified on completion — do not poll or sleep waiting for it. Continue with other work instead.",
2430
+ "Trust but verify: an agent's summary describes intent, not outcome. When an agent writes or edits code, check the actual changes before reporting work as done.",
2431
+ ],
2432
+ parameters: Type.Object({
2433
+ prompt: Type.String({
2434
+ description: "The task for the agent to perform.",
2435
+ }),
2436
+ description: Type.String({
2437
+ description:
2438
+ "A short (3-5 word) description of the task (shown in UI).",
2439
+ }),
2440
+ subagent_type: Type.String({
2441
+ description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`,
2442
+ }),
2443
+ tier: Type.Optional(
2444
+ Type.String({
2445
+ description: buildAgentTierParameterDescription(),
2270
2446
  }),
2271
- description: Type.String({
2447
+ ),
2448
+ max_turns: Type.Optional(
2449
+ Type.Number({
2272
2450
  description:
2273
- "A short (3-5 word) description of the task (shown in UI).",
2451
+ "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
2452
+ minimum: 1,
2274
2453
  }),
2275
- subagent_type: Type.String({
2276
- description: `The type of specialized agent to use. Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`,
2454
+ ),
2455
+ run_in_background: Type.Optional(
2456
+ Type.Boolean({
2457
+ description:
2458
+ "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
2277
2459
  }),
2278
- tier: Type.Optional(
2279
- Type.String({
2280
- description: buildAgentTierParameterDescription(),
2281
- }),
2282
- ),
2283
- max_turns: Type.Optional(
2284
- Type.Number({
2285
- description:
2286
- "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
2287
- minimum: 1,
2288
- }),
2289
- ),
2290
- run_in_background: Type.Optional(
2291
- Type.Boolean({
2292
- description:
2293
- "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
2294
- }),
2295
- ),
2296
- resume: Type.Optional(
2297
- Type.String({
2298
- description:
2299
- "Optional agent ID to resume from. Continues from previous context.",
2300
- }),
2301
- ),
2302
- isolated: Type.Optional(
2303
- Type.Boolean({
2304
- description:
2305
- "If true, agent gets no extension/MCP tools — only built-in tools.",
2306
- }),
2307
- ),
2308
- inherit_context: Type.Optional(
2309
- Type.Boolean({
2460
+ ),
2461
+ resume: Type.Optional(
2462
+ Type.String({
2463
+ description:
2464
+ "Optional agent ID to resume from. Continues from previous context.",
2465
+ }),
2466
+ ),
2467
+ isolated: Type.Optional(
2468
+ Type.Boolean({
2469
+ description:
2470
+ "If true, agent gets no extension/MCP tools — only built-in tools.",
2471
+ }),
2472
+ ),
2473
+ inherit_context: Type.Optional(
2474
+ Type.Boolean({
2475
+ description:
2476
+ "Unavailable on current Pi hosts: true is rejected before child dispatch. Omit or set false; provide an explicitly sanitized summary in the task prompt instead.",
2477
+ }),
2478
+ ),
2479
+ isolation: Type.Optional(
2480
+ Type.Union(
2481
+ [
2482
+ Type.Literal("worktree", {
2483
+ description:
2484
+ "Run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.",
2485
+ }),
2486
+ Type.Literal("off", {
2487
+ description:
2488
+ "Explicitly disable worktree isolation for this agent.",
2489
+ }),
2490
+ ],
2491
+ {
2310
2492
  description:
2311
- "If true, fork parent conversation into the agent. Default: false (fresh context).",
2312
- }),
2313
- ),
2314
- isolation: Type.Optional(
2315
- Type.Union(
2316
- [
2317
- Type.Literal("worktree", {
2318
- description:
2319
- 'Run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.',
2320
- }),
2321
- Type.Literal("off", {
2322
- description: 'Explicitly disable worktree isolation for this agent.',
2323
- }),
2324
- ],
2325
- {
2326
- description: 'Isolation mode: "worktree" for isolated git worktree, "off" to explicitly disable.',
2327
- },
2328
- ),
2493
+ 'Isolation mode: "worktree" for isolated git worktree, "off" to explicitly disable.',
2494
+ },
2329
2495
  ),
2330
- ...scheduleParam,
2331
- }),
2332
-
2333
- // ---- Custom rendering: Claude Code style ----
2334
-
2335
- renderCall(args, theme) {
2336
- const displayName = args.subagent_type
2337
- ? getDisplayName(args.subagent_type)
2338
- : "Agent";
2339
- const desc = sanitizeDisplayText(args.description ?? "");
2340
- const text = [
2341
- theme.fg("toolTitle", theme.bold(displayName)),
2342
- desc ? theme.fg("muted", desc) : undefined,
2343
- ]
2344
- .filter((part): part is string => part !== undefined)
2345
- .join(theme.fg("dim", " · "));
2346
- return new Text(text, 0, 0);
2347
- },
2348
-
2349
- renderResult(result, { expanded, isPartial }, theme, renderContext) {
2350
- // Everything below draws child-derived text into the parent transcript
2351
- // through pi-tui's ANSI-preserving renderer, so it is scrubbed on the way in.
2352
- const text = safeTerminalText(
2353
- result.content[0]?.type === "text" ? result.content[0].text : "",
2354
- );
2355
- const details = result.details as AgentDetails | undefined;
2356
- // Pre-execution failures have no agent status. Preserve pi's error text
2357
- // instead of rendering them as an invented subagent failure.
2358
- if (renderContext?.isError || !details?.status) {
2359
- return new Text(text, 0, 0);
2360
- }
2496
+ ),
2497
+ ...scheduleParam,
2498
+ }),
2361
2499
 
2362
- const stats = (d: AgentDetails) => {
2363
- const parts: string[] = [];
2364
- if (d.modelName) parts.push(d.modelName);
2365
- if (d.tags) parts.push(...d.tags);
2366
- if (d.turnCount != null && d.turnCount > 0) {
2367
- parts.push(formatTurns(d.turnCount, d.maxTurns));
2368
- }
2369
- if (d.toolUses > 0) parts.push(`tools ${d.toolUses}`);
2370
- if (d.tokens) parts.push(d.tokens);
2371
- return parts
2372
- .map((p) => fgPreservingNestedStyles(theme, "dim", p))
2373
- .join(theme.fg("dim", " · "));
2374
- };
2500
+ // ---- Custom rendering: Claude Code style ----
2501
+
2502
+ renderCall(args, theme) {
2503
+ const displayName = args.subagent_type
2504
+ ? getDisplayName(args.subagent_type)
2505
+ : "Agent";
2506
+ const desc = sanitizeDisplayText(args.description ?? "");
2507
+ const text = [
2508
+ theme.fg("toolTitle", theme.bold(displayName)),
2509
+ desc ? theme.fg("muted", desc) : undefined,
2510
+ ]
2511
+ .filter((part): part is string => part !== undefined)
2512
+ .join(theme.fg("dim", " · "));
2513
+ return new Text(text, 0, 0);
2514
+ },
2375
2515
 
2376
- if (details.status === "queued") {
2377
- const id = details.agentId ? ` · ID ${details.agentId}` : "";
2378
- return new Text(
2379
- theme.fg(
2380
- "dim",
2381
- `${getAgentStatusMark("queued")} queued · background${id}`,
2382
- ),
2383
- 0,
2384
- 0,
2385
- );
2386
- }
2516
+ renderResult(result, { expanded, isPartial }, theme, renderContext) {
2517
+ // Everything below draws child-derived text into the parent transcript
2518
+ // through pi-tui's ANSI-preserving renderer, so it is scrubbed on the way in.
2519
+ const text = safeTerminalText(
2520
+ result.content[0]?.type === "text" ? result.content[0].text : "",
2521
+ );
2522
+ const details = result.details as AgentDetails | undefined;
2523
+ // Pre-execution failures have no agent status. Preserve pi's error text
2524
+ // instead of rendering them as an invented subagent failure.
2525
+ if (renderContext?.isError || !details?.status) {
2526
+ return new Text(text, 0, 0);
2527
+ }
2387
2528
 
2388
- if (isPartial || details.status === "running") {
2389
- const frame = SPINNER[details.spinnerFrame ?? 0] ?? SPINNER[0]!;
2390
- const s = stats(details);
2391
- return renderRunningAgentStatus(
2392
- frame,
2393
- s,
2394
- details.activity ?? "Thinking...",
2395
- theme,
2396
- );
2529
+ const stats = (d: AgentDetails) => {
2530
+ const parts: string[] = [];
2531
+ if (d.modelName) parts.push(d.modelName);
2532
+ if (d.tags) parts.push(...d.tags);
2533
+ if (d.turnCount != null && d.turnCount > 0) {
2534
+ parts.push(formatTurns(d.turnCount, d.maxTurns));
2397
2535
  }
2536
+ if (d.toolUses > 0) parts.push(`tools ${d.toolUses}`);
2537
+ if (d.tokens) parts.push(d.tokens);
2538
+ return parts
2539
+ .map((p) => fgPreservingNestedStyles(theme, "dim", p))
2540
+ .join(theme.fg("dim", " · "));
2541
+ };
2398
2542
 
2399
- if (details.status === "background") {
2400
- const id = details.agentId ? ` · ID ${details.agentId}` : "";
2401
- return new Text(
2402
- theme.fg(
2403
- "dim",
2404
- `${getAgentStatusMark("running")} running · background${id}`,
2405
- ),
2406
- 0,
2407
- 0,
2408
- );
2409
- }
2543
+ if (details.status === "queued") {
2544
+ const id = details.agentId ? ` · ID ${details.agentId}` : "";
2545
+ return new Text(
2546
+ theme.fg(
2547
+ "dim",
2548
+ `${getAgentStatusMark("queued")} queued · background${id}`,
2549
+ ),
2550
+ 0,
2551
+ 0,
2552
+ );
2553
+ }
2410
2554
 
2411
- if (details.status === "completed" || details.status === "steered") {
2412
- const duration = formatMs(details.durationMs);
2413
- const isSteered = details.status === "steered";
2414
- const statusText = isSteered
2415
- ? "wrapped up · turn limit"
2416
- : "completed";
2417
- const statusColor = isSteered ? "warning" : "dim";
2418
- const s = stats(details);
2419
- let line = theme.fg(
2420
- statusColor,
2421
- `${getAgentStatusMark(details.status)} ${statusText}`,
2422
- );
2423
- if (s) line += theme.fg("dim", " · ") + s;
2424
- line += theme.fg("dim", " · ") + theme.fg("dim", duration);
2425
-
2426
- if (expanded) {
2427
- if (text) {
2428
- const lines = text.split("\n").slice(0, 50);
2429
- for (const l of lines) {
2430
- line += "\n" + theme.fg("dim", ` ${l}`);
2431
- }
2432
- if (text.split("\n").length > 50) {
2433
- line +=
2434
- "\n" +
2435
- theme.fg(
2436
- "muted",
2437
- " ... (use get_subagent_result with verbose for full output)",
2438
- );
2439
- }
2440
- }
2441
- } else {
2442
- const doneText = isSteered
2443
- ? "Wrapped up at the turn limit"
2444
- : "Done";
2445
- line += "\n" + theme.fg("dim", ` ${doneText}`);
2446
- }
2447
- return new Text(line, 0, 0);
2448
- }
2555
+ if (isPartial || details.status === "running") {
2556
+ const frame = SPINNER[details.spinnerFrame ?? 0] ?? SPINNER[0]!;
2557
+ const s = stats(details);
2558
+ return renderRunningAgentStatus(
2559
+ frame,
2560
+ s,
2561
+ details.activity ?? "Thinking...",
2562
+ theme,
2563
+ );
2564
+ }
2449
2565
 
2450
- if (details.status === "stopped") {
2451
- const s = stats(details);
2452
- let line = theme.fg(
2566
+ if (details.status === "background") {
2567
+ const id = details.agentId ? ` · ID ${details.agentId}` : "";
2568
+ return new Text(
2569
+ theme.fg(
2453
2570
  "dim",
2454
- `${getAgentStatusMark("stopped")} stopped`,
2455
- );
2456
- if (s) line += theme.fg("dim", " · ") + s;
2457
- line += "\n" + theme.fg("dim", " Stopped before completion");
2458
- return new Text(line, 0, 0);
2459
- }
2460
-
2461
- // Keep unknown/future statuses from falling through to the turn-limit
2462
- // renderer, which is only valid for explicit error/aborted outcomes.
2463
- if (details.status !== "error" && details.status !== "aborted") {
2464
- return new Text(text, 0, 0);
2465
- }
2571
+ `${getAgentStatusMark("running")} running · background${id}`,
2572
+ ),
2573
+ 0,
2574
+ 0,
2575
+ );
2576
+ }
2466
2577
 
2578
+ if (details.status === "completed" || details.status === "steered") {
2579
+ const duration = formatMs(details.durationMs);
2580
+ const isSteered = details.status === "steered";
2581
+ const statusText = isSteered ? "wrapped up · turn limit" : "completed";
2582
+ const statusColor = isSteered ? "warning" : "dim";
2467
2583
  const s = stats(details);
2468
- const isError = details.status === "error";
2469
2584
  let line = theme.fg(
2470
- isError ? "error" : "warning",
2471
- `${getAgentStatusMark(details.status)} ${isError ? "failed" : "aborted"}`,
2585
+ statusColor,
2586
+ `${getAgentStatusMark(details.status)} ${statusText}`,
2472
2587
  );
2473
2588
  if (s) line += theme.fg("dim", " · ") + s;
2589
+ line += theme.fg("dim", " · ") + theme.fg("dim", duration);
2474
2590
 
2475
- if (isError) {
2476
- line +=
2477
- "\n" +
2478
- theme.fg(
2479
- "error",
2480
- ` Error: ${sanitizeDisplayText(details.error ?? "unknown")}`,
2481
- );
2591
+ if (expanded) {
2592
+ if (text) {
2593
+ const lines = text.split("\n").slice(0, 50);
2594
+ for (const l of lines) {
2595
+ line += "\n" + theme.fg("dim", ` ${l}`);
2596
+ }
2597
+ if (text.split("\n").length > 50) {
2598
+ line +=
2599
+ "\n" +
2600
+ theme.fg(
2601
+ "muted",
2602
+ " ... (use get_subagent_result with verbose for full output)",
2603
+ );
2604
+ }
2605
+ }
2482
2606
  } else {
2483
- line += "\n" + theme.fg("warning", " Aborted at the turn limit");
2607
+ const doneText = isSteered ? "Wrapped up at the turn limit" : "Done";
2608
+ line += "\n" + theme.fg("dim", ` ${doneText}`);
2484
2609
  }
2610
+ return new Text(line, 0, 0);
2611
+ }
2485
2612
 
2613
+ if (details.status === "stopped") {
2614
+ const s = stats(details);
2615
+ let line = theme.fg("dim", `${getAgentStatusMark("stopped")} stopped`);
2616
+ if (s) line += theme.fg("dim", " · ") + s;
2617
+ line += "\n" + theme.fg("dim", " Stopped before completion");
2486
2618
  return new Text(line, 0, 0);
2487
- },
2619
+ }
2620
+
2621
+ // Keep unknown/future statuses from falling through to the turn-limit
2622
+ // renderer, which is only valid for explicit error/aborted outcomes.
2623
+ if (details.status !== "error" && details.status !== "aborted") {
2624
+ return new Text(text, 0, 0);
2625
+ }
2488
2626
 
2489
- // ---- Execute ----
2627
+ const s = stats(details);
2628
+ const isError = details.status === "error";
2629
+ let line = theme.fg(
2630
+ isError ? "error" : "warning",
2631
+ `${getAgentStatusMark(details.status)} ${isError ? "failed" : "aborted"}`,
2632
+ );
2633
+ if (s) line += theme.fg("dim", " · ") + s;
2490
2634
 
2491
- execute: async (toolCallId, params, signal, onUpdate, ctx) => {
2492
- // Ensure we have UI context for the FleetView list
2493
- seatFleet(ctx);
2635
+ if (isError) {
2636
+ line +=
2637
+ "\n" +
2638
+ theme.fg(
2639
+ "error",
2640
+ ` Error: ${sanitizeDisplayText(details.error ?? "unknown")}`,
2641
+ );
2642
+ } else {
2643
+ line += "\n" + theme.fg("warning", " Aborted at the turn limit");
2644
+ }
2494
2645
 
2495
- // Reload custom agents so new project/global .md files are picked up without restart
2496
- reloadCustomAgents();
2646
+ return new Text(line, 0, 0);
2647
+ },
2497
2648
 
2498
- const rawType = params.subagent_type as SubagentType;
2499
- // Single decision point for dispatch (#183): unknown, disabled and
2500
- // case-ambiguous types are refused here, BEFORE anything spawns, so a
2501
- // background or scheduled call can't start running the wrong agent while
2502
- // the caller is still unaware. `fallbackSubagent` decides whether an
2503
- // unresolvable type falls back or fails closed.
2504
- const dispatch = resolveSpawnType(rawType);
2505
- // `resume` replays a stored session and ignores `subagent_type` entirely,
2506
- // but the parameter is required by the schema — so gating it here would
2507
- // make a live agent unresumable the moment its type is deleted, disabled,
2508
- // or gains a case-clashing sibling. Only a real spawn is gated.
2509
- if (!dispatch.ok && !params.resume) return textResult(dispatch.message);
2510
- const subagentType = dispatch.ok ? dispatch.type : rawType;
2511
- // What the caller actually asked for, named once: `fellBackFrom` is "" for
2512
- // a blank request, so reading it inline invites the `??`-vs-`||` slip that
2513
- // once persisted an empty type into a scheduled job.
2514
- const requestedType =
2515
- (dispatch.ok && dispatch.fellBackFrom) || subagentType;
2516
- // Computed at resolution rather than after the run, so the background and
2517
- // schedule branches carry it too — previously it existed only on the
2518
- // foreground path. Resume deliberately doesn't: it replays the stored
2519
- // session and ignores `subagent_type` entirely, so a note about type
2520
- // substitution would be describing something that didn't happen.
2521
- const fallbackNote =
2522
- dispatch.ok && dispatch.fellBackFrom !== undefined
2523
- ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
2524
- : "";
2525
-
2526
- const displayName = getDisplayName(subagentType);
2527
-
2528
- // Get agent config (if any)
2529
- const customConfig = getAgentConfig(subagentType);
2530
-
2531
- const resolvedConfig = resolveAgentInvocationConfig(customConfig, {
2532
- ...params,
2533
- agentTiers: getAgentTiersSettings(),
2534
- });
2649
+ // ---- Execute ----
2535
2650
 
2536
- // A selected Agent tier owns final model/thinking resolution. Keep both
2537
- // fields unset here so runAgent is the only resolver and ordinary Agent
2538
- // calls cannot accidentally bypass the profile with a parent/default pin.
2539
- let model = resolvedConfig.agentTierSelected
2540
- ? undefined
2541
- : resolveConfiguredDefaultModel(ctx.modelRegistry) ?? ctx.model;
2542
- if (!resolvedConfig.agentTierSelected && resolvedConfig.modelInput) {
2543
- const resolved = resolveModel(
2544
- resolvedConfig.modelInput,
2545
- ctx.modelRegistry,
2546
- );
2547
- if (typeof resolved === "string") {
2548
- if (resolvedConfig.modelFromParams) return textResult(resolved);
2549
- // config-specified: silent fallback to the default model, then parent
2550
- } else {
2551
- model = resolved;
2552
- }
2553
- }
2651
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
2652
+ // Ensure we have UI context for the FleetView list
2653
+ seatFleet(ctx);
2554
2654
 
2555
- // Tiered model scope is checked after the single final resolution in
2556
- // runAgent. Only the legacy no-tier path is checked here.
2557
- if (!resolvedConfig.agentTierSelected) {
2558
- const scopeVerdict = checkModelScope({
2559
- model,
2560
- cwd: ctx.cwd,
2561
- modelRegistry: ctx.modelRegistry,
2562
- callerSupplied: resolvedConfig.modelFromParams,
2563
- agentLabel: customConfig?.displayName ?? subagentType,
2564
- modelInput: resolvedConfig.modelInput,
2565
- });
2566
- if (scopeVerdict.kind === "error")
2567
- return textResult(scopeVerdict.message);
2568
- if (scopeVerdict.kind === "warn")
2569
- ctx.ui.notify(scopeVerdict.message, "warning");
2655
+ // Reload custom agents so new project/global .md files are picked up without restart
2656
+ reloadCustomAgents();
2657
+
2658
+ const rawType = params.subagent_type as SubagentType;
2659
+ // Single decision point for dispatch (#183): unknown, disabled and
2660
+ // case-ambiguous types are refused here, BEFORE anything spawns, so a
2661
+ // background or scheduled call can't start running the wrong agent while
2662
+ // the caller is still unaware. `fallbackSubagent` decides whether an
2663
+ // unresolvable type falls back or fails closed.
2664
+ const dispatch = resolveSpawnType(rawType);
2665
+ // `resume` replays a stored session and ignores `subagent_type` entirely,
2666
+ // but the parameter is required by the schema — so gating it here would
2667
+ // make a live agent unresumable the moment its type is deleted, disabled,
2668
+ // or gains a case-clashing sibling. Only a real spawn is gated.
2669
+ if (!dispatch.ok && !params.resume) return textResult(dispatch.message);
2670
+ const subagentType = dispatch.ok ? dispatch.type : rawType;
2671
+ // What the caller actually asked for, named once: `fellBackFrom` is "" for
2672
+ // a blank request, so reading it inline invites the `??`-vs-`||` slip that
2673
+ // once persisted an empty type into a scheduled job.
2674
+ const requestedType =
2675
+ (dispatch.ok && dispatch.fellBackFrom) || subagentType;
2676
+ // Computed at resolution rather than after the run, so the background and
2677
+ // schedule branches carry it too — previously it existed only on the
2678
+ // foreground path. Resume deliberately doesn't: it replays the stored
2679
+ // session and ignores `subagent_type` entirely, so a note about type
2680
+ // substitution would be describing something that didn't happen.
2681
+ const fallbackNote =
2682
+ dispatch.ok && dispatch.fellBackFrom !== undefined
2683
+ ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
2684
+ : "";
2685
+
2686
+ const displayName = getDisplayName(subagentType);
2687
+
2688
+ // Get agent config (if any)
2689
+ const customConfig = getAgentConfig(subagentType);
2690
+
2691
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, {
2692
+ ...params,
2693
+ agentTiers: getAgentTiersSettings(),
2694
+ });
2695
+ if (!params.resume && !params.schedule && resolvedConfig.inheritContext) {
2696
+ return textResult(INHERIT_CONTEXT_UNAVAILABLE);
2697
+ }
2698
+
2699
+ // A selected Agent tier owns final model/thinking resolution. Keep both
2700
+ // fields unset here so runAgent is the only resolver and ordinary Agent
2701
+ // calls cannot accidentally bypass the profile with a parent/default pin.
2702
+ let model = resolvedConfig.agentTierSelected
2703
+ ? undefined
2704
+ : (resolveConfiguredDefaultModel(ctx.modelRegistry) ?? ctx.model);
2705
+ if (!resolvedConfig.agentTierSelected && resolvedConfig.modelInput) {
2706
+ const resolved = resolveModel(
2707
+ resolvedConfig.modelInput,
2708
+ ctx.modelRegistry,
2709
+ );
2710
+ if (typeof resolved === "string") {
2711
+ if (resolvedConfig.modelFromParams) return textResult(resolved);
2712
+ // config-specified: silent fallback to the default model, then parent
2713
+ } else {
2714
+ model = resolved;
2570
2715
  }
2716
+ }
2571
2717
 
2572
- const thinking = resolvedConfig.thinking;
2573
- const inheritContext = resolvedConfig.inheritContext;
2574
- const runInBackground = resolvedConfig.runInBackground;
2575
- const isolated = resolvedConfig.isolated;
2576
- const isolation = resolvedConfig.isolation;
2577
- // Whether this spawn writes its .output transcript. Per-agent
2578
- // frontmatter (`output_transcript`) wins; otherwise the project/global
2579
- // default applies. `attachTranscript` below is the SOLE gate — every
2580
- // downstream consumer keys off record.outputFile being set, so no spawn
2581
- // path can re-enable the transcript by accident.
2582
- const outputTranscript =
2583
- customConfig?.outputTranscript ?? getOutputTranscriptDefault();
2584
- const attachTranscript = (
2585
- rec: AgentRecord | undefined,
2586
- agentId: string,
2587
- ): void => {
2588
- if (!rec || !outputTranscript) return;
2589
- rec.outputFile = createOutputFilePath(
2590
- ctx.cwd,
2591
- agentId,
2592
- ctx.sessionManager.getSessionId(),
2593
- );
2594
- writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
2595
- };
2718
+ // Tiered model scope is checked after the single final resolution in
2719
+ // runAgent. Only the legacy no-tier path is checked here.
2720
+ if (!resolvedConfig.agentTierSelected) {
2721
+ const scopeVerdict = checkModelScope({
2722
+ model,
2723
+ cwd: ctx.cwd,
2724
+ modelRegistry: ctx.modelRegistry,
2725
+ callerSupplied: resolvedConfig.modelFromParams,
2726
+ agentLabel: customConfig?.displayName ?? subagentType,
2727
+ modelInput: resolvedConfig.modelInput,
2728
+ });
2729
+ if (scopeVerdict.kind === "error")
2730
+ return textResult(scopeVerdict.message);
2731
+ if (scopeVerdict.kind === "warn")
2732
+ ctx.ui.notify(scopeVerdict.message, "warning");
2733
+ }
2596
2734
 
2597
- // Untiered spawns only. A tier resolves its model inside runAgent, and
2598
- // its resolution callback supplies the label from there — computing one
2599
- // here would name a model this path never resolved.
2600
- const parentModelId = ctx.model?.id;
2601
- const modelName =
2602
- model && model.id !== parentModelId ? shortModelLabel(model) : undefined;
2603
- const effectiveMaxTurns = normalizeMaxTurns(
2604
- resolvedConfig.maxTurns ?? getDefaultMaxTurns(),
2735
+ const thinking = resolvedConfig.thinking;
2736
+ const inheritContext = resolvedConfig.inheritContext;
2737
+ const runInBackground = resolvedConfig.runInBackground;
2738
+ const isolated = resolvedConfig.isolated;
2739
+ const isolation = resolvedConfig.isolation;
2740
+ // Whether this spawn writes its .output transcript. Per-agent
2741
+ // frontmatter (`output_transcript`) wins; otherwise the project/global
2742
+ // default applies. `attachTranscript` below is the SOLE gate — every
2743
+ // downstream consumer keys off record.outputFile being set, so no spawn
2744
+ // path can re-enable the transcript by accident.
2745
+ const outputTranscript =
2746
+ customConfig?.outputTranscript ?? getOutputTranscriptDefault();
2747
+ const attachTranscript = (
2748
+ rec: AgentRecord | undefined,
2749
+ agentId: string,
2750
+ ): void => {
2751
+ if (!rec || !outputTranscript) return;
2752
+ rec.outputFile = createOutputFilePath(
2753
+ ctx.cwd,
2754
+ agentId,
2755
+ ctx.sessionManager.getSessionId(),
2605
2756
  );
2606
- const agentInvocation: AgentInvocation = {
2607
- modelName,
2608
- ...((resolvedConfig.requestedAgentTier ?? customConfig?.agentTier) === undefined
2609
- ? {}
2610
- : { agentTier: resolvedConfig.requestedAgentTier ?? customConfig?.agentTier }),
2611
- thinking,
2612
- // Explicit value only — the default fallback would just add noise.
2613
- // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
2614
- maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
2615
- isolated,
2616
- inheritContext,
2617
- runInBackground,
2618
- isolation,
2619
- };
2620
- // Tool-result render shows the mode label too; viewer's header already does.
2621
- const modeLabel = getPromptModeLabel(subagentType);
2622
- const { tags: invocationTags } = buildInvocationTags(agentInvocation);
2623
- const agentTags = modeLabel
2624
- ? [modeLabel, ...invocationTags]
2625
- : invocationTags;
2626
- const detailBase = {
2627
- displayName,
2628
- description: params.description,
2629
- subagentType,
2630
- modelName,
2631
- tags: agentTags.length > 0 ? agentTags : undefined,
2632
- };
2757
+ writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
2758
+ };
2633
2759
 
2634
- // ---- Schedule: register a job, don't spawn now ----
2635
- if (params.schedule) {
2636
- if (!isSchedulingEnabled()) {
2637
- return textResult(
2638
- "Scheduling is disabled in this project. Enable via /agents, Settings, Scheduling.",
2639
- );
2640
- }
2641
- if (params.resume) {
2642
- return textResult(
2643
- "Cannot combine `schedule` with `resume` — schedules create fresh agents.",
2644
- );
2645
- }
2646
- if (inheritContext) {
2647
- return textResult(
2648
- "Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.",
2649
- );
2650
- }
2651
- if (params.run_in_background === false) {
2652
- return textResult(
2653
- "Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.",
2654
- );
2655
- }
2656
- if (!scheduler.isActive()) {
2657
- return textResult(
2658
- "Scheduler is not active in this session yet. Try again after the session has fully started.",
2659
- );
2660
- }
2661
- try {
2662
- const job = scheduler.addJob({
2663
- name: params.description as string,
2664
- description: params.description as string,
2665
- schedule: params.schedule as string,
2666
- // The caller's own name, not the substitute — the scheduler re-resolves
2667
- // at fire time, and the original is what a user edits.
2668
- subagent_type: requestedType,
2669
- prompt: params.prompt as string,
2670
- // Only an explicitly requested tier is frozen into the job. An
2671
- // agent's frontmatter tier is deliberately not copied: it is read
2672
- // again at fire time, so editing the agent file still takes
2673
- // effect, and it keeps its "frontmatter" source rather than being
2674
- // replayed as a caller choice.
2675
- tier: resolvedConfig.requestedAgentTier,
2676
- // Store the resolved policy input (agent config first, tool params second)
2677
- // so scheduled fires use the same model fallback as an immediate spawn.
2678
- model: resolvedConfig.agentTierSelected ? undefined : resolvedConfig.modelInput,
2679
- thinking: resolvedConfig.agentTierSelected ? undefined : thinking,
2680
- max_turns: effectiveMaxTurns,
2681
- isolated: isolated,
2682
- isolation: isolation,
2683
- });
2684
- const next = scheduler.getNextRun(job.id);
2685
- return textResult(
2686
- `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
2687
- `Next run: ${next ?? "(unknown)"}. ` +
2688
- `Manage via /agents, Scheduled jobs.`,
2689
- );
2690
- } catch (err) {
2691
- return textResult(err instanceof Error ? err.message : String(err));
2692
- }
2693
- }
2760
+ // Untiered spawns only. A tier resolves its model inside runAgent, and
2761
+ // its resolution callback supplies the label from there — computing one
2762
+ // here would name a model this path never resolved.
2763
+ const parentModelId = ctx.model?.id;
2764
+ const modelName =
2765
+ model && model.id !== parentModelId
2766
+ ? shortModelLabel(model)
2767
+ : undefined;
2768
+ const effectiveMaxTurns = normalizeMaxTurns(
2769
+ resolvedConfig.maxTurns ?? getDefaultMaxTurns(),
2770
+ );
2771
+ const agentInvocation: AgentInvocation = {
2772
+ modelName,
2773
+ ...((resolvedConfig.requestedAgentTier ?? customConfig?.agentTier) ===
2774
+ undefined
2775
+ ? {}
2776
+ : {
2777
+ agentTier:
2778
+ resolvedConfig.requestedAgentTier ?? customConfig?.agentTier,
2779
+ }),
2780
+ thinking,
2781
+ // Explicit value only — the default fallback would just add noise.
2782
+ // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
2783
+ maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
2784
+ isolated,
2785
+ inheritContext,
2786
+ runInBackground,
2787
+ isolation,
2788
+ };
2789
+ // Tool-result render shows the mode label too; viewer's header already does.
2790
+ const modeLabel = getPromptModeLabel(subagentType);
2791
+ const { tags: invocationTags } = buildInvocationTags(agentInvocation);
2792
+ const agentTags = modeLabel
2793
+ ? [modeLabel, ...invocationTags]
2794
+ : invocationTags;
2795
+ const detailBase = {
2796
+ displayName,
2797
+ description: params.description,
2798
+ subagentType,
2799
+ modelName,
2800
+ tags: agentTags.length > 0 ? agentTags : undefined,
2801
+ };
2694
2802
 
2695
- // Resume existing agent
2803
+ // ---- Schedule: register a job, don't spawn now ----
2804
+ if (params.schedule) {
2805
+ if (!isSchedulingEnabled()) {
2806
+ return textResult(
2807
+ "Scheduling is disabled in this project. Enable via /agents, Settings, Scheduling.",
2808
+ );
2809
+ }
2696
2810
  if (params.resume) {
2697
- const existing = manager.getRecordMutable(params.resume);
2698
- if (!existing || existing.parentAgentId) {
2699
- return textResult(
2700
- `Agent not found: "${params.resume}". It may have been cleaned up.`,
2701
- );
2702
- }
2703
- if (!existing.session) {
2704
- return textResult(
2705
- `Agent "${params.resume}" has no active session to resume.`,
2706
- );
2707
- }
2708
- if (signal?.aborted) {
2709
- return textResult("Resume aborted.");
2710
- }
2811
+ return textResult(
2812
+ "Cannot combine `schedule` with `resume` — schedules create fresh agents.",
2813
+ );
2814
+ }
2815
+ if (inheritContext) {
2816
+ return textResult(
2817
+ "Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.",
2818
+ );
2819
+ }
2820
+ if (params.run_in_background === false) {
2821
+ return textResult(
2822
+ "Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.",
2823
+ );
2824
+ }
2825
+ if (!scheduler.isActive()) {
2826
+ return textResult(
2827
+ "Scheduler is not active in this session yet. Try again after the session has fully started.",
2828
+ );
2829
+ }
2830
+ try {
2831
+ const job = scheduler.addJob({
2832
+ name: params.description as string,
2833
+ description: params.description as string,
2834
+ schedule: params.schedule as string,
2835
+ // The caller's own name, not the substitute — the scheduler re-resolves
2836
+ // at fire time, and the original is what a user edits.
2837
+ subagent_type: requestedType,
2838
+ prompt: params.prompt as string,
2839
+ // Only an explicitly requested tier is frozen into the job. An
2840
+ // agent's frontmatter tier is deliberately not copied: it is read
2841
+ // again at fire time, so editing the agent file still takes
2842
+ // effect, and it keeps its "frontmatter" source rather than being
2843
+ // replayed as a caller choice.
2844
+ tier: resolvedConfig.requestedAgentTier,
2845
+ // Store the resolved policy input (agent config first, tool params second)
2846
+ // so scheduled fires use the same model fallback as an immediate spawn.
2847
+ model: resolvedConfig.agentTierSelected
2848
+ ? undefined
2849
+ : resolvedConfig.modelInput,
2850
+ thinking: resolvedConfig.agentTierSelected ? undefined : thinking,
2851
+ max_turns: effectiveMaxTurns,
2852
+ isolated: isolated,
2853
+ isolation: isolation,
2854
+ });
2855
+ const next = scheduler.getNextRun(job.id);
2856
+ return textResult(
2857
+ `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
2858
+ `Next run: ${next ?? "(unknown)"}. ` +
2859
+ `Manage via /agents, Scheduled jobs.`,
2860
+ );
2861
+ } catch (err) {
2862
+ return textResult(err instanceof Error ? err.message : String(err));
2863
+ }
2864
+ }
2711
2865
 
2712
- // Assigned unconditionally, before either resume path. The completion
2713
- // notification carries this as `<tool-use-id>` (see
2714
- // formatTaskNotification), and `manager.resume` clears
2715
- // `resultConsumed`, so a resumed run does notify. Keeping the id the
2716
- // original spawn wrote would point that notification at a tool call
2717
- // answered runs ago; a resume with no tool call of its own must clear
2718
- // it rather than inherit one.
2719
- existing.toolCallId = toolCallId;
2720
-
2721
- // run_in_background on resume: settle asynchronously and notify on
2722
- // completion like a background spawn, returning immediately. Previously
2723
- // the flag was accepted then silently dropped — a resumed agent always
2724
- // blocked the caller until it finished.
2725
- if (runInBackground) {
2726
- const { state: bgState, callbacks: bgCallbacks } =
2727
- createActivityTracker(effectiveMaxTurns);
2728
- // resumeAgent has no onSessionCreated — the session predates this run
2729
- // — so seed the activity tracker directly.
2730
- bgState.session = existing.session;
2731
- // Reuse the agent's transcript rather than starting a fresh one: the
2732
- // path is deterministic per agent+session, and writing an initial
2733
- // entry would truncate the previous run's turns (B1#2).
2734
- if (outputTranscript) {
2735
- existing.outputFile = createOutputFilePath(
2736
- ctx.cwd,
2737
- params.resume,
2738
- ctx.sessionManager.getSessionId(),
2739
- );
2740
- ensureOutputFile(existing.outputFile);
2741
- }
2742
- // Anchor streaming past the turns already on disk, captured BEFORE the
2743
- // run starts. The resumed prompt lands as an ordinary user message at
2744
- // this index, so it is written exactly once.
2745
- const transcriptAnchor = existing.session.messages.length ?? 0;
2746
- const attachTranscript = (): void => {
2747
- if (!existing.outputFile || existing.outputCleanup) return;
2748
- existing.outputCleanup = streamToOutputFile(
2749
- existing.session!,
2750
- existing.outputFile,
2751
- params.resume!,
2752
- ctx.cwd,
2753
- transcriptAnchor,
2754
- );
2755
- };
2756
- const record = await manager.resume(
2866
+ // Resume existing agent
2867
+ if (params.resume) {
2868
+ const existing = manager.getRecordMutable(params.resume);
2869
+ if (!existing || existing.parentAgentId) {
2870
+ return textResult(
2871
+ `Agent not found: "${params.resume}". It may have been cleaned up.`,
2872
+ );
2873
+ }
2874
+ if (!existing.session) {
2875
+ return textResult(
2876
+ `Agent "${params.resume}" has no active session to resume.`,
2877
+ );
2878
+ }
2879
+ if (signal?.aborted) {
2880
+ return textResult("Resume aborted.");
2881
+ }
2882
+
2883
+ // Assigned unconditionally, before either resume path. The completion
2884
+ // notification carries this as `<tool-use-id>` (see
2885
+ // formatTaskNotification), and `manager.resume` clears
2886
+ // `resultConsumed`, so a resumed run does notify. Keeping the id the
2887
+ // original spawn wrote would point that notification at a tool call
2888
+ // answered runs ago; a resume with no tool call of its own must clear
2889
+ // it rather than inherit one.
2890
+ existing.toolCallId = toolCallId;
2891
+
2892
+ // run_in_background on resume: settle asynchronously and notify on
2893
+ // completion like a background spawn, returning immediately. Previously
2894
+ // the flag was accepted then silently dropped — a resumed agent always
2895
+ // blocked the caller until it finished.
2896
+ if (runInBackground) {
2897
+ const { state: bgState, callbacks: bgCallbacks } =
2898
+ createActivityTracker(effectiveMaxTurns);
2899
+ // resumeAgent has no onSessionCreated — the session predates this run
2900
+ // — so seed the activity tracker directly.
2901
+ bgState.session = existing.session;
2902
+ // Reuse the agent's transcript rather than starting a fresh one: the
2903
+ // path is deterministic per agent+session, and writing an initial
2904
+ // entry would truncate the previous run's turns (B1#2).
2905
+ if (outputTranscript) {
2906
+ existing.outputFile = createOutputFilePath(
2907
+ ctx.cwd,
2757
2908
  params.resume,
2758
- params.prompt,
2759
- undefined,
2760
- {
2761
- isBackground: true,
2762
- onToolActivity: bgCallbacks.onToolActivity,
2763
- onAssistantUsage: bgCallbacks.onAssistantUsage,
2764
- onStarted: attachTranscript,
2765
- },
2766
- );
2767
- if (!record) {
2768
- return textResult(
2769
- `Cannot resume agent "${params.resume}" in background — it is already running. ` +
2770
- "Wait for it to settle, or steer it with steer_subagent.",
2771
- );
2772
- }
2773
- agentActivity.set(params.resume, bgState);
2774
- void bgCallbacks;
2775
- return textResult(
2776
- record.status === "queued"
2777
- ? `Agent "${params.resume}" resumed in background (queued at the concurrency limit).`
2778
- : `Agent "${params.resume}" resumed in background.`,
2779
- buildDetails(detailBase, record),
2909
+ ctx.sessionManager.getSessionId(),
2780
2910
  );
2911
+ ensureOutputFile(existing.outputFile);
2781
2912
  }
2782
-
2913
+ // Anchor streaming past the turns already on disk, captured BEFORE the
2914
+ // run starts. The resumed prompt lands as an ordinary user message at
2915
+ // this index, so it is written exactly once.
2916
+ const transcriptAnchor = existing.session.messages.length ?? 0;
2917
+ const attachTranscript = (): void => {
2918
+ if (!existing.outputFile || existing.outputCleanup) return;
2919
+ existing.outputCleanup = streamToOutputFile(
2920
+ existing.session!,
2921
+ existing.outputFile,
2922
+ params.resume!,
2923
+ ctx.cwd,
2924
+ transcriptAnchor,
2925
+ );
2926
+ };
2783
2927
  const record = await manager.resume(
2784
2928
  params.resume,
2785
2929
  params.prompt,
2786
- signal,
2930
+ undefined,
2931
+ {
2932
+ isBackground: true,
2933
+ onToolActivity: bgCallbacks.onToolActivity,
2934
+ onAssistantUsage: bgCallbacks.onAssistantUsage,
2935
+ onStarted: attachTranscript,
2936
+ },
2787
2937
  );
2788
2938
  if (!record) {
2789
- return textResult(`Failed to resume agent "${params.resume}".`);
2790
- }
2791
- // A failed resume surfaces the error, plus any partial output THIS
2792
- // resume produced (never the previous turn's answer, #144).
2793
- if (record.status === "error") {
2794
2939
  return textResult(
2795
- `Agent failed: ${record.error}${partialOutputSuffix(record)}`,
2796
- buildDetails(detailBase, record),
2940
+ `Cannot resume agent "${params.resume}" in background — it is already running. ` +
2941
+ "Wait for it to settle, or steer it with steer_subagent.",
2797
2942
  );
2798
2943
  }
2944
+ agentActivity.set(params.resume, bgState);
2945
+ void bgCallbacks;
2799
2946
  return textResult(
2800
- record.result?.trim() || "No output.",
2947
+ record.status === "queued"
2948
+ ? `Agent "${params.resume}" resumed in background (queued at the concurrency limit).`
2949
+ : `Agent "${params.resume}" resumed in background.`,
2801
2950
  buildDetails(detailBase, record),
2802
2951
  );
2803
2952
  }
2804
2953
 
2805
- // Background execution
2806
- if (runInBackground) {
2807
- const { state: bgState, callbacks: bgCallbacks } =
2808
- createActivityTracker(effectiveMaxTurns);
2809
-
2810
- // Wrap onSessionCreated to wire output file streaming.
2811
- // The callback lazily reads record.outputFile (set right after spawn)
2812
- // rather than closing over a value that doesn't exist yet.
2813
- let id: string;
2814
- const origBgOnSession = bgCallbacks.onSessionCreated;
2815
- bgCallbacks.onSessionCreated = (session: any) => {
2816
- origBgOnSession(session);
2817
- const rec = manager.getRecordMutable(id);
2818
- if (rec?.outputFile) {
2819
- rec.outputCleanup = streamToOutputFile(
2820
- session,
2821
- rec.outputFile,
2822
- id,
2823
- ctx.cwd,
2824
- );
2825
- }
2826
- };
2827
-
2828
- // A startup throw means the agent never started. Let pi mark the tool
2829
- // call as failed instead of returning a successful-looking text result.
2830
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
2831
- description: params.description,
2832
- model: resolvedConfig.agentTierSelected ? undefined : model,
2833
- maxTurns: effectiveMaxTurns,
2834
- isolated,
2835
- inheritContext,
2836
- thinkingLevel: resolvedConfig.agentTierSelected ? undefined : thinking,
2837
- agentTier: resolvedConfig.requestedAgentTier,
2838
- isBackground: true,
2839
- isolation,
2840
- invocation: agentInvocation,
2841
- rootSessionId: ctx.sessionManager.getSessionId(),
2842
- ...bgCallbacks,
2843
- });
2844
-
2845
- // Set output file + join mode synchronously after spawn, before the
2846
- // event loop yields — onSessionCreated is async so this is safe.
2847
- const joinMode = resolveJoinMode(defaultJoinMode, true);
2848
- const record = manager.getRecordMutable(id);
2849
- if (record && joinMode) {
2850
- record.joinMode = joinMode;
2851
- record.toolCallId = toolCallId;
2852
- attachTranscript(record, id);
2853
- }
2954
+ const record = await manager.resume(
2955
+ params.resume,
2956
+ params.prompt,
2957
+ signal,
2958
+ );
2959
+ if (!record) {
2960
+ return textResult(`Failed to resume agent "${params.resume}".`);
2961
+ }
2962
+ // A failed resume surfaces the error, plus any partial output THIS
2963
+ // resume produced (never the previous turn's answer, #144).
2964
+ if (record.status === "error") {
2965
+ return textResult(
2966
+ `Agent failed: ${record.error}${partialOutputSuffix(record)}`,
2967
+ buildDetails(detailBase, record),
2968
+ );
2969
+ }
2970
+ return textResult(
2971
+ record.result?.trim() || "No output.",
2972
+ buildDetails(detailBase, record),
2973
+ );
2974
+ }
2854
2975
 
2855
- if (joinMode == null || joinMode === "async") {
2856
- // Foreground/no join mode or explicit async — not part of any batch
2857
- } else {
2858
- // smart or group — add to current batch
2859
- currentBatchAgents.push({ id, joinMode });
2860
- // Debounce: reset timer on each new agent so parallel tool calls
2861
- // dispatched across multiple event loop ticks are captured together
2862
- if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
2863
- batchFinalizeTimer = setTimeout(finalizeBatch, 100);
2976
+ // Background execution
2977
+ if (runInBackground) {
2978
+ const { state: bgState, callbacks: bgCallbacks } =
2979
+ createActivityTracker(effectiveMaxTurns);
2980
+
2981
+ // Wrap onSessionCreated to wire output file streaming.
2982
+ // The callback lazily reads record.outputFile (set right after spawn)
2983
+ // rather than closing over a value that doesn't exist yet.
2984
+ let id: string;
2985
+ const origBgOnSession = bgCallbacks.onSessionCreated;
2986
+ bgCallbacks.onSessionCreated = (session: any) => {
2987
+ origBgOnSession(session);
2988
+ const rec = manager.getRecordMutable(id);
2989
+ if (rec?.outputFile) {
2990
+ rec.outputCleanup = streamToOutputFile(
2991
+ session,
2992
+ rec.outputFile,
2993
+ id,
2994
+ ctx.cwd,
2995
+ );
2864
2996
  }
2997
+ };
2865
2998
 
2866
- agentActivity.set(id, bgState);
2867
- fleet.ensureTimer();
2868
- fleet.update();
2999
+ // A startup throw means the agent never started. Let pi mark the tool
3000
+ // call as failed instead of returning a successful-looking text result.
3001
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
3002
+ description: params.description,
3003
+ model: resolvedConfig.agentTierSelected ? undefined : model,
3004
+ maxTurns: effectiveMaxTurns,
3005
+ isolated,
3006
+ inheritContext,
3007
+ thinkingLevel: resolvedConfig.agentTierSelected
3008
+ ? undefined
3009
+ : thinking,
3010
+ agentTier: resolvedConfig.requestedAgentTier,
3011
+ isBackground: true,
3012
+ isolation,
3013
+ invocation: agentInvocation,
3014
+ rootSessionId: ctx.sessionManager.getSessionId(),
3015
+ ...bgCallbacks,
3016
+ });
3017
+ // The clone needs a positive spawn witness before any UI, transcript,
3018
+ // or event observer can throw. A startup failure inside manager.spawn
3019
+ // removes its record and does not reach this acknowledgement.
3020
+ const record = manager.getRecordMutable(id);
3021
+ if (record) {
3022
+ (
3023
+ params as typeof params & {
3024
+ [MENTION_SPAWNED]?: (id: string) => void;
3025
+ }
3026
+ )[MENTION_SPAWNED]?.(id);
3027
+ }
2869
3028
 
2870
- // Emit created event unless a branch replacement detached the record.
2871
- if (!record?.detached) {
2872
- pi.events.emit("subagents:created", {
2873
- id,
2874
- type: subagentType,
2875
- description: params.description,
2876
- isBackground: true,
2877
- });
2878
- }
3029
+ // Set output file + join mode synchronously after spawn, before the
3030
+ // event loop yields — onSessionCreated is async so this is safe.
3031
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
3032
+ if (record && joinMode) {
3033
+ record.joinMode = joinMode;
3034
+ record.toolCallId = toolCallId;
3035
+ attachTranscript(record, id);
3036
+ }
2879
3037
 
2880
- const isQueued = record?.status === "queued";
2881
- return textResult(
2882
- `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
2883
- `Agent ID: ${id}\n` +
2884
- `Type: ${displayName}\n` +
2885
- `Description: ${params.description}\n` +
2886
- (record?.outputFile
2887
- ? `Output file: ${record.outputFile}\n`
2888
- : "") +
2889
- (isQueued
2890
- ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
2891
- : "") +
2892
- `\nYou will be notified when this agent completes.\n` +
2893
- `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
2894
- `Do not duplicate this agent's work.`,
2895
- {
2896
- ...detailBase,
2897
- toolUses: 0,
2898
- tokens: "",
2899
- durationMs: 0,
2900
- status: isQueued ? "queued" : "background",
2901
- agentId: id,
2902
- },
2903
- );
3038
+ if (joinMode == null || joinMode === "async") {
3039
+ // Foreground/no join mode or explicit async — not part of any batch
3040
+ } else {
3041
+ // smart or group — add to current batch
3042
+ currentBatchAgents.push({ id, joinMode });
3043
+ // Debounce: reset timer on each new agent so parallel tool calls
3044
+ // dispatched across multiple event loop ticks are captured together
3045
+ if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
3046
+ batchFinalizeTimer = setTimeout(finalizeBatch, 100);
2904
3047
  }
2905
3048
 
2906
- // Foreground (synchronous) execution — stream progress via onUpdate
2907
- let spinnerFrame = 0;
2908
- const startedAt = Date.now();
2909
- let fgId: string | undefined;
3049
+ agentActivity.set(id, bgState);
3050
+ fleet.ensureTimer();
3051
+ fleet.update();
2910
3052
 
2911
- const streamUpdate = () => {
2912
- const details: AgentDetails = {
2913
- ...detailBase,
2914
- toolUses: fgState.toolUses,
2915
- tokens: formatLifetimeTokens(fgState),
2916
- turnCount: fgState.turnCount,
2917
- maxTurns: fgState.maxTurns,
2918
- durationMs: Date.now() - startedAt,
2919
- status: "running",
2920
- activity: describeActivity(
2921
- fgState.activeTools,
2922
- fgState.responseText,
2923
- ),
2924
- spinnerFrame: spinnerFrame % SPINNER.length,
2925
- };
2926
- onUpdate?.({
2927
- content: [
2928
- { type: "text", text: `${fgState.toolUses} tool uses...` },
2929
- ],
2930
- details: details as any,
3053
+ // Emit created event unless a branch replacement detached the record.
3054
+ if (!record?.detached) {
3055
+ pi.events.emit("subagents:created", {
3056
+ id,
3057
+ type: subagentType,
3058
+ description: params.description,
3059
+ isBackground: true,
2931
3060
  });
2932
- };
3061
+ }
2933
3062
 
2934
- const { state: fgState, callbacks: fgCallbacks } =
2935
- createActivityTracker(effectiveMaxTurns, streamUpdate);
2936
-
2937
- // Wire session creation: register in the FleetView list + stream to the output file.
2938
- // The output file path is set synchronously after spawn (below),
2939
- // before onSessionCreated fires — same pattern as background agents.
2940
- const origOnSession = fgCallbacks.onSessionCreated;
2941
- fgCallbacks.onSessionCreated = (session: any) => {
2942
- origOnSession(session);
2943
- for (const a of manager.listAgentsMutable()) {
2944
- if (a.session === session) {
2945
- fgId = a.id;
2946
- agentActivity.set(a.id, fgState);
2947
- fleet.ensureTimer();
2948
- fleet.update();
2949
- break;
2950
- }
2951
- }
2952
- // Stream conversation to output file (foreground agent logging)
2953
- if (fgId) {
2954
- const rec = manager.getRecordMutable(fgId);
2955
- if (rec?.outputFile) {
2956
- rec.outputCleanup = streamToOutputFile(
2957
- session,
2958
- rec.outputFile,
2959
- fgId,
2960
- ctx.cwd,
2961
- );
2962
- }
2963
- }
2964
- };
3063
+ const isQueued = record?.status === "queued";
3064
+ return textResult(
3065
+ `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
3066
+ `Agent ID: ${id}\n` +
3067
+ `Type: ${displayName}\n` +
3068
+ `Description: ${params.description}\n` +
3069
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
3070
+ (isQueued
3071
+ ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
3072
+ : "") +
3073
+ `\nYou will be notified when this agent completes.\n` +
3074
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
3075
+ `Do not duplicate this agent's work.`,
3076
+ {
3077
+ ...detailBase,
3078
+ toolUses: 0,
3079
+ tokens: "",
3080
+ durationMs: 0,
3081
+ status: isQueued ? "queued" : "background",
3082
+ agentId: id,
3083
+ },
3084
+ );
3085
+ }
2965
3086
 
2966
- const spinnerInterval = setInterval(() => {
2967
- spinnerFrame++;
2968
- streamUpdate();
2969
- }, SPINNER_INTERVAL_MS);
3087
+ // Foreground (synchronous) execution — stream progress via onUpdate
3088
+ let spinnerFrame = 0;
3089
+ const startedAt = Date.now();
3090
+ let fgId: string | undefined;
3091
+
3092
+ const streamUpdate = () => {
3093
+ const details: AgentDetails = {
3094
+ ...detailBase,
3095
+ toolUses: fgState.toolUses,
3096
+ tokens: formatLifetimeTokens(fgState),
3097
+ turnCount: fgState.turnCount,
3098
+ maxTurns: fgState.maxTurns,
3099
+ durationMs: Date.now() - startedAt,
3100
+ status: "running",
3101
+ activity: describeActivity(fgState.activeTools, fgState.responseText),
3102
+ spinnerFrame: spinnerFrame % SPINNER.length,
3103
+ };
3104
+ onUpdate?.({
3105
+ content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
3106
+ details: details as any,
3107
+ });
3108
+ };
2970
3109
 
2971
- streamUpdate();
3110
+ const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(
3111
+ effectiveMaxTurns,
3112
+ streamUpdate,
3113
+ );
2972
3114
 
2973
- let record: AgentRecord;
2974
- try {
2975
- const fgResult = await manager.spawnAndWait(
2976
- pi,
2977
- ctx,
2978
- subagentType,
2979
- params.prompt,
2980
- {
2981
- description: params.description,
2982
- model: resolvedConfig.agentTierSelected ? undefined : model,
2983
- maxTurns: effectiveMaxTurns,
2984
- isolated,
2985
- inheritContext,
2986
- thinkingLevel: resolvedConfig.agentTierSelected ? undefined : thinking,
2987
- agentTier: resolvedConfig.requestedAgentTier,
2988
- isolation,
2989
- invocation: agentInvocation,
2990
- signal,
2991
- rootSessionId: ctx.sessionManager.getSessionId(),
2992
- ...fgCallbacks,
2993
- },
2994
- (fgAgentId) => {
2995
- // onSpawned: called synchronously after spawn, before onSessionCreated fires.
2996
- // Set up the output file so streamToOutputFile can pick it up.
2997
- const fgRec = manager.getRecordMutable(fgAgentId);
2998
- attachTranscript(fgRec, fgAgentId);
2999
- },
3000
- );
3001
- record = fgResult.record;
3002
- } finally {
3003
- // Always stop the spinner and drop the row, including startup errors
3004
- // that now propagate to pi as failed tool calls.
3005
- clearInterval(spinnerInterval);
3006
- if (fgId) {
3007
- agentActivity.delete(fgId);
3008
- fleet.onAgentFinished(fgId);
3115
+ // Wire session creation: register in the FleetView list + stream to the output file.
3116
+ // The output file path is set synchronously after spawn (below),
3117
+ // before onSessionCreated fires — same pattern as background agents.
3118
+ const origOnSession = fgCallbacks.onSessionCreated;
3119
+ fgCallbacks.onSessionCreated = (session: any) => {
3120
+ origOnSession(session);
3121
+ for (const a of manager.listAgentsMutable()) {
3122
+ if (a.session === session) {
3123
+ fgId = a.id;
3124
+ agentActivity.set(a.id, fgState);
3125
+ fleet.ensureTimer();
3126
+ fleet.update();
3127
+ break;
3128
+ }
3129
+ }
3130
+ // Stream conversation to output file (foreground agent logging)
3131
+ if (fgId) {
3132
+ const rec = manager.getRecordMutable(fgId);
3133
+ if (rec?.outputFile) {
3134
+ rec.outputCleanup = streamToOutputFile(
3135
+ session,
3136
+ rec.outputFile,
3137
+ fgId,
3138
+ ctx.cwd,
3139
+ );
3009
3140
  }
3010
3141
  }
3142
+ };
3011
3143
 
3012
- // Get final token count
3013
- const tokenText = formatLifetimeTokens(fgState);
3144
+ const spinnerInterval = setInterval(() => {
3145
+ spinnerFrame++;
3146
+ streamUpdate();
3147
+ }, SPINNER_INTERVAL_MS);
3014
3148
 
3015
- const details = buildDetails(detailBase, record, fgState, {
3016
- tokens: tokenText,
3017
- });
3149
+ streamUpdate();
3018
3150
 
3019
- if (record.status === "error") {
3020
- // Error headline + any partial output the run produced before failing.
3021
- return textResult(
3022
- `${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`,
3023
- details,
3024
- );
3151
+ let record: AgentRecord;
3152
+ try {
3153
+ const fgResult = await manager.spawnAndWait(
3154
+ pi,
3155
+ ctx,
3156
+ subagentType,
3157
+ params.prompt,
3158
+ {
3159
+ description: params.description,
3160
+ model: resolvedConfig.agentTierSelected ? undefined : model,
3161
+ maxTurns: effectiveMaxTurns,
3162
+ isolated,
3163
+ inheritContext,
3164
+ thinkingLevel: resolvedConfig.agentTierSelected
3165
+ ? undefined
3166
+ : thinking,
3167
+ agentTier: resolvedConfig.requestedAgentTier,
3168
+ isolation,
3169
+ invocation: agentInvocation,
3170
+ signal,
3171
+ rootSessionId: ctx.sessionManager.getSessionId(),
3172
+ ...fgCallbacks,
3173
+ },
3174
+ (fgAgentId) => {
3175
+ // onSpawned: called synchronously after spawn, before onSessionCreated fires.
3176
+ // Set up the output file so streamToOutputFile can pick it up.
3177
+ const fgRec = manager.getRecordMutable(fgAgentId);
3178
+ attachTranscript(fgRec, fgAgentId);
3179
+ },
3180
+ );
3181
+ record = fgResult.record;
3182
+ } finally {
3183
+ // Always stop the spinner and drop the row, including startup errors
3184
+ // that now propagate to pi as failed tool calls.
3185
+ clearInterval(spinnerInterval);
3186
+ if (fgId) {
3187
+ agentActivity.delete(fgId);
3188
+ fleet.onAgentFinished(fgId);
3025
3189
  }
3190
+ }
3191
+
3192
+ // Get final token count
3193
+ const tokenText = formatLifetimeTokens(fgState);
3194
+
3195
+ const details = buildDetails(detailBase, record, fgState, {
3196
+ tokens: tokenText,
3197
+ });
3026
3198
 
3027
- const durationMs =
3028
- (record.completedAt ?? Date.now()) - record.startedAt;
3029
- const statsParts = [`${record.toolUses} tool uses`];
3030
- if (tokenText) statsParts.push(tokenText);
3199
+ if (record.status === "error") {
3200
+ // Error headline + any partial output the run produced before failing.
3031
3201
  return textResult(
3032
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
3033
- (record.result?.trim() || "No output."),
3202
+ `${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`,
3034
3203
  details,
3035
3204
  );
3036
- },
3205
+ }
3206
+
3207
+ const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
3208
+ const statsParts = [`${record.toolUses} tool uses`];
3209
+ if (tokenText) statsParts.push(tokenText);
3210
+ return textResult(
3211
+ `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
3212
+ (record.result?.trim() || "No output."),
3213
+ details,
3214
+ );
3215
+ },
3037
3216
  });
3038
3217
  pi.registerTool(agentTool);
3039
3218
  agentToolRef = agentTool;
@@ -3154,7 +3333,8 @@ Terse command-style prompts produce shallow, generic work.
3154
3333
  "Chat with or redirect a running or queued background agent",
3155
3334
  parameters: Type.Object({
3156
3335
  agent_id: Type.String({
3157
- description: "The agent ID to message (must be currently running or queued).",
3336
+ description:
3337
+ "The agent ID to message (must be currently running or queued).",
3158
3338
  }),
3159
3339
  message: Type.String({
3160
3340
  description:
@@ -3185,7 +3365,10 @@ Terse command-style prompts produce shallow, generic work.
3185
3365
  id: record.id,
3186
3366
  message: params.message,
3187
3367
  });
3188
- const delivery = record.status === "queued" ? "when the agent starts" : "once the session initializes";
3368
+ const delivery =
3369
+ record.status === "queued"
3370
+ ? "when the agent starts"
3371
+ : "once the session initializes";
3189
3372
  return textResult(
3190
3373
  `Chat message queued for agent ${record.id}. It will be delivered ${delivery}.`,
3191
3374
  );
@@ -3493,7 +3676,10 @@ Terse command-style prompts produce shallow, generic work.
3493
3676
  `"${sanitizeDisplayText(record.description)}" has not started yet.${handleHint}\n\nStop this queued run?`,
3494
3677
  );
3495
3678
  if (stop && manager.abort(record.id)) {
3496
- ctx.ui.notify(`Stopped queued agent "${sanitizeDisplayText(record.description)}".`, "info");
3679
+ ctx.ui.notify(
3680
+ `Stopped queued agent "${sanitizeDisplayText(record.description)}".`,
3681
+ "info",
3682
+ );
3497
3683
  }
3498
3684
  return;
3499
3685
  }
@@ -4161,7 +4347,7 @@ prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is ap
4161
4347
  extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
4162
4348
  skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
4163
4349
  disallowed_tools: <comma-separated tool names to block, even if otherwise available. Omit for none>
4164
- inherit_context: <true to fork parent conversation into agent so it sees chat history. Default: false>
4350
+ inherit_context: <omit or false; true is rejected because Pi cannot safely transfer the post-hook parent context>
4165
4351
  run_in_background: <true to run in background by default. Default: false>
4166
4352
  output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
4167
4353
  isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
@@ -4177,7 +4363,7 @@ Guidelines for choosing settings:
4177
4363
  - For code modification tasks: include edit, write
4178
4364
  - Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
4179
4365
  - Use prompt_mode: replace for fully custom agents with their own personality/instructions
4180
- - Set inherit_context: true if the agent needs to know what was discussed in the parent conversation
4366
+ - Never set inherit_context: true; put an explicitly sanitized summary in the Agent task instead
4181
4367
  - Set isolated: true if the agent should NOT have access to MCP servers or other extensions
4182
4368
  - Set output_transcript: false to skip writing the agent's transcript; this alone doesn't keep the run off disk (persist_session, isolation: worktree commits, and memory still write) — set those too if that's the goal
4183
4369
  - Only include frontmatter fields that differ from defaults — omit fields where the default is fine
@@ -4781,7 +4967,8 @@ Do not wrap the response in a markdown code fence. Return only the file contents
4781
4967
  id: "defaultTier",
4782
4968
  label: "Default tier",
4783
4969
  description: (() => {
4784
- if (tierKeys.length === 0) return "No tiers defined yet — create one in /agents → Model tiers.";
4970
+ if (tierKeys.length === 0)
4971
+ return "No tiers defined yet — create one in /agents → Model tiers.";
4785
4972
  const fallback = shippedFallbackAgentTier();
4786
4973
  // Named from the resolver, not from a constant: on a catalogue that
4787
4974
  // removed the shipped profile, "unset" reaches nothing and behaves
@@ -4796,7 +4983,9 @@ Do not wrap the response in a markdown code fence. Return only the file contents
4796
4983
  currentValue: (() => {
4797
4984
  const selection = getDefaultAgentTierSelection();
4798
4985
  if (selection.kind === "tier") return selection.tier;
4799
- return selection.kind === "none" ? NO_DEFAULT_TIER : UNSET_DEFAULT_TIER;
4986
+ return selection.kind === "none"
4987
+ ? NO_DEFAULT_TIER
4988
+ : UNSET_DEFAULT_TIER;
4800
4989
  })(),
4801
4990
  values: [UNSET_DEFAULT_TIER, NO_DEFAULT_TIER, ...tierKeys],
4802
4991
  },
@@ -4874,7 +5063,7 @@ Do not wrap the response in a markdown code fence. Return only the file contents
4874
5063
  id: "agentMentions",
4875
5064
  label: "Agent mentions",
4876
5065
  description:
4877
- "How `@handle message` is dispatched: model (an off-screen clone of this conversation writes the new agent's prompt), direct (start it from the typed text), or off (send the text to the main model verbatim). Messaging and resuming an existing agent are always direct.",
5066
+ "How `@handle message` is dispatched: model (an isolated mention-only turn rewrites the typed task without copying parent history), direct (start it from the typed text), or off (send the text to the main model verbatim). Messaging and resuming an existing agent are always direct.",
4878
5067
  currentValue: getAgentMentionMode(),
4879
5068
  values: [...AGENT_MENTION_MODES],
4880
5069
  },
@@ -5055,13 +5244,13 @@ Do not wrap the response in a markdown code fence. Return only the file contents
5055
5244
  const enabled = value === "on";
5056
5245
  supervisorQuestionsEnabled = enabled;
5057
5246
  manager.setSupervisorQuestions(enabled);
5058
- notifyApplied(ctx, `Supervisor questions ${enabled ? "enabled" : "disabled"}`);
5059
- } else if (id === "agentMentions") {
5060
- agentMentionMode = value as AgentMentionMode;
5061
5247
  notifyApplied(
5062
5248
  ctx,
5063
- `Agent mentions set to ${agentMentionMode}`,
5249
+ `Supervisor questions ${enabled ? "enabled" : "disabled"}`,
5064
5250
  );
5251
+ } else if (id === "agentMentions") {
5252
+ agentMentionMode = value as AgentMentionMode;
5253
+ notifyApplied(ctx, `Agent mentions set to ${agentMentionMode}`);
5065
5254
  }
5066
5255
  }
5067
5256