@oh-my-pi/pi-coding-agent 15.12.4 → 15.13.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 (291) hide show
  1. package/CHANGELOG.md +304 -6
  2. package/dist/cli.js +1015 -881
  3. package/dist/types/async/job-manager.d.ts +15 -0
  4. package/dist/types/autolearn/controller.d.ts +25 -0
  5. package/dist/types/autolearn/managed-skills.d.ts +45 -0
  6. package/dist/types/autoresearch/state.d.ts +1 -1
  7. package/dist/types/autoresearch/types.d.ts +1 -1
  8. package/dist/types/cli/args.d.ts +19 -1
  9. package/dist/types/cli/session-picker.d.ts +1 -1
  10. package/dist/types/cli/setup-cli.d.ts +1 -1
  11. package/dist/types/cli/setup-model-picker.d.ts +14 -0
  12. package/dist/types/collab/protocol.d.ts +1 -1
  13. package/dist/types/commands/say.d.ts +24 -0
  14. package/dist/types/config/keybindings.d.ts +3 -3
  15. package/dist/types/config/model-registry.d.ts +10 -0
  16. package/dist/types/config/models-config-schema.d.ts +12 -0
  17. package/dist/types/config/models-config.d.ts +8 -2
  18. package/dist/types/config/settings-schema.d.ts +261 -58
  19. package/dist/types/export/html/index.d.ts +2 -1
  20. package/dist/types/extensibility/extensions/model-api.d.ts +17 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +47 -1
  23. package/dist/types/extensibility/hooks/index.d.ts +2 -1
  24. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +9 -0
  25. package/dist/types/extensibility/plugins/loader.d.ts +11 -0
  26. package/dist/types/extensibility/shared-events.d.ts +1 -1
  27. package/dist/types/extensibility/skills.d.ts +10 -0
  28. package/dist/types/goals/guided-setup.d.ts +18 -0
  29. package/dist/types/goals/state.d.ts +1 -1
  30. package/dist/types/hindsight/transcript.d.ts +1 -1
  31. package/dist/types/index.d.ts +5 -0
  32. package/dist/types/internal-urls/local-protocol.d.ts +4 -2
  33. package/dist/types/main.d.ts +4 -3
  34. package/dist/types/mcp/startup-events.d.ts +11 -0
  35. package/dist/types/memories/index.d.ts +7 -0
  36. package/dist/types/memory-backend/local-backend.d.ts +4 -3
  37. package/dist/types/mnemopi/config.d.ts +4 -4
  38. package/dist/types/modes/components/agent-hub.d.ts +6 -0
  39. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  40. package/dist/types/modes/components/compaction-summary-message.d.ts +15 -1
  41. package/dist/types/modes/components/custom-editor.d.ts +39 -1
  42. package/dist/types/modes/components/custom-editor.test.d.ts +1 -0
  43. package/dist/types/modes/components/session-selector.d.ts +1 -1
  44. package/dist/types/modes/components/tool-execution.d.ts +26 -16
  45. package/dist/types/modes/components/transcript-container.d.ts +23 -2
  46. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  47. package/dist/types/modes/components/usage-row.d.ts +3 -0
  48. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  49. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  50. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  51. package/dist/types/modes/gradient-highlight.d.ts +9 -4
  52. package/dist/types/modes/image-references.d.ts +6 -0
  53. package/dist/types/modes/interactive-mode.d.ts +27 -3
  54. package/dist/types/modes/magic-keywords.d.ts +13 -1
  55. package/dist/types/modes/rpc/rpc-mode.d.ts +35 -1
  56. package/dist/types/modes/rpc/rpc-types.d.ts +9 -1
  57. package/dist/types/modes/runtime-init.d.ts +4 -0
  58. package/dist/types/modes/theme/theme.d.ts +13 -2
  59. package/dist/types/modes/types.d.ts +8 -2
  60. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  61. package/dist/types/registry/agent-registry.d.ts +17 -0
  62. package/dist/types/secrets/obfuscator.d.ts +1 -1
  63. package/dist/types/session/agent-session.d.ts +14 -2
  64. package/dist/types/session/indexed-session-storage.d.ts +3 -4
  65. package/dist/types/session/session-context.d.ts +39 -0
  66. package/dist/types/session/session-entries.d.ts +159 -0
  67. package/dist/types/session/session-listing.d.ts +69 -0
  68. package/dist/types/session/session-loader.d.ts +16 -0
  69. package/dist/types/session/session-manager.d.ts +82 -474
  70. package/dist/types/session/session-migrations.d.ts +12 -0
  71. package/dist/types/session/session-paths.d.ts +25 -0
  72. package/dist/types/session/session-persistence.d.ts +8 -0
  73. package/dist/types/session/session-storage.d.ts +11 -12
  74. package/dist/types/session/snapcompact-inline.d.ts +12 -1
  75. package/dist/types/session/snapcompact-savings-journal.d.ts +46 -0
  76. package/dist/types/session/tool-choice-queue.d.ts +6 -6
  77. package/dist/types/stt/asr-client.d.ts +90 -0
  78. package/dist/types/stt/asr-protocol.d.ts +97 -0
  79. package/dist/types/stt/asr-worker.d.ts +2 -0
  80. package/dist/types/stt/downloader.d.ts +38 -0
  81. package/dist/types/stt/endpointer.d.ts +59 -0
  82. package/dist/types/stt/index.d.ts +5 -1
  83. package/dist/types/stt/models.d.ts +120 -0
  84. package/dist/types/stt/recorder.d.ts +17 -0
  85. package/dist/types/stt/stt-controller.d.ts +6 -0
  86. package/dist/types/stt/transcriber.d.ts +5 -7
  87. package/dist/types/stt/wav.d.ts +29 -0
  88. package/dist/types/system-prompt.d.ts +4 -0
  89. package/dist/types/task/executor.d.ts +2 -0
  90. package/dist/types/task/index.d.ts +9 -1
  91. package/dist/types/task/types.d.ts +36 -0
  92. package/dist/types/tools/bash.d.ts +2 -2
  93. package/dist/types/tools/eval-render.d.ts +1 -1
  94. package/dist/types/tools/index.d.ts +11 -1
  95. package/dist/types/tools/irc.d.ts +1 -0
  96. package/dist/types/tools/learn.d.ts +51 -0
  97. package/dist/types/tools/manage-skill.d.ts +40 -0
  98. package/dist/types/tools/plan-mode-guard.d.ts +10 -0
  99. package/dist/types/tools/renderers.d.ts +7 -11
  100. package/dist/types/tools/ssh.d.ts +1 -1
  101. package/dist/types/tools/todo.d.ts +1 -1
  102. package/dist/types/tools/tts.d.ts +25 -0
  103. package/dist/types/tools/write.d.ts +1 -1
  104. package/dist/types/tts/downloader.d.ts +20 -0
  105. package/dist/types/tts/index.d.ts +8 -0
  106. package/dist/types/tts/models.d.ts +82 -0
  107. package/dist/types/tts/player.d.ts +32 -0
  108. package/dist/types/tts/runtime.d.ts +6 -0
  109. package/dist/types/tts/streaming-player.d.ts +41 -0
  110. package/dist/types/tts/tts-client.d.ts +93 -0
  111. package/dist/types/tts/tts-protocol.d.ts +95 -0
  112. package/dist/types/tts/tts-worker.d.ts +2 -0
  113. package/dist/types/tts/vocalizer.d.ts +41 -0
  114. package/dist/types/tts/wav.d.ts +8 -0
  115. package/dist/types/utils/tool-choice.d.ts +8 -0
  116. package/dist/types/utils/tools-manager.d.ts +2 -1
  117. package/dist/types/utils/tools-manager.test.d.ts +1 -0
  118. package/dist/types/web/scrapers/github.d.ts +1 -1
  119. package/package.json +15 -14
  120. package/src/async/job-manager.ts +49 -0
  121. package/src/autolearn/controller.ts +139 -0
  122. package/src/autolearn/managed-skills.ts +257 -0
  123. package/src/autoresearch/state.ts +1 -1
  124. package/src/autoresearch/types.ts +1 -1
  125. package/src/cli/args.ts +56 -2
  126. package/src/cli/session-picker.ts +2 -1
  127. package/src/cli/setup-cli.ts +148 -47
  128. package/src/cli/setup-model-picker.ts +43 -0
  129. package/src/cli-commands.ts +1 -0
  130. package/src/cli.ts +45 -13
  131. package/src/collab/host.ts +1 -1
  132. package/src/collab/protocol.ts +1 -1
  133. package/src/commands/say.ts +102 -0
  134. package/src/commands/setup.ts +1 -1
  135. package/src/commit/agentic/tools/analyze-file.ts +3 -0
  136. package/src/config/keybindings.ts +2 -2
  137. package/src/config/model-discovery.ts +11 -5
  138. package/src/config/model-registry.ts +64 -9
  139. package/src/config/models-config-schema.ts +4 -1
  140. package/src/config/models-config.ts +2 -1
  141. package/src/config/settings-schema.ts +248 -32
  142. package/src/config/settings.ts +10 -0
  143. package/src/discovery/builtin.ts +23 -1
  144. package/src/discovery/claude-plugins.ts +44 -5
  145. package/src/discovery/helpers.ts +41 -1
  146. package/src/eval/__tests__/budget-bridge.test.ts +1 -1
  147. package/src/eval/js/shared/prelude.txt +69 -17
  148. package/src/export/html/index.ts +3 -6
  149. package/src/extensibility/extensions/model-api.ts +41 -0
  150. package/src/extensibility/extensions/runner.ts +4 -0
  151. package/src/extensibility/extensions/types.ts +52 -1
  152. package/src/extensibility/extensions/wrapper.ts +41 -5
  153. package/src/extensibility/hooks/index.ts +2 -1
  154. package/src/extensibility/plugins/legacy-pi-compat.ts +43 -13
  155. package/src/extensibility/plugins/loader.ts +30 -19
  156. package/src/extensibility/plugins/manager.ts +221 -90
  157. package/src/extensibility/shared-events.ts +1 -1
  158. package/src/extensibility/skills.ts +96 -15
  159. package/src/goals/guided-setup.ts +133 -0
  160. package/src/goals/state.ts +1 -1
  161. package/src/hindsight/transcript.ts +1 -1
  162. package/src/index.ts +5 -0
  163. package/src/internal-urls/docs-index.generated.ts +10 -10
  164. package/src/internal-urls/history-protocol.ts +1 -1
  165. package/src/internal-urls/local-protocol.ts +29 -7
  166. package/src/main.ts +27 -7
  167. package/src/mcp/startup-events.ts +21 -0
  168. package/src/mcp/transports/stdio.ts +2 -1
  169. package/src/memories/index.ts +146 -11
  170. package/src/memory-backend/local-backend.ts +11 -5
  171. package/src/mnemopi/backend.ts +1 -0
  172. package/src/mnemopi/config.ts +26 -10
  173. package/src/modes/acp/acp-agent.ts +3 -5
  174. package/src/modes/components/agent-hub.ts +49 -4
  175. package/src/modes/components/assistant-message.ts +4 -37
  176. package/src/modes/components/compaction-summary-message.ts +125 -26
  177. package/src/modes/components/custom-editor.test.ts +96 -0
  178. package/src/modes/components/custom-editor.ts +164 -8
  179. package/src/modes/components/session-selector.ts +1 -1
  180. package/src/modes/components/settings-defs.ts +7 -0
  181. package/src/modes/components/tool-execution.ts +82 -43
  182. package/src/modes/components/transcript-container.ts +70 -1
  183. package/src/modes/components/tree-selector.ts +1 -1
  184. package/src/modes/components/usage-row.ts +18 -0
  185. package/src/modes/components/user-message.ts +4 -2
  186. package/src/modes/controllers/command-controller.ts +14 -4
  187. package/src/modes/controllers/event-controller.ts +78 -11
  188. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  189. package/src/modes/controllers/input-controller.ts +258 -27
  190. package/src/modes/controllers/selector-controller.ts +12 -2
  191. package/src/modes/gradient-highlight.ts +21 -9
  192. package/src/modes/image-references.ts +20 -0
  193. package/src/modes/interactive-mode.ts +286 -40
  194. package/src/modes/magic-keywords.ts +27 -5
  195. package/src/modes/rpc/rpc-mode.ts +146 -14
  196. package/src/modes/rpc/rpc-subagents.ts +2 -2
  197. package/src/modes/rpc/rpc-types.ts +8 -2
  198. package/src/modes/runtime-init.ts +28 -3
  199. package/src/modes/theme/theme.ts +98 -50
  200. package/src/modes/types.ts +6 -2
  201. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  202. package/src/modes/utils/ui-helpers.ts +34 -6
  203. package/src/priority.json +5 -1
  204. package/src/prompts/agents/task.md +1 -0
  205. package/src/prompts/goals/guided-goal-interview.md +8 -0
  206. package/src/prompts/goals/guided-goal-system.md +12 -0
  207. package/src/prompts/memories/read-path.md +6 -0
  208. package/src/prompts/system/autolearn-guidance-learn.md +1 -0
  209. package/src/prompts/system/autolearn-guidance.md +7 -0
  210. package/src/prompts/system/autolearn-nudge.md +3 -0
  211. package/src/prompts/system/eager-task.md +7 -0
  212. package/src/prompts/system/eager-todo.md +11 -6
  213. package/src/prompts/system/subagent-system-prompt.md +4 -0
  214. package/src/prompts/system/system-prompt.md +10 -5
  215. package/src/prompts/system/title-marker-instruction.md +1 -0
  216. package/src/prompts/system/title-system-marker.md +16 -0
  217. package/src/prompts/tools/job.md +1 -0
  218. package/src/prompts/tools/learn.md +7 -0
  219. package/src/prompts/tools/manage-skill.md +9 -0
  220. package/src/prompts/tools/task.md +3 -0
  221. package/src/registry/agent-registry.ts +30 -0
  222. package/src/sdk.ts +88 -24
  223. package/src/secrets/obfuscator.ts +1 -1
  224. package/src/session/agent-session.ts +209 -87
  225. package/src/session/history-storage.ts +2 -2
  226. package/src/session/indexed-session-storage.ts +7 -17
  227. package/src/session/session-context.ts +352 -0
  228. package/src/session/session-entries.ts +194 -0
  229. package/src/session/session-listing.ts +588 -0
  230. package/src/session/session-loader.ts +106 -0
  231. package/src/session/session-manager.ts +933 -3145
  232. package/src/session/session-migrations.ts +78 -0
  233. package/src/session/session-paths.ts +193 -0
  234. package/src/session/session-persistence.ts +131 -0
  235. package/src/session/session-storage.ts +91 -50
  236. package/src/session/snapcompact-inline.ts +21 -1
  237. package/src/session/snapcompact-savings-journal.ts +113 -0
  238. package/src/session/tool-choice-queue.ts +23 -11
  239. package/src/slash-commands/builtin-registry.ts +25 -3
  240. package/src/stt/asr-client.ts +520 -0
  241. package/src/stt/asr-protocol.ts +65 -0
  242. package/src/stt/asr-worker.ts +790 -0
  243. package/src/stt/downloader.ts +107 -47
  244. package/src/stt/endpointer.ts +259 -0
  245. package/src/stt/index.ts +5 -1
  246. package/src/stt/models.ts +150 -0
  247. package/src/stt/recorder.ts +247 -60
  248. package/src/stt/stt-controller.ts +201 -22
  249. package/src/stt/transcriber.ts +37 -68
  250. package/src/stt/wav.ts +173 -0
  251. package/src/system-prompt.ts +8 -0
  252. package/src/task/agents.ts +1 -2
  253. package/src/task/executor.ts +49 -15
  254. package/src/task/index.ts +60 -6
  255. package/src/task/render.ts +83 -8
  256. package/src/task/types.ts +53 -0
  257. package/src/tools/ask.ts +8 -0
  258. package/src/tools/bash.ts +4 -3
  259. package/src/tools/eval-render.ts +4 -3
  260. package/src/tools/index.ts +40 -4
  261. package/src/tools/irc.ts +10 -2
  262. package/src/tools/job.ts +14 -2
  263. package/src/tools/learn.ts +144 -0
  264. package/src/tools/manage-skill.ts +104 -0
  265. package/src/tools/plan-mode-guard.ts +53 -19
  266. package/src/tools/renderers.ts +7 -11
  267. package/src/tools/ssh.ts +4 -3
  268. package/src/tools/todo.ts +1 -1
  269. package/src/tools/tts.ts +203 -92
  270. package/src/tools/write.ts +18 -2
  271. package/src/tts/downloader.ts +64 -0
  272. package/src/tts/index.ts +8 -0
  273. package/src/tts/models.ts +137 -0
  274. package/src/tts/player.ts +137 -0
  275. package/src/tts/runtime.ts +21 -0
  276. package/src/tts/streaming-player.ts +266 -0
  277. package/src/tts/tts-client.ts +647 -0
  278. package/src/tts/tts-protocol.ts +60 -0
  279. package/src/tts/tts-worker.ts +497 -0
  280. package/src/tts/vocalizer.ts +162 -0
  281. package/src/tts/wav.ts +58 -0
  282. package/src/utils/title-generator.ts +48 -5
  283. package/src/utils/tool-choice.ts +16 -0
  284. package/src/utils/tools-manager.test.ts +25 -0
  285. package/src/utils/tools-manager.ts +19 -1
  286. package/src/web/scrapers/github.ts +96 -0
  287. package/src/web/search/index.ts +13 -0
  288. package/src/web/search/providers/searxng.ts +13 -1
  289. package/dist/types/stt/setup.d.ts +0 -18
  290. package/src/stt/setup.ts +0 -52
  291. package/src/stt/transcribe.py +0 -70
