@oh-my-pi/pi-coding-agent 16.1.23 → 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 (254) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/cli.js +3817 -3735
  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/collab/display-name.d.ts +3 -0
  11. package/dist/types/collab/host.d.ts +0 -2
  12. package/dist/types/commands/worktree.d.ts +0 -3
  13. package/dist/types/config/inline-tool-descriptors-mode.d.ts +13 -0
  14. package/dist/types/config/model-discovery.d.ts +6 -1
  15. package/dist/types/config/model-registry.d.ts +6 -2
  16. package/dist/types/config/model-resolver.d.ts +12 -0
  17. package/dist/types/config/models-config-schema.d.ts +29 -2
  18. package/dist/types/config/models-config.d.ts +22 -1
  19. package/dist/types/config/settings-schema.d.ts +91 -21
  20. package/dist/types/config/settings.d.ts +2 -0
  21. package/dist/types/dap/config.d.ts +1 -1
  22. package/dist/types/edit/hashline/filesystem.d.ts +4 -2
  23. package/dist/types/edit/renderer.d.ts +4 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -17
  25. package/dist/types/extensibility/hooks/types.d.ts +10 -10
  26. package/dist/types/extensibility/plugins/marketplace/types.d.ts +1 -0
  27. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +1 -0
  28. package/dist/types/internal-urls/index.d.ts +1 -0
  29. package/dist/types/internal-urls/registry-helpers.d.ts +2 -0
  30. package/dist/types/internal-urls/router.d.ts +1 -1
  31. package/dist/types/internal-urls/ssh-protocol.d.ts +10 -0
  32. package/dist/types/internal-urls/types.d.ts +19 -2
  33. package/dist/types/irc/bus.d.ts +6 -0
  34. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +1 -0
  35. package/dist/types/modes/components/custom-editor.d.ts +7 -0
  36. package/dist/types/modes/components/move-overlay.d.ts +23 -0
  37. package/dist/types/modes/components/plugin-selector.d.ts +2 -1
  38. package/dist/types/modes/components/queue-mode-selector.d.ts +2 -1
  39. package/dist/types/modes/components/select-list-mouse-routing.d.ts +2 -0
  40. package/dist/types/modes/components/settings-defs.d.ts +4 -1
  41. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  42. package/dist/types/modes/components/show-images-selector.d.ts +2 -1
  43. package/dist/types/modes/components/status-line/component.d.ts +2 -0
  44. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  45. package/dist/types/modes/components/theme-selector.d.ts +2 -1
  46. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  47. package/dist/types/modes/controllers/command-controller.d.ts +10 -1
  48. package/dist/types/modes/controllers/streaming-reveal.d.ts +6 -0
  49. package/dist/types/modes/interactive-mode.d.ts +10 -1
  50. package/dist/types/modes/internal-url-autocomplete.d.ts +1 -1
  51. package/dist/types/modes/running-subagent-badge.d.ts +6 -0
  52. package/dist/types/modes/theme/theme.d.ts +1 -1
  53. package/dist/types/modes/types.d.ts +9 -1
  54. package/dist/types/sdk.d.ts +2 -2
  55. package/dist/types/session/agent-session.d.ts +2 -1
  56. package/dist/types/session/session-manager.d.ts +13 -0
  57. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  58. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +1 -0
  59. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +1 -0
  60. package/dist/types/ssh/connection-manager.d.ts +2 -0
  61. package/dist/types/ssh/file-transfer.d.ts +79 -0
  62. package/dist/types/ssh/utils.d.ts +6 -0
  63. package/dist/types/system-prompt.d.ts +3 -0
  64. package/dist/types/task/executor.d.ts +16 -0
  65. package/dist/types/tiny/text.d.ts +1 -1
  66. package/dist/types/tools/builtin-names.d.ts +5 -1
  67. package/dist/types/tools/{find.d.ts → glob.d.ts} +15 -15
  68. package/dist/types/tools/{search.d.ts → grep.d.ts} +14 -14
  69. package/dist/types/tools/index.d.ts +3 -3
  70. package/dist/types/tools/path-utils.d.ts +29 -0
  71. package/dist/types/tools/read.d.ts +2 -2
  72. package/dist/types/tools/render-utils.d.ts +8 -0
  73. package/dist/types/tools/renderers.d.ts +11 -0
  74. package/dist/types/tools/ssh.d.ts +2 -0
  75. package/dist/types/tools/todo.d.ts +0 -16
  76. package/dist/types/tools/write.d.ts +2 -2
  77. package/dist/types/utils/active-repo-context.d.ts +8 -0
  78. package/dist/types/utils/image-resize.d.ts +1 -0
  79. package/dist/types/utils/markit-cache.d.ts +23 -0
  80. package/dist/types/utils/markit.d.ts +5 -1
  81. package/dist/types/utils/prompt-path.d.ts +1 -0
  82. package/dist/types/web/search/providers/duckduckgo.d.ts +14 -0
  83. package/dist/types/web/search/providers/firecrawl.d.ts +28 -0
  84. package/dist/types/web/search/providers/tinyfish.d.ts +29 -0
  85. package/dist/types/web/search/providers/xai.d.ts +13 -0
  86. package/dist/types/web/search/types.d.ts +18 -2
  87. package/package.json +30 -15
  88. package/scripts/build-binary.ts +9 -9
  89. package/scripts/bundle-dist.ts +2 -2
  90. package/src/advisor/__tests__/advisor.test.ts +40 -4
  91. package/src/advisor/__tests__/emission-guard.test.ts +147 -0
  92. package/src/advisor/advise-tool.ts +1 -1
  93. package/src/advisor/emission-guard.ts +172 -0
  94. package/src/advisor/index.ts +1 -0
  95. package/src/advisor/runtime.ts +11 -0
  96. package/src/advisor/watchdog.ts +12 -1
  97. package/src/cli/args.ts +5 -2
  98. package/src/cli/auth-broker-cli.ts +17 -0
  99. package/src/cli/config-cli.ts +4 -0
  100. package/src/cli/flag-tables.ts +7 -4
  101. package/src/cli/gallery-cli.ts +14 -2
  102. package/src/cli/gallery-fixtures/edit.ts +60 -0
  103. package/src/cli/gallery-fixtures/fs.ts +17 -17
  104. package/src/cli/gallery-fixtures/search.ts +4 -4
  105. package/src/cli/usage-cli.ts +20 -1
  106. package/src/cli/web-search-cli.ts +1 -1
  107. package/src/collab/display-name.ts +13 -0
  108. package/src/collab/guest.ts +5 -1
  109. package/src/collab/host.ts +2 -13
  110. package/src/commands/worktree.ts +6 -0
  111. package/src/config/inline-tool-descriptors-mode.ts +27 -0
  112. package/src/config/model-discovery.ts +77 -8
  113. package/src/config/model-registry.ts +89 -11
  114. package/src/config/model-resolver.ts +39 -1
  115. package/src/config/models-config-schema.ts +41 -5
  116. package/src/config/models-config.ts +4 -1
  117. package/src/config/settings-schema.ts +85 -27
  118. package/src/config/settings.ts +172 -1
  119. package/src/cursor.ts +1 -1
  120. package/src/dap/config.ts +152 -8
  121. package/src/dap/session.ts +1 -1
  122. package/src/discovery/helpers.ts +3 -1
  123. package/src/edit/hashline/diff.ts +14 -3
  124. package/src/edit/hashline/execute.ts +42 -6
  125. package/src/edit/hashline/filesystem.ts +38 -2
  126. package/src/edit/index.ts +1 -0
  127. package/src/edit/modes/patch.ts +11 -1
  128. package/src/edit/renderer.ts +140 -13
  129. package/src/eval/__tests__/agent-bridge.test.ts +101 -0
  130. package/src/eval/agent-bridge.ts +26 -3
  131. package/src/eval/js/tool-bridge.ts +2 -2
  132. package/src/export/html/index.ts +1 -1
  133. package/src/export/html/template.js +3 -1
  134. package/src/export/html/tool-views.generated.js +20 -20
  135. package/src/extensibility/extensions/types.ts +22 -22
  136. package/src/extensibility/hooks/types.ts +11 -11
  137. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +8 -3
  138. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +25 -10
  139. package/src/extensibility/plugins/marketplace/manager.ts +22 -0
  140. package/src/extensibility/plugins/marketplace/types.ts +1 -0
  141. package/src/internal-urls/__tests__/ssh-protocol.test.ts +331 -0
  142. package/src/internal-urls/docs-index.generated.txt +2 -2
  143. package/src/internal-urls/docs-index.ts +2 -3
  144. package/src/internal-urls/index.ts +1 -0
  145. package/src/internal-urls/registry-helpers.ts +19 -4
  146. package/src/internal-urls/router.ts +5 -3
  147. package/src/internal-urls/ssh-protocol.ts +367 -0
  148. package/src/internal-urls/types.ts +19 -2
  149. package/src/irc/bus.ts +11 -3
  150. package/src/mcp/tool-bridge.ts +32 -2
  151. package/src/memories/index.ts +1 -1
  152. package/src/modes/acp/acp-event-mapper.ts +2 -2
  153. package/src/modes/components/__tests__/move-overlay.test.ts +166 -0
  154. package/src/modes/components/agent-hub.ts +3 -0
  155. package/src/modes/components/agent-transcript-viewer.ts +9 -7
  156. package/src/modes/components/custom-editor.ts +90 -5
  157. package/src/modes/components/move-overlay.ts +282 -0
  158. package/src/modes/components/plan-review-overlay.ts +35 -35
  159. package/src/modes/components/plugin-selector.ts +6 -1
  160. package/src/modes/components/queue-mode-selector.ts +6 -1
  161. package/src/modes/components/select-list-mouse-routing.ts +35 -0
  162. package/src/modes/components/session-selector.ts +11 -10
  163. package/src/modes/components/settings-defs.ts +14 -1
  164. package/src/modes/components/settings-selector.ts +196 -29
  165. package/src/modes/components/show-images-selector.ts +6 -1
  166. package/src/modes/components/status-line/component.ts +108 -28
  167. package/src/modes/components/status-line/segments.ts +5 -1
  168. package/src/modes/components/status-line/types.ts +2 -0
  169. package/src/modes/components/theme-selector.ts +6 -1
  170. package/src/modes/components/thinking-selector.ts +6 -1
  171. package/src/modes/components/tool-execution.ts +25 -9
  172. package/src/modes/components/tree-selector.ts +5 -5
  173. package/src/modes/controllers/command-controller.ts +140 -47
  174. package/src/modes/controllers/event-controller.ts +59 -3
  175. package/src/modes/controllers/input-controller.ts +68 -2
  176. package/src/modes/controllers/selector-controller.ts +5 -2
  177. package/src/modes/controllers/streaming-reveal.ts +17 -0
  178. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  179. package/src/modes/interactive-mode.ts +116 -50
  180. package/src/modes/internal-url-autocomplete.ts +17 -2
  181. package/src/modes/prompt-action-autocomplete.ts +3 -1
  182. package/src/modes/running-subagent-badge.ts +13 -0
  183. package/src/modes/setup-wizard/scenes/glyph.ts +2 -13
  184. package/src/modes/setup-wizard/scenes/providers.ts +2 -1
  185. package/src/modes/setup-wizard/scenes/theme.ts +6 -12
  186. package/src/modes/setup-wizard/scenes/web-search.ts +8 -13
  187. package/src/modes/setup-wizard/wizard-overlay.ts +6 -5
  188. package/src/modes/theme/theme.ts +9 -1
  189. package/src/modes/types.ts +9 -1
  190. package/src/modes/utils/interactive-context-helpers.ts +1 -1
  191. package/src/priority.json +15 -0
  192. package/src/prompts/advisor/active-repo-watchdog.md +6 -0
  193. package/src/prompts/advisor/system.md +21 -7
  194. package/src/prompts/agents/designer.md +1 -1
  195. package/src/prompts/agents/explore.md +1 -1
  196. package/src/prompts/agents/librarian.md +2 -2
  197. package/src/prompts/agents/plan.md +2 -2
  198. package/src/prompts/agents/reviewer.md +1 -1
  199. package/src/prompts/agents/task.md +2 -2
  200. package/src/prompts/system/active-repo-context.md +4 -0
  201. package/src/prompts/system/gemini-tool-call-reminder.md +9 -0
  202. package/src/prompts/system/plan-mode-active.md +3 -3
  203. package/src/prompts/system/project-prompt.md +2 -2
  204. package/src/prompts/system/system-prompt.md +6 -6
  205. package/src/prompts/tools/bash.md +2 -2
  206. package/src/prompts/tools/checkpoint.md +1 -1
  207. package/src/prompts/tools/{find.md → glob.md} +1 -1
  208. package/src/prompts/tools/{search.md → grep.md} +3 -3
  209. package/src/prompts/tools/read.md +4 -2
  210. package/src/sdk.ts +55 -25
  211. package/src/session/agent-session.ts +508 -132
  212. package/src/session/session-history-format.ts +2 -2
  213. package/src/session/session-manager.ts +46 -0
  214. package/src/slash-commands/builtin-registry.ts +138 -20
  215. package/src/slash-commands/helpers/usage-report.ts +23 -2
  216. package/src/ssh/__tests__/connection-manager-args.test.ts +69 -0
  217. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +68 -0
  218. package/src/ssh/connection-manager.ts +12 -15
  219. package/src/ssh/file-transfer.ts +209 -0
  220. package/src/ssh/utils.ts +24 -0
  221. package/src/system-prompt.ts +56 -28
  222. package/src/task/executor.ts +96 -36
  223. package/src/task/index.ts +3 -3
  224. package/src/task/render.ts +14 -13
  225. package/src/task/worktree.ts +38 -1
  226. package/src/tiny/text.ts +49 -4
  227. package/src/tiny/worker.ts +3 -3
  228. package/src/tools/bash.ts +2 -2
  229. package/src/tools/builtin-names.ts +26 -2
  230. package/src/tools/{find.ts → glob.ts} +48 -42
  231. package/src/tools/{search.ts → grep.ts} +311 -129
  232. package/src/tools/index.ts +13 -14
  233. package/src/tools/irc.ts +6 -2
  234. package/src/tools/path-utils.ts +64 -1
  235. package/src/tools/read.ts +35 -6
  236. package/src/tools/render-utils.ts +14 -0
  237. package/src/tools/renderers.ts +15 -4
  238. package/src/tools/ssh.ts +17 -2
  239. package/src/tools/todo.ts +4 -26
  240. package/src/tools/write.ts +25 -9
  241. package/src/utils/active-repo-context.ts +143 -0
  242. package/src/utils/image-resize.ts +88 -7
  243. package/src/utils/lang-from-path.ts +3 -3
  244. package/src/utils/markit-cache.ts +166 -0
  245. package/src/utils/markit.ts +86 -18
  246. package/src/utils/prompt-path.ts +3 -0
  247. package/src/utils/title-generator.ts +1 -1
  248. package/src/web/search/provider.ts +54 -34
  249. package/src/web/search/providers/duckduckgo.ts +140 -0
  250. package/src/web/search/providers/firecrawl.ts +144 -0
  251. package/src/web/search/providers/tinyfish.ts +159 -0
  252. package/src/web/search/providers/xai.ts +292 -0
  253. package/src/web/search/providers/zai.ts +142 -56
  254. package/src/web/search/types.ts +6 -2
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Byte-preserving remote file I/O over the shared SSH ControlMaster connection.
3
+ *
4
+ * Unlike `executeSSH` (which truncates/sanitizes through an OutputSink) and
5
+ * `runSshCaptureSync` (which `.trim()`s output), these helpers move raw bytes so
6
+ * `ssh://` reads/writes round-trip exactly — leading/trailing whitespace, tabs,
7
+ * and final newlines are preserved.
8
+ */
9
+ import { ptree } from "@oh-my-pi/pi-utils";
10
+ import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
11
+ import { quotePosixPath } from "./utils";
12
+
13
+ /** Per-operation timeout for remote transfers (matches the ssh tool's grep window). */
14
+ const DEFAULT_TIMEOUT_MS = 30_000;
15
+
16
+ /**
17
+ * Ensure the ControlMaster connection and restrict transfers to remotes whose
18
+ * *login* shell runs our POSIX snippets directly. OpenSSH hands each command to
19
+ * `$SHELL -c`, so the login shell must be POSIX: Windows (cmd/powershell) can't
20
+ * drive `head`/`cat`/`mv`, and csh/tcsh apply `!` history expansion to the
21
+ * command line. `ensureHostInfo` classifies those (and fish) as a non-sh shell,
22
+ * so accept only sh, bash, and zsh; anything else is refused here.
23
+ */
24
+ async function ensurePosixRemote(target: SSHConnectionTarget): Promise<void> {
25
+ await ensureConnection(target);
26
+ const info = await ensureHostInfo(target);
27
+ if (info.os === "windows") {
28
+ throw new Error(
29
+ `ssh://: ${target.name} is a Windows host; ssh:// supports POSIX remotes only (head/cat/mv) — use the ssh tool for Windows hosts`,
30
+ );
31
+ }
32
+ if (info.shell !== "sh" && info.shell !== "bash" && info.shell !== "zsh") {
33
+ throw new Error(
34
+ `ssh://: ${target.name} uses a non-POSIX login shell (${info.shell}); ssh:// read/write needs sh, bash, or zsh — use the ssh tool for this host`,
35
+ );
36
+ }
37
+ }
38
+
39
+ export interface RemoteFileReadOptions {
40
+ /** Maximum bytes to materialize; the helper fetches one extra byte to detect truncation. */
41
+ maxBytes: number;
42
+ signal?: AbortSignal;
43
+ timeoutMs?: number;
44
+ }
45
+
46
+ export interface RemoteFileReadResult {
47
+ /** Raw file bytes, capped at `maxBytes`. */
48
+ bytes: Uint8Array;
49
+ /** True when the remote file was larger than `maxBytes` (`bytes` is the prefix). */
50
+ truncated: boolean;
51
+ }
52
+
53
+ export interface RemoteFileWriteOptions {
54
+ signal?: AbortSignal;
55
+ timeoutMs?: number;
56
+ }
57
+
58
+ /**
59
+ * Read a remote file's raw bytes. Fetches `maxBytes + 1` so the caller can
60
+ * distinguish an exactly-`maxBytes` file from a larger (truncated) one.
61
+ *
62
+ * Throws `ptree.NonZeroExitError` (carrying the remote stderr tail) when the
63
+ * file is missing/unreadable or the host is unreachable.
64
+ */
65
+ export async function readRemoteFile(
66
+ target: SSHConnectionTarget,
67
+ remotePath: string,
68
+ opts: RemoteFileReadOptions,
69
+ ): Promise<RemoteFileReadResult> {
70
+ await ensurePosixRemote(target);
71
+ const command = `head -c ${opts.maxBytes + 1} ${quotePosixPath(remotePath)}`;
72
+ const args = await buildRemoteCommand(target, command);
73
+ using child = ptree.spawn(["ssh", ...args], {
74
+ signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
75
+ });
76
+ // Drain stdout before awaiting exit so a full pipe can't deadlock the child.
77
+ const raw = await child.bytes();
78
+ await child.exitedCleanly;
79
+ const truncated = raw.length > opts.maxBytes;
80
+ return { bytes: truncated ? raw.subarray(0, opts.maxBytes) : raw, truncated };
81
+ }
82
+
83
+ /**
84
+ * Write `content` to a remote file byte-exact. Stdin is always staged first into
85
+ * a uniquely named temp in the destination directory (so the remote never blocks
86
+ * on an unread pipe and a dropped connection lands in the temp, never the
87
+ * destination). The destination then dictates the commit:
88
+ * - a directory — or a symlink to one, since the `-d` test follows links — is
89
+ * refused (a plain `mv tmp dir` would move the temp INTO it).
90
+ * - an existing non-symlink regular file is rewritten IN PLACE from the staged
91
+ * temp, preserving its inode and therefore its ordinary permission bits (a
92
+ * `0600` secret stays `0600` on overwrite), ACLs, xattrs, and hardlinks. The
93
+ * setuid/setgid bits may be cleared by the write (per POSIX). This commit is
94
+ * not fully atomic — a remote-side failure during the local temp->dest copy
95
+ * (e.g. the disk filling) can truncate the destination — but the slow network
96
+ * transfer has already landed in the temp, and the temp is removed on failure.
97
+ * It also needs write permission on the file itself (a read-only file is
98
+ * refused, not silently replaced).
99
+ * - an existing special file (FIFO/socket/device) is refused, not replaced.
100
+ * - anything else (a new path, a symlink to a non-directory, a dangling symlink)
101
+ * is committed with an atomic rename, which REPLACES a symlink with a regular
102
+ * file rather than writing through it (resolving the link target is not
103
+ * portable across the macOS/Linux hosts this stack supports).
104
+ * Throws `ptree.NonZeroExitError` when the remote path is unwritable or the host
105
+ * is unreachable.
106
+ */
107
+ export async function writeRemoteFile(
108
+ target: SSHConnectionTarget,
109
+ remotePath: string,
110
+ content: Uint8Array,
111
+ opts: RemoteFileWriteOptions,
112
+ ): Promise<void> {
113
+ await ensurePosixRemote(target);
114
+ if (remotePath.endsWith("/")) {
115
+ throw new Error("ssh://: destination is a directory path (trailing '/'); ssh:// write requires a file path");
116
+ }
117
+ const dest = quotePosixPath(remotePath);
118
+ const tmp = quotePosixPath(`${remotePath}.omp-tmp.${crypto.randomUUID()}`);
119
+ // Stage stdin into the temp first (so the remote never blocks on an unread
120
+ // pipe and a dropped connection lands in the temp, never the destination).
121
+ // An EXIT trap removes the staged temp on every exit path (staging failure,
122
+ // in-place success, refuse branches, or a failed rename). Commit by
123
+ // destination kind: a directory (or symlink to one; `-d` follows links) is
124
+ // refused; an existing non-symlink regular file is rewritten IN PLACE
125
+ // (preserving inode, permission bits, ACLs, xattrs, hardlinks; setuid/setgid
126
+ // may clear); an existing special file (FIFO/socket/device) is refused;
127
+ // anything else (a new path or a symlink to a non-directory) uses temp+rename,
128
+ // replacing such a symlink rather than writing through it.
129
+ const command =
130
+ `t=${tmp}; trap 'rm -f -- "$t"' 0; ` +
131
+ `mkdir -p -- "$(dirname "$t")" && ` +
132
+ `cat > "$t" && { ` +
133
+ `if [ -d ${dest} ]; then echo 'ssh://: destination is a directory' >&2; exit 1; ` +
134
+ `elif [ -f ${dest} ] && [ ! -L ${dest} ]; then cat "$t" > ${dest} || exit 1; ` +
135
+ `elif [ -e ${dest} ] && [ ! -L ${dest} ]; then echo 'ssh://: destination is a special file (not a regular file)' >&2; exit 1; ` +
136
+ `else mv "$t" ${dest}; fi; ` +
137
+ `}`;
138
+ const args = await buildRemoteCommand(target, command, { allowStdin: true });
139
+ using child = ptree.spawn(["ssh", ...args], {
140
+ stdin: content,
141
+ signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
142
+ });
143
+ await child.exitedCleanly;
144
+ }
145
+
146
+ /** Classification of a remote path, used by the read handler's directory dispatch. */
147
+ export type RemotePathKind = "file" | "directory" | "other" | "missing";
148
+
149
+ /**
150
+ * Classify a remote path with POSIX `test` (portable across Linux/BSD/macOS):
151
+ * `directory`, regular `file`, `other` (special file), or `missing`.
152
+ */
153
+ export async function statRemotePath(
154
+ target: SSHConnectionTarget,
155
+ remotePath: string,
156
+ opts: { signal?: AbortSignal; timeoutMs?: number } = {},
157
+ ): Promise<RemotePathKind> {
158
+ await ensurePosixRemote(target);
159
+ const p = quotePosixPath(remotePath);
160
+ const command = `if [ -d ${p} ]; then echo directory; elif [ -f ${p} ]; then echo file; elif [ -e ${p} ]; then echo other; else echo missing; fi`;
161
+ const args = await buildRemoteCommand(target, command);
162
+ using child = ptree.spawn(["ssh", ...args], {
163
+ signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
164
+ });
165
+ const out = new TextDecoder().decode(await child.bytes()).trim();
166
+ await child.exitedCleanly;
167
+ return out === "directory" || out === "file" || out === "other" ? out : "missing";
168
+ }
169
+
170
+ /** A single entry in a remote directory listing. */
171
+ export interface RemoteDirEntry {
172
+ /** Entry name (no path component), trailing `/` stripped. */
173
+ name: string;
174
+ /** True when the entry is a directory. */
175
+ isDirectory: boolean;
176
+ }
177
+
178
+ /**
179
+ * List a remote directory one level deep with `ls -1Ap` (one per line; all
180
+ * entries incl. dotfiles but not `.`/`..`; trailing `/` marks directories).
181
+ * Plain `ls` (no `| head`) so a permission/race failure surfaces as a non-zero
182
+ * exit instead of being masked as an empty listing. Entries are returned in
183
+ * full, sorted directories-first then by name to mirror the local
184
+ * directory-resource contract, so the read tool can paginate the listing.
185
+ */
186
+ export async function listRemoteDir(
187
+ target: SSHConnectionTarget,
188
+ remotePath: string,
189
+ opts: { signal?: AbortSignal; timeoutMs?: number } = {},
190
+ ): Promise<RemoteDirEntry[]> {
191
+ await ensurePosixRemote(target);
192
+ const command = `LC_ALL=C ls -1Ap -- ${quotePosixPath(remotePath)}`;
193
+ const args = await buildRemoteCommand(target, command);
194
+ using child = ptree.spawn(["ssh", ...args], {
195
+ signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
196
+ });
197
+ const text = new TextDecoder().decode(await child.bytes());
198
+ await child.exitedCleanly;
199
+ const entries = text
200
+ .split("\n")
201
+ .filter(line => line.length > 0)
202
+ .map(line => {
203
+ const isDirectory = line.endsWith("/");
204
+ return { name: isDirectory ? line.slice(0, -1) : line, isDirectory };
205
+ });
206
+ // JS sort is the order contract (mirrors buildDirectoryResource): dirs first, then by name.
207
+ entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name));
208
+ return entries;
209
+ }
package/src/ssh/utils.ts CHANGED
@@ -4,5 +4,29 @@ export function sanitizeHostName(name: string): string {
4
4
  }
