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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  3. package/dist/types/cli/update-cli.d.ts +15 -1
  4. package/dist/types/commands/dry-balance.d.ts +31 -0
  5. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  6. package/dist/types/config/model-registry.d.ts +5 -0
  7. package/dist/types/config/models-config-schema.d.ts +18 -0
  8. package/dist/types/config/settings-schema.d.ts +3 -3
  9. package/dist/types/config/settings.d.ts +11 -0
  10. package/dist/types/discovery/helpers.d.ts +1 -0
  11. package/dist/types/exa/mcp-client.d.ts +2 -1
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  13. package/dist/types/hindsight/bank.d.ts +17 -9
  14. package/dist/types/hindsight/mental-models.d.ts +1 -1
  15. package/dist/types/hindsight/state.d.ts +9 -3
  16. package/dist/types/mcp/json-rpc.d.ts +6 -1
  17. package/dist/types/mcp/manager.d.ts +1 -1
  18. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  19. package/dist/types/mnemopi/state.d.ts +2 -2
  20. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  21. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  22. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  23. package/dist/types/modes/components/session-selector.d.ts +8 -3
  24. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  27. package/dist/types/session/agent-session.d.ts +13 -1
  28. package/dist/types/session/auth-storage.d.ts +2 -2
  29. package/dist/types/session/history-storage.d.ts +3 -4
  30. package/dist/types/session/messages.d.ts +1 -0
  31. package/dist/types/session/session-manager.d.ts +1 -0
  32. package/dist/types/slash-commands/types.d.ts +17 -4
  33. package/dist/types/task/types.d.ts +2 -0
  34. package/dist/types/tiny/text.d.ts +17 -0
  35. package/dist/types/tools/index.d.ts +16 -0
  36. package/dist/types/tools/path-utils.d.ts +11 -0
  37. package/dist/types/web/search/providers/base.d.ts +14 -0
  38. package/dist/types/web/search/providers/exa.d.ts +9 -0
  39. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  40. package/dist/types/web/search/types.d.ts +2 -1
  41. package/package.json +9 -9
  42. package/src/cli/dry-balance-cli.ts +823 -0
  43. package/src/cli/session-picker.ts +1 -0
  44. package/src/cli/update-cli.ts +54 -2
  45. package/src/cli-commands.ts +1 -0
  46. package/src/commands/completions.ts +1 -1
  47. package/src/commands/dry-balance.ts +43 -0
  48. package/src/config/append-only-context-mode.ts +37 -0
  49. package/src/config/model-registry.ts +6 -0
  50. package/src/config/models-config-schema.ts +3 -0
  51. package/src/config/settings-schema.ts +2 -2
  52. package/src/config/settings.ts +38 -0
  53. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  54. package/src/discovery/github.ts +37 -1
  55. package/src/discovery/helpers.ts +3 -1
  56. package/src/exa/mcp-client.ts +11 -5
  57. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  58. package/src/hindsight/backend.ts +184 -35
  59. package/src/hindsight/bank.ts +32 -22
  60. package/src/hindsight/mental-models.ts +1 -1
  61. package/src/hindsight/state.ts +21 -7
  62. package/src/internal-urls/docs-index.generated.ts +5 -5
  63. package/src/internal-urls/omp-protocol.ts +8 -2
  64. package/src/main.ts +4 -2
  65. package/src/mcp/json-rpc.ts +8 -0
  66. package/src/mcp/manager.ts +40 -21
  67. package/src/mcp/render.ts +3 -0
  68. package/src/mcp/tool-bridge.ts +10 -2
  69. package/src/mcp/transports/http.ts +33 -16
  70. package/src/mnemopi/state.ts +4 -4
  71. package/src/modes/acp/acp-agent.ts +168 -3
  72. package/src/modes/components/agent-dashboard.ts +103 -31
  73. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  74. package/src/modes/components/history-search.ts +128 -14
  75. package/src/modes/components/plugin-settings.ts +270 -36
  76. package/src/modes/components/session-selector.ts +45 -14
  77. package/src/modes/components/settings-selector.ts +1 -1
  78. package/src/modes/components/tips.txt +5 -1
  79. package/src/modes/components/transcript-container.ts +35 -6
  80. package/src/modes/components/tree-selector.ts +29 -2
  81. package/src/modes/controllers/command-controller.ts +4 -3
  82. package/src/modes/controllers/input-controller.ts +18 -7
  83. package/src/modes/controllers/selector-controller.ts +30 -19
  84. package/src/modes/interactive-mode.ts +38 -3
  85. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  86. package/src/modes/utils/keybinding-matchers.ts +10 -0
  87. package/src/prompts/agents/explore.md +1 -0
  88. package/src/prompts/agents/librarian.md +1 -0
  89. package/src/prompts/dry-balance-bench.md +8 -0
  90. package/src/prompts/steering/user-interjection.md +10 -0
  91. package/src/prompts/system/agent-creation-architect.md +1 -26
  92. package/src/prompts/system/system-prompt.md +143 -145
  93. package/src/prompts/system/title-system.md +3 -2
  94. package/src/prompts/tools/browser.md +29 -29
  95. package/src/prompts/tools/render-mermaid.md +2 -2
  96. package/src/sdk.ts +87 -30
  97. package/src/session/agent-session.ts +96 -14
  98. package/src/session/auth-storage.ts +4 -0
  99. package/src/session/history-storage.ts +11 -18
  100. package/src/session/messages.ts +80 -0
  101. package/src/session/session-manager.ts +7 -1
  102. package/src/slash-commands/types.ts +27 -10
  103. package/src/task/executor.ts +6 -2
  104. package/src/task/index.ts +8 -7
  105. package/src/task/types.ts +2 -0
  106. package/src/tiny/text.ts +112 -1
  107. package/src/tools/bash.ts +3 -4
  108. package/src/tools/index.ts +16 -0
  109. package/src/tools/job.ts +3 -3
  110. package/src/tools/memory-recall.ts +1 -1
  111. package/src/tools/memory-reflect.ts +3 -3
  112. package/src/tools/path-utils.ts +21 -0
  113. package/src/tools/search.ts +18 -1
  114. package/src/tools/ssh.ts +26 -10
  115. package/src/tools/write.ts +14 -2
  116. package/src/tui/status-line.ts +15 -4
  117. package/src/utils/file-mentions.ts +7 -107
  118. package/src/utils/title-generator.ts +66 -38
  119. package/src/web/search/index.ts +3 -1
  120. package/src/web/search/provider.ts +1 -1
  121. package/src/web/search/providers/base.ts +17 -0
  122. package/src/web/search/providers/exa.ts +111 -7
  123. package/src/web/search/providers/perplexity.ts +8 -4
  124. package/src/web/search/types.ts +2 -1
