@oh-my-pi/pi-coding-agent 15.13.0 → 15.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +1656 -613
  2. package/dist/cli.js +12765 -12731
  3. package/dist/types/autolearn/managed-skills.d.ts +1 -1
  4. package/dist/types/capability/mcp.d.ts +2 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/flag-tables.d.ts +126 -0
  7. package/dist/types/cli/profile-alias.d.ts +29 -0
  8. package/dist/types/cli/profile-bootstrap.d.ts +55 -0
  9. package/dist/types/commands/launch.d.ts +6 -0
  10. package/dist/types/config/model-roles.d.ts +3 -2
  11. package/dist/types/config/settings-schema.d.ts +2 -0
  12. package/dist/types/edit/file-snapshot-store.d.ts +14 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +11 -0
  14. package/dist/types/mcp/manager.d.ts +5 -1
  15. package/dist/types/mcp/oauth-credentials.d.ts +17 -0
  16. package/dist/types/mcp/oauth-flow.d.ts +41 -0
  17. package/dist/types/mcp/types.d.ts +2 -0
  18. package/dist/types/modes/components/background-tan-message.d.ts +9 -0
  19. package/dist/types/modes/components/mcp-add-wizard.d.ts +9 -5
  20. package/dist/types/modes/interactive-mode.d.ts +4 -0
  21. package/dist/types/modes/types.d.ts +3 -0
  22. package/dist/types/sdk.d.ts +1 -1
  23. package/dist/types/session/messages.d.ts +8 -0
  24. package/dist/types/session/session-manager.d.ts +6 -0
  25. package/dist/types/tools/builtin-names.d.ts +2 -0
  26. package/dist/types/tools/index.d.ts +3 -2
  27. package/dist/types/utils/external-editor.d.ts +11 -1
  28. package/package.json +12 -12
  29. package/src/autolearn/managed-skills.ts +3 -5
  30. package/src/capability/mcp.ts +2 -1
  31. package/src/cli/args.ts +61 -103
  32. package/src/cli/completion-gen.ts +2 -2
  33. package/src/cli/flag-tables.ts +270 -0
  34. package/src/cli/profile-alias.ts +338 -0
  35. package/src/cli/profile-bootstrap.ts +243 -0
  36. package/src/cli.ts +83 -16
  37. package/src/commands/launch.ts +7 -0
  38. package/src/config/mcp-schema.json +4 -0
  39. package/src/config/model-roles.ts +17 -4
  40. package/src/config/settings-schema.ts +2 -0
  41. package/src/discovery/builtin.ts +15 -9
  42. package/src/discovery/helpers.ts +25 -0
  43. package/src/discovery/mcp-json.ts +1 -0
  44. package/src/discovery/omp-extension-roots.ts +2 -2
  45. package/src/edit/file-snapshot-store.ts +43 -0
  46. package/src/eval/__tests__/agent-bridge.test.ts +3 -2
  47. package/src/eval/__tests__/helpers-local-roots.test.ts +1 -1
  48. package/src/eval/js/shared/runtime.ts +54 -0
  49. package/src/extensibility/extensions/runner.ts +25 -2
  50. package/src/goals/runtime.ts +4 -1
  51. package/src/internal-urls/docs-index.generated.ts +6 -6
  52. package/src/mcp/manager.ts +108 -71
  53. package/src/mcp/oauth-credentials.ts +104 -0
  54. package/src/mcp/oauth-flow.ts +67 -0
  55. package/src/mcp/types.ts +2 -0
  56. package/src/modes/components/agent-hub.ts +6 -0
  57. package/src/modes/components/background-tan-message.ts +36 -0
  58. package/src/modes/components/mcp-add-wizard.ts +17 -10
  59. package/src/modes/components/model-selector.ts +50 -6
  60. package/src/modes/components/tool-execution.ts +12 -0
  61. package/src/modes/controllers/input-controller.ts +21 -10
  62. package/src/modes/controllers/mcp-command-controller.ts +184 -112
  63. package/src/modes/controllers/tan-command-controller.ts +27 -11
  64. package/src/modes/interactive-mode.ts +6 -0
  65. package/src/modes/types.ts +3 -0
  66. package/src/modes/utils/ui-helpers.ts +6 -0
  67. package/src/prompts/bench.md +9 -4
  68. package/src/sdk.ts +6 -5
  69. package/src/session/agent-session.ts +30 -1
  70. package/src/session/messages.ts +9 -0
  71. package/src/session/session-manager.ts +7 -2
  72. package/src/tiny/text.ts +5 -1
  73. package/src/tools/ast-grep.ts +5 -1
  74. package/src/tools/builtin-names.ts +35 -0
  75. package/src/tools/index.ts +3 -2
  76. package/src/tools/read.ts +9 -0
  77. package/src/tools/search.ts +5 -1
  78. package/src/tts/tts-worker.ts +13 -5
  79. package/src/utils/external-editor.ts +15 -2
  80. package/src/utils/title-generator.ts +1 -1
  81. package/src/workspace-tree.ts +46 -6
  82. package/dist/types/utils/tools-manager.test.d.ts +0 -1
  83. package/src/utils/tools-manager.test.ts +0 -25
