@signalridge/pi-subagents 1.10.3 → 1.11.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.
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,
@@ -159,6 +165,7 @@ import {
159
165
  streamToOutputFile,
160
166
  writeInitialEntry,
161
167
  } from "./output-file.js";
168
+ import { captureProjectTrust, isProjectResource } from "./project-trust.js";
162
169
  import { SubagentScheduler } from "./schedule.js";
163
170
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
164
171
  import {
@@ -227,7 +234,10 @@ import {
227
234
  getSessionContextPercent,
228
235
  type LifetimeUsage,
229
236
  } from "./usage.js";
230
- import { isWorktreeIsolationEnabled, setWorktreeIsolationEnabled } from "./worktree.js";
237
+ import {
238
+ isWorktreeIsolationEnabled,
239
+ setWorktreeIsolationEnabled,
240
+ } from "./worktree.js";
231
241
 
232
242
  // ---- Shared helpers ----
233
243
 
@@ -573,16 +583,18 @@ export default function (pi: ExtensionAPI): void {
573
583
  if (inert) return;
574
584
  if (!rootRuntime) {
575
585
  try {
576
- const startupSettings = loadSettings(ctx.cwd);
586
+ const trust = captureProjectTrust(ctx);
587
+ const startupSettings = loadSettings(trust.cwd, trust.trusted);
577
588
  const startupAgents = loadCustomAgents(
578
- ctx.cwd,
589
+ trust.cwd,
579
590
  startupSettings.strictAgentFiles === true,
591
+ trust.trusted,
580
592
  );
581
593
  rootRuntime = activateRootRuntime(
582
594
  pi,
583
595
  startupSettings,
584
596
  startupAgents,
585
- ctx.cwd,
597
+ ctx,
586
598
  unsubscribeRootContext,
587
599
  );
588
600
  } catch (error: unknown) {
@@ -602,10 +614,12 @@ function activateRootRuntime(
602
614
  pi: ExtensionAPI,
603
615
  startupSettings: SubagentsSettings,
604
616
  startupAgents: Map<string, AgentConfig>,
605
- initialCwd: string,
617
+ initialContext: ExtensionContext,
606
618
  unsubscribeRootContext: () => void,
607
619
  ): RootRuntime {
608
- let sessionCwd = initialCwd;
620
+ let configurationOwner = initialContext;
621
+ let activeTrust = captureProjectTrust(configurationOwner);
622
+ let sessionCwd = activeTrust.cwd;
609
623
  let strictAgentFiles = startupSettings.strictAgentFiles === true;
610
624
 
611
625
  // ---- Register custom notification renderer ----
@@ -659,7 +673,12 @@ function activateRootRuntime(
659
673
 
660
674
  /** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */
661
675
  const reloadCustomAgents = (strict = false) => {
662
- const userAgents = loadCustomAgents(sessionCwd, strict);
676
+ const trust = captureProjectTrust(configurationOwner);
677
+ const changed = trust.cwd !== activeTrust.cwd || trust.trusted !== activeTrust.trusted;
678
+ activeTrust = trust;
679
+ sessionCwd = trust.cwd;
680
+ if (changed) applyRuntimeSettings(loadSettings(trust.cwd, trust.trusted));
681
+ const userAgents = loadCustomAgents(trust.cwd, strict, trust.trusted);
663
682
  registerAgents(userAgents);
664
683
  };
665
684
 
@@ -804,8 +823,12 @@ function activateRootRuntime(
804
823
  durationMs,
805
824
  tokens,
806
825
  ...(record.outputFile ? { outputFile: record.outputFile } : {}),
807
- ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
808
- ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: record.invocation.agentTierSnapshot } : {}),
826
+ ...(record.invocation?.agentTier === undefined
827
+ ? {}
828
+ : { tier: record.invocation.agentTier }),
829
+ ...(record.invocation?.agentTierSnapshot
830
+ ? { tierSnapshot: record.invocation.agentTierSnapshot }
831
+ : {}),
809
832
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
810
833
  };
811
834
  }
@@ -843,7 +866,9 @@ function activateRootRuntime(
843
866
  ? { tokens: { total: terminal.tokenCount } }
844
867
  : {}),
845
868
  ...(tombstone.tier === undefined ? {} : { tier: tombstone.tier }),
846
- ...(tombstone.tierSnapshot ? { tierSnapshot: tombstone.tierSnapshot } : {}),
869
+ ...(tombstone.tierSnapshot
870
+ ? { tierSnapshot: tombstone.tierSnapshot }
871
+ : {}),
847
872
  owner: snapshotOwner(tombstone.owner),
848
873
  };
849
874
  }
@@ -868,7 +893,9 @@ function activateRootRuntime(
868
893
  ...(terminal.outputFile ? { outputFile: terminal.outputFile } : {}),
869
894
  ...(terminal.tokenCount ? { tokens: terminal.tokenCount } : {}),
870
895
  ...(tombstone.tier === undefined ? {} : { tier: tombstone.tier }),
871
- ...(tombstone.tierSnapshot ? { tierSnapshot: tombstone.tierSnapshot } : {}),
896
+ ...(tombstone.tierSnapshot
897
+ ? { tierSnapshot: tombstone.tierSnapshot }
898
+ : {}),
872
899
  owner: snapshotOwner(tombstone.owner),
873
900
  });
874
901
  }
@@ -934,8 +961,12 @@ function activateRootRuntime(
934
961
  ...(record.lifetimeUsage.input + record.lifetimeUsage.output > 0
935
962
  ? { tokens: record.lifetimeUsage.input + record.lifetimeUsage.output }
936
963
  : {}),
937
- ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
938
- ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: record.invocation.agentTierSnapshot } : {}),
964
+ ...(record.invocation?.agentTier === undefined
965
+ ? {}
966
+ : { tier: record.invocation.agentTier }),
967
+ ...(record.invocation?.agentTierSnapshot
968
+ ? { tierSnapshot: record.invocation.agentTierSnapshot }
969
+ : {}),
939
970
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
940
971
  });
941
972
 
@@ -980,7 +1011,9 @@ function activateRootRuntime(
980
1011
  id: record.id,
981
1012
  type: record.type,
982
1013
  description: record.description,
983
- ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
1014
+ ...(record.invocation?.agentTier === undefined
1015
+ ? {}
1016
+ : { tier: record.invocation.agentTier }),
984
1017
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
985
1018
  });
986
1019
  },
@@ -994,8 +1027,12 @@ function activateRootRuntime(
994
1027
  reason: info.reason,
995
1028
  tokensBefore: info.tokensBefore,
996
1029
  compactionCount: record.compactionCount,
997
- ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
998
- ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: record.invocation.agentTierSnapshot } : {}),
1030
+ ...(record.invocation?.agentTier === undefined
1031
+ ? {}
1032
+ : { tier: record.invocation.agentTier }),
1033
+ ...(record.invocation?.agentTierSnapshot
1034
+ ? { tierSnapshot: record.invocation.agentTierSnapshot }
1035
+ : {}),
999
1036
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
1000
1037
  });
1001
1038
  },
@@ -1009,7 +1046,9 @@ function activateRootRuntime(
1009
1046
  type: record.type,
1010
1047
  description: record.description,
1011
1048
  isBackground: record.isBackground,
1012
- ...(record.invocation?.agentTier === undefined ? {} : { tier: record.invocation.agentTier }),
1049
+ ...(record.invocation?.agentTier === undefined
1050
+ ? {}
1051
+ : { tier: record.invocation.agentTier }),
1013
1052
  owner: snapshotOwner(record.owner),
1014
1053
  });
1015
1054
  },
@@ -1067,8 +1106,7 @@ function activateRootRuntime(
1067
1106
  const globalRegistry = globalThis as any;
1068
1107
  const predecessor =
1069
1108
  (globalRegistry[MANAGER_TAKEOVER_LOCK_KEY] as
1070
- | Promise<void>
1071
- | undefined) ?? Promise.resolve();
1109
+ Promise<void> | undefined) ?? Promise.resolve();
1072
1110
  let release!: () => void;
