@oh-my-pi/pi-coding-agent 16.2.2 → 16.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +3624 -3568
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  8. package/dist/types/advisor/watchdog.d.ts +20 -0
  9. package/dist/types/collab/guest.d.ts +29 -0
  10. package/dist/types/config/settings-schema.d.ts +81 -0
  11. package/dist/types/debug/log-viewer.d.ts +1 -0
  12. package/dist/types/debug/raw-sse.d.ts +1 -0
  13. package/dist/types/edit/hashline/diff.d.ts +0 -11
  14. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  15. package/dist/types/extensibility/utils.d.ts +12 -0
  16. package/dist/types/mcp/transports/index.d.ts +1 -0
  17. package/dist/types/mcp/transports/sse.d.ts +20 -0
  18. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  19. package/dist/types/modes/components/index.d.ts +1 -0
  20. package/dist/types/modes/components/model-selector.d.ts +9 -1
  21. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  22. package/dist/types/modes/components/status-line/component.d.ts +30 -1
  23. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  24. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  25. package/dist/types/modes/interactive-mode.d.ts +10 -4
  26. package/dist/types/modes/skill-command.d.ts +32 -0
  27. package/dist/types/modes/types.d.ts +7 -2
  28. package/dist/types/session/agent-session.d.ts +58 -10
  29. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  30. package/dist/types/session/messages.d.ts +26 -0
  31. package/dist/types/session/messages.test.d.ts +1 -0
  32. package/dist/types/session/session-entries.d.ts +31 -3
  33. package/dist/types/session/session-history-format.d.ts +6 -0
  34. package/dist/types/session/session-loader.d.ts +9 -1
  35. package/dist/types/session/session-manager.d.ts +6 -4
  36. package/dist/types/session/session-storage.d.ts +11 -0
  37. package/dist/types/session/session-title-slot.d.ts +19 -0
  38. package/dist/types/ssh/connection-manager.d.ts +47 -0
  39. package/dist/types/ssh/utils.d.ts +16 -0
  40. package/dist/types/task/executor.d.ts +3 -16
  41. package/dist/types/task/render.d.ts +0 -5
  42. package/dist/types/task/renderer.d.ts +13 -0
  43. package/dist/types/task/types.d.ts +16 -0
  44. package/dist/types/task/yield-assembly.d.ts +28 -0
  45. package/dist/types/tiny/text.d.ts +8 -0
  46. package/dist/types/tools/render-utils.d.ts +2 -0
  47. package/dist/types/tools/review.d.ts +6 -4
  48. package/dist/types/tools/ssh.d.ts +1 -1
  49. package/dist/types/tools/todo.d.ts +6 -0
  50. package/dist/types/tools/yield.d.ts +8 -3
  51. package/dist/types/utils/thinking-display.d.ts +4 -0
  52. package/package.json +12 -12
  53. package/src/advisor/__tests__/advisor.test.ts +242 -10
  54. package/src/advisor/__tests__/config.test.ts +173 -0
  55. package/src/advisor/advise-tool.ts +11 -6
  56. package/src/advisor/config.ts +256 -0
  57. package/src/advisor/index.ts +1 -0
  58. package/src/advisor/runtime.ts +12 -2
  59. package/src/advisor/transcript-recorder.ts +25 -2
  60. package/src/advisor/watchdog.ts +57 -31
  61. package/src/autoresearch/index.ts +7 -2
  62. package/src/cli/gc-cli.ts +17 -10
  63. package/src/collab/guest.ts +43 -7
  64. package/src/config/model-registry.ts +80 -18
  65. package/src/config/settings-schema.ts +77 -0
  66. package/src/debug/index.ts +32 -7
  67. package/src/debug/log-viewer.ts +111 -53
  68. package/src/debug/raw-sse.ts +68 -48
  69. package/src/discovery/codex.ts +13 -5
  70. package/src/edit/hashline/diff.ts +57 -4
  71. package/src/eval/js/shared/local-module-loader.ts +23 -1
  72. package/src/export/html/template.js +13 -7
  73. package/src/extensibility/extensions/loader.ts +5 -3
  74. package/src/extensibility/extensions/wrapper.ts +9 -3
  75. package/src/extensibility/hooks/loader.ts +3 -3
  76. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  77. package/src/extensibility/plugins/manager.ts +2 -1
  78. package/src/extensibility/tool-event-input.ts +23 -0
  79. package/src/extensibility/utils.ts +74 -0
  80. package/src/internal-urls/docs-index.generated.txt +1 -1
  81. package/src/mcp/client.ts +3 -1
  82. package/src/mcp/manager.ts +12 -5
  83. package/src/mcp/transports/index.ts +1 -0
  84. package/src/mcp/transports/sse.ts +377 -0
  85. package/src/memories/index.ts +15 -6
  86. package/src/modes/components/advisor-config.ts +555 -0
  87. package/src/modes/components/advisor-message.ts +9 -2
  88. package/src/modes/components/agent-hub.ts +9 -4
  89. package/src/modes/components/index.ts +2 -0
  90. package/src/modes/components/model-selector.ts +79 -48
  91. package/src/modes/components/settings-selector.ts +1 -0
  92. package/src/modes/components/status-line/component.ts +144 -5
  93. package/src/modes/components/status-line/segments.ts +46 -21
  94. package/src/modes/components/status-line/types.ts +13 -1
  95. package/src/modes/components/tool-execution.ts +47 -6
  96. package/src/modes/controllers/command-controller.ts +23 -2
  97. package/src/modes/controllers/event-controller.ts +106 -0
  98. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  99. package/src/modes/controllers/input-controller.ts +61 -61
  100. package/src/modes/controllers/selector-controller.ts +100 -9
  101. package/src/modes/interactive-mode.ts +65 -5
  102. package/src/modes/skill-command.ts +116 -0
  103. package/src/modes/types.ts +7 -2
  104. package/src/modes/utils/ui-helpers.ts +41 -23
  105. package/src/prompts/agents/reviewer.md +11 -10
  106. package/src/prompts/review-custom-request.md +1 -2
  107. package/src/prompts/review-request.md +1 -2
  108. package/src/prompts/system/interrupted-thinking.md +7 -0
  109. package/src/prompts/system/recap-user.md +9 -0
  110. package/src/prompts/system/subagent-system-prompt.md +8 -5
  111. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  112. package/src/prompts/system/system-prompt.md +0 -1
  113. package/src/prompts/tools/irc.md +2 -2
  114. package/src/prompts/tools/read.md +2 -2
  115. package/src/sdk.ts +28 -24
  116. package/src/session/agent-session.ts +867 -428
  117. package/src/session/indexed-session-storage.ts +86 -13
  118. package/src/session/messages.test.ts +125 -0
  119. package/src/session/messages.ts +172 -9
  120. package/src/session/redis-session-storage.ts +49 -2
  121. package/src/session/session-entries.ts +39 -2
  122. package/src/session/session-history-format.ts +29 -2
  123. package/src/session/session-listing.ts +54 -24
  124. package/src/session/session-loader.ts +66 -3
  125. package/src/session/session-manager.ts +113 -19
  126. package/src/session/session-persistence.ts +95 -1
  127. package/src/session/session-storage.ts +36 -0
  128. package/src/session/session-title-slot.ts +141 -0
  129. package/src/session/sql-session-storage.ts +71 -11
  130. package/src/slash-commands/builtin-registry.ts +16 -3
  131. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  132. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  133. package/src/ssh/connection-manager.ts +139 -12
  134. package/src/ssh/file-transfer.ts +23 -18
  135. package/src/ssh/ssh-executor.ts +2 -13
  136. package/src/ssh/utils.ts +19 -0
  137. package/src/task/executor.ts +21 -23
  138. package/src/task/render.ts +162 -20
  139. package/src/task/renderer.ts +14 -0
  140. package/src/task/types.ts +17 -0
  141. package/src/task/yield-assembly.ts +207 -0
  142. package/src/tiny/text.ts +23 -0
  143. package/src/tools/ask.ts +55 -4
  144. package/src/tools/render-utils.ts +2 -0
  145. package/src/tools/renderers.ts +8 -2
  146. package/src/tools/review.ts +17 -7
  147. package/src/tools/ssh.ts +8 -4
  148. package/src/tools/todo.ts +17 -1
  149. package/src/tools/yield.ts +140 -31
  150. package/src/utils/thinking-display.ts +15 -0
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, it, vi } from "bun:test";
2
2
  import type { SSHConnectionTarget } from "../connection-manager";