@@ -0,0 +1,104 @@
1
+ import * as path from "node:path";
2
+ import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
3
+ import { z } from "zod/v4";
4
+ import {
5
+ deleteManagedSkill,
6
+ getManagedSkillsDir,
7
+ sanitizeSkillName,
8
+ writeManagedSkill,
9
+ } from "../autolearn/managed-skills";
10
+ import { isNameClaimedByAuthoredSkill } from "../extensibility/skills";
11
+ import manageSkillDescription from "../prompts/tools/manage-skill.md" with { type: "text" };
12
+ import type { ToolSession } from ".";
13
+
14
+ const manageSkillSchema = z
15
+ .object({
16
+ action: z.enum(["create", "update", "delete"]),
17
+ name: z.string().describe("kebab-case skill name"),
18
+ description: z
19
+ .string()
20
+ .describe("one-line description of when to use the skill (required for create/update)")
21
+ .optional(),
22
+ body: z
23
+ .string()
24
+ .describe("the SKILL.md body in markdown, no frontmatter (required for create/update)")
25
+ .optional(),
26
+ })
27
+ // Enforce the action/field contract at validation time rather than only in
28
+ // execute. Kept as a cross-field refine (not a discriminated union) so the
29
+ // wire schema stays a single root object — strict structured-output mode and
30
+ // the Anthropic tool-schema builder both require that.
31
+ .refine(p => p.action === "delete" || (p.description !== undefined && p.body !== undefined), {
32
+ message: '"create" and "update" require both "description" and "body".',
33
+ path: ["description"],
34
+ });
35
+
36
+ export type ManageSkillParams = z.infer<typeof manageSkillSchema>;
37
+
38
+ /**
39
+ * Direct create/update/delete of isolated managed skills. Gated behind
40
+ * `autolearn.enabled`; backend-independent (the skill side is standalone).
41
+ */
42
+ export class ManageSkillTool implements AgentTool<typeof manageSkillSchema> {
43
+ readonly name = "manage_skill";
44
+ readonly approval = "write" as const;
45
+ readonly label = "Manage Skill";
46
+ readonly description = manageSkillDescription;
47
+ readonly parameters = manageSkillSchema;
48
+ readonly strict = true;
49
+ readonly loadMode = "essential" as const;
50
+ readonly summary = "Create, update, or delete an isolated managed skill";
51
+
52
+ // No session state needed: createIf reads settings; writes target the
53
+ // home-based managed-skills dir directly.
54
+ static createIf(session: ToolSession): ManageSkillTool | null {
55
+ if (!session.settings.get("autolearn.enabled")) return null;
56
+ return new ManageSkillTool();
57
+ }
58
+
59
+ async execute(_id: string, params: ManageSkillParams): Promise<AgentToolResult> {
60
+ if (params.action === "delete") {
61
+ await deleteManagedSkill(params.name);
62
+ return {
63
+ content: [{ type: "text", text: `Deleted managed skill "${params.name}".` }],
64
+ details: { action: "delete", name: params.name },
65
+ };
66
+ }
67
+
68
+ // Defensive narrowing: the schema refine already rejects create/update
69
+ // without both fields, so this is unreachable for valid input — it only
70
+ // proves the strings are present to `writeManagedSkill`'s typed contract.
71
+ if (!params.description || !params.body) {
72
+ throw new Error(`"${params.action}" requires both "description" and "body".`);
73
+ }
74
+ // A managed skill resolves below any authored skill of the same name
75
+ // (authored always wins in discovery), so creating one under a name an
76
+ // authored skill already claims writes a file that never surfaces. Refuse
77
+ // up front rather than report a false "Created". `sanitizeSkillName`
78
+ // normalizes to the on-disk name the discovery scan compares against.
79
+ if (params.action === "create" && isNameClaimedByAuthoredSkill(sanitizeSkillName(params.name))) {
80
+ return {
81
+ content: [
82
+ {
83
+ type: "text",
84
+ text: `Cannot create managed skill "${params.name}": an authored skill of that name already exists, and managed skills cannot override authored ones. Choose a different name.`,
85
+ },
86
+ ],
87
+ isError: true,
88
+ details: { action: "create", name: params.name, shadowed: true },
89
+ };
90
+ }
91
+ const { path: skillPath } = await writeManagedSkill({
92
+ action: params.action,
93
+ name: params.name,
94
+ description: params.description,
95
+ body: params.body,
96
+ });
97
+ const relativePath = path.relative(getManagedSkillsDir(), skillPath);
98
+ const verb = params.action === "create" ? "Created" : "Updated";
99
+ return {
100
+ content: [{ type: "text", text: `${verb} managed skill "${params.name}" (managed-skills/${relativePath}).` }],
101
+ details: { action: params.action, name: params.name },
102
+ };
103
+ }
104
+ }
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { HL_FILE_HASH_LENGTH, HL_FILE_HASH_SEP, HL_FILE_PREFIX, HL_FILE_SUFFIX } from "@oh-my-pi/hashline";
3
4
  import { resolveLocalRoot, resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
4
5
  import type { ToolSession } from ".";
5
6
  import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
@@ -7,6 +8,7 @@ import { ToolError } from "./tool-errors";
7
8
 
8
9
  const VAULT_SCHEME_PREFIX = "vault:";
9
10
  const LOCAL_SCHEME_PREFIX = "local:";
11
+ const HL_TRAILING_TAG_RE = new RegExp(`${HL_FILE_HASH_SEP}[0-9A-Fa-f]{${HL_FILE_HASH_LENGTH}}$`);
10
12
 
11
13
  /** Resolve the absolute path of the session's `local://` artifact sandbox.
12
14
  * Returns `null` when the session has no artifact wiring (e.g. tests). */
@@ -30,29 +32,57 @@ function isWithinRoot(absolutePath: string, root: string): boolean {
30
32
  return absolutePath.startsWith(sep);
31
33
  }
32
34
 
33
- /** True when `targetPath` addresses the session-local artifact sandbox.
34
- * Accepts both `local://…` URLs and absolute paths pointing inside the
35
- * resolved sandbox root the latter is what `read local://…` echoes back
36
- * in the `[path#tag]` header. Those files are not part of the working tree,
37
- * so plan mode treats them as freely writable scratch/plan space. */
35
+ /** Strip the hashline `[path#TAG]` wrapper from a write/edit target so the inner
36
+ * filesystem path drives both authorization and resolution. Only unwraps inputs
37
+ * that match the strict hashline header shape (`[path]` or `[path#XXXX]` with a
38
+ * 4-hex tag); anything else returns the original string so the downstream
39
+ * resolver surfaces the real error. Exported for callers (e.g. `write`) that
40
+ * make scheme/bridge-routing decisions before {@link resolvePlanPath} runs. */
41
+ export function unwrapHashlineHeaderPath(targetPath: string): string {
42
+ const trimmed = targetPath.trimEnd();
43
+ if (
44
+ trimmed.length < HL_FILE_PREFIX.length + HL_FILE_SUFFIX.length ||
45
+ trimmed[0] !== HL_FILE_PREFIX ||
46
+ trimmed[trimmed.length - 1] !== HL_FILE_SUFFIX
47
+ ) {
48
+ return targetPath;
49
+ }
50
+ const inner = trimmed.slice(HL_FILE_PREFIX.length, trimmed.length - HL_FILE_SUFFIX.length);
51
+ const tagMatch = HL_TRAILING_TAG_RE.exec(inner);
52
+ const pathPart = tagMatch ? inner.slice(0, tagMatch.index) : inner;
53
+ // A valid header is exactly `PATH` or `PATH#XXXX`; reject any other shape
54
+ // (selectors, non-hex tags, embedded `#`) so we never silently rewrite a
55
+ // path the model did not author.
56
+ if (pathPart.length === 0 || pathPart.includes(HL_FILE_HASH_SEP)) return targetPath;
57
+ return pathPart;
58
+ }
59
+
60
+ /** True when `targetPath` resolves into the session-local artifact sandbox.
61
+ * Routes through {@link resolvePlanPath} so the guard and the eventual write
62
+ * always agree on the absolute target (including bracketed hashline headers,
63
+ * `local://` URLs, and bare absolute paths). Files inside the sandbox are not
64
+ * part of the working tree, so plan mode treats them as freely writable
65
+ * scratch/plan space. */
38
66
  function targetsLocalSandbox(session: ToolSession, targetPath: string): boolean {
39
- const normalized = normalizeLocalScheme(targetPath);
40
- if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) return true;
41
- if (!path.isAbsolute(normalized)) return false;
42
67
  const root = localSandboxRoot(session);
43
68
  if (!root) return false;
44
- // Compare both raw and realpath-normalized forms so that
45
- // `/tmp/…` vs `/private/tmp/…` (macOS) and other symlink-collapsed
46
- // roots both resolve to the same sandbox identity.
47
- const resolved = path.resolve(normalized);
48
- if (isWithinRoot(resolved, root)) return true;
69
+ let resolved: string;
70
+ try {
71
+ resolved = resolvePlanPath(session, targetPath);
72
+ } catch {
73
+ return false;
74
+ }
75
+ if (!path.isAbsolute(resolved)) return false;
76
+ const absolute = path.resolve(resolved);
77
+ if (isWithinRoot(absolute, root)) return true;
78
+ // Compare realpath-normalized forms so that `/tmp/…` vs `/private/tmp/…`
79
+ // (macOS) and other symlink-collapsed roots both resolve to the same
80
+ // sandbox identity.
49
81
  try {
50
82
  const realRoot = fs.realpathSync.native(root);
51
- if (isWithinRoot(resolved, realRoot)) return true;
52
- // `resolved` itself may live in `/tmp/...` while `realRoot` is `/private/tmp/...`;
53
- // realpath the parent dir of `resolved` so we catch that direction too.
54
- const realParent = fs.realpathSync.native(path.dirname(resolved));
55
- return isWithinRoot(path.join(realParent, path.basename(resolved)), realRoot);
83
+ if (isWithinRoot(absolute, realRoot)) return true;
84
+ const realParent = fs.realpathSync.native(path.dirname(absolute));
85
+ return isWithinRoot(path.join(realParent, path.basename(absolute)), realRoot);
56
86
  } catch {
57
87
  return false;
58
88
  }
@@ -61,9 +91,13 @@ function targetsLocalSandbox(session: ToolSession, targetPath: string): boolean
61
91
  /**
62
92
  * Resolve a write/edit target to its absolute filesystem path, honoring the
63
93
  * `local://` and `vault://` schemes. Plain paths resolve against the session cwd.
94
+ * Bracketed hashline headers (`[path#TAG]`) are unwrapped first so the inner
95
+ * filesystem path drives resolution — keeping the plan-mode guard and the
96
+ * eventual write in lockstep.
64
97
  */
65
98
  export function resolvePlanPath(session: ToolSession, targetPath: string): string {
66
- const normalized = normalizeLocalScheme(targetPath);
99
+ const unwrapped = unwrapHashlineHeaderPath(targetPath);
100
+ const normalized = normalizeLocalScheme(unwrapped);
67
101
  if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) {
68
102
  return resolveLocalUrlToPath(normalized, {
69
103
  getArtifactsDir: session.getArtifactsDir,
@@ -44,18 +44,14 @@ export type ToolRenderer = {
44
44
  /** Render without background box, inline in the response flow */
45
45
  inline?: boolean;
46
46
  /**
47
- * Collapsed pending preview is provisional a tail-window or otherwise
48
- * re-anchored view the result render replaces wholesale (an edit's
49
- * streamed-diff tail, bash/ssh command caps, eval cells whose outputs
50
- * interleave under each cell). Its rows must never commit to native
51
- * scrollback mid-run; see
52
- * `ToolExecutionComponent.isTranscriptBlockCommitStable`. Absent = the
53
- * pending preview streams top-anchored append-shaped rows the result
54
- * render preserves (task context/assignment, write content), which stay
55
- * commit-eligible so a call taller than the viewport scrolls into history
56
- * instead of reading as cut off.
47
+ * Whether pending-call rows are provisional: useful on screen while a tool is
48
+ * streaming, but not durable transcript history. `true` means every pending
49
+ * shape is provisional. `"collapsed"` means only the collapsed pending shape
50
+ * is provisional; expanded rendering is top-anchored/append-shaped enough to
51
+ * let the transcript commit its settled prefix. Absent = the pending preview
52
+ * streams rows the result render preserves.
57
53
  */
58
- provisionalPendingPreview?: boolean;
54
+ provisionalPendingPreview?: boolean | "collapsed";
59
55
  };
60
56
 
61
57
  export const toolRenderers: Record<string, ToolRenderer> = {
package/src/tools/ssh.ts CHANGED
@@ -346,7 +346,8 @@ export const sshToolRenderer = {
346
346
  });
347
347
  },
348
348
  mergeCallAndResult: true,
349
- // Pending preview caps the command to a viewport-sized tail window that
350
- // shifts while args stream; keep it out of native scrollback mid-run.
351
- provisionalPendingPreview: true,
349
+ // Collapsed pending preview caps the command to a viewport-sized tail window
350
+ // that shifts while args stream. Expanded output is top-anchored enough for
351
+ // the transcript to commit its settled prefix.
352
+ provisionalPendingPreview: "collapsed",
352
353
  };
package/src/tools/todo.ts CHANGED
@@ -8,7 +8,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
8
8
  import type { Theme } from "../modes/theme/theme";
9
9
  import todoDescription from "../prompts/tools/todo.md" with { type: "text" };
10
10
  import type { ToolSession } from "../sdk";
11
- import type { SessionEntry } from "../session/session-manager";
11
+ import type { SessionEntry } from "../session/session-entries";
12
12
  import { framedBlock, renderStatusLine, renderTreeList } from "../tui";
13
13
  import { formatErrorDetail, PREVIEW_LIMITS } from "./render-utils";
14
14
 
package/src/tools/tts.ts CHANGED
@@ -1,10 +1,17 @@
1
1
  // Ported from NousResearch/hermes-agent (MIT) — tools/tts_tool.py L167-171, L896-959.
2
+ // The xAI Grok Voice path below is preserved intact; a local on-device neural TTS
3
+ // backend (Kokoro-82M via kokoro-js on the shared ONNX worker) is layered on behind
4
+ // the `providers.tts` switch.
2
5
 
3
6
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
4
7
  import { type ApiKey, ProviderHttpError, withAuth } from "@oh-my-pi/pi-ai";
5
8
  import { z } from "zod/v4";
9
+ import { settings } from "../config/settings";
6
10
  import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
7
11
  import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http";
12
+ import { DEFAULT_TTS_LOCAL_MODEL_KEY, DEFAULT_TTS_VOICE, isTtsLocalModelKey, KOKORO_VOICES } from "../tts/models";
13
+ import { ttsClient } from "../tts/tts-client";
14
+ import { encodeWav } from "../tts/wav";
8
15
  import { formatPathRelativeToCwd, resolveToCwd } from "./path-utils";
9
16
 
10
17
  // Hermes tts_tool.py L167-171
@@ -22,6 +29,7 @@ const formatVoiceList = (): string =>
22
29
  XAI_BUILTIN_VOICES.map(v => (v === DEFAULT_XAI_VOICE_ID ? `${v} (default)` : v)).join(", ");
23
30
 
24
31
  type TtsCodec = "mp3" | "wav";
32
+ type TtsBackend = "local" | "xai";
25
33
 
26
34
  const ttsSchema = z.object({
27
35
  text: z.string().min(1).max(XAI_MAX_TEXT_LENGTH),
@@ -36,16 +44,200 @@ interface TtsToolDetails {
36
44
  bytes: number;
37
45
  voiceId: string;
38
46
  codec: TtsCodec;
47
+ backend: TtsBackend;
48
+ }
49
+
50
+ /**
51
+ * Pick the synthesis backend. Pure for testability.
52
+ *
53
+ * - `xai` / `local` are honored verbatim (the xAI path still surfaces its own
54
+ * "no credentials" error when creds are missing).
55
+ * - `auto` prefers the local on-device backend, except when the caller asked for
56
+ * an `.mp3` and xAI credentials exist — only the cloud path can emit MP3, so we
57
+ * route there to satisfy the requested container rather than substituting WAV.
58
+ */
59
+ export function resolveTtsBackend(opts: { preference: string; wantsMp3: boolean; hasXaiCreds: boolean }): TtsBackend {
60
+ if (opts.preference === "xai") return "xai";
61
+ if (opts.preference === "local") return "local";
62
+ if (opts.wantsMp3 && opts.hasXaiCreds) return "xai";
63
+ return "local";
64
+ }
65
+
66
+ /**
67
+ * Resolve the on-disk path for local synthesis. Local output is always WAV (no
68
+ * MP3 encoder is bundled), so an `.mp3` (or any non-`.wav`) request is rewritten
69
+ * to a sibling `.wav` and flagged so the tool result can note the substitution.
70
+ */
71
+ export function resolveLocalWavPath(outputPath: string): { wavPath: string; substituted: boolean } {
72
+ const lower = outputPath.toLowerCase();
73
+ if (lower.endsWith(".wav")) return { wavPath: outputPath, substituted: false };
74
+ const slash = Math.max(outputPath.lastIndexOf("/"), outputPath.lastIndexOf("\\"));
75
+ const dot = outputPath.lastIndexOf(".");
76
+ const base = dot > slash ? outputPath.slice(0, dot) : outputPath;
77
+ return { wavPath: `${base}.wav`, substituted: true };
78
+ }
79
+
80
+ function readStringSetting(key: "providers.tts" | "tts.localModel" | "tts.localVoice"): string | undefined {
81
+ try {
82
+ const value = settings.get(key);
83
+ return typeof value === "string" ? value : undefined;
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ async function synthesizeXai(
90
+ params: z.infer<typeof ttsSchema>,
91
+ ctx: CustomToolContext,
92
+ outputPath: string,
93
+ displayPath: string,
94
+ codec: TtsCodec,
95
+ signal: AbortSignal | undefined,
96
+ ): Promise<AgentToolResult<TtsToolDetails, typeof ttsSchema>> {
97
+ const creds = await resolveXAIHttpCredentials(ctx.modelRegistry);
98
+ if (!creds) {
99
+ return {
100
+ isError: true,
101
+ content: [
102
+ {
103
+ type: "text",
104
+ text: "No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok Subscription) or set XAI_API_KEY.",
105
+ },
106
+ ],
107
+ };
108
+ }
109
+
110
+ const voiceId = params.voice_id;
111
+ const language = params.language;
112
+ const sampleRate = params.sample_rate ?? DEFAULT_XAI_SAMPLE_RATE;
113
+ const bitRate = params.bit_rate ?? DEFAULT_XAI_BIT_RATE;
114
+
115
+ const payload: Record<string, unknown> = {
116
+ text: params.text,
117
+ voice_id: voiceId,
118
+ language,
119
+ };
120
+ // Hermes tts_tool.py L926-940 — only send output_format when caller overrides a default.
121
+ const codecOverridden = codec !== "mp3";
122
+ const sampleRateOverridden = sampleRate !== DEFAULT_XAI_SAMPLE_RATE;
123
+ const bitRateOverridden = codec === "mp3" && bitRate !== DEFAULT_XAI_BIT_RATE;
124
+ if (codecOverridden || sampleRateOverridden || bitRateOverridden) {
125
+ const fmt: Record<string, unknown> = { codec };
126
+ if (sampleRate) fmt.sample_rate = sampleRate;
127
+ if (codec === "mp3" && bitRate) fmt.bit_rate = bitRate;
128
+ payload.output_format = fmt;
129
+ }
130
+
131
+ // Compose the caller signal with a 60 s timeout fence.
132
+ const timeoutSignal = AbortSignal.timeout(60_000);
133
+ const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
134
+
135
+ const sessionId = ctx.sessionManager.getSessionId();
136
+ const apiKey: ApiKey = ctx.modelRegistry.resolver(creds.provider, {
137
+ sessionId,
138
+ baseUrl: creds.baseURL,
139
+ });
140
+
141
+ let response: Response;
142
+ try {
143
+ response = await withAuth(
144
+ apiKey,
145
+ async key => {
146
+ const resp = await fetch(`${creds.baseURL}/tts`, {
147
+ method: "POST",
148
+ headers: {
149
+ Authorization: `Bearer ${key}`,
150
+ "Content-Type": "application/json",
151
+ "User-Agent": ohMyPiXAIUserAgent(),
152
+ },
153
+ body: JSON.stringify(payload),
154
+ signal: combinedSignal,
155
+ });
156
+ if (!resp.ok) {
157
+ const detail = await resp.text();
158
+ throw new ProviderHttpError(`xAI TTS failed (${resp.status}): ${detail.slice(0, 300)}`, resp.status, {
159
+ headers: resp.headers,
160
+ });
161
+ }
162
+ return resp;
163
+ },
164
+ { signal: combinedSignal },
165
+ );
166
+ } catch (error) {
167
+ const status = (error as { status?: unknown }).status;
168
+ if (error instanceof Error && typeof status === "number") {
169
+ return {
170
+ isError: true,
171
+ content: [{ type: "text", text: error.message }],
172
+ };
173
+ }
174
+ throw error;
175
+ }
176
+ const bytes = new Uint8Array(await response.arrayBuffer());
177
+ await Bun.write(outputPath, bytes);
178
+ return {
179
+ content: [
180
+ {
181
+ type: "text",
182
+ text: `Saved ${bytes.length} bytes to ${displayPath} (voice=${voiceId}, codec=${codec}, backend=xai).`,
183
+ },
184
+ ],
185
+ details: { bytes: bytes.length, voiceId, codec, backend: "xai" },
186
+ };
187
+ }
188
+
189
+ async function synthesizeLocal(
190
+ params: z.infer<typeof ttsSchema>,
191
+ cwd: string,
192
+ outputPath: string,
193
+ signal: AbortSignal | undefined,
194
+ ): Promise<AgentToolResult<TtsToolDetails, typeof ttsSchema>> {
195
+ const modelSetting = readStringSetting("tts.localModel");
196
+ const modelKey = modelSetting && isTtsLocalModelKey(modelSetting) ? modelSetting : DEFAULT_TTS_LOCAL_MODEL_KEY;
197
+ const voice = readStringSetting("tts.localVoice") || DEFAULT_TTS_VOICE;
198
+
199
+ const audio = await ttsClient.synthesize(modelKey, params.text, { voice, signal });
200
+ if (!audio) {
201
+ return {
202
+ isError: true,
203
+ content: [
204
+ {
205
+ type: "text",
206
+ text: `Local TTS synthesis failed (model=${modelKey}). The on-device worker may be unavailable or the model download was interrupted.`,
207
+ },
208
+ ],
209
+ };
210
+ }
211
+
212
+ const { wavPath, substituted } = resolveLocalWavPath(outputPath);
213
+ const wav = encodeWav(audio.pcm, audio.sampleRate);
214
+ await Bun.write(wavPath, wav);
215
+ const displayPath = formatPathRelativeToCwd(wavPath, cwd);
216
+ const note = substituted
217
+ ? ` No local MP3 encoder is bundled, so WAV (PCM16) was written instead of the requested container.`
218
+ : "";
219
+ return {
220
+ content: [
221
+ {
222
+ type: "text",
223
+ text: `Saved ${wav.length} bytes to ${displayPath} (voice=${modelKey}/${voice}, codec=wav, backend=local, ${audio.sampleRate} Hz).${note}`,
224
+ },
225
+ ],
226
+ details: { bytes: wav.length, voiceId: `${modelKey}/${voice}`, codec: "wav", backend: "local" },
227
+ };
39
228
  }
40
229
 
41
230
  export const ttsTool: CustomTool<typeof ttsSchema, TtsToolDetails> = {
42
231
  name: "tts",
43
- label: "TextToSpeech",
232
+ label: "Speech Generation",
44
233
  strict: false,
45
234
  approval: "write",
46
235
  description:
47
- `Synthesize speech from text using xAI Grok Voice. Built-in voices: ${formatVoiceList()}. ` +
48
- "Custom voice IDs also accepted. Output codec inferred from output_path suffix (.wav wav, else mp3). " +
236
+ "Generate a speech audio file from text and write it to output_path. Two backends, selected by the providers.tts setting (auto|local|xai): " +
237
+ `local = on-device neural TTS (Kokoro-82M via the bundled ONNX runtime, no network, output is always WAV/PCM16; voice set by the tts.localVoice setting — ${KOKORO_VOICES.map(v => (v.id === DEFAULT_TTS_VOICE ? `${v.id} (default)` : v.id)).join(", ")}); ` +
238
+ `xai = xAI Grok Voice cloud (built-in voices: ${formatVoiceList()}; custom voice IDs accepted; MP3 or WAV). ` +
239
+ "auto prefers local, but routes an .mp3 request to xAI when credentials exist (only the cloud path emits MP3); " +
240
+ "otherwise an .mp3 path is written as a sibling .wav. xAI codec is inferred from the output_path suffix. " +
49
241
  `Max ${XAI_MAX_TEXT_LENGTH.toLocaleString("en-US")} characters.`,
50
242
  parameters: ttsSchema,
51
243
  async execute(
@@ -55,99 +247,18 @@ export const ttsTool: CustomTool<typeof ttsSchema, TtsToolDetails> = {
55
247
  ctx: CustomToolContext,
56
248
  signal?: AbortSignal,
57
249
  ): Promise<AgentToolResult<TtsToolDetails, typeof ttsSchema>> {
58
- const creds = await resolveXAIHttpCredentials(ctx.modelRegistry);
59
- if (!creds) {
60
- return {
61
- isError: true,
62
- content: [
63
- {
64
- type: "text",
65
- text: "No xAI credentials. Run /login → xAI Grok OAuth (SuperGrok Subscription) or set XAI_API_KEY.",
66
- },
67
- ],
68
- };
69
- }
70
-
71
250
  const cwd = ctx.sessionManager.getCwd();
72
251
  const outputPath = resolveToCwd(params.output_path, cwd);
73
252
  const displayPath = formatPathRelativeToCwd(outputPath, cwd);
74
253
  const codec: TtsCodec = outputPath.toLowerCase().endsWith(".wav") ? "wav" : "mp3";
75
- const voiceId = params.voice_id;
76
- const language = params.language;
77
- const sampleRate = params.sample_rate ?? DEFAULT_XAI_SAMPLE_RATE;
78
- const bitRate = params.bit_rate ?? DEFAULT_XAI_BIT_RATE;
79
-
80
- const payload: Record<string, unknown> = {
81
- text: params.text,
82
- voice_id: voiceId,
83
- language,
84
- };
85
- // Hermes tts_tool.py L926-940 — only send output_format when caller overrides a default.
86
- const codecOverridden = codec !== "mp3";
87
- const sampleRateOverridden = sampleRate !== DEFAULT_XAI_SAMPLE_RATE;
88
- const bitRateOverridden = codec === "mp3" && bitRate !== DEFAULT_XAI_BIT_RATE;
89
- if (codecOverridden || sampleRateOverridden || bitRateOverridden) {
90
- const fmt: Record<string, unknown> = { codec };
91
- if (sampleRate) fmt.sample_rate = sampleRate;
92
- if (codec === "mp3" && bitRate) fmt.bit_rate = bitRate;
93
- payload.output_format = fmt;
94
- }
95
254
 
96
- // Compose the caller signal with a 60 s timeout fence.
97
- const timeoutSignal = AbortSignal.timeout(60_000);
98
- const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
99
-
100
- const sessionId = ctx.sessionManager.getSessionId();
101
- const apiKey: ApiKey = ctx.modelRegistry.resolver(creds.provider, {
102
- sessionId,
103
- baseUrl: creds.baseURL,
104
- });
105
-
106
- let response: Response;
107
- try {
108
- response = await withAuth(
109
- apiKey,
110
- async key => {
111
- const resp = await fetch(`${creds.baseURL}/tts`, {
112
- method: "POST",
113
- headers: {
114
- Authorization: `Bearer ${key}`,
115
- "Content-Type": "application/json",
116
- "User-Agent": ohMyPiXAIUserAgent(),
117
- },
118
- body: JSON.stringify(payload),
119
- signal: combinedSignal,
120
- });
121
- if (!resp.ok) {
122
- const detail = await resp.text();
123
- throw new ProviderHttpError(`xAI TTS failed (${resp.status}): ${detail.slice(0, 300)}`, resp.status, {
124
- headers: resp.headers,
125
- });
126
- }
127
- return resp;
128
- },
129
- { signal: combinedSignal },
130
- );
131
- } catch (error) {
132
- const status = (error as { status?: unknown }).status;
133
- if (error instanceof Error && typeof status === "number") {
134
- return {
135
- isError: true,
136
- content: [{ type: "text", text: error.message }],
137
- };
138
- }
139
- throw error;
140
- }
141
- const bytes = new Uint8Array(await response.arrayBuffer());
142
- await Bun.write(outputPath, bytes);
143
- return {
144
- content: [
145
- {
146
- type: "text",
147
- text: `Saved ${bytes.length} bytes to ${displayPath} (voice=${voiceId}, codec=${codec}).`,
148
- },
149
- ],
150
- details: { bytes: bytes.length, voiceId, codec },
151
- };
255
+ const preference = readStringSetting("providers.tts") ?? "auto";
256
+ // Only resolve xAI creds when they can affect routing (skip for an explicit local preference).
257
+ const hasXaiCreds =
258
+ preference === "local" ? false : (await resolveXAIHttpCredentials(ctx.modelRegistry)) !== null;
259
+ const backend = resolveTtsBackend({ preference, wantsMp3: codec === "mp3", hasXaiCreds });
260
+
261
+ if (backend === "local") return synthesizeLocal(params, cwd, outputPath, signal);
262
+ return synthesizeXai(params, ctx, outputPath, displayPath, codec, signal);
152
263
  },
153
264
  };
@@ -35,7 +35,7 @@ import {
35
35
  import { invalidateFsScanAfterWrite } from "./fs-cache-invalidation";
36
36
  import { type OutputMeta, outputMeta } from "./output-meta";
37
37
  import { formatPathRelativeToCwd, isInternalUrlPath } from "./path-utils";
38
- import { enforcePlanModeWrite, resolvePlanPath } from "./plan-mode-guard";
38
+ import { enforcePlanModeWrite, resolvePlanPath, unwrapHashlineHeaderPath } from "./plan-mode-guard";
39
39
  import {
40
40
  cachedRenderedString,
41
41
  createRenderedStringCache,
@@ -63,6 +63,7 @@ import { ToolError } from "./tool-errors";
63
63
  import { toolResult } from "./tool-result";
64
64
 
65
65
  const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
66
+ const EXECUTABLE_NOTICE = "[Notice: Made executable via chmod +x]";
66
67
 
67
68
  let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
68
69
  async function loadFflate(): Promise<typeof import("fflate")> {
@@ -818,11 +819,20 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
818
819
  }
819
820
  async execute(
820
821
  _toolCallId: string,
821
- { path, content }: WriteParams,
822
+ { path: rawPath, content }: WriteParams,
822
823
  signal?: AbortSignal,
823
824
  _onUpdate?: AgentToolUpdateCallback<WriteToolDetails>,
824
825
  context?: AgentToolContext,
825
826
  ): Promise<AgentToolResult<WriteToolDetails>> {
827
+ // Strip a hashline `[path#TAG]` wrapper up front so every downstream
828
+ // decision (scheme routing, internal-URL handler dispatch, plan-mode
829
+ // guard, plan path resolution, ACP bridge routing) sees the same
830
+ // filesystem target. Without this, a model that pastes a `read`
831
+ // header as the `path` arg would slip past `isInternalUrlPath`
832
+ // (which fails on a leading `[`) and the bridge router would send a
833
+ // `[local://scratch.md#ABCD]` write to the editor instead of the
834
+ // session-local sandbox.
835
+ const path = unwrapHashlineHeaderPath(rawPath);
826
836
  return untilAborted(signal, async () => {
827
837
  // Strip hashline display prefixes ([PATH#HASH] + LINE:) if the model copied them from read output
828
838
  const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
@@ -932,6 +942,9 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
932
942
  if (stripped) {
933
943
  resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
934
944
  }
945
+ if (madeExecutable) {
946
+ resultText += `\n${EXECUTABLE_NOTICE}`;
947
+ }
935
948
  return {
936
949
  content: [{ type: "text", text: resultText }],
937
950
  details: { resolvedPath: absolutePath, madeExecutable: madeExecutable || undefined },
@@ -950,6 +963,9 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
950
963
  if (stripped) {
951
964
  resultText += `\nNote: auto-stripped hashline display prefixes from content before writing.`;
952
965
  }
966
+ if (madeExecutable) {
967
+ resultText += `\n${EXECUTABLE_NOTICE}`;
968
+ }
953
969
  if (!diagnostics) {
954
970
  return {
955
971
  content: [{ type: "text", text: resultText }],