@oh-my-pi/pi-coding-agent 16.1.23 → 16.2.1

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 (260) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/cli.js +3785 -3695
  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 +13 -0
  8. package/dist/types/cli/flag-tables.d.ts +1 -0
  9. package/dist/types/cli/gallery-fixtures/search.d.ts +1 -1
  10. package/dist/types/collab/display-name.d.ts +3 -0
  11. package/dist/types/collab/host.d.ts +0 -2
  12. package/dist/types/commands/worktree.d.ts +0 -3
  13. package/dist/types/config/inline-tool-descriptors-mode.d.ts +13 -0
  14. package/dist/types/config/model-discovery.d.ts +6 -1
  15. package/dist/types/config/model-registry.d.ts +6 -2
  16. package/dist/types/config/model-resolver.d.ts +12 -0
  17. package/dist/types/config/models-config-schema.d.ts +29 -2
  18. package/dist/types/config/models-config.d.ts +22 -1
  19. package/dist/types/config/settings-schema.d.ts +91 -21
  20. package/dist/types/config/settings.d.ts +2 -0
  21. package/dist/types/dap/config.d.ts +1 -1
  22. package/dist/types/edit/hashline/filesystem.d.ts +4 -2
  23. package/dist/types/edit/renderer.d.ts +4 -0
  24. package/dist/types/extensibility/extensions/types.d.ts +17 -17
  25. package/dist/types/extensibility/hooks/types.d.ts +10 -10
  26. package/dist/types/extensibility/plugins/marketplace/types.d.ts +1 -0
  27. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +1 -0
  28. package/dist/types/internal-urls/index.d.ts +1 -0
  29. package/dist/types/internal-urls/registry-helpers.d.ts +2 -0
  30. package/dist/types/internal-urls/router.d.ts +1 -1
  31. package/dist/types/internal-urls/ssh-protocol.d.ts +10 -0
  32. package/dist/types/internal-urls/types.d.ts +19 -2
  33. package/dist/types/irc/bus.d.ts +6 -0
  34. package/dist/types/modes/acp/acp-event-mapper.d.ts +1 -0
  35. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +1 -0
  36. package/dist/types/modes/components/custom-editor.d.ts +7 -0
  37. package/dist/types/modes/components/move-overlay.d.ts +23 -0
  38. package/dist/types/modes/components/plugin-selector.d.ts +2 -1
  39. package/dist/types/modes/components/queue-mode-selector.d.ts +2 -1
  40. package/dist/types/modes/components/select-list-mouse-routing.d.ts +2 -0
  41. package/dist/types/modes/components/settings-defs.d.ts +4 -1
  42. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  43. package/dist/types/modes/components/show-images-selector.d.ts +2 -1
  44. package/dist/types/modes/components/status-line/component.d.ts +2 -0
  45. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  46. package/dist/types/modes/components/theme-selector.d.ts +2 -1
  47. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  48. package/dist/types/modes/controllers/command-controller.d.ts +10 -1
  49. package/dist/types/modes/controllers/streaming-reveal.d.ts +6 -0
  50. package/dist/types/modes/interactive-mode.d.ts +10 -1
  51. package/dist/types/modes/internal-url-autocomplete.d.ts +1 -1
  52. package/dist/types/modes/running-subagent-badge.d.ts +6 -0
  53. package/dist/types/modes/theme/theme.d.ts +1 -1
  54. package/dist/types/modes/types.d.ts +9 -1
  55. package/dist/types/sdk.d.ts +2 -2
  56. package/dist/types/session/agent-session.d.ts +8 -1
  57. package/dist/types/session/blob-store.d.ts +4 -0
  58. package/dist/types/session/session-manager.d.ts +13 -0
  59. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  60. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +1 -0
  61. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +1 -0
  62. package/dist/types/ssh/connection-manager.d.ts +2 -0
  63. package/dist/types/ssh/file-transfer.d.ts +79 -0
  64. package/dist/types/ssh/utils.d.ts +6 -0
  65. package/dist/types/system-prompt.d.ts +3 -0
  66. package/dist/types/task/executor.d.ts +16 -0
  67. package/dist/types/tiny/text.d.ts +1 -1
  68. package/dist/types/tools/builtin-names.d.ts +5 -1
  69. package/dist/types/tools/{find.d.ts → glob.d.ts} +15 -15
  70. package/dist/types/tools/{search.d.ts → grep.d.ts} +14 -14
  71. package/dist/types/tools/index.d.ts +3 -3
  72. package/dist/types/tools/path-utils.d.ts +29 -0
  73. package/dist/types/tools/read.d.ts +2 -2
  74. package/dist/types/tools/render-utils.d.ts +8 -0
  75. package/dist/types/tools/renderers.d.ts +11 -0
  76. package/dist/types/tools/ssh.d.ts +2 -0
  77. package/dist/types/tools/todo.d.ts +0 -16
  78. package/dist/types/tools/write.d.ts +2 -2
  79. package/dist/types/utils/active-repo-context.d.ts +8 -0
  80. package/dist/types/utils/image-resize.d.ts +1 -0
  81. package/dist/types/utils/markit-cache.d.ts +23 -0
  82. package/dist/types/utils/markit.d.ts +5 -1
  83. package/dist/types/utils/prompt-path.d.ts +1 -0
  84. package/dist/types/web/search/providers/duckduckgo.d.ts +14 -0
  85. package/dist/types/web/search/providers/firecrawl.d.ts +28 -0
  86. package/dist/types/web/search/providers/tinyfish.d.ts +29 -0
  87. package/dist/types/web/search/providers/xai.d.ts +13 -0
  88. package/dist/types/web/search/types.d.ts +18 -2
  89. package/package.json +30 -15
  90. package/scripts/build-binary.ts +9 -9
  91. package/scripts/bundle-dist.ts +2 -2
  92. package/src/advisor/__tests__/advisor.test.ts +61 -4
  93. package/src/advisor/__tests__/emission-guard.test.ts +147 -0
  94. package/src/advisor/advise-tool.ts +1 -1
  95. package/src/advisor/emission-guard.ts +172 -0
  96. package/src/advisor/index.ts +1 -0
  97. package/src/advisor/runtime.ts +11 -0
  98. package/src/advisor/watchdog.ts +27 -1
  99. package/src/cli/args.ts +5 -2
  100. package/src/cli/auth-broker-cli.ts +17 -0
  101. package/src/cli/config-cli.ts +4 -0
  102. package/src/cli/flag-tables.ts +7 -4
  103. package/src/cli/gallery-cli.ts +14 -2
  104. package/src/cli/gallery-fixtures/edit.ts +60 -0
  105. package/src/cli/gallery-fixtures/fs.ts +17 -17
  106. package/src/cli/gallery-fixtures/search.ts +4 -4
  107. package/src/cli/usage-cli.ts +20 -1
  108. package/src/cli/web-search-cli.ts +1 -1
  109. package/src/collab/display-name.ts +13 -0
  110. package/src/collab/guest.ts +5 -1
  111. package/src/collab/host.ts +2 -13
  112. package/src/commands/worktree.ts +6 -0
  113. package/src/config/inline-tool-descriptors-mode.ts +27 -0
  114. package/src/config/model-discovery.ts +77 -8
  115. package/src/config/model-registry.ts +89 -11
  116. package/src/config/model-resolver.ts +39 -1
  117. package/src/config/models-config-schema.ts +41 -5
  118. package/src/config/models-config.ts +4 -1
  119. package/src/config/settings-schema.ts +85 -27
  120. package/src/config/settings.ts +172 -1
  121. package/src/cursor.ts +1 -1
  122. package/src/dap/config.ts +152 -8
  123. package/src/dap/session.ts +1 -1
  124. package/src/discovery/claude-plugins.ts +3 -2
  125. package/src/discovery/helpers.ts +3 -1
  126. package/src/edit/hashline/diff.ts +14 -3
  127. package/src/edit/hashline/execute.ts +42 -6
  128. package/src/edit/hashline/filesystem.ts +38 -2
  129. package/src/edit/index.ts +1 -0
  130. package/src/edit/modes/patch.ts +11 -1
  131. package/src/edit/renderer.ts +140 -13
  132. package/src/eval/__tests__/agent-bridge.test.ts +101 -0
  133. package/src/eval/agent-bridge.ts +26 -3
  134. package/src/eval/js/tool-bridge.ts +2 -2
  135. package/src/export/html/index.ts +1 -1
  136. package/src/export/html/template.js +3 -1
  137. package/src/export/html/tool-views.generated.js +20 -20
  138. package/src/extensibility/extensions/types.ts +22 -22
  139. package/src/extensibility/hooks/types.ts +11 -11
  140. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +8 -3
  141. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +25 -10
  142. package/src/extensibility/plugins/marketplace/manager.ts +22 -0
  143. package/src/extensibility/plugins/marketplace/types.ts +1 -0
  144. package/src/internal-urls/__tests__/ssh-protocol.test.ts +331 -0
  145. package/src/internal-urls/docs-index.generated.txt +2 -2
  146. package/src/internal-urls/docs-index.ts +2 -3
  147. package/src/internal-urls/index.ts +1 -0
  148. package/src/internal-urls/registry-helpers.ts +19 -4
  149. package/src/internal-urls/router.ts +5 -3
  150. package/src/internal-urls/ssh-protocol.ts +367 -0
  151. package/src/internal-urls/types.ts +19 -2
  152. package/src/irc/bus.ts +11 -3
  153. package/src/mcp/tool-bridge.ts +32 -2
  154. package/src/memories/index.ts +1 -1
  155. package/src/modes/acp/acp-agent.ts +14 -1
  156. package/src/modes/acp/acp-event-mapper.ts +91 -27
  157. package/src/modes/components/__tests__/move-overlay.test.ts +166 -0
  158. package/src/modes/components/agent-hub.ts +3 -0
  159. package/src/modes/components/agent-transcript-viewer.ts +9 -7
  160. package/src/modes/components/custom-editor.ts +90 -5
  161. package/src/modes/components/move-overlay.ts +282 -0
  162. package/src/modes/components/plan-review-overlay.ts +35 -35
  163. package/src/modes/components/plugin-selector.ts +6 -1
  164. package/src/modes/components/queue-mode-selector.ts +6 -1
  165. package/src/modes/components/select-list-mouse-routing.ts +35 -0
  166. package/src/modes/components/session-selector.ts +11 -10
  167. package/src/modes/components/settings-defs.ts +14 -1
  168. package/src/modes/components/settings-selector.ts +196 -29
  169. package/src/modes/components/show-images-selector.ts +6 -1
  170. package/src/modes/components/status-line/component.ts +108 -28
  171. package/src/modes/components/status-line/segments.ts +5 -1
  172. package/src/modes/components/status-line/types.ts +2 -0
  173. package/src/modes/components/theme-selector.ts +6 -1
  174. package/src/modes/components/thinking-selector.ts +6 -1
  175. package/src/modes/components/tool-execution.ts +25 -9
  176. package/src/modes/components/tree-selector.ts +5 -5
  177. package/src/modes/controllers/command-controller.ts +140 -47
  178. package/src/modes/controllers/event-controller.ts +59 -3
  179. package/src/modes/controllers/input-controller.ts +68 -2
  180. package/src/modes/controllers/selector-controller.ts +5 -2
  181. package/src/modes/controllers/streaming-reveal.ts +17 -0
  182. package/src/modes/controllers/tool-args-reveal.ts +1 -1
  183. package/src/modes/interactive-mode.ts +116 -50
  184. package/src/modes/internal-url-autocomplete.ts +17 -2
  185. package/src/modes/prompt-action-autocomplete.ts +3 -1
  186. package/src/modes/running-subagent-badge.ts +13 -0
  187. package/src/modes/setup-wizard/scenes/glyph.ts +2 -13
  188. package/src/modes/setup-wizard/scenes/providers.ts +2 -1
  189. package/src/modes/setup-wizard/scenes/theme.ts +6 -12
  190. package/src/modes/setup-wizard/scenes/web-search.ts +8 -13
  191. package/src/modes/setup-wizard/wizard-overlay.ts +6 -5
  192. package/src/modes/theme/theme.ts +9 -1
  193. package/src/modes/types.ts +9 -1
  194. package/src/modes/utils/interactive-context-helpers.ts +1 -1
  195. package/src/priority.json +15 -0
  196. package/src/prompts/advisor/active-repo-watchdog.md +6 -0
  197. package/src/prompts/advisor/context-files.md +8 -0
  198. package/src/prompts/advisor/system.md +21 -7
  199. package/src/prompts/agents/designer.md +1 -1
  200. package/src/prompts/agents/explore.md +1 -1
  201. package/src/prompts/agents/librarian.md +2 -2
  202. package/src/prompts/agents/plan.md +2 -2
  203. package/src/prompts/agents/reviewer.md +1 -1
  204. package/src/prompts/agents/task.md +2 -2
  205. package/src/prompts/system/active-repo-context.md +4 -0
  206. package/src/prompts/system/gemini-tool-call-reminder.md +9 -0
  207. package/src/prompts/system/plan-mode-active.md +3 -3
  208. package/src/prompts/system/project-prompt.md +2 -2
  209. package/src/prompts/system/system-prompt.md +6 -6
  210. package/src/prompts/tools/bash.md +2 -2
  211. package/src/prompts/tools/checkpoint.md +1 -1
  212. package/src/prompts/tools/{find.md → glob.md} +1 -1
  213. package/src/prompts/tools/{search.md → grep.md} +3 -3
  214. package/src/prompts/tools/read.md +4 -2
  215. package/src/sdk.ts +65 -25
  216. package/src/session/agent-session.ts +519 -132
  217. package/src/session/blob-store.ts +24 -0
  218. package/src/session/session-history-format.ts +2 -2
  219. package/src/session/session-manager.ts +46 -0
  220. package/src/slash-commands/builtin-registry.ts +138 -20
  221. package/src/slash-commands/helpers/usage-report.ts +23 -2
  222. package/src/ssh/__tests__/connection-manager-args.test.ts +69 -0
  223. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +68 -0
  224. package/src/ssh/connection-manager.ts +12 -15
  225. package/src/ssh/file-transfer.ts +209 -0
  226. package/src/ssh/utils.ts +24 -0
  227. package/src/system-prompt.ts +56 -28
  228. package/src/task/executor.ts +96 -36
  229. package/src/task/index.ts +3 -3
  230. package/src/task/render.ts +14 -13
  231. package/src/task/worktree.ts +38 -1
  232. package/src/tiny/text.ts +49 -4
  233. package/src/tiny/worker.ts +3 -3
  234. package/src/tools/bash.ts +2 -2
  235. package/src/tools/builtin-names.ts +26 -2
  236. package/src/tools/{find.ts → glob.ts} +48 -42
  237. package/src/tools/{search.ts → grep.ts} +311 -129
  238. package/src/tools/index.ts +13 -14
  239. package/src/tools/irc.ts +6 -2
  240. package/src/tools/path-utils.ts +64 -1
  241. package/src/tools/read.ts +35 -6
  242. package/src/tools/render-utils.ts +14 -0
  243. package/src/tools/renderers.ts +15 -4
  244. package/src/tools/ssh.ts +17 -2
  245. package/src/tools/todo.ts +4 -26
  246. package/src/tools/write.ts +25 -9
  247. package/src/utils/active-repo-context.ts +143 -0
  248. package/src/utils/image-resize.ts +88 -7
  249. package/src/utils/lang-from-path.ts +3 -3
  250. package/src/utils/markit-cache.ts +166 -0
  251. package/src/utils/markit.ts +86 -18
  252. package/src/utils/prompt-path.ts +3 -0
  253. package/src/utils/title-generator.ts +1 -1
  254. package/src/web/search/provider.ts +54 -34
  255. package/src/web/search/providers/duckduckgo.ts +140 -0
  256. package/src/web/search/providers/firecrawl.ts +144 -0
  257. package/src/web/search/providers/tinyfish.ts +159 -0
  258. package/src/web/search/providers/xai.ts +292 -0
  259. package/src/web/search/providers/zai.ts +142 -56
  260. package/src/web/search/types.ts +6 -2
