@oh-my-pi/pi-coding-agent 15.9.1 → 15.9.5

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.
Files changed (109) hide show
  1. package/CHANGELOG.md +68 -2
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  4. package/dist/types/commands/dry-balance.d.ts +31 -0
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/models-config-schema.d.ts +3 -0
  7. package/dist/types/config/settings-schema.d.ts +13 -4
  8. package/dist/types/config/settings.d.ts +11 -0
  9. package/dist/types/discovery/helpers.d.ts +1 -0
  10. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  11. package/dist/types/hindsight/bank.d.ts +17 -9
  12. package/dist/types/hindsight/mental-models.d.ts +1 -1
  13. package/dist/types/hindsight/state.d.ts +9 -3
  14. package/dist/types/mcp/manager.d.ts +1 -1
  15. package/dist/types/modes/components/assistant-message.d.ts +11 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  17. package/dist/types/modes/components/error-banner.d.ts +11 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +4 -2
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/image-references.d.ts +17 -0
  22. package/dist/types/modes/interactive-mode.d.ts +7 -0
  23. package/dist/types/modes/types.d.ts +7 -0
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  25. package/dist/types/session/agent-session.d.ts +9 -0
  26. package/dist/types/session/auth-storage.d.ts +2 -2
  27. package/dist/types/session/blob-store.d.ts +12 -11
  28. package/dist/types/session/session-manager.d.ts +5 -3
  29. package/dist/types/system-prompt.d.ts +2 -0
  30. package/dist/types/task/types.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +16 -1
  32. package/dist/types/tool-discovery/mode.d.ts +8 -0
  33. package/dist/types/tools/archive-reader.d.ts +5 -1
  34. package/dist/types/tools/index.d.ts +16 -0
  35. package/dist/types/tools/path-utils.d.ts +11 -0
  36. package/dist/types/tui/hyperlink.d.ts +12 -0
  37. package/dist/types/web/search/render.d.ts +1 -2
  38. package/package.json +9 -9
  39. package/src/cli/classify-install-target.ts +31 -5
  40. package/src/cli/dry-balance-cli.ts +823 -0
  41. package/src/cli/plugin-cli.ts +45 -0
  42. package/src/cli/web-search-cli.ts +0 -1
  43. package/src/cli-commands.ts +1 -0
  44. package/src/commands/dry-balance.ts +43 -0
  45. package/src/config/model-registry.ts +60 -4
  46. package/src/config/models-config-schema.ts +2 -0
  47. package/src/config/settings-schema.ts +14 -4
  48. package/src/config/settings.ts +38 -0
  49. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  50. package/src/discovery/github.ts +37 -1
  51. package/src/discovery/helpers.ts +3 -1
  52. package/src/eval/__tests__/agent-bridge.test.ts +72 -0
  53. package/src/eval/py/tool-bridge.ts +43 -5
  54. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  55. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  56. package/src/hindsight/backend.ts +184 -35
  57. package/src/hindsight/bank.ts +32 -22
  58. package/src/hindsight/mental-models.ts +1 -1
  59. package/src/hindsight/state.ts +21 -7
  60. package/src/internal-urls/docs-index.generated.ts +6 -6
  61. package/src/internal-urls/omp-protocol.ts +8 -2
  62. package/src/main.ts +7 -1
  63. package/src/mcp/manager.ts +40 -21
  64. package/src/modes/components/assistant-message.ts +22 -0
  65. package/src/modes/components/custom-editor.ts +14 -2
  66. package/src/modes/components/error-banner.ts +33 -0
  67. package/src/modes/components/tool-execution.ts +44 -0
  68. package/src/modes/components/transcript-container.ts +102 -30
  69. package/src/modes/components/tree-selector.ts +29 -2
  70. package/src/modes/components/user-message.ts +9 -2
  71. package/src/modes/controllers/event-controller.ts +42 -3
  72. package/src/modes/controllers/input-controller.ts +41 -3
  73. package/src/modes/image-references.ts +111 -0
  74. package/src/modes/interactive-mode.ts +48 -13
  75. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  76. package/src/modes/types.ts +10 -1
  77. package/src/modes/utils/ui-helpers.ts +23 -2
  78. package/src/prompts/agents/explore.md +1 -0
  79. package/src/prompts/agents/librarian.md +1 -0
  80. package/src/prompts/ci-green-request.md +5 -3
  81. package/src/prompts/dry-balance-bench.md +8 -0
  82. package/src/prompts/system/project-prompt.md +1 -0
  83. package/src/sdk.ts +99 -18
  84. package/src/session/agent-session.ts +103 -19
  85. package/src/session/auth-storage.ts +4 -0
  86. package/src/session/blob-store.ts +96 -9
  87. package/src/session/session-manager.ts +19 -10
  88. package/src/system-prompt.ts +4 -0
  89. package/src/task/executor.ts +6 -2
  90. package/src/task/index.ts +8 -7
  91. package/src/task/types.ts +2 -0
  92. package/src/tiny/title-client.ts +7 -1
  93. package/src/tool-discovery/mode.ts +24 -0
  94. package/src/tools/archive-reader.ts +339 -31
  95. package/src/tools/bash.ts +3 -4
  96. package/src/tools/fetch.ts +29 -9
  97. package/src/tools/gh.ts +65 -11
  98. package/src/tools/index.ts +22 -8
  99. package/src/tools/job.ts +3 -3
  100. package/src/tools/memory-reflect.ts +2 -2
  101. package/src/tools/path-utils.ts +21 -0
  102. package/src/tools/read.ts +58 -12
  103. package/src/tools/search-tool-bm25.ts +4 -6
  104. package/src/tools/search.ts +78 -12
  105. package/src/tui/hyperlink.ts +42 -7
  106. package/src/utils/file-mentions.ts +7 -107
  107. package/src/utils/title-generator.ts +58 -37
  108. package/src/web/search/index.ts +2 -2
  109. package/src/web/search/render.ts +20 -52
