@oh-my-pi/pi-coding-agent 17.2.3 → 17.2.4

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 (92) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/{CHANGELOG-c5hpqt9r.md → CHANGELOG-bpmhv26t.md} +41 -0
  3. package/dist/cli.js +3270 -3270
  4. package/dist/types/advisor/advise-tool.d.ts +6 -0
  5. package/dist/types/advisor/runtime.d.ts +4 -4
  6. package/dist/types/capability/mcp.d.ts +9 -0
  7. package/dist/types/cli/models-cli.d.ts +1 -1
  8. package/dist/types/config/keybindings.d.ts +1 -1
  9. package/dist/types/config/settings-schema.d.ts +1 -1
  10. package/dist/types/discovery/helpers.d.ts +6 -0
  11. package/dist/types/discovery/omp-extension-roots.d.ts +21 -2
  12. package/dist/types/extensibility/extensions/loader.d.ts +6 -2
  13. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +2 -2
  14. package/dist/types/launch/paths.d.ts +2 -1
  15. package/dist/types/mcp/request-id.d.ts +19 -0
  16. package/dist/types/mcp/types.d.ts +14 -0
  17. package/dist/types/memory-backend/index.d.ts +1 -0
  18. package/dist/types/memory-backend/messages.d.ts +15 -0
  19. package/dist/types/modes/components/model-browser.d.ts +7 -5
  20. package/dist/types/modes/components/model-picker.d.ts +9 -3
  21. package/dist/types/modes/components/read-tool-group.d.ts +9 -0
  22. package/dist/types/modes/types.d.ts +1 -1
  23. package/dist/types/registry/agent-lifecycle.d.ts +14 -5
  24. package/dist/types/registry/agent-registry.d.ts +4 -3
  25. package/dist/types/sdk.d.ts +1 -1
  26. package/dist/types/secrets/index.d.ts +6 -8
  27. package/dist/types/session/agent-session.d.ts +6 -1
  28. package/dist/types/session/model-controls.d.ts +0 -1
  29. package/dist/types/session/session-advisors.d.ts +7 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -0
  31. package/dist/types/session/session-manager.d.ts +11 -5
  32. package/dist/types/system-prompt.d.ts +2 -0
  33. package/dist/types/tools/default-renderer.d.ts +3 -0
  34. package/package.json +12 -12
  35. package/scripts/legacy-pi-virtual-module.ts +12 -2
  36. package/src/advisor/advise-tool.ts +18 -0
  37. package/src/advisor/runtime.ts +6 -6
  38. package/src/capability/mcp.ts +7 -0
  39. package/src/cli/models-cli.ts +13 -16
  40. package/src/collab/host.ts +1 -1
  41. package/src/config/keybindings.ts +14 -9
  42. package/src/config/mcp-schema.json +5 -0
  43. package/src/config/model-registry.ts +7 -1
  44. package/src/config/settings-schema.ts +1 -1
  45. package/src/discovery/builtin.ts +10 -0
  46. package/src/discovery/helpers.ts +10 -0
  47. package/src/discovery/mcp-json.ts +11 -1
  48. package/src/discovery/omp-extension-roots.ts +79 -20
  49. package/src/discovery/omp-plugins.ts +10 -1
  50. package/src/extensibility/extensions/loader.ts +66 -28
  51. package/src/extensibility/plugins/marketplace/registry.ts +4 -8
  52. package/src/launch/paths.ts +2 -5
  53. package/src/main.ts +24 -22
  54. package/src/mcp/config.ts +1 -0
  55. package/src/mcp/request-id.ts +24 -0
  56. package/src/mcp/transports/http.ts +4 -2
  57. package/src/mcp/transports/sse.ts +4 -2
  58. package/src/mcp/transports/stdio.ts +4 -2
  59. package/src/mcp/types.ts +15 -0
  60. package/src/memory-backend/index.ts +1 -0
  61. package/src/memory-backend/messages.ts +19 -0
  62. package/src/modes/components/agent-hub.ts +1 -1
  63. package/src/modes/components/custom-editor.ts +1 -1
  64. package/src/modes/components/model-browser.ts +25 -17
  65. package/src/modes/components/model-picker.ts +10 -6
  66. package/src/modes/components/read-tool-group.ts +26 -0
  67. package/src/modes/components/tool-execution.ts +77 -20
  68. package/src/modes/controllers/command-controller.ts +2 -2
  69. package/src/modes/controllers/event-controller.ts +182 -10
  70. package/src/modes/controllers/selector-controller.ts +27 -6
  71. package/src/modes/types.ts +5 -1
  72. package/src/prompts/tools/bash.md +1 -1
  73. package/src/registry/agent-lifecycle.ts +36 -11
  74. package/src/registry/agent-registry.ts +12 -5
  75. package/src/sdk.ts +68 -29
  76. package/src/secrets/index.ts +15 -17
  77. package/src/session/agent-session.ts +10 -1
  78. package/src/session/agent-storage.ts +6 -4
  79. package/src/session/history-storage.ts +4 -2
  80. package/src/session/messages.ts +31 -7
  81. package/src/session/model-controls.ts +0 -1
  82. package/src/session/session-advisors.ts +19 -1
  83. package/src/session/session-maintenance.ts +3 -0
  84. package/src/session/session-manager.ts +128 -76
  85. package/src/session/session-tools.ts +25 -23
  86. package/src/slash-commands/builtin-registry.ts +2 -2
  87. package/src/system-prompt.ts +6 -1
  88. package/src/task/executor.ts +8 -1
  89. package/src/tiny/text.ts +12 -0
  90. package/src/tools/default-renderer.ts +19 -4
  91. package/src/tools/index.ts +10 -6
  92. package/src/web/search/providers/anthropic.ts +3 -1