@@ -31,7 +31,11 @@ export interface ParsedSlashCommand {
31
31
  /**
32
32
  * Result returned by a slash-command handler.
33
33
  *
34
- * - `void` / `undefined` — command was handled and consumed; no further input.
34
+ * - `undefined` (and the implicit `void` return) — command was handled and
35
+ * consumed; no further input. Handlers may simply omit a `return` rather than
36
+ * building `{ consumed: true }`; `void` is accepted in the handler signatures
37
+ * below so the contract typechecks under TypeScript 5.x (which does not
38
+ * coerce `() => void` to `() => T | undefined`) as well as 6.x / tsgo.
35
39
  * - `{ consumed: true }` — explicit equivalent of the above (ACP shape).
36
40
  * - `{ prompt: string }` — command handled, pass `prompt` through as the new
37
41
  * user input (e.g. `/force <tool> <prompt>` keeps `<prompt>` as the message).
@@ -100,20 +104,33 @@ export interface SlashCommandSpec extends BuiltinSlashCommand {
100
104
  /**
101
105
  * Text/ACP-mode handler. The same body is invoked from the ACP dispatcher
102
106
  * and, via the TUI adapter, when no `handleTui` override is provided.
107
+ *
108
+ * Expressed as a union of two function types — one returning a
109
+ * `SlashCommandResult`, one returning `void` — so handlers that simply
110
+ * `return` (or omit the return) typecheck under TypeScript 5.x as well as
111
+ * 6.x / tsgo. TS 5.x does not coerce a `() => void` value into a
112
+ * `() => T | undefined` slot, so the two return shapes must be siblings
113
+ * rather than a `T | void` union inside one function type (which Biome's
114
+ * `noConfusingVoidType` also rejects).
103
115
  */
104
- handle?: (
105
- command: ParsedSlashCommand,
106
- runtime: SlashCommandRuntime,
107
- ) => Promise<SlashCommandResult> | SlashCommandResult;
116
+ handle?:
117
+ | ((
118
+ command: ParsedSlashCommand,
119
+ runtime: SlashCommandRuntime,
120
+ ) => SlashCommandResult | Promise<SlashCommandResult>)
121
+ | ((command: ParsedSlashCommand, runtime: SlashCommandRuntime) => void | Promise<void>);
108
122
  /**
109
123
  * TUI-only handler that supersedes `handle` when both are present. Use for
110
124
  * selectors, wizards, dashboards, and anything else that requires
111
- * `InteractiveModeContext`.
125
+ * `InteractiveModeContext`. See `handle` for the rationale behind the
126
+ * function-type union shape.
112
127
  */
113
- handleTui?: (
114
- command: ParsedSlashCommand,
115
- runtime: TuiSlashCommandRuntime,
116
- ) => Promise<SlashCommandResult> | SlashCommandResult;
128
+ handleTui?:
129
+ | ((
130
+ command: ParsedSlashCommand,
131
+ runtime: TuiSlashCommandRuntime,
132
+ ) => SlashCommandResult | Promise<SlashCommandResult>)
133
+ | ((command: ParsedSlashCommand, runtime: TuiSlashCommandRuntime) => void | Promise<void>);
117
134
  }