5
5
 
6
6
  export function buildSshTarget(username: string | undefined, host: string): string {
7
+ // SSH treats a destination starting with "-" as an option, so a host/user of
8
+ // `-oProxyCommand=...` becomes local command execution. Reject before this
9
+ // string reaches any `ssh` argv (this is the single render chokepoint for
10
+ // every connection, transfer, and sshfs mount).
11
+ if (host.startsWith("-")) {
12
+ throw new Error(
13
+ `Invalid SSH host "${host}": an SSH destination must not begin with "-" (argument-injection guard)`,
14
+ );
15
+ }
16
+ if (username?.startsWith("-")) {
17
+ throw new Error(
18
+ `Invalid SSH username "${username}": an SSH username must not begin with "-" (argument-injection guard)`,
19
+ );
20
+ }
7
21
  return username ? `${username}@${host}` : host;
8
22
  }
23
+
24
+ /**
25
+ * Single-quote a path for a POSIX remote shell, escaping embedded single quotes.
26
+ * Mirrors the private `quoteRemotePath` in `tools/ssh.ts`; shared here for the
27
+ * `ssh://` file-transfer helpers.
28
+ */
29
+ export function quotePosixPath(value: string): string {
30
+ if (value.length === 0) return "''";
31
+ return `'${value.replace(/'/g, "'\\''")}'`;
32
+ }
@@ -16,6 +16,7 @@ import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile
16
16
  import { expandAtImports } from "./discovery/at-imports";
