@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.
- package/CHANGELOG.md +82 -0
- package/dist/cli.js +3785 -3695
- 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 +13 -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/acp/acp-event-mapper.d.ts +1 -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 +8 -1
- package/dist/types/session/blob-store.d.ts +4 -0
- 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 +61 -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 +27 -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/claude-plugins.ts +3 -2
- 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-agent.ts +14 -1
- package/src/modes/acp/acp-event-mapper.ts +91 -27
- 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/context-files.md +8 -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 +65 -25
- package/src/session/agent-session.ts +519 -132
- package/src/session/blob-store.ts +24 -0
- 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
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Harness documentation index for the `omp://` protocol.
|
|
3
3
|
*
|
|
4
4
|
* Compiled binaries and the prepacked npm bundle inline a compressed index from
|
|
5
|
-
* `docs-index.generated.txt` (populated by `
|
|
6
|
-
* --generate` at build time). The format is two lines:
|
|
5
|
+
* `docs-index.generated.txt` (populated by `gen:docs` at build time). The format is two lines:
|
|
7
6
|
* 1. a plain JSON array of the sorted doc file names, and
|
|
8
7
|
* 2. a base64 gzip blob of the index-aligned doc bodies (`string[]`).
|
|
9
8
|
* Listing/completion (`getDocFilenames`) parses only the small first line and
|
|
@@ -84,7 +83,7 @@ function getIndex(): DocsIndex {
|
|
|
84
83
|
if (decoded === null) {
|
|
85
84
|
throw new Error(
|
|
86
85
|
"Malformed embedded docs index (docs-index.generated.txt): non-empty payload without a newline separator. " +
|
|
87
|
-
"Rebuild with `bun
|
|
86
|
+
"Rebuild with `bun run gen:docs`.",
|
|
88
87
|
);
|
|
89
88
|
}
|
|
90
89
|
index = decoded;
|
|
@@ -4,6 +4,19 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { AgentRegistry } from "../registry/agent-registry";
|
|
6
6
|
|
|
7
|
+
const extraArtifactsDirs = new Set<string>();
|
|
8
|
+
|
|
9
|
+
export function registerArtifactsDir(dir: string): () => void {
|
|
10
|
+
extraArtifactsDirs.add(dir);
|
|
11
|
+
return () => {
|
|
12
|
+
extraArtifactsDirs.delete(dir);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resetRegisteredArtifactDirsForTests(): void {
|
|
17
|
+
extraArtifactsDirs.clear();
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
/**
|
|
8
21
|
* Snapshot of artifacts dirs for every registered session, deduped.
|
|
9
22
|
*
|
|
@@ -15,11 +28,13 @@ import { AgentRegistry } from "../registry/agent-registry";
|
|
|
15
28
|
*/
|
|
16
29
|
export function artifactsDirsFromRegistry(): string[] {
|
|
17
30
|
const dirs: string[] = [];
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null);
|
|
21
|
-
if (!dir) continue;
|
|
31
|
+
const addDir = (dir: string | null | undefined) => {
|
|
32
|
+
if (!dir) return;
|
|
22
33
|
if (!dirs.includes(dir)) dirs.push(dir);
|
|
34
|
+
};
|
|
35
|
+
for (const ref of AgentRegistry.global().list()) {
|
|
36
|
+
addDir(ref.session?.sessionManager.getArtifactsDir() ?? (ref.sessionFile ? ref.sessionFile.slice(0, -6) : null));
|
|
23
37
|
}
|
|
38
|
+
for (const dir of extraArtifactsDirs) addDir(dir);
|
|
24
39
|
return dirs;
|
|
25
40
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Internal URL router for internal protocols (`agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`).
|
|
2
|
+
* Internal URL router for internal protocols (`agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, `ssh://`, and `vault://`).
|
|
3
3
|
*
|
|
4
4
|
* One process-global router with one handler per scheme. Access via
|
|
5
5
|
* `InternalUrlRouter.instance()`. Handlers are stateless; per-session and
|
|
@@ -16,6 +16,7 @@ import { OmpProtocolHandler } from "./omp-protocol";
|
|
|
16
16
|
import { parseInternalUrl } from "./parse";
|
|
17
17
|
import { RuleProtocolHandler } from "./rule-protocol";
|
|
18
18
|
import { SkillProtocolHandler } from "./skill-protocol";
|
|
19
|
+
import { SshProtocolHandler } from "./ssh-protocol";
|
|
19
20
|
import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
|
|
20
21
|
import { VaultProtocolHandler } from "./vault-protocol";
|
|
21
22
|
|
|
@@ -37,6 +38,7 @@ export class InternalUrlRouter {
|
|
|
37
38
|
this.register(new IssueProtocolHandler());
|
|
38
39
|
this.register(new PrProtocolHandler());
|
|
39
40
|
this.register(new HistoryProtocolHandler());
|
|
41
|
+
this.register(new SshProtocolHandler());
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
/** Process-global router instance. */
|
|
@@ -81,10 +83,10 @@ export class InternalUrlRouter {
|
|
|
81
83
|
* Candidate completions for the host/path portion of `scheme://<query>`.
|
|
82
84
|
* Returns `null` when the scheme is unknown or does not support completion.
|
|
83
85
|
*/
|
|
84
|
-
async complete(scheme: string, query: string): Promise<UrlCompletion[] | null> {
|
|
86
|
+
async complete(scheme: string, query: string, context?: ResolveContext): Promise<UrlCompletion[] | null> {
|
|
85
87
|
const handler = this.#handlers.get(scheme.toLowerCase());
|
|
86
88
|
if (!handler?.complete) return null;
|
|
87
|
-
return handler.complete(query);
|
|
89
|
+
return handler.complete(query, context);
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
async resolve(input: string, context?: ResolveContext): Promise<InternalResource> {
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol handler for `ssh://host/path` URLs.
|
|
3
|
+
*
|
|
4
|
+
* Resolves a remote text file or directory listing on a pre-configured SSH host — or any
|
|
5
|
+
* destination OpenSSH can resolve itself (e.g. a `~/.ssh/config` alias) — for
|
|
6
|
+
* the read, search, and write tools, reusing the shared ControlMaster
|
|
7
|
+
* connection in `../ssh/connection-manager`.
|
|
8
|
+
*
|
|
9
|
+
* A remote path resolves to a UTF-8 text file (≤ 1 MiB) or, when it is a
|
|
10
|
+
* directory, a one-level listing. Binary/non-UTF-8 or oversized files are
|
|
11
|
+
* rejected with an explicit error. This handler exposes no `sourcePath`;
|
|
12
|
+
* directory listings carry `isDirectory` so `search` refuses to grep the
|
|
13
|
+
* listing text instead of the directory's real contents.
|
|
14
|
+
*
|
|
15
|
+
* `loadCapability` is imported from `../capability` (not the `../discovery`
|
|
16
|
+
* barrel) on purpose — pulling the barrel here would route
|
|
17
|
+
* `path-utils -> internal-urls -> ssh-protocol -> discovery -> path-utils` and
|
|
18
|
+
* eager-load every provider on any `path-utils` import. Runtime bootstraps the
|
|
19
|
+
* SSH provider via `import "./discovery"` (sdk.ts) / `initializeWithSettings`
|
|
20
|
+
* (main.ts) before any tool resolves.
|
|
21
|
+
*/
|
|
22
|
+
import * as capability from "../capability";
|
|
23
|
+
import { type SSHHost, sshCapability } from "../capability/ssh";
|
|
24
|
+
import type { SSHConnectionTarget } from "../ssh/connection-manager";
|
|
25
|
+
import {
|
|
26
|
+
listRemoteDir,
|
|
27
|
+
type RemoteDirEntry,
|
|
28
|
+
type RemotePathKind,
|
|
29
|
+
readRemoteFile,
|
|
30
|
+
statRemotePath,
|
|
31
|
+
writeRemoteFile,
|
|
32
|
+
} from "../ssh/file-transfer";
|
|
33
|
+
import type {
|
|
34
|
+
InternalResource,
|
|
35
|
+
InternalUrl,
|
|
36
|
+
ProtocolHandler,
|
|
37
|
+
ResolveContext,
|
|
38
|
+
UrlCompletion,
|
|
39
|
+
WriteContext,
|
|
40
|
+
} from "./types";
|
|
41
|
+
|
|
42
|
+
/** Largest remote text file `ssh://` will materialize (mirrors the local:// cap). */
|
|
43
|
+
const SSH_TEXT_MAX_BYTES = 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
/** POSIX-aware content type from the last path segment's extension. */
|
|
46
|
+
function contentTypeFor(remotePath: string): InternalResource["contentType"] {
|
|
47
|
+
const slash = remotePath.lastIndexOf("/");
|
|
48
|
+
const base = slash === -1 ? remotePath : remotePath.slice(slash + 1);
|
|
49
|
+
const dot = base.lastIndexOf(".");
|
|
50
|
+
const ext = dot <= 0 ? "" : base.slice(dot).toLowerCase();
|
|
51
|
+
if (ext === ".md") return "text/markdown";
|
|
52
|
+
if (ext === ".json") return "application/json";
|
|
53
|
+
return "text/plain";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Decode the whole buffer as UTF-8 text, or null if it holds a NUL or invalid byte. */
|
|
57
|
+
function decodeUtf8Text(bytes: Uint8Array): string | null {
|
|
58
|
+
if (bytes.indexOf(0) !== -1) return null;
|
|
59
|
+
try {
|
|
60
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Remote absolute path from the URL. Uses `rawPathname` (pre-normalization) so
|
|
68
|
+
* `..`/`//` and percent-escapes survive verbatim to the remote shell; the
|
|
69
|
+
* authority (host/user/port) stays on the WHATWG fields, which preserve case for
|
|
70
|
+
* the non-special `ssh` scheme.
|
|
71
|
+
*/
|
|
72
|
+
function remotePathFromUrl(url: InternalUrl): string {
|
|
73
|
+
// `?`/`#` are URL delimiters, so parseInternalUrl strips them from the path
|
|
74
|
+
// (`ssh://h/tmp/a?draft` → `/tmp/a`). Reject the unsupported suffix instead of
|
|
75
|
+
// silently operating on the truncated path; a literal `?`/`#` in a filename
|
|
76
|
+
// must be percent-encoded (`%3F`/`%23`).
|
|
77
|
+
if (url.search) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`ssh:// does not support URL query strings; percent-encode a literal '?' as %3F in the path: ${url.href}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (url.hash) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`ssh:// does not support URL fragments; percent-encode a literal '#' as %23 in the path: ${url.href}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const raw = url.rawPathname ?? url.pathname;
|
|
88
|
+
let decoded: string;
|
|
89
|
+
try {
|
|
90
|
+
decoded = decodeURIComponent(raw);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error(`Invalid URL encoding in ssh:// path: ${url.href}`);
|
|
93
|
+
}
|
|
94
|
+
if (!decoded) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
"ssh:// requires an absolute path, e.g. ssh://host/etc/hosts or ssh://host/ for the root directory",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return decoded;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Load the configured SSH hosts from the `ssh` capability (managed/project `ssh.json`). */
|
|
103
|
+
async function loadConfiguredHosts(cwd?: string): Promise<SSHHost[]> {
|
|
104
|
+
const { items } = await capability.loadCapability<SSHHost>(sshCapability.id, cwd ? { cwd } : {});
|
|
105
|
+
return items;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One-line address for a host, e.g. `deploy@10.0.0.1:2222`. */
|
|
109
|
+
function hostAddress(host: SSHHost): string {
|
|
110
|
+
return `${host.username ? `${host.username}@` : ""}${host.host}${host.port ? `:${host.port}` : ""}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Render the configured-host index for a bare `ssh://` read (markdown with per-host links). */
|
|
114
|
+
function formatHostIndex(hosts: readonly SSHHost[]): string {
|
|
115
|
+
if (hosts.length === 0) {
|
|
116
|
+
return "# SSH hosts\n\nNo SSH hosts are configured. Add hosts to an `ssh.json` capability file, or read `ssh://<host>/<path>` with any destination OpenSSH can resolve (e.g. a `~/.ssh/config` alias).\n";
|
|
117
|
+
}
|
|
118
|
+
const lines = hosts.map(host => {
|
|
119
|
+
const addr = hostAddress(host);
|
|
120
|
+
const suffix = addr === host.name ? "" : ` — \`${addr}\``;
|
|
121
|
+
const desc = host.description ? ` (${host.description})` : "";
|
|
122
|
+
return `- [${host.name}](ssh://${encodeURIComponent(host.name)}/)${suffix}${desc}`;
|
|
123
|
+
});
|
|
124
|
+
return `# SSH hosts\n\n${hosts.length} configured host${hosts.length === 1 ? "" : "s"}:\n\n${lines.join("\n")}\n`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve the URL authority to an SSH connection target. With no explicit
|
|
129
|
+
* user/port, the full DECODED authority (`url.rawHost`) is matched against a
|
|
130
|
+
* configured host name, so percent-encoded reserved-char aliases (e.g.
|
|
131
|
+
* `alice%40prod` → `alice@prod`) resolve correctly. A literal `user@`/`:port`
|
|
132
|
+
* in the URL is an override: it is rejected on a configured bare name (the
|
|
133
|
+
* ControlMaster/host-info caches key on `name` alone) and otherwise treated as
|
|
134
|
+
* an opaque OpenSSH destination so plain `~/.ssh/config` aliases work.
|
|
135
|
+
*/
|
|
136
|
+
async function resolveTarget(url: InternalUrl, cwd?: string): Promise<SSHConnectionTarget> {
|
|
137
|
+
// `parseInternalUrl` falls back to a lenient regex parse when WHATWG `new URL`
|
|
138
|
+
// rejects the input. For ssh:// that only happens on a malformed authority — an
|
|
139
|
+
// invalid or out-of-range port (`prod:abc`, `host:65536`) or a bad IPv6 literal —
|
|
140
|
+
// which would otherwise be mis-read as an opaque host and silently connect to the
|
|
141
|
+
// default port. Reject it before resolving.
|
|
142
|
+
if (!URL.canParse(url.href)) {
|
|
143
|
+
throw new Error(`ssh://: invalid host or port in "${url.href}"; use ssh://host[:1-65535]/<absolute-path>`);
|
|
144
|
+
}
|
|
145
|
+
// WHATWG `hostname` is bracketed only for a *valid* IPv6 literal, so a bracketed
|
|
146
|
+
// host is unambiguously IPv6 — hand OpenSSH the bare address. Percent-encoded
|
|
147
|
+
// bracketed aliases (e.g. `%5Bprod%3A2222%5D`) keep their literal brackets in the
|
|
148
|
+
// decoded `rawHost`, so they are matched and forwarded verbatim, never stripped.
|
|
149
|
+
const bareHost = url.hostname;
|
|
150
|
+
const rawAuthority = url.rawHost || bareHost;
|
|
151
|
+
if (!bareHost && !rawAuthority) {
|
|
152
|
+
throw new Error("ssh:// requires a host: ssh://<host>/<absolute-path>");
|
|
153
|
+
}
|
|
154
|
+
// `decodeOr` fails open, so a malformed percent-escape (`%ZZ`) in the authority
|
|
155
|
+
// would otherwise pass the canonical check below and reach OpenSSH literally.
|
|
156
|
+
// Reject it up front — the path decoder fails closed for the same bad escapes.
|
|
157
|
+
for (const part of [url.username, bareHost]) {
|
|
158
|
+
if (part.includes("%")) {
|
|
159
|
+
try {
|
|
160
|
+
decodeURIComponent(part);
|
|
161
|
+
} catch {
|
|
162
|
+
throw new Error(`ssh://: invalid percent-escape in authority "${url.href}"`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (url.password) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
"ssh://: password authentication is not supported; ssh:// uses key/agent auth — drop the ':<password>' from the URL",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const isIpv6Literal = bareHost.startsWith("[") && bareHost.endsWith("]");
|
|
172
|
+
const sshHost = isIpv6Literal ? bareHost.slice(1, -1) : bareHost;
|
|
173
|
+
const username = url.username || undefined;
|
|
174
|
+
const port = url.port ? Number(url.port) : undefined;
|
|
175
|
+
if (port === 0) {
|
|
176
|
+
throw new Error("ssh://: port 0 is not a valid SSH port; use ssh://host:<1-65535>/<path> or omit the port");
|
|
177
|
+
}
|
|
178
|
+
// An empty port (`ssh://prod:/path`, `ssh://user@host:/path`, including
|
|
179
|
+
// percent-encoded authority parts) parses cleanly with `url.port === ""`, so it
|
|
180
|
+
// slips past the malformed-authority guard and would be read as "no port" —
|
|
181
|
+
// silently using the default/configured target. `url.rawHost` is the decoded
|
|
182
|
+
// authority and uniquely retains the trailing `:`; comparing it to the decoded
|
|
183
|
+
// host (+ user) catches the empty port, while a percent-encoded alias like
|
|
184
|
+
// `prod%3A` (whose decoded host already ends in `:`) reconstructs to `prod::`
|
|
185
|
+
// and is left alone.
|
|
186
|
+
const decodeOr = (s: string): string => {
|
|
187
|
+
try {
|
|
188
|
+
return decodeURIComponent(s);
|
|
189
|
+
} catch {
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
if (port === undefined && url.rawHost === `${username ? `${decodeOr(username)}@` : ""}${decodeOr(bareHost)}:`) {
|
|
194
|
+
throw new Error(`ssh://: empty port in "${url.href}"; use ssh://host:<1-65535>/<path> or drop the colon`);
|
|
195
|
+
}
|
|
196
|
+
// A literal but empty userinfo (`ssh://@host`) sets username to "" — WHATWG drops
|
|
197
|
+
// the `@` from hostname, but rawHost keeps the leading `@`. A percent-encoded
|
|
198
|
+
// alias like `%40prod` decodes to `@prod` in rawHost too, but its hostname keeps
|
|
199
|
+
// `%40`, so the reconstruction is `@@prod` and is left alone.
|
|
200
|
+
if (username === undefined && url.rawHost === `@${decodeOr(bareHost)}${port !== undefined ? `:${port}` : ""}`) {
|
|
201
|
+
throw new Error(`ssh://: empty username in "${url.href}"; drop the leading '@' or provide a username before it`);
|
|
202
|
+
}
|
|
203
|
+
// Backstop for any remaining stray/empty authority marker the explicit checks
|
|
204
|
+
// above do not name — notably an empty password (`ssh://user:@host`, `ssh://:@host`,
|
|
205
|
+
// where `url.password === ""`). `rawHost` keeps the literal marker, so it differs
|
|
206
|
+
// from the canonical decoded `[user@]host[:port]` WHATWG actually parsed. Every
|
|
207
|
+
// valid authority — including percent-encoded reserved-char aliases — reconstructs
|
|
208
|
+
// to exactly `rawHost`, so only malformed userinfo trips this.
|
|
209
|
+
const canonicalAuthority = `${url.username ? `${decodeOr(url.username)}@` : ""}${decodeOr(bareHost)}${port !== undefined ? `:${port}` : ""}`;
|
|
210
|
+
if (url.rawHost !== canonicalAuthority) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`ssh://: unsupported or malformed authority in "${url.href}"; use ssh://[user@]host[:1-65535]/<absolute-path>`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const items = await loadConfiguredHosts(cwd);
|
|
216
|
+
|
|
217
|
+
// A literal user/port in the URL is an authority override. A configured alias
|
|
218
|
+
// is addressed only by its (percent-encoded) name, never with a separate
|
|
219
|
+
// user/port — so reject an override on a configured bare name, else opaque.
|
|
220
|
+
if (username || port !== undefined) {
|
|
221
|
+
const decodedBareHost = decodeOr(bareHost);
|
|
222
|
+
if (items.some(entry => entry.name === bareHost || entry.name === decodedBareHost)) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`ssh://: user/port overrides are not allowed for the configured host "${decodedBareHost}"; use ssh://${bareHost}/<path> or an unconfigured hostname`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
const sshUser = username ? decodeOr(username) : undefined;
|
|
228
|
+
const sshTargetHost = decodeOr(sshHost);
|
|
229
|
+
const name = `${sshUser ? `${sshUser}@` : ""}${sshTargetHost}${port !== undefined ? `:${port}` : ""}`;
|
|
230
|
+
return { name, host: sshTargetHost, username: sshUser, port };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// No explicit user/port: match the full decoded authority against a
|
|
234
|
+
// configured name (so an encoded reserved-char alias resolves correctly).
|
|
235
|
+
const match = items.find(entry => entry.name === rawAuthority) ?? items.find(entry => entry.name === bareHost);
|
|
236
|
+
if (match) {
|
|
237
|
+
return {
|
|
238
|
+
name: match.name,
|
|
239
|
+
host: match.host,
|
|
240
|
+
username: match.username,
|
|
241
|
+
port: match.port,
|
|
242
|
+
keyPath: match.keyPath,
|
|
243
|
+
compat: match.compat,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
// Opaque OpenSSH destination (plain ~/.ssh/config alias, or any resolvable host).
|
|
247
|
+
return { name: rawAuthority, host: isIpv6Literal ? sshHost : rawAuthority };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Format a one-level remote directory listing — mirrors buildDirectoryResource's plain `name/` lines. */
|
|
251
|
+
function formatDirListing(entries: readonly RemoteDirEntry[]): string {
|
|
252
|
+
if (entries.length === 0) return "(empty directory)";
|
|
253
|
+
return entries.map(entry => `${entry.name}${entry.isDirectory ? "/" : ""}`).join("\n");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export class SshProtocolHandler implements ProtocolHandler {
|
|
257
|
+
readonly scheme = "ssh";
|
|
258
|
+
readonly immutable = false;
|
|
259
|
+
|
|
260
|
+
async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
|
|
261
|
+
// Bare `ssh://` (or `ssh:///`) with no host lists the configured hosts. A
|
|
262
|
+
// host-less URL that still carries a path (`ssh:///etc/hosts`) is malformed —
|
|
263
|
+
// reject it instead of silently dropping the path and listing hosts.
|
|
264
|
+
if (!(url.rawHost || url.hostname)) {
|
|
265
|
+
const rawPath = url.rawPathname ?? url.pathname;
|
|
266
|
+
if (rawPath && rawPath !== "/") {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`ssh:// requires a host before the path: ssh://<host>${rawPath} (host-less ssh://${rawPath} is not valid)`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return this.#resolveHostIndex(url, context?.cwd);
|
|
272
|
+
}
|
|
273
|
+
const target = await resolveTarget(url, context?.cwd);
|
|
274
|
+
const remotePath = remotePathFromUrl(url);
|
|
275
|
+
// Classify before reading. A FIFO with no writer would block `head` until the
|
|
276
|
+
// timeout, and a device (e.g. /dev/zero) would stream the whole probe, so a
|
|
277
|
+
// special file must fail fast. Only a regular file is read; a directory lists.
|
|
278
|
+
// `missing`/stat-failure falls through to the read so its original remote stderr
|
|
279
|
+
// (e.g. "No such file or directory") still surfaces.
|
|
280
|
+
let kind: RemotePathKind | undefined;
|
|
281
|
+
try {
|
|
282
|
+
kind = await statRemotePath(target, remotePath, { signal: context?.signal });
|
|
283
|
+
} catch {
|
|
284
|
+
// stat failed (host/connection issue) — fall through; the read gives a clearer error.
|
|
285
|
+
}
|
|
286
|
+
if (kind === "directory") {
|
|
287
|
+
return this.#resolveDirectory(target, remotePath, url, context?.signal, context?.skipDirectoryListing);
|
|
288
|
+
}
|
|
289
|
+
if (kind === "other") {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`ssh://: ${remotePath} is not a regular file (FIFO, socket, or device); ssh:// reads UTF-8 text files only — use the ssh tool for special files`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const fileResult = await readRemoteFile(target, remotePath, {
|
|
295
|
+
maxBytes: SSH_TEXT_MAX_BYTES,
|
|
296
|
+
signal: context?.signal,
|
|
297
|
+
});
|
|
298
|
+
if (fileResult.truncated) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`ssh://: ${remotePath} exceeds the 1 MiB limit; ssh:// supports text files up to 1 MiB — use an sshfs mount for larger files`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const content = decodeUtf8Text(fileResult.bytes);
|
|
304
|
+
if (content === null) {
|
|
305
|
+
throw new Error(
|
|
306
|
+
`ssh://: ${remotePath} is a binary or non-UTF-8 file; ssh:// supports UTF-8 text only — use the ssh tool or an sshfs mount`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
// No `sourcePath`: keeps search on the virtual-resource path so the
|
|
310
|
+
// displayed/searched resource stays `ssh://…` instead of a temp path.
|
|
311
|
+
return {
|
|
312
|
+
url: url.href,
|
|
313
|
+
content,
|
|
314
|
+
contentType: contentTypeFor(remotePath),
|
|
315
|
+
size: fileResult.bytes.length,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Resolve a remote directory to a one-level listing (no `sourcePath`; `isDirectory` so search refuses it; immutable). */
|
|
320
|
+
async #resolveDirectory(
|
|
321
|
+
target: SSHConnectionTarget,
|
|
322
|
+
remotePath: string,
|
|
323
|
+
url: InternalUrl,
|
|
324
|
+
signal?: AbortSignal,
|
|
325
|
+
skipListing?: boolean,
|
|
326
|
+
): Promise<InternalResource> {
|
|
327
|
+
// `search`/`find` reject an ssh:// directory outright, so they pass `skipListing`
|
|
328
|
+
// to avoid draining a full remote `ls` we would only discard.
|
|
329
|
+
const content = skipListing ? "" : formatDirListing(await listRemoteDir(target, remotePath, { signal }));
|
|
330
|
+
return {
|
|
331
|
+
url: url.href,
|
|
332
|
+
content,
|
|
333
|
+
contentType: "text/plain",
|
|
334
|
+
size: Buffer.byteLength(content, "utf-8"),
|
|
335
|
+
immutable: true,
|
|
336
|
+
isDirectory: true,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Resolve a bare `ssh://` to a listing of configured hosts (immutable; plain virtual text, so `search` can still grep host names). */
|
|
341
|
+
async #resolveHostIndex(url: InternalUrl, cwd?: string): Promise<InternalResource> {
|
|
342
|
+
const content = formatHostIndex(await loadConfiguredHosts(cwd));
|
|
343
|
+
return {
|
|
344
|
+
url: url.href,
|
|
345
|
+
content,
|
|
346
|
+
contentType: "text/markdown",
|
|
347
|
+
size: Buffer.byteLength(content, "utf-8"),
|
|
348
|
+
immutable: true,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Autocomplete the host segment of `ssh://` with the configured SSH hosts. */
|
|
353
|
+
async complete(_query?: string, context?: ResolveContext): Promise<UrlCompletion[]> {
|
|
354
|
+
const hosts = await loadConfiguredHosts(context?.cwd);
|
|
355
|
+
return hosts.map(host => ({
|
|
356
|
+
value: encodeURIComponent(host.name),
|
|
357
|
+
label: host.name,
|
|
358
|
+
description: host.description ?? hostAddress(host),
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async write(url: InternalUrl, content: string, context?: WriteContext): Promise<void> {
|
|
363
|
+
const target = await resolveTarget(url, context?.cwd);
|
|
364
|
+
const remotePath = remotePathFromUrl(url);
|
|
365
|
+
await writeRemoteFile(target, remotePath, new TextEncoder().encode(content), { signal: context?.signal });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Types for the internal URL routing system.
|
|
3
3
|
*
|
|
4
|
-
* Internal URLs (`agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, and `vault://`) are resolved by tools like read,
|
|
4
|
+
* Internal URLs (`agent://`, `artifact://`, `history://`, `issue://`, `local://`, `mcp://`, `memory://`, `omp://`, `pr://`, `rule://`, `skill://`, `ssh://`, and `vault://`) are resolved by tools like read,
|
|
5
5
|
* providing access to agent outputs and server resources without exposing filesystem paths.
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -33,6 +33,13 @@ export interface InternalResource {
|
|
|
33
33
|
* resources. Mutable resources (e.g. local://) behave like editable files.
|
|
34
34
|
*/
|
|
35
35
|
immutable?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* True when the resource is a directory listing rather than file content.
|
|
38
|
+
* `search` refuses to grep such a resource when it has no `sourcePath` — a
|
|
39
|
+
* remote `ssh://` listing has no local path to recurse, so its listing text
|
|
40
|
+
* must never be mistaken for the directory's contents.
|
|
41
|
+
*/
|
|
42
|
+
isDirectory?: boolean;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
/**
|
|
@@ -93,6 +100,13 @@ export interface ResolveContext {
|
|
|
93
100
|
localProtocolOptions?: LocalProtocolOptions;
|
|
94
101
|
/** Calling session's loaded skills. Prefer this over process-global skill state. */
|
|
95
102
|
skills?: readonly Skill[];
|
|
103
|
+
/**
|
|
104
|
+
* When set, handlers that would otherwise materialize an expensive directory
|
|
105
|
+
* listing (e.g. the ssh:// handler draining a full remote `ls`) instead return
|
|
106
|
+
* the directory shape (`isDirectory: true`) with empty content. `search`/`find`
|
|
107
|
+
* reject directory resources, so they never need the listing.
|
|
108
|
+
*/
|
|
109
|
+
skipDirectoryListing?: boolean;
|
|
96
110
|
}
|
|
97
111
|
|
|
98
112
|
/**
|
|
@@ -150,6 +164,9 @@ export interface ProtocolHandler {
|
|
|
150
164
|
* mcp://) omit it. The caller fuzzy-filters the returned set against the
|
|
151
165
|
* partially typed `query`, so handlers return their full (bounded) candidate
|
|
152
166
|
* list; `query` is provided only so handlers can scope expensive enumeration.
|
|
167
|
+
* `context.cwd`/`context.localProtocolOptions` carry the caller's working dir
|
|
168
|
+
* and session, for handlers whose candidates are project- or session-scoped
|
|
169
|
+
* (e.g. ssh:// hosts from a project `ssh.json`, local:// roots per session).
|
|
153
170
|
*/
|
|
154
|
-
complete?(query
|
|
171
|
+
complete?(query?: string, context?: ResolveContext): Promise<UrlCompletion[]>;
|
|
155
172
|
}
|
package/src/irc/bus.ts
CHANGED
|
@@ -91,8 +91,16 @@ export class IrcBus {
|
|
|
91
91
|
* disabled — e.g. blocked in a synchronous task spawn awaiting the
|
|
92
92
|
* sender's own batch) can generate an ephemeral side-channel auto-reply
|
|
93
93
|
* instead of stranding the sender until timeout.
|
|
94
|
+
*
|
|
95
|
+
* `opts.suppressRelay` skips the display-only main-UI relay for this leg.
|
|
96
|
+
* Set by broadcast fan-out when the same broadcast also targets the main
|
|
97
|
+
* agent directly: the main agent then already sees the body as its own
|
|
98
|
+
* incoming card, so relaying the sibling legs would duplicate it.
|
|
94
99
|
*/
|
|
95
|
-
async send(
|
|
100
|
+
async send(
|
|
101
|
+
msg: Omit<IrcMessage, "id" | "ts">,
|
|
102
|
+
opts?: { expectsReply?: boolean; suppressRelay?: boolean },
|
|
103
|
+
): Promise<IrcDeliveryReceipt> {
|
|
96
104
|
const message: IrcMessage = { ...msg, id: Snowflake.next(), ts: Date.now() };
|
|
97
105
|
const ref = this.#registry.get(message.to);
|
|
98
106
|
if (!ref || ref.status === "aborted") {
|
|
@@ -127,7 +135,7 @@ export class IrcBus {
|
|
|
127
135
|
const waiter = this.#takeMatchingWaiter(message.to, message.from);
|
|
128
136
|
if (waiter) {
|
|
129
137
|
waiter.resolve(message);
|
|
130
|
-
this.#relayToMainUi(message);
|
|
138
|
+
if (!opts?.suppressRelay) this.#relayToMainUi(message);
|
|
131
139
|
return { to: message.to, outcome: revived ? "revived" : "injected" };
|
|
132
140
|
}
|
|
133
141
|
|
|
@@ -138,7 +146,7 @@ export class IrcBus {
|
|
|
138
146
|
|
|
139
147
|
try {
|
|
140
148
|
const delivery = await session.deliverIrcMessage(message, opts);
|
|
141
|
-
this.#relayToMainUi(message);
|
|
149
|
+
if (!opts?.suppressRelay) this.#relayToMainUi(message);
|
|
142
150
|
return { to: message.to, outcome: revived ? "revived" : delivery };
|
|
143
151
|
} catch (error) {
|
|
144
152
|
// Live hand-off failed (e.g. recipient disposed mid-shutdown): buffer
|
package/src/mcp/tool-bridge.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
|
7
7
|
import type { TSchema } from "@oh-my-pi/pi-ai";
|
|
8
8
|
import { normalizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
|
|
9
9
|
import { untilAborted } from "@oh-my-pi/pi-utils";
|
|
10
|
+
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
10
11
|
import type { SourceMeta } from "../capability/types";
|
|
11
12
|
import type {
|
|
12
13
|
CustomTool,
|
|
@@ -83,6 +84,35 @@ function omitUnusedOptionalArgs(args: MCPToolArgs, inputSchema: MCPToolDefinitio
|
|
|
83
84
|
return cleaned ?? args;
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Drop the harness-internal intent field (`INTENT_FIELD`) before forwarding
|
|
89
|
+
* args to an MCP server. The harness injects `i` into every tool's wire
|
|
90
|
+
* schema; the direct model tool-call path strips it via `extractIntent`, but
|
|
91
|
+
* the `eval` `tool.*` bridge and any other in-process caller forwards args
|
|
92
|
+
* verbatim. Strict-schema servers (Linear, anything with
|
|
93
|
+
* `additionalProperties:false` / Zod `.strict()`) reject every call that
|
|
94
|
+
* carries `i`. The MCP boundary is the authoritative guard so callers don't
|
|
95
|
+
* have to pre-strip.
|
|
96
|
+
*
|
|
97
|
+
* Leaves `i` in place when the server's own `inputSchema.properties` declares
|
|
98
|
+
* it, so a server that legitimately uses `i` as a parameter is unaffected.
|
|
99
|
+
*/
|
|
100
|
+
function stripHarnessIntent(args: MCPToolArgs, inputSchema: MCPToolDefinition["inputSchema"]): MCPToolArgs {
|
|
101
|
+
if (!Object.hasOwn(args, INTENT_FIELD)) return args;
|
|
102
|
+
if (inputSchema.properties && Object.hasOwn(inputSchema.properties, INTENT_FIELD)) return args;
|
|
103
|
+
const { [INTENT_FIELD]: _intent, ...rest } = args;
|
|
104
|
+
return rest;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Normalize raw tool params into the outbound `tools/call` arguments: strip
|
|
109
|
+
* the harness intent field, then drop optional empty placeholders the server
|
|
110
|
+
* declares but doesn't require.
|
|
111
|
+
*/
|
|
112
|
+
function prepareOutboundArgs(params: unknown, inputSchema: MCPToolDefinition["inputSchema"]): MCPToolArgs {
|
|
113
|
+
return omitUnusedOptionalArgs(stripHarnessIntent(normalizeToolArgs(params), inputSchema), inputSchema);
|
|
114
|
+
}
|
|
115
|
+
|
|
86
116
|
/** Details included in MCP tool results for rendering */
|
|
87
117
|
export interface MCPToolDetails {
|
|
88
118
|
/** Server name */
|
|
@@ -286,7 +316,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
|
|
|
286
316
|
signal?: AbortSignal,
|
|
287
317
|
): Promise<CustomToolResult<MCPToolDetails>> {
|
|
288
318
|
throwIfAborted(signal);
|
|
289
|
-
const args =
|
|
319
|
+
const args = prepareOutboundArgs(params, this.tool.inputSchema);
|
|
290
320
|
const provider = this.connection._source?.provider;
|
|
291
321
|
const providerName = this.connection._source?.providerName;
|
|
292
322
|
|
|
@@ -385,7 +415,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
|
|
|
385
415
|
signal?: AbortSignal,
|
|
386
416
|
): Promise<CustomToolResult<MCPToolDetails>> {
|
|
387
417
|
throwIfAborted(signal);
|
|
388
|
-
const args =
|
|
418
|
+
const args = prepareOutboundArgs(params, this.tool.inputSchema);
|
|
389
419
|
const provider = this.#fallbackProvider;
|
|
390
420
|
const providerName = this.#fallbackProviderName;
|
|
391
421
|
|
package/src/memories/index.ts
CHANGED
|
@@ -559,7 +559,7 @@ function shouldPersistResponseItemForMemories(message: AgentMessage): boolean {
|
|
|
559
559
|
}
|
|
560
560
|
if (role !== "toolResult") return false;
|
|
561
561
|
const toolName = (message as { toolName?: string }).toolName;
|
|
562
|
-
if (toolName === "bash" || toolName === "eval" || toolName === "read" || toolName === "
|
|
562
|
+
if (toolName === "bash" || toolName === "eval" || toolName === "read" || toolName === "grep") {
|
|
563
563
|
const text = extractMessageText(message);
|
|
564
564
|
return text.length > 0 && text.length <= 32_000;
|
|
565
565
|
}
|