118
135
 
119
136
  /** Result returned by `executeAcpBuiltinSlashCommand`. */
@@ -531,7 +531,7 @@ function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
531
531
  });
532
532
  }
533
533
 
534
- function createSubagentSettings(baseSettings: Settings): Settings {
534
+ function createSubagentSettings(baseSettings: Settings, overrides?: Partial<Record<SettingPath, unknown>>): Settings {
535
535
  const snapshot: Partial<Record<SettingPath, unknown>> = {};
536
536
  for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
537
537
  snapshot[key] = baseSettings.get(key);
@@ -545,6 +545,7 @@ function createSubagentSettings(baseSettings: Settings): Settings {
545
545
  // the parent task approval is the authorization boundary. Use yolo mode
546
546
  // to preserve unattended subagent execution. User `tools.approval` policies still apply.
547
547
  "tools.approvalMode": "yolo",
548
+ ...overrides,
548
549
  });
549
550
  }
550
551
 
@@ -619,7 +620,10 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
619
620
  }
620
621
 
621
622
  const settings = options.settings ?? Settings.isolated();
622
- const subagentSettings = createSubagentSettings(settings);
623
+ const subagentSettings = createSubagentSettings(
624
+ settings,
625
+ agent.readSummarize === false ? { "read.summarize.enabled": false } : undefined,
626
+ );
623
627
  const maxRecursionDepth = settings.get("task.maxRecursionDepth") ?? 2;
624
628
  const maxRuntimeMs = Math.max(0, Math.trunc(Number(settings.get("task.maxRuntimeMs") ?? 0) || 0));
625
629
  const parentDepth = options.taskDepth ?? 0;
package/src/task/index.ts CHANGED
@@ -17,9 +17,8 @@ import * as os from "node:os";
17
17
  import path from "node:path";
18
18
  import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import type { Usage } from "@oh-my-pi/pi-ai";
20
- import { $env, prompt, Snowflake } from "@oh-my-pi/pi-utils";
20
+ import { $env, logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
21
21
  import type { ToolSession } from "..";
22
- import { AsyncJobManager } from "../async";
23
22
  import { resolveAgentModelPatterns } from "../config/model-resolver";
24
23
  import { MCPManager } from "../mcp/manager";
25
24
  import type { Theme } from "../modes/theme/theme";
@@ -343,12 +342,14 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
343
342
  return this.#executeSync(_toolCallId, params, signal, onUpdate);
344
343
  }
345
344
 
346
- const manager = AsyncJobManager.instance();
345
+ const manager = this.session.asyncJobManager;
347
346
  if (!manager) {
348
- return {
349
- content: [{ type: "text", text: "Async execution is enabled but no async job manager is available." }],
350
- details: { projectAgentsDir: null, results: [], totalDurationMs: 0 },
351
- };
347
+ // Async was requested but no manager is registered (e.g. an
348
+ // orphaned session whose host never wired one up). Falling back
349
+ // to the sync path keeps the tool usable; only background/job-poll
350
+ // semantics are lost.
351
+ logger.warn("task: async.enabled but no AsyncJobManager registered; falling back to sync execution");
352
+ return this.#executeSync(_toolCallId, params, signal, onUpdate);
352
353
  }
353
354
 
354
355
  const taskItems = params.tasks ?? [];
package/src/task/types.ts CHANGED
@@ -174,6 +174,8 @@ export interface AgentDefinition {
174
174
  output?: unknown;
175
175
  blocking?: boolean;
176
176
  autoloadSkills?: string[];
177
+ /** When `false`, the agent's `read` tool returns verbatim file content instead of structural summaries. */
178
+ readSummarize?: boolean;
177
179
  source: AgentSource;
178
180
  filePath?: string;
179
181
  }