package/src/sdk.ts CHANGED
@@ -66,6 +66,7 @@ import { CursorExecHandlers, type CursorMcpResourceAdapter } from "./cursor";
66
66
  import { createBridgeEditTool, createBridgeGrepFactory } from "./cursor-bridge-tools";
67
67
  import "./discovery";
68
68
  import { initializeWithSettings } from "./discovery";
69
+ import { withOmpExtensionRootScope } from "./discovery/omp-extension-roots";
69
70
  import { disposeAllJuliaKernelSessions, disposeJuliaKernelSessionsByOwner } from "./eval/jl/executor";
70
71
  import { disposeVmContextsByOwner } from "./eval/js/context-manager";
71
72
  import { disposeAllKernelSessions, disposeKernelSessionsByOwner } from "./eval/py/executor";
@@ -695,12 +696,15 @@ export async function discoverSessionExtensionPaths(
695
696
  cwd: string,
696
697
  settings: Settings,
697
698
  ): Promise<string[]> {
698
- if (options.disableExtensionDiscovery) {
699
- return options.additionalExtensionPaths ?? [];
700
- }
701
- const configuredPaths = [...(options.additionalExtensionPaths ?? []), ...(settings.get("extensions") ?? [])];
702
- const disabledExtensionIds = settings.get("disabledExtensions") ?? [];
703
- return discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds);
699
+ const configuredPaths = options.disableExtensionDiscovery
700
+ ? (options.additionalExtensionPaths ?? [])
701
+ : [...(options.additionalExtensionPaths ?? []), ...(settings.get("extensions") ?? [])];
702
+ const disabledExtensionIds = options.disableExtensionDiscovery
703
+ ? undefined
704
+ : (settings.get("disabledExtensions") ?? []);
705
+ return discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds, {
706
+ ambient: !options.disableExtensionDiscovery,
707
+ });
704
708
  }
705
709
 
706
710
  /**
@@ -778,9 +782,11 @@ export async function discoverSkills(
778
782
  export async function discoverContextFiles(
779
783
  cwd?: string,
780
784
  _agentDir?: string,
785
+ disabledExtensions?: string[],
781
786
  ): Promise<Array<{ path: string; content: string; depth?: number }>> {
782
787
  return await loadContextFilesInternal({
783
788
  cwd: cwd ?? getProjectDir(),
789
+ disabledExtensions,
784
790
  });
785
791
  }
786
792
 
@@ -1214,6 +1220,13 @@ export function createAutoLearnCaptureRunner(
1214
1220
  * ```
1215
1221
  */
1216
1222
  export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
