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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/CHANGELOG.md +99 -1
  2. package/dist/cli.js +4387 -4164
  3. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +1 -0
  4. package/dist/types/advisor/emission-guard.d.ts +73 -0
  5. package/dist/types/advisor/index.d.ts +1 -0
  6. package/dist/types/advisor/runtime.d.ts +7 -0
  7. package/dist/types/advisor/watchdog.d.ts +2 -0
  8. package/dist/types/cli/flag-tables.d.ts +1 -0
  9. package/dist/types/cli/gallery-fixtures/search.d.ts +1 -1
  10. package/dist/types/cli/gc-cli.d.ts +58 -0
  11. package/dist/types/collab/display-name.d.ts +3 -0
  12. package/dist/types/collab/host.d.ts +0 -2
  13. package/dist/types/commands/gc.d.ts +37 -0
  14. package/dist/types/commands/worktree.d.ts +0 -3
  15. package/dist/types/config/inline-tool-descriptors-mode.d.ts +13 -0
  16. package/dist/types/config/model-discovery.d.ts +6 -1
  17. package/dist/types/config/model-registry.d.ts +6 -2
  18. package/dist/types/config/model-resolver.d.ts +12 -0
  19. package/dist/types/config/models-config-schema.d.ts +29 -2
  20. package/dist/types/config/models-config.d.ts +22 -1
  21. package/dist/types/config/settings-schema.d.ts +145 -21
  22. package/dist/types/config/settings.d.ts +13 -0
  23. package/dist/types/dap/config.d.ts +1 -1
  24. package/dist/types/edit/hashline/filesystem.d.ts +4 -2
  25. package/dist/types/edit/renderer.d.ts +4 -0
  26. package/dist/types/extensibility/extensions/types.d.ts +17 -17
  27. package/dist/types/extensibility/hooks/types.d.ts +10 -10
  28. package/dist/types/extensibility/plugins/marketplace/types.d.ts +1 -0
  29. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +1 -0
  30. package/dist/types/internal-urls/index.d.ts +1 -0
  31. package/dist/types/internal-urls/registry-helpers.d.ts +2 -0
  32. package/dist/types/internal-urls/router.d.ts +1 -1
  33. package/dist/types/internal-urls/ssh-protocol.d.ts +10 -0
  34. package/dist/types/internal-urls/types.d.ts +19 -2
  35. package/dist/types/irc/bus.d.ts +6 -0
  36. package/dist/types/mcp/transports/stdio.d.ts +25 -1
  37. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +1 -0
  38. package/dist/types/modes/components/custom-editor.d.ts +7 -0
  39. package/dist/types/modes/components/move-overlay.d.ts +23 -0
  40. package/dist/types/modes/components/plugin-selector.d.ts +2 -1
  41. package/dist/types/modes/components/queue-mode-selector.d.ts +2 -1
  42. package/dist/types/modes/components/select-list-mouse-routing.d.ts +2 -0
  43. package/dist/types/modes/components/settings-defs.d.ts +4 -1
  44. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  45. package/dist/types/modes/components/show-images-selector.d.ts +2 -1
  46. package/dist/types/modes/components/status-line/component.d.ts +2 -0
  47. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  48. package/dist/types/modes/components/theme-selector.d.ts +2 -1
  49. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  50. package/dist/types/modes/controllers/command-controller.d.ts +10 -1
  51. package/dist/types/modes/controllers/streaming-reveal.d.ts +6 -0
  52. package/dist/types/modes/interactive-mode.d.ts +10 -1
  53. package/dist/types/modes/internal-url-autocomplete.d.ts +1 -1
  54. package/dist/types/modes/running-subagent-badge.d.ts +6 -0
  55. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +1 -0
  56. package/dist/types/modes/theme/theme.d.ts +2 -1
  57. package/dist/types/modes/types.d.ts +9 -1
  58. package/dist/types/sdk.d.ts +2 -2
  59. package/dist/types/session/agent-session.d.ts +2 -1
  60. package/dist/types/session/session-listing.d.ts +10 -1
  61. package/dist/types/session/session-manager.d.ts +13 -0
  62. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  63. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +1 -0
  64. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +1 -0
  65. package/dist/types/ssh/connection-manager.d.ts +2 -0
  66. package/dist/types/ssh/file-transfer.d.ts +79 -0
  67. package/dist/types/ssh/utils.d.ts +6 -0
  68. package/dist/types/system-prompt.d.ts +5 -0
  69. package/dist/types/task/executor.d.ts +16 -0
  70. package/dist/types/tiny/text.d.ts +1 -1
  71. package/dist/types/tools/builtin-names.d.ts +5 -1
  72. package/dist/types/tools/{find.d.ts → glob.d.ts} +15 -15
  73. package/dist/types/tools/{search.d.ts → grep.d.ts} +14 -14
  74. package/dist/types/tools/index.d.ts +3 -3
  75. package/dist/types/tools/path-utils.d.ts +29 -0
  76. package/dist/types/tools/plan-mode-guard.d.ts +7 -0
  77. package/dist/types/tools/read.d.ts +2 -2
  78. package/dist/types/tools/render-utils.d.ts +8 -0
  79. package/dist/types/tools/renderers.d.ts +11 -0
  80. package/dist/types/tools/ssh.d.ts +2 -0
  81. package/dist/types/tools/todo.d.ts +0 -16
  82. package/dist/types/tools/write.d.ts +2 -2
  83. package/dist/types/utils/active-repo-context.d.ts +8 -0
  84. package/dist/types/utils/image-resize.d.ts +1 -0
  85. package/dist/types/utils/markit-cache.d.ts +23 -0
  86. package/dist/types/utils/markit.d.ts +5 -1
  87. package/dist/types/utils/prompt-path.d.ts +1 -0
  88. package/dist/types/web/search/providers/duckduckgo.d.ts +14 -0
  89. package/dist/types/web/search/providers/firecrawl.d.ts +28 -0
  90. package/dist/types/web/search/providers/tinyfish.d.ts +29 -0
  91. package/dist/types/web/search/providers/xai.d.ts +13 -0
  92. package/dist/types/web/search/types.d.ts +18 -2
  93. package/package.json +30 -15
  94. package/scripts/bench-guard.ts +1 -1
  95. package/scripts/build-binary.ts +9 -9
  96. package/scripts/bundle-dist.ts +2 -2
  97. package/src/advisor/__tests__/advisor.test.ts +40 -4
  98. package/src/advisor/__tests__/emission-guard.test.ts +147 -0
  99. package/src/advisor/advise-tool.ts +1 -1
  100. package/src/advisor/emission-guard.ts +172 -0
  101. package/src/advisor/index.ts +1 -0
  102. package/src/advisor/runtime.ts +11 -0
  103. package/src/advisor/watchdog.ts +12 -1
  104. package/src/cli/args.ts +5 -2
  105. package/src/cli/auth-broker-cli.ts +17 -0
  106. package/src/cli/config-cli.ts +4 -0
  107. package/src/cli/flag-tables.ts +7 -4
  108. package/src/cli/gallery-cli.ts +14 -2
  109. package/src/cli/gallery-fixtures/edit.ts +60 -0
  110. package/src/cli/gallery-fixtures/fs.ts +17 -17
  111. package/src/cli/gallery-fixtures/search.ts +4 -4
  112. package/src/cli/gc-cli.ts +939 -0
  113. package/src/cli/usage-cli.ts +20 -1
  114. package/src/cli/web-search-cli.ts +1 -1
  115. package/src/cli-commands.ts +1 -0
  116. package/src/collab/display-name.ts +13 -0
  117. package/src/collab/guest.ts +5 -1
  118. package/src/collab/host.ts +2 -13
  119. package/src/commands/gc.ts +46 -0
  120. package/src/commands/worktree.ts +6 -0
  121. package/src/config/inline-tool-descriptors-mode.ts +27 -0
  122. package/src/config/model-discovery.ts +77 -8
  123. package/src/config/model-registry.ts +89 -11
  124. package/src/config/model-resolver.ts +39 -1
  125. package/src/config/models-config-schema.ts +41 -5
  126. package/src/config/models-config.ts +4 -1
  127. package/src/config/settings-schema.ts +130 -27
  128. package/src/config/settings.ts +216 -7
  129. package/src/cursor.ts +1 -1
  130. package/src/dap/config.ts +152 -8
  131. package/src/dap/session.ts +1 -1
  132. package/src/discovery/helpers.ts +3 -1
  133. package/src/edit/hashline/diff.ts +14 -3
  134. package/src/edit/hashline/execute.ts +42 -6
  135. package/src/edit/hashline/filesystem.ts +49 -8
  136. package/src/edit/index.ts +1 -0
  137. package/src/edit/modes/patch.ts +11 -1
  138. package/src/edit/renderer.ts +140 -13
  139. package/src/eval/__tests__/agent-bridge.test.ts +101 -0
  140. package/src/eval/__tests__/julia-prelude.test.ts +18 -0
  141. package/src/eval/agent-bridge.ts +26 -3
  142. package/src/eval/jl/runner.jl +7 -1
  143. package/src/eval/js/tool-bridge.ts +2 -2
  144. package/src/export/html/index.ts +1 -1
  145. package/src/export/html/template.js +3 -1
  146. package/src/export/html/tool-views.generated.js +20 -20
  147. package/src/extensibility/extensions/types.ts +22 -22
  148. package/src/extensibility/hooks/types.ts +11 -11
  149. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +10 -3
  150. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +31 -10
  151. package/src/extensibility/plugins/marketplace/manager.ts +22 -0
  152. package/src/extensibility/plugins/marketplace/types.ts +1 -0
  153. package/src/internal-urls/__tests__/ssh-protocol.test.ts +331 -0
  154. package/src/internal-urls/docs-index.generated.txt +2 -2
  155. package/src/internal-urls/docs-index.ts +2 -3
  156. package/src/internal-urls/index.ts +1 -0
  157. package/src/internal-urls/registry-helpers.ts +19 -4
  158. package/src/internal-urls/router.ts +5 -3
  159. package/src/internal-urls/ssh-protocol.ts +367 -0
  160. package/src/internal-urls/types.ts +19 -2
  161. package/src/irc/bus.ts +11 -3
  162. package/src/lsp/index.ts +8 -1
  163. package/src/mcp/oauth-discovery.ts +20 -20
  164. package/src/mcp/tool-bridge.ts +32 -2
  165. package/src/mcp/transports/stdio.test.ts +20 -3
  166. package/src/mcp/transports/stdio.ts +45 -14
  167. package/src/memories/index.ts +1 -1
  168. package/src/modes/acp/acp-event-mapper.ts +2 -2
  169. package/src/modes/components/__tests__/move-overlay.test.ts +166 -0
  170. package/src/modes/components/agent-hub.ts +3 -0
  171. package/src/modes/components/agent-transcript-viewer.ts +9 -7
  172. package/src/modes/components/custom-editor.ts +90 -5
  173. package/src/modes/components/move-overlay.ts +282 -0
  174. package/src/modes/components/plan-review-overlay.ts +35 -35
  175. package/src/modes/components/plugin-selector.ts +6 -1
  176. package/src/modes/components/queue-mode-selector.ts +6 -1
  177. package/src/modes/components/select-list-mouse-routing.ts +35 -0
  178. package/src/modes/components/session-selector.ts +11 -10
  179. package/src/modes/components/settings-defs.ts +14 -1
  180. package/src/modes/components/settings-selector.ts +196 -29
  181. package/src/modes/components/show-images-selector.ts +6 -1
  182. package/src/modes/components/status-line/component.ts +108 -28
  183. package/src/modes/components/status-line/segments.ts +5 -1
  184. package/src/modes/components/status-line/types.ts +2 -0
  185. package/src/modes/components/theme-selector.ts +6 -1
  186. package/src/modes/components/thinking-selector.ts +6 -1
  187. package/src/modes/components/tool-execution.ts +25 -9
  188. package/src/modes/components/tree-selector.ts +5 -5
  189. package/src/modes/controllers/command-controller.ts +140 -47
  190. package/src/modes/controllers/event-controller.ts +59 -3
  191. package/src/modes/controllers/input-controller.ts +70 -4
  192. package/src/modes/controllers/selector-controller.ts +15 -2
  193. package/src/modes/controllers/streaming-reveal.ts +17 -0
  194. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  195. package/src/modes/interactive-mode.ts +142 -59
  196. package/src/modes/internal-url-autocomplete.ts +17 -2
  197. package/src/modes/prompt-action-autocomplete.ts +3 -1
  198. package/src/modes/running-subagent-badge.ts +13 -0
  199. package/src/modes/setup-wizard/scenes/glyph.ts +2 -13
  200. package/src/modes/setup-wizard/scenes/providers.ts +2 -1
  201. package/src/modes/setup-wizard/scenes/theme.ts +6 -12
  202. package/src/modes/setup-wizard/scenes/web-search.ts +8 -13
  203. package/src/modes/setup-wizard/wizard-overlay.ts +6 -5
  204. package/src/modes/theme/mermaid-rendering.test.ts +53 -0
  205. package/src/modes/theme/theme.ts +42 -15
  206. package/src/modes/types.ts +9 -1
  207. package/src/modes/utils/interactive-context-helpers.ts +1 -1
  208. package/src/priority.json +15 -0
  209. package/src/prompts/advisor/active-repo-watchdog.md +6 -0
  210. package/src/prompts/advisor/system.md +21 -7
  211. package/src/prompts/agents/designer.md +1 -1
  212. package/src/prompts/agents/explore.md +1 -1
  213. package/src/prompts/agents/librarian.md +2 -2
  214. package/src/prompts/agents/plan.md +2 -2
  215. package/src/prompts/agents/reviewer.md +1 -1
  216. package/src/prompts/agents/task.md +2 -2
  217. package/src/prompts/system/active-repo-context.md +4 -0
  218. package/src/prompts/system/gemini-tool-call-reminder.md +9 -0
  219. package/src/prompts/system/plan-mode-active.md +12 -3
  220. package/src/prompts/system/project-prompt.md +2 -2
  221. package/src/prompts/system/system-prompt.md +8 -6
  222. package/src/prompts/tools/bash.md +2 -2
  223. package/src/prompts/tools/checkpoint.md +1 -1
  224. package/src/prompts/tools/{find.md → glob.md} +1 -1
  225. package/src/prompts/tools/{search.md → grep.md} +3 -3
  226. package/src/prompts/tools/read.md +4 -2
  227. package/src/sdk.ts +98 -29
  228. package/src/session/agent-session.ts +828 -191
  229. package/src/session/session-history-format.ts +2 -2
  230. package/src/session/session-listing.ts +35 -2
  231. package/src/session/session-manager.ts +46 -0
  232. package/src/slash-commands/builtin-registry.ts +158 -22
  233. package/src/slash-commands/helpers/usage-report.ts +23 -2
  234. package/src/ssh/__tests__/connection-manager-args.test.ts +69 -0
  235. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +68 -0
  236. package/src/ssh/connection-manager.ts +12 -15
  237. package/src/ssh/file-transfer.ts +209 -0
  238. package/src/ssh/utils.ts +24 -0
  239. package/src/system-prompt.ts +60 -28
  240. package/src/task/executor.ts +96 -36
  241. package/src/task/index.ts +3 -3
  242. package/src/task/render.ts +14 -13
  243. package/src/task/worktree.ts +38 -1
  244. package/src/tiny/text.ts +49 -4
  245. package/src/tiny/worker.ts +3 -3
  246. package/src/tools/acp-bridge.ts +6 -1
  247. package/src/tools/bash.ts +2 -2
  248. package/src/tools/builtin-names.ts +26 -2
  249. package/src/tools/{find.ts → glob.ts} +48 -42
  250. package/src/tools/{search.ts → grep.ts} +311 -129
  251. package/src/tools/index.ts +13 -14
  252. package/src/tools/irc.ts +6 -2
  253. package/src/tools/path-utils.ts +64 -1
  254. package/src/tools/plan-mode-guard.ts +26 -13
  255. package/src/tools/read.ts +35 -6
  256. package/src/tools/render-utils.ts +14 -0
  257. package/src/tools/renderers.ts +15 -4
  258. package/src/tools/ssh.ts +17 -2
  259. package/src/tools/todo.ts +4 -26
  260. package/src/tools/write.ts +25 -9
  261. package/src/utils/active-repo-context.ts +143 -0
  262. package/src/utils/edit-mode.ts +19 -2
  263. package/src/utils/image-resize.ts +88 -7
  264. package/src/utils/lang-from-path.ts +3 -3
  265. package/src/utils/markit-cache.ts +166 -0
  266. package/src/utils/markit.ts +86 -18
  267. package/src/utils/prompt-path.ts +3 -0
  268. package/src/utils/shell-snapshot.ts +1 -1
  269. package/src/utils/title-generator.ts +1 -1
  270. package/src/web/search/provider.ts +54 -34
  271. package/src/web/search/providers/duckduckgo.ts +140 -0
  272. package/src/web/search/providers/firecrawl.ts +144 -0
  273. package/src/web/search/providers/tinyfish.ts +159 -0
  274. package/src/web/search/providers/xai.ts +292 -0
  275. package/src/web/search/providers/zai.ts +142 -56
  276. package/src/web/search/types.ts +6 -2