@@ -152,6 +152,17 @@ export class BlobStore {
152
152
  }
153
153
  }
154
154
 
155
+ /** Synchronous variant of {@link get}. */
156
+ getSync(hash: string): Buffer | null {
157
+ const blobPath = path.join(this.dir, hash);
158
+ try {
159
+ return fs.readFileSync(blobPath);
160
+ } catch (err) {
161
+ if (isEnoent(err)) return null;
162
+ throw err;
163
+ }
164
+ }
165
+
155
166
  /** Check if a blob exists. */
156
167
  async has(hash: string): Promise<boolean> {
157
168
  try {
@@ -253,3 +264,16 @@ export async function resolveImageData(blobStore: BlobStore, data: string): Prom
253
264
  }
254
265
  return buffer.toString("base64");
255
266
  }
267
+
268
+ /** Synchronous variant of {@link resolveImageData}. */
269
+ export function resolveImageDataSync(blobStore: BlobStore, data: string): string {
270
+ const hash = parseBlobRef(data);
271
+ if (!hash) return data;
272
+
273
+ const buffer = blobStore.getSync(hash);
274
+ if (!buffer) {
275
+ logger.warn("Blob not found for image reference", { hash });
276
+ return data;
277
+ }
278
+ return buffer.toString("base64");
279
+ }
@@ -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
  }
