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

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 (164) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli.js +3682 -3615
  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/cli/update-cli.d.ts +15 -0
  10. package/dist/types/collab/guest.d.ts +29 -0
  11. package/dist/types/collab/replication-shrink.d.ts +39 -0
  12. package/dist/types/config/provider-globals.d.ts +7 -0
  13. package/dist/types/config/settings-schema.d.ts +81 -0
  14. package/dist/types/debug/log-viewer.d.ts +1 -0
  15. package/dist/types/debug/raw-sse.d.ts +1 -0
  16. package/dist/types/edit/hashline/diff.d.ts +0 -11
  17. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  18. package/dist/types/extensibility/utils.d.ts +12 -0
  19. package/dist/types/mcp/transports/index.d.ts +1 -0
  20. package/dist/types/mcp/transports/sse.d.ts +20 -0
  21. package/dist/types/memories/index.d.ts +20 -1
  22. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  23. package/dist/types/modes/components/index.d.ts +1 -0
  24. package/dist/types/modes/components/model-selector.d.ts +9 -1
  25. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  26. package/dist/types/modes/components/status-line/component.d.ts +35 -1
  27. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  28. package/dist/types/modes/controllers/command-controller.d.ts +3 -3
  29. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  30. package/dist/types/modes/interactive-mode.d.ts +11 -4
  31. package/dist/types/modes/skill-command.d.ts +32 -0
  32. package/dist/types/modes/types.d.ts +9 -3
  33. package/dist/types/session/agent-session.d.ts +58 -10
  34. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  35. package/dist/types/session/messages.d.ts +26 -0
  36. package/dist/types/session/messages.test.d.ts +1 -0
  37. package/dist/types/session/session-entries.d.ts +31 -3
  38. package/dist/types/session/session-history-format.d.ts +6 -0
  39. package/dist/types/session/session-loader.d.ts +9 -1
  40. package/dist/types/session/session-manager.d.ts +8 -7
  41. package/dist/types/session/session-storage.d.ts +11 -0
  42. package/dist/types/session/session-title-slot.d.ts +19 -0
  43. package/dist/types/ssh/connection-manager.d.ts +47 -0
  44. package/dist/types/ssh/utils.d.ts +16 -0
  45. package/dist/types/task/executor.d.ts +3 -16
  46. package/dist/types/task/render.d.ts +0 -5
  47. package/dist/types/task/renderer.d.ts +13 -0
  48. package/dist/types/task/types.d.ts +16 -0
  49. package/dist/types/task/yield-assembly.d.ts +28 -0
  50. package/dist/types/tiny/text.d.ts +8 -0
  51. package/dist/types/tools/render-utils.d.ts +2 -0
  52. package/dist/types/tools/review.d.ts +6 -4
  53. package/dist/types/tools/ssh.d.ts +1 -1
  54. package/dist/types/tools/todo.d.ts +6 -0
  55. package/dist/types/tools/yield.d.ts +8 -3
  56. package/dist/types/utils/thinking-display.d.ts +4 -0
  57. package/package.json +12 -12
  58. package/src/advisor/__tests__/advisor.test.ts +242 -10
  59. package/src/advisor/__tests__/config.test.ts +173 -0
  60. package/src/advisor/advise-tool.ts +11 -6
  61. package/src/advisor/config.ts +256 -0
  62. package/src/advisor/index.ts +1 -0
  63. package/src/advisor/runtime.ts +12 -2
  64. package/src/advisor/transcript-recorder.ts +25 -2
  65. package/src/advisor/watchdog.ts +57 -31
  66. package/src/autolearn/controller.ts +13 -22
  67. package/src/autoresearch/index.ts +7 -2
  68. package/src/cli/gc-cli.ts +17 -10
  69. package/src/cli/update-cli.ts +254 -0
  70. package/src/collab/guest.ts +43 -7
  71. package/src/collab/host.ts +13 -8
  72. package/src/collab/replication-shrink.ts +111 -0
  73. package/src/config/model-registry.ts +80 -18
  74. package/src/config/provider-globals.ts +25 -0
  75. package/src/config/settings-schema.ts +77 -0
  76. package/src/debug/index.ts +32 -7
  77. package/src/debug/log-viewer.ts +111 -53
  78. package/src/debug/raw-sse.ts +68 -48
  79. package/src/discovery/codex.ts +13 -5
  80. package/src/edit/hashline/diff.ts +57 -4
  81. package/src/eval/__tests__/julia-prelude.test.ts +2 -2
  82. package/src/eval/js/shared/local-module-loader.ts +23 -1
  83. package/src/export/html/template.js +13 -7
  84. package/src/extensibility/extensions/loader.ts +5 -3
  85. package/src/extensibility/extensions/wrapper.ts +9 -3
  86. package/src/extensibility/hooks/loader.ts +3 -3
  87. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  88. package/src/extensibility/plugins/manager.ts +2 -1
  89. package/src/extensibility/tool-event-input.ts +23 -0
  90. package/src/extensibility/utils.ts +74 -0
  91. package/src/internal-urls/docs-index.generated.txt +1 -1
  92. package/src/mcp/client.ts +3 -1
  93. package/src/mcp/manager.ts +12 -5
  94. package/src/mcp/transports/index.ts +1 -0
  95. package/src/mcp/transports/sse.ts +377 -0
  96. package/src/memories/index.ts +130 -15
  97. package/src/memory-backend/local-backend.ts +5 -3
  98. package/src/modes/components/advisor-config.ts +555 -0
  99. package/src/modes/components/advisor-message.ts +9 -2
  100. package/src/modes/components/agent-hub.ts +9 -4
  101. package/src/modes/components/index.ts +2 -0
  102. package/src/modes/components/model-selector.ts +79 -48
  103. package/src/modes/components/settings-selector.ts +1 -0
  104. package/src/modes/components/status-line/component.ts +150 -5
  105. package/src/modes/components/status-line/segments.ts +46 -21
  106. package/src/modes/components/status-line/types.ts +13 -1
  107. package/src/modes/components/tool-execution.ts +47 -6
  108. package/src/modes/controllers/command-controller.ts +27 -36
  109. package/src/modes/controllers/event-controller.ts +113 -1
  110. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  111. package/src/modes/controllers/input-controller.ts +61 -61
  112. package/src/modes/controllers/selector-controller.ts +100 -9
  113. package/src/modes/interactive-mode.ts +74 -25
  114. package/src/modes/skill-command.ts +116 -0
  115. package/src/modes/types.ts +9 -3
  116. package/src/modes/utils/ui-helpers.ts +41 -23
  117. package/src/prompts/agents/reviewer.md +11 -10
  118. package/src/prompts/goals/goal-todo-context.md +12 -0
  119. package/src/prompts/review-custom-request.md +1 -2
  120. package/src/prompts/review-request.md +1 -2
  121. package/src/prompts/system/interrupted-thinking.md +7 -0
  122. package/src/prompts/system/recap-user.md +9 -0
  123. package/src/prompts/system/subagent-system-prompt.md +8 -5
  124. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  125. package/src/prompts/system/system-prompt.md +0 -1
  126. package/src/prompts/tools/irc.md +2 -2
  127. package/src/prompts/tools/read.md +2 -2
  128. package/src/sdk.ts +28 -24
  129. package/src/session/agent-session.ts +899 -429
  130. package/src/session/indexed-session-storage.ts +86 -13
  131. package/src/session/messages.test.ts +125 -0
  132. package/src/session/messages.ts +172 -9
  133. package/src/session/redis-session-storage.ts +49 -2
  134. package/src/session/session-entries.ts +39 -2
  135. package/src/session/session-history-format.ts +29 -2
  136. package/src/session/session-listing.ts +54 -24
  137. package/src/session/session-loader.ts +66 -3
  138. package/src/session/session-manager.ts +115 -22
  139. package/src/session/session-persistence.ts +95 -1
  140. package/src/session/session-storage.ts +36 -0
  141. package/src/session/session-title-slot.ts +141 -0
  142. package/src/session/sql-session-storage.ts +71 -11
  143. package/src/slash-commands/builtin-registry.ts +23 -24
  144. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  145. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  146. package/src/ssh/connection-manager.ts +139 -12
  147. package/src/ssh/file-transfer.ts +23 -18
  148. package/src/ssh/ssh-executor.ts +2 -13
  149. package/src/ssh/utils.ts +19 -0
  150. package/src/task/executor.ts +21 -23
  151. package/src/task/render.ts +162 -20
  152. package/src/task/renderer.ts +14 -0
  153. package/src/task/types.ts +17 -0
  154. package/src/task/yield-assembly.ts +207 -0
  155. package/src/tiny/text.ts +23 -0
  156. package/src/tools/ask.ts +55 -4
  157. package/src/tools/render-utils.ts +2 -0
  158. package/src/tools/renderers.ts +8 -2
  159. package/src/tools/review.ts +17 -7
  160. package/src/tools/ssh.ts +8 -4
  161. package/src/tools/todo.ts +17 -1
  162. package/src/tools/yield.ts +140 -31
  163. package/src/utils/thinking-display.ts +15 -0
  164. package/src/prompts/system/autolearn-nudge.md +0 -5
