@oh-my-pi/pi-coding-agent 16.1.22 → 16.2.0

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 (276) hide show
  1. package/CHANGELOG.md +99 -1
  2. package/dist/cli.js +4387 -4164
  3. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +1 -0
  4. package/dist/types/advisor/emission-guard.d.ts +73 -0
  5. package/dist/types/advisor/index.d.ts +1 -0
  6. package/dist/types/advisor/runtime.d.ts +7 -0
  7. package/dist/types/advisor/watchdog.d.ts +2 -0
  8. package/dist/types/cli/flag-tables.d.ts +1 -0
  9. package/dist/types/cli/gallery-fixtures/search.d.ts +1 -1
  10. package/dist/types/cli/gc-cli.d.ts +58 -0
  11. package/dist/types/collab/display-name.d.ts +3 -0
  12. package/dist/types/collab/host.d.ts +0 -2
  13. package/dist/types/commands/gc.d.ts +37 -0
  14. package/dist/types/commands/worktree.d.ts +0 -3
  15. package/dist/types/config/inline-tool-descriptors-mode.d.ts +13 -0
  16. package/dist/types/config/model-discovery.d.ts +6 -1
  17. package/dist/types/config/model-registry.d.ts +6 -2
  18. package/dist/types/config/model-resolver.d.ts +12 -0
  19. package/dist/types/config/models-config-schema.d.ts +29 -2
  20. package/dist/types/config/models-config.d.ts +22 -1
  21. package/dist/types/config/settings-schema.d.ts +145 -21
  22. package/dist/types/config/settings.d.ts +13 -0
  23. package/dist/types/dap/config.d.ts +1 -1
  24. package/dist/types/edit/hashline/filesystem.d.ts +4 -2
  25. package/dist/types/edit/renderer.d.ts +4 -0
  26. package/dist/types/extensibility/extensions/types.d.ts +17 -17
  27. package/dist/types/extensibility/hooks/types.d.ts +10 -10
  28. package/dist/types/extensibility/plugins/marketplace/types.d.ts +1 -0
  29. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +1 -0
  30. package/dist/types/internal-urls/index.d.ts +1 -0
  31. package/dist/types/internal-urls/registry-helpers.d.ts +2 -0
  32. package/dist/types/internal-urls/router.d.ts +1 -1
  33. package/dist/types/internal-urls/ssh-protocol.d.ts +10 -0
  34. package/dist/types/internal-urls/types.d.ts +19 -2
  35. package/dist/types/irc/bus.d.ts +6 -0
  36. package/dist/types/mcp/transports/stdio.d.ts +25 -1
  37. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +1 -0
  38. package/dist/types/modes/components/custom-editor.d.ts +7 -0
  39. package/dist/types/modes/components/move-overlay.d.ts +23 -0
  40. package/dist/types/modes/components/plugin-selector.d.ts +2 -1
  41. package/dist/types/modes/components/queue-mode-selector.d.ts +2 -1
  42. package/dist/types/modes/components/select-list-mouse-routing.d.ts +2 -0
  43. package/dist/types/modes/components/settings-defs.d.ts +4 -1
  44. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  45. package/dist/types/modes/components/show-images-selector.d.ts +2 -1
  46. package/dist/types/modes/components/status-line/component.d.ts +2 -0
  47. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  48. package/dist/types/modes/components/theme-selector.d.ts +2 -1
  49. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  50. package/dist/types/modes/controllers/command-controller.d.ts +10 -1
  51. package/dist/types/modes/controllers/streaming-reveal.d.ts +6 -0
  52. package/dist/types/modes/interactive-mode.d.ts +10 -1
  53. package/dist/types/modes/internal-url-autocomplete.d.ts +1 -1
  54. package/dist/types/modes/running-subagent-badge.d.ts +6 -0
  55. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +1 -0
  56. package/dist/types/modes/theme/theme.d.ts +2 -1
  57. package/dist/types/modes/types.d.ts +9 -1
  58. package/dist/types/sdk.d.ts +2 -2
  59. package/dist/types/session/agent-session.d.ts +2 -1
  60. package/dist/types/session/session-listing.d.ts +10 -1
  61. package/dist/types/session/session-manager.d.ts +13 -0
  62. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  63. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +1 -0
  64. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +1 -0
  65. package/dist/types/ssh/connection-manager.d.ts +2 -0
  66. package/dist/types/ssh/file-transfer.d.ts +79 -0
  67. package/dist/types/ssh/utils.d.ts +6 -0
  68. package/dist/types/system-prompt.d.ts +5 -0
  69. package/dist/types/task/executor.d.ts +16 -0
  70. package/dist/types/tiny/text.d.ts +1 -1
  71. package/dist/types/tools/builtin-names.d.ts +5 -1
  72. package/dist/types/tools/{find.d.ts → glob.d.ts} +15 -15
  73. package/dist/types/tools/{search.d.ts → grep.d.ts} +14 -14
  74. package/dist/types/tools/index.d.ts +3 -3
  75. package/dist/types/tools/path-utils.d.ts +29 -0
  76. package/dist/types/tools/plan-mode-guard.d.ts +7 -0
  77. package/dist/types/tools/read.d.ts +2 -2
  78. package/dist/types/tools/render-utils.d.ts +8 -0
  79. package/dist/types/tools/renderers.d.ts +11 -0
  80. package/dist/types/tools/ssh.d.ts +2 -0
  81. package/dist/types/tools/todo.d.ts +0 -16
  82. package/dist/types/tools/write.d.ts +2 -2
  83. package/dist/types/utils/active-repo-context.d.ts +8 -0
  84. package/dist/types/utils/image-resize.d.ts +1 -0
  85. package/dist/types/utils/markit-cache.d.ts +23 -0
  86. package/dist/types/utils/markit.d.ts +5 -1
  87. package/dist/types/utils/prompt-path.d.ts +1 -0
  88. package/dist/types/web/search/providers/duckduckgo.d.ts +14 -0
  89. package/dist/types/web/search/providers/firecrawl.d.ts +28 -0
  90. package/dist/types/web/search/providers/tinyfish.d.ts +29 -0
  91. package/dist/types/web/search/providers/xai.d.ts +13 -0
  92. package/dist/types/web/search/types.d.ts +18 -2
  93. package/package.json +30 -15
  94. package/scripts/bench-guard.ts +1 -1
  95. package/scripts/build-binary.ts +9 -9
  96. package/scripts/bundle-dist.ts +2 -2
  97. package/src/advisor/__tests__/advisor.test.ts +40 -4
  98. package/src/advisor/__tests__/emission-guard.test.ts +147 -0
  99. package/src/advisor/advise-tool.ts +1 -1
  100. package/src/advisor/emission-guard.ts +172 -0
  101. package/src/advisor/index.ts +1 -0
  102. package/src/advisor/runtime.ts +11 -0
  103. package/src/advisor/watchdog.ts +12 -1
  104. package/src/cli/args.ts +5 -2
  105. package/src/cli/auth-broker-cli.ts +17 -0
  106. package/src/cli/config-cli.ts +4 -0
  107. package/src/cli/flag-tables.ts +7 -4
  108. package/src/cli/gallery-cli.ts +14 -2
  109. package/src/cli/gallery-fixtures/edit.ts +60 -0
  110. package/src/cli/gallery-fixtures/fs.ts +17 -17
  111. package/src/cli/gallery-fixtures/search.ts +4 -4
  112. package/src/cli/gc-cli.ts +939 -0
  113. package/src/cli/usage-cli.ts +20 -1
  114. package/src/cli/web-search-cli.ts +1 -1
  115. package/src/cli-commands.ts +1 -0
  116. package/src/collab/display-name.ts +13 -0
  117. package/src/collab/guest.ts +5 -1
  118. package/src/collab/host.ts +2 -13
  119. package/src/commands/gc.ts +46 -0
  120. package/src/commands/worktree.ts +6 -0
  121. package/src/config/inline-tool-descriptors-mode.ts +27 -0
  122. package/src/config/model-discovery.ts +77 -8
  123. package/src/config/model-registry.ts +89 -11
  124. package/src/config/model-resolver.ts +39 -1
  125. package/src/config/models-config-schema.ts +41 -5
  126. package/src/config/models-config.ts +4 -1
  127. package/src/config/settings-schema.ts +130 -27
  128. package/src/config/settings.ts +216 -7
  129. package/src/cursor.ts +1 -1
  130. package/src/dap/config.ts +152 -8
  131. package/src/dap/session.ts +1 -1
  132. package/src/discovery/helpers.ts +3 -1
  133. package/src/edit/hashline/diff.ts +14 -3
  134. package/src/edit/hashline/execute.ts +42 -6
  135. package/src/edit/hashline/filesystem.ts +49 -8
  136. package/src/edit/index.ts +1 -0
  137. package/src/edit/modes/patch.ts +11 -1
  138. package/src/edit/renderer.ts +140 -13
  139. package/src/eval/__tests__/agent-bridge.test.ts +101 -0
  140. package/src/eval/__tests__/julia-prelude.test.ts +18 -0
  141. package/src/eval/agent-bridge.ts +26 -3
  142. package/src/eval/jl/runner.jl +7 -1
  143. package/src/eval/js/tool-bridge.ts +2 -2
  144. package/src/export/html/index.ts +1 -1
  145. package/src/export/html/template.js +3 -1
  146. package/src/export/html/tool-views.generated.js +20 -20
  147. package/src/extensibility/extensions/types.ts +22 -22
  148. package/src/extensibility/hooks/types.ts +11 -11
  149. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +10 -3
  150. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +31 -10
  151. package/src/extensibility/plugins/marketplace/manager.ts +22 -0
  152. package/src/extensibility/plugins/marketplace/types.ts +1 -0
  153. package/src/internal-urls/__tests__/ssh-protocol.test.ts +331 -0
  154. package/src/internal-urls/docs-index.generated.txt +2 -2
  155. package/src/internal-urls/docs-index.ts +2 -3
  156. package/src/internal-urls/index.ts +1 -0
  157. package/src/internal-urls/registry-helpers.ts +19 -4
  158. package/src/internal-urls/router.ts +5 -3
  159. package/src/internal-urls/ssh-protocol.ts +367 -0
  160. package/src/internal-urls/types.ts +19 -2
  161. package/src/irc/bus.ts +11 -3
  162. package/src/lsp/index.ts +8 -1
  163. package/src/mcp/oauth-discovery.ts +20 -20
  164. package/src/mcp/tool-bridge.ts +32 -2
  165. package/src/mcp/transports/stdio.test.ts +20 -3
  166. package/src/mcp/transports/stdio.ts +45 -14
  167. package/src/memories/index.ts +1 -1
  168. package/src/modes/acp/acp-event-mapper.ts +2 -2
  169. package/src/modes/components/__tests__/move-overlay.test.ts +166 -0
  170. package/src/modes/components/agent-hub.ts +3 -0
  171. package/src/modes/components/agent-transcript-viewer.ts +9 -7
  172. package/src/modes/components/custom-editor.ts +90 -5
  173. package/src/modes/components/move-overlay.ts +282 -0
  174. package/src/modes/components/plan-review-overlay.ts +35 -35
  175. package/src/modes/components/plugin-selector.ts +6 -1
  176. package/src/modes/components/queue-mode-selector.ts +6 -1
  177. package/src/modes/components/select-list-mouse-routing.ts +35 -0
  178. package/src/modes/components/session-selector.ts +11 -10
  179. package/src/modes/components/settings-defs.ts +14 -1
  180. package/src/modes/components/settings-selector.ts +196 -29
  181. package/src/modes/components/show-images-selector.ts +6 -1
  182. package/src/modes/components/status-line/component.ts +108 -28
  183. package/src/modes/components/status-line/segments.ts +5 -1
  184. package/src/modes/components/status-line/types.ts +2 -0
  185. package/src/modes/components/theme-selector.ts +6 -1
  186. package/src/modes/components/thinking-selector.ts +6 -1
  187. package/src/modes/components/tool-execution.ts +25 -9
  188. package/src/modes/components/tree-selector.ts +5 -5
  189. package/src/modes/controllers/command-controller.ts +140 -47
  190. package/src/modes/controllers/event-controller.ts +59 -3
  191. package/src/modes/controllers/input-controller.ts +70 -4
  192. package/src/modes/controllers/selector-controller.ts +15 -2
  193. package/src/modes/controllers/streaming-reveal.ts +17 -0
  194. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  195. package/src/modes/interactive-mode.ts +142 -59
  196. package/src/modes/internal-url-autocomplete.ts +17 -2
  197. package/src/modes/prompt-action-autocomplete.ts +3 -1
  198. package/src/modes/running-subagent-badge.ts +13 -0
  199. package/src/modes/setup-wizard/scenes/glyph.ts +2 -13
  200. package/src/modes/setup-wizard/scenes/providers.ts +2 -1
  201. package/src/modes/setup-wizard/scenes/theme.ts +6 -12
  202. package/src/modes/setup-wizard/scenes/web-search.ts +8 -13
  203. package/src/modes/setup-wizard/wizard-overlay.ts +6 -5
  204. package/src/modes/theme/mermaid-rendering.test.ts +53 -0
  205. package/src/modes/theme/theme.ts +42 -15
  206. package/src/modes/types.ts +9 -1
  207. package/src/modes/utils/interactive-context-helpers.ts +1 -1
  208. package/src/priority.json +15 -0
  209. package/src/prompts/advisor/active-repo-watchdog.md +6 -0
  210. package/src/prompts/advisor/system.md +21 -7
  211. package/src/prompts/agents/designer.md +1 -1
  212. package/src/prompts/agents/explore.md +1 -1
  213. package/src/prompts/agents/librarian.md +2 -2
  214. package/src/prompts/agents/plan.md +2 -2
  215. package/src/prompts/agents/reviewer.md +1 -1
  216. package/src/prompts/agents/task.md +2 -2
  217. package/src/prompts/system/active-repo-context.md +4 -0
  218. package/src/prompts/system/gemini-tool-call-reminder.md +9 -0
  219. package/src/prompts/system/plan-mode-active.md +12 -3
  220. package/src/prompts/system/project-prompt.md +2 -2
  221. package/src/prompts/system/system-prompt.md +8 -6
  222. package/src/prompts/tools/bash.md +2 -2
  223. package/src/prompts/tools/checkpoint.md +1 -1
  224. package/src/prompts/tools/{find.md → glob.md} +1 -1
  225. package/src/prompts/tools/{search.md → grep.md} +3 -3
  226. package/src/prompts/tools/read.md +4 -2
  227. package/src/sdk.ts +98 -29
  228. package/src/session/agent-session.ts +828 -191
  229. package/src/session/session-history-format.ts +2 -2
  230. package/src/session/session-listing.ts +35 -2
  231. package/src/session/session-manager.ts +46 -0
  232. package/src/slash-commands/builtin-registry.ts +158 -22
  233. package/src/slash-commands/helpers/usage-report.ts +23 -2
  234. package/src/ssh/__tests__/connection-manager-args.test.ts +69 -0
  235. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +68 -0
  236. package/src/ssh/connection-manager.ts +12 -15
  237. package/src/ssh/file-transfer.ts +209 -0
  238. package/src/ssh/utils.ts +24 -0
  239. package/src/system-prompt.ts +60 -28
  240. package/src/task/executor.ts +96 -36
  241. package/src/task/index.ts +3 -3
  242. package/src/task/render.ts +14 -13
  243. package/src/task/worktree.ts +38 -1
  244. package/src/tiny/text.ts +49 -4
  245. package/src/tiny/worker.ts +3 -3
  246. package/src/tools/acp-bridge.ts +6 -1
  247. package/src/tools/bash.ts +2 -2
  248. package/src/tools/builtin-names.ts +26 -2
  249. package/src/tools/{find.ts → glob.ts} +48 -42
  250. package/src/tools/{search.ts → grep.ts} +311 -129
  251. package/src/tools/index.ts +13 -14
  252. package/src/tools/irc.ts +6 -2
  253. package/src/tools/path-utils.ts +64 -1
  254. package/src/tools/plan-mode-guard.ts +26 -13
  255. package/src/tools/read.ts +35 -6
  256. package/src/tools/render-utils.ts +14 -0
  257. package/src/tools/renderers.ts +15 -4
  258. package/src/tools/ssh.ts +17 -2
  259. package/src/tools/todo.ts +4 -26
  260. package/src/tools/write.ts +25 -9
  261. package/src/utils/active-repo-context.ts +143 -0
  262. package/src/utils/edit-mode.ts +19 -2
  263. package/src/utils/image-resize.ts +88 -7
  264. package/src/utils/lang-from-path.ts +3 -3
  265. package/src/utils/markit-cache.ts +166 -0
  266. package/src/utils/markit.ts +86 -18
  267. package/src/utils/prompt-path.ts +3 -0
  268. package/src/utils/shell-snapshot.ts +1 -1
  269. package/src/utils/title-generator.ts +1 -1
  270. package/src/web/search/provider.ts +54 -34
  271. package/src/web/search/providers/duckduckgo.ts +140 -0
  272. package/src/web/search/providers/firecrawl.ts +144 -0
  273. package/src/web/search/providers/tinyfish.ts +159 -0
  274. package/src/web/search/providers/xai.ts +292 -0
  275. package/src/web/search/providers/zai.ts +142 -56
  276. package/src/web/search/types.ts +6 -2
