@oh-my-pi/pi-coding-agent 16.1.23 → 16.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -0
- package/dist/cli.js +3817 -3735
- package/dist/types/advisor/__tests__/emission-guard.test.d.ts +1 -0
- package/dist/types/advisor/emission-guard.d.ts +73 -0
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/runtime.d.ts +7 -0
- package/dist/types/advisor/watchdog.d.ts +2 -0
- package/dist/types/cli/flag-tables.d.ts +1 -0
- package/dist/types/cli/gallery-fixtures/search.d.ts +1 -1
- package/dist/types/collab/display-name.d.ts +3 -0
- package/dist/types/collab/host.d.ts +0 -2
- package/dist/types/commands/worktree.d.ts +0 -3
- package/dist/types/config/inline-tool-descriptors-mode.d.ts +13 -0
- package/dist/types/config/model-discovery.d.ts +6 -1
- package/dist/types/config/model-registry.d.ts +6 -2
- package/dist/types/config/model-resolver.d.ts +12 -0
- package/dist/types/config/models-config-schema.d.ts +29 -2
- package/dist/types/config/models-config.d.ts +22 -1
- package/dist/types/config/settings-schema.d.ts +91 -21
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/dap/config.d.ts +1 -1
- package/dist/types/edit/hashline/filesystem.d.ts +4 -2
- package/dist/types/edit/renderer.d.ts +4 -0
- package/dist/types/extensibility/extensions/types.d.ts +17 -17
- package/dist/types/extensibility/hooks/types.d.ts +10 -10
- package/dist/types/extensibility/plugins/marketplace/types.d.ts +1 -0
- package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +1 -0
- package/dist/types/internal-urls/index.d.ts +1 -0
- package/dist/types/internal-urls/registry-helpers.d.ts +2 -0
- package/dist/types/internal-urls/router.d.ts +1 -1
- package/dist/types/internal-urls/ssh-protocol.d.ts +10 -0
- package/dist/types/internal-urls/types.d.ts +19 -2
- package/dist/types/irc/bus.d.ts +6 -0
- package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +7 -0
- package/dist/types/modes/components/move-overlay.d.ts +23 -0
- package/dist/types/modes/components/plugin-selector.d.ts +2 -1
- package/dist/types/modes/components/queue-mode-selector.d.ts +2 -1
- package/dist/types/modes/components/select-list-mouse-routing.d.ts +2 -0
- package/dist/types/modes/components/settings-defs.d.ts +4 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/show-images-selector.d.ts +2 -1
- package/dist/types/modes/components/status-line/component.d.ts +2 -0
- package/dist/types/modes/components/status-line/types.d.ts +2 -0
- package/dist/types/modes/components/theme-selector.d.ts +2 -1
- package/dist/types/modes/components/thinking-selector.d.ts +2 -1
- package/dist/types/modes/controllers/command-controller.d.ts +10 -1
- package/dist/types/modes/controllers/streaming-reveal.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +10 -1
- package/dist/types/modes/internal-url-autocomplete.d.ts +1 -1
- package/dist/types/modes/running-subagent-badge.d.ts +6 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +9 -1
- package/dist/types/sdk.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +2 -1
- package/dist/types/session/session-manager.d.ts +13 -0
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
- package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +1 -0
- package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +1 -0
- package/dist/types/ssh/connection-manager.d.ts +2 -0
- package/dist/types/ssh/file-transfer.d.ts +79 -0
- package/dist/types/ssh/utils.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +3 -0
- package/dist/types/task/executor.d.ts +16 -0
- package/dist/types/tiny/text.d.ts +1 -1
- package/dist/types/tools/builtin-names.d.ts +5 -1
- package/dist/types/tools/{find.d.ts → glob.d.ts} +15 -15
- package/dist/types/tools/{search.d.ts → grep.d.ts} +14 -14
- package/dist/types/tools/index.d.ts +3 -3
- package/dist/types/tools/path-utils.d.ts +29 -0
- package/dist/types/tools/read.d.ts +2 -2
- package/dist/types/tools/render-utils.d.ts +8 -0
- package/dist/types/tools/renderers.d.ts +11 -0
- package/dist/types/tools/ssh.d.ts +2 -0
- package/dist/types/tools/todo.d.ts +0 -16
- package/dist/types/tools/write.d.ts +2 -2
- package/dist/types/utils/active-repo-context.d.ts +8 -0
- package/dist/types/utils/image-resize.d.ts +1 -0
- package/dist/types/utils/markit-cache.d.ts +23 -0
- package/dist/types/utils/markit.d.ts +5 -1
- package/dist/types/utils/prompt-path.d.ts +1 -0
- package/dist/types/web/search/providers/duckduckgo.d.ts +14 -0
- package/dist/types/web/search/providers/firecrawl.d.ts +28 -0
- package/dist/types/web/search/providers/tinyfish.d.ts +29 -0
- package/dist/types/web/search/providers/xai.d.ts +13 -0
- package/dist/types/web/search/types.d.ts +18 -2
- package/package.json +30 -15
- package/scripts/build-binary.ts +9 -9
- package/scripts/bundle-dist.ts +2 -2
- package/src/advisor/__tests__/advisor.test.ts +40 -4
- package/src/advisor/__tests__/emission-guard.test.ts +147 -0
- package/src/advisor/advise-tool.ts +1 -1
- package/src/advisor/emission-guard.ts +172 -0
- package/src/advisor/index.ts +1 -0
- package/src/advisor/runtime.ts +11 -0
- package/src/advisor/watchdog.ts +12 -1
- package/src/cli/args.ts +5 -2
- package/src/cli/auth-broker-cli.ts +17 -0
- package/src/cli/config-cli.ts +4 -0
- package/src/cli/flag-tables.ts +7 -4
- package/src/cli/gallery-cli.ts +14 -2
- package/src/cli/gallery-fixtures/edit.ts +60 -0
- package/src/cli/gallery-fixtures/fs.ts +17 -17
- package/src/cli/gallery-fixtures/search.ts +4 -4
- package/src/cli/usage-cli.ts +20 -1
- package/src/cli/web-search-cli.ts +1 -1
- package/src/collab/display-name.ts +13 -0
- package/src/collab/guest.ts +5 -1
- package/src/collab/host.ts +2 -13
- package/src/commands/worktree.ts +6 -0
- package/src/config/inline-tool-descriptors-mode.ts +27 -0
- package/src/config/model-discovery.ts +77 -8
- package/src/config/model-registry.ts +89 -11
- package/src/config/model-resolver.ts +39 -1
- package/src/config/models-config-schema.ts +41 -5
- package/src/config/models-config.ts +4 -1
- package/src/config/settings-schema.ts +85 -27
- package/src/config/settings.ts +172 -1
- package/src/cursor.ts +1 -1
- package/src/dap/config.ts +152 -8
- package/src/dap/session.ts +1 -1
- package/src/discovery/helpers.ts +3 -1
- package/src/edit/hashline/diff.ts +14 -3
- package/src/edit/hashline/execute.ts +42 -6
- package/src/edit/hashline/filesystem.ts +38 -2
- package/src/edit/index.ts +1 -0
- package/src/edit/modes/patch.ts +11 -1
- package/src/edit/renderer.ts +140 -13
- package/src/eval/__tests__/agent-bridge.test.ts +101 -0
- package/src/eval/agent-bridge.ts +26 -3
- package/src/eval/js/tool-bridge.ts +2 -2
- package/src/export/html/index.ts +1 -1
- package/src/export/html/template.js +3 -1
- package/src/export/html/tool-views.generated.js +20 -20
- package/src/extensibility/extensions/types.ts +22 -22
- package/src/extensibility/hooks/types.ts +11 -11
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +8 -3
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +25 -10
- package/src/extensibility/plugins/marketplace/manager.ts +22 -0
- package/src/extensibility/plugins/marketplace/types.ts +1 -0
- package/src/internal-urls/__tests__/ssh-protocol.test.ts +331 -0
- package/src/internal-urls/docs-index.generated.txt +2 -2
- package/src/internal-urls/docs-index.ts +2 -3
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/registry-helpers.ts +19 -4
- package/src/internal-urls/router.ts +5 -3
- package/src/internal-urls/ssh-protocol.ts +367 -0
- package/src/internal-urls/types.ts +19 -2
- package/src/irc/bus.ts +11 -3
- package/src/mcp/tool-bridge.ts +32 -2
- package/src/memories/index.ts +1 -1
- package/src/modes/acp/acp-event-mapper.ts +2 -2
- package/src/modes/components/__tests__/move-overlay.test.ts +166 -0
- package/src/modes/components/agent-hub.ts +3 -0
- package/src/modes/components/agent-transcript-viewer.ts +9 -7
- package/src/modes/components/custom-editor.ts +90 -5
- package/src/modes/components/move-overlay.ts +282 -0
- package/src/modes/components/plan-review-overlay.ts +35 -35
- package/src/modes/components/plugin-selector.ts +6 -1
- package/src/modes/components/queue-mode-selector.ts +6 -1
- package/src/modes/components/select-list-mouse-routing.ts +35 -0
- package/src/modes/components/session-selector.ts +11 -10
- package/src/modes/components/settings-defs.ts +14 -1
- package/src/modes/components/settings-selector.ts +196 -29
- package/src/modes/components/show-images-selector.ts +6 -1
- package/src/modes/components/status-line/component.ts +108 -28
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/theme-selector.ts +6 -1
- package/src/modes/components/thinking-selector.ts +6 -1
- package/src/modes/components/tool-execution.ts +25 -9
- package/src/modes/components/tree-selector.ts +5 -5
- package/src/modes/controllers/command-controller.ts +140 -47
- package/src/modes/controllers/event-controller.ts +59 -3
- package/src/modes/controllers/input-controller.ts +68 -2
- package/src/modes/controllers/selector-controller.ts +5 -2
- package/src/modes/controllers/streaming-reveal.ts +17 -0
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/modes/interactive-mode.ts +116 -50
- package/src/modes/internal-url-autocomplete.ts +17 -2
- package/src/modes/prompt-action-autocomplete.ts +3 -1
- package/src/modes/running-subagent-badge.ts +13 -0
- package/src/modes/setup-wizard/scenes/glyph.ts +2 -13
- package/src/modes/setup-wizard/scenes/providers.ts +2 -1
- package/src/modes/setup-wizard/scenes/theme.ts +6 -12
- package/src/modes/setup-wizard/scenes/web-search.ts +8 -13
- package/src/modes/setup-wizard/wizard-overlay.ts +6 -5
- package/src/modes/theme/theme.ts +9 -1
- package/src/modes/types.ts +9 -1
- package/src/modes/utils/interactive-context-helpers.ts +1 -1
- package/src/priority.json +15 -0
- package/src/prompts/advisor/active-repo-watchdog.md +6 -0
- package/src/prompts/advisor/system.md +21 -7
- package/src/prompts/agents/designer.md +1 -1
- package/src/prompts/agents/explore.md +1 -1
- package/src/prompts/agents/librarian.md +2 -2
- package/src/prompts/agents/plan.md +2 -2
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/agents/task.md +2 -2
- package/src/prompts/system/active-repo-context.md +4 -0
- package/src/prompts/system/gemini-tool-call-reminder.md +9 -0
- package/src/prompts/system/plan-mode-active.md +3 -3
- package/src/prompts/system/project-prompt.md +2 -2
- package/src/prompts/system/system-prompt.md +6 -6
- package/src/prompts/tools/bash.md +2 -2
- package/src/prompts/tools/checkpoint.md +1 -1
- package/src/prompts/tools/{find.md → glob.md} +1 -1
- package/src/prompts/tools/{search.md → grep.md} +3 -3
- package/src/prompts/tools/read.md +4 -2
- package/src/sdk.ts +55 -25
- package/src/session/agent-session.ts +508 -132
- package/src/session/session-history-format.ts +2 -2
- package/src/session/session-manager.ts +46 -0
- package/src/slash-commands/builtin-registry.ts +138 -20
- package/src/slash-commands/helpers/usage-report.ts +23 -2
- package/src/ssh/__tests__/connection-manager-args.test.ts +69 -0
- package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +68 -0
- package/src/ssh/connection-manager.ts +12 -15
- package/src/ssh/file-transfer.ts +209 -0
- package/src/ssh/utils.ts +24 -0
- package/src/system-prompt.ts +56 -28
- package/src/task/executor.ts +96 -36
- package/src/task/index.ts +3 -3
- package/src/task/render.ts +14 -13
- package/src/task/worktree.ts +38 -1
- package/src/tiny/text.ts +49 -4
- package/src/tiny/worker.ts +3 -3
- package/src/tools/bash.ts +2 -2
- package/src/tools/builtin-names.ts +26 -2
- package/src/tools/{find.ts → glob.ts} +48 -42
- package/src/tools/{search.ts → grep.ts} +311 -129
- package/src/tools/index.ts +13 -14
- package/src/tools/irc.ts +6 -2
- package/src/tools/path-utils.ts +64 -1
- package/src/tools/read.ts +35 -6
- package/src/tools/render-utils.ts +14 -0
- package/src/tools/renderers.ts +15 -4
- package/src/tools/ssh.ts +17 -2
- package/src/tools/todo.ts +4 -26
- package/src/tools/write.ts +25 -9
- package/src/utils/active-repo-context.ts +143 -0
- package/src/utils/image-resize.ts +88 -7
- package/src/utils/lang-from-path.ts +3 -3
- package/src/utils/markit-cache.ts +166 -0
- package/src/utils/markit.ts +86 -18
- package/src/utils/prompt-path.ts +3 -0
- package/src/utils/title-generator.ts +1 -1
- package/src/web/search/provider.ts +54 -34
- package/src/web/search/providers/duckduckgo.ts +140 -0
- package/src/web/search/providers/firecrawl.ts +144 -0
- package/src/web/search/providers/tinyfish.ts +159 -0
- package/src/web/search/providers/xai.ts +292 -0
- package/src/web/search/providers/zai.ts +142 -56
- 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 === "
|
|
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 === "
|
|
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: "
|
|
1594
|
-
acpDescription: "
|
|
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 =
|
|
1601
|
-
let isDirectory: boolean;
|
|
1603
|
+
const resolvedPath = resolveToCwd(command.args, runtime.cwd);
|
|
1602
1604
|
try {
|
|
1603
|
-
|
|
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
|
|
1610
|
+
return usage(`Directory does not exist: ${resolvedPath}`, runtime);
|
|
1606
1611
|
}
|
|
1607
|
-
|
|
1612
|
+
let newSessionFile: string | undefined;
|
|
1608
1613
|
try {
|
|
1609
|
-
|
|
1610
|
-
await runtime.
|
|
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(`
|
|
1639
|
+
await runtime.output(`Moved to ${runtime.sessionManager.getCwd()}.`);
|
|
1620
1640
|
return commandConsumed();
|
|
1621
1641
|
},
|
|
1622
1642
|
handleTui: async (command, runtime) => {
|
|
1623
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|