1073
1111
  const gate = new Promise<void>((resolve) => {
1074
1112
  release = resolve;
@@ -1152,6 +1190,33 @@ function activateRootRuntime(
1152
1190
 
1153
1191
  // --- Cross-extension RPC via pi.events ---
1154
1192
  let currentCtx: ExtensionContext | undefined;
1193
+ // A session ID alone cannot distinguish a branch change or a resume of the
1194
+ // same session. Invalidate pending off-screen mention turns only when the
1195
+ // replacement commits: before-switch/tree handlers can still cancel it.
1196
+ let sessionGeneration = 0;
1197
+ // Public synchronous bus preflight works in either extension load order. A
1198
+ // filtered-out activation has no bound context and must not veto navigation.
1199
+ pi.events.on("pi:navigation-preflight", (raw: unknown) => {
1200
+ if (!currentCtx || !manager.hasUnsettledWork()) return;
1201
+ const report = (raw as { report?: unknown } | null)?.report;
1202
+ if (typeof report === "function") report("subagents");
1203
+ });
1204
+ const navigationPreflight = (
1205
+ ctx: ExtensionContext,
1206
+ ): { cancel: true } | undefined => {
1207
+ let busy = false;
1208
+ pi.events.emit("pi:navigation-preflight", {
1209
+ report: () => {
1210
+ busy = true;
1211
+ },
1212
+ });
1213
+ if (!busy) return undefined;
1214
+ const notice =
1215
+ "Navigation blocked while agents or workflows are active or cleaning up. Wait for them to finish, or stop them explicitly and retry.";
1216
+ if (ctx.hasUI) ctx.ui.notify(notice, "warning");
1217
+ else console.warn(`[pi-subagents] ${notice}`);
1218
+ return { cancel: true };
1219
+ };
1155
1220
  // RPC handlers + the `subagents:ready` broadcast are wired on `session_start`
1156
1221
  // (a bound lifecycle event), not at factory time. pi runs every extension
1157
1222
  // factory before the `extensions:` filter and only fires lifecycle events for
@@ -1192,7 +1257,23 @@ function activateRootRuntime(
1192
1257
  event: { reason?: string },
1193
1258
  ctx: ExtensionContext,
1194
1259
  ): Promise<void> {
1195
- sessionCwd = ctx.cwd;
1260
+ // A successful session_start is the commit point for a reused runtime. A
1261
+ // cancelled session_before_switch leaves the old context and scheduler live.
1262
+ if (currentCtx) {
1263
+ scheduler.stop();
1264
+ manager.detachForBranchChange();
1265
+ manager.resetManagedSpawns();
1266
+ }
1267
+ sessionGeneration++;
1268
+ const replacingConfiguration = currentCtx !== undefined;
1269
+ configurationOwner = ctx;
1270
+ const trust = captureProjectTrust(ctx);
1271
+ sessionCwd = trust.cwd;
1272
+ if (replacingConfiguration) {
1273
+ activeTrust = trust;
1274
+ applyRuntimeSettings(loadSettings(trust.cwd, trust.trusted));
1275
+ }
1276
+ reloadCustomAgents();
1196
1277
  boundSessionId = ctx.sessionManager?.getSessionId?.();
1197
1278
  currentCtx = ctx;
1198
1279
  await claimRpcRegistry(ctx, event.reason === "reload");
@@ -1233,7 +1314,8 @@ function activateRootRuntime(
1233
1314
  abortOwned: (id, owner) => manager.abortOwned(id, owner),
1234
1315
  quiesceOwned: (runId, agentIds, timeoutMs, owners) =>
1235
1316
  manager.quiesceOwned(runId, agentIds, timeoutMs, owners),
1236
- reconcileManaged: (spawnKey, owner) => manager.reconcileManaged(spawnKey, owner),
1317
+ reconcileManaged: (spawnKey, owner) =>
1318
+ manager.reconcileManaged(spawnKey, owner),
1237
1319
  // The live pool size, not a snapshot: `/subagents` can change it
1238
1320
  // mid-session, and a peer that sizes its fan-out from this must see
1239
1321
  // the value that is actually throttling it now.
@@ -1297,38 +1379,34 @@ function activateRootRuntime(
1297
1379
  startScheduler(ctx);
1298
1380
  }
1299
1381
 
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
- }
1382
+ // Both before-events are cancellable. A later handler may veto, and tree
1383
+ // summarization can still abort after every handler has returned. Never stop
1384
+ // live work on an attempted navigation.
1385
+ pi.on("session_before_switch", (_event, ctx) => navigationPreflight(ctx));
1386
+ pi.on("session_before_tree", (_event, ctx) => navigationPreflight(ctx));
1387
+ let detachedTreeEvent: unknown;
1388
+ const detachCommittedTree = (event: unknown): void => {
1389
+ if (!currentCtx || detachedTreeEvent === event) return;
1390
+ detachedTreeEvent = event;
1391
+ manager.detachForBranchChange();
1392
+ };
1393
+ // If workflows' committed-tree handler runs first, it must detach our old
1394
+ // records before aborting its controller: abort listeners can synchronously
1395
+ // issue stop-owned RPC and otherwise append a managed tombstone on the new leaf.
1396
+ pi.events.on("pi-workflows:session_tree_committed", (raw: unknown) => {
1397
+ const event = (raw as { event?: unknown } | null)?.event;
1398
+ if (event) detachCommittedTree(event);
1327
1399
  });
1328
- pi.on("session_tree", (_event, ctx) => {
1400
+ pi.on("session_tree", (event, ctx) => {
1401
+ sessionGeneration++;
1402
+ detachCommittedTree(event);
1403
+ // Pi has already changed the leaf. Fence workflow listeners synchronously
1404
+ // even when subagents' session_tree handler runs first.
1405
+ pi.events.emit("subagents:session_tree_committed", { event });
1329
1406
  currentCtx = ctx;
1330
- manager.abortAll();
1331
- manager.detachForBranchChange();
1407
+ configurationOwner = ctx;
1408
+ reloadCustomAgents();
1409
+ scheduler.stop();
1332
1410
  manager.clearCompleted(true);
1333
1411
  manager.resetManagedSpawns();
1334
1412
  const entries =
@@ -1350,6 +1428,52 @@ function activateRootRuntime(
1350
1428
  let runtimeShutdown: Promise<void> | undefined;
1351
1429
  const shutdownRuntime = (): Promise<void> => {
1352
1430
  runtimeShutdown ??= (async () => {
1431
+ // session_shutdown is confirmed, unlike either before-event. Start the
1432
+ // workflow's owned-agent cleanup while its RPC responder and the old leaf
1433
+ // are still available, regardless of which extension loads first.
1434
+ const pending: Array<
1435
+ Promise<{ settled: boolean; pending: string[]; diagnostic?: string }>
1436
+ > = [];
1437
+ pi.events.emit("pi-workflows:shutdown-quiesce", {
1438
+ respond: (
1439
+ operation: Promise<{
1440
+ settled: boolean;
1441
+ pending: string[];
1442
+ diagnostic?: string;
1443
+ }>,
1444
+ ) => {
1445
+ pending.push(operation);
1446
+ },
1447
+ });
1448
+ scheduler.stop();
1449
+ currentCtx = undefined; // reject new spawns; owned cleanup RPC remains bound
1450
+ if (pending.length > 0) {
1451
+ let timer: ReturnType<typeof setTimeout> | undefined;
1452
+ const results = await Promise.race([
1453
+ Promise.allSettled(pending),
1454
+ new Promise<undefined>((resolve) => {
1455
+ timer = setTimeout(() => resolve(undefined), 6_000);
1456
+ }),
1457
+ ]);
1458
+ if (timer) clearTimeout(timer);
1459
+ if (
1460
+ !results ||
1461
+ results.some(
1462
+ (result) => result.status === "rejected" || !result.value.settled,
1463
+ )
1464
+ ) {
1465
+ console.warn(
1466
+ "[pi-subagents] workflow shutdown quiescence timed out or failed; pending work will be quarantined",
1467
+ );
1468
+ }
1469
+ }
1470
+ const quiesced = await manager.quiesceAll(5_000);
1471
+ if (!quiesced.settled) {
1472
+ console.warn(
1473
+ `[pi-subagents] shutdown quiescence timed out; quarantined: ${quiesced.pending.join(", ")}`,
1474
+ );
1475
+ }
1476
+ sessionGeneration++;
1353
1477
  unsubscribeRootContext();
1354
1478
  rpcHandle?.unsubSpawn();
1355
1479
  rpcHandle?.unsubSpawnManaged();
@@ -1474,7 +1598,11 @@ function activateRootRuntime(
1474
1598
  const effectiveTier = (() => {
1475
1599
  try {
1476
1600
  return selectAgentTier(
1477
- { requestedTier: request.tier, requireTier: true, agentConfig: customConfig },
1601
+ {
1602
+ requestedTier: request.tier,
1603
+ requireTier: true,
1604
+ agentConfig: customConfig,
1605
+ },
1478
1606
  agentTiers,
1479
1607
  )?.tier;
1480
1608
  } catch {
@@ -1496,7 +1624,12 @@ function activateRootRuntime(
1496
1624
  resolvedConfig.maxTurns ?? getDefaultMaxTurns(),
1497
1625
  );
1498
1626
  const effectiveIsolation = request.isolation ?? resolvedConfig.isolation;
1499
- const effectiveExcludeTools = [...new Set([...(customConfig?.disallowedTools ?? []), ...(request.excludeTools ?? [])])];
1627
+ const effectiveExcludeTools = [
1628
+ ...new Set([
1629
+ ...(customConfig?.disallowedTools ?? []),
1630
+ ...(request.excludeTools ?? []),
1631
+ ]),
1632
+ ];
1500
1633
  const managedPolicy: ManagedSpawnPolicy = {
1501
1634
  maxTurns: effectiveMaxTurns,
1502
1635
  isolated: resolvedConfig.isolated,
@@ -1747,8 +1880,8 @@ function activateRootRuntime(
1747
1880
 
1748
1881
  // ---- Agent tool description mode ----
1749
1882
  // "full" (default) keeps the rich Claude Code-style description; "compact"
1750
- // swaps in a ~75% smaller one for small/local models (#91). Read once at
1751
- // tool registration — flipping it applies on the next pi session.
1883
+ // swaps in a ~75% smaller one for small/local models (#91). The description
1884
+ // getter rechecks configuration authority so replacements cannot retain denied text.
1752
1885
  let toolDescriptionMode: ToolDescriptionMode = "full";
1753
1886
  function getToolDescriptionMode(): ToolDescriptionMode {
1754
1887
  return toolDescriptionMode;
@@ -1848,40 +1981,47 @@ function activateRootRuntime(
1848
1981
 
1849
1982
  // Apply the already-validated session settings and emit the same lifecycle
1850
1983
  // event that the normal startup path exposes. Do not re-read process.cwd().
1851
- applySettings(startupSettings, {
1852
- setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
1853
- setDefaultMaxTurns,
1854
- setGraceTurns,
1855
- setDefaultMaxTokens,
1856
- setDefaultMaxToolCalls,
1857
- setDefaultToolTimeout: (ms) => setDefaultToolTimeoutMs(ms),
1858
- setDefaultJoinMode,
1859
- setDefaultModel,
1860
- setSchedulingEnabled,
1861
- setScopeModels: setScopeModelsEnabled,
1862
- setStrictAgentFiles: (enabled) => {
1863
- strictAgentFiles = enabled;
1864
- },
1865
- setDisableDefaultAgents,
1866
- setToolDescriptionMode,
1867
- setFleetView: setFleetViewEnabled,
1868
- setOutputTranscript: setOutputTranscriptDefault,
1869
- setRememberAgents: (enabled) => manager.setRememberAgents(enabled),
1870
- setAgentMentions: (enabled) => {
1871
- agentMentionMode = enabled;
1872
- },
1873
- setSupervisorQuestions: (enabled) => {
1874
- supervisorQuestionsEnabled = enabled;
1875
- manager.setSupervisorQuestions(enabled);
1876
- },
1877
- setMaxSubagentDepth,
1878
- setMaxSubagentSpawnsPerBranch: (n) =>
1879
- manager.setMaxSubagentSpawnsPerBranch(n),
1880
- setFallbackSubagent,
1881
- setWorktreeIsolation: setWorktreeIsolationEnabled,
1882
- setAgentTiers: setAgentTiersSettings,
1883
- });
1884
- pi.events.emit("subagents:settings_loaded", { settings: startupSettings });
1984
+ const baselineSettings: SubagentsSettings = { ...snapshotSettings(), strictAgentFiles: false, agentTiers: {} };
1985
+ function applyRuntimeSettings(settings: SubagentsSettings): void {
1986
+ // Clear optional policies as well: absence must not retain the old project's values.
1987
+ setDefaultModel(settings.defaultModel ?? baselineSettings.defaultModel);
1988
+ setFallbackSubagent(settings.fallbackSubagent ?? baselineSettings.fallbackSubagent);
1989
+ applySettings({ ...baselineSettings, ...settings }, {
1990
+ setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
1991
+ setDefaultMaxTurns,
1992
+ setGraceTurns,
1993
+ setDefaultMaxTokens,
1994
+ setDefaultMaxToolCalls,
1995
+ setDefaultToolTimeout: (ms) => setDefaultToolTimeoutMs(ms),
1996
+ setDefaultJoinMode,
1997
+ setDefaultModel,
1998
+ setSchedulingEnabled,
1999
+ setScopeModels: setScopeModelsEnabled,
2000
+ setStrictAgentFiles: (enabled) => {
2001
+ strictAgentFiles = enabled;
2002
+ },
2003
+ setDisableDefaultAgents,
2004
+ setToolDescriptionMode,
2005
+ setFleetView: setFleetViewEnabled,
2006
+ setOutputTranscript: setOutputTranscriptDefault,
2007
+ setRememberAgents: (enabled) => manager.setRememberAgents(enabled),
2008
+ setAgentMentions: (enabled) => {
2009
+ agentMentionMode = enabled;
2010
+ },
2011
+ setSupervisorQuestions: (enabled) => {
2012
+ supervisorQuestionsEnabled = enabled;
2013
+ manager.setSupervisorQuestions(enabled);
2014
+ },
2015
+ setMaxSubagentDepth,
2016
+ setMaxSubagentSpawnsPerBranch: (n) =>
2017
+ manager.setMaxSubagentSpawnsPerBranch(n),
2018
+ setFallbackSubagent,
2019
+ setWorktreeIsolation: setWorktreeIsolationEnabled,
2020
+ setAgentTiers: setAgentTiersSettings,
2021
+ });
2022
+ pi.events.emit("subagents:settings_loaded", { settings });
2023
+ }
2024
+ applyRuntimeSettings(startupSettings);
1885
2025
 
1886
2026
  // A tier that nothing defines is a typo, and the resolver would only reach it
1887
2027
  // on the first spawn that needs it — possibly minutes in, mid-task. Report it
@@ -1963,18 +2103,25 @@ function activateRootRuntime(
1963
2103
  }
1964
2104
 
1965
2105
  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
- );
2106
+ try {
2107
+ const resumed = await manager.resume(
2108
+ record.id,
2109
+ mention.message,
2110
+ undefined,
2111
+ { isBackground: true },
2112
+ );
2113
+ ctx.ui.notify(
2114
+ resumed
2115
+ ? `Resuming ${target}`
2116
+ : `Could not resume ${target} — it is still running.`,
2117
+ resumed ? "info" : "warning",
2118
+ );
2119
+ } catch (error) {
2120
+ ctx.ui.notify(
2121
+ `Could not resume ${target}: ${error instanceof Error ? error.message : String(error)}`,
2122
+ "warning",
2123
+ );
2124
+ }
1978
2125
  return { action: "handled" };
1979
2126
  }
1980
2127
  // A live record with no session never got far enough to continue, so it
@@ -2053,37 +2200,82 @@ function activateRootRuntime(
2053
2200
  if (!type) return { action: "continue" };
2054
2201
 
2055
2202
  const label = `@${handleBase(type)}`;
2203
+ const originGeneration = sessionGeneration;
2204
+ const originSessionId = ctx.sessionManager.getSessionId();
2205
+ const isOriginCurrent = (): boolean =>
2206
+ currentCtx !== undefined &&
2207
+ sessionGeneration === originGeneration &&
2208
+ boundSessionId === originSessionId &&
2209
+ ctx.sessionManager.getSessionId() === originSessionId;
2056
2210
  const startDirectly = (): void => {
2057
- spawnMention(ctx, type, mention.message, {
2211
+ // The hidden model turn may outlive a change to agent files or settings.
2212
+ // Revalidate the handle rather than bypassing Agent's dispatch policy
2213
+ // with a type that has since been disabled or removed.
2214
+ reloadCustomAgents();
2215
+ const current = resolveSpawnType(type);
2216
+ if (
2217
+ !current.ok ||
2218
+ current.fellBackFrom !== undefined ||
2219
+ current.type !== type
2220
+ ) {
2221
+ throw new Error(`Agent type "${type}" is no longer available`);
2222
+ }
2223
+ spawnMention(ctx, current.type, mention.message, {
2058
2224
  description: describeMention(mention.message),
2059
2225
  });
2060
2226
  };
2061
2227
 
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.
2066
- const registeredAgentTool = agentToolRef;
2228
+ // In `model` mode an off-screen mention-only turn rewrites the typed task
2229
+ // without copying parent history or its request-local context hooks.
2230
+ // Nothing reaches the chat, and it starts an ordinary top-level agent.
2231
+ const registeredAgentTool = agentToolRef ? freshAgentToolDefinition() : undefined;
2067
2232
  if (getAgentMentionMode() === "model" && registeredAgentTool) {
2233
+ // The hidden turn is itself a provider call. Refuse before saying
2234
+ // "Starting" or spending that call when its inherited parent model is
2235
+ // unavailable. A shape-only check would let the hidden clone refuse, then
2236
+ // let its direct fallback allocate an ID and announce an agent that fails.
2237
+ try {
2238
+ parentModelSessionOptions(ctx, ctx.model);
2239
+ } catch (err) {
2240
+ ctx.ui.notify(
2241
+ `Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`,
2242
+ "error",
2243
+ );
2244
+ return { action: "handled" };
2245
+ }
2068
2246
  ctx.ui.notify(`Starting ${label}…`, "info");
2069
2247
  // Not awaited: the clone runs a full model turn and `prompt()` is blocked
2070
2248
  // 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
- );
2249
+ void runMentionClone({
2250
+ ctx,
2251
+ type,
2252
+ message: mention.message,
2253
+ agentTool: registeredAgentTool,
2254
+ isOriginCurrent,
2255
+ }).then((result) => {
2256
+ if (result.spawned || !isOriginCurrent()) return;
2257
+ if (result.refused) {
2258
+ ctx.ui.notify(
2259
+ `Could not start ${label} — ${result.error}`,
2260
+ "warning",
2261
+ );
2262
+ return;
2263
+ }
2264
+ // A clone that could not run must not swallow the mention: start the
2265
+ // agent the direct way rather than leaving a toast and nothing running.
2266
+ try {
2267
+ startDirectly();
2268
+ ctx.ui.notify(
2269
+ `Started ${label} directly — ${result.error}`,
2270
+ "warning",
2271
+ );
2272
+ } catch (err) {
2273
+ ctx.ui.notify(
2274
+ `Could not start ${label}: ${err instanceof Error ? err.message : String(err)}`,
2275
+ "error",
2276
+ );
2277
+ }
2278
+ });
2087
2279
  return { action: "handled" };
2088
2280
  }
2089
2281
 
@@ -2129,15 +2321,13 @@ function activateRootRuntime(
2129
2321
  // The catalogue is injected into the description rather than left to a lookup
2130
2322
  // tool: the host has to pick a tier on its first call, and a tool it must
2131
2323
  // remember to call first is a tool it will skip.
2132
- const tierListText = buildAgentTierListText();
2133
- const compactTierListText = buildCompactAgentTierListText();
2134
- const tierSection = tierListText ? `\n\n${tierListText}` : "";
2135
- const compactTierSection = compactTierListText
2136
- ? `\n\n${compactTierListText}`
2137
- : "";
2324
+ const tierSection = (compact = false): string => {
2325
+ const text = compact ? buildCompactAgentTierListText() : buildAgentTierListText();
2326
+ return text ? `\n\n${text}` : "";
2327
+ };
2138
2328
 
2139
- const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
2140
- ${buildCompactTypeListText()}${compactTierSection}
2329
+ const compactAgentToolDescription = () => `Launch an autonomous agent for complex, multi-step tasks. Agent types:
2330
+ ${buildCompactTypeListText()}${tierSection(true)}
2141
2331
 
2142
2332
  Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
2143
2333
 
@@ -2146,14 +2336,15 @@ Notes:
2146
2336
  - 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
2337
  - The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
2148
2338
  - 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.`;
2339
+ - isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.
2340
+ - Raw inherit_context is unavailable; put only an explicitly sanitized summary in the task prompt.`;
2150
2341
 
2151
- const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
2342
+ 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
2343
 
2153
2344
  Available agent types and the tools they have access to:
2154
2345
  ${buildTypeListText()}
2155
2346
 
2156
- Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.${tierSection}
2347
+ Custom agents can be defined in .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.${tierSection()}
2157
2348
 
2158
2349
  When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
2159
2350
 
@@ -2174,7 +2365,7 @@ If the target is already known, use a direct tool — \`read\` for a known path,
2174
2365
  - 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
2366
  - 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
2367
  - 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.
2368
+ - 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
2369
  - 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
2370
 
2180
2371
  ## Writing the prompt
@@ -2201,8 +2392,8 @@ Terse command-style prompts produce shallow, generic work.
2201
2392
  // Both carry their own leading blank line, so a template that drops the
2202
2393
  // placeholder inline renders byte-identically to the built-in description
2203
2394
  // whether or not any tier is configured.
2204
- tierList: () => tierSection,
2205
- compactTierList: () => compactTierSection,
2395
+ tierList: () => tierSection(),
2396
+ compactTierList: () => tierSection(true),
2206
2397
  defaultTier: getDefaultAgentTierText,
2207
2398
  agentDir: getAgentDir,
2208
2399
  scheduleGuideline: () => scheduleGuideline,
@@ -2218,10 +2409,12 @@ Terse command-style prompts produce shallow, generic work.
2218
2409
  };
2219
2410
 
2220
2411
  const loadCustomToolDescription = (): string | undefined => {
2412
+ const trust = captureProjectTrust(configurationOwner);
2221
2413
  for (const path of [
2222
- join(sessionCwd, ".pi", "agent-tool-description.md"),
2414
+ ...(trust.trusted ? [join(trust.cwd, ".pi", "agent-tool-description.md")] : []),
2223
2415
  join(getAgentDir(), "agent-tool-description.md"),
2224
2416
  ]) {
2417
+ if (!trust.trusted && isProjectResource(path, trust.cwd)) continue;
2225
2418
  try {
2226
2419
  if (!existsSync(path)) continue;
2227
2420
  const text = readFileSync(path, "utf-8").trim();
@@ -2236,9 +2429,11 @@ Terse command-style prompts produce shallow, generic work.
2236
2429
  return undefined;
2237
2430
  };
2238
2431
 
2239
- const agentToolDescription = (() => {
2432
+ const agentToolDescription = () => {
2433
+ const trust = captureProjectTrust(configurationOwner);
2434
+ if (trust.cwd !== activeTrust.cwd || trust.trusted !== activeTrust.trusted) reloadCustomAgents();
2240
2435
  const mode = getToolDescriptionMode();
2241
- if (mode === "compact") return compactAgentToolDescription;
2436
+ if (mode === "compact") return compactAgentToolDescription();
2242
2437
  if (mode === "custom") {
2243
2438
  const custom = loadCustomToolDescription();
2244
2439
  if (custom) return custom;
@@ -2246,798 +2441,850 @@ Terse command-style prompts produce shallow, generic work.
2246
2441
  '[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"',
2247
2442
  );
2248
2443
  }
2249
- return fullAgentToolDescription;
2250
- })();
2444
+ return fullAgentToolDescription();
2445
+ };
2251
2446
 
2252
2447
  // Captured so the mention clone can hand the copy the REAL registered tool:
2253
2448
  // its handler closes over this activation, which is what makes a clone-driven
2254
2449
  // spawn an ordinary top-level agent rather than something the fork owns.
2450
+ const agentTypeParameterDescription = () =>
2451
+ `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.`;
2255
2452
  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.",
2453
+ name: SUBAGENT_TOOL_NAMES.AGENT,
2454
+ label: "Agent",
2455
+ get description() { return agentToolDescription(); },
2456
+ promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
2457
+ promptGuidelines: [
2458
+ "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.",
2459
+ "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.",
2460
+ "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.",
2461
+ "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.",
2462
+ ],
2463
+ parameters: Type.Object({
2464
+ prompt: Type.String({
2465
+ description: "The task for the agent to perform.",
2466
+ }),
2467
+ description: Type.String({
2468
+ description:
2469
+ "A short (3-5 word) description of the task (shown in UI).",
2470
+ }),
2471
+ subagent_type: Type.String({
2472
+ description: agentTypeParameterDescription(),
2473
+ }),
2474
+ tier: Type.Optional(
2475
+ Type.String({
2476
+ description: buildAgentTierParameterDescription(),
2270
2477
  }),
2271
- description: Type.String({
2478
+ ),
2479
+ max_turns: Type.Optional(
2480
+ Type.Number({
2272
2481
  description:
2273
- "A short (3-5 word) description of the task (shown in UI).",
2482
+ "Maximum number of agentic turns before stopping. Omit for unlimited (default).",
2483
+ minimum: 1,
2274
2484
  }),
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.`,
2485
+ ),
2486
+ run_in_background: Type.Optional(
2487
+ Type.Boolean({
2488
+ description:
2489
+ "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.",
2277
2490
  }),
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({
2491
+ ),
2492
+ resume: Type.Optional(
2493
+ Type.String({
2494
+ description:
2495
+ "Optional agent ID to resume from. Continues from previous context.",
2496
+ }),
2497
+ ),
2498
+ isolated: Type.Optional(
2499
+ Type.Boolean({
2500
+ description:
2501
+ "If true, agent gets no extension/MCP tools — only built-in tools.",
2502
+ }),
2503
+ ),
2504
+ inherit_context: Type.Optional(
2505
+ Type.Boolean({
2506
+ description:
2507
+ "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.",
2508
+ }),
2509
+ ),
2510
+ isolation: Type.Optional(
2511
+ Type.Union(
2512
+ [
2513
+ Type.Literal("worktree", {
2514
+ description:
2515
+ "Run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.",
2516
+ }),
2517
+ Type.Literal("off", {
2518
+ description:
2519
+ "Explicitly disable worktree isolation for this agent.",
2520
+ }),
2521
+ ],
2522
+ {
2310
2523
  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
- ),
2524
+ 'Isolation mode: "worktree" for isolated git worktree, "off" to explicitly disable.',
2525
+ },
2329
2526
  ),
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
- }
2527
+ ),
2528
+ ...scheduleParam,
2529
+ }),
2361
2530
 
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
- };
2531
+ // ---- Custom rendering: Claude Code style ----
2532
+
2533
+ renderCall(args, theme) {
2534
+ const displayName = args.subagent_type
2535
+ ? getDisplayName(args.subagent_type)
2536
+ : "Agent";
2537
+ const desc = sanitizeDisplayText(args.description ?? "");
2538
+ const text = [
2539
+ theme.fg("toolTitle", theme.bold(displayName)),
2540
+ desc ? theme.fg("muted", desc) : undefined,
2541
+ ]
2542
+ .filter((part): part is string => part !== undefined)
2543
+ .join(theme.fg("dim", " · "));
2544
+ return new Text(text, 0, 0);
2545
+ },
2375
2546
 
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
- }
2547
+ renderResult(result, { expanded, isPartial }, theme, renderContext) {
2548
+ // Everything below draws child-derived text into the parent transcript
2549
+ // through pi-tui's ANSI-preserving renderer, so it is scrubbed on the way in.
2550
+ const text = safeTerminalText(
2551
+ result.content[0]?.type === "text" ? result.content[0].text : "",
2552
+ );
2553
+ const details = result.details as AgentDetails | undefined;
2554
+ // Pre-execution failures have no agent status. Preserve pi's error text
2555
+ // instead of rendering them as an invented subagent failure.
2556
+ if (renderContext?.isError || !details?.status) {
2557
+ return new Text(text, 0, 0);
2558
+ }
2387
2559
 
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
- );
2560
+ const stats = (d: AgentDetails) => {
2561
+ const parts: string[] = [];
2562
+ if (d.modelName) parts.push(d.modelName);
2563
+ if (d.tags) parts.push(...d.tags);
2564
+ if (d.turnCount != null && d.turnCount > 0) {
2565
+ parts.push(formatTurns(d.turnCount, d.maxTurns));
2397
2566
  }
2567
+ if (d.toolUses > 0) parts.push(`tools ${d.toolUses}`);
2568
+ if (d.tokens) parts.push(d.tokens);
2569
+ return parts
2570
+ .map((p) => fgPreservingNestedStyles(theme, "dim", p))
2571
+ .join(theme.fg("dim", " · "));
2572
+ };
2398
2573
 
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
- }
2574
+ if (details.status === "queued") {
2575
+ const id = details.agentId ? ` · ID ${details.agentId}` : "";
2576
+ return new Text(
2577
+ theme.fg(
2578
+ "dim",
2579
+ `${getAgentStatusMark("queued")} queued · background${id}`,
2580
+ ),
2581
+ 0,
2582
+ 0,
2583
+ );
2584
+ }
2410
2585
 
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
- }
2586
+ if (isPartial || details.status === "running") {
2587
+ const frame = SPINNER[details.spinnerFrame ?? 0] ?? SPINNER[0]!;
2588
+ const s = stats(details);
2589
+ return renderRunningAgentStatus(
2590
+ frame,
2591
+ s,
2592
+ details.activity ?? "Thinking...",
2593
+ theme,
2594
+ );
2595
+ }
2449
2596
 
2450
- if (details.status === "stopped") {
2451
- const s = stats(details);
2452
- let line = theme.fg(
2597
+ if (details.status === "background") {
2598
+ const id = details.agentId ? ` · ID ${details.agentId}` : "";
2599
+ return new Text(
2600
+ theme.fg(
2453
2601
  "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
- }
2602
+ `${getAgentStatusMark("running")} running · background${id}`,
2603
+ ),
2604
+ 0,
2605
+ 0,
2606
+ );
2607
+ }
2466
2608
 
2609
+ if (details.status === "completed" || details.status === "steered") {
2610
+ const duration = formatMs(details.durationMs);
2611
+ const isSteered = details.status === "steered";
2612
+ const statusText = isSteered ? "wrapped up · turn limit" : "completed";
2613
+ const statusColor = isSteered ? "warning" : "dim";
2467
2614
  const s = stats(details);
2468
- const isError = details.status === "error";
2469
2615
  let line = theme.fg(
2470
- isError ? "error" : "warning",
2471
- `${getAgentStatusMark(details.status)} ${isError ? "failed" : "aborted"}`,
2616
+ statusColor,
2617
+ `${getAgentStatusMark(details.status)} ${statusText}`,
2472
2618
  );
2473
2619
  if (s) line += theme.fg("dim", " · ") + s;
2620
+ line += theme.fg("dim", " · ") + theme.fg("dim", duration);
2474
2621
 
2475
- if (isError) {
2476
- line +=
2477
- "\n" +
2478
- theme.fg(
2479
- "error",
2480
- ` Error: ${sanitizeDisplayText(details.error ?? "unknown")}`,
2481
- );
2622
+ if (expanded) {
2623
+ if (text) {
2624
+ const lines = text.split("\n").slice(0, 50);
2625
+ for (const l of lines) {
2626
+ line += "\n" + theme.fg("dim", ` ${l}`);
2627
+ }
2628
+ if (text.split("\n").length > 50) {
2629
+ line +=
2630
+ "\n" +
2631
+ theme.fg(
2632
+ "muted",
2633
+ " ... (use get_subagent_result with verbose for full output)",
2634
+ );
2635
+ }
2636
+ }
2482
2637
  } else {
2483
- line += "\n" + theme.fg("warning", " Aborted at the turn limit");
2638
+ const doneText = isSteered ? "Wrapped up at the turn limit" : "Done";
2639
+ line += "\n" + theme.fg("dim", ` ${doneText}`);
2484
2640
  }
2641
+ return new Text(line, 0, 0);
2642
+ }
2485
2643
 
2644
+ if (details.status === "stopped") {
2645
+ const s = stats(details);
2646
+ let line = theme.fg("dim", `${getAgentStatusMark("stopped")} stopped`);
2647
+ if (s) line += theme.fg("dim", " · ") + s;
2648
+ line += "\n" + theme.fg("dim", " Stopped before completion");
2486
2649
  return new Text(line, 0, 0);
2487
- },
2650
+ }
2488
2651
 
2489
- // ---- Execute ----
2652
+ // Keep unknown/future statuses from falling through to the turn-limit
2653
+ // renderer, which is only valid for explicit error/aborted outcomes.
2654
+ if (details.status !== "error" && details.status !== "aborted") {
2655
+ return new Text(text, 0, 0);
2656
+ }
2490
2657
 
2491
- execute: async (toolCallId, params, signal, onUpdate, ctx) => {
2492
- // Ensure we have UI context for the FleetView list
2493
- seatFleet(ctx);
2658
+ const s = stats(details);
2659
+ const isError = details.status === "error";
2660
+ let line = theme.fg(
2661
+ isError ? "error" : "warning",
2662
+ `${getAgentStatusMark(details.status)} ${isError ? "failed" : "aborted"}`,
2663
+ );
2664
+ if (s) line += theme.fg("dim", " · ") + s;
2494
2665
 
2495
- // Reload custom agents so new project/global .md files are picked up without restart
2496
- reloadCustomAgents();
2666
+ if (isError) {
2667
+ line +=
2668
+ "\n" +
2669
+ theme.fg(
2670
+ "error",
2671
+ ` Error: ${sanitizeDisplayText(details.error ?? "unknown")}`,
2672
+ );
2673
+ } else {
2674
+ line += "\n" + theme.fg("warning", " Aborted at the turn limit");
2675
+ }
2497
2676
 
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
- });
2677
+ return new Text(line, 0, 0);
2678
+ },
2535
2679
 
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
- }
2680
+ // ---- Execute ----
2554
2681
 
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");
2682
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
2683
+ // Ensure we have UI context for the FleetView list
2684
+ seatFleet(ctx);
2685
+
2686
+ // Reload custom agents so new project/global .md files are picked up without restart
2687
+ reloadCustomAgents();
2688
+
2689
+ const rawType = params.subagent_type as SubagentType;
2690
+ // Single decision point for dispatch (#183): unknown, disabled and
2691
+ // case-ambiguous types are refused here, BEFORE anything spawns, so a
2692
+ // background or scheduled call can't start running the wrong agent while
2693
+ // the caller is still unaware. `fallbackSubagent` decides whether an
2694
+ // unresolvable type falls back or fails closed.
2695
+ const dispatch = resolveSpawnType(rawType);
2696
+ // `resume` replays a stored session and ignores `subagent_type` entirely,
2697
+ // but the parameter is required by the schema — so gating it here would
2698
+ // make a live agent unresumable the moment its type is deleted, disabled,
2699
+ // or gains a case-clashing sibling. Only a real spawn is gated.
2700
+ if (!dispatch.ok && !params.resume) return textResult(dispatch.message);
2701
+ const subagentType = dispatch.ok ? dispatch.type : rawType;
2702
+ // What the caller actually asked for, named once: `fellBackFrom` is "" for
2703
+ // a blank request, so reading it inline invites the `??`-vs-`||` slip that
2704
+ // once persisted an empty type into a scheduled job.
2705
+ const requestedType =
2706
+ (dispatch.ok && dispatch.fellBackFrom) || subagentType;
2707
+ // Computed at resolution rather than after the run, so the background and
2708
+ // schedule branches carry it too — previously it existed only on the
2709
+ // foreground path. Resume deliberately doesn't: it replays the stored
2710
+ // session and ignores `subagent_type` entirely, so a note about type
2711
+ // substitution would be describing something that didn't happen.
2712
+ const fallbackNote =
2713
+ dispatch.ok && dispatch.fellBackFrom !== undefined
2714
+ ? `Note: Unknown agent type "${dispatch.fellBackFrom}" — using ${resolveType(subagentType) ? subagentType : "the fallback agent config"}.\n\n`
2715
+ : "";
2716
+
2717
+ const displayName = getDisplayName(subagentType);
2718
+
2719
+ // Get agent config (if any)
2720
+ const customConfig = getAgentConfig(subagentType);
2721
+
2722
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, {
2723
+ ...params,
2724
+ agentTiers: getAgentTiersSettings(),
2725
+ });
2726
+ if (!params.resume && !params.schedule && resolvedConfig.inheritContext) {
2727
+ return textResult(INHERIT_CONTEXT_UNAVAILABLE);
2728
+ }
2729
+
2730
+ // A selected Agent tier owns final model/thinking resolution. Keep both
2731
+ // fields unset here so runAgent is the only resolver and ordinary Agent
2732
+ // calls cannot accidentally bypass the profile with a parent/default pin.
2733
+ let model = resolvedConfig.agentTierSelected
2734
+ ? undefined
2735
+ : (resolveConfiguredDefaultModel(ctx.modelRegistry) ?? ctx.model);
2736
+ if (!resolvedConfig.agentTierSelected && resolvedConfig.modelInput) {
2737
+ const resolved = resolveModel(
2738
+ resolvedConfig.modelInput,
2739
+ ctx.modelRegistry,
2740
+ );
2741
+ if (typeof resolved === "string") {
2742
+ if (resolvedConfig.modelFromParams) return textResult(resolved);
2743
+ // config-specified: silent fallback to the default model, then parent
2744
+ } else {
2745
+ model = resolved;
2570
2746
  }
2747
+ }
2571
2748
 
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
- };
2749
+ // Tiered model scope is checked after the single final resolution in
2750
+ // runAgent. Only the legacy no-tier path is checked here.
2751
+ if (!resolvedConfig.agentTierSelected) {
2752
+ const scopeVerdict = checkModelScope({
2753
+ model,
2754
+ cwd: activeTrust.cwd,
2755
+ authority: activeTrust,
2756
+ modelRegistry: ctx.modelRegistry,
2757
+ callerSupplied: resolvedConfig.modelFromParams,
2758
+ agentLabel: customConfig?.displayName ?? subagentType,
2759
+ modelInput: resolvedConfig.modelInput,
2760
+ });
2761
+ if (scopeVerdict.kind === "error")
2762
+ return textResult(scopeVerdict.message);
2763
+ if (scopeVerdict.kind === "warn")
2764
+ ctx.ui.notify(scopeVerdict.message, "warning");
2765
+ }
2596
2766
 
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(),
2767
+ const thinking = resolvedConfig.thinking;
2768
+ const inheritContext = resolvedConfig.inheritContext;
2769
+ const runInBackground = resolvedConfig.runInBackground;
2770
+ const isolated = resolvedConfig.isolated;
2771
+ const isolation = resolvedConfig.isolation;
2772
+ // Whether this spawn writes its .output transcript. Per-agent
2773
+ // frontmatter (`output_transcript`) wins; otherwise the project/global
2774
+ // default applies. `attachTranscript` below is the SOLE gate — every
2775
+ // downstream consumer keys off record.outputFile being set, so no spawn
2776
+ // path can re-enable the transcript by accident.
2777
+ const outputTranscript =
2778
+ customConfig?.outputTranscript ?? getOutputTranscriptDefault();
2779
+ const attachTranscript = (
2780
+ rec: AgentRecord | undefined,
2781
+ agentId: string,
2782
+ ): void => {
2783
+ if (!rec || !outputTranscript) return;
2784
+ rec.outputFile = createOutputFilePath(
2785
+ ctx.cwd,
2786
+ agentId,
2787
+ ctx.sessionManager.getSessionId(),
2605
2788
  );
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
- };
2789
+ writeInitialEntry(rec.outputFile, agentId, params.prompt, ctx.cwd);
2790
+ };
2633
2791
 
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
- }
2792
+ // Untiered spawns only. A tier resolves its model inside runAgent, and
2793
+ // its resolution callback supplies the label from there — computing one
2794
+ // here would name a model this path never resolved.
2795
+ const parentModelId = ctx.model?.id;
2796
+ const modelName =
2797
+ model && model.id !== parentModelId
2798
+ ? shortModelLabel(model)
2799
+ : undefined;
2800
+ const effectiveMaxTurns = normalizeMaxTurns(
2801
+ resolvedConfig.maxTurns ?? getDefaultMaxTurns(),
2802
+ );
2803
+ const agentInvocation: AgentInvocation = {
2804
+ modelName,
2805
+ ...((resolvedConfig.requestedAgentTier ?? customConfig?.agentTier) ===
2806
+ undefined
2807
+ ? {}
2808
+ : {
2809
+ agentTier:
2810
+ resolvedConfig.requestedAgentTier ?? customConfig?.agentTier,
2811
+ }),
2812
+ thinking,
2813
+ // Explicit value only — the default fallback would just add noise.
2814
+ // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
2815
+ maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
2816
+ isolated,
2817
+ inheritContext,
2818
+ runInBackground,
2819
+ isolation,
2820
+ };
2821
+ // Tool-result render shows the mode label too; viewer's header already does.
2822
+ const modeLabel = getPromptModeLabel(subagentType);
2823
+ const { tags: invocationTags } = buildInvocationTags(agentInvocation);
2824
+ const agentTags = modeLabel
2825
+ ? [modeLabel, ...invocationTags]
2826
+ : invocationTags;
2827
+ const detailBase = {
2828
+ displayName,
2829
+ description: params.description,
2830
+ subagentType,
2831
+ modelName,
2832
+ tags: agentTags.length > 0 ? agentTags : undefined,
2833
+ };
2694
2834
 
2695
- // Resume existing agent
2835
+ // ---- Schedule: register a job, don't spawn now ----
2836
+ if (params.schedule) {
2837
+ if (!isSchedulingEnabled()) {
2838
+ return textResult(
2839
+ "Scheduling is disabled in this project. Enable via /agents, Settings, Scheduling.",
2840
+ );
2841
+ }
2696
2842
  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
- }
2843
+ return textResult(
2844
+ "Cannot combine `schedule` with `resume` — schedules create fresh agents.",
2845
+ );
2846
+ }
2847
+ if (inheritContext) {
2848
+ return textResult(
2849
+ "Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time.",
2850
+ );
2851
+ }
2852
+ if (params.run_in_background === false) {
2853
+ return textResult(
2854
+ "Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background.",
2855
+ );
2856
+ }
2857
+ if (!scheduler.isActive()) {
2858
+ return textResult(
2859
+ "Scheduler is not active in this session yet. Try again after the session has fully started.",
2860
+ );
2861
+ }
2862
+ try {
2863
+ const job = scheduler.addJob({
2864
+ name: params.description as string,
2865
+ description: params.description as string,
2866
+ schedule: params.schedule as string,
2867
+ // The caller's own name, not the substitute — the scheduler re-resolves
2868
+ // at fire time, and the original is what a user edits.
2869
+ subagent_type: requestedType,
2870
+ prompt: params.prompt as string,
2871
+ // Only an explicitly requested tier is frozen into the job. An
2872
+ // agent's frontmatter tier is deliberately not copied: it is read
2873
+ // again at fire time, so editing the agent file still takes
2874
+ // effect, and it keeps its "frontmatter" source rather than being
2875
+ // replayed as a caller choice.
2876
+ tier: resolvedConfig.requestedAgentTier,
2877
+ // Store the resolved policy input (agent config first, tool params second)
2878
+ // so scheduled fires use the same model fallback as an immediate spawn.
2879
+ model: resolvedConfig.agentTierSelected
2880
+ ? undefined
2881
+ : resolvedConfig.modelInput,
2882
+ thinking: resolvedConfig.agentTierSelected ? undefined : thinking,
2883
+ max_turns: effectiveMaxTurns,
2884
+ isolated: isolated,
2885
+ isolation: isolation,
2886
+ });
2887
+ const next = scheduler.getNextRun(job.id);
2888
+ return textResult(
2889
+ `${fallbackNote}Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` +
2890
+ `Next run: ${next ?? "(unknown)"}. ` +
2891
+ `Manage via /agents, Scheduled jobs.`,
2892
+ );
2893
+ } catch (err) {
2894
+ return textResult(err instanceof Error ? err.message : String(err));
2895
+ }
2896
+ }
2711
2897
 
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(
2898
+ // Resume existing agent
2899
+ if (params.resume) {
2900
+ const existing = manager.getRecordMutable(params.resume);
2901
+ if (!existing || existing.parentAgentId) {
2902
+ return textResult(
2903
+ `Agent not found: "${params.resume}". It may have been cleaned up.`,
2904
+ );
2905
+ }
2906
+ if (!existing.session) {
2907
+ return textResult(
2908
+ `Agent "${params.resume}" has no active session to resume.`,
2909
+ );
2910
+ }
2911
+ if (signal?.aborted) {
2912
+ return textResult("Resume aborted.");
2913
+ }
2914
+
2915
+ // Assigned unconditionally, before either resume path. The completion
2916
+ // notification carries this as `<tool-use-id>` (see
2917
+ // formatTaskNotification), and `manager.resume` clears
2918
+ // `resultConsumed`, so a resumed run does notify. Keeping the id the
2919
+ // original spawn wrote would point that notification at a tool call
2920
+ // answered runs ago; a resume with no tool call of its own must clear
2921
+ // it rather than inherit one.
2922
+ existing.toolCallId = toolCallId;
2923
+
2924
+ // run_in_background on resume: settle asynchronously and notify on
2925
+ // completion like a background spawn, returning immediately. Previously
2926
+ // the flag was accepted then silently dropped — a resumed agent always
2927
+ // blocked the caller until it finished.
2928
+ if (runInBackground) {
2929
+ const { state: bgState, callbacks: bgCallbacks } =
2930
+ createActivityTracker(effectiveMaxTurns);
2931
+ // resumeAgent has no onSessionCreated — the session predates this run
2932
+ // — so seed the activity tracker directly.
2933
+ bgState.session = existing.session;
2934
+ // Reuse the agent's transcript rather than starting a fresh one: the
2935
+ // path is deterministic per agent+session, and writing an initial
2936
+ // entry would truncate the previous run's turns (B1#2).
2937
+ if (outputTranscript) {
2938
+ existing.outputFile = createOutputFilePath(
2939
+ ctx.cwd,
2757
2940
  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),
2941
+ ctx.sessionManager.getSessionId(),
2780
2942
  );
2943
+ ensureOutputFile(existing.outputFile);
2781
2944
  }
2782
-
2945
+ // Anchor streaming past the turns already on disk, captured BEFORE the
2946
+ // run starts. The resumed prompt lands as an ordinary user message at
2947
+ // this index, so it is written exactly once.
2948
+ const transcriptAnchor = existing.session.messages.length ?? 0;
2949
+ const attachTranscript = (): void => {
2950
+ if (!existing.outputFile || existing.outputCleanup) return;
2951
+ existing.outputCleanup = streamToOutputFile(
2952
+ existing.session!,
2953
+ existing.outputFile,
2954
+ params.resume!,
2955
+ ctx.cwd,
2956
+ transcriptAnchor,
2957
+ );
2958
+ };
2783
2959
  const record = await manager.resume(
2784
2960
  params.resume,
2785
2961
  params.prompt,
2786
- signal,
2962
+ undefined,
2963
+ {
2964
+ isBackground: true,
2965
+ onToolActivity: bgCallbacks.onToolActivity,
2966
+ onAssistantUsage: bgCallbacks.onAssistantUsage,
2967
+ onStarted: attachTranscript,
2968
+ },
2787
2969
  );
2788
2970
  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
2971
  return textResult(
2795
- `Agent failed: ${record.error}${partialOutputSuffix(record)}`,
2796
- buildDetails(detailBase, record),
2972
+ `Cannot resume agent "${params.resume}" in background — it is already running. ` +
2973
+ "Wait for it to settle, or steer it with steer_subagent.",
2797
2974
  );
2798
2975
  }
2976
+ agentActivity.set(params.resume, bgState);
2977
+ void bgCallbacks;
2799
2978
  return textResult(
2800
- record.result?.trim() || "No output.",
2979
+ record.status === "queued"
2980
+ ? `Agent "${params.resume}" resumed in background (queued at the concurrency limit).`
2981
+ : `Agent "${params.resume}" resumed in background.`,
2801
2982
  buildDetails(detailBase, record),
2802
2983
  );
2803
2984
  }
2804
2985
 
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
- }
2986
+ const record = await manager.resume(
2987
+ params.resume,
2988
+ params.prompt,
2989
+ signal,
2990
+ );
2991
+ if (!record) {
2992
+ return textResult(`Failed to resume agent "${params.resume}".`);
2993
+ }
2994
+ // A failed resume surfaces the error, plus any partial output THIS
2995
+ // resume produced (never the previous turn's answer, #144).
2996
+ if (record.status === "error") {
2997
+ return textResult(
2998
+ `Agent failed: ${record.error}${partialOutputSuffix(record)}`,
2999
+ buildDetails(detailBase, record),
3000
+ );
3001
+ }
3002
+ return textResult(
3003
+ record.result?.trim() || "No output.",
3004
+ buildDetails(detailBase, record),
3005
+ );
3006
+ }
2854
3007
 
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);
3008
+ // Background execution
3009
+ if (runInBackground) {
3010
+ const { state: bgState, callbacks: bgCallbacks } =
3011
+ createActivityTracker(effectiveMaxTurns);
3012
+
3013
+ // Wrap onSessionCreated to wire output file streaming.
3014
+ // The callback lazily reads record.outputFile (set right after spawn)
3015
+ // rather than closing over a value that doesn't exist yet.
3016
+ let id: string;
3017
+ const origBgOnSession = bgCallbacks.onSessionCreated;
3018
+ bgCallbacks.onSessionCreated = (session: any) => {
3019
+ origBgOnSession(session);
3020
+ const rec = manager.getRecordMutable(id);
3021
+ if (rec?.outputFile) {
3022
+ rec.outputCleanup = streamToOutputFile(
3023
+ session,
3024
+ rec.outputFile,
3025
+ id,
3026
+ ctx.cwd,
3027
+ );
2864
3028
  }
3029
+ };
2865
3030
 
2866
- agentActivity.set(id, bgState);
2867
- fleet.ensureTimer();
2868
- fleet.update();
3031
+ // A startup throw means the agent never started. Let pi mark the tool
3032
+ // call as failed instead of returning a successful-looking text result.
3033
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
3034
+ description: params.description,
3035
+ model: resolvedConfig.agentTierSelected ? undefined : model,
3036
+ maxTurns: effectiveMaxTurns,
3037
+ isolated,
3038
+ inheritContext,
3039
+ thinkingLevel: resolvedConfig.agentTierSelected
3040
+ ? undefined
3041
+ : thinking,
3042
+ agentTier: resolvedConfig.requestedAgentTier,
3043
+ isBackground: true,
3044
+ isolation,
3045
+ invocation: agentInvocation,
3046
+ rootSessionId: ctx.sessionManager.getSessionId(),
3047
+ ...bgCallbacks,
3048
+ });
3049
+ // The clone needs a positive spawn witness before any UI, transcript,
3050
+ // or event observer can throw. A startup failure inside manager.spawn
3051
+ // removes its record and does not reach this acknowledgement.
3052
+ const record = manager.getRecordMutable(id);
3053
+ if (record) {
3054
+ (
3055
+ params as typeof params & {
3056
+ [MENTION_SPAWNED]?: (id: string) => void;
3057
+ }
3058
+ )[MENTION_SPAWNED]?.(id);
3059
+ }
2869
3060
 
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
- }
3061
+ // Set output file + join mode synchronously after spawn, before the
3062
+ // event loop yields — onSessionCreated is async so this is safe.
3063
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
3064
+ if (record && joinMode) {
3065
+ record.joinMode = joinMode;
3066
+ record.toolCallId = toolCallId;
3067
+ attachTranscript(record, id);
3068
+ }
2879
3069
 
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
- );
3070
+ if (joinMode == null || joinMode === "async") {
3071
+ // Foreground/no join mode or explicit async — not part of any batch
3072
+ } else {
3073
+ // smart or group — add to current batch
3074
+ currentBatchAgents.push({ id, joinMode });
3075
+ // Debounce: reset timer on each new agent so parallel tool calls
3076
+ // dispatched across multiple event loop ticks are captured together
3077
+ if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
3078
+ batchFinalizeTimer = setTimeout(finalizeBatch, 100);
2904
3079
  }