@@ -39,13 +39,14 @@ import { AstEditTool } from "./ast-edit";
39
39
  import { AstGrepTool } from "./ast-grep";
40
40
  import { BashTool } from "./bash";
41
41
  import { BrowserTool } from "./browser";
42
- import type { BuiltinToolName } from "./builtin-names";
42
+ import { type BuiltinToolName, normalizeToolNames } from "./builtin-names";
43
43
  import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
44
44
  import { DebugTool } from "./debug";
45
45
  import { EvalTool } from "./eval";
46
46
  import { resolveEvalBackends } from "./eval-backends";
47
- import { FindTool } from "./find";
48
47
  import { GithubTool } from "./gh";
48
+ import { GlobTool } from "./glob";
49
+ import { GrepTool } from "./grep";
49
50
  import { InspectImageTool } from "./inspect-image";
50
51
  import { IrcTool, isIrcEnabled } from "./irc";
51
52
  import { JobTool } from "./job";
@@ -60,7 +61,6 @@ import { ReadTool } from "./read";
60
61
  import { createReportToolIssueTool, isAutoQaEnabled } from "./report-tool-issue";
61
62
  import { ResolveTool } from "./resolve";
62
63
  import { reportFindingTool } from "./review";
63
- import { SearchTool } from "./search";
64
64
  import { SearchToolBm25Tool } from "./search-tool-bm25";