3
3
  import * as connectionManager from "../connection-manager";
4
- import { readRemoteFile, writeRemoteFile } from "../file-transfer";
4
+ import { listRemoteDir, readRemoteFile, statRemotePath, writeRemoteFile } from "../file-transfer";
5
5
 
6
6
  describe("ssh file-transfer POSIX guard", () => {
7
7
  afterEach(() => {
@@ -13,7 +13,7 @@ describe("ssh file-transfer POSIX guard", () => {
13
13
  // without opening a real SSH connection and before any command is spawned.
14
14
  const ensureConnectionSpy = vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
15
15
  const ensureHostInfoSpy = vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
16
- version: 2,
16
+ version: 4,
17
17
  os: "windows",
18
18
  shell: "powershell",
19
19
  compatEnabled: false,
@@ -27,42 +27,79 @@ describe("ssh file-transfer POSIX guard", () => {
27
27
  expect(ensureHostInfoSpy).toHaveBeenCalled();
28
28
  });
29
29
 
30
- it("rejects a non-POSIX login shell (csh/tcsh/fish classify as non-sh) before any transfer", async () => {
31
- // csh/tcsh history-expand `!`, and fish can't parse our POSIX source; all
32
- // classify as a non-sh shell, so the guard must refuse them before any spawn.
30
+ it("rejects a non-Windows remote with no verified transferShell", async () => {
31
+ // No transferShell means the capability probe never confirmed any of
32
+ // sh/bash/zsh works. The guard refuses regardless of `shell` because the
33
+ // real ssh:// contract is "did we verify a POSIX shell works", not
34
+ // "what name did the login shell self-report" (#3719).
33
35
  vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
34
36
  vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
35
- version: 3,
37
+ version: 4,
36
38
  os: "linux",
37
39
  shell: "unknown",
38
40
  compatEnabled: false,
39
41
  });
40
- const target: SSHConnectionTarget = { name: "fishbox", host: "fishbox" };
41
- await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/non-POSIX login shell/);
42
- await expect(writeRemoteFile(target, "/tmp/x", new Uint8Array([1]), {})).rejects.toThrow(/non-POSIX login shell/);
42
+ const target: SSHConnectionTarget = { name: "noshell", host: "noshell" };
43
+ await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/no verified POSIX shell/);
44
+ await expect(writeRemoteFile(target, "/tmp/x", new Uint8Array([1]), {})).rejects.toThrow(
45
+ /no verified POSIX shell/,
46
+ );
43
47
  });
44
48
 
45
- it("allows a POSIX login shell (sh/bash/zsh) to run the transfer commands directly", async () => {
46
- // A POSIX login shell runs our snippets verbatim; the guard must let it through.
47
- // Reject at buildRemoteCommand to capture the command before any real ssh spawn.
49
+ it("dispatches transfer commands through the verified transferShell, not the login shell", async () => {
50
+ // The bug fix: if the login shell is fish/csh/tcsh, the legacy guard
51
+ // would refuse the host but allowing it isn't enough on its own.
52
+ // OpenSSH still hands our snippets to `$SHELL -c`, so a fish login
53
+ // shell would choke on `if [ … ]; then …`. Every transfer command
54
+ // must be wrapped in `<transferShell> -c '…'` to force parsing
55
+ // under the shell we verified can run it (#3719).
48
56
  vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
49
57
  vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
50
- version: 3,
58
+ version: 4,
51
59
  os: "linux",
52
- shell: "sh",
60
+ // Login shell is fish; only `transferShell` indicates a working POSIX shell.
61
+ shell: "unknown",
62
+ transferShell: "bash",
53
63
  compatEnabled: false,
54
64
  });
55
65
  const buildSpy = vi
56
66
  .spyOn(connectionManager, "buildRemoteCommand")
57
67
  .mockRejectedValue(new Error("stop-before-spawn"));
58
- const target: SSHConnectionTarget = { name: "shbox", host: "shbox" };
68
+ const target: SSHConnectionTarget = { name: "fishbox", host: "fishbox" };
59
69
 
60
70
  await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/stop-before-spawn/);
61
71
  await expect(writeRemoteFile(target, "/tmp/x", new Uint8Array([1]), {})).rejects.toThrow(/stop-before-spawn/);
72
+ await expect(statRemotePath(target, "/etc/hosts")).rejects.toThrow(/stop-before-spawn/);
73
+ await expect(listRemoteDir(target, "/etc")).rejects.toThrow(/stop-before-spawn/);
62
74
 
63
- // Reached buildRemoteCommand the guard allowed the POSIX shell. Commands are
64
- // sent verbatim (no `sh -c` wrapper); write keeps its stdin staging.
65
- expect(buildSpy.mock.calls[0]?.[1]).toContain("head -c 1025");
75
+ // Each dispatch must start with `bash -c '…'` and embed the original
76
+ // POSIX snippet inside the quoted command. Read also drops `-n`
77
+ // (allowStdin: true) because cat-staging needs stdin streaming.
78
+ const dispatches = buildSpy.mock.calls.map(call => call[1] as string);
79
+ expect(dispatches[0]).toMatch(/^bash -c '.*head -c 1025/);
80
+ expect(dispatches[1]).toMatch(/^bash -c '.*cat > /);
66
81
  expect(buildSpy.mock.calls[1]?.[2]).toMatchObject({ allowStdin: true });
82
+ expect(dispatches[2]).toMatch(/^bash -c '.*if \[ -d /);
83
+ expect(dispatches[3]).toMatch(/^bash -c '.*LC_ALL=C ls -1Ap /);
84
+ });
85
+
86
+ it("uses sh -c when transferShell is sh (the most universal POSIX fallback)", async () => {
87
+ // Belt-and-suspenders: the common happy path with a sh-family login
88
+ // shell still routes through `sh -c` to keep one dispatch shape.
89
+ vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
90
+ vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
91
+ version: 4,
92
+ os: "linux",
93
+ shell: "sh",
94
+ transferShell: "sh",
95
+ compatEnabled: false,
96
+ });
97
+ const buildSpy = vi
98
+ .spyOn(connectionManager, "buildRemoteCommand")
99
+ .mockRejectedValue(new Error("stop-before-spawn"));
100
+ const target: SSHConnectionTarget = { name: "shbox", host: "shbox" };
101
+
102
+ await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/stop-before-spawn/);
103
+ expect(buildSpy.mock.calls[0]?.[1]).toMatch(/^sh -c '.*head -c 1025/);
67
104
  });
68
105
  });
@@ -25,6 +25,15 @@ export interface SSHHostInfo {
25
25
  version: number;
26
26
  os: SSHHostOs;
27
27
  shell: SSHHostShell;
28
+ /**
29
+ * Shell name OMP verified can execute the POSIX transfer snippets
30
+ * (`head`/`cat`/`mv`/`test`/`ls`) `ssh://` uses. Probed by running
31
+ * `sh -lc` / `bash -lc` / `zsh -lc` against the remote and keeping the
32
+ * first one that round-trips a known marker. Independent of `shell`
33
+ * (the self-reported login shell), which may be noisy, exotic, or simply
34
+ * mis-classified — only `transferShell` gates ssh:// transfers.
35
+ */
36
+ transferShell?: "sh" | "bash" | "zsh";
28
37
  compatShell?: "bash" | "sh";
29
38
  compatEnabled: boolean;
30
39
  }
@@ -32,7 +41,7 @@ export interface SSHHostInfo {
32
41
  const CONTROL_DIR = getSshControlDir();
33
42
  const CONTROL_PATH = path.join(CONTROL_DIR, "%C.sock");
34
43
  const HOST_INFO_DIR = getRemoteHostDir();
35
- const HOST_INFO_VERSION = 3;
44
+ const HOST_INFO_VERSION = 4;
36
45
 
37
46
  const activeHosts = new Map<string, SSHConnectionTarget>();
38
47
  const pendingConnections = new Map<string, Promise<void>>();
@@ -166,6 +175,11 @@ function parseCompatShell(value: unknown): "bash" | "sh" | undefined {
166
175
  return undefined;
167
176
  }
168
177
 
178
+ function parseTransferShell(value: unknown): SSHHostInfo["transferShell"] {
179
+ if (value === "sh" || value === "bash" || value === "zsh") return value;
180
+ return undefined;
181
+ }
182
+
169
183
  function applyCompatOverride(host: SSHConnectionTarget, info: SSHHostInfo): SSHHostInfo {
170
184
  const compatShell =
171
185
  info.compatShell ??
@@ -184,18 +198,26 @@ function applyCompatOverride(host: SSHConnectionTarget, info: SSHHostInfo): SSHH
184
198
  return { ...info, version: info.version ?? 0, compatShell, compatEnabled };
185
199
  }
186
200
 
187
- function parseHostInfo(value: unknown): SSHHostInfo | null {
201
+ /**
202
+ * Parse a raw cache-file value (or any unknown) into a normalized
203
+ * {@link SSHHostInfo}, dropping fields that don't pass the per-field guards.
204
+ * Exported so cache-layer round-tripping (incl. the new `transferShell`
205
+ * field, #3719) is testable without touching disk.
206
+ */
207
+ export function parseHostInfo(value: unknown): SSHHostInfo | null {
188
208
  if (!value || typeof value !== "object") return null;
189
209
  const record = value as Record<string, unknown>;
190
210
  const os = parseOs(record.os) ?? "unknown";
191
211
  const shell = parseShell(record.shell) ?? "unknown";
192
212
  const compatShell = parseCompatShell(record.compatShell);
213
+ const transferShell = parseTransferShell(record.transferShell);
193
214
  const compatEnabled = typeof record.compatEnabled === "boolean" ? record.compatEnabled : false;
194
215
  const version = typeof record.version === "number" ? record.version : 0;
195
216
  return {
196
217
  version,
197
218
  os,
198
219
  shell,
220
+ transferShell,
199
221
  compatShell,
200
222
  compatEnabled,
201
223
  };
@@ -208,6 +230,11 @@ function shouldRefreshHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): bo
208
230
  if (info.os === "windows" && info.compatEnabled && !info.compatShell) return true;
209
231
  if (info.os === "windows" && info.compatShell === "bash" && info.shell === "unknown") return true;
210
232
  if (host.compat === true && info.os === "windows" && !info.compatShell) return true;
233
+ // A non-Windows host with no verified POSIX transfer shell is ambiguous —
234
+ // either the probe never ran capability checks, or every candidate failed.
235
+ // Re-probe rather than letting the ssh:// transfer guard reject it on a
236
+ // stale `shell: "unknown"` classification (#3719).
237
+ if (info.os !== "windows" && !info.transferShell) return true;
211
238
  return false;
212
239
  }
213
240
 
@@ -252,15 +279,99 @@ async function persistHostInfo(host: SSHConnectionTarget, info: SSHHostInfo): Pr
252
279
  }
253
280
  }
254
281
 
282
+ /**
283
+ * Frame marker emitted by the remote OS/shell probe. The probe wraps its
284
+ * payload in this prefix so the parser can ignore startup-file noise (banners,
285
+ * `motd`, login messages, `Last login: …`) instead of trusting only the first
286
+ * line of stdout. See #3719.
287
+ */
288
+ export const HOST_PROBE_MARKER = "PI_HOST_PROBE=";
289
+
290
+ /** Marker for the transfer-shell capability probe. */
291
+ export const TRANSFER_PROBE_MARKER = "PI_TRANSFER_OK|";
292
+
293
+ /** sh / bash / zsh, in the order we'll try as `transferShell` candidates. */
294
+ const TRANSFER_SHELL_CANDIDATES = ["sh", "bash", "zsh"] as const;
295
+
296
+ /**
297
+ * Find the first line of `stdout`/`stderr` that begins with `marker` and
298
+ * return everything after it. Used by the SSH host probe so noisy login
299
+ * dotfiles can't corrupt OS/shell classification by emitting text on the
300
+ * first line of `ssh` output.
301
+ *
302
+ * Returns `null` when no marker line is found in either stream.
303
+ */
304
+ export function extractProbePayload(stdout: string, stderr: string, marker = HOST_PROBE_MARKER): string | null {
305
+ for (const blob of [stdout, stderr]) {
306
+ if (!blob) continue;
307
+ for (const line of blob.split("\n")) {
308
+ const trimmed = line.trim();
309
+ if (trimmed.startsWith(marker)) {
310
+ return trimmed.slice(marker.length);
311
+ }
312
+ }
313
+ }
314
+ return null;
315
+ }
316
+
317
+ /**
318
+ * Find `marker` anywhere in `stdout` or `stderr` and return everything that
319
+ * follows it, scanning stdout first. Returns `null` when the marker is in
320
+ * neither stream.
321
+ *
322
+ * Used by the transfer-shell capability probe. Some remotes have broken
323
+ * login dotfiles that swap fd 1/2, so the marker can land on stderr even
324
+ * though the probe ran the printf successfully (matches the host-info
325
+ * probe's stderr fallback). See #3719.
326
+ */
327
+ export function findProbeMarker(stdout: string, stderr: string, marker: string): string | null {
328
+ for (const blob of [stdout, stderr]) {
329
+ if (!blob) continue;
330
+ const idx = blob.indexOf(marker);
331
+ if (idx !== -1) return blob.slice(idx + marker.length);
332
+ }
333
+ return null;
334
+ }
335
+
336
+ /** Classify a POSIX-ish `uname -s` payload from the transfer-shell probe. */
337
+ export function osFromUname(value: string): SSHHostOs | undefined {
338
+ const uname = value.toLowerCase();
339
+ if (uname.includes("darwin")) return "macos";
340
+ if (uname.includes("linux") || uname.includes("gnu")) return "linux";
341
+ if (uname.includes("mingw") || uname.includes("msys") || uname.includes("cygwin") || uname.includes("windows")) {
342
+ return "windows";
343
+ }
344
+ return undefined;
345
+ }
346
+
347
+ async function probeTransferShell(
348
+ host: SSHConnectionTarget,
349
+ ): Promise<{ shell: SSHHostInfo["transferShell"]; uname: string }> {
350
+ for (const candidate of TRANSFER_SHELL_CANDIDATES) {
351
+ // `printf` is POSIX and emits no trailing newline, so we can pin the
352
+ // marker right against the uname output and split on it cleanly.
353
+ const remote = `${candidate} -lc 'printf "${TRANSFER_PROBE_MARKER}"; uname -s 2>/dev/null || true'`;
354
+ const probe = await runSshCaptureSync(await buildRemoteCommand(host, remote));
355
+ if (probe.exitCode !== 0) continue;
356
+ const tail = findProbeMarker(probe.stdout, probe.stderr, TRANSFER_PROBE_MARKER);
357
+ if (tail === null) continue;
358
+ return { shell: candidate, uname: tail.trim() };
359
+ }
360
+ return { shell: undefined, uname: "" };
361
+ }
362
+
255
363
  async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
256
- const command = 'echo "$OSTYPE|$SHELL|$BASH_VERSION" 2>/dev/null || echo "%OS%|%COMSPEC%|"';
364
+ const command = `echo "${HOST_PROBE_MARKER}$OSTYPE|$SHELL|$BASH_VERSION" 2>/dev/null || echo "${HOST_PROBE_MARKER}%OS%|%COMSPEC%|"`;
257
365
  const result = await runSshCaptureSync(await buildRemoteCommand(host, command));
258
- if (result.exitCode !== 0 && !result.stdout) {
366
+ const payload = extractProbePayload(result.stdout, result.stderr);
367
+ if (payload === null) {
259
368
  logger.debug("SSH host probe failed", { host: host.name, error: result.stderr });
369
+ const transferProbe = await probeTransferShell(host);
260
370
  const fallback: SSHHostInfo = {
261
371
  version: HOST_INFO_VERSION,
262
- os: "unknown",
372
+ os: transferProbe.shell ? (osFromUname(transferProbe.uname) ?? "unknown") : "unknown",
263
373
  shell: "unknown",
374
+ transferShell: transferProbe.shell,
264
375
  compatShell: undefined,
265
376
  compatEnabled: false,
266
377
  };
@@ -268,27 +379,26 @@ async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
268
379
  return fallback;
269
380
  }
270
381
 
271
- const output = (result.stdout || result.stderr).split("\n")[0]?.trim() ?? "";
272
- const [rawOs = "", rawShell = "", rawBash = ""] = output.split("|");
382
+ const [rawOs = "", rawShell = "", rawBash = ""] = payload.split("|");
273
383
  const ostype = rawOs.trim();
274
384
  const shellRaw = rawShell.trim();
275
385
  const bashVersion = rawBash.trim();
276
- const outputLower = output.toLowerCase();
386
+ const payloadLower = payload.toLowerCase();
277
387
  const osLower = ostype.toLowerCase();
278
388
  const shellLower = shellRaw.toLowerCase();
279
389
  const unexpandedPosixVars =
280
- output.includes("$OSTYPE") || output.includes("$SHELL") || output.includes("$BASH_VERSION");
390
+ payload.includes("$OSTYPE") || payload.includes("$SHELL") || payload.includes("$BASH_VERSION");
281
391
  const windowsDetected =
282
392
  osLower.includes("windows") ||
283
393
  osLower.includes("msys") ||
284
394
  osLower.includes("cygwin") ||
285
395
  osLower.includes("mingw") ||
286
- outputLower.includes("windows_nt") ||
287
- outputLower.includes("comspec") ||
396
+ payloadLower.includes("windows_nt") ||
397
+ payloadLower.includes("comspec") ||
288
398
  shellLower.includes("cmd") ||
289
399
  shellLower.includes("powershell") ||
290
400
  unexpandedPosixVars ||
291
- output.includes("%OS%");
401
+ payload.includes("%OS%");
292
402
 
293
403
  let os: SSHHostOs = "unknown";
294
404
  if (windowsDetected) {
@@ -305,6 +415,22 @@ async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
305
415
  shell = "cmd";
306
416
  }
307
417
 
418
+ // For any non-Windows host (including `unknown`, which is often a misclassified
419
+ // POSIX remote with noisy login output) verify a working transfer shell by
420
+ // running `sh -lc` / `bash -lc` / `zsh -lc` against it. The first one whose
421
+ // printf round-trips becomes `transferShell`; ssh:// gates on this rather
422
+ // than the self-reported login-shell name (#3719).
423
+ let transferShell: SSHHostInfo["transferShell"];
424
+ if (os !== "windows") {
425
+ const probe = await probeTransferShell(host);
426
+ transferShell = probe.shell;
427
+ // `uname -s` from the same probe lets us recover the OS when the first
428
+ // probe couldn't classify it (e.g. the remote silently nuked `$OSTYPE`).
429
+ if (transferShell && os === "unknown") {
430
+ os = osFromUname(probe.uname) ?? os;
431
+ }
432
+ }
433
+
308
434
  const hasBash = !unexpandedPosixVars && (Boolean(bashVersion) || shell === "bash");
309
435
  let compatShell: SSHHostInfo["compatShell"];
310
436
  if (os === "windows" && host.compat !== false) {
@@ -328,6 +454,7 @@ async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
328
454
  version: HOST_INFO_VERSION,
329
455
  os,
330
456
  shell,
457
+ transferShell,
331
458
  compatShell,
332
459
  compatEnabled,
333
460
  });
@@ -8,20 +8,24 @@
8
8
  */
9
9
  import { ptree } from "@oh-my-pi/pi-utils";
10
10
  import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
11
- import { quotePosixPath } from "./utils";
11
+ import { quotePosixPath, wrapInPosixShell } from "./utils";
12
12
 
13
13
  /** Per-operation timeout for remote transfers (matches the ssh tool's grep window). */
14
14
  const DEFAULT_TIMEOUT_MS = 30_000;
15
15
 
16
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.
17
+ * Ensure the ControlMaster connection and pick the verified POSIX shell to
18
+ * run transfer commands under. Returns the shell name so the caller can
19
+ * wrap its snippet in `<shell> -c '…'`; OpenSSH otherwise hands the command
20
+ * to the user's login shell, which on fish/csh/tcsh hosts can't parse our
21
+ * `if [ ]; then …` constructs (#3719).
22
+ *
23
+ * Windows hosts are refused up front — `ssh://` runs `head`/`cat`/`mv`/`test`
24
+ * directly and cmd/powershell can't drive those. Everywhere else, we require
25
+ * a non-empty `transferShell` (set by `probeHostInfo` after `sh -lc` /
26
+ * `bash -lc` / `zsh -lc` round-trips a marker against the remote).
23
27
  */
24
- async function ensurePosixRemote(target: SSHConnectionTarget): Promise<void> {
28
+ async function ensurePosixRemote(target: SSHConnectionTarget): Promise<"sh" | "bash" | "zsh"> {
25
29
  await ensureConnection(target);
26
30
  const info = await ensureHostInfo(target);
27
31
  if (info.os === "windows") {
@@ -29,11 +33,12 @@ async function ensurePosixRemote(target: SSHConnectionTarget): Promise<void> {
29
33
  `ssh://: ${target.name} is a Windows host; ssh:// supports POSIX remotes only (head/cat/mv) — use the ssh tool for Windows hosts`,
30
34
  );
31
35
  }
32
- if (info.shell !== "sh" && info.shell !== "bash" && info.shell !== "zsh") {
36
+ if (!info.transferShell) {
33
37
  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`,
38
+ `ssh://: ${target.name} has no verified POSIX shell for ssh:// read/write none of sh/bash/zsh round-tripped a capability probe (use the ssh tool for this host)`,
35
39
  );
36
40
  }
41
+ return info.transferShell;
37
42
  }
38
43
 
39
44
  export interface RemoteFileReadOptions {
@@ -67,9 +72,9 @@ export async function readRemoteFile(
67
72
  remotePath: string,
68
73
  opts: RemoteFileReadOptions,
69
74
  ): Promise<RemoteFileReadResult> {
70
- await ensurePosixRemote(target);
75
+ const shell = await ensurePosixRemote(target);
71
76
  const command = `head -c ${opts.maxBytes + 1} ${quotePosixPath(remotePath)}`;
72
- const args = await buildRemoteCommand(target, command);
77
+ const args = await buildRemoteCommand(target, wrapInPosixShell(shell, command));
73
78
  using child = ptree.spawn(["ssh", ...args], {
74
79
  signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
75
80
  });
@@ -110,7 +115,7 @@ export async function writeRemoteFile(
110
115
  content: Uint8Array,
111
116
  opts: RemoteFileWriteOptions,
112
117
  ): Promise<void> {
113
- await ensurePosixRemote(target);
118
+ const shell = await ensurePosixRemote(target);
114
119
  if (remotePath.endsWith("/")) {
115
120
  throw new Error("ssh://: destination is a directory path (trailing '/'); ssh:// write requires a file path");
116
121
  }
@@ -135,7 +140,7 @@ export async function writeRemoteFile(
135
140
  `elif [ -e ${dest} ] && [ ! -L ${dest} ]; then echo 'ssh://: destination is a special file (not a regular file)' >&2; exit 1; ` +
136
141
  `else mv "$t" ${dest}; fi; ` +
137
142
  `}`;
138
- const args = await buildRemoteCommand(target, command, { allowStdin: true });
143
+ const args = await buildRemoteCommand(target, wrapInPosixShell(shell, command), { allowStdin: true });
139
144
  using child = ptree.spawn(["ssh", ...args], {
140
145
  stdin: content,
141
146
  signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
@@ -155,10 +160,10 @@ export async function statRemotePath(
155
160
  remotePath: string,
156
161
  opts: { signal?: AbortSignal; timeoutMs?: number } = {},
157
162
  ): Promise<RemotePathKind> {
158
- await ensurePosixRemote(target);
163
+ const shell = await ensurePosixRemote(target);
159
164
  const p = quotePosixPath(remotePath);
160
165
  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);
166
+ const args = await buildRemoteCommand(target, wrapInPosixShell(shell, command));
162
167
  using child = ptree.spawn(["ssh", ...args], {
163
168
  signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
164
169
  });
@@ -188,9 +193,9 @@ export async function listRemoteDir(
188
193
  remotePath: string,
189
194
  opts: { signal?: AbortSignal; timeoutMs?: number } = {},
190
195
  ): Promise<RemoteDirEntry[]> {
191
- await ensurePosixRemote(target);
196
+ const shell = await ensurePosixRemote(target);
192
197
  const command = `LC_ALL=C ls -1Ap -- ${quotePosixPath(remotePath)}`;
193
- const args = await buildRemoteCommand(target, command);
198
+ const args = await buildRemoteCommand(target, wrapInPosixShell(shell, command));
194
199
  using child = ptree.spawn(["ssh", ...args], {
195
200
  signal: ptree.combineSignals(opts.signal, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
196
201
  });
@@ -4,6 +4,7 @@ import { OutputSink } from "../session/streaming-output";
4
4
  import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
5
5
  import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
6
6
  import { hasSshfs, mountRemote } from "./sshfs-mount";
7
+ import { wrapInPosixShell } from "./utils";
7
8
 
8
9
  export interface SSHExecutorOptions {
9
10
  /** Timeout in milliseconds */
@@ -78,18 +79,6 @@ function createAbortWaiter(
78
79
  return { promise, cleanup: () => signal.removeEventListener("abort", onAbort) };
79
80
  }
80
81
 
81
- function quoteForCompatShell(command: string): string {
82
- if (command.length === 0) {
83
- return "''";
84
- }
85
- const escaped = command.replace(/'/g, "'\\''");
86
- return `'${escaped}'`;
87
- }
88
-
89
- function buildCompatCommand(shell: "bash" | "sh", command: string): string {
90
- return `${shell} -c ${quoteForCompatShell(command)}`;
91
- }
92
-
93
82
  export async function executeSSH(
94
83
  host: SSHConnectionTarget,
95
84
  command: string,
@@ -108,7 +97,7 @@ export async function executeSSH(
108
97
  if (options?.compatEnabled) {
109
98
  const info = await ensureHostInfo(host);
110
99
  if (info.compatShell) {
111
- resolvedCommand = buildCompatCommand(info.compatShell, command);
100
+ resolvedCommand = wrapInPosixShell(info.compatShell, command);
112
101
  } else {
113
102
  logger.warn("SSH compat enabled without detected compat shell", { host: host.name });
114
103
  }
package/src/ssh/utils.ts CHANGED
@@ -30,3 +30,22 @@ export function quotePosixPath(value: string): string {
30
30
  if (value.length === 0) return "''";
31
31
  return `'${value.replace(/'/g, "'\\''")}'`;
32
32
  }
33
+
34
+ /**
35
+ * Wrap a POSIX command in `<shell> -c '<command>'` so it runs under the
36
+ * named shell rather than whatever `$SHELL` happens to be on the remote.
37
+ *
38
+ * Used by the `ssh://` transfer helpers and the Windows compat dispatch:
39
+ * OpenSSH passes our snippets to `<login-shell> -c`, so a remote whose
40
+ * login shell is fish/csh/tcsh (or cmd/powershell on Windows compat)
41
+ * can't parse `if [ … ]; then …`. Wrapping forces parsing under the
42
+ * shell OMP actually verified can run the snippet.
43
+ *
44
+ * `-c` (not `-lc`): the transfer snippets only call absolute POSIX
45
+ * builtins (`head`/`cat`/`mv`/`test`/`ls`/`mkdir`/`rm`/`dirname`) and
46
+ * don't need login-profile setup. Capability *probing* still uses
47
+ * `-lc` to mirror the user's real environment.
48
+ */
49
+ export function wrapInPosixShell(shell: "sh" | "bash" | "zsh", command: string): string {
50
+ return `${shell} -c ${quotePosixPath(command)}`;
51
+ }
@@ -71,7 +71,11 @@ import {
71
71
  TASK_SUBAGENT_LIFECYCLE_CHANNEL,
72
72
  TASK_SUBAGENT_PROGRESS_CHANNEL,
73
73
  type TaskToolDetails,
74
+ type YieldItem,
74
75
  } from "./types";
76
+ import { arrayValuedLabels, assembleYieldResult } from "./yield-assembly";
77
+
78
+ export type { YieldItem } from "./types";
75
79
 
76
80
  const MCP_CALL_TIMEOUT_MS = 60_000;
77
81
 
@@ -517,21 +521,6 @@ function resolveFallbackCompletion(rawOutput: string, outputSchema: unknown): {
517
521
  return { data: candidate };
518
522
  }
519
523
 
520
- export interface YieldItem {
521
- data?: unknown;
522
- status?: "success" | "aborted";
523
- error?: string;
524
- /**
525
- * Set by the in-tool yield validator when it exhausted its retry budget
526
- * (MAX_SCHEMA_RETRIES) and accepted a schema-invalid payload anyway.
527
- * `finalizeSubprocessOutput` honors this by serializing the payload and
528
- * surfacing a stderr warning, instead of re-emitting `schema_violation`
529
- * — which would silently swap the subagent's "accepted" view for a
530
- * different, opaque error blob in the parent's view of the result.
531
- */
532
- schemaOverridden?: boolean;
533
- }
534
-
535
524
  interface FinalizeSubprocessOutputArgs {
536
525
  rawOutput: string;
537
526
  exitCode: number;
@@ -541,6 +530,7 @@ interface FinalizeSubprocessOutputArgs {
541
530
  yieldItems?: YieldItem[];
542
531
  reportFindings?: ReviewFinding[];
543
532
  outputSchema: unknown;
533
+ lastAssistantText?: string;
544
534
  }
545
535
 
546
536
  interface FinalizeSubprocessOutputResult {
@@ -583,7 +573,7 @@ function buildSchemaViolationOutcome(
583
573
 
584
574
  export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): FinalizeSubprocessOutputResult {
585
575
  let { rawOutput, exitCode, stderr } = args;
586
- const { yieldItems, reportFindings, doneAborted, signalAborted, outputSchema } = args;
576
+ const { yieldItems, reportFindings, doneAborted, signalAborted, outputSchema, lastAssistantText } = args;
587
577
  let abortedViaYield = false;
588
578
  const hasYield = Array.isArray(yieldItems) && yieldItems.length > 0;
589
579
  const hadFailureBeforeYield = exitCode !== 0 && stderr.trim().length > 0;
@@ -600,15 +590,16 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
600
590
  rawOutput = `{"aborted":true,"error":"${lastYield.error || "Unknown error"}"}`;
601
591
  }
602
592
  } else {
603
- const submitData = lastYield?.data;
604
- if (submitData === null || submitData === undefined) {
593
+ const assembled = assembleYieldResult(yieldItems, lastAssistantText, arrayValuedLabels(outputSchema));
594
+ if (!assembled || assembled.missingData) {
605
595
  rawOutput = rawOutput ? `${SUBAGENT_WARNING_NULL_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_NULL_YIELD;
606
596
  } else {
607
597
  const { validator, error: schemaError } = buildOutputValidator(outputSchema);
608
- const overridden = lastYield?.schemaOverridden === true;
609
- const completeData = normalizeCompleteData(submitData, reportFindings, validator);
598
+ const completeData = assembled.rawText
599
+ ? assembled.data
600
+ : normalizeCompleteData(assembled.data, reportFindings, validator);
610
601
  const result =
611
- schemaError || overridden
602
+ schemaError || assembled.schemaOverridden
612
603
  ? { success: true as const }
613
604
  : (validator?.validate(completeData) ?? { success: true as const });
614
605
  if (!result.success) {
@@ -619,14 +610,17 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
619
610
  exitCode = outcome.exitCode;
620
611
  } else {
621
612
  try {
622
- rawOutput = JSON.stringify(completeData, null, 2) ?? "null";
613
+ rawOutput =
614
+ assembled.rawText && typeof completeData === "string"
615
+ ? completeData
616
+ : (JSON.stringify(completeData, null, 2) ?? "null");
623
617
  } catch (err) {
624
618
  const errorMessage = err instanceof Error ? err.message : String(err);
625
619
  rawOutput = `{"error":"Failed to serialize yield data: ${errorMessage}"}`;
626
620
  }
627
621
  if (!hadFailureBeforeYield) {
628
622
  exitCode = 0;
629
- stderr = overridden
623
+ stderr = assembled.schemaOverridden
630
624
  ? SUBAGENT_WARNING_SCHEMA_OVERRIDDEN
631
625
  : schemaError
632
626
  ? `invalid output schema: ${schemaError}`
@@ -925,6 +919,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
925
919
  cacheRead: 0,
926
920
  cacheWrite: 0,
927
921
  totalTokens: 0,
922
+ reasoningTokens: 0,
928
923
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
929
924
  };
930
925
  let hasUsage = false;
@@ -1315,6 +1310,8 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1315
1310
  accumulatedUsage.cacheRead += getNumberField(usageRecord, "cacheRead") ?? 0;
1316
1311
  accumulatedUsage.cacheWrite += getNumberField(usageRecord, "cacheWrite") ?? 0;
1317
1312
  accumulatedUsage.totalTokens += getNumberField(usageRecord, "totalTokens") ?? 0;
1313
+ accumulatedUsage.reasoningTokens =
1314
+ (accumulatedUsage.reasoningTokens ?? 0) + (getNumberField(usageRecord, "reasoningTokens") ?? 0);
1318
1315
  if (costRecord) {
1319
1316
  accumulatedUsage.cost.input += getNumberField(costRecord, "input") ?? 0;
1320
1317
  accumulatedUsage.cost.output += getNumberField(costRecord, "output") ?? 0;
@@ -1665,6 +1662,7 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise<SingleResult> {
1665
1662
  yieldItems,
1666
1663
  reportFindings,
1667
1664
  outputSchema: args.outputSchema,
1665
+ lastAssistantText: monitor.lastAssistantSalvageText(),
1668
1666
  });
1669
1667
  } finally {
1670
1668
  popLoopPhase();