2905
3080
 
2906
- // Foreground (synchronous) execution — stream progress via onUpdate
2907
- let spinnerFrame = 0;
2908
- const startedAt = Date.now();
2909
- let fgId: string | undefined;
3081
+ agentActivity.set(id, bgState);
3082
+ fleet.ensureTimer();
3083
+ fleet.update();
2910
3084
 
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,
3085
+ // Emit created event unless a branch replacement detached the record.
3086
+ if (!record?.detached) {
3087
+ pi.events.emit("subagents:created", {
3088
+ id,
3089
+ type: subagentType,
3090
+ description: params.description,
3091
+ isBackground: true,
2931
3092
  });
2932
- };
3093
+ }
2933
3094
 
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
- };
3095
+ const isQueued = record?.status === "queued";
3096
+ return textResult(
3097
+ `${fallbackNote}Agent ${isQueued ? "queued" : "started"} in background.\n` +
3098
+ `Agent ID: ${id}\n` +
3099
+ `Type: ${displayName}\n` +
3100
+ `Description: ${params.description}\n` +
3101
+ (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") +
3102
+ (isQueued
3103
+ ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
3104
+ : "") +
3105
+ `\nYou will be notified when this agent completes.\n` +
3106
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
3107
+ `Do not duplicate this agent's work.`,
3108
+ {
3109
+ ...detailBase,
3110
+ toolUses: 0,
3111
+ tokens: "",
3112
+ durationMs: 0,
3113
+ status: isQueued ? "queued" : "background",
3114
+ agentId: id,
3115
+ },
3116
+ );
3117
+ }
2965
3118
 
