@oh-my-pi/pi-coding-agent 15.9.0 → 15.9.3

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 (124) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  3. package/dist/types/cli/update-cli.d.ts +15 -1
  4. package/dist/types/commands/dry-balance.d.ts +31 -0
  5. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  6. package/dist/types/config/model-registry.d.ts +5 -0
  7. package/dist/types/config/models-config-schema.d.ts +18 -0
  8. package/dist/types/config/settings-schema.d.ts +3 -3
  9. package/dist/types/config/settings.d.ts +11 -0
  10. package/dist/types/discovery/helpers.d.ts +1 -0
  11. package/dist/types/exa/mcp-client.d.ts +2 -1
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  13. package/dist/types/hindsight/bank.d.ts +17 -9
  14. package/dist/types/hindsight/mental-models.d.ts +1 -1
  15. package/dist/types/hindsight/state.d.ts +9 -3
  16. package/dist/types/mcp/json-rpc.d.ts +6 -1
  17. package/dist/types/mcp/manager.d.ts +1 -1
  18. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  19. package/dist/types/mnemopi/state.d.ts +2 -2
  20. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  21. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  22. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  23. package/dist/types/modes/components/session-selector.d.ts +8 -3
  24. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  27. package/dist/types/session/agent-session.d.ts +13 -1
  28. package/dist/types/session/auth-storage.d.ts +2 -2
  29. package/dist/types/session/history-storage.d.ts +3 -4
  30. package/dist/types/session/messages.d.ts +1 -0
  31. package/dist/types/session/session-manager.d.ts +1 -0
  32. package/dist/types/slash-commands/types.d.ts +17 -4
  33. package/dist/types/task/types.d.ts +2 -0
  34. package/dist/types/tiny/text.d.ts +17 -0
  35. package/dist/types/tools/index.d.ts +16 -0
  36. package/dist/types/tools/path-utils.d.ts +11 -0
  37. package/dist/types/web/search/providers/base.d.ts +14 -0
  38. package/dist/types/web/search/providers/exa.d.ts +9 -0
  39. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  40. package/dist/types/web/search/types.d.ts +2 -1
  41. package/package.json +9 -9
  42. package/src/cli/dry-balance-cli.ts +823 -0
  43. package/src/cli/session-picker.ts +1 -0
  44. package/src/cli/update-cli.ts +54 -2
  45. package/src/cli-commands.ts +1 -0
  46. package/src/commands/completions.ts +1 -1
  47. package/src/commands/dry-balance.ts +43 -0
  48. package/src/config/append-only-context-mode.ts +37 -0
  49. package/src/config/model-registry.ts +6 -0
  50. package/src/config/models-config-schema.ts +3 -0
  51. package/src/config/settings-schema.ts +2 -2
  52. package/src/config/settings.ts +38 -0
  53. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  54. package/src/discovery/github.ts +37 -1
  55. package/src/discovery/helpers.ts +3 -1
  56. package/src/exa/mcp-client.ts +11 -5
  57. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  58. package/src/hindsight/backend.ts +184 -35
  59. package/src/hindsight/bank.ts +32 -22
  60. package/src/hindsight/mental-models.ts +1 -1
  61. package/src/hindsight/state.ts +21 -7
  62. package/src/internal-urls/docs-index.generated.ts +5 -5
  63. package/src/internal-urls/omp-protocol.ts +8 -2
  64. package/src/main.ts +4 -2
  65. package/src/mcp/json-rpc.ts +8 -0
  66. package/src/mcp/manager.ts +40 -21
  67. package/src/mcp/render.ts +3 -0
  68. package/src/mcp/tool-bridge.ts +10 -2
  69. package/src/mcp/transports/http.ts +33 -16
  70. package/src/mnemopi/state.ts +4 -4
  71. package/src/modes/acp/acp-agent.ts +168 -3
  72. package/src/modes/components/agent-dashboard.ts +103 -31
  73. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  74. package/src/modes/components/history-search.ts +128 -14
  75. package/src/modes/components/plugin-settings.ts +270 -36
  76. package/src/modes/components/session-selector.ts +45 -14
  77. package/src/modes/components/settings-selector.ts +1 -1
  78. package/src/modes/components/tips.txt +5 -1
  79. package/src/modes/components/transcript-container.ts +35 -6
  80. package/src/modes/components/tree-selector.ts +29 -2
  81. package/src/modes/controllers/command-controller.ts +4 -3
  82. package/src/modes/controllers/input-controller.ts +18 -7
  83. package/src/modes/controllers/selector-controller.ts +30 -19
  84. package/src/modes/interactive-mode.ts +38 -3
  85. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  86. package/src/modes/utils/keybinding-matchers.ts +10 -0
  87. package/src/prompts/agents/explore.md +1 -0
  88. package/src/prompts/agents/librarian.md +1 -0
  89. package/src/prompts/dry-balance-bench.md +8 -0
  90. package/src/prompts/steering/user-interjection.md +10 -0
  91. package/src/prompts/system/agent-creation-architect.md +1 -26
  92. package/src/prompts/system/system-prompt.md +143 -145
  93. package/src/prompts/system/title-system.md +3 -2
  94. package/src/prompts/tools/browser.md +29 -29
  95. package/src/prompts/tools/render-mermaid.md +2 -2
  96. package/src/sdk.ts +87 -30
  97. package/src/session/agent-session.ts +96 -14
  98. package/src/session/auth-storage.ts +4 -0
  99. package/src/session/history-storage.ts +11 -18
  100. package/src/session/messages.ts +80 -0
  101. package/src/session/session-manager.ts +7 -1
  102. package/src/slash-commands/types.ts +27 -10
  103. package/src/task/executor.ts +6 -2
  104. package/src/task/index.ts +8 -7
  105. package/src/task/types.ts +2 -0
  106. package/src/tiny/text.ts +112 -1
  107. package/src/tools/bash.ts +3 -4
  108. package/src/tools/index.ts +16 -0
  109. package/src/tools/job.ts +3 -3
  110. package/src/tools/memory-recall.ts +1 -1
  111. package/src/tools/memory-reflect.ts +3 -3
  112. package/src/tools/path-utils.ts +21 -0
  113. package/src/tools/search.ts +18 -1
  114. package/src/tools/ssh.ts +26 -10
  115. package/src/tools/write.ts +14 -2
  116. package/src/tui/status-line.ts +15 -4
  117. package/src/utils/file-mentions.ts +7 -107
  118. package/src/utils/title-generator.ts +66 -38
  119. package/src/web/search/index.ts +3 -1
  120. package/src/web/search/provider.ts +1 -1
  121. package/src/web/search/providers/base.ts +17 -0
  122. package/src/web/search/providers/exa.ts +111 -7
  123. package/src/web/search/providers/perplexity.ts +8 -4
  124. package/src/web/search/types.ts +2 -1
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,
@@ -39,6 +40,7 @@ import { createAutoresearchExtension } from "./autoresearch";
39
40
  import { loadCapability } from "./capability";