1223
+ const rootMode = options.disableExtensionDiscovery ? "explicit-only" : "merge";
1224
+ return await withOmpExtensionRootScope(options.additionalExtensionPaths ?? [], rootMode, () =>
1225
+ createAgentSessionScoped(options),
1226
+ );
1227
+ }
1228
+
1229
+ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Promise<CreateAgentSessionResult> {
1217
1230
  const cwd = options.cwd ?? getProjectDir();
1218
1231
  const agentDir = options.agentDir ?? getAgentDir();
1219
1232
  const eventBus = options.eventBus ?? new EventBus();
@@ -1277,14 +1290,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1277
1290
  ? Promise.resolve(options.contextFiles)
1278
1291
  : logger.time("discoverContextFiles", discoverContextFiles, cwd, agentDir);
1279
1292
  contextFilesPromise.catch(() => {});
1280
- const activeRepoContextPromise = logger.time("resolveActiveRepoContext", async () => {
1293
+ const resolveRepoContext = async (repoCwd: string) => {
1281
1294
  try {
1282
- return await resolveActiveRepoContext(cwd);
1295
+ return await resolveActiveRepoContext(repoCwd);
1283
1296
  } catch (err) {
1284
1297
  logger.debug("Failed to resolve active repo context", { err: String(err) });
1285
1298
  return null;
1286
1299
  }
1287
- });
1300
+ };
1301
+ const activeRepoContextPromise = logger.time("resolveActiveRepoContext", resolveRepoContext, cwd);
1288
1302
  activeRepoContextPromise.catch(() => {});
1289
1303
  const watchdogFilesPromise = logger.time("discoverWatchdogFiles", () => discoverWatchdogFiles(cwd, agentDir));
1290
1304
  watchdogFilesPromise.catch(() => {});
@@ -1377,11 +1391,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1377
1391
  // headless run on an unwritable default config root pays for a feature it
1378
1392
  // does not use.
1379
1393
  const needsPlaceholderKey = secretEntriesNeedPlaceholderKey([...envEntries, ...fileEntries]);
1394
+ const explicitAgentDir = options.agentDir;
1380
1395
  const placeholderKey = needsPlaceholderKey
1381
- ? await getSecretPlaceholderKey(agentDir)
1382
- : await getExistingSecretPlaceholderKey(agentDir);
1396
+ ? await getSecretPlaceholderKey(explicitAgentDir)
1397
+ : await getExistingSecretPlaceholderKey(explicitAgentDir);
1383
1398
  if (allEntries.length > 0) {
1384
- obfuscator = new SecretObfuscator(allEntries, placeholderKey ?? (() => getSecretPlaceholderKeySync(agentDir)));
1399
+ obfuscator = new SecretObfuscator(
1400
+ allEntries,
1401
+ placeholderKey ?? (() => getSecretPlaceholderKeySync(explicitAgentDir)),
1402
+ );
1385
1403
  }
1386
1404
  if (obfuscator?.hasSecrets() !== true && placeholderKey !== undefined) {
1387
1405
  // No configured entry produced an active secret (e.g. only ignored short
@@ -1591,7 +1609,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1591
1609
  }
1592
1610
  return result;
1593
1611
  };
1594
- const [contextFiles, resolvedWorkspaceTree, watchdogFiles, activeRepoContext, discoveredAdvisors] =
1612
+ const [initialContextFiles, resolvedWorkspaceTree, watchdogFiles, initialActiveRepoContext, discoveredAdvisors] =
1595
1613
  await Promise.all([
1596
1614
  contextFilesPromise,
1597
1615
  raceWithDeadline("buildWorkspaceTree", workspaceTreePromise),
@@ -1599,6 +1617,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1599
1617
  activeRepoContextPromise,
1600
1618
  advisorConfigsPromise,
1601
1619
  ]);
1620
+ let contextFiles = initialContextFiles;
1602
1621
 
1603
1622
  let agent: Agent;
1604
1623
  let session!: AgentSession;