package/src/cli.ts CHANGED
@@ -15,8 +15,17 @@ try {
15
15
  * lightweight CLI runner from pi-utils.
16
16
  */
17
17
  import type { CliConfig } from "@oh-my-pi/pi-utils/cli";
18
- import { APP_NAME, MIN_BUN_VERSION, VERSION } from "@oh-my-pi/pi-utils/dirs";
19
- import { declareWorkerHostEntry } from "@oh-my-pi/pi-utils/env";
18
+ import {
19
+ APP_NAME,
20
+ getActiveProfile,
21
+ MIN_BUN_VERSION,
22
+ resolveProfileEnv,
23
+ setProfile,
24
+ VERSION,
25
+ } from "@oh-my-pi/pi-utils/dirs";
26
+ import { declareWorkerHostEntry } from "@oh-my-pi/pi-utils/worker-host";
27
+ import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias";
28
+ import { extractProfileFlags } from "./cli/profile-bootstrap";
20
29
 
21
30
  if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
22
31
  process.stderr.write(
@@ -27,11 +36,11 @@ if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
27
36
 
28
37
  process.title = APP_NAME;
29
38
 
30
- // Declare this module as the worker-host entry: Worker threads and worker
31
- // subprocesses re-enter `Bun.main` with a hidden argv selector instead of
32
- // loading separate worker entrypoints (single-entry contract across source,
33
- // npm bundle, and compiled binary).
34
- declareWorkerHostEntry();
39
+ // Worker-host entry declaration (Worker threads and worker subprocesses
40
+ // re-enter `Bun.main` with a hidden argv selector instead of loading separate
41
+ // worker entrypoints) happens inside `runCli` after profile bootstrap:
42
+ // `@oh-my-pi/pi-utils/env` eagerly loads `.env` from the agent directory at
43
+ // import time, so it must not be imported before `setProfile` runs.
35
44
 
36
45
  async function showHelp(config: CliConfig): Promise<void> {
37
46
  const { renderRootHelp } = await import("@oh-my-pi/pi-utils/cli");
@@ -196,15 +205,67 @@ async function runTinyWorker(): Promise<void> {
196
205
 
197
206
  /** Run the CLI with the given argv (no `process.argv` prefix). */
198
207
  export async function runCli(argv: string[]): Promise<void> {
199
- if (argv[0] === "--smoke-test") {
200
- await runSmokeTest();
208
+ let resolvedArgv = argv;
209
+ try {
210
+ const extracted = extractProfileFlags(resolvedArgv);
211
+ resolvedArgv = extracted.argv;
212
+ if (extracted.profile !== undefined) {
213
+ setProfile(extracted.profile);
214
+ } else {
215
+ // No explicit --profile: activate any OMP_PROFILE/PI_PROFILE inherited
216
+ // from the environment. Module-load resolution deliberately swallows an
217
+ // invalid value to avoid an uncaught throw before this try/catch is in
218
+ // scope (see `readProfileFromEnvSafe` in dirs.ts), and callers may set
219
+ // OMP_PROFILE after importing this module (profile aliases/tests). Surfacing
220
+ // validation here turns `OMP_PROFILE=.. omp --version` into a clean error;
221
+ // calling setProfile keeps every later path helper on the env-selected
222
+ // profile instead of the default agent directory.
223
+ setProfile(resolveProfileEnv(process.env.OMP_PROFILE, process.env.PI_PROFILE));
224
+ }
225
+ if (extracted.aliasName !== undefined) {
226
+ const profile = extracted.profile ?? getActiveProfile();
227
+ if (!profile) {
228
+ throw new Error("--alias requires --profile <name> or OMP_PROFILE");
229
+ }
230
+ const result = await installProfileAlias({
231
+ profile,
232
+ aliasName: extracted.aliasName,
233
+ command: resolveProfileAliasCommandFromProcess(),
234
+ });
235
+ process.stdout.write(
236
+ `Created ${result.aliasName} for profile ${result.profile} in ${result.configPath}\n` +
237
+ `Restart your shell or run: ${result.reloadedWith}\n` +
238
+ `Then use: ${result.aliasName} update, ${result.aliasName} --version, or ${result.aliasName}\n`,
239
+ );
240
+ return;
241
+ }
242
+ } catch (error) {
243
+ const message = error instanceof Error ? error.message : String(error);
244
+ process.stderr.write(`Error: ${message}\n`);
245
+ process.exitCode = 1;
201
246
  return;
202
247
  }
203
- if (TINY_WORKER_ARGS.has(argv[0] ?? "")) {
248
+
249
+ // Worker-thread entry dispatch must run before the first `await`: the
250
+ // stats sync worker's buffering onmessage handler is installed in the
251
+ // synchronous prefix of `runWorkerEntrypoint`, and Bun flushes the
252
+ // worker's parked initial messages as soon as the entry module's
253
+ // top-level evaluation finishes.
254
+ if (TINY_WORKER_ARGS.has(resolvedArgv[0] ?? "")) {
204
255
  await runTinyWorker();
205
256
  return;
206
257
  }
207
- if (await runWorkerEntrypoint(argv[0])) {
258
+ if (await runWorkerEntrypoint(resolvedArgv[0])) {
259
+ return;
260
+ }
261
+
262
+ // Declare this module as the worker-host entry now that the active profile
263
+ // is resolved. The worker-host module is side-effect-free; importing
264
+ // `@oh-my-pi/pi-utils/env` here would snapshot the wrong agent `.env`.
265
+ declareWorkerHostEntry();
266
+
267
+ if (resolvedArgv[0] === "--smoke-test") {
268
+ await runSmokeTest();
208
269
  return;
209
270
  }
210
271
  const [{ run }, { commands, resolveCliArgv }] = await Promise.all([
@@ -213,7 +274,7 @@ export async function runCli(argv: string[]): Promise<void> {
213
274
  ]);
214
275
  // --help and --version are handled by run() directly, don't rewrite those.
215
276
  // Everything else that isn't a known subcommand routes to "launch".
216
- const resolved = resolveCliArgv(argv);
277
+ const resolved = resolveCliArgv(resolvedArgv);
217
278
  if ("error" in resolved) {
218
279
  process.stderr.write(`error: ${resolved.error}\n`);
219
280
  process.exitCode = 1;
@@ -226,7 +287,13 @@ export async function runCli(argv: string[]): Promise<void> {
226
287
  // lowering) builds to fail, and the entrypoint needs nothing after this.
227
288
  // The catch mirrors what an unhandled TLA rejection produced: error dump to
228
289
  // stderr, exit code 1. Success paths resolve without touching the exit code.
229
- runCli(process.argv.slice(2)).catch((err: unknown) => {
230
- process.stderr.write(`${Bun.inspect(err, { colors: process.stderr.isTTY === true })}\n`);
231
- process.exit(1);
232
- });
290
+ // Guarded so importing `runCli` (profile CLI tests, SDK embedding) does not
291
+ // launch the agent as a side effect. Worker threads re-enter this module as
292
+ // their entry with `import.meta.main === false`, so the worker-host dispatch
293
+ // is admitted via `!Bun.isMainThread`.
294
+ if (import.meta.main || !Bun.isMainThread) {
295
+ runCli(process.argv.slice(2)).catch((err: unknown) => {
296
+ process.stderr.write(`${Bun.inspect(err, { colors: process.stderr.isTTY === true })}\n`);
297
+ process.exit(1);
298
+ });
299
+ }
@@ -49,6 +49,12 @@ export default class Index extends Command {
49
49
  "allow-home": Flags.boolean({
50
50
  description: "Allow starting in ~ without auto-switching to a temp dir",
51
51
  }),
52
+ profile: Flags.string({
53
+ description: "Use an isolated profile for auth, sessions, settings, and caches",
54
+ }),
55
+ alias: Flags.string({
56
+ description: "Create a shell shortcut for the selected profile and exit",
57
+ }),
52
58
  cwd: Flags.string({
53
59
  description: "Directory to start in (overrides the launch cwd)",
54
60
  }),
@@ -151,6 +157,7 @@ export default class Index extends Command {
151
157
  `# Include files in initial message\n ${APP_NAME} @prompt.md @image.png "What color is the sky?"`,
152
158
  `# Non-interactive mode (process and exit)\n ${APP_NAME} -p "List all .ts files in src/"`,
153
159
  `# Continue previous session\n ${APP_NAME} --continue "What did we discuss?"`,
160
+ `# Create a shell shortcut for a work profile\n ${APP_NAME} --profile work --alias omp-work`,
154
161
  `# Use different model (fuzzy matching)\n ${APP_NAME} --model opus "Help me refactor this code"`,
155
162
  `# Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o`,
156
163
  `# Export a session file to HTML\n ${APP_NAME} --export ~/.omp/agent/sessions/--path--/session.jsonl`,
@@ -89,6 +89,10 @@
89
89
  },
90
90
  "callbackPath": {
91
91
  "type": "string"
92
+ },
93
+ "prompt": {
94
+ "type": "string",
95
+ "description": "OAuth `prompt` parameter sent during authorization (default: \"consent\" so the provider always shows its account/consent screen; set to \"\" to omit)."
92
96
  }
93
97
  },
94
98
  "description": "Explicit OAuth client settings for servers that need them during /mcp reauth or initial connect."
@@ -5,12 +5,14 @@
5
5
  import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
6
6
  import type { Settings } from "./settings";
7
7
 
8
- export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "task";
8
+ export type ModelRole = "default" | "smol" | "slow" | "vision" | "plan" | "designer" | "commit" | "title" | "task";
9
9
 
10
10
  export interface ModelRoleInfo {
11
11
  tag?: string;
12
12
  name: string;
13
13
  color?: ThemeColor;
14
+ /** If true, the role is functional but not shown in the model selector UI. */
15
+ hidden?: boolean;
14
16
  }
15
17
 
16
18
  export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
@@ -21,12 +23,22 @@ export const MODEL_ROLES: Record<ModelRole, ModelRoleInfo> = {
21
23
  plan: { tag: "PLAN", name: "Architect", color: "muted" },
22
24
  designer: { tag: "DESIGNER", name: "Designer", color: "muted" },
23
25
  commit: { tag: "COMMIT", name: "Commit", color: "dim" },
26
+ title: { tag: "TITLE", name: "Title", color: "dim", hidden: true },
24
27
  task: { tag: "TASK", name: "Subtask", color: "muted" },
25
28
  };
26
29
 
27
- export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "vision", "plan", "designer", "commit", "task"];
30
+ export const MODEL_ROLE_IDS: ModelRole[] = [
31
+ "default",
32
+ "smol",
33
+ "slow",
34
+ "vision",
35
+ "plan",
36
+ "designer",
37
+ "commit",
38
+ "title",
39
+ "task",
40
+ ];
28
41
 
29
- /** Alias for ModelRoleInfo - used for both built-in and custom roles */
30
42
  export type RoleInfo = ModelRoleInfo;
31
43
 
32
44
  /**
@@ -37,7 +49,7 @@ export type RoleInfo = ModelRoleInfo;
37
49
  * entries across settings.
38
50
  */
39
51
  export function getKnownRoleIds(settings: Settings): string[] {
40
- const roles = [...MODEL_ROLE_IDS] as string[];
52
+ const roles = MODEL_ROLE_IDS.filter(role => !MODEL_ROLES[role as ModelRole]?.hidden) as string[];
41
53
  const seen = new Set<string>(roles);
42
54
  const addRole = (role: string) => {
43
55
  if (seen.has(role)) return;
@@ -65,6 +77,7 @@ export function getRoleInfo(role: string, settings: Settings): RoleInfo {
65
77
  tag: builtIn?.tag,
66
78
  name: configured.name || builtIn?.name || role,
67
79
  color: configured.color && isValidThemeColor(configured.color) ? configured.color : builtIn?.color,
80
+ hidden: configured.hidden ?? builtIn?.hidden,
68
81
  };
69
82
  }
70
83
 
@@ -258,6 +258,8 @@ type SettingDef =
258
258
  export interface ModelTagDef {
259
259
  name: string;
260
260
  color?: string;
261
+ /** If true, the role is functional but not shown in the model selector UI. */
262
+ hidden?: boolean;
261
263
  }
262
264
 
263
265
  export interface ModelTagsSettings {
@@ -4,7 +4,7 @@
4
4
  * Primary provider for OMP native configs. Supports all capabilities.
5
5
  */
6
6
  import * as path from "node:path";
7
- import { logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
7
+ import { getAgentDir, logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
8
8
  import { YAML } from "bun";
9
9
  import { getManagedSkillsDir, MANAGED_SKILLS_PROVIDER_ID } from "../autolearn/managed-skills";
10
10
  import { registerProvider } from "../capability";
@@ -61,7 +61,9 @@ async function getConfigDirs(ctx: LoadContext): Promise<Array<{ dir: string; lev
61
61
  if (projectDir) {
62
62
  result.push({ dir: projectDir, level: "project" });
63
63
  }
64
- const userDir = await ifNonEmptyDir(ctx.home, PATHS.userAgent);
64
+ // Native user config is profile-scoped: getAgentDir() points at the active
65
+ // profile's agent dir (~/.omp/profiles/<name>/agent), like sessions and MCP.
66
+ const userDir = await ifNonEmptyDir(getAgentDir());
65
67
  if (userDir) {
66
68
  result.push({ dir: userDir, level: "user" });
67
69
  }
@@ -178,6 +180,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
178
180
  redirectUri?: string;
179
181
  callbackPort?: number;
180
182
  callbackPath?: string;
183
+ prompt?: string;
181
184
  }
182
185
  | undefined,
183
186
  transport: serverConfig.type as "stdio" | "sse" | "http" | undefined,
@@ -187,11 +190,14 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
187
190
  return result;
188
191
  };
189
192
 
193
+ // User scope tracks the active profile via getAgentDir() (not ctx.home), so it
194
+ // stays in sync with getMCPConfigPath("user") and the /mcp config writer.
195
+ const userAgentDir = getAgentDir();
190
196
  const paths = [
191
197
  { path: path.join(ctx.cwd, PATHS.projectDir, "mcp.json"), level: "project" as const },
192
198
  { path: path.join(ctx.cwd, PATHS.projectDir, ".mcp.json"), level: "project" as const },
193
- { path: path.join(ctx.home, PATHS.userAgent, "mcp.json"), level: "user" as const },
194
- { path: path.join(ctx.home, PATHS.userAgent, ".mcp.json"), level: "user" as const },
199
+ { path: path.join(userAgentDir, "mcp.json"), level: "user" as const },
200
+ { path: path.join(userAgentDir, ".mcp.json"), level: "user" as const },
195
201
  ];
196
202
 
197
203
  const contents = await Promise.allSettled(
@@ -226,7 +232,7 @@ registerProvider<MCPServer>(mcpCapability.id, {
226
232
  async function loadSystemPrompt(ctx: LoadContext): Promise<LoadResult<SystemPrompt>> {
227
233
  const items: SystemPrompt[] = [];
228
234
 
229
- const userPath = path.join(ctx.home, PATHS.userAgent, "SYSTEM.md");
235
+ const userPath = path.join(getAgentDir(), "SYSTEM.md");
230
236
  const userContent = await readFile(userPath);
231
237
  if (userContent) {
232
238
  items.push({
@@ -277,7 +283,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
277
283
 
278
284
  // User-level scan from ~/.omp/agent/skills/
279
285
  const userScan = scanSkillsFromDir(ctx, {
280
- dir: path.join(ctx.home, PATHS.userAgent, "skills"),
286
+ dir: path.join(getAgentDir(), "skills"),
281
287
  providerId: PROVIDER_ID,
282
288
  level: "user",
283
289
  requireDescription: true,
@@ -297,7 +303,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
297
303
  const MANAGED_SKILLS_PRIORITY = 5;
298
304
  async function loadManagedSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
299
305
  return scanSkillsFromDir(ctx, {
300
- dir: getManagedSkillsDir(ctx.home),
306
+ dir: getManagedSkillsDir(),
301
307
  providerId: MANAGED_SKILLS_PROVIDER_ID,
302
308
  level: "user",
303
309
  requireDescription: true,
@@ -373,7 +379,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
373
379
  // the current turn so they keep hold across long conversations".
374
380
  // User scope: ~/.omp/agent/RULES.md
375
381
  // Project scope: nearest .omp/RULES.md walking up from cwd to repoRoot
376
- const userRulesFile = path.join(ctx.home, PATHS.userAgent, "RULES.md");
382
+ const userRulesFile = path.join(getAgentDir(), "RULES.md");
377
383
  const userRule = await loadStickyRulesFile(userRulesFile, "user");
378
384
  if (userRule) items.push(userRule);
379
385
 
@@ -890,7 +896,7 @@ async function loadContextFiles(ctx: LoadContext): Promise<LoadResult<ContextFil
890
896
  const items: ContextFile[] = [];
891
897
  const warnings: string[] = [];
892
898
 
893
- const userPath = path.join(ctx.home, PATHS.userAgent, "AGENTS.md");
899
+ const userPath = path.join(getAgentDir(), "AGENTS.md");
894
900
  const userContent = await readFile(userPath);
895
901
  if (userContent) {
896
902
  items.push({
@@ -5,6 +5,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
5
  import { FileType, glob } from "@oh-my-pi/pi-natives";
6
6
  import {
7
7
  CONFIG_DIR_NAME,
8
+ getAgentDir,
8
9
  getConfigDirName,
9
10
  getPluginsDir,
10
11
  getProjectDir,
@@ -86,6 +87,11 @@ export type SourceId = keyof typeof SOURCE_PATHS;
86
87
  * Get user-level path for a source.
87
88
  */
88
89
  export function getUserPath(ctx: LoadContext, source: SourceId, subpath: string): string | null {
90
+ // Native user config is profile-scoped via getAgentDir() (the active profile's
91
+ // agent dir), matching builtin.ts and getMCPConfigPath("user"). External tools
92
+ // (~/.claude, ~/.gemini, …) are intentionally not profile-scoped, so they keep
93
+ // resolving against ctx.home below.
94
+ if (source === "native") return path.join(getAgentDir(), subpath);
89
95
  const paths = SOURCE_PATHS[source];
90
96
  if (!paths.userAgent) return null;
91
97
  return path.join(ctx.home, paths.userAgent, subpath);
@@ -569,6 +575,25 @@ export async function discoverExtensionModulePaths(_ctx: LoadContext, dir: strin
569
575
  const indexFiles = [...globIndexFiles, ...linkedFiles.indexFiles];
570
576
  const packageJsonFiles = [...globPackageJsonFiles, ...linkedFiles.packageJsonFiles];
571
577
 
578
+ // The native glob walker runs with follow_links=false, so a symlinked extension
579
+ // directory is yielded as a Symlink entry but never descended into: its inner
580
+ // index.{ts,js}/package.json are invisible to the `*/...` patterns above.
581
+ // Detect top-level symlinked directories and synthesize the equivalent subdir
582
+ // matches so the resolution below treats them like real directories. Symlinked
583
+ // *files* already match, because the native file-type filter resolves a
584
+ // symlink's target type for File filters.
585
+ const topLevelEntries = await readDirEntries(dir);
586
+ for (const entry of topLevelEntries) {
587
+ if (!entry.isSymbolicLink()) continue;
588
+ // readDirEntries follows the symlink: a link to a file/dangling link yields [].
589
+ const subEntries = await readDirEntries(path.join(dir, entry.name));
590
+ const hasEntry = (name: string): boolean =>
591
+ subEntries.some(e => e.name === name && (e.isFile() || e.isSymbolicLink()));
592
+ if (hasEntry("package.json")) packageJsonFiles.push({ path: `${entry.name}/package.json` });
593
+ if (hasEntry("index.ts")) indexFiles.push({ path: `${entry.name}/index.ts` });
594
+ else if (hasEntry("index.js")) indexFiles.push({ path: `${entry.name}/index.js` });
595
+ }
596
+
572
597
  // Process direct files
573
598
  for (const match of directFiles) {
574
599
  if (match.path.includes("/")) continue;
@@ -46,6 +46,7 @@ interface MCPConfigFile {
46
46
  redirectUri?: string;
47
47
  callbackPort?: number;
48
48
  callbackPath?: string;
49
+ prompt?: string;
49
50
  };
50
51
  }
51
52
  >;
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import * as fs from "node:fs/promises";
19
19
  import * as path from "node:path";
20
- import { isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
20
+ import { getAgentDir, isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
21
21
  import { readDirEntries, readFile } from "../capability/fs";
22
22
  import type { LoadContext } from "../capability/types";
23
23
  import { getEnabledPlugins } from "../extensibility/plugins/loader";
@@ -82,7 +82,7 @@ interface ScopeDirs {
82
82
  function scopeDirs(ctx: LoadContext): ScopeDirs {
83
83
  return {
84
84
  project: path.join(ctx.cwd, ".omp"),
85
- user: path.join(ctx.home, ".omp", "agent"),
85
+ user: getAgentDir(),
86
86
  };
87
87
  }
88
88
 
@@ -89,3 +89,46 @@ export async function recordFileSnapshot(
89
89
  return undefined;
90
90
  }
91
91
  }
92
+
93
+ /**
94
+ * Leading line-number prefix the hashline/summary/grep formatters stamp on
95
+ * every displayed body line: `NN:` or a collapsed summary `NN-MM:` from `read`,
96
+ * optionally preceded by a grep `*` (match) / space (context) marker from
97
+ * `search`/`ast-grep`. Anchored at line start, so source content after the
98
+ * colon never matches.
99
+ */
100
+ const HASHLINE_LINE_PREFIX = /^[ *]?(\d+)(?:-(\d+))?:/;
101
+
102
+ /**
103
+ * The 1-indexed file lines a hashline-formatted body actually displayed.
104
+ * Single `NN:` rows contribute that line; a collapsed summary `NN-MM:` row
105
+ * (a `{ .. }` brace pair) contributes only its boundary lines `NN` and `MM` —
106
+ * the elided interior was never shown, so editing inside it must be rejected.
107
+ */
108
+ export function parseSeenLinesFromHashlineBody(body: string): number[] {
109
+ const seen: number[] = [];
110
+ for (const row of body.split("\n")) {
111
+ const match = HASHLINE_LINE_PREFIX.exec(row);
112
+ if (!match) continue;
113
+ seen.push(Number(match[1]));
114
+ if (match[2] !== undefined) seen.push(Number(match[2]));
115
+ }
116
+ return seen;
117
+ }
118
+
119
+ /**
120
+ * Attach the lines a read displayed to the snapshot it minted, so the patcher
121
+ * can reject edits anchored on lines the model never saw. Best-effort: a no-op
122
+ * when the body has no numbered rows or the snapshot already aged out. `tag`
123
+ * must be the tag returned when this exact content was recorded.
124
+ */
125
+ export function recordSeenLinesFromBody(
126
+ session: FileSnapshotStoreOwner,
127
+ absolutePath: string,
128
+ tag: string,
129
+ body: string,
130
+ ): void {
131
+ const seen = parseSeenLinesFromHashlineBody(body);
132
+ if (seen.length === 0) return;
133
+ getFileSnapshotStore(session).recordSeenLines(canonicalSnapshotKey(absolutePath), tag, seen);
134
+ }
@@ -679,13 +679,14 @@ describe("agent() through eval runtimes", () => {
679
679
  cost: 0,
680
680
  durationMs: i * 10,
681
681
  });
682
- await Bun.sleep(5);
682
+ await Bun.sleep(40);
683
683
  }
684
684
  return singleResult(options, { output: "done" });
685
685
  });
686
686
 
687
687
  const ops: string[] = [];
688
- using idle = new IdleTimeout(40);
688
+ // Timing invariant (keep, do not re-tighten): total mock work (20*40ms = 800ms) > idle window (250ms) > scheduling jitter (~tens of ms).
689
+ using idle = new IdleTimeout(250);
689
690
  const result = await runEvalAgent(
690
691
  { prompt: "investigate" },
691
692
  {
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import * as path from "node:path";
3
- import { TempDir } from "@oh-my-pi/pi-utils";
3
+ import { TempDir } from "@oh-my-pi/pi-utils/temp";
4
4
  import { createHelpers, type HelperContext } from "../js/shared/helpers";
5
5
 
6
6
  /**
@@ -342,9 +342,63 @@ export class JsRuntime {
342
342
  // Prelude assigns console bridge + short aliases (`read`, `write`, `tool`, `display`, ...)
343
343
  // onto globalThis. Must run after helpers are in place.
344
344
  indirectEval(JAVASCRIPT_PRELUDE_SOURCE);
345
+ RUN_HOOK_RESOLVERS.add(() => this.#als.getStore()?.hooks);
346
+ patchStdioOnce();
345
347
  }
346
348
  }
347
349
 
350
+ /** Resolvers for each live runtime's active-run hooks (one per JsRuntime instance). */
351
+ const RUN_HOOK_RESOLVERS = new Set<() => RuntimeHooks | undefined>();
352
+
353
+ /** Streams whose `write` the runtime has already wrapped (patch-once guard). */
354
+ const PATCHED_STDIO_STREAMS = new WeakSet<NodeJS.WriteStream>();
355
+
356
+ /** Hooks for whichever registered runtime currently has an active run, if any. */
357
+ function activeRunHooks(): RuntimeHooks | undefined {
358
+ for (const resolve of RUN_HOOK_RESOLVERS) {
359
+ const hooks = resolve();
360
+ if (hooks) return hooks;
361
+ }
362
+ return undefined;
363
+ }
364
+
365
+ /**
366
+ * Wrap `process.stdout` / `process.stderr` `write` exactly once per process so
367
+ * user `process.stdout.write(...)` lands in the active run's text sink. Models
368
+ * reach for it out of Node habit, but `process` is intentionally the host
369
+ * worker's real object (see {@link JsRuntime} `#install`), so unrouted writes
370
+ * escape to the worker's own stdio and never reach the cell — and `write()`
371
+ * returns a boolean, so a cell ending in `process.stdout.write("x")` captured
372
+ * `true` while losing the text. Patch only the `write` method (never replace
373
+ * `process`), preserve exact bytes (no trailing newline), and fall through to
374
+ * the real stream when no run is active so the worker's own logging is intact.
375
+ */
376
+ function patchStdioOnce(): void {
377
+ const streams: NodeJS.WriteStream[] = [process.stdout, process.stderr];
378
+ for (const stream of streams) {
379
+ if (!stream || PATCHED_STDIO_STREAMS.has(stream)) continue;
380
+ PATCHED_STDIO_STREAMS.add(stream);
381
+ const original = stream.write.bind(stream) as (...args: unknown[]) => boolean;
382
+ const routed = (chunk: unknown, encoding?: unknown, callback?: unknown): boolean => {
383
+ const hooks = activeRunHooks();
384
+ if (!hooks) return original(chunk, encoding, callback);
385
+ const cb = typeof encoding === "function" ? encoding : callback;
386
+ const enc = typeof encoding === "string" ? (encoding as BufferEncoding) : undefined;
387
+ hooks.onText(chunkToString(chunk, enc));
388
+ if (typeof cb === "function") (cb as (error?: Error | null) => void)();
389
+ return true;
390
+ };
391
+ stream.write = routed as unknown as typeof stream.write;
392
+ }
393
+ }
394
+
395
+ /** Coerce a `write()` chunk to text, honoring an explicit encoding for byte chunks. */
396
+ function chunkToString(chunk: unknown, encoding?: BufferEncoding): string {
397
+ if (typeof chunk === "string") return chunk;
398
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString(encoding ?? "utf8");
399
+ return String(chunk);
400
+ }
401
+
348
402
  function formatConsoleArgs(args: unknown[]): string {
349
403
  return args
350
404
  .map(arg => (typeof arg === "string" ? arg : util.inspect(arg, { depth: 6, colors: false, breakLength: 120 })))
@@ -71,6 +71,28 @@ export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void {
71
71
  extensionHandlerTimeoutMs = timeoutMs;
72
72
  }
73
73
 
74
+ /**
75
+ * Dedicated cap for `session_shutdown` handlers. The generic 30s budget is
76
+ * appropriate for events extensions can observe (e.g. `session_start`,
77
+ * `before_provider_request`), but `session_shutdown` is fire-and-forget
78
+ * teardown — extensions receive no result and the user has already asked to
79
+ * leave. A hung handler (e.g. an extension waiting on a stuck IPC pipe to a
80
+ * companion app) MUST NOT hold Ctrl+C / `/exit` hostage for the full window.
81
+ * See issue #2600.
82
+ */
83
+ export const SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS = 2_000;
84
+ let sessionShutdownHandlerTimeoutMs = SESSION_SHUTDOWN_HANDLER_TIMEOUT_MS;
85
+
86
+ export function testSetSessionShutdownHandlerTimeoutMs(timeoutMs: number): void {
87
+ sessionShutdownHandlerTimeoutMs = timeoutMs;
88
+ }
89
+
90
+ /** Per-event handler budget. Defaults to the generic cap; `session_shutdown`
91
+ * uses its own short cap so teardown stays prompt. */
92
+ function handlerTimeoutForEvent(eventType: string): number {
93
+ return eventType === "session_shutdown" ? sessionShutdownHandlerTimeoutMs : extensionHandlerTimeoutMs;
94
+ }
95
+
74
96
  const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
75
97
 
76
98
  const MAX_PENDING_CREDENTIAL_DISABLED = 32;
@@ -576,7 +598,7 @@ export class ExtensionRunner {
576
598
  event,
577
599
  ctx,
578
600
  ext,
579
- extensionHandlerTimeoutMs,
601
+ handlerTimeoutForEvent(event.type),
580
602
  );
581
603
 
582
604
  if (this.#isSessionBeforeEvent(event) && handlerResult) {
@@ -907,7 +929,8 @@ export class ExtensionRunner {
907
929
  messages.push(result.message);
908
930
  }
909
931
  if (result.systemPrompt !== undefined) {
910
- currentSystemPrompt = result.systemPrompt;
932
+ currentSystemPrompt =
933
+ typeof result.systemPrompt === "string" ? [result.systemPrompt] : result.systemPrompt;
911
934
  systemPromptModified = true;
912
935
  }
913
936
  }
@@ -356,7 +356,10 @@ export class GoalRuntime {
356
356
  this.#wallClock.lastAccountedAt += wallSeconds * 1000;
357
357
  }
358
358
 
359
- await this.#commitState(state, { persist: "goal" });
359
+ // Persisting wall-clock-only accounting on every tool event bloats /goal sessions with full
360
+ // objective snapshots. Keep the in-memory/UI state fresh, but persist only token/budget changes.
361
+ const shouldPersistUsage = tokenDelta > 0 || flippedToBudgetLimited;
362
+ await this.#commitState(state, { persist: shouldPersistUsage ? "goal" : undefined });
360
363
 
361
364
  if (state.goal.status !== "budget-limited") {
362
365
  this.#budgetReportedFor = undefined;