17
17
  import { loadSkills, type Skill } from "./extensibility/skills";
18
18
  import { hasObsidian } from "./internal-urls/vault-protocol";
19
+ import activeRepoContextTemplate from "./prompts/system/active-repo-context.md" with { type: "text" };
19
20
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
20
21
  import defaultPersonality from "./prompts/system/personalities/default.md" with { type: "text" };
21
22
  import friendlyPersonality from "./prompts/system/personalities/friendly.md" with { type: "text" };
@@ -23,6 +24,8 @@ import pragmaticPersonality from "./prompts/system/personalities/pragmatic.md" w
23
24
  import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" };
24
25
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
25
26
  import { shortenPath } from "./tools/render-utils";
27
+ import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context";
28
+ import { normalizePromptPath } from "./utils/prompt-path";
26
29
  import { AGENTS_MD_LIMIT, buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
27
30
 
28
31
  /** Bundled personality specs, keyed by the `personality` setting value. */
@@ -90,6 +93,15 @@ function firstNonEmpty(...values: (string | undefined | null)[]): string | null
90
93
  return null;
91
94
  }
92
95
 
96
+ function renderActiveRepoContextPrompt(activeRepoContext: ActiveRepoContext | null): string {
97
+ if (!activeRepoContext) return "";
98
+ return prompt
99
+ .render(activeRepoContextTemplate, {
100
+ relativeRepoRoot: normalizePromptPath(activeRepoContext.relativeRepoRoot),
101
+ })
102
+ .trim();
103
+ }
104
+
93
105
  function parseWmicTable(output: string, header: string): string | null {
94
106
  const lines = output
95
107
  .split("\n")
@@ -425,6 +437,8 @@ export interface BuildSystemPromptOptions {
425
437
  includeWorkspaceTree?: boolean;
426
438
  /** Whether Mermaid fenced blocks render as terminal ASCII diagrams. Default: true */
427
439
  renderMermaid?: boolean;
440
+ /** Pre-resolved nested active repo context. Undefined resolves from cwd. */
441
+ activeRepoContext?: ActiveRepoContext | null;
428
442
  }
429
443
 
430
444
  /** Result of building provider-facing system prompt messages. */
@@ -467,6 +481,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
467
481
  personality = "default",
468
482
  includeWorkspaceTree = false,
469
483
  renderMermaid = true,
484
+ activeRepoContext: providedActiveRepoContext,
470
485
  } = options;
