dsh-pi-bridge 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Twofruitsgrape
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM.
package/README.md CHANGED
@@ -1,3 +1,172 @@
1
- # Temporary Holding Version
1
+ # dsh-pi-bridge
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **One plugin, two hosts — and each host can install the other’s plugins.**
4
+
5
+ Install it into [pi](https://pi.dev) (`pi install npm:dsh-pi-bridge`) or into
6
+ [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (`dsh plugin add dsh-pi-bridge`),
7
+ and you get the same behaviour either way, plus a bridge that loads packages from *the other
8
+ ecosystem* as native tools, slash commands and skills.
9
+
10
+ 一份代码,两个宿主:装到 pi 或装到 DSH —— 装完之后,两边的插件可以互相安装。
11
+ DSH 里用 `pi_install` 装 pi 的插件,pi 里用 `/dsh-install` 装 DSH 的插件,转成本地宿主的
12
+ 工具 / 斜杠命令 / 技能。
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ # pi coding agent
18
+ pi install npm:dsh-pi-bridge # or: pi install git:github.com/Twofruitsgrape/dsh-pi-bridge
19
+
20
+ # DeepSeek Harness
21
+ dsh plugin add dsh-pi-bridge # or install from the plugin panel: github:Twofruitsgrape/dsh-pi-bridge
22
+ ```
23
+
24
+ Restart the host afterwards. Both keywords (`pi-package`, `dsh-plugin`) are declared, so the
25
+ package is discoverable in each ecosystem’s own market.
26
+
27
+ ## What you get in each host
28
+
29
+ | | pi | DSH |
30
+ |---|---|---|
31
+ | Install from the other side | `/dsh-install <source>`, tools `dsh_install` / `dsh_preview` | tools `pi_install` / `pi_preview` / `dsh_install` |
32
+ | Manage bridges | `/bridge-list`, `/bridge-remove` | tools `bridge_list` / `bridge_remove` |
33
+ | Skill | `skills/dsh-pi-bridge/SKILL.md` | same `SKILL.md`, mounted through the skill registry |
34
+
35
+ Sources use one grammar on both sides: `npm:<pkg>[@ver]`, `git:github.com/<owner>/<repo>[@ref]`,
36
+ `github:<owner>/<repo>`, a repository URL, or a local path — exactly what each host already accepts.
37
+
38
+ ```text
39
+ dsh> pi_preview({ source: "npm:pi-subagents" })
40
+ package: pi-subagents@0.78.0
41
+ detected: {"pi":["keyword:pi-package","manifest:pi","layout:extensions|skills"]}
42
+ [tool] task Run a supervised subagent task
43
+ [tool] taskstop Stop a running subagent
44
+ [command] /tasks Show running subagent tasks
45
+ [unsupported] TUI renderer (host-specific)
46
+
47
+ dsh> pi_install({ source: "npm:pi-subagents" })
48
+ bridged npm:pi-subagents as pi-subagents@0.78.0
49
+ tools: pisubagents_task, pisubagents_taskstop
50
+ commands: /pisubagents_tasks
51
+ skipped: event has no DSH equivalent: cache_warming_decision
52
+ ```
53
+
54
+ ## How it works
55
+
56
+ ```text
57
+ resolve npm registry tarball / shallow git clone / local copy -> ~/.dsh-pi-bridge cache
58
+ mirror a private build tree, so a bridged package never imports a real host
59
+ stub every foreign host import becomes an inert stand-in, or a real bridge shim
60
+ compile TypeScript -> ESM with node:module stripTypeScriptTypes (no toolchain, no download)
61
+ capture load the package against a recording host API and collect what it registers
62
+ replay install those registrations onto the local host, converting schema both ways
63
+ ```
64
+
65
+ A DSH tool declaration carries a property spec map (`{ text: { type: ‘string’, required: true } }`)
66
+ that `@deepseek-ai/dsh-tools` compiles to JSON Schema; a pi tool carries the JSON Schema directly
67
+ (TypeBox). `src/schema.js` converts both directions, so a bridged tool’s parameters validate in the
68
+ host that receives it. Result shapes are projected the same way: pi’s
69
+ `{ content: [{ type: ‘text’ }] }` to DSH’s `{ ok, result }` and back.
70
+
71
+ Skills need no conversion at all — both hosts implement the
72
+ [Agent Skills specification](https://agentskills.io/specification), so a `SKILL.md` directory simply
73
+ travels. DSH providers are read asynchronously (`list()` is a promise), and the bridge awaits them
74
+ before replay, which is how a bundle like `dsh-comsol` crosses into pi intact.
75
+
76
+ ## Verified against real published packages
77
+
78
+ Measured with `npm run test:all` (offline suites plus the network cases), on the
79
+ packages each ecosystem actually ships:
80
+
81
+ | Bridged package | Direction | Arrived | Reported as not crossing |
82
+ |---|---|---|---|
83
+ | `pi-web-access@0.40.0` | pi → DSH | 4 tools, 4 commands, 6 event hooks | `registerShortcut`, 4 unmapped events |
84
+ | `pi-subagents@0.78.0` | pi → DSH | 3 tools, 19 commands, 4 skill dirs | 33 items listed in the report |
85
+ | `@plannotator/pi-extension@0.28.20` | pi → DSH | 216 TS files compiled, 1 skill dir | renderer registrations |
86
+ | `dsh-comsol@1.1.1` (git) | DSH → pi / DSH | its `comsol-dsh` skill | — |
87
+ | `dsh-cost-meter@1.8.26` | DSH → pi | nothing | correctly reports zero registrations |
88
+
89
+ The last row is the honest case: a DSH plugin that is purely a UI/service
90
+ overlay has nothing to bridge, and the preview says so instead of pretending.
91
+
92
+ Bridged tools are executed in the tests, not just listed — `web_search` and
93
+ `source_check` respond with their own argument-validation errors, which proves
94
+ the real package logic runs under the receiving host's tool shape.
95
+
96
+ ## What does not cross
97
+
98
+ The bridge reports these instead of failing quietly:
99
+
100
+ - **Model providers and adapters** (`pi.registerProvider`, `ctx.llm.registerAdapter`): wired to host
101
+ internals, not portable.
102
+ - **Host UI**: DSH browser pages / `ctx.slots` and pi’s TUI renderers, `ctx.ui`, shortcuts, CLI flags.
103
+ - **Events without a counterpart**: a small name map covers the lifecycle overlap
104
+ (`session_start` ↔ `agent/created`, `tool_call` ↔ `tools/pre-execute`, …); anything else is listed.
105
+
106
+ Registrations land in the current session immediately, and each host replays the recorded bridges when
107
+ the plugin loads, so an install survives a restart. That replay is cache-only: host startup never waits
108
+ on a network download, and a pruned cache simply reports the bridge as unavailable until it is installed
109
+ again. `restoreOnLoad: false` in the bundle config turns replay off.
110
+
111
+ The bridge keeps its own registry at `~/.dsh-pi-bridge/bridges.json` and writes nothing into either
112
+ host’s settings. `bridge_remove` forgets a bridge and deletes its mirror; tools already registered in
113
+ the running process stay until restart, because silently unregistering host state would be worse than
114
+ leaving it visible.
115
+
116
+ ## Security
117
+
118
+ A bridged package **executes code with your permissions**, on whichever side you install it — the
119
+ same trust requirement every extension host has, doubled because the package came from the other
120
+ ecosystem and was not vetted by its market. Read its source before you install it.
121
+
122
+ The bridge limits the blast radius, it does not remove it: packages load from a private mirror whose
123
+ `node_modules` contains only generated stubs, so a bridged extension cannot import the real host API
124
+ or reach either host’s configuration through the import map. `preview` first — it resolves and
125
+ captures but registers nothing.
126
+
127
+ ## Develop
128
+
129
+ ```bash
130
+ npm test # 34 offline checks: both directions, mock hosts, no network
131
+ npm run test:entries # loads both real entries and asserts the host-facing surface
132
+ npm run test:all # the above plus the real-package cases (needs network)
133
+ npm run e2e # just the published-package probes
134
+ ```
135
+
136
+ The self-test builds fixtures by hand — a TypeScript pi extension using the TypeBox builder, and a
137
+ DSH bundle using `defineTool` with an async skill provider — then runs the full pipeline against a
138
+ recording mock host and executes the bridged tools. `scripts/selfhost-check.mjs` feeds this package's
139
+ own DSH entry through the bridge, so the entry conventions are tested by the same machinery a foreign
140
+ bundle meets.
141
+
142
+ Nothing is written outside `~/.dsh-pi-bridge` (override with `DSH_PI_BRIDGE_HOME`). A bridged package
143
+ sees only its mirrored build tree, so its relative imports work while its host imports resolve to the
144
+ bridge's stubs.
145
+
146
+ ## Layout
147
+
148
+ ```text
149
+ index.js DSH entry: name / inject / apply(ctx), the pi_install tool family
150
+ extensions/index.js pi entry: default(pi) factory, /dsh-install and the dsh_install tools
151
+ skills/dsh-pi-bridge/ SKILL.md, shared by both hosts
152
+ cordis.patch.yml DSH bundle mount (the plugin panel toggle target)
153
+ src/manifest.js host detection from keywords, manifests and directory layout
154
+ src/resolve.js npm / git / url / local sources into a cache
155
+ src/tar.js gzipped-tar reader (npm tarballs), no dependency
156
+ src/transpile.js TypeScript -> ESM via node:module, swc/typescript fallback
157
+ src/stubs.js import scanning + inert stub generation
158
+ src/typebox-shim.js TypeBox- and zod-shaped builders over plain JSON Schema
159
+ src/capture.js recording stand-in hosts for both entry conventions
160
+ src/schema.js parameter schema conversion, both directions
161
+ src/replay-dsh.js capabilities -> ctx.tools / ctx.commands / ctx.skills
162
+ src/replay-pi.js capabilities -> pi.registerTool / registerCommand / resources_discover
163
+ src/capabilities.js normalized capability model, event name map, result projection
164
+ ```
165
+
166
+ `index.js` imports only `node:` builtins plus this package, and declares `inject: [‘tools’]` with
167
+ everything else acquired through `ctx.inject`, so a profile that composes without the skill registry
168
+ loses skill mounting rather than failing to load.
169
+
170
+ ## License
171
+
172
+ MIT
@@ -0,0 +1,5 @@
1
+ # dsh-pi-bridge bundle patch: mount the host half under its own name.
2
+ - insert:
3
+ - id: pi-bridge
4
+ name: 'dsh-pi-bridge'
5
+ config: {}
@@ -0,0 +1,221 @@
1
+ /**
2
+ * dsh-pi-bridge — pi entry: the extension half of the plugin.
3
+ *
4
+ * pi loads this file through the package's pi.extensions manifest and calls the
5
+ * default factory with an ExtensionAPI. The factory registers the cross-host
6
+ * installer as pi slash commands plus two model-callable tools, so a pi user or
7
+ * agent can install a DSH plugin bundle into the running pi session.
8
+ *
9
+ * A DSH bundle is loaded against a recording stand-in host (src/capture.js) and
10
+ * its registrations are replayed onto pi's real API (src/replay-pi.js).
11
+ * DSH-only capabilities are reported rather than dropped in silence.
12
+ *
13
+ * @module extensions/index.js
14
+ */
15
+ import { loadForeign } from "../src/load.js";
16
+ import { replayIntoPi } from "../src/replay-pi.js";
17
+ import { CACHE_ROOT } from "../src/resolve.js";
18
+ import { restoreIntoPi } from "../src/restore.js";
19
+
20
+ const REGISTRY = CACHE_ROOT + "/bridges.json";
21
+
22
+ /** Remember bridges in our own cache, never in pi's settings. */
23
+ async function readState() {
24
+ try {
25
+ const { readFile } = await import("node:fs/promises");
26
+ return JSON.parse(await readFile(REGISTRY, "utf8"));
27
+ } catch {
28
+ return { bridges: {} };
29
+ }
30
+ }
31
+
32
+ async function writeState(state) {
33
+ const { mkdir, writeFile } = await import("node:fs/promises");
34
+ await mkdir(CACHE_ROOT, { recursive: true });
35
+ await writeFile(REGISTRY, JSON.stringify(state, null, 2), "utf8");
36
+ }
37
+
38
+ function shortPrefix(name) {
39
+ const s = String(name)
40
+ .replace(/^@[^/]+\//, "")
41
+ .replace(/^dsh-/, "")
42
+ .replace(/^pi-/, "")
43
+ .replace(/[^A-Za-z0-9]+/g, "_")
44
+ .toLowerCase()
45
+ .replace(/^_+|_+$/g, "")
46
+ .slice(0, 24);
47
+ return s || "bridged";
48
+ }
49
+
50
+ function formatReport(spec, captured, report) {
51
+ const lines = ["bridged " + spec + " as " + captured.info.name + "@" + captured.info.version];
52
+ lines.push(" tools: " + (report.tools.length ? report.tools.join(", ") : "none"));
53
+ lines.push(" commands: " + (report.commands.length ? report.commands.map((c) => "/" + c).join(", ") : "none"));
54
+ lines.push(" skills: " + (report.skills.length ? report.skills.join(", ") : "none"));
55
+ if (report.events.length) lines.push(" events: " + report.events.join(", "));
56
+ if (report.skipped.length) lines.push(" skipped: " + report.skipped.join("; "));
57
+ if (captured.errors && captured.errors.length) lines.push(" load errors: " + captured.errors.join("; "));
58
+ lines.push(" bridged tools are callable in this session immediately.");
59
+ return lines.join("\n");
60
+ }
61
+
62
+ async function previewText(captured) {
63
+ const lines = [
64
+ "package: " + captured.info.name + "@" + captured.info.version,
65
+ "detected hosts: " + JSON.stringify(captured.info.signals),
66
+ ];
67
+ for (const cap of captured.capabilities || []) {
68
+ lines.push(" [" + cap.kind + "] " + cap.name + " - " + String(cap.description || "").slice(0, 100));
69
+ }
70
+ for (const dir of captured.skillDirs || []) lines.push(" [skill-dir] " + dir);
71
+ for (const e of captured.events || []) lines.push(" [event] " + e.event);
72
+ for (const u of captured.unsupported || []) lines.push(" [unsupported] " + u);
73
+ for (const err of captured.errors || []) lines.push(" [error] " + err);
74
+ if (!(captured.capabilities || []).length && !(captured.skillDirs || []).length) {
75
+ lines.push(" (nothing registrable found - the bundle may need host services the bridge cannot stand in for)");
76
+ }
77
+ return lines.join("\n");
78
+ }
79
+
80
+ export default async function (pi) {
81
+ async function installDsh(spec, opts) {
82
+ const captured = await loadForeign(spec, { host: "dsh", refresh: !!(opts && opts.refresh) });
83
+ const prefix = (opts && opts.prefix) || shortPrefix(captured.info.name);
84
+ const report = replayIntoPi(pi, captured, { prefix });
85
+ const state = await readState();
86
+ state.bridges = state.bridges || {};
87
+ state.bridges[spec] = {
88
+ name: captured.info.name,
89
+ version: captured.info.version,
90
+ host: "dsh",
91
+ prefix,
92
+ mirror: captured.mirror,
93
+ report,
94
+ at: new Date().toISOString(),
95
+ };
96
+ await writeState(state);
97
+ return formatReport(spec, captured, report);
98
+ }
99
+
100
+ function firstArg(args) {
101
+ return String(args || "").trim().split(/\s+/)[0];
102
+ }
103
+
104
+ pi.registerCommand("dsh-install", {
105
+ description: "Install a DeepSeek Harness (DSH) plugin bundle into this pi session as tools, commands and skills",
106
+ handler: async (args, ctx) => {
107
+ const spec = firstArg(args);
108
+ if (!spec) return notify(ctx, "usage: /dsh-install <npm:pkg | github:owner/repo | url | local path>", "error");
109
+ notify(ctx, "bridging " + spec + " ...", "info");
110
+ try {
111
+ notify(ctx, await installDsh(spec), "info");
112
+ } catch (e) {
113
+ notify(ctx, "dsh-install failed: " + message(e), "error");
114
+ }
115
+ },
116
+ });
117
+
118
+ pi.registerCommand("dsh-preview", {
119
+ description: "Inspect what a DSH plugin bundle would contribute before installing it",
120
+ handler: async (args, ctx) => {
121
+ const spec = firstArg(args);
122
+ if (!spec) return notify(ctx, "usage: /dsh-preview <source>", "error");
123
+ try {
124
+ notify(ctx, await previewText(await loadForeign(spec, { host: "dsh" })), "info");
125
+ } catch (e) {
126
+ notify(ctx, "dsh-preview failed: " + message(e), "error");
127
+ }
128
+ },
129
+ });
130
+
131
+ pi.registerCommand("bridge-list", {
132
+ description: "List plugin bundles this bridge installed into pi",
133
+ handler: async (_args, ctx) => {
134
+ const state = await readState();
135
+ const keys = Object.keys(state.bridges || {});
136
+ if (!keys.length) return notify(ctx, "no bridged bundles yet", "info");
137
+ notify(ctx, keys.map((k) => {
138
+ const b = state.bridges[k];
139
+ return "- " + k + " (" + b.name + "@" + b.version + ") tools=" + (b.report.tools || []).join(",");
140
+ }).join("\n"), "info");
141
+ },
142
+ });
143
+
144
+ pi.registerCommand("bridge-remove", {
145
+ description: "Forget a bridged bundle and delete its mirror build",
146
+ handler: async (args, ctx) => {
147
+ const spec = firstArg(args);
148
+ const state = await readState();
149
+ if (!spec || !state.bridges || !state.bridges[spec]) {
150
+ return notify(ctx, "not bridged: " + (spec || "(no source given)"), "error");
151
+ }
152
+ const entry = state.bridges[spec];
153
+ delete state.bridges[spec];
154
+ await writeState(state);
155
+ const { rm } = await import("node:fs/promises");
156
+ if (entry.mirror) await rm(entry.mirror, { recursive: true, force: true });
157
+ notify(ctx, "removed " + spec + " (tools registered earlier stay until pi restarts)", "info");
158
+ },
159
+ });
160
+
161
+ // Model-callable mirrors, so an agent can install a DSH bundle unattended.
162
+ pi.registerTool({
163
+ name: "dsh_install",
164
+ label: "Install DSH plugin",
165
+ description: "Install a DeepSeek Harness plugin bundle into this pi session as native pi tools, slash commands and skills. Accepts the same source grammar pi uses: npm:<pkg>[@ver], git:github.com/<owner>/<repo>[@ref], github:<owner>/<repo>, a URL, or a local path.",
166
+ promptSnippet: "Install a DSH plugin bundle as pi tools and commands",
167
+ parameters: {
168
+ type: "object",
169
+ properties: {
170
+ source: { type: "string", description: "Bundle source string." },
171
+ prefix: { type: "string", description: "Optional lowercase prefix for bridged names." },
172
+ refresh: { type: "boolean", description: "Re-download even when already cached." },
173
+ },
174
+ required: ["source"],
175
+ },
176
+ async execute(_id, params) {
177
+ try {
178
+ return { content: [{ type: "text", text: await installDsh(params.source, params) }], details: { bridge: "dsh-install" } };
179
+ } catch (e) {
180
+ return { content: [{ type: "text", text: "dsh_install failed: " + message(e) }], details: { error: true } };
181
+ }
182
+ },
183
+ });
184
+
185
+ // Replay recorded DSH bundles so /dsh-install survives a pi restart.
186
+ const state = await readState();
187
+ const restore = await restoreIntoPi(pi, state, { log: (m) => console.log(m) });
188
+ if (restore.failed.length) console.log("dsh-pi-bridge restore skipped: " + restore.failed.join("; "));
189
+
190
+ pi.registerTool({
191
+ name: "dsh_preview",
192
+ label: "Preview DSH plugin",
193
+ description: "Inspect what a DeepSeek Harness plugin bundle would contribute to this pi session before installing it.",
194
+ promptSnippet: "Preview a DSH bundle's tools, commands and skills",
195
+ parameters: {
196
+ type: "object",
197
+ properties: { source: { type: "string", description: "Bundle source string." } },
198
+ required: ["source"],
199
+ },
200
+ async execute(_id, params) {
201
+ try {
202
+ return { content: [{ type: "text", text: await previewText(await loadForeign(params.source, { host: "dsh" })) }], details: { bridge: "dsh-preview" } };
203
+ } catch (e) {
204
+ return { content: [{ type: "text", text: "dsh_preview failed: " + message(e) }], details: { error: true } };
205
+ }
206
+ },
207
+ });
208
+ }
209
+
210
+ function notify(ctx, text, level) {
211
+ try {
212
+ if (ctx && ctx.ui && typeof ctx.ui.notify === "function") return ctx.ui.notify(text, level || "info");
213
+ } catch {
214
+ /* fall through to stdout */
215
+ }
216
+ console.log(text);
217
+ }
218
+
219
+ function message(e) {
220
+ return e && e.message ? String(e.message).slice(0, 400) : String(e);
221
+ }
package/index.js ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * dsh-pi-bridge — DSH (DeepSeek Harness) entry: the host half of the plugin.
3
+ *
4
+ * Cordis plugin contract: name, inject, apply(ctx, config). The plugin exposes
5
+ * the cross-host installer as DSH tools, so an agent — or the user through the
6
+ * tool — can install pi packages into the running DSH, preview what a package
7
+ * would provide before installing, and forget bridges again. Registry state
8
+ * lives under ~/.dsh-pi-bridge; nothing here writes into DSH's own profile, so
9
+ * uninstalling the bridge leaves the profile as it was.
10
+ *
11
+ * Services are acquired with ctx.inject rather than a static inject list for
12
+ * everything except tools: a profile that composes without the skills registry
13
+ * loses skill mounting, not the whole plugin.
14
+ *
15
+ * @module index.js
16
+ */
17
+ import { existsSync } from 'node:fs'
18
+ import { fileURLToPath } from 'node:url'
19
+ import path from 'node:path'
20
+ import { readFile, writeFile, mkdir, rm } from 'node:fs/promises'
21
+ import { loadForeign } from './src/load.js'
22
+ import { replayIntoDsh, skillProviderFor } from './src/replay-dsh.js'
23
+ import { CACHE_ROOT } from './src/resolve.js'
24
+ import { restoreIntoDsh } from './src/restore.js'
25
+
26
+ export const name = 'dsh-pi-bridge'
27
+ /** tools is the only hard requirement; the rest degrades through ctx.inject. */
28
+ export const inject = ['tools']
29
+
30
+ const REGISTRY_FILE = path.join(CACHE_ROOT, 'bridges.json')
31
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
32
+
33
+ const OUT = () => ({
34
+ schema: {
35
+ type: 'object',
36
+ additionalProperties: false,
37
+ properties: {
38
+ ok: { type: 'boolean', required: true },
39
+ result: { type: 'string', required: true },
40
+ },
41
+ },
42
+ render: (_args, value) => [{ type: 'text', text: (value && value.result) || '' }],
43
+ })
44
+
45
+ /** Every host tool answers through one guard: never throw into the agent loop. */
46
+ function guarded (fn) {
47
+ return async (args) => {
48
+ try { return { ok: true, result: String(await fn(args || {})) } }
49
+ catch (e) { return { ok: false, result: 'dsh-pi-bridge: ' + message(e) } }
50
+ }
51
+ }
52
+
53
+ /** Host defineTool when the profile composes dsh-tools; a faithful fallback otherwise. */
54
+ async function toolCompiler () {
55
+ try {
56
+ const mod = await import('@deepseek-ai/dsh-tools')
57
+ if (typeof mod.defineTool === 'function') return mod.defineTool
58
+ } catch { /* host without the tools package (self-test) */ }
59
+ const { specToJsonSchema } = await import('./src/schema.js')
60
+ return (options) => ({
61
+ ...options,
62
+ parameters: specToJsonSchema(options.parameters),
63
+ execute: async (args, exec) => options.execute(args, exec),
64
+ })
65
+ }
66
+
67
+ async function readRegistry () {
68
+ try { return JSON.parse(await readFile(REGISTRY_FILE, 'utf8')) } catch { return { bridges: {} } }
69
+ }
70
+
71
+ async function writeRegistry (state) {
72
+ await mkdir(CACHE_ROOT, { recursive: true })
73
+ await writeFile(REGISTRY_FILE, JSON.stringify(state, null, 2), 'utf8')
74
+ }
75
+
76
+ function defaultPrefix (name) {
77
+ const s = String(name)
78
+ .replace(/^@[^/]+\//, '')
79
+ .replace(/^dsh-/, '')
80
+ .replace(/^pi-/, '')
81
+ .replace(/[^A-Za-z0-9]+/g, '_')
82
+ .toLowerCase()
83
+ .replace(/^_+|_+$/g, '')
84
+ .slice(0, 24)
85
+ return s || 'bridged'
86
+ }
87
+
88
+ function message (e) {
89
+ return e && e.message ? String(e.message).slice(0, 400) : String(e)
90
+ }
91
+
92
+ function formatReport (spec, captured, report) {
93
+ const lines = ['bridged ' + spec + ' as ' + captured.info.name + '@' + captured.info.version]
94
+ lines.push(' tools: ' + (report.tools.length ? report.tools.join(', ') : 'none'))
95
+ lines.push(' commands: ' + (report.commands.length ? report.commands.map(c => '/' + c).join(', ') : 'none'))
96
+ lines.push(' skills: ' + (report.skills.length ? report.skills.join(', ') : 'none'))
97
+ if (report.events.length) lines.push(' events: ' + report.events.join(', '))
98
+ if (report.skipped.length) lines.push(' skipped: ' + report.skipped.join('; '))
99
+ if (captured.errors && captured.errors.length) lines.push(' load errors: ' + captured.errors.join('; '))
100
+ lines.push(' Tools are callable now. On the next DSH start they are replayed from the bridge cache, so the install persists; other clients pick them up then.')
101
+ return lines.join('\n')
102
+ }
103
+
104
+ async function previewText (captured) {
105
+ const lines = ['package: ' + captured.info.name + '@' + captured.info.version, 'detected: ' + JSON.stringify(captured.info.signals)]
106
+ for (const cap of captured.capabilities || []) lines.push(' [' + cap.kind + '] ' + cap.name + ' - ' + String(cap.description || '').slice(0, 100))
107
+ for (const dir of captured.skillDirs || []) lines.push(' [skill-dir] ' + dir)
108
+ for (const e of captured.events || []) lines.push(' [event] ' + e.event)
109
+ for (const u of captured.unsupported || []) lines.push(' [unsupported] ' + u)
110
+ for (const err of captured.errors || []) lines.push(' [error] ' + err)
111
+ if (!(captured.capabilities || []).length && !(captured.skillDirs || []).length) lines.push(' (nothing registrable found - the bundle may need host services the bridge cannot stand in for)')
112
+ return lines.join('\n')
113
+ }
114
+
115
+ export async function apply (ctx, config) {
116
+ void config
117
+ const tool = await toolCompiler()
118
+ const state = await readRegistry()
119
+ state.bridges = state.bridges || {}
120
+ const log = (m) => { try { ctx.logger && ctx.logger.info && ctx.logger.info(m) } catch { /* ignore */ } }
121
+
122
+ /** Install one pi package as DSH capabilities and record it. */
123
+ async function installPi (spec, opts) {
124
+ const captured = await loadForeign(spec, { host: 'pi', refresh: !!(opts && opts.refresh), log })
125
+ const prefix = (opts && opts.prefix) || defaultPrefix(captured.info.name)
126
+ const report = await replayIntoDsh(ctx, captured, { prefix })
127
+ state.bridges[spec] = {
128
+ name: captured.info.name,
129
+ version: captured.info.version,
130
+ host: 'pi',
131
+ prefix,
132
+ mirror: captured.mirror,
133
+ report,
134
+ at: new Date().toISOString(),
135
+ }
136
+ await writeRegistry(state)
137
+ return formatReport(spec, captured, report)
138
+ }
139
+
140
+ const SOURCE = { type: 'string', required: true, description: 'Plugin source in pi grammar: npm:<pkg>[@ver], git:github.com/<owner>/<repo>[@ref], github:<owner>/<repo>, a URL, or a local path.' }
141
+ const PREFIX = { type: 'string', description: 'Optional lowercase prefix for bridged names; defaults to the package name.' }
142
+
143
+ ctx.tools.register(tool({
144
+ name: 'pi_install',
145
+ description: 'Install a pi coding agent package into this DSH session as native DSH tools, slash commands and skills. It resolves the source, compiles its TypeScript to ESM, loads it against a recording host, and replays what it registers onto DSH. Accepts the same source string pi would use. Re-running reloads the package.',
146
+ parameters: {
147
+ source: SOURCE,
148
+ prefix: PREFIX,
149
+ refresh: { type: 'boolean', description: 'Re-download the source even when it is already in the bridge cache.' },
150
+ },
151
+ output: OUT(),
152
+ execute: guarded(async (args) => installPi(args.source, args)),
153
+ }))
154
+
155
+ ctx.tools.register(tool({
156
+ name: 'pi_preview',
157
+ description: 'Inspect what a pi package would provide before installing it: the tools, commands, skills and events it registers, plus anything the bridge cannot carry over. Loads the package into the cache without touching the session.',
158
+ parameters: { source: SOURCE },
159
+ output: OUT(),
160
+ execute: guarded(async (args) => previewText(await loadForeign(args.source, { host: 'pi', log }))),
161
+ }))
162
+
163
+ ctx.tools.register(tool({
164
+ name: 'dsh_install',
165
+ description: 'Load another DSH plugin bundle into this session through the bridge, for a bundle the profile does not mount. Inspects the bundle manifest, records its registrations and replays them; it does not replace a bundle the profile already installed.',
166
+ parameters: { source: SOURCE, prefix: PREFIX },
167
+ output: OUT(),
168
+ execute: guarded(async (args) => {
169
+ const captured = await loadForeign(args.source, { host: 'dsh', log })
170
+ const prefix = (args.prefix) || defaultPrefix(captured.info.name)
171
+ const report = await replayIntoDsh(ctx, captured, { prefix })
172
+ state.bridges[args.source] = { name: captured.info.name, version: captured.info.version, host: 'dsh', prefix, mirror: captured.mirror, report, at: new Date().toISOString() }
173
+ await writeRegistry(state)
174
+ return formatReport(args.source, captured, report)
175
+ }),
176
+ }))
177
+
178
+ ctx.tools.register(tool({
179
+ name: 'bridge_list',
180
+ description: 'List plugin bundles this bridge installed into DSH, with the tools, commands and skills each contributed.',
181
+ parameters: {},
182
+ output: OUT(),
183
+ execute: guarded(async () => {
184
+ const keys = Object.keys(state.bridges)
185
+ if (!keys.length) return 'no bridged packages yet'
186
+ return keys.map(k => {
187
+ const b = state.bridges[k]
188
+ return '- ' + k + ' (' + b.name + '@' + b.version + ', host=' + b.host + ') tools=' + (b.report.tools || []).join(',') + ' commands=' + (b.report.commands || []).join(',') + ' skills=' + (b.report.skills || []).length
189
+ }).join('\n')
190
+ }),
191
+ }))
192
+
193
+ ctx.tools.register(tool({
194
+ name: 'bridge_remove',
195
+ description: 'Forget a bridged package and delete its mirror build from the bridge cache. Registrations already made in this process stay until DSH restarts.',
196
+ parameters: { source: { ...SOURCE, description: 'The exact source string previously installed.' } },
197
+ output: OUT(),
198
+ execute: guarded(async (args) => {
199
+ if (!state.bridges[args.source]) return 'not bridged: ' + args.source
200
+ const entry = state.bridges[args.source]
201
+ delete state.bridges[args.source]
202
+ await writeRegistry(state)
203
+ if (entry.mirror) await rm(entry.mirror, { recursive: true, force: true })
204
+ return 'removed ' + args.source + ' from the bridge registry (restart DSH to drop its registrations)'
205
+ }),
206
+ }))
207
+
208
+ // Skills from previously recorded bridges remount on every plugin load.
209
+ ctx.inject(['skills'], (scope) => {
210
+ if (!scope || !scope.skills || typeof scope.skills.registerProvider !== 'function') return
211
+ for (const entry of Object.values(state.bridges)) {
212
+ for (const dir of (entry.report && entry.report.skills) || []) {
213
+ if (existsSync(path.join(dir, 'SKILL.md'))) scope.skills.registerProvider(() => skillProviderFor(dir))
214
+ }
215
+ }
216
+ // The bridge's own skill, so users find the workflow from the skill list.
217
+ const selfSkill = path.join(HERE, 'skills', 'dsh-pi-bridge')
218
+ if (existsSync(path.join(selfSkill, 'SKILL.md'))) scope.skills.registerProvider(() => skillProviderFor(selfSkill))
219
+ })
220
+
221
+ // Replay recorded pi packages so an install survives a restart instead of
222
+ // living one session. Disabled with { restoreOnLoad: false } in bundle config.
223
+ const restore = (config && config.restoreOnLoad === false) ? { restored: [], failed: [] } : await restoreIntoDsh(ctx, state, { log })
224
+ if (restore.failed.length) { try { ctx.logger && ctx.logger.warn && ctx.logger.warn('dsh-pi-bridge restore: ' + restore.failed.join('; ')) } catch { /* ignore */ } }
225
+
226
+ const count = Object.keys(state.bridges).length
227
+ log('dsh-pi-bridge loaded: ' + count + ' bridged package(s) on record, ' + restore.restored.length + ' replayed')
228
+ }
229
+
230
+ export default { name, inject, apply }