40
41
  import { type Rule, ruleCapability, setActiveRules } from "./capability/rule";
41
42
  import { bucketRules } from "./capability/rule-buckets";
43
+ import { shouldEnableAppendOnlyContext } from "./config/append-only-context-mode";
42
44
  import { ModelRegistry } from "./config/model-registry";
43
45
  import {
44
46
  formatModelString,
@@ -100,8 +102,16 @@ import {
100
102
  } from "./secrets";
101
103
  import { AgentSession } from "./session/agent-session";
102
104
  import { resolveAuthBrokerConfig } from "./session/auth-broker-config";
103
- import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./session/auth-storage";
104
- import { type CustomMessage, convertToLlm } from "./session/messages";
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";
114
+ import { type CustomMessage, convertToLlm, wrapSteeringForModel } from "./session/messages";
105
115
  import { getRestorableSessionModels, SessionManager } from "./session/session-manager";
106
116
  import { closeAllConnections } from "./ssh/connection-manager";
107
117
  import { unmountAll } from "./ssh/sshfs-mount";
@@ -417,6 +427,17 @@ function getDefaultAgentDir(): string {
417
427
  return getAgentDir();
418
428
  }
419
429
 
430
+ function resolveSnapshotTtlMs(): number {
431
+ const raw = process.env.OMP_AUTH_BROKER_SNAPSHOT_TTL_MS;
432
+ if (raw === undefined) return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
433
+ const value = raw.trim();
434
+ if (value === "") return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
435
+ const ttlMs = Number(value);
436
+ if (Number.isFinite(ttlMs) && ttlMs >= 0) return ttlMs;
437
+ logger.warn("Invalid OMP_AUTH_BROKER_SNAPSHOT_TTL_MS; using default", { value: raw });
438
+ return DEFAULT_SNAPSHOT_CACHE_TTL_MS;
439
+ }
440
+
420
441
  // Discovery Functions
421
442
 
422
443
  /**
@@ -434,9 +455,42 @@ export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir(
434
455
  const brokerConfig = await resolveAuthBrokerConfig();
435
456
  if (brokerConfig) {
436
457
  const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
437
- const initialResult = await client.fetchSnapshot();
438
- if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot");
439
- const store = new RemoteAuthCredentialStore({ client, initialSnapshot: initialResult.snapshot });
458
+ const ttlMs = resolveSnapshotTtlMs();
459
+ const cachePath = getAuthBrokerSnapshotCachePath();
460
+ const persist =
461
+ ttlMs > 0
462
+ ? (snapshot: SnapshotResponse): void => {
463
+ void writeAuthBrokerSnapshotCache({
464
+ path: cachePath,
465
+ token: brokerConfig.token,
466
+ url: brokerConfig.url,
467
+ snapshot,
468
+ }).catch(error => {
469
+ logger.debug("auth-broker snapshot cache write failed", { error: String(error) });
470
+ });
471
+ }
472
+ : undefined;
473
+
474
+ let initialSnapshot: SnapshotResponse | undefined;
475
+ if (ttlMs > 0) {
476
+ initialSnapshot =
477
+ (await readAuthBrokerSnapshotCache({
478
+ path: cachePath,
479
+ token: brokerConfig.token,
480
+ url: brokerConfig.url,
481
+ ttlMs,
482
+ }).catch(error => {
483
+ logger.debug("auth-broker snapshot cache read failed", { error: String(error) });
484
+ return null;
485
+ })) ?? undefined;
486
+ }
487
+ if (!initialSnapshot) {
488
+ const initialResult = await client.fetchSnapshot();
489
+ if (initialResult.status !== 200) throw new Error("Auth broker returned no initial snapshot");
490
+ initialSnapshot = initialResult.snapshot;
491
+ persist?.(initialSnapshot);
492
+ }
493
+ const store = new RemoteAuthCredentialStore({ client, initialSnapshot, onSnapshot: persist });
440
494
  // Refresh + usage hooks live on RemoteAuthCredentialStore; AuthStorage
441
495
  // discovers them automatically when no explicit option overrides them.
442
496
  const storage = new AuthStorage(store, {
@@ -646,24 +700,6 @@ function registerPythonCleanup(): void {
646
700
  postmortem.register("python-cleanup", disposeAllKernelSessions);
647
701
  }
648
702
 
649
- /**
650
- * Resolve whether to enable append-only context mode based on the setting and provider.
651
- *
652
- * - `"on"` → always enable
653
- * - `"off"` → never enable
654
- * - `"auto"` → enable for DeepSeek (prefix-caching provider)
655
- */
656
- function resolveAppendOnlyMode(setting: "auto" | "on" | "off" | undefined, provider: string): boolean {
657
- switch (setting ?? "auto") {
658
- case "on":
659
- return true;
660
- case "off":
661
- return false;
662
- default:
663
- return provider === "deepseek";
664
- }
665
- }
666
-
667
703
  function customToolToDefinition(tool: CustomTool): ToolDefinition {
668
704
  const definition: ToolDefinition & { [TOOL_DEFINITION_MARKER]: true } = {
669
705
  name: tool.name,
@@ -1185,12 +1221,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1185
1221
 
1186
1222
  return preview;
1187
1223
  };
1188
- // Only top-level sessions own an AsyncJobManager. Subagents reach the
1189
- // parent's manager via `AsyncJobManager.instance()` (set below), so creating
1190
- // a second instance here just to leave it orphaned wastes a constructor and
1191
- // risks accidental disposal of the parent's manager on subagent teardown.
1224
+ // Only the first top-level session in a process owns an AsyncJobManager.
1225
+ // Subagents inherit the parent's manager via `AsyncJobManager.instance()`
1226
+ // (set below), and any additional top-level session spun up in-process
1227
+ // (e.g. the agent-creation architect in `agent-dashboard.ts`) must share
1228
+ // the live singleton — otherwise its dispose path would clobber the
1229
+ // owning session's manager and break the `task`/`bash` async paths
1230
+ // (issue #1923). The `instance()` guard means later sessions also skip
1231
+ // constructing an orphaned manager that nothing would ever route to.
1192
1232
  const asyncJobManager =
1193
- backgroundJobsEnabled && !options.parentTaskPrefix
1233
+ backgroundJobsEnabled && !options.parentTaskPrefix && !AsyncJobManager.instance()
1194
1234
  ? new AsyncJobManager({
1195
1235
  maxRunningJobs: asyncMaxJobs,
1196
1236
  onJobComplete: async (jobId, result, job) => {
@@ -1209,6 +1249,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1209
1249
  })
1210
1250
  : undefined;
1211
1251
 
1252
+ const scopedAsyncJobManager = asyncJobManager ?? (options.parentTaskPrefix ? AsyncJobManager.instance() : undefined);
1253
+
1212
1254
  const agentRegistry = options.agentRegistry ?? AgentRegistry.global();
1213
1255
  const resolvedAgentId = options.agentId ?? options.parentTaskPrefix ?? MAIN_AGENT_ID;
1214
1256
  const resolvedAgentDisplayName =
@@ -1310,6 +1352,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1310
1352
  authStorage,
1311
1353
  modelRegistry,
1312
1354
  getTelemetry: () => agent?.telemetry,
1355
+ // Subagents inherit the singleton (the parent's manager) so their bash/task
1356
+ // completions still flow into the spawning conversation's yieldQueue.
1357
+ // Secondary in-process top-level sessions (no parentTaskPrefix, no
1358
+ // constructed manager because the singleton was already installed) leave
1359
+ // this undefined so tools and session job snapshots refuse async work
1360
+ // instead of silently routing into the owning session (issue #1923).
1361
+ asyncJobManager: scopedAsyncJobManager,
1313
1362
  };
1314
1363
 
1315
1364
  // Wire process-wide internal URL singletons owned by their real classes.
@@ -1905,7 +1954,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1905
1954
  return obfuscateMessages(obfuscator, converted);
1906
1955
  };
1907
1956
  const transformContext = async (messages: AgentMessage[], _signal?: AbortSignal) => {
1908
- return await extensionRunner.emitContext(messages);
1957
+ const withContext = await extensionRunner.emitContext(messages);
1958
+ return wrapSteeringForModel(withContext);
1909
1959
  };
1910
1960
  const onPayload = async (payload: unknown, _model?: Model) => {
1911
1961
  return await extensionRunner.emitBeforeProviderRequest(payload);
@@ -2028,7 +2078,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2028
2078
  getToolChoice: () => session?.nextToolChoice(),
2029
2079
  telemetry: options.telemetry,
2030
2080
  appendOnlyContext: model
2031
- ? resolveAppendOnlyMode(settings.get("provider.appendOnlyContext"), model.provider)
2081
+ ? shouldEnableAppendOnlyContext(settings.get("provider.appendOnlyContext"), model)
2032
2082
  ? new AppendOnlyContextManager()
2033
2083
  : undefined
2034
2084
  : undefined,
@@ -2065,6 +2115,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2065
2115
  // AsyncJobManager on teardown; subagents inherit the parent's and
2066
2116
  // **MUST NOT** tear it down.
2067
2117
  ownedAsyncJobManager: asyncJobManager,
2118
+ asyncJobManager: scopedAsyncJobManager,
2068
2119
  scopedModels: options.scopedModels,
2069
2120
  promptTemplates,
2070
2121
  slashCommands,
@@ -2278,6 +2329,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2278
2329
  await session.dispose();
2279
2330
  } else {
2280
2331
  if (hasRegistered) agentRegistry.unregister(resolvedAgentId);
2332
+ if (asyncJobManager) {
2333
+ if (AsyncJobManager.instance() === asyncJobManager) {
2334
+ AsyncJobManager.setInstance(undefined);
2335
+ }
2336
+ await asyncJobManager.dispose({ timeoutMs: 3_000 });
2337
+ }
2281
2338
  await disposeKernelSessionsByOwner(evalKernelOwnerId);
2282
2339
  }
2283
2340
  } 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,12 +95,14 @@ 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";