471
486
  const inlineToolDescriptors = providedInlineToolDescriptors ?? false;
472
487
  const resolvedCwd = cwd ?? getProjectDir();
@@ -484,6 +499,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
484
499
  totalLines: 0,
485
500
  agentsMdFiles: [],
486
501
  } satisfies WorkspaceTree,
502
+ activeRepoContext: null as ActiveRepoContext | null,
487
503
  };
488
504
 
489
505
  const deadline = Bun.sleep(SYSTEM_PROMPT_PREP_TIMEOUT_MS).then(() => "__timeout__" as const);
@@ -546,34 +562,42 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
546
562
  : skillsSettings?.enabled !== false
547
563
  ? loadSkills({ ...skillsSettings, cwd: resolvedCwd }).then(result => result.skills)
548
564
  : Promise.resolve([]);
565
+ const activeRepoContextPromise =
566
+ providedActiveRepoContext !== undefined
567
+ ? Promise.resolve(providedActiveRepoContext)
568
+ : logger.time("resolveActiveRepoContext", () => resolveActiveRepoContext(resolvedCwd));
549
569
 
550
- const [resolvedCustomPrompt, resolvedAppendPrompt, systemPromptCustomization, contextFiles, skills, workspaceTree] =
551
- await Promise.all([
552
- withDeadline(
553
- "customPrompt",
554
- providedResolvedCustomPrompt !== undefined
555
- ? Promise.resolve(providedResolvedCustomPrompt)
556
- : resolvePromptInput(customPrompt, "system prompt"),
557
- prepDefaults.resolvedCustomPrompt,
558
- ),
559
- withDeadline(
560
- "appendSystemPrompt",
561
- providedResolvedAppendPrompt !== undefined
562
- ? Promise.resolve(providedResolvedAppendPrompt)
563
- : resolvePromptInput(appendSystemPrompt, "append system prompt"),
564
- prepDefaults.resolvedAppendPrompt,
565
- ),
566
- withDeadline(
567
- "loadSystemPromptFiles",
568
- systemPromptCustomizationPromise,
569
- prepDefaults.systemPromptCustomization,
570
- ),
571
- withDeadline("loadProjectContextFiles", contextFilesPromise, prepDefaults.contextFiles).then(
572
- dedupeExactContextFiles,
573
- ),
574
- withDeadline("loadSkills", skillsPromise, prepDefaults.skills),
575
- withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree),
576
- ]);
570
+ const [
571
+ resolvedCustomPrompt,
572
+ resolvedAppendPrompt,
573
+ systemPromptCustomization,
574
+ contextFiles,
575
+ skills,
576
+ workspaceTree,
577
+ activeRepoContext,
578
+ ] = await Promise.all([
579
+ withDeadline(
580
+ "customPrompt",
581
+ providedResolvedCustomPrompt !== undefined
582
+ ? Promise.resolve(providedResolvedCustomPrompt)
583
+ : resolvePromptInput(customPrompt, "system prompt"),
584
+ prepDefaults.resolvedCustomPrompt,
585
+ ),
586
+ withDeadline(
587
+ "appendSystemPrompt",
588
+ providedResolvedAppendPrompt !== undefined
589
+ ? Promise.resolve(providedResolvedAppendPrompt)
590
+ : resolvePromptInput(appendSystemPrompt, "append system prompt"),
591
+ prepDefaults.resolvedAppendPrompt,
592
+ ),
593
+ withDeadline("loadSystemPromptFiles", systemPromptCustomizationPromise, prepDefaults.systemPromptCustomization),
594
+ withDeadline("loadProjectContextFiles", contextFilesPromise, prepDefaults.contextFiles).then(
595
+ dedupeExactContextFiles,
596
+ ),
597
+ withDeadline("loadSkills", skillsPromise, prepDefaults.skills),
598
+ withDeadline("buildWorkspaceTree", workspaceTreePromise, prepDefaults.workspaceTree),
599
+ withDeadline("resolveActiveRepoContext", activeRepoContextPromise, prepDefaults.activeRepoContext),
600
+ ]);
577
601
  const agentsMdFiles = Array.from(new Set(workspaceTree.agentsMdFiles)).sort().slice(0, AGENTS_MD_LIMIT);