@@ -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";
@@ -28,7 +29,9 @@ 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";
30
31
  import { resolveResumableSession } from "../session/session-listing";
32
+ import { SessionManager } from "../session/session-manager";
31
33
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
34
+ import { expandTilde, resolveToCwd } from "../tools/path-utils";
32
35
  import { urlHyperlinkAlways } from "../tui";
33
36
  import { getChangelogPath, parseChangelog } from "../utils/changelog";
34
37
  import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
@@ -59,7 +62,7 @@ export type { BuiltinSlashCommand, SubcommandDef } from "./types";
59
62
  export type BuiltinSlashCommandRuntime = TuiSlashCommandRuntime;
60
63
 
61
64
  export interface TuiBuiltinSlashCommand extends BuiltinSlashCommand {
62
- getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
65
+ getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null | Promise<AutocompleteItem[] | null>;
63
66
  getInlineHint?: (argumentText: string) => string | null;
64
67
  getAutocompleteDescription?: () => string | undefined;
65
68
  }
@@ -1590,44 +1593,56 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1590
1593
  },
1591
1594
  {
1592
1595
  name: "move",
1593
- description: "Move session to a different working directory",
1594
- acpDescription: "Move the current session file",
1595
- 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>]",
1596
1599
  allowArgs: true,
1597
1600
  handle: async (command, runtime) => {
1598
1601
  if (runtime.session.isStreaming) return usage("Cannot move while streaming.", runtime);
1599
1602
  if (!command.args) return usage("Usage: /move <path>", runtime);
1600
- const resolvedPath = path.resolve(runtime.cwd, command.args);
1601
- let isDirectory: boolean;
1603
+ const resolvedPath = resolveToCwd(command.args, runtime.cwd);
1602
1604
  try {
1603
- 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
+ }
1604
1609
  } catch {
1605
- return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime);
1610
+ return usage(`Directory does not exist: ${resolvedPath}`, runtime);
1606
1611
  }