2966
- const spinnerInterval = setInterval(() => {
2967
- spinnerFrame++;
2968
- streamUpdate();
2969
- }, SPINNER_INTERVAL_MS);
3119
+ // Foreground (synchronous) execution — stream progress via onUpdate
3120
+ let spinnerFrame = 0;
3121
+ const startedAt = Date.now();
3122
+ let fgId: string | undefined;
3123
+
3124
+ const streamUpdate = () => {
3125
+ const details: AgentDetails = {
3126
+ ...detailBase,
3127
+ toolUses: fgState.toolUses,
3128
+ tokens: formatLifetimeTokens(fgState),
3129
+ turnCount: fgState.turnCount,
3130
+ maxTurns: fgState.maxTurns,
3131
+ durationMs: Date.now() - startedAt,
3132
+ status: "running",
3133
+ activity: describeActivity(fgState.activeTools, fgState.responseText),
3134
+ spinnerFrame: spinnerFrame % SPINNER.length,
3135
+ };
3136
+ onUpdate?.({
3137
+ content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
3138
+ details: details as any,
3139
+ });
3140
+ };
2970
3141
 
2971
- streamUpdate();
3142
+ const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(
3143
+ effectiveMaxTurns,
3144
+ streamUpdate,
3145
+ );
2972
3146
 
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);
3147
+ // Wire session creation: register in the FleetView list + stream to the output file.
3148
+ // The output file path is set synchronously after spawn (below),
3149
+ // before onSessionCreated fires — same pattern as background agents.
3150
+ const origOnSession = fgCallbacks.onSessionCreated;
3151
+ fgCallbacks.onSessionCreated = (session: any) => {
3152
+ origOnSession(session);
3153
+ for (const a of manager.listAgentsMutable()) {
3154
+ if (a.session === session) {
3155
+ fgId = a.id;
3156
+ agentActivity.set(a.id, fgState);
3157
+ fleet.ensureTimer();
3158
+ fleet.update();
3159
+ break;
3009
3160
  }
3010
3161
  }
3162
+ // Stream conversation to output file (foreground agent logging)
3163
+ if (fgId) {
3164
+ const rec = manager.getRecordMutable(fgId);
3165
+ if (rec?.outputFile) {
3166
+ rec.outputCleanup = streamToOutputFile(
3167
+ session,
3168
+ rec.outputFile,
3169
+ fgId,
3170
+ ctx.cwd,
3171
+ );
3172
+ }
3173
+ }
3174
+ };
3011
3175
 