package/src/sdk.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  extractRetryHint,
28
28
  getAgentDbPath,
29
29
  getAgentDir,
30
+ getAuthBrokerSnapshotCachePath,
30
31
  getProjectDir,
31
32
  logger,
32
33
  postmortem,
@@ -101,7 +102,15 @@ import {
101
102
  } from "./secrets";
102
103
  import { AgentSession } from "./session/agent-session";
103
104
  import { resolveAuthBrokerConfig } from "./session/auth-broker-config";
104
- import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./session/auth-storage";
105
+ import {
106
+ AuthBrokerClient,
107
+ AuthStorage,
108
+ DEFAULT_SNAPSHOT_CACHE_TTL_MS,
109
+ RemoteAuthCredentialStore,
110
+ readAuthBrokerSnapshotCache,
111
+ type SnapshotResponse,
112
+ writeAuthBrokerSnapshotCache,
113
+ } from "./session/auth-storage";
105
114
  import { type CustomMessage, convertToLlm, wrapSteeringForModel } from "./session/messages";
106
115
  import { getRestorableSessionModels, SessionManager } from "./session/session-manager";
107
116
  import { closeAllConnections } from "./ssh/connection-manager";
@@ -121,6 +130,7 @@ import {
121
130
  resolveThinkingLevelForModel,
122
131
  toReasoningEffort,
123
132
  } from "./thinking";