1607
- if (!isDirectory) return usage(`Directory does not exist or is not a directory: ${resolvedPath}`, runtime);
1612
+ let newSessionFile: string | undefined;
1608
1613
  try {
1609
- await runtime.sessionManager.flush();
1610
- 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
+ }
1611
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
+ }
1612
1631
  return usage(`Move failed: ${errorMessage(err)}`, runtime);
1613
1632
  }
1633
+ runtime.session.markMovedFromEmptySessionFile(newSessionFile!);
1614
1634
  setProjectDir(resolvedPath);
1615
1635
  // Reload plugin/capability caches so the next prompt sees commands and
1616
1636
  // capabilities scoped to the new cwd.
1617
1637
  await runtime.reloadPlugins();
1618
1638
  await runtime.notifyTitleChanged?.();
1619
- await runtime.output(`Session moved to ${runtime.sessionManager.getCwd()}.`);
1639
+ await runtime.output(`Moved to ${runtime.sessionManager.getCwd()}.`);
1620
1640
  return commandConsumed();
1621
1641
  },
1622
1642
  handleTui: async (command, runtime) => {
1623
- const targetPath = command.args;
1624
- if (!targetPath) {
1625
- runtime.ctx.showError("Usage: /move <path>");
1626
- runtime.ctx.editor.setText("");
1627
- return;
1628
- }
1643
+ runtime.ctx.editor.addToHistory(command.text);
1629
1644
  runtime.ctx.editor.setText("");
1630
- await runtime.ctx.handleMoveCommand(targetPath);
1645
+ await runtime.ctx.handleMoveCommand(command.args || undefined);
1631
1646
  },
1632
1647
  },
1633
1648
  {
@@ -2294,6 +2309,106 @@ function buildStaticInlineHint(hint: string): (argumentText: string) => string |
2294
2309
  return (argumentText: string) => (argumentText.trim().length === 0 ? hint : null);
2295
2310
  }
2296
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
+
2297
2412
  /** Builtin command metadata used for slash-command autocomplete and help text. */
2298
2413
  export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray<BuiltinSlashCommand> = BUILTIN_SLASH_COMMAND_REGISTRY.map(
2299
2414
  command => ({
@@ -2314,6 +2429,9 @@ function materializeTuiBuiltinSlashCommand(
2314
2429
  if (cmd.subcommands) {
2315
2430
  materialized.getArgumentCompletions = buildArgumentCompletions(cmd.subcommands);
2316
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);
2317
2435
  } else if (cmd.inlineHint) {
2318
2436
  materialized.getInlineHint = buildStaticInlineHint(cmd.inlineHint);
2319
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