578
602
 
579
603
  if (timedOut.length > 0) {
@@ -598,7 +622,8 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
598
622
 
599
623
  const date = new Date().toISOString().slice(0, 10);
600
624
  const dateTime = date;
601
- const promptCwd = shortenPath(resolvedCwd.replace(/\\/g, "/"));
625
+ const promptCwd = shortenPath(normalizePromptPath(resolvedCwd));
626
+ const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext);
602
627
 
603
628
  // Build tool metadata for system prompt rendering.
604
629
  // Priority: explicit list > tools map > conservative SDK fallback.
@@ -697,6 +722,9 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
697
722
  if (projectPrompt) {
698
723
  systemPrompt.push(projectPrompt);
699
724
  }
725
+ if (activeRepoContextPrompt) {
726
+ systemPrompt.push(activeRepoContextPrompt);
727
+ }
700
728
 
701
729
  return { systemPrompt };
702
730
  }
@@ -421,6 +421,12 @@ export interface ExecutorOptions {
421
421
  * passes its own `getAgentId()`).
422
422
  */
423
423
  parentAgentId?: string;
424
+ /**
425
+ * Keep the finished subagent addressable in the registry for IRC/revival.
426
+ * Defaults to true. Eval bridge agents are programmatic one-shot helpers and
427
+ * set this false so disposal unregisters them instead of leaving idle peers.
428
+ */
429
+ keepAlive?: boolean;
424
430
  }