@@ -105,14 +105,14 @@ function primaryArg(name: string, args: Record<string, unknown> | undefined): st
105
105
  if (note) return oneLine(note);
106
106
  if (severity) return oneLine(severity);
107
107
  }
108
- if (name === "search") {
108
+ if (name === "grep") {
109
109
  const pattern = primaryArgValue(args.pattern);
110
110
  const paths = primaryArgValue(args.paths);
111
111
  if (pattern && paths) return oneLine(`${pattern} @ ${paths}`);
112
112
  if (pattern) return oneLine(pattern);
113
113
  if (paths) return oneLine(paths);
114
114
  }
115
- if (name === "find") {
115
+ if (name === "glob") {
116
116
  const paths = primaryArgValue(args.paths);
117
117
  if (paths) return oneLine(paths);
118
118
  }
@@ -495,6 +495,19 @@ async function scanSessionDir(
495
495
  }
496
496
  }
497
497
 
498
+ async function scanSessionDirReadOnly(
499
+ sessionDir: string,
500
+ storage: SessionStorage,
501
+ withStatus: boolean,
502
+ ): Promise<SessionInfo[]> {
503
+ try {
504
+ const files = storage.listFilesSync(sessionDir, "*.jsonl");
505
+ return await collectSessionsFromFiles(files, storage, withStatus);
506
+ } catch {
507
+ return [];
508
+ }
509
+ }
510
+
498
511
  /**
499
512
  * List sessions in a resolved session directory (newest first), reading each
500
513
  * file's lifecycle {@link SessionStatus}.
@@ -503,6 +516,13 @@ export function listSessions(sessionDir: string, storage: SessionStorage): Promi
503
516
  return scanSessionDir(sessionDir, storage, true);
504
517
  }
505
518
 
519
+ /**
520
+ * List sessions without repairing orphaned backups or mutating the directory.
521
+ */
522
+ export function listSessionsReadOnly(sessionDir: string, storage: SessionStorage): Promise<SessionInfo[]> {
523
+ return scanSessionDirReadOnly(sessionDir, storage, true);
524
+ }
525
+
506
526
  /** List all sessions across all project directories (newest first). */
507
527
  export async function listAllSessions(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
508
528
  const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
@@ -561,12 +581,25 @@ function sessionMatchesResumeArg(session: SessionInfo, sessionArg: string): bool
561
581
  return fileSessionId.startsWith(normalizedArg);
562
582
  }
563
583
 
584
+ /** Controls cross-directory fallback for resumable session lookup. */
585
+ export interface ResolveResumableSessionOptions {
586
+ /** Search default global session buckets after the active/custom session directory misses. */
587
+ allowGlobalFallback?: boolean;
588
+ }
589
+
590
+ function isSessionStorage(value: SessionStorage | ResolveResumableSessionOptions): value is SessionStorage {
591
+ return "listFilesSync" in value;
592
+ }
593
+
564
594
  export async function resolveResumableSession(
565
595
  sessionArg: string,
566
596
  cwd: string,
567
597
  sessionDir?: string,
568
- storage: SessionStorage = new FileSessionStorage(),
598
+ storageOrOptions: SessionStorage | ResolveResumableSessionOptions = new FileSessionStorage(),
599
+ options: ResolveResumableSessionOptions = {},
569
600
  ): Promise<ResolvedSessionMatch | undefined> {
601
+ const storage = isSessionStorage(storageOrOptions) ? storageOrOptions : new FileSessionStorage();
602
+ const resolvedOptions = isSessionStorage(storageOrOptions) ? options : storageOrOptions;
570
603
  const localSessionDir = sessionDir ?? computeDefaultSessionDir(cwd, storage);
571
604
  const localSessions = await listSessions(localSessionDir, storage);
572
605
  const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
@@ -574,7 +607,7 @@ export async function resolveResumableSession(
574
607
  return { session: localMatch, scope: "local" };
575
608
  }
576
609
 
577
- if (sessionDir) {
610
+ if (sessionDir && resolvedOptions.allowGlobalFallback !== true) {
578
611
  return undefined;
579
612
  }
580
613
 
@@ -1523,6 +1523,29 @@ export class SessionManager {
1523
1523
  return manager;
1524
1524
  }
1525
1525
 
1526
+ /**
1527
+ * Create a fresh empty session file in the default session directory for
1528
+ * `cwd`, writing only the session header. The returned path can be passed to
1529
+ * `setSessionFile` / `AgentSession.switchSession` to start a new empty
1530
+ * session in that directory. Used by `/move` to switch projects without
1531
+ * dragging the current conversation along.
1532
+ */
1533
+ static createEmptySessionFile(cwd: string, storage: SessionStorage = new FileSessionStorage()): string {
1534
+ const sessionDir = SessionManager.getDefaultSessionDir(cwd, undefined, storage);
1535
+ const id = mintSessionId();
1536
+ const timestamp = nowIso();
1537
+ const header: SessionHeader = {
1538
+ type: "session",
1539
+ version: CURRENT_SESSION_VERSION,
1540
+ id,
1541
+ timestamp,
1542
+ cwd: path.resolve(cwd),
1543
+ };
1544
+ const file = path.join(sessionDir, `${fileSafeTimestamp(timestamp)}_${id}.jsonl`);
1545
+ storage.writeTextSync(file, `${JSON.stringify(header)}\n`);
1546
+ return file;
1547
+ }
1548
+
1526
1549
  /**
1527
1550
  * Fork a session into the current project directory: copy history from another
1528
1551
  * session file while creating a fresh session file in this sessionDir.
@@ -1748,3 +1771,26 @@ export class SessionManager {
1748
1771
  return listAllSessions(storage);
1749
1772
  }
1750
1773
  }
1774
+
1775
+ /**
1776
+ * If the current session was created by `/move` and contains no real
1777
+ * user/assistant messages, delete it so empty move sessions don't accumulate.
1778
+ */
1779
+ export async function cleanupEmptyMoveSession(
1780
+ sessionManager: SessionManager,
1781
+ movedFromEmptySessionFile: string | undefined,
1782
+ ): Promise<void> {
1783
+ const sessionFile = sessionManager.getSessionFile();
1784
+ if (!sessionFile || !movedFromEmptySessionFile) return;
1785
+ if (path.resolve(sessionFile) !== path.resolve(movedFromEmptySessionFile)) return;
1786
+ const entries = sessionManager.getEntries();
1787
+ const hasRealMessages = entries.some(
1788
+ e => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"),
1789
+ );
1790
+ if (hasRealMessages) return;
1791
+ try {
1792
+ await sessionManager.dropSession(sessionFile);
1793
+ } catch (err) {
1794
+ logger.warn("Failed to clean up empty move session", { sessionFile, error: String(err) });
1795
+ }
1796
+ }
@@ -1,8 +1,9 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
4
5
  import { type AutocompleteItem, Spacer } from "@oh-my-pi/pi-tui";
5
- import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils";
6
+ import { APP_NAME, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils";
6
7
  import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
7
8
  import { CollabHost } from "../collab/host";
8
9
  import type { SettingPath, SettingValue } from "../config/settings";
@@ -27,7 +28,10 @@ import { theme } from "../modes/theme/theme";
27
28
  import type { InteractiveModeContext } from "../modes/types";
28
29
  import type { AgentSession, FreshSessionResult } from "../session/agent-session";
29
30
  import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
31
+ import { resolveResumableSession } from "../session/session-listing";
32
+ import { SessionManager } from "../session/session-manager";
30
33
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
34
+ import { expandTilde, resolveToCwd } from "../tools/path-utils";
31
35
  import { urlHyperlinkAlways } from "../tui";
32
36
  import { getChangelogPath, parseChangelog } from "../utils/changelog";
33
37
  import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
@@ -58,7 +62,7 @@ export type { BuiltinSlashCommand, SubcommandDef } from "./types";
58
62
  export type BuiltinSlashCommandRuntime = TuiSlashCommandRuntime;
59
63
 
60
64
  export interface TuiBuiltinSlashCommand extends BuiltinSlashCommand {
61
- getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
65
+ getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null | Promise<AutocompleteItem[] | null>;
62
66
  getInlineHint?: (argumentText: string) => string | null;
63
67
  getAutocompleteDescription?: () => string | undefined;
64
68
  }
@@ -1414,9 +1418,26 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1414
1418
  {
1415
1419
  name: "resume",
1416
1420
  description: "Resume a different session",
1417
- handleTui: (_command, runtime) => {
1418
- runtime.ctx.showSessionSelector();
1421
+ inlineHint: "[session id]",
1422
+ allowArgs: true,
1423
+ handleTui: async (command, runtime) => {
1424
+ const sessionArg = command.args.trim();
1419
1425
  runtime.ctx.editor.setText("");
1426
+ if (!sessionArg) {
1427
+ runtime.ctx.showSessionSelector();
1428
+ return;
1429
+ }
1430
+ const match = await resolveResumableSession(
1431
+ sessionArg,
1432
+ runtime.ctx.sessionManager.getCwd(),
1433
+ runtime.ctx.sessionManager.getSessionDir(),
1434
+ { allowGlobalFallback: true },
1435
+ );
1436
+ if (!match) {
1437
+ runtime.ctx.showError(`Session "${sessionArg}" not found`);
1438
+ return;
1439
+ }
1440
+ await runtime.ctx.handleResumeSession(match.session.path);
1420
1441
  },
1421
1442
  },
1422
1443
  {
@@ -1572,44 +1593,56 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1572
1593
  },
1573
1594
  {
1574
1595
  name: "move",
1575
- description: "Move session to a different working directory",
1576
- acpDescription: "Move the current session file",
1577
- inlineHint: "<path>",
1596
+ description: "Switch to a fresh session in a different directory",
1597
+ acpDescription: "Start a fresh session in a different directory",
1598
+ inlineHint: "[<path>]",
1578
1599
  allowArgs: true,
1579
1600
  handle: async (command, runtime) => {
1580
1601
  if (runtime.session.isStreaming) return usage("Cannot move while streaming.", runtime);
1581
1602
  if (!command.args) return usage("Usage: /move <path>", runtime);
1582
- const resolvedPath = path.resolve(runtime.cwd, command.args);
1583
- let isDirectory: boolean;
1603
+ const resolvedPath = resolveToCwd(command.args, runtime.cwd);
1584
1604
  try {
1585
- isDirectory = (await fs.stat(resolvedPath)).isDirectory();
1605
+ const stat = await fs.stat(resolvedPath);
1606
+ if (!stat.isDirectory()) {
1607
+ return usage(`Not a directory: ${resolvedPath}`, runtime);
1608
+ }
1586
1609
  } catch {
1587
- return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime);
1610
+ return usage(`Directory does not exist: ${resolvedPath}`, runtime);
1588
1611
  }
1589
- if (!isDirectory) return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime);
1612
+ let newSessionFile: string | undefined;
1590
1613
  try {
1591
- await runtime.sessionManager.flush();
1592
- await runtime.sessionManager.moveTo(resolvedPath);
1614
+ newSessionFile = SessionManager.createEmptySessionFile(resolvedPath);
1615
+ const switched = await runtime.session.switchSession(newSessionFile);
1616
+ if (!switched) {
1617
+ await runtime.sessionManager.dropSession(newSessionFile);
1618
+ return usage("Move cancelled.", runtime);
1619
+ }
1593
1620
  } catch (err) {
1621
+ if (newSessionFile) {
1622
+ try {
1623
+ await runtime.sessionManager.dropSession(newSessionFile);
1624
+ } catch (dropErr) {
1625
+ return usage(
1626
+ `Move failed: ${errorMessage(err)}; failed to remove empty session: ${errorMessage(dropErr)}`,
1627
+ runtime,
1628
+ );
1629
+ }
1630
+ }
1594
1631
  return usage(`Move failed: ${errorMessage(err)}`, runtime);
1595
1632
  }
1633
+ runtime.session.markMovedFromEmptySessionFile(newSessionFile!);
1596
1634
  setProjectDir(resolvedPath);
1597
1635
  // Reload plugin/capability caches so the next prompt sees commands and
1598
1636
  // capabilities scoped to the new cwd.
1599
1637
  await runtime.reloadPlugins();
1600
1638
  await runtime.notifyTitleChanged?.();
1601
- await runtime.output(`Session moved to ${runtime.sessionManager.getCwd()}.`);
1639
+ await runtime.output(`Moved to ${runtime.sessionManager.getCwd()}.`);
1602
1640
  return commandConsumed();
1603
1641
  },
1604
1642
  handleTui: async (command, runtime) => {
1605
- const targetPath = command.args;
1606
- if (!targetPath) {
1607
- runtime.ctx.showError("Usage: /move <path>");
1608
- runtime.ctx.editor.setText("");
1609
- return;
1610
- }
1643
+ runtime.ctx.editor.addToHistory(command.text);
1611
1644
  runtime.ctx.editor.setText("");
1612
- await runtime.ctx.handleMoveCommand(targetPath);
1645
+ await runtime.ctx.handleMoveCommand(command.args || undefined);
1613
1646
  },
1614
1647
  },
1615
1648
  {
@@ -2276,6 +2309,106 @@ function buildStaticInlineHint(hint: string): (argumentText: string) => string |
2276
2309
  return (argumentText: string) => (argumentText.trim().length === 0 ? hint : null);
2277
2310
  }
2278
2311
 
2312
+ /**
2313
+ * Build getArgumentCompletions that suggests directories relative to the
2314
+ * current project directory. Used by /move so users can Tab-complete the
2315
+ * destination directory.
2316
+ */
2317
+ function buildDirectoryArgumentCompletions(): (prefix: string) => Promise<AutocompleteItem[] | null> {
2318
+ return async (argumentPrefix: string) => {
2319
+ const prefix = argumentPrefix.trim();
2320
+
2321
+ const cwd = getProjectDir();
2322
+ const expandedPrefix = expandTilde(prefix);
2323
+ const isAbsolute = path.isAbsolute(expandedPrefix);
2324
+
2325
+ let searchDir: string;
2326
+ let searchPrefix: string;
2327
+ if (
2328
+ prefix === "" ||
2329
+ prefix === "." ||
2330
+ prefix === "./" ||
2331
+ prefix === ".." ||
2332
+ prefix === "../" ||
2333
+ prefix === "~" ||
2334
+ prefix === "~/" ||
2335
+ prefix === "/"
2336
+ ) {
2337
+ searchDir = isAbsolute ? expandedPrefix : path.join(cwd, expandedPrefix);
2338
+ searchPrefix = "";
2339
+ } else if (expandedPrefix.endsWith("/")) {
2340
+ searchDir = isAbsolute ? expandedPrefix : path.join(cwd, expandedPrefix);
2341
+ searchPrefix = "";
2342
+ } else {
2343
+ const dir = path.dirname(expandedPrefix);
2344
+ searchDir = isAbsolute ? dir : path.join(cwd, dir);
2345
+ searchPrefix = path.basename(expandedPrefix);
2346
+ }
2347
+
2348
+ try {
2349
+ const entries = await fs.readdir(searchDir, { withFileTypes: true });
2350
+ const suggestions: AutocompleteItem[] = [];
2351
+ for (const entry of entries) {
2352
+ if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) continue;
2353
+ if (entry.name === ".git") continue;
2354
+
2355
+ let isDirectory = entry.isDirectory();
2356
+ if (!isDirectory && entry.isSymbolicLink()) {
2357
+ try {
2358
+ isDirectory = (await fs.stat(path.join(searchDir, entry.name))).isDirectory();
2359
+ } catch {
2360
+ continue;
2361
+ }
2362
+ }
2363
+ if (!isDirectory) continue;
2364
+
2365
+ const absoluteValue = path.join(searchDir, entry.name);
2366
+ const displayValue = buildDirectoryCompletionDisplayValue(prefix, absoluteValue, cwd);
2367
+ suggestions.push({ value: displayValue, label: `${entry.name}/` });
2368
+ }
2369
+ suggestions.sort((a, b) => a.label.localeCompare(b.label));
2370
+ return suggestions.length > 0 ? suggestions : null;
2371
+ } catch {
2372
+ return null;
2373
+ }
2374
+ };
2375
+ }
2376
+ function buildDirectoryCompletionDisplayValue(prefix: string, absoluteValue: string, cwd: string): string {
2377
+ // Preserve the user's prefix style where possible, but always return a
2378
+ // value that /move can resolve (absolute or relative) without escaping.
2379
+ const normalized = path.normalize(absoluteValue);
2380
+
2381
+ if (prefix.startsWith("~/")) {
2382
+ const home = os.homedir();
2383
+ const homeRelative = path.relative(home, normalized);
2384
+ return `~/${homeRelative.replaceAll("\\", "/")}/`;
2385
+ }
2386
+ if (prefix === "~") {
2387
+ const home = os.homedir();
2388
+ const homeRelative = path.relative(home, normalized);
2389
+ return `~/${homeRelative.replaceAll("\\", "/")}/`;
2390
+ }
2391
+ if (prefix.startsWith("/")) {
2392
+ return `${normalized.replaceAll("\\", "/")}/`;
2393
+ }
2394
+ if (prefix.startsWith("./")) {
2395
+ const relative = path.relative(cwd, normalized);
2396
+ return `./${relative.replaceAll("\\", "/")}/`;
2397
+ }
2398
+ if (prefix.startsWith("../")) {
2399
+ const relative = path.relative(cwd, normalized);
2400
+ return `${relative.replaceAll("\\", "/")}/`;
2401
+ }
2402
+ if (prefix === "..") {
2403
+ const relative = path.relative(cwd, normalized);
2404
+ return `${relative.replaceAll("\\", "/")}/`;
2405
+ }
2406
+
2407
+ // Default: relative to cwd.
2408
+ const relative = path.relative(cwd, normalized);
2409
+ return `${relative.replaceAll("\\", "/")}/`;
2410
+ }
2411
+
2279
2412
  /** Builtin command metadata used for slash-command autocomplete and help text. */
2280
2413
  export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray<BuiltinSlashCommand> = BUILTIN_SLASH_COMMAND_REGISTRY.map(
2281
2414
  command => ({
@@ -2296,6 +2429,9 @@ function materializeTuiBuiltinSlashCommand(
2296
2429
  if (cmd.subcommands) {
2297
2430
  materialized.getArgumentCompletions = buildArgumentCompletions(cmd.subcommands);
2298
2431
  materialized.getInlineHint = buildSubcommandInlineHint(cmd.subcommands);
2432
+ } else if (cmd.name === "move") {
2433
+ materialized.getArgumentCompletions = buildDirectoryArgumentCompletions();
2434
+ if (cmd.inlineHint) materialized.getInlineHint = buildStaticInlineHint(cmd.inlineHint);
2299
2435
  } else if (cmd.inlineHint) {
2300
2436
  materialized.getInlineHint = buildStaticInlineHint(cmd.inlineHint);
2301
2437
  }
@@ -27,9 +27,14 @@ function formatUsageAmount(limit: UsageLimit): string {
27
27
  function formatUsageReportAccount(report: UsageReport, limit: UsageLimit, index: number): string {
28
28
  const email = report.metadata?.email;
29
29
  if (typeof email === "string" && email) return email;
30
- const accountId = report.metadata?.accountId ?? limit.scope.accountId;
30
+ // Guard metadata values for truthiness before using, then fall back to scope.
31
+ // ?? won't help here: empty string is not null/undefined, so it would suppress
32
+ // a valid scoped fallback (e.g. metadata.accountId="" hides limit.scope.accountId).
33
+ const metaAccountId = report.metadata?.accountId;
34
+ const accountId = typeof metaAccountId === "string" && metaAccountId ? metaAccountId : limit.scope.accountId;
31
35
  if (typeof accountId === "string" && accountId) return accountId;
32
- const projectId = report.metadata?.projectId ?? limit.scope.projectId;
36
+ const metaProjectId = report.metadata?.projectId;
37
+ const projectId = typeof metaProjectId === "string" && metaProjectId ? metaProjectId : limit.scope.projectId;
33
38
  if (typeof projectId === "string" && projectId) return projectId;
34
39
  return `account ${index + 1}`;
35
40
  }
@@ -70,6 +75,22 @@ function renderUsageReports(
70
75
  lines.push(
71
76
  `- ${resetLabel}: ${savedResets} saved rate-limit reset${savedResets === 1 ? "" : "s"} available — /usage reset to spend`,
72
77
  );
78
+ const credits = report.resetCredits?.credits;
79
+ if (credits) {
80
+ for (const credit of credits) {
81
+ if (credit.expiresAt) {
82
+ const expiryMs = Date.parse(credit.expiresAt);
83
+ if (!Number.isNaN(expiryMs)) {
84
+ const remaining = expiryMs - nowMs;
85
+ if (remaining > 0) {
86
+ lines.push(` expires in ${formatDuration(remaining)} (${credit.expiresAt.slice(0, 10)})`);
87
+ } else {
88
+ lines.push(` expired (${credit.expiresAt.slice(0, 10)})`);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
73
94
  }
74
95
  if (report.limits.length === 0) {
75
96
  const email = typeof report.metadata?.email === "string" ? report.metadata.email : "account";
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { getRemoteHostDir } from "@oh-my-pi/pi-utils";
5
+ import { buildRemoteCommand, getHostInfo, type SSHConnectionTarget, type SSHHostShell } from "../connection-manager";
6
+ import { buildSshTarget, sanitizeHostName } from "../utils";
7
+
8
+ const TARGET: SSHConnectionTarget = { name: "h", host: "h" };
9
+
10
+ describe("buildRemoteCommand stdin handling", () => {
11
+ it("includes -n by default so ssh reads stdin from /dev/null", async () => {
12
+ const args = await buildRemoteCommand(TARGET, "cat");
13
+ expect(args).toContain("-n");
14
+ });
15
+
16
+ it("omits -n when allowStdin is set so the remote command reads piped stdin", async () => {
17
+ const args = await buildRemoteCommand(TARGET, "cat", { allowStdin: true });
18
+ expect(args).not.toContain("-n");
19
+ });
20
+ });
21
+
22
+ describe("buildSshTarget argument-injection guard", () => {
23
+ it("rejects a host that begins with '-' (ssh would parse it as an option)", () => {
24
+ expect(() => buildSshTarget(undefined, "-oProxyCommand=touch /tmp/pwned")).toThrow(/must not begin with/);
25
+ });
26
+
27
+ it("rejects a username that begins with '-'", () => {
28
+ expect(() => buildSshTarget("-oProxyCommand=x", "host")).toThrow(/must not begin with/);
29
+ });
30
+
31
+ it("renders a normal destination unchanged", () => {
32
+ expect(buildSshTarget("user", "host")).toBe("user@host");
33
+ expect(buildSshTarget(undefined, "host")).toBe("host");
34
+ });
35
+
36
+ it("rejects a dash-leading host through the real buildRemoteCommand path", async () => {
37
+ await expect(buildRemoteCommand({ name: "x", host: "-oProxyCommand=x" }, "cat")).rejects.toThrow(
38
+ /must not begin with/,
39
+ );
40
+ });
41
+ });
42
+
43
+ describe("ssh host shell classification", () => {
44
+ it("treats fish/csh/tcsh as non-POSIX (unknown) and keeps real sh-family as sh", async () => {
45
+ // parseHostInfo re-runs parseShell on the stored shell field, so getHostInfo
46
+ // exercises the classifier through a public seam. The ensurePosixRemote
47
+ // whitelist then refuses anything that isn't sh/bash/zsh.
48
+ const cases: Array<[string, SSHHostShell]> = [
49
+ ["/usr/bin/fish", "unknown"],
50
+ ["/bin/csh", "unknown"],
51
+ ["/bin/tcsh", "unknown"],
52
+ ["/bin/dash", "sh"],
53
+ ["/bin/sh", "sh"],
54
+ ["/usr/bin/bash", "bash"],
55
+ ["/usr/bin/zsh", "zsh"],
56
+ ];
57
+ for (const [shellValue, expected] of cases) {
58
+ const name = `omp-shellclf-${crypto.randomUUID()}`;
59
+ const file = path.join(getRemoteHostDir(), `${sanitizeHostName(name)}.json`);
60
+ await Bun.write(file, JSON.stringify({ version: 3, os: "linux", shell: shellValue, compatEnabled: false }));
61
+ try {
62
+ const info = await getHostInfo(name);
63
+ expect(info?.shell).toBe(expected);
64
+ } finally {
65
+ await fs.promises.rm(file, { force: true });
66
+ }
67
+ }
68
+ });
69
+ });
@@ -0,0 +1,68 @@
1
+ import { afterEach, describe, expect, it, vi } from "bun:test";
2
+ import type { SSHConnectionTarget } from "../connection-manager";
3
+ import * as connectionManager from "../connection-manager";
4
+ import { readRemoteFile, writeRemoteFile } from "../file-transfer";
5
+
6
+ describe("ssh file-transfer POSIX guard", () => {
7
+ afterEach(() => {
8
+ vi.restoreAllMocks();
9
+ });
10
+
11
+ it("rejects a confirmed Windows remote before running any POSIX command", async () => {
12
+ // Stub BOTH the connection and the host-info probe so the guard is reached
13
+ // without opening a real SSH connection and before any command is spawned.
14
+ const ensureConnectionSpy = vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
15
+ const ensureHostInfoSpy = vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
16
+ version: 2,
17
+ os: "windows",
18
+ shell: "powershell",
19
+ compatEnabled: false,
20
+ });
21
+ const target: SSHConnectionTarget = { name: "winbox", host: "winbox" };
22
+ await expect(readRemoteFile(target, "C:/x.txt", { maxBytes: 1024 })).rejects.toThrow(/Windows host/);
23
+ await expect(writeRemoteFile(target, "C:/x.txt", new Uint8Array([1]), {})).rejects.toThrow(/Windows host/);
24
+ // Prove the guard ran through the stubbed transport rather than failing early
25
+ // for an unrelated reason (e.g. a future import refactor bypassing the mocks).
26
+ expect(ensureConnectionSpy).toHaveBeenCalled();
27
+ expect(ensureHostInfoSpy).toHaveBeenCalled();
28
+ });
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.
33
+ vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
34
+ vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
35
+ version: 3,
36
+ os: "linux",
37
+ shell: "unknown",
38
+ compatEnabled: false,
39
+ });
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/);
43
+ });
44
+
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.
48
+ vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
49
+ vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
50
+ version: 3,
51
+ os: "linux",
52
+ shell: "sh",
53
+ compatEnabled: false,
54
+ });
55
+ const buildSpy = vi
56
+ .spyOn(connectionManager, "buildRemoteCommand")
57
+ .mockRejectedValue(new Error("stop-before-spawn"));
58
+ const target: SSHConnectionTarget = { name: "shbox", host: "shbox" };
59
+
60
+ await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/stop-before-spawn/);
61
+ await expect(writeRemoteFile(target, "/tmp/x", new Uint8Array([1]), {})).rejects.toThrow(/stop-before-spawn/);
62
+
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");
66
+ expect(buildSpy.mock.calls[1]?.[2]).toMatchObject({ allowStdin: true });
67
+ });
68
+ });
@@ -32,7 +32,7 @@ export interface SSHHostInfo {
32
32
  const CONTROL_DIR = getSshControlDir();
33
33
  const CONTROL_PATH = path.join(CONTROL_DIR, "%C.sock");
34
34
  const HOST_INFO_DIR = getRemoteHostDir();
35
- const HOST_INFO_VERSION = 2;
35
+ const HOST_INFO_VERSION = 3;
36
36
 
37
37
  const activeHosts = new Map<string, SSHConnectionTarget>();
38
38
  const pendingConnections = new Map<string, Promise<void>>();
@@ -40,6 +40,8 @@ const hostInfoCache = new Map<string, SSHHostInfo>();
40
40
 
41
41
  interface SSHArgsOptions {
42
42
  platform?: SshPlatform;
43
+ /** When true, omit `-n` so the remote command can read from our piped stdin. */
44
+ allowStdin?: boolean;
43
45
  }
44
46
 
45
47
  function ensureControlDir() {
@@ -87,7 +89,7 @@ async function validateKeyPermissions(keyPath?: string, platform: SshPlatform =
87
89
  }
88
90
 
89
91
  function buildCommonArgs(host: SSHConnectionTarget, options?: SSHArgsOptions): string[] {
90
- const args = ["-n"];
92
+ const args = options?.allowStdin ? [] : ["-n"];
91
93
 
92
94
  if (supportsSshControlMaster(options?.platform)) {
93
95
  args.push("-o", "ControlMaster=auto", "-o", `ControlPath=${CONTROL_PATH}`, "-o", "ControlPersist=3600");
@@ -151,7 +153,11 @@ function parseShell(value: unknown): SSHHostShell | null {
151
153
  if (normalized.includes("zsh")) return "zsh";
152
154
  if (normalized.includes("pwsh") || normalized.includes("powershell")) return "powershell";
153
155
  if (normalized.includes("cmd.exe") || normalized === "cmd") return "cmd";
154
- if (normalized.endsWith("sh") || normalized.includes("/sh")) return "sh";
156
+ // Only genuine POSIX sh-family by basename — fish/csh/tcsh also end in "sh"
157
+ // but are non-POSIX (csh/tcsh history-expand `!`), so they fall through to
158
+ // "unknown" and are refused by the ssh:// transfer guard.
159
+ const base = normalized.slice(normalized.lastIndexOf("/") + 1);
160
+ if (base === "sh" || base === "dash" || base === "ash" || base === "ksh" || base === "mksh") return "sh";
155
161
  return "unknown";
156
162
  }
157
163
 
@@ -293,18 +299,9 @@ async function probeHostInfo(host: SSHConnectionTarget): Promise<SSHHostInfo> {
293
299
  os = "linux";
294
300
  }
295
301
 
296
- let shell: SSHHostShell = "unknown";
297
- if (shellLower.includes("bash")) {
298
- shell = "bash";
299
- } else if (shellLower.includes("zsh")) {
300
- shell = "zsh";
301
- } else if (shellLower.includes("pwsh") || shellLower.includes("powershell")) {
302
- shell = "powershell";
303
- } else if (shellLower.includes("cmd.exe") || shellLower === "cmd") {
304
- shell = "cmd";
305
- } else if (shellLower.endsWith("sh") || shellLower.includes("/sh")) {
306
- shell = "sh";
307
- } else if (os === "windows" && !shellLower) {
302
+ // Reuse parseShell so probe-time and cached classification stay identical.
303
+ let shell = parseShell(shellLower) ?? "unknown";
304
+ if (shell === "unknown" && os === "windows" && !shellLower) {
308
305
  shell = "cmd";
309
306
  }
310
307