@oh-my-pi/pi-coding-agent 17.2.7 → 17.2.9

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 (91) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/dist/{CHANGELOG-1p8yvde7.md → CHANGELOG-8yy8h3bc.md} +57 -1
  3. package/dist/cli.js +4974 -4968
  4. package/dist/types/async/job-manager.d.ts +12 -0
  5. package/dist/types/discovery/agents.d.ts +11 -0
  6. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +10 -0
  7. package/dist/types/hindsight/state.d.ts +0 -1
  8. package/dist/types/lsp/utils.d.ts +8 -0
  9. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  10. package/dist/types/registry/agent-lifecycle.d.ts +1 -1
  11. package/dist/types/session/agent-session-types.d.ts +7 -0
  12. package/dist/types/session/agent-session.d.ts +5 -3
  13. package/dist/types/session/turn-recovery.d.ts +6 -2
  14. package/dist/types/task/executor.d.ts +4 -0
  15. package/dist/types/tools/browser/launch.d.ts +2 -0
  16. package/dist/types/tools/path-utils.d.ts +7 -0
  17. package/dist/types/tools/read.d.ts +2 -0
  18. package/dist/types/tools/shell-tokenize.d.ts +21 -3
  19. package/dist/types/utils/changelog.d.ts +3 -2
  20. package/dist/types/utils/late-cleanup.d.ts +2 -0
  21. package/dist/types/vibe/runtime.d.ts +2 -0
  22. package/package.json +13 -13
  23. package/scripts/omp +11 -2
  24. package/src/async/job-manager.ts +26 -0
  25. package/src/cli/stats-cli.ts +3 -3
  26. package/src/cli/update-cli.ts +2 -56
  27. package/src/config/config-file.ts +2 -2
  28. package/src/config/keybindings.ts +3 -3
  29. package/src/config/model-discovery.ts +36 -2
  30. package/src/config/model-registry.ts +4 -1
  31. package/src/discovery/agents.ts +71 -3
  32. package/src/discovery/claude.ts +8 -5
  33. package/src/discovery/cursor.ts +8 -5
  34. package/src/discovery/gemini.ts +11 -8
  35. package/src/discovery/vscode.ts +1 -0
  36. package/src/discovery/windsurf.ts +4 -2
  37. package/src/edit/hashline/filesystem.ts +7 -7
  38. package/src/eval/agent-bridge.ts +1 -3
  39. package/src/eval/jl/prelude.jl +4 -4
  40. package/src/eval/js/shared/prelude.txt +2 -2
  41. package/src/eval/py/prelude.py +0 -3
  42. package/src/eval/rb/prelude.rb +1 -2
  43. package/src/extensibility/legacy-pi-coding-agent-shim.ts +23 -0
  44. package/src/hindsight/state.ts +2 -22
  45. package/src/internal-urls/memory-protocol.ts +29 -0
  46. package/src/lsp/client.ts +2 -2
  47. package/src/lsp/utils.ts +29 -0
  48. package/src/modes/acp/acp-agent.ts +2 -1
  49. package/src/modes/components/agent-hub.ts +36 -20
  50. package/src/modes/components/agent-transcript-viewer.ts +1 -3
  51. package/src/modes/components/bordered-loader.ts +1 -1
  52. package/src/modes/components/custom-editor.ts +2 -2
  53. package/src/modes/components/hook-input.ts +1 -1
  54. package/src/modes/components/hook-selector.ts +2 -2
  55. package/src/modes/components/session-selector.ts +2 -2
  56. package/src/modes/controllers/event-controller.ts +159 -2
  57. package/src/modes/controllers/extension-ui-controller.ts +2 -1
  58. package/src/modes/print-mode.ts +29 -12
  59. package/src/modes/session-observer-registry.ts +5 -0
  60. package/src/registry/agent-lifecycle.ts +21 -3
  61. package/src/sdk.ts +11 -5
  62. package/src/session/agent-session-types.ts +8 -0
  63. package/src/session/agent-session.ts +156 -186
  64. package/src/session/session-paths.ts +68 -121
  65. package/src/session/settings-stream-fn.ts +7 -3
  66. package/src/session/turn-recovery.ts +234 -16
  67. package/src/session/unexpected-stop-classifier.ts +12 -3
  68. package/src/slash-commands/helpers/stats-dashboard.ts +2 -1
  69. package/src/task/agents.ts +1 -1
  70. package/src/task/executor.ts +105 -29
  71. package/src/task/isolation-runner.ts +19 -1
  72. package/src/task/structured-subagent.ts +17 -2
  73. package/src/tiny/worker.ts +1 -1
  74. package/src/tools/bash-interceptor.ts +8 -4
  75. package/src/tools/browser/launch.ts +37 -9
  76. package/src/tools/glob.ts +4 -2
  77. package/src/tools/path-utils.ts +13 -3
  78. package/src/tools/read.ts +6 -0
  79. package/src/tools/shell-tokenize.ts +38 -9
  80. package/src/utils/changelog.ts +5 -4
  81. package/src/utils/late-cleanup.ts +17 -0
  82. package/src/vibe/runtime.ts +197 -53
  83. package/src/web/scrapers/hackage.ts +1 -12
  84. package/src/web/search/providers/duckduckgo.ts +1 -1
  85. package/src/web/search/providers/ecosia.ts +1 -1
  86. package/src/web/search/providers/exa.ts +1 -1
  87. package/src/web/search/providers/firecrawl.ts +1 -1
  88. package/src/web/search/providers/mojeek.ts +1 -1
  89. package/src/web/search/providers/perplexity.ts +1 -1
  90. package/src/web/search/providers/public.ts +1 -1
  91. package/src/web/search/providers/startpage.ts +1 -1