100
102
  import { classifyDifficulty } from "../auto-thinking/classifier";
101
103
  import { reset as resetCapabilities } from "../capability";
102
104
  import type { Rule } from "../capability/rule";
105
+ import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mode";
103
106
  import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
104
107
  import {
105
108
  extractExplicitThinkingSelector,
@@ -232,7 +235,7 @@ import type {
232
235
  SessionContext,
233
236
  SessionManager,
234
237
  } from "./session-manager";
235
- import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-manager";
238
+ import { EPHEMERAL_MODEL_CHANGE_ROLE, getLatestCompactionEntry, getRestorableSessionModels } from "./session-manager";
236
239
  import type { ShakeMode, ShakeResult } from "./shake-types";
237
240
  import { ToolChoiceQueue } from "./tool-choice-queue";
238
241
  import { YieldQueue } from "./yield-queue";
@@ -365,6 +368,15 @@ export interface AgentSessionConfig {
365
368
  * **MUST NOT** dispose it on their own teardown.
366
369
  */
367
370
  ownedAsyncJobManager?: AsyncJobManager;
371
+ /**
372
+ * AsyncJobManager reachable by this session for scoped job actions.
373
+ *
374
+ * Top-level owners receive their own manager, subagents receive the inherited
375
+ * parent manager, and secondary in-process top-level sessions receive
376
+ * `undefined` so job snapshots and ACP drains cannot observe the primary's
377
+ * state.
378
+ */
379
+ asyncJobManager?: AsyncJobManager;
368
380
  /** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */
369
381
  agentId?: string;
370
382
  /** Shared agent registry (for forwarding IRC observations to the main session UI). */
@@ -889,6 +901,14 @@ export class AgentSession {
889
901
  * this undefined and **MUST NOT** dispose the global instance on teardown.
890
902
  */
891
903
  readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
904
+ /**
905
+ * AsyncJobManager scoped to this session for introspection/cancellation.
906
+ *
907
+ * This differs from `#ownedAsyncJobManager`: subagents can inherit a parent
908
+ * manager for their own owner id, while secondary top-level sessions are left
909
+ * undefined to avoid reading the primary's jobs.
910
+ */
911
+ readonly #asyncJobManager: AsyncJobManager | undefined;
892
912
  #pendingPythonMessages: PythonExecutionMessage[] = [];
893
913
  #activeEvalExecutions = new Set<Promise<unknown>>();
894
914
  #evalExecutionDisposing = false;
@@ -1079,6 +1099,7 @@ export class AgentSession {
1079
1099
  this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
1080
1100
  this.#parentEvalSessionId = config.parentEvalSessionId;
1081
1101
  this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
1102
+ this.#asyncJobManager = config.asyncJobManager ?? config.ownedAsyncJobManager;
1082
1103
  this.#scopedModels = config.scopedModels ?? [];
1083
1104
  if (config.thinkingLevel === AUTO_THINKING) {
1084
1105
  // `auto` is session-level: keep the flag and show a provisional concrete
@@ -1293,6 +1314,12 @@ export class AgentSession {
1293
1314
  this.#standingResolveHandler = handler ?? undefined;
1294
1315
  }
1295
1316
 
1317
+ #sessionSwitchReconciler: (() => Promise<void>) | undefined;
1318
+
1319
+ setSessionSwitchReconciler(reconciler: (() => Promise<void>) | null): void {
1320
+ this.#sessionSwitchReconciler = reconciler ?? undefined;
1321
+ }
1322
+
1296
1323
  /** Provider-scoped mutable state store for transport/session caches. */
1297
1324
  get providerSessionState(): Map<string, ProviderSessionState> {
1298
1325
  return this.#providerSessionState;
@@ -1366,7 +1393,7 @@ export class AgentSession {
1366
1393
  }
1367
1394
 
1368
1395
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
1369
- const manager = AsyncJobManager.instance();
1396
+ const manager = this.#asyncJobManager;
1370
1397
  if (!manager) return null;
1371
1398
  const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
1372
1399
  const running = manager.getRunningJobs(ownerFilter).map(job => ({
@@ -1391,11 +1418,20 @@ export class AgentSession {
1391
1418
  * Cancel async jobs registered by *this* agent only. Used by lifecycle
1392
1419
  * transitions (newSession, switchSession, handoff, dispose) so a subagent
1393
1420
  * cleans up its own background work without touching its parent's jobs.
1394
- * No-op when no manager is installed or this session has no agent id.
1421
+ *
1422
+ * Cancellation runs against this session's scoped manager. Subagents have
1423
+ * unique agent ids and inherit the parent's manager to clean up their own
1424
+ * jobs. A secondary in-process top-level session gets no scoped manager,
1425
+ * because it defaults to `MAIN_AGENT_ID`; reaching through the global
1426
+ * singleton would tear down the owning primary session's bash/task jobs at
1427
+ * dispose time (issue #1923).
1428
+ *
1429
+ * No-op when no manager is reachable or this session has no agent id.
1395
1430
  */
1396
1431
  #cancelOwnAsyncJobs(): void {
1397
1432
  if (!this.#agentId) return;
1398
- AsyncJobManager.instance()?.cancelAll({ ownerId: this.#agentId });
1433
+ const manager = this.#asyncJobManager;
1434
+ manager?.cancelAll({ ownerId: this.#agentId });
1399
1435
  }
1400
1436
 
1401
1437
  // =========================================================================
@@ -2121,12 +2157,31 @@ export class AgentSession {
2121
2157
  if (this.#pendingTtsrInjections.length === 0) return undefined;
2122
2158
  const rules = this.#pendingTtsrInjections;
2123
2159
  const content = rules
2124
- .map(r => prompt.render(ttsrInterruptTemplate, { name: r.name, path: r.path, content: r.content }))
2160
+ .map(r =>
2161
+ prompt.render(ttsrInterruptTemplate, {
2162
+ name: r.name,
2163
+ path: this.#displayRulePath(r.path),
2164
+ content: r.content,
2165
+ }),
2166
+ )
2125
2167
  .join("\n\n");
2126
2168
  this.#pendingTtsrInjections = [];
2127
2169
  return { content, rules };
2128
2170
  }
2129
2171
 
2172
+ /**
2173
+ * Render a rule's file path for model-facing TTSR injections without leaking
2174
+ * the absolute home directory: cwd-relative when the rule lives in the
2175
+ * project, `~`-relative when it lives under home, else the raw path.
2176
+ */
2177
+ #displayRulePath(rulePath: string): string {
2178
+ const cwdRel = relativePathWithinRoot(this.sessionManager.getCwd(), rulePath);
2179
+ if (cwdRel) return cwdRel;
2180
+ const homeRel = relativePathWithinRoot(os.homedir(), rulePath);
2181
+ if (homeRel) return `~/${homeRel}`;
2182
+ return rulePath;
2183
+ }
2184
+
2130
2185
  #addPendingTtsrInjections(rules: Rule[]): void {
2131
2186
  const seen = new Set(this.#pendingTtsrInjections.map(rule => rule.name));
2132
2187
  for (const rule of rules) {
@@ -2179,7 +2234,13 @@ export class AgentSession {
2179
2234
  if (!rules || rules.length === 0) return undefined;
2180
2235
  this.#perToolTtsrInjections.delete(ctx.toolCall.id);
2181
2236
  const reminder = rules
2182
- .map(r => prompt.render(ttsrToolReminderTemplate, { name: r.name, path: r.path, content: r.content }))
2237
+ .map(r =>
2238
+ prompt.render(ttsrToolReminderTemplate, {
2239
+ name: r.name,
2240
+ path: this.#displayRulePath(r.path),
2241
+ content: r.content,
2242
+ }),
2243
+ )
2183
2244
  .join("\n\n");
2184
2245
  // The TTSR manager was already claimed at bucket time; only persistence remains.
2185
2246
  const ruleNames = rules.map(r => r.name.trim()).filter(n => n.length > 0);
@@ -2983,8 +3044,13 @@ export class AgentSession {
2983
3044
  this.#releasePowerAssertion();
2984
3045
  await this.sessionManager.close();
2985
3046
  this.#closeAllProviderSessions("dispose");
2986
- const hindsightState = this.setHindsightSessionState(undefined);
3047
+ // Flush the retain queue BEFORE clearing the session's pointer so
3048
+ // `HindsightRetainQueue.#doFlush` still sees `session.getHindsightSessionState() === state`.
3049
+ // Reversed, the spliced batch survives just long enough to fail the
3050
+ // identity check and get dropped with a `session vanished` warning.
3051
+ const hindsightState = this.getHindsightSessionState();
2987
3052
  await hindsightState?.flushRetainQueue();
3053
+ this.setHindsightSessionState(undefined);
2988
3054
  hindsightState?.dispose();
2989
3055
  const mnemopiState = setMnemopiSessionState(this, undefined);
2990
3056
  mnemopiState?.dispose();
@@ -3062,7 +3128,7 @@ export class AgentSession {
3062
3128
  }
3063
3129
 
3064
3130
  async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
3065
- const manager = AsyncJobManager.instance();
3131
+ const manager = this.#asyncJobManager;
3066
3132
  if (!manager) return false;
3067
3133
  const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
3068
3134
  const before = manager.getDeliveryState(ownerFilter);
@@ -4639,6 +4705,7 @@ export class AgentSession {
4639
4705
  this.agent.steer({
4640
4706
  role: "user",
4641
4707
  content,
4708
+ steering: true,
4642
4709
  attribution: "user",
4643
4710
  timestamp: Date.now(),
4644
4711
  });
@@ -5225,7 +5292,11 @@ export class AgentSession {
5225
5292
  * Validates API key, saves to session log but NOT to settings.
5226
5293
  * @throws Error if no API key available for the model
5227
5294
  */
5228
- async setModelTemporary(model: Model, thinkingLevel?: ThinkingLevel): Promise<void> {
5295
+ async setModelTemporary(
5296
+ model: Model,
5297
+ thinkingLevel?: ThinkingLevel,
5298
+ options?: { ephemeral?: boolean },
5299
+ ): Promise<void> {
5229
5300
  const previousEditMode = this.#resolveActiveEditMode();
5230
5301
  const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
5231
5302
  if (!apiKey) {
@@ -5234,7 +5305,10 @@ export class AgentSession {
5234
5305
 
5235
5306
  this.#clearActiveRetryFallback();
5236
5307
  this.#setModelWithProviderSessionReset(model);
5237
- this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
5308
+ this.sessionManager.appendModelChange(
5309
+ `${model.provider}/${model.id}`,
5310
+ options?.ephemeral ? EPHEMERAL_MODEL_CHANGE_ROLE : "temporary",
5311
+ );
5238
5312
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
5239
5313
 
5240
5314
  // Apply explicit thinking level if given; otherwise prefer the model's
@@ -6682,7 +6756,7 @@ export class AgentSession {
6682
6756
  if (!targetModel) return false;
6683
6757
 
6684
6758
  try {
6685
- await this.setModelTemporary(targetModel);
6759
+ await this.setModelTemporary(targetModel, undefined, { ephemeral: true });
6686
6760
  logger.debug("Context promotion switched model on overflow", {
6687
6761
  from: `${currentModel.provider}/${currentModel.id}`,
6688
6762
  to: `${targetModel.provider}/${targetModel.id}`,
@@ -6735,8 +6809,8 @@ export class AgentSession {
6735
6809
  */
6736
6810
  #syncAppendOnlyContext(model: Model | null | undefined): void {
6737
6811
  const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
6812
+ const enable = shouldEnableAppendOnlyContext(setting, model);
6738
6813
  const providerId = model?.provider;
6739
- const enable = setting === "on" || (setting === "auto" && providerId === "deepseek");
6740
6814
  const prev = this.#lastAppendOnlyResolution;
6741
6815
  if (prev && prev.enable === enable && prev.providerId === providerId) return;
6742
6816
  this.#lastAppendOnlyResolution = { enable, providerId };
@@ -7848,7 +7922,7 @@ export class AgentSession {
7848
7922
  const nextThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel;
7849
7923
 
7850
7924
  this.#setModelWithProviderSessionReset(candidate);
7851
- this.sessionManager.appendModelChange(`${candidate.provider}/${candidate.id}`, "temporary");
7925
+ this.sessionManager.appendModelChange(`${candidate.provider}/${candidate.id}`, EPHEMERAL_MODEL_CHANGE_ROLE);
7852
7926
  this.settings.getStorage()?.recordModelUsage(`${candidate.provider}/${candidate.id}`);
7853
7927
  this.setThinkingLevel(nextThinkingLevel);
7854
7928
  if (!this.#activeRetryFallback) {
@@ -7921,7 +7995,7 @@ export class AgentSession {
7921
7995
  const thinkingToApply =
7922
7996
  currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
7923
7997
  this.#setModelWithProviderSessionReset(primaryModel);
7924
- this.sessionManager.appendModelChange(`${primaryModel.provider}/${primaryModel.id}`, "temporary");
7998
+ this.sessionManager.appendModelChange(`${primaryModel.provider}/${primaryModel.id}`, EPHEMERAL_MODEL_CHANGE_ROLE);
7925
7999
  this.settings.getStorage()?.recordModelUsage(`${primaryModel.provider}/${primaryModel.id}`);
7926
8000
  this.setThinkingLevel(thinkingToApply);
7927
8001
  this.#clearActiveRetryFallback();
@@ -8948,6 +9022,14 @@ export class AgentSession {
8948
9022
  this.#resetMnemopiConversationTrackingIfMnemopi();
8949
9023
  }
8950
9024
  this.#reconnectToAgent();
9025
+ try {
9026
+ await this.#sessionSwitchReconciler?.();
9027
+ } catch (error) {
9028
+ logger.warn("Failed to reconcile session mode after switch", {
9029
+ targetSessionFile: sessionPath,
9030
+ error: String(error),
9031
+ });
9032
+ }
8951
9033
  return true;
8952
9034
  } catch (error) {
8953
9035
  this.sessionManager.restoreState(previousSessionState);
@@ -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";
@@ -209,10 +209,6 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
209
209
  logger.debug("HistoryStorage FTS query failed, using substring only", { error: String(error) });
210
210
  }
211
211
 
212
- if (ftsRows.length >= safeLimit) {
213
- return ftsRows.map(row => this.#toEntry(row));
214
- }
215
-
216
212
  // 2. Substring fallback (token-AND LIKE). Catches infix matches FTS5's
217
213
  // prefix-only wildcard cannot reach (e.g. "mit" -> "commit"). Bounded
218
214
  // by safeLimit, ordered by recency - no full-table load into JS.
@@ -227,27 +223,24 @@ CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
227
223
  return subRows.map(row => this.#toEntry(row));
228
224
  }
229
225
 
230
- const seen = new Set<number>();
231
- const merged: HistoryEntry[] = [];
226
+ const rowsById = new Map<number, HistoryRow>();
232
227
  for (const row of ftsRows) {
233
- if (seen.has(row.id)) continue;
234
- seen.add(row.id);
235
- merged.push(this.#toEntry(row));
228
+ rowsById.set(row.id, row);
236
229
  }
237
230
  for (const row of subRows) {
238
- if (merged.length >= safeLimit) break;
239
- if (seen.has(row.id)) continue;
240
- seen.add(row.id);
241
- merged.push(this.#toEntry(row));
231
+ if (!rowsById.has(row.id)) rowsById.set(row.id, row);
242
232
  }
243
- return merged;
233
+
234
+ return [...rowsById.values()]
235
+ .sort((a, b) => b.created_at - a.created_at || b.id - a.id)
236
+ .slice(0, safeLimit)
237
+ .map(row => this.#toEntry(row));
244
238
  }
245
239
 
246
240
  /**
247
- * IDs of the sessions whose stored prompts match `query`, ordered by match
248
- * relevance (most relevant/recent first) and de-duplicated. Prompts with no
249
- * recorded session are skipped. Used to augment session ranking in the
250
- * resume picker with prompts that the 4KB session-list prefix never sees.
241
+ * IDs of the sessions whose stored prompts match `query`, ordered by prompt
242
+ * recency and de-duplicated. Used to augment session ranking in the resume
243
+ * picker with prompts that the 4KB session-list prefix never sees.
251
244
  */
252
245
  matchingSessionIds(query: string, limit = 500): string[] {
253
246
  const seen = new Set<string>();
@@ -18,7 +18,10 @@ import type {
18
18
  MessageAttribution,
19
19
  TextContent,
20
20
  ToolResultMessage,
21
+ UserMessage,
21
22
  } from "@oh-my-pi/pi-ai";
23
+ import { prompt } from "@oh-my-pi/pi-utils";
24
+ import userInterjectionTemplate from "../prompts/steering/user-interjection.md" with { type: "text" };
22
25
 
23
26
  export {
24
27
  type BranchSummaryMessage,
@@ -105,6 +108,83 @@ export function stripInternalDetailsFields<T>(details: T | undefined): T | undef
105
108
  return cleaned as T;
106
109
  }
107
110
 
111
+ function isSteeringUserMessage(message: AgentMessage | undefined): message is UserMessage & { steering: true } {
112
+ return message?.role === "user" && message.steering === true;
113
+ }
114
+
115
+ function userMessageWithoutSteering(message: UserMessage): UserMessage {
116
+ const { steering, ...rest } = message;
117
+ void steering;
118
+ return rest;
119
+ }
120
+
121
+ function renderSteeringEnvelope(message: string): string {
122
+ return prompt.render(userInterjectionTemplate, { message });
123
+ }
124
+
125
+ function getArrayContentText(content: (TextContent | ImageContent)[]): string {
126
+ let firstText: string | undefined;
127
+ let textParts: string[] | undefined;
128
+ for (const part of content) {
129
+ if (part.type !== "text") continue;
130
+ if (firstText === undefined) {
131
+ firstText = part.text;
132
+ continue;
133
+ }
134
+ if (textParts === undefined) {
135
+ textParts = [firstText];
136
+ }
137
+ textParts.push(part.text);
138
+ }
139
+ return textParts === undefined ? (firstText ?? "") : textParts.join("\n");
140
+ }
141
+
142
+ function getArrayContentImages(content: (TextContent | ImageContent)[]): ImageContent[] {
143
+ let images: ImageContent[] | undefined;
144
+ for (const part of content) {
145
+ if (part.type !== "image") continue;
146
+ if (images === undefined) images = [];
147
+ images.push(part);
148
+ }
149
+ return images ?? [];
150
+ }
151
+
152
+ function wrapSteeringUserMessage(message: UserMessage): UserMessage {
153
+ if (typeof message.content === "string") {
154
+ if (message.content.length === 0) return message;
155
+ return { ...userMessageWithoutSteering(message), content: renderSteeringEnvelope(message.content) };
156
+ }
157
+
158
+ const text = getArrayContentText(message.content);
159
+ if (text.length === 0) return message;
160
+ const content: (TextContent | ImageContent)[] = [{ type: "text", text: renderSteeringEnvelope(text) }];
161
+ content.push(...getArrayContentImages(message.content));
162
+ return { ...userMessageWithoutSteering(message), content };
163
+ }
164
+
165
+ export function wrapSteeringForModel(messages: AgentMessage[]): AgentMessage[] {
166
+ const last = messages[messages.length - 1];
167
+ if (!isSteeringUserMessage(last)) return messages;
168
+
169
+ let firstSteer = messages.length - 1;
170
+ while (firstSteer > 0 && isSteeringUserMessage(messages[firstSteer - 1])) {
171
+ firstSteer--;
172
+ }
173
+
174
+ let wrappedMessages: AgentMessage[] | undefined;
175
+ for (let i = firstSteer; i < messages.length; i++) {
176
+ const message = messages[i];
177
+ if (!isSteeringUserMessage(message)) continue;
178
+ const wrappedMessage = wrapSteeringUserMessage(message);
179
+ if (wrappedMessage === message) continue;
180
+ if (wrappedMessages === undefined) {
181
+ wrappedMessages = messages.slice();
182
+ }
183
+ wrappedMessages[i] = wrappedMessage;
184
+ }
185
+ return wrappedMessages ?? messages;
186
+ }
187
+
108
188
  function getPrunedToolResultContent(message: ToolResultMessage): (TextContent | ImageContent)[] {
109
189
  if (message.prunedAt === undefined) {
110
190
  return message.content;
@@ -254,13 +254,19 @@ export interface SessionContext {
254
254
  modeData?: Record<string, unknown>;
255
255
  }
256
256
 
257
+ export const EPHEMERAL_MODEL_CHANGE_ROLE = "fallback";
258
+
257
259
  /** Lists session model strings to try when restoring, in fallback order. */
258
260
  export function getRestorableSessionModels(
259
261
  models: Readonly<Record<string, string>>,
260
262
  lastModelChangeRole: string | undefined,
261
263
  ): string[] {
262
264
  const defaultModel = models.default;
263
- if (!lastModelChangeRole || lastModelChangeRole === "default" || lastModelChangeRole === "temporary") {
265
+ if (
266
+ !lastModelChangeRole ||
267
+ lastModelChangeRole === "default" ||
268
+ lastModelChangeRole === EPHEMERAL_MODEL_CHANGE_ROLE
269
+ ) {
264
270
  return defaultModel ? [defaultModel] : [];
265
271
  }
266
272