@@ -2,7 +2,18 @@ import { describe, expect, it } from "bun:test";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { getRemoteHostDir } from "@oh-my-pi/pi-utils";
5
- import { buildRemoteCommand, getHostInfo, type SSHConnectionTarget, type SSHHostShell } from "../connection-manager";
5
+ import {
6
+ buildRemoteCommand,
7
+ extractProbePayload,
8
+ findProbeMarker,
9
+ getHostInfo,
10
+ HOST_PROBE_MARKER,
11
+ osFromUname,
12
+ parseHostInfo,
13
+ type SSHConnectionTarget,
14
+ type SSHHostShell,
15
+ TRANSFER_PROBE_MARKER,
16
+ } from "../connection-manager";
6
17
  import { buildSshTarget, sanitizeHostName } from "../utils";
7
18
 
8
19
  const TARGET: SSHConnectionTarget = { name: "h", host: "h" };
@@ -67,3 +78,114 @@ describe("ssh host shell classification", () => {
67
78
  }
68
79
  });
69
80
  });
81
+
82
+ describe("extractProbePayload (host probe framing)", () => {
83
+ it("returns the text after the first marker line, ignoring login banners", async () => {
84
+ // Real-world failure shape: noisy dotfiles print a banner before the
85
+ // echo we asked for, so the legacy first-line parser would have read
86
+ // `Last login: ...` and classified the host as unknown (#3719).
87
+ const stdout = [
88
+ "Last login: Wed Mar 19 09:14:22 2025 from 10.0.0.1",
89
+ "Welcome to fancybox 1.0",
90
+ `${HOST_PROBE_MARKER}linux-gnu|/bin/bash|5.2.21`,
91
+ ].join("\n");
92
+ expect(extractProbePayload(stdout, "")).toBe("linux-gnu|/bin/bash|5.2.21");
93
+ });
94
+
95
+ it("falls back to stderr when the payload only shows up there", async () => {
96
+ // Some shells redirect every echo to stderr after a dotfile error; the
97
+ // parser needs to recover the marker line from either stream.
98
+ const stderr = `noise\n${HOST_PROBE_MARKER}darwin|/bin/zsh|\n`;
99
+ expect(extractProbePayload("", stderr)).toBe("darwin|/bin/zsh|");
100
+ });
101
+
102
+ it("returns null when no marker line is present", async () => {
103
+ expect(extractProbePayload("just login banner\n", "and stderr noise\n")).toBeNull();
104
+ });
105
+ });
106
+
107
+ describe("findProbeMarker (transfer-shell probe recovery)", () => {
108
+ it("returns the tail after the marker when it appears in stdout", () => {
109
+ // Happy path: `sh -lc 'printf "PI_TRANSFER_OK|"; uname -s'` lands in
110
+ // stdout. The tail is the uname output the caller uses to refine OS.
111
+ const stdout = `${TRANSFER_PROBE_MARKER}Linux\n`;
112
+ expect(findProbeMarker(stdout, "", TRANSFER_PROBE_MARKER)).toBe("Linux\n");
113
+ });
114
+
115
+ it("falls back to stderr when a broken dotfile swaps fd 1/2", () => {
116
+ // Some remotes have dotfiles that redirect every shell write to stderr.
117
+ // The transfer probe must still recognize the marker so ssh:// doesn't
118
+ // refuse a POSIX-capable host (#3722 review).
119
+ const stderr = `dotfile noise\n${TRANSFER_PROBE_MARKER}Darwin\n`;
120
+ expect(findProbeMarker("", stderr, TRANSFER_PROBE_MARKER)).toBe("Darwin\n");
121
+ });
122
+
123
+ it("prefers stdout over stderr when the marker is in both", () => {
124
+ // Order matters: stdout is the canonical path, stderr is the rescue.
125
+ // A reordering bug would silently use stale stderr fragments first.
126
+ const stdout = `${TRANSFER_PROBE_MARKER}Linux`;
127
+ const stderr = `${TRANSFER_PROBE_MARKER}stale`;
128
+ expect(findProbeMarker(stdout, stderr, TRANSFER_PROBE_MARKER)).toBe("Linux");
129
+ });
130
+
131
+ it("returns null when the marker is in neither stream", () => {
132
+ expect(findProbeMarker("noise", "more noise", TRANSFER_PROBE_MARKER)).toBeNull();
133
+ });
134
+ });
135
+
136
+ describe("osFromUname (transfer-shell probe OS recovery)", () => {
137
+ it("classifies common POSIX uname payloads", () => {
138
+ // The markerless host-info fallback uses the transfer-shell probe's
139
+ // uname output to avoid returning a durable `os: "unknown"` when csh/tcsh
140
+ // killed the first marker probe before it could echo anything (#3722 review).
141
+ expect(osFromUname("Linux")).toBe("linux");
142
+ expect(osFromUname("GNU/Linux")).toBe("linux");
143
+ expect(osFromUname("Darwin")).toBe("macos");
144
+ });
145
+
146
+ it("classifies Windows compat unames as windows so ssh:// still refuses them", () => {
147
+ expect(osFromUname("MINGW64_NT-10.0")).toBe("windows");
148
+ expect(osFromUname("MSYS_NT-10.0")).toBe("windows");
149
+ expect(osFromUname("CYGWIN_NT-10.0")).toBe("windows");
150
+ });
151
+
152
+ it("returns undefined when uname is not recognized", () => {
153
+ expect(osFromUname("")).toBeUndefined();
154
+ expect(osFromUname("SunOS")).toBeUndefined();
155
+ });
156
+ });
157
+
158
+ describe("parseHostInfo transferShell handling", () => {
159
+ it("round-trips a verified transferShell value", () => {
160
+ // Cache writers persist `transferShell` so callers don't re-probe
161
+ // every session; parseHostInfo must thread it back through (#3719).
162
+ const parsed = parseHostInfo({
163
+ version: 4,
164
+ os: "linux",
165
+ shell: "unknown",
166
+ transferShell: "bash",
167
+ compatEnabled: false,
168
+ });
169
+ expect(parsed?.transferShell).toBe("bash");
170
+ });
171
+
172
+ it("drops a transferShell value outside the sh/bash/zsh allowlist", () => {
173
+ // Anything we couldn't have probed (fish, csh, garbage) must not slip
174
+ // into the cache and bypass the ssh:// transfer guard.
175
+ const parsed = parseHostInfo({
176
+ version: 4,
177
+ os: "linux",
178
+ shell: "sh",
179
+ transferShell: "fish",
180
+ compatEnabled: false,
181
+ });
182
+ expect(parsed?.transferShell).toBeUndefined();
183
+ });
184
+
185
+ it("returns transferShell undefined when the field is missing", () => {
186
+ // A pre-v4 cache file lacks transferShell entirely; the parsed value
187
+ // must be undefined so shouldRefreshHostInfo treats it as stale.
188
+ const parsed = parseHostInfo({ version: 3, os: "linux", shell: "sh", compatEnabled: false });
189
+ expect(parsed?.transferShell).toBeUndefined();
190
+ });
191
+ });
@@ -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
+ }