@@ -28,9 +28,77 @@ const DISPLAY_NAME = "Agent Dirs (.agent/.agents)";
28
28
  const PRIORITY = 70;
29
29
  const AGENT_DIR_CANDIDATES = [".agent", ".agents"] as const;
30
30
 
31
- /** User-level paths: ~/.agent/<segments> and ~/.agents/<segments>. */
32
- function getUserPathCandidates(ctx: LoadContext, ...segments: string[]): string[] {
33
- return AGENT_DIR_CANDIDATES.map(baseDir => path.join(ctx.home, baseDir, ...segments));
31
+ interface UserPathCandidateOptions {
32
+ platform?: NodeJS.Platform;
33
+ env?: NodeJS.ProcessEnv;
34
+ windowsUserProfile?: () => string | undefined;
35
+ wslPath?: (windowsPath: string) => string | undefined;
36
+ }
37
+
38
+ const WINDOWS_DRIVE_PROFILE_PATTERN = /^([A-Za-z]):[\\/](.*)$/;
39
+
40
+ function isWsl(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean {
41
+ return platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP);
42
+ }
43
+
44
+ function convertWindowsPathToDefaultWslMount(windowsPath: string): string | undefined {
45
+ const trimmed = windowsPath.trim();
46
+ if (trimmed.length === 0) return undefined;
47
+ if (path.isAbsolute(trimmed)) return path.normalize(trimmed);
48
+ const match = WINDOWS_DRIVE_PROFILE_PATTERN.exec(trimmed);
49
+ if (!match) return undefined;
50
+ const [, drive, rest] = match;
51
+ const segments = rest.replace(/\\/g, "/").split("/").filter(Boolean);
52
+ return path.join("/mnt", drive.toLowerCase(), ...segments);
53
+ }
54
+
55
+ function resolveWithWslPath(windowsPath: string): string | undefined {
56
+ try {
57
+ const result = Bun.spawnSync(["wslpath", "-u", windowsPath], { stdout: "pipe", stderr: "ignore" });
58
+ if (result.exitCode !== 0) return undefined;
59
+ const resolved = result.stdout.toString().trim();
60
+ return resolved.length > 0 ? resolved : undefined;
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ }
65
+
66
+ function resolveWindowsUserProfile(): string | undefined {
67
+ try {
68
+ const result = Bun.spawnSync(["cmd.exe", "/d", "/c", "echo", "%USERPROFILE%"], {
69
+ stdout: "pipe",
70
+ stderr: "ignore",
71
+ });
72
+ if (result.exitCode !== 0) return undefined;
73
+ const resolved = result.stdout.toString().trim();
74
+ return resolved.length > 0 && resolved !== "%USERPROFILE%" ? resolved : undefined;
75
+ } catch {
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ /** Resolve the Windows host profile home exposed to WSL, if available. */
81
+ export function getWslWindowsHomeCandidate(options: UserPathCandidateOptions = {}): string | undefined {
82
+ const platform = options.platform ?? process.platform;
83
+ const env = options.env ?? process.env;
84
+ if (!isWsl(platform, env)) return undefined;
85
+ const userProfile = env.USERPROFILE ?? (options.windowsUserProfile ?? resolveWindowsUserProfile)();
86
+ if (!userProfile) return undefined;
87
+ return (options.wslPath ?? resolveWithWslPath)(userProfile) ?? convertWindowsPathToDefaultWslMount(userProfile);
88
+ }
89
+
90
+ function getUserHomeCandidates(ctx: LoadContext): string[] {
91
+ const homes = [ctx.home];
92
+ const wslHome = getWslWindowsHomeCandidate();
93
+ if (wslHome && !homes.includes(wslHome)) homes.push(wslHome);
94
+ return homes;
95
+ }
96
+
97
+ /** User-level paths: ~/.agent[s]/<segments>, plus the Windows host profile under WSL. */
98
+ export function getUserPathCandidates(ctx: LoadContext, ...segments: string[]): string[] {
99
+ return getUserHomeCandidates(ctx).flatMap(home =>
100
+ AGENT_DIR_CANDIDATES.map(baseDir => path.join(home, baseDir, ...segments)),
101
+ );
34
102
  }
35
103
 
36
104
  /**
@@ -90,6 +90,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
90
90
  const serverConfig = config as Record<string, unknown>;
91
91
  return {
92
92
  name,
93
+ enabled: typeof serverConfig.enabled === "boolean" ? serverConfig.enabled : undefined,
93
94
  timeout: typeof serverConfig.timeout === "number" ? serverConfig.timeout : undefined,
94
95
  command: serverConfig.command as string | undefined,
95
96
  args: serverConfig.args as string[] | undefined,
@@ -102,17 +103,19 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
102
103
  });
103
104
  };
104
105
 
105
- for (let i = 0; i < userPaths.length; i++) {
106
- const servers = parseMcpServers(contents[i], userPaths[i].path, userPaths[i].level);
106
+ // Load project entries before user entries so a project `enabled: false`
107
+ // claims its dedupe key before a same-named user server can survive (#7654).
108
+ const projectOffset = userPaths.length;
109
+ for (let i = 0; i < projectPaths.length; i++) {
110
+ const servers = parseMcpServers(contents[projectOffset + i], projectPaths[i].path, projectPaths[i].level);
107
111
  if (servers.length > 0) {
108
112
  items.push(...servers);
109
113
  break;
110
114
  }
111
115
  }
112
116
 
113
- const projectOffset = userPaths.length;
114
- for (let i = 0; i < projectPaths.length; i++) {
115
- const servers = parseMcpServers(contents[projectOffset + i], projectPaths[i].path, projectPaths[i].level);
117
+ for (let i = 0; i < userPaths.length; i++) {
118
+ const servers = parseMcpServers(contents[i], userPaths[i].path, userPaths[i].level);
116
119
  if (servers.length > 0) {
117
120
  items.push(...servers);
118
121
  break;
@@ -57,6 +57,7 @@ function parseMCPServers(
57
57
  const serverConfig = config as Record<string, unknown>;
58
58
  items.push({
59
59
  name,
60
+ enabled: typeof serverConfig.enabled === "boolean" ? serverConfig.enabled : undefined,
60
61
  command: serverConfig.command as string | undefined,
61
62
  args: serverConfig.args as string[] | undefined,
62
63
  env: serverConfig.env as Record<string, string> | undefined,
@@ -86,15 +87,17 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
86
87
 
87
88
  const projectContentPromise = projectPath ? readFile(projectPath) : Promise.resolve(null);
88
89
 
89
- if (userContent && userPath) {
90
- const result = parseMCPServers(userContent, userPath, "user");
90
+ // Load project entries before user entries so a project `enabled: false`
91
+ // claims its dedupe key before a same-named user server can survive (#7654).
92
+ const projectContent = await projectContentPromise;
93
+ if (projectContent && projectPath) {
94
+ const result = parseMCPServers(projectContent, projectPath, "project");
91
95
  items.push(...result.items);
92
96
  if (result.warning) warnings.push(result.warning);
93
97
  }
94
98
 
95
- const projectContent = await projectContentPromise;
96
- if (projectContent && projectPath) {
97
- const result = parseMCPServers(projectContent, projectPath, "project");
99
+ if (userContent && userPath) {
100
+ const result = parseMCPServers(userContent, userPath, "user");
98
101
  items.push(...result.items);
99
102
  if (result.warning) warnings.push(result.warning);
100
103
  }
@@ -48,14 +48,8 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
48
48
  const items: MCPServer[] = [];
49
49
  const warnings: string[] = [];
50
50
 
51
- // User-level: ~/.gemini/settings.json mcpServers
52
- const userPath = getUserPath(ctx, "gemini", "settings.json");
53
- if (userPath) {
54
- const result = await loadMCPFromSettings(ctx, userPath, "user");
55
- items.push(...result.items);
56
- if (result.warnings) warnings.push(...result.warnings);
57
- }
58
-
51
+ // Load project entries before user entries so a project `enabled: false`
52
+ // claims its dedupe key before a same-named user server can survive (#7654).
59
53
  // Project-level: .gemini/settings.json → mcpServers
60
54
  const projectPath = getProjectPath(ctx, "gemini", "settings.json");
61
55
  if (projectPath) {
@@ -64,6 +58,14 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
64
58
  if (result.warnings) warnings.push(...result.warnings);
65
59
  }
66
60
 
61
+ // User-level: ~/.gemini/settings.json → mcpServers
62
+ const userPath = getUserPath(ctx, "gemini", "settings.json");
63
+ if (userPath) {
64
+ const result = await loadMCPFromSettings(ctx, userPath, "user");
65
+ items.push(...result.items);
66
+ if (result.warnings) warnings.push(...result.warnings);
67
+ }
68
+
67
69
  return { items, warnings };
68
70
  }
69
71
 
@@ -102,6 +104,7 @@ async function loadMCPFromSettings(
102
104
 
103
105
  items.push({
104
106
  name,
107
+ enabled: typeof raw.enabled === "boolean" ? raw.enabled : undefined,
105
108
  command: typeof raw.command === "string" ? raw.command : undefined,
106
109
  args: Array.isArray(raw.args) ? (raw.args as string[]) : undefined,
107
110
  env: raw.env && typeof raw.env === "object" ? (raw.env as Record<string, string>) : undefined,
@@ -83,6 +83,7 @@ async function loadMCPConfig(
83
83
 
84
84
  const server: MCPServer = {
85
85
  name,
86
+ enabled: typeof expanded.enabled === "boolean" ? expanded.enabled : undefined,
86
87
  command: typeof expanded.command === "string" ? expanded.command : undefined,
87
88
  args: Array.isArray(expanded.args) ? (expanded.args as string[]) : undefined,
88
89
  env: expanded.env && typeof expanded.env === "object" ? (expanded.env as Record<string, string>) : undefined,
@@ -48,6 +48,7 @@ function parseServerConfig(
48
48
  return {
49
49
  server: {
50
50
  name,
51
+ enabled: typeof server.enabled === "boolean" ? server.enabled : undefined,
51
52
  command: server.command as string | undefined,
52
53
  args: server.args as string[] | undefined,
53
54
  env: server.env as Record<string, string> | undefined,
@@ -71,10 +72,11 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
71
72
  ]);
72
73
 
73
74
  const projectContent = projectPath ? await readFile(projectPath) : null;
74
-
75
+ // Load project entries before user entries so a project `enabled: false`
76
+ // claims its dedupe key before a same-named user server can survive (#7654).
75
77
  const configs: Array<{ content: string | null; path: string | null; scope: "user" | "project" }> = [
76
- { content: userContent, path: userPath, scope: "user" },
77
78
  { content: projectContent, path: projectPath, scope: "project" },
79
+ { content: userContent, path: userPath, scope: "user" },
78
80
  ];
79
81
 
80
82
  for (const { content, path, scope } of configs) {
@@ -87,11 +87,11 @@ export class HashlineFilesystem extends Filesystem {
87
87
  return resolvePlanPath(this.session, relativePath);
88
88
  }
89
89
 
90
- canonicalPath(relativePath: string): string {
90
+ override canonicalPath(relativePath: string): string {
91
91
  return canonicalSnapshotKey(this.resolveAbsolute(relativePath));
92
92
  }
93
93
 
94
- allowTagPathRecovery(authoredPath: string, resolvedPath: string): boolean {
94
+ override allowTagPathRecovery(authoredPath: string, resolvedPath: string): boolean {
95
95
  // Internal-URL authored targets (`local://`, `vault://`, …) are approved
96
96
  // at the lower "read" privilege; never let one redirect onto a "write".
97
97
  if (isInternalUrlPath(authoredPath)) return false;
@@ -125,7 +125,7 @@ export class HashlineFilesystem extends Filesystem {
125
125
  return content;
126
126
  }
127
127
 
128
- async readBinary(relativePath: string): Promise<Uint8Array | undefined> {
128
+ override async readBinary(relativePath: string): Promise<Uint8Array | undefined> {
129
129
  const absolutePath = this.resolveAbsolute(relativePath);
130
130
  if (isNotebookPath(absolutePath)) return undefined;
131
131
  try {
@@ -136,7 +136,7 @@ export class HashlineFilesystem extends Filesystem {
136
136
  }
137
137
  }
138
138
 
139
- async preflightWrite(relativePath: string, options?: PreflightWriteOptions): Promise<void> {
139
+ override async preflightWrite(relativePath: string, options?: PreflightWriteOptions): Promise<void> {
140
140
  const fileOp = options?.fileOp;
141
141
  if (fileOp?.kind === "rem") {
142
142
  enforcePlanModeWrite(this.session, relativePath, { op: "delete" });
@@ -149,7 +149,7 @@ export class HashlineFilesystem extends Filesystem {
149
149
  enforcePlanModeWrite(this.session, relativePath, { op: "update" });
150
150
  }
151
151
 
152
- async delete(relativePath: string): Promise<void> {
152
+ override async delete(relativePath: string): Promise<void> {
153
153
  enforcePlanModeWrite(this.session, relativePath, { op: "delete" });
154
154
  const absolutePath = this.resolveAbsolute(relativePath);
155
155
  try {
@@ -168,7 +168,7 @@ export class HashlineFilesystem extends Filesystem {
168
168
  invalidateFsScanAfterWrite(absolutePath);
169
169
  }
170
170
 
171
- async move(fromRelative: string, toRelative: string, content?: string): Promise<void> {
171
+ override async move(fromRelative: string, toRelative: string, content?: string): Promise<void> {
172
172
  enforcePlanModeWrite(this.session, fromRelative, { op: "update", move: toRelative });
173
173
  const fromAbsolute = this.resolveAbsolute(fromRelative);
174
174
  const toAbsolute = this.resolveAbsolute(toRelative);
@@ -240,7 +240,7 @@ export class HashlineFilesystem extends Filesystem {
240
240
  return { text: content };
241
241
  }
242
242
 
243
- async exists(relativePath: string): Promise<boolean> {
243
+ override async exists(relativePath: string): Promise<boolean> {
244
244
  const absolutePath = this.resolveAbsolute(relativePath);
245
245
  return Bun.file(absolutePath).exists();
246
246
  }
@@ -23,7 +23,6 @@ export const EVAL_AGENT_BRIDGE_NAME = "__agent__";
23
23
  const agentArgsSchema = type({
24
24
  prompt: "string>0",
25
25
  "agent?": "string>0",
26
- "model?": "string>0|string>0[]",
27
26
  "label?": "string",
28
27
  "schema?": "unknown",
29
28
  "schemaMode?": "'permissive' | 'strict'",
@@ -31,12 +30,12 @@ const agentArgsSchema = type({
31
30
  "apply?": "boolean",
32
31
  "merge?": "boolean",
33
32
  "handle?": "boolean",
33
+ "+": "delete",
34
34
  });
35
35
 
36
36
  interface EvalAgentArgs {
37
37
  prompt: string;
38
38
  agent?: string;
39
- model?: string | string[];
40
39
  label?: string;
41
40
  schema?: unknown;
42
41
  schemaMode?: StructuredSubagentSchemaMode;
@@ -148,7 +147,6 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
148
147
  invocationKind: "eval",
149
148
  assignment: parsed.prompt,
150
149
  ...(parsed.agent !== undefined ? { agent: parsed.agent } : {}),
151
- ...(parsed.model !== undefined ? { model: parsed.model } : {}),
152
150
  ...(Object.hasOwn(parsed, "schema") ? { outputSchema: parsed.schema } : {}),
153
151
  ...(parsed.schemaMode !== undefined ? { schemaMode: parsed.schemaMode } : {}),
154
152
  ...(parsed.label !== undefined ? { identity: { label: parsed.label } } : {}),
@@ -519,14 +519,11 @@ function completion(prompt::String; model="default", system=nothing, schema=noth
519
519
  return schema === nothing ? text : Main.json_parse(string(text))
520
520
  end
521
521
 
522
- function agent(prompt::String; agent="task", model=nothing, label=nothing, schema=nothing, schema_mode=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...)
522
+ function agent(prompt::String; agent="task", label=nothing, schema=nothing, schema_mode=nothing, isolated=nothing, apply=nothing, merge=nothing, handle=false, kwargs...)
523
523
  args_dict = Dict{String, Any}("prompt" => prompt)
524
524
  if agent !== nothing
525
525
  args_dict["agent"] = agent
526
526
  end
527
- if model !== nothing
528
- args_dict["model"] = model
529
- end
530
527
  if label !== nothing
531
528
  args_dict["label"] = label
532
529
  end
@@ -545,6 +542,9 @@ function agent(prompt::String; agent="task", model=nothing, label=nothing, schem
545
542
  if merge !== nothing
546
543
  args_dict["merge"] = Bool(merge)
547
544
  end
545
+ if haskey(kwargs, :model)
546
+ error("agent() no longer accepts a per-call model override; the selected agent's frontmatter model is used")
547
+ end
548
548
  handle_result = handle
549
549
  for (k, v) in kwargs
550
550
  args_dict[string(k)] = v
@@ -104,8 +104,8 @@ if (!globalThis.__omp_js_prelude_loaded__) {
104
104
  "agent",
105
105
  opts,
106
106
  rest,
107
- ["agent", "model", "label", "schema", "isolated", "apply", "merge", "schemaMode"],
108
- "{ agent, model, label, schema, isolated, apply, merge, schemaMode, handle }",
107
+ ["agent", "label", "schema", "isolated", "apply", "merge", "schemaMode"],
108
+ "{ agent, label, schema, isolated, apply, merge, schemaMode, handle }",
109
109
  );
110
110
  const { handle, ...callArgs } = o;
111
111
  const res = await globalThis.__omp_call_tool__("__agent__", { prompt, ...callArgs, handle: Boolean(handle) });
@@ -488,7 +488,6 @@ if "__omp_prelude_loaded__" not in globals():
488
488
  prompt,
489
489
  *,
490
490
  agent="task",
491
- model=None,
492
491
  label=None,
493
492
  schema=None,
494
493
  schema_mode=None,
@@ -506,8 +505,6 @@ if "__omp_prelude_loaded__" not in globals():
506
505
  args = {"prompt": prompt}
507
506
  if agent is not None:
508
507
  args["agent"] = agent
509
- if model is not None:
510
- args["model"] = model
511
508
  if label is not None:
512
509
  args["label"] = label
513
510
  if schema is not None:
@@ -392,10 +392,9 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
392
392
  schema.nil? ? text : JSON.parse(text)
393
393
  end
394
394
 
395
- def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, schema_mode: nil, isolated: nil, apply: nil, merge: nil, handle: false)
395
+ def agent(prompt, agent: "task", label: nil, schema: nil, schema_mode: nil, isolated: nil, apply: nil, merge: nil, handle: false)
396
396
  args = { "prompt" => prompt }
397
397
  args["agent"] = agent unless agent.nil?
398
- args["model"] = model unless model.nil?
399
398
  args["label"] = label unless label.nil?
400
399
  args["schema"] = schema unless schema.nil?
401
400
  args["schemaMode"] = schema_mode unless schema_mode.nil?
@@ -54,6 +54,7 @@ import { ReadTool } from "../tools/read";
54
54
  import { formatBytes } from "../tools/render-utils";
55
55
  import { WriteTool } from "../tools/write";
56
56
  import { EventBus } from "../utils/event-bus";
57
+ import { convertImageToPng } from "../utils/image-loading";
57
58
  import { discoverExtensionPaths, loadExtensionFromFactory, loadExtensions } from "./extensions";
58
59
  import { ExtensionRuntime } from "./extensions/loader";
59
60
  import type { ExtensionFactory, ToolDefinition } from "./extensions/types";
@@ -384,6 +385,28 @@ async function executeLegacyBashOperations(
384
385
  }
385
386
  }
386
387
 
388
+ /**
389
+ * Convert an image attachment to PNG using the legacy package-root contract.
390
+ *
391
+ * Invalid or unsupported image data returns `null`, matching Pi's historical
392
+ * helper instead of surfacing Bun's decoder error to extensions.
393
+ */
394
+ export async function convertToPng(
395
+ base64Data: string,
396
+ mimeType: string,
397
+ ): Promise<{ data: string; mimeType: string } | null> {
398
+ if (mimeType === "image/png") {
399
+ return { data: base64Data, mimeType };
400
+ }
401
+
402
+ try {
403
+ const converted = await convertImageToPng({ type: "image", data: base64Data, mimeType });
404
+ return { data: converted.data, mimeType: converted.mimeType };
405
+ } catch {
406
+ return null;
407
+ }
408
+ }
409
+
387
410
  /** Format the active shortcut for legacy extensions that render keybinding hints. */
388
411
  export function keyText(action: Keybinding): string {
389
412
  return formatKeyHints(getKeybindings().getKeys(action));
@@ -411,24 +411,6 @@ export class HindsightSessionState {
411
411
  }
412
412
  }
413
413
 
414
- async maybeRecallOnAgentStart(): Promise<void> {
415
- if (!this.config.autoRecall || this.hasRecalledForFirstTurn) return;
416
- const messages = extractMessages(this.session.sessionManager);
417
- const lastUser = messages.findLast(m => m.role === "user");
418
- if (!lastUser) return;
419
-
420
- const query = composeRecallQuery(lastUser.content, messages, this.config.recallContextTurns);
421
- const truncated = truncateRecallQuery(query, lastUser.content, this.config.recallMaxQueryChars);
422
- const { context, ok } = await this.recallForContext(truncated);
423
- if (!ok) return;
424
-
425
- this.hasRecalledForFirstTurn = true;
426
- if (!context) return;
427
-
428
- this.lastRecallSnippet = context;
429
- await this.#refreshBaseSystemPromptAfter("recall");
430
- }
431
-
432
414
  async beforeAgentStartPrompt(promptText: string): Promise<string | undefined> {
433
415
  if (this.config.mentalModelsEnabled && this.mentalModelsLoadPromise && this.mentalModelsLoadedAt === undefined) {
434
416
  await Promise.race([this.mentalModelsLoadPromise, Bun.sleep(MENTAL_MODEL_FIRST_TURN_DEADLINE_MS)]);
@@ -509,9 +491,7 @@ export class HindsightSessionState {
509
491
  attachSessionListeners(): void {
510
492
  this.unsubscribe?.();
511
493
  this.unsubscribe = this.session.subscribe(event => {
512
- if (event.type === "agent_start") {
513
- void this.maybeRecallOnAgentStart();
514
- } else if (event.type === "agent_end") {
494
+ if (event.type === "agent_end") {
515
495
  void this.maybeRetainOnAgentEnd();
516
496
  // Drain any queued tool-initiated retain calls now that the turn
517
497
  // is settled. The queue is also debounced/size-bounded, but
@@ -540,7 +520,7 @@ export class HindsightSessionState {
540
520
  this.retainQueue.dispose();
541
521
  }
542
522
 
543
- async #refreshBaseSystemPromptAfter(reason: "recall" | "MM load" | "MM reload" | "MM TTL reload"): Promise<void> {
523
+ async #refreshBaseSystemPromptAfter(reason: "MM load" | "MM reload" | "MM TTL reload"): Promise<void> {
544
524
  try {
545
525
  await this.session.refreshBaseSystemPrompt();
546
526
  } catch (err) {
@@ -222,6 +222,18 @@ function mnemopiSessionStatesFromRegistry(): MnemopiSessionState[] {
222
222
  return states;
223
223
  }
224
224
 
225
+ function memoryBackendFromContext(context?: ResolveContext): string | undefined {
226
+ if (!context?.settings || typeof context.settings !== "object") return undefined;
227
+ try {
228
+ const get = Reflect.get(context.settings, "get");
229
+ if (typeof get !== "function") return undefined;
230
+ const backend = Reflect.apply(get, context.settings, ["memory.backend"]);
231
+ return typeof backend === "string" ? backend : undefined;
232
+ } catch {
233
+ return undefined;
234
+ }
235
+ }
236
+
225
237
  /**
226
238
  * Look up a mnemopi memory row by id across every live session's scoped banks.
227
239
  * First hit wins; returns `null` when the id is not stored anywhere in scope.
@@ -290,6 +302,23 @@ export class MemoryProtocolHandler implements ProtocolHandler {
290
302
  // clipped recall preview before overwriting it (issue #4443).
291
303
  if (namespace !== MEMORY_NAMESPACE) {
292
304
  const mnemopiStates = mnemopiSessionStatesFromRegistry();
305
+ const hindsightActive =
306
+ memoryBackendFromContext(context) === "hindsight" ||
307
+ (mnemopiStates.length === 0 &&
308
+ AgentRegistry.global()
309
+ .list()
310
+ .some(ref => ref.session?.getHindsightSessionState?.()));
311
+ if (hindsightActive) {
312
+ // Hindsight keeps memories server-side and exposes no
313
+ // `memory://<id>` addressing, yet the shared `recall` tool
314
+ // description still steers a follow-up `read memory://<id>`.
315
+ // Return a corrective pointer so that stray read self-corrects in
316
+ // one turn instead of derailing on the generic namespace error
317
+ // (issue #7587).
318
+ throw new Error(
319
+ "Hindsight memories are not addressable via memory://. Recall results are final — use `recall` to search or `reflect` to synthesize. `read memory://<id>` is only available with memory.backend=mnemopi.",
320
+ );
321
+ }
293
322
  if (mnemopiStates.length === 0) {
294
323
  throw new Error(
295
324
  `Unknown memory namespace: ${namespace}. Supported: ${MEMORY_NAMESPACE} (file-backed memory summary), or a mnemopi memory id when memory.backend=mnemopi is active.`,
package/src/lsp/client.ts CHANGED
@@ -17,7 +17,7 @@ import type {
17
17
  ServerConfig,
18
18
  WorkspaceEdit,
19
19
  } from "./types";
20
- import { detectLanguageId, fileToUri } from "./utils";
20
+ import { detectLanguageId, EquivalentUriMap, fileToUri } from "./utils";
21
21
 
22
22
  // =============================================================================
23
23
  // Client State
@@ -787,7 +787,7 @@ export async function getOrCreateClient(
787
787
  proc,
788
788
  config,
789
789
  requestId: 0,
790
- diagnostics: new Map(),
790
+ diagnostics: new EquivalentUriMap(),
791
791
  diagnosticsVersion: 0,
792
792
  dynamicCapabilityRegistrations: new Map(),
793
793
  openFiles: new Map(),
package/src/lsp/utils.ts CHANGED
@@ -78,6 +78,35 @@ function laxUriToFile(uri: string): string {
78
78
  return filePath;
79
79
  }
80
80
 
81
+ /** Map that treats equivalent file URI spellings as the same key. */
82
+ export class EquivalentUriMap<Value> extends Map<string, Value> {
83
+ #key(uri: string): string {
84
+ if (!uri.startsWith("file://")) return uri;
85
+ const filePath = path.normalize(uriToFile(uri));
86
+ return process.platform === "win32" ? filePath.toLowerCase() : filePath;
87
+ }
88
+
89
+ override delete(uri: string): boolean {
90
+ const key = this.#key(uri);
91
+ return super.delete(key);
92
+ }
93
+
94
+ override get(uri: string): Value | undefined {
95
+ const key = this.#key(uri);
96
+ return super.get(key);
97
+ }
98
+
99
+ override has(uri: string): boolean {
100
+ const key = this.#key(uri);
101
+ return super.has(key);
102
+ }
103
+
104
+ override set(uri: string, value: Value): this {
105
+ const key = this.#key(uri);
106
+ return super.set(key, value);
107
+ }
108
+ }
109
+
81
110
  // =============================================================================
82
111
  // Diagnostic Formatting
83
112
  // =============================================================================
@@ -2312,7 +2312,7 @@ export class AcpAgent implements Agent {
2312
2312
  this.#clientCapabilities,
2313
2313
  );
2314
2314
  if (this.#clientCapabilities?.elicitation?.form != null) {
2315
- record.session.setUsageFallbackConfirmer(confirmation => {
2315
+ record.session.setUsageFallbackConfirmer((confirmation, signal) => {
2316
2316
  const reserve =
2317
2317
  confirmation.remainingPercent === undefined
2318
2318
  ? "inside the configured reserve margin"
@@ -2320,6 +2320,7 @@ export class AcpAgent implements Agent {
2320
2320
  return uiContext.confirm(
2321
2321
  "Coding-plan reserve reached",
2322
2322
  `${confirmation.from} has ${reserve}. Switch to ${confirmation.to}? Choose No to keep using the current plan.`,
2323
+ { signal },
2323
2324
  );
2324
2325
  });
2325
2326
  }