3012
- // Get final token count
3013
- const tokenText = formatLifetimeTokens(fgState);
3176
+ const spinnerInterval = setInterval(() => {
3177
+ spinnerFrame++;
3178
+ streamUpdate();
3179
+ }, SPINNER_INTERVAL_MS);
3014
3180
 
3015
- const details = buildDetails(detailBase, record, fgState, {
3016
- tokens: tokenText,
3017
- });
3181
+ streamUpdate();
3018
3182
 
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
- );
3183
+ let record: AgentRecord;
3184
+ try {
3185
+ const fgResult = await manager.spawnAndWait(
3186
+ pi,
3187
+ ctx,
3188
+ subagentType,
3189
+ params.prompt,
3190
+ {
3191
+ description: params.description,
3192
+ model: resolvedConfig.agentTierSelected ? undefined : model,
3193
+ maxTurns: effectiveMaxTurns,
3194
+ isolated,
3195
+ inheritContext,
3196
+ thinkingLevel: resolvedConfig.agentTierSelected
3197
+ ? undefined
3198
+ : thinking,
3199
+ agentTier: resolvedConfig.requestedAgentTier,
3200
+ isolation,
3201
+ invocation: agentInvocation,
3202
+ signal,
3203
+ rootSessionId: ctx.sessionManager.getSessionId(),
3204
+ ...fgCallbacks,
3205
+ },
3206
+ (fgAgentId) => {
3207
+ // onSpawned: called synchronously after spawn, before onSessionCreated fires.
3208
+ // Set up the output file so streamToOutputFile can pick it up.
3209
+ const fgRec = manager.getRecordMutable(fgAgentId);
3210
+ attachTranscript(fgRec, fgAgentId);
3211
+ },
3212
+ );
3213
+ record = fgResult.record;
3214
+ } finally {
3215
+ // Always stop the spinner and drop the row, including startup errors
3216
+ // that now propagate to pi as failed tool calls.
3217
+ clearInterval(spinnerInterval);
3218
+ if (fgId) {
3219
+ agentActivity.delete(fgId);
3220
+ fleet.onAgentFinished(fgId);
3025
3221
  }
3222
+ }
3026
3223
 