133
+ import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "./tool-discovery/mode";
124
134
  import {
125
135
  collectDiscoverableTools,
126
136
  type DiscoverableTool,
@@ -148,6 +158,7 @@ import {
148
158
  ResolveTool,
149
159
  renderSearchToolBm25Description,
150
160
  SearchTool,
161
+ SearchToolBm25Tool,
151
162
  setPreferredImageProvider,
152
163
  setPreferredSearchProvider,
153
164
  type Tool,
@@ -418,6 +429,17 @@ function getDefaultAgentDir(): string {
418
429
  return getAgentDir();
419
430
  }
420
431
 
432
+ function resolveSnapshotTtlMs(): number {
433
+ const raw = process.env.OMP_AUTH_BROKER_SNAPSHOT_TTL_MS;
434
+ if (raw === undefined) return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
435
+ const value = raw.trim();
436
+ if (value === "") return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
437
+ const ttlMs = Number(value);
438
+ if (Number.isFinite(ttlMs) && ttlMs >= 0) return ttlMs;
439
+ logger.warn("Invalid OMP_AUTH_BROKER_SNAPSHOT_TTL_MS; using default", { value: raw });
440
+ return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
441
+ }
442
+
421
443
  // Discovery Functions
422
444
 
423
445
  /**
@@ -435,9 +457,42 @@ export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir(
435
457
  const brokerConfig = await resolveAuthBrokerConfig();
436
458
  if (brokerConfig) {
437
459
  const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
438
- const initialResult = await client.fetchSnapshot();
439
- if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot");
440
- const store = new RemoteAuthCredentialStore({ client, initialSnapshot: initialResult.snapshot });
460
+ const ttlMs = resolveSnapshotTtlMs();
461
+ const cachePath = getAuthBrokerSnapshotCachePath();
462
+ const persist =
463
+ ttlMs > 0
464
+ ? (snapshot: SnapshotResponse): void => {
465
+ void writeAuthBrokerSnapshotCache({
466
+ path: cachePath,
467
+ token: brokerConfig.token,
468
+ url: brokerConfig.url,
469
+ snapshot,
470
+ }).catch(error => {
471
+ logger.debug("auth-broker snapshot cache write failed", { error: String(error) });
472
+ });
473
+ }
474
+ : undefined;
475
+
476
+ let initialSnapshot: SnapshotResponse | undefined;
477
+ if (ttlMs > 0) {
478
+ initialSnapshot =
479
+ (await readAuthBrokerSnapshotCache({
480
+ path: cachePath,
481
+ token: brokerConfig.token,
482
+ url: brokerConfig.url,
483
+ ttlMs,
484
+ }).catch(error => {
485
+ logger.debug("auth-broker snapshot cache read failed", { error: String(error) });
486
+ return null;
487
+ })) ?? undefined;
488
+ }
489
+ if (!initialSnapshot) {
490
+ const initialResult = await client.fetchSnapshot();
491
+ if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot");
492
+ initialSnapshot = initialResult.snapshot;
493
+ persist?.(initialSnapshot);
494
+ }
495
+ const store = new RemoteAuthCredentialStore({ client, initialSnapshot, onSnapshot: persist });
441
496
  // Refresh + usage hooks live on RemoteAuthCredentialStore; AuthStorage
442
497
  // discovers them automatically when no explicit option overrides them.
443
498
  const storage = new AuthStorage(store, {
@@ -1168,12 +1223,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1168
1223
 
1169
1224
  return preview;
1170
1225
  };
1171
- // Only top-level sessions own an AsyncJobManager. Subagents reach the
1172
- // parent's manager via `AsyncJobManager.instance()` (set below), so creating
1173
- // a second instance here just to leave it orphaned wastes a constructor and
1174
- // risks accidental disposal of the parent's manager on subagent teardown.
1226
+ // Only the first top-level session in a process owns an AsyncJobManager.
1227
+ // Subagents inherit the parent's manager via `AsyncJobManager.instance()`
1228
+ // (set below), and any additional top-level session spun up in-process
1229
+ // (e.g. the agent-creation architect in `agent-dashboard.ts`) must share
1230
+ // the live singleton — otherwise its dispose path would clobber the
1231
+ // owning session's manager and break the `task`/`bash` async paths
1232
+ // (issue #1923). The `instance()` guard means later sessions also skip
1233
+ // constructing an orphaned manager that nothing would ever route to.
1175
1234
  const asyncJobManager =
1176
- backgroundJobsEnabled && !options.parentTaskPrefix
1235
+ backgroundJobsEnabled && !options.parentTaskPrefix && !AsyncJobManager.instance()
1177
1236
  ? new AsyncJobManager({
1178
1237
  maxRunningJobs: asyncMaxJobs,
1179
1238
  onJobComplete: async (jobId, result, job) => {
@@ -1192,6 +1251,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1192
1251
  })
1193
1252
  : undefined;
1194
1253
 
1254
+ const scopedAsyncJobManager = asyncJobManager ?? (options.parentTaskPrefix ? AsyncJobManager.instance() : undefined);
1255
+
1195
1256
  const agentRegistry = options.agentRegistry ?? AgentRegistry.global();
1196
1257
  const resolvedAgentId = options.agentId ?? options.parentTaskPrefix ?? MAIN_AGENT_ID;
1197
1258
  const resolvedAgentDisplayName =
@@ -1293,6 +1354,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1293
1354
  authStorage,
1294
1355
  modelRegistry,
1295
1356
  getTelemetry: () => agent?.telemetry,
1357
+ // Subagents inherit the singleton (the parent's manager) so their bash/task
1358
+ // completions still flow into the spawning conversation's yieldQueue.
1359
+ // Secondary in-process top-level sessions (no parentTaskPrefix, no
1360
+ // constructed manager because the singleton was already installed) leave
1361
+ // this undefined so tools and session job snapshots refuse async work
1362
+ // instead of silently routing into the owning session (issue #1923).
1363
+ asyncJobManager: scopedAsyncJobManager,
1296
1364
  };
1297
1365
 
1298
1366
  // Wire process-wide internal URL singletons owned by their real classes.
@@ -1621,6 +1689,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1621
1689
  }
1622
1690
  }
1623
1691
 
1692
+ const effectiveDiscoveryMode = resolveEffectiveToolDiscoveryMode(
1693
+ settings,
1694
+ countToolsForAutoDiscovery(toolRegistry.keys()),
1695
+ );
1696
+ if (effectiveDiscoveryMode !== "off" && !toolRegistry.has("search_tool_bm25")) {
1697
+ const searchTool: Tool = new SearchToolBm25Tool(toolSession);
1698
+ toolRegistry.set(
1699
+ searchTool.name,
1700
+ new ExtensionToolWrapper(wrapToolWithMetaNotice(searchTool), extensionRunner) as Tool,
1701
+ );
1702
+ }
1703
+ const mcpDiscoveryEnabled = effectiveDiscoveryMode !== "off"; // back-compat: true when any discovery active
1704
+
1624
1705
  const reloadSshTool = async (): Promise<AgentTool | null> => {
1625
1706
  if (!requestedToolNameSet.has("ssh")) return null;
1626
1707
  const sshTool = (await loadSshTool({
@@ -1707,6 +1788,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1707
1788
  secretsEnabled,
1708
1789
  workspaceTree: workspaceTreePromise,
1709
1790
  memoryRootEnabled: memoryBackend.id === "local",
1791
+ model: settings.get("includeModelInPrompt") ? getActiveModelString() : undefined,
1710
1792
  });
1711
1793
 
1712
1794
  if (options.systemPrompt === undefined) {
@@ -1739,15 +1821,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1739
1821
  const requestedToolNames = explicitlyRequestedToolNames ?? toolNamesFromRegistry;
1740
1822
  const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
1741
1823
  const requestedToolNameSet = new Set(normalizedRequested);
1742
- // Effective discovery mode: tools.discoveryMode takes precedence; mcp.discoveryMode is back-compat alias.
1743
- const toolsDiscoveryModeSetting = settings.get("tools.discoveryMode");
1744
- const effectiveDiscoveryMode: "off" | "mcp-only" | "all" =
1745
- toolsDiscoveryModeSetting !== "off"
1746
- ? (toolsDiscoveryModeSetting as "off" | "mcp-only" | "all")
1747
- : settings.get("mcp.discoveryMode")
1748
- ? "mcp-only"
1749
- : "off";
1750
- const mcpDiscoveryEnabled = effectiveDiscoveryMode !== "off"; // back-compat: true when any discovery active
1824
+ // Effective discovery mode is resolved after the full registry exists so auto mode can count MCP/extension tools.
1751
1825
  const defaultInactiveToolNames = new Set(
1752
1826
  registeredTools.filter(tool => tool.definition.defaultInactive).map(tool => tool.definition.name),
1753
1827
  );
@@ -2049,6 +2123,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2049
2123
  // AsyncJobManager on teardown; subagents inherit the parent's and
2050
2124
  // **MUST NOT** tear it down.
2051
2125
  ownedAsyncJobManager: asyncJobManager,
2126
+ asyncJobManager: scopedAsyncJobManager,
2052
2127
  scopedModels: options.scopedModels,
2053
2128
  promptTemplates,
2054
2129
  slashCommands,
@@ -2262,6 +2337,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2262
2337
  await session.dispose();
2263
2338
  } else {
2264
2339
  if (hasRegistered) agentRegistry.unregister(resolvedAgentId);
2340
+ if (asyncJobManager) {
2341
+ if (AsyncJobManager.instance() === asyncJobManager) {
2342
+ AsyncJobManager.setInstance(undefined);
2343
+ }
2344
+ await asyncJobManager.dispose({ timeoutMs: 3_000 });
2345
+ }
2265
2346
  await disposeKernelSessionsByOwner(evalKernelOwnerId);
2266
2347
  }
2267
2348
  } catch (cleanupError) {
@@ -15,6 +15,7 @@
15
15
 
16
16
  import * as crypto from "node:crypto";
17
17
  import * as fs from "node:fs";
18
+ import * as os from "node:os";
18
19
  import * as path from "node:path";
19
20
  import { scheduler } from "node:timers/promises";
20
21
  import { isPromise } from "node:util/types";
@@ -94,6 +95,7 @@ import {
94
95
  isUnexpectedSocketCloseMessage,
95
96
  logger,
96
97
  prompt,
98
+ relativePathWithinRoot,
97
99
  Snowflake,
98
100
  } from "@oh-my-pi/pi-utils";
99
101
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
@@ -190,6 +192,7 @@ import {
190
192
  toReasoningEffort,
191
193
  } from "../thinking";
192
194
  import { shutdownTinyTitleClient } from "../tiny/title-client";
195
+ import { countToolsForAutoDiscovery, resolveEffectiveToolDiscoveryMode } from "../tool-discovery/mode";
193
196
  import {
194
197
  buildDiscoverableToolSearchIndex,
195
198
  collectDiscoverableTools,
@@ -366,6 +369,15 @@ export interface AgentSessionConfig {
366
369
  * **MUST NOT** dispose it on their own teardown.
367
370
  */
368
371
  ownedAsyncJobManager?: AsyncJobManager;
372
+ /**
373
+ * AsyncJobManager reachable by this session for scoped job actions.
374
+ *
375
+ * Top-level owners receive their own manager, subagents receive the inherited
376
+ * parent manager, and secondary in-process top-level sessions receive
377
+ * `undefined` so job snapshots and ACP drains cannot observe the primary's
378
+ * state.
379
+ */
380
+ asyncJobManager?: AsyncJobManager;
369
381
  /** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */
370
382
  agentId?: string;
371
383
  /** Shared agent registry (for forwarding IRC observations to the main session UI). */
@@ -890,6 +902,14 @@ export class AgentSession {
890
902
  * this undefined and **MUST NOT** dispose the global instance on teardown.
891
903
  */
892
904
  readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
905
+ /**
906
+ * AsyncJobManager scoped to this session for introspection/cancellation.
907
+ *
908
+ * This differs from `#ownedAsyncJobManager`: subagents can inherit a parent
909
+ * manager for their own owner id, while secondary top-level sessions are left
910
+ * undefined to avoid reading the primary's jobs.
911
+ */
912
+ readonly #asyncJobManager: AsyncJobManager | undefined;
893
913
  #pendingPythonMessages: PythonExecutionMessage[] = [];
894
914
  #activeEvalExecutions = new Set<Promise<unknown>>();
895
915
  #evalExecutionDisposing = false;
@@ -941,6 +961,13 @@ export class AgentSession {
941
961
  * the dominant cause of prompt-cache invalidation in long sessions.
942
962
  */
943
963
  #lastAppliedToolSignature: string | undefined;
964
+ /**
965
+ * Model identifier (`provider/id`) currently rendered into `#baseSystemPrompt`.
966
+ * The prompt surfaces the active model to the agent, so a model switch must
967
+ * trigger a rebuild. Compared against the live model after every model change
968
+ * to decide whether the cached prompt is stale.
969
+ */
970
+ #promptModelKey: string | undefined;
944
971
  #mcpDiscoveryEnabled = false;
945
972
  #discoverableMCPTools = new Map<string, DiscoverableTool>();
946
973
  #selectedMCPToolNames = new Set<string>();
@@ -1080,6 +1107,7 @@ export class AgentSession {
1080
1107
  this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
1081
1108
  this.#parentEvalSessionId = config.parentEvalSessionId;
1082
1109
  this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
1110
+ this.#asyncJobManager = config.asyncJobManager ?? config.ownedAsyncJobManager;
1083
1111
  this.#scopedModels = config.scopedModels ?? [];
1084
1112
  if (config.thinkingLevel === AUTO_THINKING) {
1085
1113
  // `auto` is session-level: keep the flag and show a provisional concrete
@@ -1153,6 +1181,7 @@ export class AgentSession {
1153
1181
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
1154
1182
  this.#reloadSshTool = config.reloadSshTool;
1155
1183
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
1184
+ this.#promptModelKey = this.#currentPromptModelKey();
1156
1185
  this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
1157
1186
  this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
1158
1187
  this.#selectedMCPToolNames = new Set(config.initialSelectedMCPToolNames ?? []);
@@ -1373,7 +1402,7 @@ export class AgentSession {
1373
1402
  }
1374
1403
 
1375
1404
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
1376
- const manager = AsyncJobManager.instance();
1405
+ const manager = this.#asyncJobManager;
1377
1406
  if (!manager) return null;
1378
1407
  const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
1379
1408
  const running = manager.getRunningJobs(ownerFilter).map(job => ({
@@ -1398,11 +1427,20 @@ export class AgentSession {
1398
1427
  * Cancel async jobs registered by *this* agent only. Used by lifecycle
1399
1428
  * transitions (newSession, switchSession, handoff, dispose) so a subagent
1400
1429
  * cleans up its own background work without touching its parent's jobs.
1401
- * No-op when no manager is installed or this session has no agent id.
1430
+ *
1431
+ * Cancellation runs against this session's scoped manager. Subagents have
1432
+ * unique agent ids and inherit the parent's manager to clean up their own
1433
+ * jobs. A secondary in-process top-level session gets no scoped manager,
1434
+ * because it defaults to `MAIN_AGENT_ID`; reaching through the global
1435
+ * singleton would tear down the owning primary session's bash/task jobs at
1436
+ * dispose time (issue #1923).
1437
+ *
1438
+ * No-op when no manager is reachable or this session has no agent id.
1402
1439
  */
1403
1440
  #cancelOwnAsyncJobs(): void {
1404
1441
  if (!this.#agentId) return;
1405
- AsyncJobManager.instance()?.cancelAll({ ownerId: this.#agentId });
1442
+ const manager = this.#asyncJobManager;
1443
+ manager?.cancelAll({ ownerId: this.#agentId });
1406
1444
  }
1407
1445
 
1408
1446
  // =========================================================================
@@ -2128,12 +2166,31 @@ export class AgentSession {
2128
2166
  if (this.#pendingTtsrInjections.length === 0) return undefined;
2129
2167
  const rules = this.#pendingTtsrInjections;
2130
2168
  const content = rules
2131
- .map(r => prompt.render(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content }))
2169
+ .map(r =>
2170
+ prompt.render(ttsrInterruptTemplate, {
2171
+ name: r.name,
2172
+ path: this.#displayRulePath(r.path),
2173
+ content: r.content,
2174
+ }),
2175
+ )
2132
2176
  .join("\n\n");
2133
2177
  this.#pendingTtsrInjections = [];
2134
2178
  return { content, rules };
2135
2179
  }
2136
2180
 
2181
+ /**
2182
+ * Render a rule's file path for model-facing TTSR injections without leaking
2183
+ * the absolute home directory: cwd-relative when the rule lives in the
2184
+ * project, `~`-relative when it lives under home, else the raw path.
2185
+ */
2186
+ #displayRulePath(rulePath: string): string {
2187
+ const cwdRel = relativePathWithinRoot(this.sessionManager.getCwd(), rulePath);
2188
+ if (cwdRel) return cwdRel;
2189
+ const homeRel = relativePathWithinRoot(os.homedir(), rulePath);
2190
+ if (homeRel) return `~/${homeRel}`;
2191
+ return rulePath;
2192
+ }
2193
+
2137
2194
  #addPendingTtsrInjections(rules: Rule[]): void {
2138
2195
  const seen = new Set(this.#pendingTtsrInjections.map(rule => rule.name));
2139
2196
  for (const rule of rules) {
@@ -2186,7 +2243,13 @@ export class AgentSession {
2186
2243
  if (!rules || rules.length === 0) return undefined;
2187
2244
  this.#perToolTtsrInjections.delete(ctx.toolCall.id);
2188
2245
  const reminder = rules
2189
- .map(r => prompt.render(ttsrToolReminderTemplate, { name: r.name, path: r.path, content: r.content }))
2246
+ .map(r =>
2247
+ prompt.render(ttsrToolReminderTemplate, {
2248
+ name: r.name,
2249
+ path: this.#displayRulePath(r.path),
2250
+ content: r.content,
2251
+ }),
2252
+ )
2190
2253
  .join("\n\n");
2191
2254
  // The TTSR manager was already claimed at bucket time; only persistence remains.
2192
2255
  const ruleNames = rules.map(r => r.name.trim()).filter(n => n.length > 0);
@@ -2990,8 +3053,13 @@ export class AgentSession {
2990
3053
  this.#releasePowerAssertion();
2991
3054
  await this.sessionManager.close();
2992
3055
  this.#closeAllProviderSessions("dispose");
2993
- const hindsightState = this.setHindsightSessionState(undefined);
3056
+ // Flush the retain queue BEFORE clearing the session's pointer so
3057
+ // `HindsightRetainQueue.#doFlush` still sees `session.getHindsightSessionState() === state`.
3058
+ // Reversed, the spliced batch survives just long enough to fail the
3059
+ // identity check and get dropped with a `session vanished` warning.
3060
+ const hindsightState = this.getHindsightSessionState();
2994
3061
  await hindsightState?.flushRetainQueue();
3062
+ this.setHindsightSessionState(undefined);
2995
3063
  hindsightState?.dispose();
2996
3064
  const mnemopiState = setMnemopiSessionState(this, undefined);
2997
3065
  mnemopiState?.dispose();
@@ -3069,7 +3137,7 @@ export class AgentSession {
3069
3137
  }
3070
3138
 
3071
3139
  async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
3072
- const manager = AsyncJobManager.instance();
3140
+ const manager = this.#asyncJobManager;
3073
3141
  if (!manager) return false;
3074
3142
  const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
3075
3143
  const before = manager.getDeliveryState(ownerFilter);
@@ -3205,9 +3273,21 @@ export class AgentSession {
3205
3273
  return resolveEditMode(this.#getEditModeSession());
3206
3274
  }
3207
3275
 
3208
- async #syncEditToolModeAfterModelChange(previousEditMode: EditMode): Promise<void> {
3276
+ /**
3277
+ * Model key (`provider/id`) currently surfaced in the system prompt, or
3278
+ * undefined when the model is unset or `includeModelInPrompt` is disabled.
3279
+ */
3280
+ #currentPromptModelKey(): string | undefined {
3281
+ if (!this.settings.get("includeModelInPrompt")) return undefined;
3282
+ return this.model ? formatModelString(this.model) : undefined;
3283
+ }
3284
+
3285
+ async #syncAfterModelChange(previousEditMode: EditMode): Promise<void> {
3209
3286
  const currentEditMode = this.#resolveActiveEditMode();
3210
- if (previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit")) {
3287
+ const editModeChanged = previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit");
3288
+ // The system prompt may surface the active model; a switch makes the cached prompt stale.
3289
+ const modelChanged = this.#currentPromptModelKey() !== this.#promptModelKey;
3290
+ if (editModeChanged || modelChanged) {
3211
3291
  await this.refreshBaseSystemPrompt();
3212
3292
  }
3213
3293
  }
@@ -3246,12 +3326,14 @@ export class AgentSession {
3246
3326
 
3247
3327
  // ── Generic tool discovery (covers built-in + MCP + extension) ────────────
3248
3328
 
3249
- /** Resolve effective discovery mode: tools.discoveryMode wins; mcp.discoveryMode is back-compat alias. */
3329
+ /** Resolve effective discovery mode from the current registry size. */
3250
3330
  #resolveEffectiveDiscoveryMode(): "off" | "mcp-only" | "all" {
3251
- const toolsMode = this.settings.get("tools.discoveryMode");
3252
- if (toolsMode !== "off") return toolsMode as "off" | "mcp-only" | "all";
3253
- if (this.settings.get("mcp.discoveryMode")) return "mcp-only";
3254
- return "off";
3331
+ const mode = resolveEffectiveToolDiscoveryMode(
3332
+ this.settings,
3333
+ countToolsForAutoDiscovery(this.#toolRegistry.keys()),
3334
+ );
3335
+ if (mode !== "off") return mode;
3336
+ return this.#mcpDiscoveryEnabled ? "mcp-only" : "off";
3255
3337
  }
3256
3338
 
3257
3339
  isToolDiscoveryEnabled(): boolean {
@@ -3492,6 +3574,7 @@ export class AgentSession {
3492
3574
  this.#baseSystemPrompt = built.systemPrompt;
3493
3575
  this.agent.setSystemPrompt(this.#baseSystemPrompt);
3494
3576
  this.#lastAppliedToolSignature = signature;
3577
+ this.#promptModelKey = this.#currentPromptModelKey();
3495
3578
  }
3496
3579
  }
3497
3580
  if (options?.persistMCPSelection !== false) {
@@ -3574,6 +3657,7 @@ export class AgentSession {
3574
3657
  const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
3575
3658
  this.#baseSystemPrompt = built.systemPrompt;
3576
3659
  this.agent.setSystemPrompt(this.#baseSystemPrompt);
3660
+ this.#promptModelKey = this.#currentPromptModelKey();
3577
3661
  // Refresh the cached signature so a subsequent `#applyActiveToolsByName` with
3578
3662
  // the same tool set does not re-rebuild on top of the explicit refresh we
3579
3663
  // just performed (and conversely, a different set forces a fresh rebuild).
@@ -3633,7 +3717,7 @@ export class AgentSession {
3633
3717
  * closure-captured ones cannot change at runtime regardless of skip behavior.
3634
3718
  * For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
3635
3719
  * after side-effecting changes; see e.g. the memory hooks and
3636
- * `#syncEditToolModeAfterModelChange`.
3720
+ * `#syncAfterModelChange`.
3637
3721
  *
3638
3722
  * The current calendar date IS covered (appended as a segment) because
3639
3723
  * `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`).
@@ -5225,7 +5309,7 @@ export class AgentSession {
5225
5309
  // Re-apply thinking for the newly selected model. Prefer the model's
5226
5310
  // configured defaultLevel; otherwise preserve the current level (or auto).
5227
5311
  this.#reapplyThinkingLevel(model.thinking?.defaultLevel);
5228
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
5312
+ await this.#syncAfterModelChange(previousEditMode);
5229
5313
  }
5230
5314
 
5231
5315
  /**
@@ -5259,7 +5343,7 @@ export class AgentSession {
5259
5343
  } else {
5260
5344
  this.#reapplyThinkingLevel(model.thinking?.defaultLevel);
5261
5345
  }
5262
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
5346
+ await this.#syncAfterModelChange(previousEditMode);
5263
5347
  }
5264
5348
 
5265
5349
  /**
@@ -5403,7 +5487,7 @@ export class AgentSession {
5403
5487
 
5404
5488
  // Apply the scoped model's configured thinking level, preserving auto.
5405
5489
  this.setThinkingLevel(this.#autoThinking ? AUTO_THINKING : next.thinkingLevel);
5406
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
5490
+ await this.#syncAfterModelChange(previousEditMode);
5407
5491
 
5408
5492
  return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
5409
5493
  }
@@ -5432,7 +5516,7 @@ export class AgentSession {
5432
5516
  this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
5433
5517
  // Re-apply the current thinking level (or auto) for the newly selected model
5434
5518
  this.#reapplyThinkingLevel();
5435
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
5519
+ await this.#syncAfterModelChange(previousEditMode);
5436
5520
 
5437
5521
  return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
5438
5522
  }
@@ -12,12 +12,16 @@ export type {
12
12
  AuthStorageOptions,
13
13
  OAuthCredential,
14
14
  SerializedAuthStorage,
15
+ SnapshotResponse,
15
16
  StoredAuthCredential,
16
17
  } from "@oh-my-pi/pi-ai";
17
18
  export {
18
19
  AuthBrokerClient,
19
20
  AuthStorage,
21
+ DEFAULT_SNAPSHOT_CACHE_TTL_MS,
20
22
  REMOTE_REFRESH_SENTINEL,
21
23
  RemoteAuthCredentialStore,
24
+ readAuthBrokerSnapshotCache,
22
25
  SqliteAuthCredentialStore,
26
+ writeAuthBrokerSnapshotCache,
23
27
  } from "@oh-my-pi/pi-ai";