package/src/tiny/text.ts CHANGED
@@ -43,6 +43,116 @@ export function formatTitleUserMessage(message: string): string {
43
43
  return `<user-message>\n${prepareTitleInput(message)}\n</user-message>`;
44
44
  }
45
45
 
46
+ /**
47
+ * Greeting / acknowledgement / filler tokens. A first user message composed
48
+ * entirely of these (or of bare numbers / punctuation / emoji) carries no
49
+ * concrete task, so titling is deferred to a later message instead of latching
50
+ * onto "hi". See {@link isLowSignalTitleInput}.
51
+ */
52
+ const FILLER_TITLE_TOKENS = new Set<string>([
53
+ // greetings
54
+ "hi",
55
+ "hii",
56
+ "hiii",
57
+ "hiya",
58
+ "hey",
59
+ "heya",
60
+ "hello",
61
+ "helo",
62
+ "hullo",
63
+ "yo",
64
+ "ya",
65
+ "sup",
66
+ "wassup",
67
+ "whatsup",
68
+ "howdy",
69
+ "greetings",
70
+ "hola",
71
+ "ciao",
72
+ "aloha",
73
+ "gm",
74
+ "gn",
75
+ "good",
76
+ "morning",
77
+ "afternoon",
78
+ "evening",
79
+ "night",
80
+ "day",
81
+ // politeness / acknowledgement
82
+ "thanks",
83
+ "thank",
84
+ "thx",
85
+ "ty",
86
+ "tysm",
87
+ "cheers",
88
+ "please",
89
+ "pls",
90
+ "plz",
91
+ "ok",
92
+ "okay",
93
+ "okey",
94
+ "k",
95
+ "kk",
96
+ "yep",
97
+ "yes",
98
+ "yeah",
99
+ "yup",
100
+ "nope",
101
+ "no",
102
+ "nah",
103
+ "sure",
104
+ "cool",
105
+ "nice",
106
+ "great",
107
+ "awesome",
108
+ "perfect",
109
+ "lol",
110
+ "lmao",
111
+ "haha",
112
+ "hehe",
113
+ // poking the agent / fillers
114
+ "test",
115
+ "tests",
116
+ "testing",
117
+ "ping",
118
+ "pong",
119
+ "there",
120
+ "you",
121
+ "u",
122
+ "hmm",
123
+ "hmmm",
124
+ "um",
125
+ "uh",
126
+ "so",
127
+ "well",
128
+ "anyway",
129
+ ]);
130
+
131
+ const TITLE_WORD = /[\p{L}\p{N}]+/gu;
132
+
133
+ /**
134
+ * True when a first user message is too low-signal to title (greeting, ack,
135
+ * bare number, or empty once code/punctuation/emoji are stripped).
136
+ *
137
+ * Deterministic pre-filter: the default tiny title model (~350M local) cannot
138
+ * reliably follow a "respond with none" instruction and tends to hallucinate a
139
+ * title for trivial input, so we never ask it — the caller defers titling to
140
+ * the next message instead.
141
+ */
142
+ export function isLowSignalTitleInput(message: string): boolean {
143
+ const tokens = stripCodeBlocks(message).toLowerCase().match(TITLE_WORD);
144
+ if (!tokens) return true;
145
+ return tokens.every(token => FILLER_TITLE_TOKENS.has(token) || /^\d+$/.test(token));
146
+ }
147
+
148
+ /**
149
+ * Sentinel a capable title model may emit when a message carries no concrete
150
+ * task. Treated as "no title yet" so the caller can defer titling. Backstop for
151
+ * the deterministic {@link isLowSignalTitleInput} filter; kept in sync with the
152
+ * `none` instruction in `prompts/system/title-system.md`.
153
+ */
154
+ export const NO_TITLE_SENTINEL = "none";
155
+
46
156
  export function normalizeGeneratedTitle(value: string | null | undefined): string | null {
47
157
  const firstLine = value?.trim().split(/\r?\n/, 1)[0]?.trim();
48
158
  if (!firstLine) return null;
@@ -50,5 +160,6 @@ export function normalizeGeneratedTitle(value: string | null | undefined): strin
50
160
  .replace(/^["']|["']$/g, "")
51
161
  .replace(/[.!?]$/, "")
52
162
  .trim();
53
- return title || null;
163
+ if (!title || title.toLowerCase() === NO_TITLE_SENTINEL) return null;
164
+ return title;
54
165
  }
package/src/tools/bash.ts CHANGED
@@ -10,7 +10,6 @@ import type { Component } from "@oh-my-pi/pi-tui";
10
10
  import { ImageProtocol, TERMINAL } from "@oh-my-pi/pi-tui";
11
11
  import { getProjectDir, isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
12
12
  import * as z from "zod/v4";
13
- import { AsyncJobManager } from "../async";
14
13
  import { type BashResult, executeBash } from "../exec/bash-executor";
15
14
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
16
15
  import { InternalUrlRouter } from "../internal-urls";
@@ -489,7 +488,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
489
488
  onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
490
489
  startBackgrounded: boolean;
491
490
  }): ManagedBashJobHandle {
492
- const manager = AsyncJobManager.instance();
491
+ const manager = this.session.asyncJobManager;
493
492
  if (!manager) {
494
493
  throw new ToolError("Background job manager unavailable for this session.");
495
494
  }
@@ -716,7 +715,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
716
715
  if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
717
716
 
718
717
  if (asyncRequested) {
719
- if (!AsyncJobManager.instance()) {
718
+ if (!this.session.asyncJobManager) {
720
719
  throw new ToolError("Async job manager unavailable for this session.");
721
720
  }
722
721
  const job = this.#startManagedBashJob({
@@ -737,7 +736,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
737
736
  });
738
737
  }
739
738
 
740
- const autoBgManager = AsyncJobManager.instance();
739
+ const autoBgManager = this.session.asyncJobManager;
741
740
  if (this.#autoBackgroundEnabled && !pty && autoBgManager) {
742
741
  const autoBackgroundWaitMs = this.#resolveAutoBackgroundWaitMs(timeoutMs);
743
742
  const startBackgrounded = autoBackgroundWaitMs === 0;
@@ -2,6 +2,7 @@ import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import type { ToolChoice } from "@oh-my-pi/pi-ai";
4
4
  import { logger } from "@oh-my-pi/pi-utils";
5
+ import type { AsyncJobManager } from "../async/job-manager";
5
6
  import type { PromptTemplate } from "../config/prompt-templates";
6
7
  import type { Settings } from "../config/settings";
7
8
  import { EditTool } from "../edit";
@@ -183,6 +184,21 @@ export interface ToolSession {
183
184
  modelRegistry?: import("../config/model-registry").ModelRegistry;
184
185
  /** Agent output manager for unique agent:// IDs across task invocations */
185
186
  agentOutputManager?: AgentOutputManager;
187
+ /**
188
+ * Async job manager scoped to this session.
189
+ *
190
+ * - Top-level session that constructed one: its own manager.
191
+ * - Subagent (`parentTaskPrefix` set): the parent's manager, so background
192
+ * bash/task work and `onJobComplete` deliveries flow into the conversation
193
+ * that spawned it.
194
+ * - Secondary in-process top-level session that found a singleton already
195
+ * installed (issue #1923): `undefined`. Tools refuse async work rather
196
+ * than silently route completions into the owning session's `yieldQueue`.
197
+ *
198
+ * Tools MUST use this instead of `AsyncJobManager.instance()` so a secondary
199
+ * session never borrows the owning session's manager by accident.
200
+ */
201
+ asyncJobManager?: AsyncJobManager;
186
202
  /** MCP manager visible to subagents without relying on the process-global singleton. */
187
203
  mcpManager?: MCPManager;
188
204
  /** Local protocol root to propagate to nested subagents and eval-created agents. */
package/src/tools/job.ts CHANGED
@@ -3,7 +3,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
3
3
  import { Text } from "@oh-my-pi/pi-tui";
4
4
  import { prompt } from "@oh-my-pi/pi-utils";
5
5
  import * as z from "zod/v4";
6
- import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled } from "../async";
6
+ import { type AsyncJob, type AsyncJobManager, isBackgroundJobSupportEnabled } from "../async";
7
7
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
8
8
  import type { Theme } from "../modes/theme/theme";
9
9
  import jobDescription from "../prompts/tools/job.md" with { type: "text" };
@@ -90,7 +90,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
90
90
  onUpdate?: AgentToolUpdateCallback<JobToolDetails>,
91
91
  _context?: AgentToolContext,
92
92
  ): Promise<AgentToolResult<JobToolDetails>> {
93
- const manager = AsyncJobManager.instance();
93
+ const manager = this.session.asyncJobManager;
94
94
  if (!manager) {
95
95
  return {
96
96
  content: [{ type: "text", text: "Async execution is disabled; no background jobs are available." }],
@@ -254,7 +254,7 @@ export class JobTool implements AgentTool<typeof jobSchema, JobToolDetails> {
254
254
  ): JobSnapshot[] {
255
255
  const now = Date.now();
256
256
  return jobs.map(j => {
257
- const current = AsyncJobManager.instance()?.getJob(j.id);
257
+ const current = this.session.asyncJobManager?.getJob(j.id);
258
258
  const latest = current ?? j;
259
259
  return {
260
260
  id: latest.id,
@@ -38,7 +38,7 @@ export class MemoryRecallTool implements AgentTool<typeof memoryRecallSchema> {
38
38
  throw new Error("Mnemopi backend is not initialised for this session.");
39
39
  }
40
40
  try {
41
- const results = state.recallResultsScoped(params.query);
41
+ const results = await state.recallResultsScoped(params.query);
42
42
  if (results.length === 0) {
43
43
  return {
44
44
  content: [{ type: "text", text: "No relevant memories found." }],
@@ -1,7 +1,7 @@
1
1
  import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
2
  import { logger, untilAborted } from "@oh-my-pi/pi-utils";
3
3
  import * as z from "zod/v4";
4
- import { ensureBankMission } from "../hindsight/bank";
4
+ import { ensureBankExists } from "../hindsight/bank";
5
5
  import reflectDescription from "../prompts/tools/reflect.md" with { type: "text" };
6
6
  import type { ToolSession } from ".";
7
7
 
@@ -43,7 +43,7 @@ export class MemoryReflectTool implements AgentTool<typeof memoryReflectSchema>
43
43
  const query = params.context?.trim()
44
44
  ? `${params.query.trim()}\n\nAdditional context:\n${params.context.trim()}`
45
45
  : params.query;
46
- const results = state.recallResultsScoped(query);
46
+ const results = await state.recallResultsScoped(query);
47
47
  if (results.length === 0) {
48
48
  return {
49
49
  content: [{ type: "text", text: "No relevant information found to reflect on." }],
@@ -67,7 +67,7 @@ export class MemoryReflectTool implements AgentTool<typeof memoryReflectSchema>
67
67
  }
68
68
 
69
69
  try {
70
- await ensureBankMission(state.client, state.bankId, state.config, state.missionsSet);
70
+ await ensureBankExists(state.client, state.bankId, state.config, state.banksSet);
71
71
  const response = await state.client.reflect(state.bankId, params.query, {
72
72
  context: params.context,
73
73
  budget: state.config.recallBudget,
@@ -217,6 +217,27 @@ export function parseLineRanges(sel: string): [LineRange, ...LineRange[]] | null
217
217
  return merged as [LineRange, ...LineRange[]];
218
218
  }
219
219
 
220
+ /**
221
+ * Extract the line-range component from a read-tool selector that may also
222
+ * carry a verbatim/index display mode (`raw`, `conflicts`) — alone or compounded
223
+ * with a range (`raw:50-100`, `50-100:raw`). Returns the parsed ranges when the
224
+ * selector names any, otherwise `undefined` (pure `raw`/`conflicts`/none).
225
+ *
226
+ * Used by content search, which honors line ranges as a match filter but has no
227
+ * use for verbatim/conflict display modes — so those selectors are accepted and
228
+ * treated as an unfiltered, whole-resource search rather than rejected.
229
+ */
230
+ export function selectorLineRanges(sel: string | undefined): [LineRange, ...LineRange[]] | undefined {
231
+ if (!sel) return undefined;
232
+ for (const chunk of sel.split(":")) {
233
+ const lower = chunk.toLowerCase();
234
+ if (lower === "raw" || lower === "conflicts") continue;
235
+ const ranges = parseLineRanges(chunk);
236
+ if (ranges) return ranges;
237
+ }
238
+ return undefined;
239
+ }
240
+
220
241
  /** Return `true` when `lineNumber` (1-indexed) falls in any of the supplied ranges. */
221
242
  export function isLineInRanges(lineNumber: number, ranges: readonly LineRange[]): boolean {
222
243
  for (const range of ranges) {
@@ -38,6 +38,8 @@ import {
38
38
  type ResolvedSearchTarget,
39
39
  resolveReadPath,
40
40
  resolveToolSearchScope,
41
+ selectorLineRanges,
42
+ splitInternalUrlSel,
41
43
  splitPathAndSel,
42
44
  } from "./path-utils";
43
45
  import {
@@ -109,6 +111,21 @@ interface SearchPathSpec {
109
111
  function parsePathSpecs(rawEntries: readonly string[]): SearchPathSpec[] {
110
112
  const specs: SearchPathSpec[] = [];
111
113
  for (const entry of rawEntries) {
114
+ // Internal URLs (`artifact://`, `skill://`, …) use the URL-aware splitter,
115
+ // which peels selector-shaped tails only for selector-capable schemes and
116
+ // leaves opaque ones (`mcp://`) intact. Unlike filesystem paths, their
117
+ // verbatim/index display modes (`raw`, `conflicts`) carry no meaning for
118
+ // content search, so we accept them — searching the whole resource — and
119
+ // still honor any embedded line range as a match filter.
120
+ const internalSplit = splitInternalUrlSel(entry);
121
+ if (internalSplit.sel !== undefined) {
122
+ specs.push({
123
+ original: entry,
124
+ clean: internalSplit.path,
125
+ ranges: selectorLineRanges(internalSplit.sel),
126
+ });
127
+ continue;
128
+ }
112
129
  const split = splitPathAndSel(entry);
113
130
  let clean = entry;
114
131
  let ranges: [LineRange, ...LineRange[]] | undefined;
@@ -259,7 +276,7 @@ interface IndexedContentLines {
259
276
  }
260
277
 
261
278
  const INTERNAL_URL_DISPLAY_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
262
- const OMP_ROOT_URL_RE = /^omp:\/\/\/?$/i;
279
+ const OMP_ROOT_URL_RE = /^omp:\/\/(?:\/?|docs\/?)$/i;
263
280
 
264
281
  function normalizeSearchLine(line: string): string {
265
282
  return line.endsWith("\r") ? line.slice(0, -1) : line;
package/src/tools/ssh.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Component } from "@oh-my-pi/pi-tui";
3
- import { Text } from "@oh-my-pi/pi-tui";
4
3
  import { prompt } from "@oh-my-pi/pi-utils";
5
4
  import * as z from "zod/v4";
6
5
  import type { SSHHost } from "../capability/ssh";
@@ -18,6 +17,7 @@ import { CachedOutputBlock } from "../tui/output-block";
18
17
  import type { ToolSession } from ".";
19
18
  import { truncateForPrompt } from "./approval";
20
19
  import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
20
+ import { replaceTabs } from "./render-utils";
21
21
  import { ToolError } from "./tool-errors";
22
22
  import { toolResult } from "./tool-result";
23
23
  import { clampTimeout } from "./tool-timeouts";
@@ -230,12 +230,30 @@ interface SshRenderContext {
230
230
  totalVisualLines?: number;
231
231
  }
232
232
 
233
+ function formatSshCommandLines(command: string, uiTheme: Theme): string[] {
234
+ const sanitized = replaceTabs(command);
235
+ const rawLines = sanitized.length > 0 ? sanitized.split("\n") : ["…"];
236
+ const prefix = uiTheme.fg("dim", "$ ");
237
+ return rawLines.map((line, i) => (i === 0 ? `${prefix}${line}` : line));
238
+ }
239
+
233
240
  export const sshToolRenderer = {
234
241
  renderCall(args: SshRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
235
242
  const host = args.host || "…";
236
- const command = args.command || "";
237
- const text = renderStatusLine({ icon: "pending", title: "SSH", description: `[${host}] $ ${command}` }, uiTheme);
238
- return new Text(text, 0, 0);
243
+ const command = args.command ?? "";
244
+ const header = renderStatusLine({ icon: "pending", title: "SSH", description: `[${host}]` }, uiTheme);
245
+ const cmdLines = formatSshCommandLines(command, uiTheme);
246
+ const outputBlock = new CachedOutputBlock();
247
+ return {
248
+ render: (width: number): string[] =>
249
+ outputBlock.render(
250
+ { header, state: "pending", sections: [{ lines: cmdLines }], width, animate: true },
251
+ uiTheme,
252
+ ),
253
+ invalidate: () => {
254
+ outputBlock.invalidate();
255
+ },
256
+ };
239
257
  },
240
258
 
241
259
  renderResult(
@@ -249,11 +267,9 @@ export const sshToolRenderer = {
249
267
  ): Component {
250
268
  const details = result.details;
251
269
  const host = args?.host || "…";
252
- const command = args?.command || "";
253
- const header = renderStatusLine(
254
- { icon: "success", title: "SSH", description: `[${host}] $ ${command}` },
255
- uiTheme,
256
- );
270
+ const command = args?.command ?? "";
271
+ const header = renderStatusLine({ icon: "success", title: "SSH", description: `[${host}]` }, uiTheme);
272
+ const cmdLines = formatSshCommandLines(command, uiTheme);
257
273
  const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
258
274
  const outputBlock = new CachedOutputBlock();
259
275
 
@@ -303,7 +319,7 @@ export const sshToolRenderer = {
303
319
  {
304
320
  header,
305
321
  state: "success",
306
- sections: [{ label: uiTheme.fg("toolTitle", "Output"), lines: outputLines }],
322
+ sections: [{ lines: cmdLines }, { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines }],
307
323
  width,
308
324
  },
309
325
  uiTheme,
@@ -135,6 +135,15 @@ function maybeWriteSnapshotHeader(session: ToolSession, absolutePath: string, co
135
135
  return formatHashlineHeader(formatPathRelativeToCwd(absolutePath, session.cwd), tag);
136
136
  }
137
137
 
138
+ function shouldRouteWriteThroughBridge(session: ToolSession, requestedPath: string, absolutePath: string): boolean {
139
+ if (isInternalUrlPath(requestedPath)) return false;
140
+
141
+ const state = session.getPlanModeState?.();
142
+ if (!state?.enabled || !isInternalUrlPath(state.planFilePath)) return true;
143
+
144
+ return absolutePath !== resolvePlanPath(session, state.planFilePath);
145
+ }
146
+
138
147
  /**
139
148
  * Append a trailing note line to the first text block of a tool result.
140
149
  * Mutates `result` in place (the result object is owned by this call).
@@ -845,8 +854,11 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
845
854
  await assertEditableFile(absolutePath, path);
846
855
  }
847
856
 
848
- // Try ACP bridge first no disk write when client handles it
849
- const bridgePromise = this.#routeWriteThroughBridge(absolutePath, cleanContent);
857
+ // Try ACP bridge first for editor-visible filesystem paths. Internal
858
+ // artifacts such as local:// plans are owned by OMP, not the editor.
859
+ const bridgePromise = shouldRouteWriteThroughBridge(this.session, path, absolutePath)
860
+ ? this.#routeWriteThroughBridge(absolutePath, cleanContent)
861
+ : undefined;
850
862
  if (bridgePromise !== undefined) {
851
863
  try {
852
864
  await bridgePromise;
@@ -15,22 +15,33 @@ export interface StatusLineOptions {
15
15
  meta?: string[];
16
16
  }
17
17
 
18
+ /**
19
+ * Flatten CR/LF runs in caller-supplied header fragments so a single newline
20
+ * embedded in `description` or `meta` cannot expand the status line into
21
+ * multiple rows — which would otherwise break the bordered output block the
22
+ * header sits on. Tab characters are left alone; tool renderers that need
23
+ * tab-safe text run `replaceTabs()` themselves.
24
+ */
25
+ function flattenForHeader(text: string): string {
26
+ return text.replace(/\r\n?|\n/g, " ");
27
+ }
28
+
18
29
  export function renderStatusLine(options: StatusLineOptions, theme: Theme): string {
19
30
  const icon = options.icon ? formatStatusIcon(options.icon, theme, options.spinnerFrame) : "";
20
31
  const titleColor = options.titleColor ?? "accent";
21
- const title = theme.fg(titleColor, options.title);
32
+ const title = theme.fg(titleColor, flattenForHeader(options.title));
22
33
  let line = icon ? `${icon} ${title}` : title;
23
34
 
24
35
  if (options.description) {
25
- line += `: ${theme.fg("muted", options.description)}`;
36
+ line += `: ${theme.fg("muted", flattenForHeader(options.description))}`;
26
37
  }
27
38
 
28
39
  if (options.badge) {
29
40
  const { label, color } = options.badge;
30
- line += ` ${theme.fg(color, `${theme.format.bracketLeft}${label}${theme.format.bracketRight}`)}`;
41
+ line += ` ${theme.fg(color, `${theme.format.bracketLeft}${flattenForHeader(label)}${theme.format.bracketRight}`)}`;
31
42
  }
32
43
 
33
- const meta = options.meta?.filter(value => value.trim().length > 0) ?? [];
44
+ const meta = options.meta?.map(flattenForHeader).filter(value => value.trim().length > 0) ?? [];
34
45
  if (meta.length > 0) {
35
46
  line += ` ${theme.fg("dim", meta.join(theme.sep.dot))}`;
36
47
  }