3027
- const durationMs =
3028
- (record.completedAt ?? Date.now()) - record.startedAt;
3029
- const statsParts = [`${record.toolUses} tool uses`];
3030
- if (tokenText) statsParts.push(tokenText);
3224
+ // Get final token count
3225
+ const tokenText = formatLifetimeTokens(fgState);
3226
+
3227
+ const details = buildDetails(detailBase, record, fgState, {
3228
+ tokens: tokenText,
3229
+ });
3230
+
3231
+ if (record.status === "error") {
3232
+ // Error headline + any partial output the run produced before failing.
3031
3233
  return textResult(
3032
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
3033
- (record.result?.trim() || "No output."),
3234
+ `${fallbackNote}Agent failed: ${record.error}${partialOutputSuffix(record)}`,
3034
3235
  details,
3035
3236
  );
3036
- },
3237
+ }
3238
+
3239
+ const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
3240
+ const statsParts = [`${record.toolUses} tool uses`];
3241
+ if (tokenText) statsParts.push(tokenText);
3242
+ return textResult(
3243
+ `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
3244
+ (record.result?.trim() || "No output."),
3245
+ details,
3246
+ );
3247
+ },
3037
3248
  });
3038
3249
  pi.registerTool(agentTool);
3039
3250
  agentToolRef = agentTool;
3040
3251
 
3252
+ // Pi snapshots description and parameters when wrapping a registered tool.
3253
+ // Trust changes alone emit no host event. Re-register before each new prompt,
3254
+ // ahead of Pi's effective loadout snapshot, using its public post-bind refresh.
3255
+ // Turn end also covers provider continuations after tools revoke authority.
3256
+ // Keep the same exposure/execution policy and retain the active set.
3257
+ function freshAgentToolDefinition() {
3258
+ reloadCustomAgents();
3259
+ return {
3260
+ ...agentTool,
3261
+ description: agentToolDescription(),
3262
+ parameters: {
3263
+ ...agentTool.parameters,
3264
+ properties: {
3265
+ ...agentTool.parameters.properties,
3266
+ subagent_type: { ...agentTool.parameters.properties.subagent_type, description: agentTypeParameterDescription() },
3267
+ tier: { ...agentTool.parameters.properties.tier, description: buildAgentTierParameterDescription() },
3268
+ },
3269
+ },
3270
+ };
3271
+ }
3272
+ const refreshAgentDeclaration = () => {
3273
+ const refreshed = freshAgentToolDefinition();
3274
+ const activeTools = pi.getActiveTools();
3275
+ pi.registerTool(refreshed);
3276
+ // Pi's explicit --tools/SDK tools selector can re-activate every matching
3277
+ // name during registry refresh. A metadata refresh must not undo a later
3278
+ // activation/exposure decision by the host or another extension.
3279
+ const refreshedActive = pi.getActiveTools();
3280
+ if (activeTools.length !== refreshedActive.length || activeTools.some((name, i) => name !== refreshedActive[i])) {
3281
+ pi.setActiveTools(activeTools);
3282
+ }
3283
+ agentToolRef = refreshed;
3284
+ };
3285
+ pi.on("before_agent_start", refreshAgentDeclaration);
3286
+ pi.on("turn_end", refreshAgentDeclaration);
3287
+
3041
3288
  // ---- get_subagent_result tool ----
3042
3289
 
3043
3290
  pi.registerTool(
@@ -3154,7 +3401,8 @@ Terse command-style prompts produce shallow, generic work.
3154
3401
  "Chat with or redirect a running or queued background agent",
3155
3402
  parameters: Type.Object({
3156
3403
  agent_id: Type.String({
3157
- description: "The agent ID to message (must be currently running or queued).",
3404
+ description:
3405
+ "The agent ID to message (must be currently running or queued).",
3158
3406
  }),
3159
3407
  message: Type.String({
3160
3408
  description:
@@ -3185,7 +3433,10 @@ Terse command-style prompts produce shallow, generic work.
3185
3433
  id: record.id,
3186
3434
  message: params.message,
3187
3435
  });
3188
- const delivery = record.status === "queued" ? "when the agent starts" : "once the session initializes";
3436
+ const delivery =
3437
+ record.status === "queued"
3438
+ ? "when the agent starts"
3439
+ : "once the session initializes";
3189
3440
  return textResult(
3190
3441
  `Chat message queued for agent ${record.id}. It will be delivered ${delivery}.`,
3191
3442
  );
@@ -3493,7 +3744,10 @@ Terse command-style prompts produce shallow, generic work.
3493
3744
  `"${sanitizeDisplayText(record.description)}" has not started yet.${handleHint}\n\nStop this queued run?`,
3494
3745
  );
3495
3746
  if (stop && manager.abort(record.id)) {
3496
- ctx.ui.notify(`Stopped queued agent "${sanitizeDisplayText(record.description)}".`, "info");
3747
+ ctx.ui.notify(
3748
+ `Stopped queued agent "${sanitizeDisplayText(record.description)}".`,
3749
+ "info",
3750
+ );
3497
3751
  }
3498
3752
  return;
3499
3753
  }
@@ -4161,7 +4415,7 @@ prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is ap
4161
4415
  extensions: <true (inherit all MCP/extension tools), false (none), or comma-separated names. Default: true>
4162
4416
  skills: <true (inherit all), false (none), or comma-separated skill names to preload into prompt. Default: true>
4163
4417
  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>
4418
+ inherit_context: <omit or false; true is rejected because Pi cannot safely transfer the post-hook parent context>
4165
4419
  run_in_background: <true to run in background by default. Default: false>
4166
4420
  output_transcript: <false to write no transcript file or path for this agent. Independent of persist_session. Default: true>
4167
4421
  isolated: <true for no extension/MCP tools, only built-in tools. Default: false>
@@ -4177,7 +4431,7 @@ Guidelines for choosing settings:
4177
4431
  - For code modification tasks: include edit, write
4178
4432
  - Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top
4179
4433
  - 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
4434
+ - Never set inherit_context: true; put an explicitly sanitized summary in the Agent task instead
4181
4435
  - Set isolated: true if the agent should NOT have access to MCP servers or other extensions
4182
4436
  - 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
4437
  - Only include frontmatter fields that differ from defaults — omit fields where the default is fine
@@ -4648,7 +4902,7 @@ Do not wrap the response in a markdown code fence. Return only the file contents
4648
4902
  // scoped set would fail the scope check at spawn time.
4649
4903
  if (isScopeModelsEnabled()) {
4650
4904
  const enabled = resolveEnabledModels(
4651
- readEnabledModels(sessionCwd),
4905
+ readEnabledModels(sessionCwd, captureProjectTrust(configurationOwner)),
4652
4906
  ctx.modelRegistry,
4653
4907
  sessionCwd,
4654
4908
  );
@@ -4781,7 +5035,8 @@ Do not wrap the response in a markdown code fence. Return only the file contents
4781
5035
  id: "defaultTier",
4782
5036
  label: "Default tier",
4783
5037
  description: (() => {
4784
- if (tierKeys.length === 0) return "No tiers defined yet — create one in /agents → Model tiers.";
5038
+ if (tierKeys.length === 0)
5039
+ return "No tiers defined yet — create one in /agents → Model tiers.";
4785
5040
  const fallback = shippedFallbackAgentTier();
4786
5041
  // Named from the resolver, not from a constant: on a catalogue that
4787
5042
  // removed the shipped profile, "unset" reaches nothing and behaves
@@ -4796,7 +5051,9 @@ Do not wrap the response in a markdown code fence. Return only the file contents
4796
5051
  currentValue: (() => {
4797
5052
  const selection = getDefaultAgentTierSelection();
4798
5053
  if (selection.kind === "tier") return selection.tier;
4799
- return selection.kind === "none" ? NO_DEFAULT_TIER : UNSET_DEFAULT_TIER;
5054
+ return selection.kind === "none"
5055
+ ? NO_DEFAULT_TIER
5056
+ : UNSET_DEFAULT_TIER;
4800
5057
  })(),
4801
5058
  values: [UNSET_DEFAULT_TIER, NO_DEFAULT_TIER, ...tierKeys],
4802
5059
  },
@@ -4874,7 +5131,7 @@ Do not wrap the response in a markdown code fence. Return only the file contents
4874
5131
  id: "agentMentions",
4875
5132
  label: "Agent mentions",
4876
5133
  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.",
5134
+ "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
5135
  currentValue: getAgentMentionMode(),
4879
5136
  values: [...AGENT_MENTION_MODES],
4880
5137
  },
@@ -5055,13 +5312,13 @@ Do not wrap the response in a markdown code fence. Return only the file contents
5055
5312
  const enabled = value === "on";
5056
5313
  supervisorQuestionsEnabled = enabled;
5057
5314
  manager.setSupervisorQuestions(enabled);
5058
- notifyApplied(ctx, `Supervisor questions ${enabled ? "enabled" : "disabled"}`);
5059
- } else if (id === "agentMentions") {
5060
- agentMentionMode = value as AgentMentionMode;
5061
5315
  notifyApplied(
5062
5316
  ctx,
5063
- `Agent mentions set to ${agentMentionMode}`,
5317
+ `Supervisor questions ${enabled ? "enabled" : "disabled"}`,
5064
5318
  );
5319
+ } else if (id === "agentMentions") {
5320
+ agentMentionMode = value as AgentMentionMode;
5321
+ notifyApplied(ctx, `Agent mentions set to ${agentMentionMode}`);
5065
5322
  }
5066
5323
  }
5067
5324