425
431
 
426
432
  function parseStringifiedJson(value: unknown): unknown {
@@ -854,6 +860,8 @@ interface SubagentRunMonitor {
854
860
  /** Whether the (attempted) abort counts as a cancelled run rather than an internal failure. */
855
861
  isAbortedRun(): boolean;
856
862
  requestAbort(reason: AbortReason): void;
863
+ abortActiveSession(): Promise<void>;
864
+ waitForActiveSessionAbort(): Promise<void>;
857
865
  resolveSignalAbortReason(): string;
858
866
  resolveAbortReasonText(): string;
859
867
  setActiveSession(session: AgentSession | null): void;
@@ -923,6 +931,22 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
923
931
  let budgetSteerSent = false;
924
932
  let budgetLimitExceeded = false;
925
933
  let lastAssistantSalvageText: string | undefined;
934
+ let activeSessionAbortPromise: Promise<void> | undefined;
935
+
936
+ const abortActiveSession = (): Promise<void> => {
937
+ const session = activeSession;
938
+ if (!session) return Promise.resolve();
939
+ activeSessionAbortPromise ??= session.abort().catch(error => {
940
+ logger.debug("Subagent session abort cleanup failed", {
941
+ error: error instanceof Error ? error.message : String(error),
942
+ });
943
+ });
944
+ return activeSessionAbortPromise;
945
+ };
946
+
947
+ const waitForActiveSessionAbort = async (): Promise<void> => {
948
+ if (activeSessionAbortPromise) await activeSessionAbortPromise;
949
+ };
926
950
 
927
951
  const requestAbort = (reason: AbortReason) => {
928
952
  if (reason === "timeout") {
@@ -941,9 +965,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
941
965
  abortSent = true;
942
966
  abortReason = reason;
943
967
  abortController.abort();
944
- if (activeSession) {
945
- void activeSession.abort();
946
- }
968
+ void abortActiveSession();
947
969
  };
948
970
 
949
971
  // Handle abort signal
@@ -1423,6 +1445,8 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1423
1445
  isAbortedRun: () =>
1424
1446
  abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded || abortReason === undefined,
1425
1447
  requestAbort,
1448
+ abortActiveSession,
1449
+ waitForActiveSessionAbort,
1426
1450
  resolveSignalAbortReason,
1427
1451
  resolveAbortReasonText,
1428
1452
  setActiveSession: session => {
@@ -1752,6 +1776,59 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise<SingleResult> {
1752
1776
  };
1753
1777
  }
1754
1778
 
1779
+ export async function finalizeSubagentLifecycle(args: {
1780
+ id: string;
1781
+ session: AgentSession;
1782
+ aborted: boolean;
1783
+ keepAlive: boolean;
1784
+ isolated: boolean;
1785
+ agentIdleTtlMs: number;
1786
+ reviveSession: (() => Promise<AgentSession>) | null;
1787
+ }): Promise<void> {
1788
+ const registry = AgentRegistry.global();
1789
+ const disposeSession = async (): Promise<void> => {
1790
+ try {
1791
+ await untilAborted(AbortSignal.timeout(5000), () => args.session.dispose());
1792
+ } catch {
1793
+ // Ignore cleanup errors
1794
+ }
1795
+ };
1796
+
1797
+ if (args.aborted) {
1798
+ // Hard abort (caller signal / wall-clock / budget): terminal teardown.
1799
+ registry.setStatus(args.id, "aborted");
1800
+ await disposeSession();
1801
+ return;
1802
+ }
1803
+
1804
+ if (!args.keepAlive) {
1805
+ // One-shot helper: dispose and unregister. No IRC, no revival.
1806
+ await disposeSession();
1807
+ registry.unregister(args.id);
1808
+ return;
1809
+ }
1810
+
1811
+ if (args.isolated) {
1812
+ // Isolated run: the worktree is merged + cleaned after the run, so
1813
+ // the session is not resumable. Park the ref WITHOUT adopting — the
1814
+ // transcript stays reachable (history://), but ensureLive will throw.
1815
+ // Status must flip to "parked" before dispose so the sdk dispose
1816
+ // wrapper skips unregister.
1817
+ registry.setStatus(args.id, "parked");
1818
+ await disposeSession();
1819
+ registry.detachSession(args.id);
1820
+ return;
1821
+ }
1822
+
1823
+ // Keep-alive: finished and failed subagents both stay interrogable.
1824
+ // The lifecycle manager owns idle-TTL parking + revival from here on.
1825
+ registry.setStatus(args.id, "idle");
1826
+ AgentLifecycleManager.global().adopt(args.id, {
1827
+ idleTtlMs: args.agentIdleTtlMs,
1828
+ revive: args.reviveSession ?? undefined,
1829
+ });
1830
+ }
1831
+
1755
1832
  /**
1756
1833
  * Run a single agent in-process.
1757
1834
  */
@@ -2211,7 +2288,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2211
2288
  abortSignal.addEventListener(
2212
2289
  "abort",
2213
2290
  () => {
2214
- void session.abort();
2291
+ void monitor.abortActiveSession();
2215
2292
  },
2216
2293
  { once: true, signal: sessionAbortController.signal },
2217
2294
  );
@@ -2219,7 +2296,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2219
2296
  // the awaited setup above, the listener registration races the dispatch
2220
2297
  // and may not observe the already-fired abort event. Mirror it manually.
2221
2298
  if (abortSignal.aborted) {
2222
- void session.abort();
2299
+ void monitor.abortActiveSession();
2223
2300
  }
2224
2301
 
2225
2302
  const pendingExtensionMessages: Array<Promise<unknown>> = [];
@@ -2325,6 +2402,11 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2325
2402
  providerSemaphoreAcquired = false;
2326
2403
  }
2327
2404
  sessionAbortController.abort();
2405
+ try {
2406
+ await untilAborted(AbortSignal.timeout(5000), () => monitor.waitForActiveSessionAbort());
2407
+ } catch {
2408
+ // Ignore abort cleanup timeouts/errors; terminal disposal below is still best-effort.
2409
+ }
2328
2410
  if (unsubscribe) {
2329
2411
  try {
2330
2412
  unsubscribe();
@@ -2336,37 +2418,15 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2336
2418
  const session = monitor.takeActiveSession();
2337
2419
  if (session) {
2338
2420
  monitor.captureSalvage(session);
2339
- const registry = AgentRegistry.global();
2340
- if (aborted) {
2341
- // Hard abort (caller signal / wall-clock / budget): terminal teardown.
2342
- registry.setStatus(id, "aborted");
2343
- try {
2344
- await untilAborted(AbortSignal.timeout(5000), () => session.dispose());
2345
- } catch {
2346
- // Ignore cleanup errors
2347
- }
2348
- } else if (worktree !== undefined) {
2349
- // Isolated run: the worktree is merged + cleaned after the run, so
2350
- // the session is not resumable. Park the ref WITHOUT adopting — the
2351
- // transcript stays reachable (history://), but ensureLive will throw.
2352
- // Status must flip to "parked" before dispose so the sdk dispose
2353
- // wrapper skips unregister.
2354
- registry.setStatus(id, "parked");
2355
- try {
2356
- await untilAborted(AbortSignal.timeout(5000), () => session.dispose());
2357
- } catch {
2358
- // Ignore cleanup errors
2359
- }
2360
- registry.detachSession(id);
2361
- } else {
2362
- // Keep-alive: finished and failed subagents both stay interrogable.
2363
- // The lifecycle manager owns idle-TTL parking + revival from here on.
2364
- registry.setStatus(id, "idle");
2365
- AgentLifecycleManager.global().adopt(id, {
2366
- idleTtlMs: agentIdleTtlMs,
2367
- revive: reviveSession ?? undefined,
2368
- });
2369
- }
2421
+ await finalizeSubagentLifecycle({
2422
+ id,
2423
+ session,
2424
+ aborted,
2425
+ keepAlive: options.keepAlive !== false,
2426
+ isolated: worktree !== undefined,
2427
+ agentIdleTtlMs,
2428
+ reviveSession,
2429
+ });
2370
2430
  }
2371
2431
  }
2372
2432
 
package/src/task/index.ts CHANGED
@@ -136,8 +136,8 @@ export {
136
136
  // Fail-safe: any unknown tool makes the agent not read-only.
137
137
  export const READ_ONLY_TOOL_NAMES: ReadonlySet<string> = new Set([
138
138
  "read",
139
- "search",
140
- "find",
139
+ "grep",
140
+ "glob",
141
141
  "web_search",
142
142
  "ast_grep",
143
143
  "yield",
@@ -1076,7 +1076,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1076
1076
  }
1077
1077
 
1078
1078
  const planModeState = this.session.getPlanModeState?.();
1079
- const planModeBaseTools = ["read", "search", "find", "lsp", "web_search"];
1079
+ const planModeBaseTools = ["read", "grep", "glob", "lsp", "web_search"];
1080
1080
  const planModeTools = [
1081
1081
  ...planModeBaseTools,
1082
1082
  ...(agent.tools ?? []).filter(