@@ -1633,14 +1652,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1633
1652
  const agentKind = (options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? ("sub" as const) : ("main" as const);
1634
1653
  let registeredAgentRef: AgentRef | undefined;
1635
1654
  /**
1636
- * Forget the agent ref on teardown — unless the agent is being parked (or is
1637
- * already parked). Parking disposes the session but keeps the ref addressable
1638
- * (history://, revive); only process teardown / explicit kill unregisters.
1655
+ * Forget the agent ref on teardown — unless it is a retained terminal ref.
1656
+ * Parking disposes the session but keeps the ref addressable (history://,
1657
+ * revive); a hard kill leaves it as a terminal `aborted` tombstone. Both are
1658
+ * detached (session === null) by the time dispose runs, per the AgentRef
1659
+ * invariant, so preserving them never keeps a disposed session reachable — an
1660
+ * aborted ref that still holds a live session is a bug and is unregistered
1661
+ * rather than handed to ensureLive. Only process teardown / a plain release
1662
+ * unregisters.
1639
1663
  */
1640
1664
  const unregisterUnlessParked = (): void => {
1641
1665
  const ref = registeredAgentRef;
1642
1666
  if (!ref || agentRegistry.get(resolvedAgentId) !== ref) return;
1643
- if (ref.status === "parked") return;
1667
+ if (ref.status === "parked" || (ref.status === "aborted" && !ref.session)) return;
1644
1668
  if (AgentLifecycleManager.global().isParking(resolvedAgentId, ref)) return;
1645
1669
  agentRegistry.unregister(resolvedAgentId, ref);
1646
1670
  };
@@ -2704,10 +2728,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2704
2728
 
2705
2729
  // Existing staged/device paths need write registered before active-set assembly.
2706
2730
  // Deferred MCP also registers it now, but refresh activates it only after a server connects.
2731
+ // xd:// mounts never register write: xdev state only exists when the session
2732
+ // already granted a write tool (see createTools), so mounting rides that grant.
2707
2733
  const hasDeferrableTools = Array.from(toolRegistry.values()).some(tool => tool.deferrable === true);
2708
- const hasXdevTools = (toolSession.xdev?.mountedNames.size ?? 0) > 0;
2709
2734
  const planModeAvailable = settings.get("plan.enabled");
2710
- if (!restrictToolNames && (hasDeferrableTools || hasXdevTools || planModeAvailable || deferMCPDiscoveryForUI)) {
2735
+ if (!restrictToolNames && (hasDeferrableTools || planModeAvailable || deferMCPDiscoveryForUI)) {
2711
2736
  await ensureWriteRegistered();
2712
2737
  }
2713
2738
 
@@ -2783,6 +2808,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2783
2808
  tools: Map<string, AgentTool>,
2784
2809
  ): Promise<BuildSystemPromptResult> => {
2785
2810
  toolContextStore.setToolNames(toolNames);
2811
+ const promptCwd = sessionManager.getCwd();
2812
+ const activeRepoContext = hasSession
2813
+ ? await logger.time("resolveActiveRepoContext", resolveRepoContext, promptCwd)
2814
+ : initialActiveRepoContext;
2815
+ if (hasSession && options.contextFiles === undefined) {
2816
+ contextFiles = await logger.time("discoverContextFiles", discoverContextFiles, promptCwd, agentDir, [
2817
+ ...(settings.get("disabledExtensions") ?? []),
2818
+ ]);
2819
+ toolSession.contextFiles = contextFiles;
2820
+ session.setAdvisorContextPrompt(formatAdvisorContextPrompt(contextFiles));
2821
+ }
2786
2822
  const memoryBackend = restrictToolNames ? undefined : await resolveMemoryBackend(settings);
2787
2823
  const memoryInstructions = memoryBackend
2788
2824
  ? await memoryBackend.buildDeveloperInstructions(agentDir, settings, session)
@@ -2852,7 +2888,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2852
2888
  : options.appendSystemPrompt;
2853
2889
  }
2854
2890
  const defaultPrompt = await buildSystemPromptInternal({
2855
- cwd,
2891
+ cwd: promptCwd,
2856
2892
  additionalWorkspaceRoots: sessionManager.getAdditionalDirectories(),
2857
2893
  xdevTools: toolSession.xdev ? xdevEntries(toolSession.xdev) : [],
2858
2894
  xdevDocs: toolSession.xdev
@@ -2953,6 +2989,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2953
2989
  const xdevReadAvailable =
2954
2990
  builtInRegistryToolNames.has("read") &&
2955
2991
  (explicitlyRequestedToolNameSet === undefined || explicitlyRequestedToolNameSet.has("read"));
2992
+ const xdevWriteAvailable =
2993
+ builtInRegistryToolNames.has("write") &&
2994
+ (explicitlyRequestedToolNameSet === undefined || explicitlyRequestedToolNameSet.has("write"));
2956
2995
  const initialRequestedActiveToolNames = options.toolNames
2957
2996
  ? requestedActiveToolNames
2958
2997
  : requestedActiveToolNames.filter(name => !defaultInactiveToolNames.has(name));
@@ -2998,23 +3037,23 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2998
3037
 
2999
3038
  // Partition the initial enabled set for the xd:// transport. Tool instances
3000
3039
  // remain in the canonical map; only presentation names move between layers.
3040
+ // Mounting requires both transport halves in the granted set (`read xd://`
3041
+ // discovers, `write xd://<tool>` executes); a session without either keeps
3042
+ // every tool top-level instead of auto-granting the missing transport.
3001
3043
  if (toolSession.xdev) {
3002
3044
  const topLevelToolNames: string[] = [];
3003
3045
  const mountedNames: string[] = [];
3004
3046
  for (const name of initialToolNames) {
3005
3047
  const tool = toolRegistry.get(name);
3006
3048
  const explicitlyRequested = explicitlyRequestedToolNameSet?.has(name) === true;
3007
- if (tool && xdevReadAvailable && !explicitlyRequested && isMountableUnderXdev(tool))
3049
+ if (tool && xdevReadAvailable && xdevWriteAvailable && !explicitlyRequested && isMountableUnderXdev(tool))
3008
3050
  mountedNames.push(name);
3009
3051
  else topLevelToolNames.push(name);
3010
3052
  }
3011
- const writeTransportAvailable = mountedNames.length === 0 || (await ensureWriteRegistered());
3012
3053
  toolSession.xdev.mountedNames.clear();
3013
- if (writeTransportAvailable) {
3014
- for (const name of mountedNames) toolSession.xdev.mountedNames.add(name);
3015
- initialToolNames = topLevelToolNames;
3016
- if (mountedNames.length > 0 && !initialToolNames.includes("write")) initialToolNames.push("write");
3017
- }
3054
+ for (const name of mountedNames) toolSession.xdev.mountedNames.add(name);
3055
+ initialToolNames = topLevelToolNames;
3056
+ if (mountedNames.length > 0 && !initialToolNames.includes("write")) initialToolNames.push("write");
3018
3057
  }
3019
3058
 
3020
3059
  setActiveToolNames(initialToolNames);
@@ -3271,8 +3310,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3271
3310
  .map(tool => new ExtensionToolWrapper(wrapToolWithMetaNotice(tool), extensionRunner) as Tool);
3272
3311
 
3273
3312
  const advisorWatchdogPrompts = [...watchdogFiles];
3274
- if (activeRepoContext) {
3275
- advisorWatchdogPrompts.push(formatActiveRepoWatchdogPrompt(activeRepoContext));
3313
+ if (initialActiveRepoContext) {
3314
+ advisorWatchdogPrompts.push(formatActiveRepoWatchdogPrompt(initialActiveRepoContext));
3276
3315
  }
3277
3316
  const advisorWatchdogPrompt = advisorWatchdogPrompts.length > 0 ? advisorWatchdogPrompts.join("\n\n") : undefined;
3278
3317
  // Hand the advisor the same project context files (AGENTS.md, etc.) the
@@ -2,7 +2,7 @@ import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { SENSITIVE_TOKEN_RE } from "@oh-my-pi/pi-ai/providers/transform-messages";
5
- import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
5
+ import { getSecretPlaceholderKeyPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
6
6
  import { YAML } from "bun";
7
7
  import { regexHasUnresolvableShortMatchFallback, type SecretEntry, sanitizeSecretFriendlyName } from "./obfuscator";
8
8
  import { compileSecretRegex } from "./regex";
@@ -11,17 +11,15 @@ const PLACEHOLDER_KEY_RE = /^[A-Za-z0-9_-]{43}$/;
11
11
  const cachedPlaceholderKeys = new Map<string, string>();
12
12
 
13
13
  /**
14
- * Per-install secret key for the placeholder digest. Persisted under the agent
15
- * config directory and never sent to a provider, so model-visible placeholders
16
- * cannot be reversed by dictionary-hashing candidate secrets. Stable across
17
- * sessions so persisted transcripts deobfuscate consistently. Defaults to
18
- * `getAgentDir()` the same directory `createAgentSession()` passes as
19
- * `agentDir` so a caller relying on the default reads/writes the identical
20
- * key file live sessions use, per `~/.omp/agent/secret-placeholder.key` in
21
- * docs/secrets.md.
14
+ * Per-install secret key for the placeholder digest. Persisted under XDG state
15
+ * and never sent to a provider, so model-visible placeholders cannot be reversed
16
+ * by dictionary-hashing candidate secrets. Stable across sessions so persisted
17
+ * transcripts deobfuscate consistently. Defaults to `getSecretPlaceholderKeyPath()`
18
+ * — `$XDG_STATE_HOME/omp/secret-placeholder.key` (or `~/.omp/agent/secret-placeholder.key`
19
+ * without XDG), per docs/secrets.md.
22
20
  */
23
- export async function getSecretPlaceholderKey(keyDir: string = getAgentDir()): Promise<string> {
24
- const keyPath = path.join(keyDir, "secret-placeholder.key");
21
+ export async function getSecretPlaceholderKey(keyDir?: string): Promise<string> {
22
+ const keyPath = keyDir ? path.join(keyDir, "secret-placeholder.key") : getSecretPlaceholderKeyPath();
25
23
  const cached = cachedPlaceholderKeys.get(keyPath);
26
24
  if (cached !== undefined) return cached;
27
25
 
@@ -32,7 +30,7 @@ export async function getSecretPlaceholderKey(keyDir: string = getAgentDir()): P
32
30
  }
33
31
 
34
32
  const generated = crypto.randomBytes(32).toString("base64url");
35
- await fs.promises.mkdir(keyDir, { recursive: true });
33
+ await fs.promises.mkdir(path.dirname(keyPath), { recursive: true });
36
34
  try {
37
35
  await fs.promises.writeFile(keyPath, generated, { flag: "wx", mode: 0o600 });
38
36
  cachedPlaceholderKeys.set(keyPath, generated);
@@ -53,8 +51,8 @@ export async function getSecretPlaceholderKey(keyDir: string = getAgentDir()): P
53
51
  }
54
52
 
55
53
  /** Return an existing placeholder key for redaction without creating a new key file. */
56
- export async function getExistingSecretPlaceholderKey(keyDir: string = getAgentDir()): Promise<string | undefined> {
57
- const keyPath = path.join(keyDir, "secret-placeholder.key");
54
+ export async function getExistingSecretPlaceholderKey(keyDir?: string): Promise<string | undefined> {
55
+ const keyPath = keyDir ? path.join(keyDir, "secret-placeholder.key") : getSecretPlaceholderKeyPath();
58
56
  const cached = cachedPlaceholderKeys.get(keyPath);
59
57
  if (cached !== undefined) return cached;
60
58
  // Redaction-only: this key is loaded solely to redact an existing key file from
@@ -85,8 +83,8 @@ let ephemeralSyncPlaceholderKey: string | undefined;
85
83
  * throws: an unreadable or unwritable key file degrades to a process-ephemeral
86
84
  * key (with a warning) instead of breaking the session.
87
85
  */
88
- export function getSecretPlaceholderKeySync(keyDir: string = getAgentDir()): string {
89
- const keyPath = path.join(keyDir, "secret-placeholder.key");
86
+ export function getSecretPlaceholderKeySync(keyDir?: string): string {
87
+ const keyPath = keyDir ? path.join(keyDir, "secret-placeholder.key") : getSecretPlaceholderKeyPath();
90
88
  const cached = cachedPlaceholderKeys.get(keyPath);
91
89
  if (cached !== undefined) return cached;
92
90
  try {
@@ -100,7 +98,7 @@ export function getSecretPlaceholderKeySync(keyDir: string = getAgentDir()): str
100
98
  }
101
99
  const generated = crypto.randomBytes(32).toString("base64url");
102
100
  try {
103
- fs.mkdirSync(keyDir, { recursive: true });
101
+ fs.mkdirSync(path.dirname(keyPath), { recursive: true });
104
102
  fs.writeFileSync(keyPath, generated, { flag: "wx", mode: 0o600 });
105
103
  cachedPlaceholderKeys.set(keyPath, generated);
106
104
  return generated;
@@ -1382,6 +1382,7 @@ export class AgentSession {
1382
1382
  extensionRunner: this.#extensionRunner,
1383
1383
  sideStreamFn: this.#sideStreamFn,
1384
1384
  providerSessionState: this.#providerSessionState,
1385
+ preferWebsockets: this.#preferWebsockets,
1385
1386
  model: () => this.model,
1386
1387
  thinkingLevel: () => this.thinkingLevel,
1387
1388
  isDisposed: () => this.#isDisposed,
@@ -6333,7 +6334,6 @@ export class AgentSession {
6333
6334
  selector?: string;
6334
6335
  thinkingLevel?: ThinkingLevel;
6335
6336
  persist?: boolean;
6336
- currentContextTokens?: number;
6337
6337
  },
6338
6338
  ): Promise<{ switched: boolean }> {
6339
6339
  return this.#models.setModel(model, role, options);
@@ -8719,6 +8719,15 @@ export class AgentSession {
8719
8719
  return this.#advisors.applyAdvisorConfigs(advisors, sharedInstructions);
8720
8720
  }
8721
8721
 
8722
+ /**
8723
+ * Refresh the project context prompt advisor sessions run against after
8724
+ * context files change on `/reload-plugins`. Rebuilds live advisor runtimes so
8725
+ * they stop evaluating turns against stale `AGENTS.md` instructions.
8726
+ */
8727
+ setAdvisorContextPrompt(contextPrompt: string | undefined): void {
8728
+ this.#advisors.setContextPrompt(contextPrompt);
8729
+ }
8730
+
8722
8731
  /**
8723
8732
  * Whether the advisor setting is enabled for this session.
8724
8733
  */
@@ -8,7 +8,7 @@ import {
8
8
  SqliteAuthCredentialStore,
9
9
  type StoredAuthCredential,
10
10
  } from "@oh-my-pi/pi-ai";
11
- import { AsyncDrain, getAgentDbPath, getStatsDbPath, isRecord, logger } from "@oh-my-pi/pi-utils";
11
+ import { AsyncDrain, getAgentDbPath, getDbBusyTimeoutMs, getStatsDbPath, isRecord, logger } from "@oh-my-pi/pi-utils";
12
12
  import type { RawSettings as Settings } from "../config/settings";
13
13
 
14
14
  /** Row shape for settings table queries */
@@ -199,8 +199,10 @@ ON CONFLICT(model_key) DO UPDATE SET
199
199
  // Install the busy handler BEFORE any lock-taking statement (incl.
200
200
  // `PRAGMA journal_mode=WAL`, which acquires an exclusive lock during WAL
201
201
  // recovery). Without this, concurrent omp startups can crash here with
202
- // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. See issue #2421.
203
- this.#db.run("PRAGMA busy_timeout = 5000");
202
+ // `SQLITE_BUSY` / `SQLITE_BUSY_RECOVERY`. See issue #2421. Headless
203
+ // hosts bound the wait so lock contention cannot freeze the protocol
204
+ // loop for the full interactive timeout.
205
+ this.#db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
204
206
  this.#db.run(`
205
207
  PRAGMA journal_mode=WAL;
206
208
  PRAGMA synchronous=NORMAL;
@@ -571,7 +573,7 @@ FROM model_usage_legacy
571
573
  async backfillModelPerfFromStats(statsDbPath: string): Promise<number> {
572
574
  const statsDb = new Database(statsDbPath, { readonly: true });
573
575
  try {
574
- statsDb.run("PRAGMA busy_timeout = 5000");
576
+ statsDb.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
575
577
  const select = statsDb.prepare(
576
578
  `SELECT rowid, timestamp, provider, model, output_tokens, duration, ttft
577
579
  FROM messages
@@ -1,7 +1,7 @@
1
1
  import { Database, type Statement } from "bun:sqlite";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { AsyncDrain, getHistoryDbPath, logger } from "@oh-my-pi/pi-utils";
4
+ import { AsyncDrain, getDbBusyTimeoutMs, getHistoryDbPath, logger } from "@oh-my-pi/pi-utils";
5
5
 
6
6
  export interface HistoryEntry {
7
7
  id: number;
@@ -51,7 +51,9 @@ export class HistoryStorage {
51
51
  this.#db = new Database(dbPath);
52
52
 
53
53
  // Install the busy handler BEFORE any lock-taking statement. See #2421.
54
- this.#db.run("PRAGMA busy_timeout = 5000");
54
+ // Headless hosts bound the wait so lock contention cannot freeze the
55
+ // protocol loop for the full interactive timeout.
56
+ this.#db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
55
57
 
56
58
  const hasFts = this.#db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='history_fts'").get();
57
59
  this.#db.run(`
@@ -24,6 +24,7 @@ import type {
24
24
  } from "@oh-my-pi/pi-ai";
25
25
  import * as AIError from "@oh-my-pi/pi-ai/error";
26
26
  import { isRecord, logger, prompt } from "@oh-my-pi/pi-utils";
27
+ import { COLLAB_PROMPT_MESSAGE_TYPE } from "@oh-my-pi/pi-wire";
27
28
  import userInterjectionTemplate from "../prompts/steering/user-interjection.md" with { type: "text" };
28
29
  import { formatTitleConversationContext, type TitleConversationTurn } from "../tiny/message-preproc";
29
30
 
@@ -614,8 +615,18 @@ export function normalizeCustomMessagePayload<T = unknown>(
614
615
  };
615
616
  }
616
617
 
617
- function isSteeringUserMessage(message: AgentMessage | undefined): message is UserMessage & { steering: true } {
618
- return message?.role === "user" && message.steering === true;
618
+ type SteeringUserMessage =
619
+ | (UserMessage & { steering: true })
620
+ | (CustomMessage & {
621
+ customType: typeof COLLAB_PROMPT_MESSAGE_TYPE;
622
+ attribution: "user";
623
+ });
624
+
625
+ function isSteeringUserMessage(message: AgentMessage | undefined): message is SteeringUserMessage {
626
+ if (message?.role === "user") return message.steering === true;
627
+ return (
628
+ message?.role === "custom" && message.customType === COLLAB_PROMPT_MESSAGE_TYPE && message.attribution === "user"
629
+ );
619
630
  }
620
631
 
621
632
  function userMessageWithoutSteering(message: UserMessage): UserMessage {
@@ -655,17 +666,26 @@ function getArrayContentImages(content: (TextContent | ImageContent)[]): ImageCo
655
666
  return images ?? [];
656
667
  }
657
668
 
658
- function wrapSteeringUserMessage(message: UserMessage): UserMessage {
669
+ function wrapSteeringUserMessage(message: SteeringUserMessage): UserMessage {
670
+ const userMessage: UserMessage =
671
+ message.role === "user"
672
+ ? userMessageWithoutSteering(message)
673
+ : {
674
+ role: "user",
675
+ content: message.content,
676
+ attribution: "user",
677
+ timestamp: message.timestamp,
678
+ };
659
679
  if (typeof message.content === "string") {
660
- if (message.content.length === 0) return message;
661
- return { ...userMessageWithoutSteering(message), content: renderSteeringEnvelope(message.content) };
680
+ if (message.content.length === 0) return message.role === "user" ? message : userMessage;
681
+ return { ...userMessage, content: renderSteeringEnvelope(message.content) };
662
682
  }
663
683
 
664
684
  const text = getArrayContentText(message.content);
665
- if (text.length === 0) return message;
685
+ if (text.length === 0) return message.role === "user" ? message : userMessage;
666
686
  const content: (TextContent | ImageContent)[] = [{ type: "text", text: renderSteeringEnvelope(text) }];
667
687
  content.push(...getArrayContentImages(message.content));
668
- return { ...userMessageWithoutSteering(message), content };
688
+ return { ...userMessage, content };
669
689
  }
670
690
 
671
691
  export function wrapSteeringForModel(messages: AgentMessage[]): AgentMessage[] {
@@ -1146,6 +1166,10 @@ function convertOne(m: AgentMessage, interruptedNext: boolean): Message[] {
1146
1166
  }
1147
1167
  case "custom": {
1148
1168
  if (!isCustomMessageContent(m.content)) return [];
1169
+ if (isSteeringUserMessage(m)) {
1170
+ const converted = convertMessageToLlm(wrapSteeringUserMessage(m));
1171
+ return converted ? [converted] : [];
1172
+ }
1149
1173
  if (isUserInvokedSkillPrompt(m)) {
1150
1174
  return [
1151
1175
  {
@@ -209,7 +209,6 @@ export class ModelControls {
209
209
  selector?: string;
210
210
  thinkingLevel?: ThinkingLevel;
211
211
  persist?: boolean;
212
- currentContextTokens?: number;
213
212
  },
214
213
  ): Promise<{ switched: boolean }> {
215
214
  const previousEditMode = this.#host.resolveActiveEditMode();
@@ -877,7 +877,10 @@ export class SessionAdvisors {
877
877
  this.#maintainAdvisorContext(advisorRef, incomingTokens, signal),
878
878
  obfuscator: this.#host.obfuscator,
879
879
  getModelIdentity: () => formatModelString(advisorRef.agent.state.model),
880
- beginAdvisorUpdate: () => advisorRef.emissionGuard.beginUpdate(),
880
+ beginAdvisorUpdate: inProgress => {
881
+ advisorRef.adviseTool.beginUpdate(inProgress);
882
+ advisorRef.emissionGuard.beginUpdate();
883
+ },
881
884
  onTurnError: (error, failedMessages, signal) =>
882
885
  this.#recoverAdvisorTurn(advisorRef, error, failedMessages, signal),
883
886
  onTurnSuccess: async () => {
@@ -1449,6 +1452,7 @@ export class SessionAdvisors {
1449
1452
  promptCacheKey: advisorProviderSessionId,
1450
1453
  metadata: advisorMetadata,
1451
1454
  providerSessionState: this.#host.providerSessionState,
1455
+ preferWebsockets: this.#host.preferWebsockets,
1452
1456
  codexCompaction,
1453
1457
  },
1454
1458
  );
@@ -1579,6 +1583,20 @@ export class SessionAdvisors {
1579
1583
  return this.#advisors.length;
1580
1584
  }
1581
1585
 
1586
+ /**
1587
+ * Swap the project context prompt handed to advisor sessions after context
1588
+ * files change (`/reload-plugins` edit/disable). Rebuilds live runtimes in
1589
+ * place so the next advisor turn evaluates against the current instructions;
1590
+ * a no-op when the rendered prompt is unchanged.
1591
+ */
1592
+ setContextPrompt(contextPrompt: string | undefined): void {
1593
+ if (contextPrompt === this.#advisorContextPrompt) return;
1594
+ this.#advisorContextPrompt = contextPrompt;
1595
+ if (!this.#advisorEnabled || this.#advisors.length === 0) return;
1596
+ this.#stopAdvisorRuntime();
1597
+ this.#buildAdvisorRuntime(true);
1598
+ }
1599
+
1582
1600
  /**
1583
1601
  * Whether the advisor setting is enabled for this session.
1584
1602
  */
@@ -180,6 +180,7 @@ export interface SessionMaintenanceHost {
180
180
  extensionRunner: ExtensionRunner | undefined;
181
181
  sideStreamFn: StreamFn;
182
182
  providerSessionState: Map<string, ProviderSessionState>;
183
+ preferWebsockets: boolean | undefined;
183
184
  model(): Model | undefined;
184
185
  thinkingLevel(): ThinkingLevel | undefined;
185
186
  isDisposed(): boolean;
@@ -1541,6 +1542,7 @@ export class SessionMaintenance {
1541
1542
  sessionId: this.#host.sessionId(),
1542
1543
  promptCacheKey: this.#host.agent.promptCacheKey ?? this.#host.agent.sessionId,
1543
1544
  providerSessionState: this.#host.providerSessionState,
1545
+ preferWebsockets: this.#host.preferWebsockets,
1544
1546
  // Route every summarization HTTP request through the
1545
1547
  // session's side-stream transport so the provider
1546
1548
  // concurrency cap (e.g. providers.ollama-cloud.maxConcurrency)
@@ -2587,6 +2589,7 @@ export class SessionMaintenance {
2587
2589
  sessionId: this.#host.sessionId(),
2588
2590
  promptCacheKey: this.#host.agent.promptCacheKey ?? this.#host.agent.sessionId,
2589
2591
  providerSessionState: this.#host.providerSessionState,
2592
+ preferWebsockets: this.#host.preferWebsockets,
2590
2593
  codexCompaction,
2591
2594
  },
2592
2595
  );