65
65
  import { loadSshTool } from "./ssh";
66
66
  import { type TodoPhase, TodoTool } from "./todo";
@@ -82,8 +82,9 @@ export * from "./checkpoint";
82
82
  export * from "./debug";
83
83
  export * from "./eval";
84
84
  export * from "./eval-backends";
85
- export * from "./find";
86
85
  export * from "./gh";
86
+ export * from "./glob";
87
+ export * from "./grep";
87
88
  export * from "./image-gen";
88
89
  export * from "./inspect-image";
89
90
  export * from "./irc";
@@ -98,7 +99,6 @@ export * from "./read";
98
99
  export * from "./report-tool-issue";
99
100
  export * from "./resolve";
100
101
  export * from "./review";
101
- export * from "./search";
102
102
  export * from "./search-tool-bm25";
103
103
  export * from "./ssh";
104
104
  export * from "./todo";
@@ -384,7 +384,7 @@ export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = [
384
384
  "bash",
385
385
  "edit",
386
386
  "write",
387
- "find",
387
+ "glob",
388
388
  "eval",
389
389
  ] as const;
390
390
 
@@ -395,7 +395,7 @@ export const DEFAULT_ESSENTIAL_TOOL_NAMES: readonly string[] = [
395
395
  */
396
396
  export function computeEssentialBuiltinNames(settings: Settings): string[] {
397
397
  const override = settings.get("tools.essentialOverride") ?? [];
398
- const cleaned = override.map(name => name.trim()).filter(Boolean);
398
+ const cleaned = normalizeToolNames(override.map(name => name.trim()).filter(Boolean));
399
399
  if (cleaned.length > 0) {
400
400
  return cleaned.filter(name => name in BUILTIN_TOOLS);
401
401
  }
@@ -449,8 +449,8 @@ export const BUILTIN_TOOLS: Record<BuiltinToolName, ToolFactory> = {
449
449
  eval: s => new EvalTool(s),
450
450
  ssh: loadSshTool,
451
451
  github: GithubTool.createIf,
452
- find: s => new FindTool(s),
453
- search: s => new SearchTool(s),
452
+ glob: s => new GlobTool(s),
453
+ grep: s => new GrepTool(s),
454
454
  lsp: LspTool.createIf,
455
455
  inspect_image: s => new InspectImageTool(s),
456
456
  browser: s => new BrowserTool(s),
@@ -487,8 +487,7 @@ export type ToolName = BuiltinToolName;
487
487
  export async function createTools(session: ToolSession, toolNames?: string[]): Promise<Tool[]> {
488
488
  const includeYield = session.requireYieldTool === true;
489
489
  const enableLsp = session.enableLsp ?? true;
490
- let requestedTools =
491
- toolNames && toolNames.length > 0 ? [...new Set(toolNames.map(name => name.toLowerCase()))] : undefined;
490
+ let requestedTools = toolNames && toolNames.length > 0 ? normalizeToolNames(toolNames) : undefined;
492
491
  const goalEnabled = session.settings.get("goal.enabled");
493
492
  const goalModeActive = goalEnabled && session.getGoalModeState?.()?.enabled === true;
494
493
  if (goalModeActive && requestedTools && !requestedTools.includes("goal")) {
@@ -552,7 +551,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
552
551
  // Auto-include AST counterparts when their text-based sibling is present
553
552
  if (requestedTools) {
554
553
  if (
555
- requestedTools.includes("search") &&
554
+ requestedTools.includes("grep") &&
556
555
  !requestedTools.includes("ast_grep") &&
557
556
  session.settings.get("astGrep.enabled")
558
557
  ) {
@@ -602,8 +601,8 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
602
601
  if (name === "eval") return allowEval;
603
602
  if (name === "debug") return session.settings.get("debug.enabled");
604
603
  if (name === "todo") return !includeYield && session.settings.get("todo.enabled");
605
- if (name === "find") return session.settings.get("find.enabled");
606
- if (name === "search") return session.settings.get("search.enabled");
604
+ if (name === "glob") return session.settings.get("glob.enabled");
605
+ if (name === "grep") return session.settings.get("grep.enabled");
607
606
  if (name === "github") return session.settings.get("github.enabled");
608
607
  if (name === "ast_grep") return session.settings.get("astGrep.enabled");
609
608
  if (name === "ast_edit") return session.settings.get("astEdit.enabled");
package/src/tools/irc.ts CHANGED
@@ -19,7 +19,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
19
19
  import { IrcBus, type IrcDeliveryReceipt, type IrcMessage } from "../irc/bus";
20
20
  import type { Theme } from "../modes/theme/theme";
21
21
  import ircDescription from "../prompts/tools/irc.md" with { type: "text" };
22
- import type { AgentRegistry } from "../registry/agent-registry";
22
+ import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
23
23
  import { canSpawnAtDepth } from "../task/types";
24
24
  import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
25
25
  import type { ToolSession } from ".";
@@ -280,6 +280,10 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
280
280
  // parked agent on a broadcast would be a stampede. Direct sends go
281
281
  // through the bus unfiltered so parked recipients are revived.
282
282
  const targets = isBroadcast ? registry.listVisibleTo(senderId).map(ref => ref.id) : [to];
283
+ // A broadcast that also reaches the main agent delivers the body to it
284
+ // directly (its own incoming card); relaying the sibling legs to the
285
+ // main UI would then show the same body once per other recipient.
286
+ const suppressRelay = isBroadcast && targets.includes(MAIN_AGENT_ID);
283
287
  const receipts = await Promise.all(
284
288
  targets.map(target =>
285
289
  bus.send(
@@ -287,7 +291,7 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
287
291
  // Awaited sends mark the sender as blocked on an answer so a
288
292
  // busy recipient that cannot reach a step boundary (async
289
293
  // disabled) auto-replies instead of stranding the sender.
290
- params.await ? { expectsReply: true } : undefined,
294
+ { expectsReply: params.await || undefined, suppressRelay: suppressRelay || undefined },
291
295
  ),
292
296
  ),
293
297
  );
@@ -26,7 +26,11 @@ const INTERNAL_URL_SELECTOR_PART_RE = new RegExp(
26
26
  );
27
27
  // Schemes whose host grammar is identifier-shaped, so any trailing
28
28
  // `:<selector-chunk>` is unambiguously a read-tool selector. `mcp://` is
29
- // excluded because mcp resource URIs may legitimately contain colons.
29
+ // excluded because mcp resource URIs may legitimately contain colons. `ssh://`
30
+ // is included despite an optional `:port`; `splitInternalUrlSel` skips the peel
31
+ // for an `ssh://host:port` that has no `/path`, so the port colon is never
32
+ // mistaken for a selector (a real ssh selector trails the `/path`, e.g.
33
+ // `ssh://h/f:1-5`).
30
34
  const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
31
35
  agent: true,
32
36
  artifact: true,
@@ -37,6 +41,7 @@ const INTERNAL_SCHEMES_WITH_SELECTORS: Record<string, true> = {
37
41
  pr: true,
38
42
  rule: true,
39
43
  skill: true,
44
+ ssh: true,
40
45
  vault: true,
41
46
  };
42
47
  // Schemes whose resource URIs are server-defined and may legitimately end
@@ -55,6 +60,7 @@ const TOP_LEVEL_INTERNAL_URL_PREFIXES = [
55
60
  "rule://",
56
61
  "local://",
57
62
  "mcp://",
63
+ "ssh://",
58
64
  "vault://",
59
65
  ] as const;
60
66
 
@@ -337,6 +343,13 @@ export function splitInternalUrlSel(rawPath: string): { path: string; sel?: stri
337
343
  if (!INTERNAL_SCHEMES_WITH_SELECTORS[scheme]) return { path: rawPath };
338
344
 
339
345
  const schemeEnd = schemeMatch[0].length;
346
+ // ssh:// authority carries an optional `:port`; with no `/path` after the
347
+ // authority, a trailing `:NNNN` is the port, not a read selector
348
+ // (e.g. ssh://host:2222). Other schemes' authority-trailing selectors
349
+ // (artifact://5:1-50) still peel, so this guard is ssh-specific.
350
+ if (scheme === "ssh" && rawPath.indexOf("/", schemeEnd) === -1) {
351
+ return { path: rawPath };
352
+ }
340
353
  let path = rawPath;
341
354
  const chunks: string[] = [];
342
355
  while (true) {
@@ -352,6 +365,27 @@ export function splitInternalUrlSel(rawPath: string): { path: string; sel?: stri
352
365
  return { path, sel: chunks.join(":") };
353
366
  }
354
367
 
368
+ /**
369
+ * Peel a read-tool selector off an internal-URL write target so `write` resolves
370
+ * the same file `read` does (e.g. `ssh://h/f:raw` -> `ssh://h/f`). Only the
371
+ * whole-file display modes `raw`/`conflicts` are accepted (they do not change
372
+ * which bytes are written); any other selector-shaped tail `splitInternalUrlSel`
373
+ * peels — a line range, a compound like `raw:1-20`, or a malformed `:-N` — throws,
374
+ * because `write` addresses a whole file, not a partial range, and silently
375
+ * stripping it would write to a path the caller never named. Non-URL paths and
376
+ * URLs without a selector pass through unchanged.
377
+ */
378
+ export function peelWriteUrlSelector(rawPath: string): string {
379
+ const { path, sel } = splitInternalUrlSel(rawPath);
380
+ if (sel === undefined) return rawPath;
381
+ // Case-insensitive to match read's selector grammar (parseSel + the /i regexes above).
382
+ if (/^(?:raw|conflicts)$/i.test(sel)) return path;
383
+ throw new ToolError(
384
+ `write does not accept the trailing selector ":${sel}" — it writes a whole file. ` +
385
+ `Remove ":${sel}", or if the filename truly ends with it, percent-encode the ":" as %3A.`,
386
+ );
387
+ }
388
+
355
389
  function assertNotInternalUrl(expanded: string, original: string): void {
356
390
  for (const prefix of TOP_LEVEL_INTERNAL_URL_PREFIXES) {
357
391
  if (expanded.startsWith(prefix)) {
@@ -375,6 +409,30 @@ export function isInternalUrlPath(filePath: string): boolean {
375
409
  return false;
376
410
  }
377
411
 
412
+ /**
413
+ * True when a tool path argument references the `ssh://` scheme anywhere.
414
+ *
415
+ * Substring (not anchored) on purpose: it feeds the read/search/write approval
416
+ * tier, which runs synchronously on the raw args. `search` only flattens a
417
+ * delimited `paths: "a,ssh://h/x"` into separate entries *after* approval, so an
418
+ * anchored check would let an embedded `ssh://` slip through at the read tier.
419
+ * Matching the literal `ssh://` substring also tracks exactly what routes to the
420
+ * SSH handler; over-matching only over-prompts (fail-closed).
421
+ */
422
+ export function pathTargetsSsh(path: string): boolean {
423
+ return /ssh:\/\//i.test(path);
424
+ }
425
+
426
+ /**
427
+ * True when a path is specifically an `ssh://` URL (anchored scheme match).
428
+ * Unlike {@link pathTargetsSsh} (substring, for the pre-expansion approval
429
+ * scan), this is the exact per-entry check used to reject `ssh://` *before* a
430
+ * side-effecting `InternalUrlRouter.resolve` in tools that need a local file.
431
+ */
432
+ export function isSshUrl(path: string): boolean {
433
+ return /^ssh:\/\//i.test(path.trim());
434
+ }
435
+
378
436
  /**
379
437
  * Resolve a path relative to the given cwd.
380
438
  * Handles ~ expansion and absolute paths.
@@ -1037,6 +1095,11 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
1037
1095
  resolvedPathInputs.push(rawPath);
1038
1096
  continue;
1039
1097
  }
1098
+ if (isSshUrl(rawPath)) {
1099
+ throw new ToolError(
1100
+ `Cannot ${internalUrlAction} a remote ssh:// path (no local file): ${rawPath}. Use \`read ${rawPath}\` to view it, or the \`search\` tool to grep remote files.`,
1101
+ );
1102
+ }
1040
1103
  if (hasGlobPathChars(rawPath)) {
1041
1104
  throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
1042
1105
  }
@@ -1,7 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
4
- import { resolveLocalRoot, resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
4
+ import {
5
+ type LocalProtocolOptions,
6
+ resolveLocalRoot,
7
+ resolveLocalUrlToPath,
8
+ resolveVaultUrlToPath,
9
+ } from "../internal-urls";
5
10
  import type { ToolSession } from ".";
6
11
  import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
7
12
  import { ToolError } from "./tool-errors";
@@ -10,16 +15,27 @@ const VAULT_SCHEME_PREFIX = "vault:";
10
15
  const LOCAL_SCHEME_PREFIX = "local:";
11
16
  const HL_TRAILING_TAG_RE = new RegExp(`${HL_FILE_HASH_SEP}[0-9A-Fa-f]{${HL_FILE_HASH_LENGTH}}$`);
12
17
 
18
+ /** Resolve the `local://` options the session uses, preferring its own
19
+ * {@link LocalProtocolOptions} (the mapping `read`/`write`/`eval` resolve
20
+ * through) over the bare `getArtifactsDir`/`getSessionId` pair. Subagents and
21
+ * multi-session hosts (cmux/ACP, embedded SDK) pin `local://` to a parent/foreign
22
+ * root via `localProtocolOptions`; the sandbox root the plan-mode guard derives
23
+ * must match where the artifact actually lives, or it rejects a legitimate plan
24
+ * edit (and tag-based path recovery onto the sandbox would miss it). */
25
+ function planLocalProtocolOptions(session: ToolSession): LocalProtocolOptions {
26
+ return (
27
+ session.localProtocolOptions ?? {
28
+ getArtifactsDir: () => session.getArtifactsDir?.() ?? null,
29
+ getSessionId: () => session.getSessionId?.() ?? null,
30
+ }
31
+ );
32
+ }
33
+
13
34
  /** Resolve the absolute path of the session's `local://` artifact sandbox.
14
35
  * Returns `null` when the session has no artifact wiring (e.g. tests). */
15
36
  function localSandboxRoot(session: ToolSession): string | null {
16
37
  try {
17
- return path.resolve(
18
- resolveLocalRoot({
19
- getArtifactsDir: session.getArtifactsDir,
20
- getSessionId: session.getSessionId,
21
- }),
22
- );
38
+ return path.resolve(resolveLocalRoot(planLocalProtocolOptions(session)));
23
39
  } catch {
24
40
  return null;
25
41
  }
@@ -62,8 +78,8 @@ export function unwrapHashlineHeaderPath(targetPath: string): string {
62
78
  * always agree on the absolute target (including bracketed hashline headers,
63
79
  * `local://` URLs, and bare absolute paths). Files inside the sandbox are not
64
80
  * part of the working tree, so plan mode treats them as freely writable
65
- * scratch/plan space. */
66
- function targetsLocalSandbox(session: ToolSession, targetPath: string): boolean {
81
+ * scratch/plan space — and tag-based path recovery may rebind onto them. */
82
+ export function targetsLocalSandbox(session: ToolSession, targetPath: string): boolean {
67
83
  const root = localSandboxRoot(session);
68
84
  if (!root) return false;
69
85
  let resolved: string;
@@ -99,10 +115,7 @@ export function resolvePlanPath(session: ToolSession, targetPath: string): strin
99
115
  const unwrapped = unwrapHashlineHeaderPath(targetPath);
100
116
  const normalized = normalizeLocalScheme(unwrapped);
101
117
  if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) {
102
- return resolveLocalUrlToPath(normalized, {
103
- getArtifactsDir: session.getArtifactsDir,
104
- getSessionId: session.getSessionId,
105
- });
118
+ return resolveLocalUrlToPath(normalized, planLocalProtocolOptions(session));
106
119
  }
107
120
 
108
121
  if (normalized.startsWith(VAULT_SCHEME_PREFIX)) {
package/src/tools/read.ts CHANGED
@@ -3,7 +3,13 @@ import * as fs from "node:fs/promises";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { formatHashlineHeader, formatNumberedLine, formatNumberedLines } from "@oh-my-pi/hashline";
6
- import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
6
+ import type {
7
+ AgentTool,
8
+ AgentToolContext,
9
+ AgentToolResult,
10
+ AgentToolUpdateCallback,
11
+ ToolTier,
12
+ } from "@oh-my-pi/pi-agent-core";
7
13
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
8
14
  import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
9
15
  import type { Component } from "@oh-my-pi/pi-tui";
@@ -83,6 +89,7 @@ import {
83
89
  formatPathRelativeToCwd,
84
90
  type LineRange,
85
91
  parseLineRanges,
92
+ pathTargetsSsh,
86
93
  resolveReadPath,
87
94
  splitDelimitedPathEntry,
88
95
  splitInternalUrlSel,
@@ -170,6 +177,10 @@ interface HashlineHeaderContext {
170
177
  fullText?: string;
171
178
  }
172
179
 
180
+ function formatReadHashlineHeader(displayPath: string, tag: string): string {
181
+ return formatHashlineHeader(path.basename(displayPath), tag);
182
+ }
183
+
173
184
  function recordFullHashlineContext(
174
185
  session: ToolSession,
175
186
  absolutePath: string | undefined,
@@ -180,7 +191,7 @@ function recordFullHashlineContext(
180
191
  const normalized = normalizeToLF(fullText);
181
192
  const tag = getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalized);
182
193
  return {
183
- header: formatHashlineHeader(displayPath, tag),
194
+ header: formatReadHashlineHeader(displayPath, tag),
184
195
  tag,
185
196
  fullText: normalized,
186
197
  };
@@ -203,7 +214,7 @@ async function readHashlineHeaderContext(
203
214
  }
204
215
 
205
216
  function hashlineHeaderContext(displayPath: string, tag: string): HashlineHeaderContext {
206
- return { header: formatHashlineHeader(displayPath, tag), tag };
217
+ return { header: formatReadHashlineHeader(displayPath, tag), tag };
207
218
  }
208
219
 
209
220
  function prependHashlineHeader(text: string, context: HashlineHeaderContext | undefined): string {
@@ -742,11 +753,27 @@ function isMultiRange(parsed: ParsedSelector): boolean {
742
753
  return parsed.kind === "lines" && parsed.ranges.length > 1;
743
754
  }
744
755
 
756
+ function selectorChunkLooksReadLike(chunk: string): boolean {
757
+ const lower = chunk.toLowerCase();
758
+ return (
759
+ lower === "raw" || lower === "conflicts" || /^-\d+(?:[-+]\d+)?$/.test(chunk) || parseLineRanges(chunk) !== null
760
+ );
761
+ }
762
+
763
+ function invalidSelector(sel: string): ToolError {
764
+ return new ToolError(
765
+ `Invalid selector ':${sel}'. Use :N, :N-M, :N+K, :N- (open-ended), a comma-separated list of ranges, :raw, or a range combined with raw (e.g. :raw:50-100).`,
766
+ );
767
+ }
768
+
745
769
  function parseSel(sel: string | undefined): ParsedSelector {
746
770
  if (!sel || sel.length === 0) return { kind: "none" };
747
771
 
748
772
  // Compound selector: `1-50:raw` or `raw:1-50`. Split into chunks and accept
749
- // any combination of one line range (possibly multi) and the literal `raw`.
773
+ // exactly one line range (possibly multi) plus the literal `raw`. Selector-like
774
+ // compounds that are not in that accepted set are invalid rather than "none";
775
+ // otherwise `read` can silently widen a malformed selector like
776
+ // `artifact://5:conflicts:1-1` while `grep` rejects it.
750
777
  if (sel.includes(":")) {
751
778
  const chunks = sel.split(":");
752
779
  if (chunks.length === 2) {
@@ -762,6 +789,7 @@ function parseSel(sel: string | undefined): ParsedSelector {
762
789
  }
763
790
  }
764
791
  }
792
+ if (chunks.every(selectorChunkLooksReadLike)) throw invalidSelector(sel);
765
793
  // Unrecognized compound — fall through (sqlite/archive/url consume their own colon syntax).
766
794
  return { kind: "none" };
767
795
  }
@@ -814,7 +842,8 @@ type SuffixMatchCache = Map<string, { absolutePath: string; displayPath: string
814
842
  */
815
843
  export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
816
844
  readonly name = "read";
817
- readonly approval = "read" as const;
845
+ readonly approval = (args: unknown): ToolTier =>
846
+ pathTargetsSsh(String((args as { path?: unknown }).path ?? "")) ? "exec" : "read";
818
847
  readonly label = "Read";
819
848
  readonly loadMode = "essential";
820
849
  readonly description: string;
@@ -1598,7 +1627,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1598
1627
  const tag = await recordFileSnapshot(this.session, absolutePath);
1599
1628
  if (tag) {
1600
1629
  recordSeenLinesFromBody(this.session, absolutePath, tag, outputText);
1601
- outputText = `${formatHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
1630
+ outputText = `${formatReadHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
1602
1631
  }
1603
1632
  }
1604
1633
  if (notices.length > 0) {
@@ -99,6 +99,17 @@ export function getPreviewLines(text: string, maxLines: number, maxLineLen: numb
99
99
  return lines.slice(0, maxLines).map(l => truncateToWidth(l.trim(), maxLineLen, ellipsis));
100
100
  }
101
101
 
102
+ /**
103
+ * Collapse a possibly multi-line string into a single line, then truncate it to
104
+ * `maxWidth` display cells. {@link truncateToWidth} alone caps width but
105
+ * newlines are zero-width, so multi-line content (markdown briefs, tool args,
106
+ * provider errors) would otherwise spill a single status row across several
107
+ * visual lines. Whitespace runs collapse to one space, so tabs are handled too.
108
+ */
109
+ export function previewLine(text: string, maxWidth: number, ellipsis?: Ellipsis): string {
110
+ return truncateToWidth(text.replace(/\s+/g, " ").trim(), maxWidth, ellipsis);
111
+ }
112
+
102
113
  // =============================================================================
103
114
  // URL Utilities
104
115
  // =============================================================================
@@ -698,6 +709,9 @@ export function formatScreenshot(opts: {
698
709
  lines.push(`Format: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB)`);
699
710
  lines.push(`Dimensions: ${opts.resized.width}x${opts.resized.height}`);
700
711
  }
712
+ if (opts.resized.decodeFailed) {
713
+ lines.push("Resize: image decoder failed; using original image bytes");
714
+ }
701
715
  const dimensionNote = formatDimensionNote(opts.resized);
702
716
  if (dimensionNote) {
703
717
  lines.push(dimensionNote);
@@ -18,15 +18,15 @@ import { bashToolRenderer } from "./bash";
18
18
  import { browserToolRenderer } from "./browser/render";
19
19
  import { debugToolRenderer } from "./debug";
20
20
  import { evalToolRenderer } from "./eval-render";
21
- import { findToolRenderer } from "./find";
22
21
  import { githubToolRenderer } from "./gh-renderer";
22
+ import { globToolRenderer } from "./glob";
23
+ import { grepToolRenderer } from "./grep";
23
24
  import { inspectImageToolRenderer } from "./inspect-image-renderer";
24
25
  import { ircToolRenderer } from "./irc";
25
26
  import { jobToolRenderer } from "./job";
26
27
  import { recallToolRenderer, reflectToolRenderer, retainToolRenderer } from "./memory-render";
27
28
  import { readToolRenderer } from "./read";
28
29
  import { resolveToolRenderer } from "./resolve";
29
- import { searchToolRenderer } from "./search";
30
30
  import { searchToolBm25Renderer } from "./search-tool-bm25";
31
31
  import { sshToolRenderer } from "./ssh";
32
32
  import { todoToolRenderer } from "./todo";
@@ -52,6 +52,17 @@ export type ToolRenderer = {
52
52
  * streams rows the result render preserves.
53
53
  */
54
54
  provisionalPendingPreview?: boolean | "collapsed";
55
+ /**
56
+ * Whether the partial-result render is provisional: chrome rows (header
57
+ * glyph, frame state) that change between `options.isPartial === true` and
58
+ * the final result render. When `true`, the block is treated as
59
+ * commit-unstable while a partial result is in flight, so the
60
+ * stable-prefix ratchet in `deriveLiveCommitState` cannot promote the
61
+ * partial chrome to native scrollback only to have the final render strand
62
+ * it above the settled frame. Absent = the partial render is byte-stable
63
+ * with the final render and may commit like any settled stream.
64
+ */
65
+ provisionalPartialResult?: boolean;
55
66
  };
56
67
 
57
68
  export const toolRenderers: Record<string, ToolRenderer> = {
@@ -64,8 +75,8 @@ export const toolRenderers: Record<string, ToolRenderer> = {
64
75
  eval: evalToolRenderer as ToolRenderer,
65
76
  edit: editToolRenderer as ToolRenderer,
66
77
  apply_patch: editToolRenderer as ToolRenderer,
67
- find: findToolRenderer as ToolRenderer,
68
- search: searchToolRenderer as ToolRenderer,
78
+ glob: globToolRenderer as ToolRenderer,
79
+ grep: grepToolRenderer as ToolRenderer,
69
80
  lsp: lspToolRenderer as ToolRenderer,
70
81
  inspect_image: inspectImageToolRenderer as ToolRenderer,
71
82
  irc: ircToolRenderer as ToolRenderer,
package/src/tools/ssh.ts CHANGED
@@ -281,6 +281,7 @@ export const sshToolRenderer = {
281
281
  result: {
282
282
  content: Array<{ type: string; text?: string }>;
283
283
  details?: SSHToolDetails;
284
+ isError?: boolean;
284
285
  },
285
286
  options: RenderResultOptions & { renderContext?: SshRenderContext },
286
287
  uiTheme: Theme,
@@ -289,8 +290,14 @@ export const sshToolRenderer = {
289
290
  const details = result.details;
290
291
  const host = args?.host || "…";
291
292
  const command = args?.command ?? "";
293
+ const isError = result.isError === true;
294
+ const isPartial = options.isPartial === true;
292
295
  const header = renderStatusLine(
293
- { iconOverride: uiTheme.styledSymbol("tool.ssh", "accent"), title: "SSH", description: `[${host}]` },
296
+ isPartial
297
+ ? { icon: "pending", title: "SSH", description: `[${host}]` }
298
+ : isError
299
+ ? { icon: "error", title: "SSH", description: `[${host}]` }
300
+ : { iconOverride: uiTheme.styledSymbol("tool.ssh", "accent"), title: "SSH", description: `[${host}]` },
294
301
  uiTheme,
295
302
  );
296
303
  const cmdLines = formatSshCommandLines(command, uiTheme);
@@ -342,7 +349,7 @@ export const sshToolRenderer = {
342
349
  return outputBlock.render(
343
350
  {
344
351
  header,
345
- state: "success",
352
+ state: isPartial ? "pending" : isError ? "error" : "success",
346
353
  sections: [
347
354
  {
348
355
  // Viewport-sized tail window in every state — streaming and final
@@ -366,4 +373,12 @@ export const sshToolRenderer = {
366
373
  // that shifts while args stream. Expanded output is top-anchored enough for
367
374
  // the transcript to commit its settled prefix.
368
375
  provisionalPendingPreview: "collapsed",
376
+ // Partial-result chrome (pending icon and frame state) differs from the
377
+ // final SSH glyph/state, so the block stays commit-unstable while
378
+ // `options.isPartial` holds. Without this, a long-running SSH command's
379
+ // stable pending header would be promoted by the stable-prefix ratchet and
380
+ // committed to native scrollback, then the final render's SSH glyph would
381
+ // land below and strand a duplicate pending header above the final frame
382
+ // ([#3177](https://github.com/can1357/oh-my-pi/issues/3177)).
383
+ provisionalPartialResult: true,
369
384
  };
package/src/tools/todo.ts CHANGED
@@ -57,7 +57,10 @@ const todoSchema = type({
57
57
  "list?": InitListEntry.array().describe("phased task list (init)"),
58
58
  "task?": type("string").describe("task content"),
59
59
  "phase?": type("string").describe("phase name"),
60
- "items?": type("string").describe("task content").array().atLeastLength(1).describe("tasks to append"),
60
+ // No `atLeastLength(1)` here: `items` is only meaningful for `init`/`append`,
61
+ // and both enforce non-empty with op-specific errors. A stray `items: []` on
62
+ // an op that ignores it (e.g. `view`) must not be a hard schema rejection.
63
+ "items?": type("string").describe("task content").array().describe("tasks to append"),
61
64
  }).describe("apply a single todo operation");
62
65
 
63
66
  type TodoParams = TodoSchema;
@@ -156,31 +159,6 @@ export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPha
156
159
  return [];
157
160
  }
158
161
 
159
- /**
160
- * Pick the actionable window of tasks to display in the sticky todo panel.
161
- *
162
- * Returns up to `maxVisible` open (pending / in_progress) tasks in their
163
- * original phase order, plus the count of remaining open tasks not shown so
164
- * the caller can render a `+N more` hint. When every task in `tasks` is
165
- * closed (completed or abandoned), returns the trailing `maxVisible` tasks
166
- * with `hiddenOpenCount = 0`, so the panel keeps useful context until the
167
- * active-phase pointer advances on the next `todo`.
168
- *
169
- * Task identity and order are preserved — this is a slice, never a sort.
170
- */
171
- export function selectStickyTodoWindow(
172
- tasks: TodoItem[],
173
- maxVisible = 5,
174
- ): { visible: TodoItem[]; hiddenOpenCount: number } {
175
- const openTasks = tasks.filter(t => t.status === "pending" || t.status === "in_progress");
176
- if (openTasks.length > 0) {
177
- const visible = openTasks.slice(0, maxVisible);
178
- return { visible, hiddenOpenCount: openTasks.length - visible.length };
179
- }
180
- const start = Math.max(0, tasks.length - maxVisible);
181
- return { visible: tasks.slice(start), hiddenOpenCount: 0 };
182
- }
183
-
184
162
  /** Minimum overlap (after normalization) required for a substring match.
185
163
  * Picked at six chars to admit single-word identifiers like "review" /
186
164
  * "Sonnet" without admitting tiny common substrings like "test" / "fix"
@@ -3,7 +3,13 @@ import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
4
 
5
5
  import { formatHashlineHeader, stripHashlinePrefixes } from "@oh-my-pi/hashline";
6
- import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
6
+ import type {
7
+ AgentTool,
8
+ AgentToolContext,
9
+ AgentToolResult,
10
+ AgentToolUpdateCallback,
11
+ ToolTier,
12
+ } from "@oh-my-pi/pi-agent-core";
7
13
  import type { Component } from "@oh-my-pi/pi-tui";
8
14
  import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
9
15
  import { type } from "arktype";
@@ -41,7 +47,7 @@ import {
41
47
  } from "./conflict-detect";
42
48
  import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation";
43
49
  import { type OutputMeta, outputMeta } from "./output-meta";
44
- import { formatPathRelativeToCwd, isInternalUrlPath } from "./path-utils";
50
+ import { formatPathRelativeToCwd, isInternalUrlPath, pathTargetsSsh, peelWriteUrlSelector } from "./path-utils";
45
51
  import { enforcePlanModeWrite, resolvePlanPath, unwrapHashlineHeaderPath } from "./plan-mode-guard";
46
52
  import {
47
53
  cachedRenderedString,
@@ -263,14 +269,22 @@ function parseSqliteWriteTarget(subPath: string, queryString: string): { table:
263
269
  */
264
270
  export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails> {
265
271
  readonly name = "write";
266
- readonly approval = (args: unknown) => {
272
+ readonly approval = (args: unknown): ToolTier => {
267
273
  const rawPath = (args as Partial<WriteParams>).path;
268
- if (typeof rawPath !== "string" || !isInternalUrlPath(rawPath)) return "write";
274
+ if (typeof rawPath !== "string") return "write";
275
+ // Unwrap a hashline `[path#TAG]` wrapper first (parity with execute) so a
276
+ // wrapped `[ssh://h/x#ABCD]` can't dodge scheme detection and the tier checks below.
277
+ const path = unwrapHashlineHeaderPath(rawPath);
278
+ // Remote SSH writes open an outbound connection and run a remote shell —
279
+ // gate them like the exec-tier `ssh` tool, ahead of the handler-write
280
+ // logic. Substring match also covers selector-suffixed targets.
281
+ if (pathTargetsSsh(path)) return "exec";
282
+ if (!isInternalUrlPath(path)) return "write";
269
283
  // Internal URLs are usually session-local artifacts (read tier), but a
270
- // scheme whose handler exposes a `write` hook mutates handler-owned
271
- // user data (e.g. vault:// notes, host-owned mcp:// URIs) and must take
272
- // the write tier so always-ask mode actually prompts.
273
- const match = /^([a-z][a-z0-9+.-]*):\/\//i.exec(rawPath.trim());
284
+ // scheme whose handler exposes a `write` hook mutates handler-owned user
285
+ // data (e.g. vault:// notes) and must take the write tier so always-ask
286
+ // mode actually prompts.
287
+ const match = /^([a-z][a-z0-9+.-]*):\/\//i.exec(path.trim());
274
288
  const handler = match ? InternalUrlRouter.instance().getHandler(match[1]!.toLowerCase()) : undefined;
275
289
  return handler?.write ? "write" : "read";
276
290
  };
@@ -778,7 +792,9 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
778
792
  // (which fails on a leading `[`) and the bridge router would send a
779
793
  // `[local://scratch.md#ABCD]` write to the editor instead of the
780
794
  // session-local sandbox.
781
- const path = unwrapHashlineHeaderPath(rawPath);
795
+ // Peel a read-tool selector (`:raw`, `:1-20`, …) so the write target matches
796
+ // what `read` resolves for the same URL; line-range/malformed selectors throw.
797
+ const path = peelWriteUrlSelector(unwrapHashlineHeaderPath(rawPath));
782
798
  return untilAborted(signal, async () => {
783
799
  // Strip hashline display prefixes ([PATH#HASH] + LINE:) if the model copied them from read output
784
800
  const { text: cleanContent, stripped } = stripWriteContent(this.session, content);