@deepseek-insight/dsi 0.5.38 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.agents/skills/dsi-plugin-rack/SKILL.md +27 -0
  2. package/.agents/skills/dsi-plugin-rack/rack.mjs +237 -0
  3. package/README.md +27 -3
  4. package/bin/dsi.mjs +42 -1
  5. package/bin/lib/plugin-chain.mjs +62 -0
  6. package/build/client/_app/immutable/assets/{2.B3K-kE1m.css → 2.DLFRZPDf.css} +1 -1
  7. package/build/client/_app/immutable/chunks/BZW4nu4u.js +1 -0
  8. package/build/client/_app/immutable/chunks/CpgMZB3l.js +1 -0
  9. package/build/client/_app/immutable/chunks/DZixYQAd.js +2 -0
  10. package/build/client/_app/immutable/chunks/{8isMKkmK.js → DzZu8EY2.js} +1 -1
  11. package/build/client/_app/immutable/entry/{app.CpbSsqDG.js → app.Toc7d2RT.js} +2 -2
  12. package/build/client/_app/immutable/entry/start.CCaKY2bM.js +1 -0
  13. package/build/client/_app/immutable/nodes/{1.C4SBbU1i.js → 1.BMemLSP2.js} +1 -1
  14. package/build/client/_app/immutable/nodes/{2.C1u8HG5a.js → 2.BDUOgoKA.js} +195 -195
  15. package/build/client/_app/version.json +1 -1
  16. package/build/handler.js +4 -4
  17. package/build/index.js +4 -4
  18. package/build/server/chunks/chunks/engine.js-CrdWRSwf.js +97 -0
  19. package/build/server/chunks/chunks/{engine.js-BRoFbCnL.js → engine2.js-BRoFbCnL.js} +1 -1
  20. package/build/server/chunks/chunks/{internal.js-D0IzcQru.js → internal.js-Dhppl8VW.js} +2 -2
  21. package/build/server/chunks/chunks/{state.js-BeFSJ3AU.js → state.js--QbGDz9E.js} +2 -2
  22. package/build/server/chunks/entries/endpoints/api/plugins/apply/_server.ts.js-BXwRN5sp.js +100 -0
  23. package/build/server/chunks/entries/endpoints/api/plugins/reload/_server.ts.js-U8vRgx7E.js +34 -0
  24. package/build/server/chunks/entries/endpoints/api/plugins/snapshot/_server.ts.js-9Ezy2OeQ.js +36 -0
  25. package/build/server/chunks/entries/endpoints/api/skills/install/{_server.ts.js-DijnCqgt.js → _server.ts.js-DyzPF_Z5.js} +2 -2
  26. package/build/server/chunks/entries/endpoints/api/skills/reload/{_server.ts.js-Cqig6BdK.js → _server.ts.js-BUnCt45D.js} +2 -2
  27. package/build/server/chunks/entries/endpoints/api/skills/reload/cancel/{_server.ts.js-CGlFnmuI.js → _server.ts.js-DT52g6_a.js} +2 -2
  28. package/build/server/chunks/entries/endpoints/api/skills/snapshot/{_server.ts.js-p7P1CFjG.js → _server.ts.js-CaBD0w0v.js} +2 -2
  29. package/build/server/chunks/entries/endpoints/api/skills/uninstall/{_server.ts.js-h5IfIfov.js → _server.ts.js-D_NXkZEr.js} +2 -2
  30. package/build/server/chunks/entries/pages/{_error.svelte.js-C5-GtTAX.js → _error.svelte.js-n2xOEPvp.js} +3 -3
  31. package/build/server/chunks/entries/pages/{_page.svelte.js-DoOQ17mk.js → _page.svelte.js-BJ7eKmkS.js} +1183 -105
  32. package/build/server/chunks/{handler-UO5maDaQ.js → handler-BJHxQwK-.js} +3 -3
  33. package/build/server/chunks/{index.js-sK5zTj9E.js → index.js-BXPH1Bno.js} +2 -2
  34. package/build/server/chunks/{manifest.js-DdUlckhQ.js → manifest.js-DjQqMW2U.js} +30 -9
  35. package/build/server/chunks/nodes/1.js-Cb5I-Dtm.js +9 -0
  36. package/build/server/chunks/nodes/{2.js-ClaC6DDN.js → 2.js-BnWybqO0.js} +4 -4
  37. package/package.json +1 -1
  38. package/template/.dsi/resources/plugins-reff.md +3 -0
  39. package/build/client/_app/immutable/chunks/DaJQaZs4.js +0 -1
  40. package/build/client/_app/immutable/chunks/vqHvFU33.js +0 -2
  41. package/build/client/_app/immutable/chunks/y7n0suOs.js +0 -1
  42. package/build/client/_app/immutable/entry/start.CNrTX-th.js +0 -1
  43. package/build/server/chunks/nodes/1.js-CKpJoUo-.js +0 -9
@@ -0,0 +1,27 @@
1
+ # dsi-plugin-rack
2
+
3
+ Install and uninstall dsh plugins from the plugins reference (~/.dsi/resources/plugins-reff.md), cached at ~/.dsi/resources/pgr-cache.json. Implements the ADR "The Plugin Rack" (dev/architectural-decission/2026-09/, 2026-09-27).
4
+
5
+ ## Engine
6
+
7
+ `rack.mjs` — plain Node, JSON on stdout, every payload carries `v: 1`.
8
+
9
+ ```bash
10
+ # build snapshot (reuses existing unless --reload)
11
+ node rack.mjs refresh [--reload] [--reff PATH] [--cache PATH] [--manifest PATH] [--profile P] [--dsh-cmd CMD]
12
+
13
+ # dsh-delegated install / remove (D3: never hand-edit the profile)
14
+ node rack.mjs apply install 1 # numbers or ids; spec = git+<repo>.git
15
+ node rack.mjs apply remove dsh-rules-paths
16
+
17
+ node rack.mjs snapshot-status
18
+ ```
19
+
20
+ ## Rules (do not violate)
21
+
22
+ - **dsh is the writer.** Every install/remove goes through `dsh plugin --profile <p> add|remove` — the profile manifest, its lock, and reconcilePlugins are dsh's. Never write ~/.dsh/profiles/* by hand (D3).
23
+ - **The manifest's dependencies are the install authority (D4, amended 2026-09-27).** pnpm records git plugins under the package's OWN scoped name (`@owner/name`), not the reff id — matching is by repo identity (exact id, or the owner/name tail in the dep resolution), and the recorded key is captured as `pkg` and IS the remove target. Bundle entries are a derived hint, never the gate; after dsh reports success the engine re-reads the manifest and DISTRUSTS success it cannot see recorded.
24
+ - **Snapshot-first (D3):** refresh reuses an existing cache; only --reload rebuilds. Never delete the cache to force a rebuild.
25
+ - **Gates surface, never auto-clear (D6):** allowBuilds and version-approval failures relay dsh's own diagnostics verbatim; the operator unlocks, then retries.
26
+ - **Uninstall only what the manifest records AND the reff lists** — hand-added packages (e.g. the OV plugin) are invisible to the rack.
27
+ - Tests MUST use --reff + --cache + --manifest + --dsh-cmd fixture overrides — never the real home directories.
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsi-plugin-rack — the rack engine (ADR "The Plugin Rack" 2026-09-27, D2–D6).
4
+ * Plain Node, JSON on stdout, every payload carries v: 1.
5
+ *
6
+ * rack.mjs refresh [--reload] [--reff PATH] [--cache PATH] [--manifest PATH]
7
+ * [--profile P] [--dsh-cmd CMD]
8
+ * rack.mjs apply install|remove <n|id>...
9
+ * rack.mjs snapshot-status
10
+ *
11
+ * Reading is ours (reff parse, snapshot); WRITING is dsh's — apply shells out
12
+ * to `dsh plugin --profile <p> add|remove` and never hand-edits the profile
13
+ * manifest (D3). The manifest is provenance's state home (D4): installed =
14
+ * dependencies[id] AND dsh.profile.bundles includes id. Gates surface, they
15
+ * never auto-clear (D6).
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { spawnSync } from 'node:child_process';
21
+
22
+ const CACHE_VERSION = 1;
23
+
24
+ function parseArgs(argv) {
25
+ const out = { command: argv[0], reload: false, reff: null, cache: null, manifest: null, profile: process.env.DSH_PROFILE ?? 'web', dshCmd: null, targets: [] };
26
+ for (let i = 1; i < argv.length; i++) {
27
+ const a = argv[i];
28
+ if (a === '--reload') out.reload = true;
29
+ else if (a === '--reff') out.reff = argv[++i];
30
+ else if (a === '--cache') out.cache = argv[++i];
31
+ else if (a === '--manifest') out.manifest = argv[++i];
32
+ else if (a === '--profile') out.profile = argv[++i];
33
+ else if (a === '--dsh-cmd') out.dshCmd = argv[++i];
34
+ else out.targets.push(a);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ /** plugins-reff.md line grammar: `1. [name](repo-url) - [author](author-url)`
40
+ * (author side optional). One line IS one plugin (ADR §2.1). */
41
+ export function parseReff(text) {
42
+ const plugins = [];
43
+ const warnings = [];
44
+ const re = /^\s*\d+\.\s+\[([^\]]+)\]\(([^)\s]+)\)(?:\s+-\s+\[([^\]]+)\]\(([^)\s]+)\))?\s*$/;
45
+ for (const line of text.split(/\r?\n/)) {
46
+ if (/^\s*\d+\.\s+\[/.test(line) && !re.test(line)) {
47
+ warnings.push({ code: 'reff-malformed-line', detail: line.trim() });
48
+ continue;
49
+ }
50
+ const m = line.match(re);
51
+ if (m) plugins.push({ id: m[1], repo: m[2], author: m[3] ?? null, authorUrl: m[4] ?? null });
52
+ }
53
+ if (plugins.length === 0) warnings.push({ code: 'reff-empty', detail: 'no plugin lines found' });
54
+ return { plugins, warnings };
55
+ }
56
+
57
+ /** D4 (amended 2026-09-27): the manifest's DEPENDENCIES are the install
58
+ * authority. pnpm records a git plugin under the package's REAL name from
59
+ * its own package.json (e.g. '@temoa/dsh-rules-paths'), not the reff id -
60
+ * so matching goes by the repo's owner/name tail found in the dep's
61
+ * resolution (e.g. 'github:Temoa/dsh-rules-paths'), with the exact reff id
62
+ * as the fallback. Bundle entries are dsh's own reconcile business and MAY
63
+ * lag or be absent for git plugins - they are a derived hint, never the
64
+ * install gate. Returns the recorded dep key (the remove target) or null. */
65
+ export function findDepKey(manifest, id, repo) {
66
+ if (manifest.deps[id] !== undefined) return id;
67
+ const tail = String(repo || '').replace(/\\.git$/, '').replace(/\/+$/, '').split('/').slice(-2).join('/');
68
+ if (!tail) return null;
69
+ for (const [key, value] of Object.entries(manifest.deps)) {
70
+ if (typeof value === 'string' && value.includes(tail)) return key;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * D4: the profile manifest is the install authority.
77
+ */
78
+ export function readManifest(path) {
79
+ if (!existsSync(path)) return { deps: {}, bundles: [], missing: true };
80
+ const doc = JSON.parse(readFileSync(path, 'utf8'));
81
+ return {
82
+ deps: (doc && typeof doc === 'object' && doc.dependencies) || {},
83
+ bundles: (doc?.dsh?.profile?.bundles) ?? [],
84
+ missing: false
85
+ };
86
+ }
87
+
88
+ export function isInstalled(manifest, id, repo) {
89
+ return findDepKey(manifest, id, repo) !== null;
90
+ }
91
+
92
+ export function buildSnapshot({ reffPath, manifestPath, profile }) {
93
+ const warnings = [];
94
+ if (!existsSync(reffPath)) warnings.push({ code: 'reff-missing', detail: reffPath });
95
+ const parsed = existsSync(reffPath) ? parseReff(readFileSync(reffPath, 'utf8')) : { plugins: [], warnings: [] };
96
+ warnings.push(...parsed.warnings);
97
+ const manifest = readManifest(manifestPath);
98
+ if (manifest.missing) warnings.push({ code: 'manifest-missing', detail: manifestPath });
99
+ return {
100
+ snapshot: {
101
+ v: CACHE_VERSION,
102
+ generatedAt: new Date().toISOString(),
103
+ profile,
104
+ plugins: parsed.plugins.map((p, i) => {
105
+ const depKey = findDepKey(manifest, p.id, p.repo);
106
+ return {
107
+ n: String(i + 1),
108
+ id: p.id,
109
+ repo: p.repo,
110
+ author: p.author,
111
+ ...(p.authorUrl ? { authorUrl: p.authorUrl } : {}),
112
+ installed: depKey !== null,
113
+ // The recorded dependency name (pnpm's truth) - the remove target.
114
+ pkg: depKey,
115
+ bundle: (depKey !== null && manifest.bundles.includes(depKey)) || null
116
+ };
117
+ })
118
+ },
119
+ warnings
120
+ };
121
+ }
122
+
123
+ /** D3: the reff URL becomes the dsh add spec: git+<url>.git (append .git
124
+ * only when absent — the operator's line stays the single source). */
125
+ export function toInstallSpec(repo) {
126
+ return 'git+' + repo + (repo.endsWith('.git') ? '' : '.git');
127
+ }
128
+
129
+ function resolveDshCommand(flags) {
130
+ if (flags.dshCmd) return flags.dshCmd;
131
+ if (process.env.DSH_WEB_CMD) return process.env.DSH_WEB_CMD;
132
+ return 'npx --yes @deepseek-ai/dsh@latest';
133
+ }
134
+
135
+ /** D3/D6: run dsh, relay its own diagnostics verbatim on failure. */
136
+ export function runDsh(flags, action, spec) {
137
+ const cmd = resolveDshCommand(flags) + ' plugin --profile ' + flags.profile + ' ' + action + ' ' + spec;
138
+ const r = spawnSync('/bin/sh', ['-c', cmd], { encoding: 'utf8' });
139
+ if (r.error) return { ok: false, error: String(r.error.message ?? r.error) };
140
+ if (r.status !== 0) {
141
+ const tail = ((r.stderr ?? '') + (r.stdout ?? '')).trim();
142
+ return { ok: false, error: 'dsh plugin ' + action + ' failed (exit ' + (r.status ?? 1) + ')' + (tail ? ': ' + tail.slice(-800) : '') };
143
+ }
144
+ return { ok: true };
145
+ }
146
+
147
+ function loadSnapshot(cachePath) {
148
+ const snap = JSON.parse(readFileSync(cachePath, 'utf8'));
149
+ if (snap.v !== CACHE_VERSION) throw new Error('rack cache wire version mismatch: v=' + snap.v);
150
+ return snap;
151
+ }
152
+
153
+ function main() {
154
+ const args = parseArgs(process.argv.slice(2));
155
+ const reffPath = resolve(args.reff ?? join(homedir(), '.dsi/resources/plugins-reff.md'));
156
+ const cachePath = resolve(args.cache ?? join(homedir(), '.dsi/resources/pgr-cache.json'));
157
+ const manifestPath = resolve(args.manifest ?? join(homedir(), '.dsh/profiles', args.profile, 'package.json'));
158
+
159
+ if (args.command === 'snapshot-status') {
160
+ console.log(JSON.stringify({ v: CACHE_VERSION, ok: true, present: existsSync(cachePath), path: cachePath }));
161
+ return;
162
+ }
163
+
164
+ if (args.command === 'refresh') {
165
+ if (!args.reload && existsSync(cachePath)) {
166
+ console.log(JSON.stringify({ v: CACHE_VERSION, ok: true, reused: true, snapshot: loadSnapshot(cachePath) }));
167
+ return;
168
+ }
169
+ const { snapshot, warnings } = buildSnapshot({ reffPath, manifestPath, profile: args.profile });
170
+ mkdirSync(dirname(cachePath), { recursive: true });
171
+ writeFileSync(cachePath, JSON.stringify(snapshot, null, 2));
172
+ console.log(JSON.stringify({ v: CACHE_VERSION, ok: true, snapshot, ...(warnings.length ? { errors: warnings.map((w) => w.code + ': ' + w.detail) } : {}) }));
173
+ return;
174
+ }
175
+
176
+ if (args.command === 'apply') {
177
+ const action = args.targets.shift();
178
+ const targets = args.targets;
179
+ if (action !== 'install' && action !== 'remove') return fail(['apply needs install|remove and at least one target']);
180
+ if (targets.length === 0) return fail(['apply ' + action + ' needs at least one target']);
181
+ if (!existsSync(cachePath)) return fail(['snapshot missing, run refresh first: ' + cachePath]);
182
+ const snap = loadSnapshot(cachePath);
183
+ const results = [];
184
+ for (const t of targets) {
185
+ const plugin = snap.plugins.find((p) => p.n === t || p.id === t);
186
+ if (!plugin) { results.push({ n: t, ok: false, error: 'not on the rack: ' + t }); continue; }
187
+ if (action === 'install') {
188
+ if (plugin.installed) { results.push({ n: plugin.n, id: plugin.id, ok: true, already: true }); continue; }
189
+ const r = runDsh(args, 'add', toInstallSpec(plugin.repo));
190
+ if (!r.ok) { results.push({ n: plugin.n, id: plugin.id, ok: false, error: r.error }); continue; }
191
+ const manifest = readManifest(manifestPath);
192
+ const depKey = findDepKey(manifest, plugin.id, plugin.repo);
193
+ if (!depKey) {
194
+ results.push({ n: plugin.n, id: plugin.id, ok: false, error: 'dsh reported success but the manifest does not record a dependency matching ' + plugin.repo + ' (D4 authority)' });
195
+ continue;
196
+ }
197
+ plugin.installed = true;
198
+ plugin.pkg = depKey;
199
+ plugin.bundle = manifest.bundles.includes(depKey) || null;
200
+ results.push({ n: plugin.n, id: plugin.id, pkg: depKey, ok: true });
201
+ } else {
202
+ if (!plugin.installed) { results.push({ n: plugin.n, id: plugin.id, ok: false, error: 'not installed: ' + plugin.id }); continue; }
203
+ // Remove targets the RECORDED dependency name (pnpm's truth),
204
+ // falling back to the reff id for caches written before D4's
205
+ // amendment - then refresh heals the manifest reconciliation.
206
+ const manifestNow = readManifest(manifestPath);
207
+ const depKey = findDepKey(manifestNow, plugin.pkg ?? plugin.id, plugin.repo) ?? plugin.pkg ?? plugin.id;
208
+ const r = runDsh(args, 'remove', depKey);
209
+ if (!r.ok) { results.push({ n: plugin.n, id: plugin.id, ok: false, error: r.error }); continue; }
210
+ const manifest = readManifest(manifestPath);
211
+ if (findDepKey(manifest, plugin.id, plugin.repo) !== null) {
212
+ results.push({ n: plugin.n, id: plugin.id, ok: false, error: 'dsh reported success but the manifest still records ' + plugin.id + ' (D4 authority)' });
213
+ continue;
214
+ }
215
+ plugin.installed = false;
216
+ plugin.bundle = null;
217
+ plugin.pkg = null;
218
+ results.push({ n: plugin.n, id: plugin.id, ok: true });
219
+ }
220
+ }
221
+ // Flip flags ONLY after every dsh call so a mid-apply failure never
222
+ // leaves the cache half-written (shelf apply precedent).
223
+ if (results.some((x) => x.ok && !x.already)) writeFileSync(cachePath, JSON.stringify(snap, null, 2));
224
+ const failed = results.filter((x) => !x.ok);
225
+ console.log(JSON.stringify({ v: CACHE_VERSION, ok: failed.length === 0, results, ...(failed.length ? { errors: failed.map((f) => f.id + ': ' + f.error) } : {}) }));
226
+ return;
227
+ }
228
+
229
+ fail(['unknown command: ' + args.command + ' (use refresh | apply | snapshot-status)']);
230
+ }
231
+
232
+ function fail(errors) {
233
+ console.log(JSON.stringify({ v: CACHE_VERSION, ok: false, errors }));
234
+ process.exit(1);
235
+ }
236
+
237
+ main();
package/README.md CHANGED
@@ -6,12 +6,36 @@
6
6
 
7
7
  > A `Prompt Input` with **Slash-command(/)**, **Macros(!)** and **Auto-complete(?)**. Prompt-manager you can search it and to manage macros & autocomplete; user-prompts worth keeping is one-click-away tobe reuse.
8
8
 
9
- Slash commands are available to execute various operations:
9
+ ## Slash commands are available to execute various operations:
10
10
  - `/dsi-terminal` — Open a terminal session
11
11
  - `/dsi-prompts` — Open prompt manager
12
+ - `/dsi-plugins` — Open plugin manager
12
13
  - `/dsi-skills` — Open skill manager
13
-
14
- Included skills for:
14
+
15
+ ### Autocomplete (`?`)
16
+
17
+ Type `?` followed by a few letters, for example `?deploy`, and a small list appears showing the prompts you have saved before, best match first. Choosing one puts its text into the input, so you can read it and change a detail before sending. It keeps you from typing the same long prompts over and over, and from trying to remember where you saved them.
18
+
19
+ ### Macros (`!`)
20
+
21
+ `!` works the same way, but choosing a prompt runs it right away instead of filling the input. Save the prompts you use every day as macros, and routines like a code review or a session summary become a single short line. Use `?` when you want to look first, and `!` when you already know what you want.
22
+
23
+ ### Prompt Manager (`/dsi-prompts`)
24
+ The prompt manager is where your saved prompts live, so the `?` and `!` tricks above always have something to find. Search across everything you have kept, open a prompt to tweak its wording, flag the ones you use daily as macros, and send a keeper straight back into the input with one click. A prompt you were proud of today becomes a shortcut tomorrow.
25
+
26
+ ### Plugin Manager (`/dsi-plugins`)
27
+ Plugins add whole features to DSI — the plugin manager shows what is on the rack. A simple install tab lists what you can add, uninstall lists what is already on, and each row shows the plugin's state so you always know what is running. You never touch a config file to add or remove one.
28
+
29
+ ### Skill Manager (`/dsi-skills`)
30
+ Skills are the instructions your agents can follow, and this panel is their shelf. Browse what is installed group by group, tick the ones you want gone, and anything you remove can come back later. If a skill stopped being useful, it is two clicks away from being off the shelf.
31
+
32
+ ### Terminal (`/dsi-terminal`)
33
+ One terminal is rarely enough when agents are working, so this one opens several, each in its own tab. Open a tab per task, keep a long build running in the background, and your tabs survive a page refresh — come back and they are still there. When a session has ended, the tab tells you instead of leaving you to guess.
34
+
35
+ ### Workspace explorer
36
+ The workspace explorer is DSI's window into the folder your agents are working in. Click the workspace chip at the top of a conversation — or a file name in one of the assistant's replies — and the panel opens with that file ready. A directory tree shows the whole project, every opened file keeps its own tab so several can sit side by side, and files your agents have changed are marked in the tree, so the work is visible at a glance. One click turns any tab into a before-and-after diff when you want to see exactly what was edited.
37
+
38
+ ## Included skills for:
15
39
  - Coding agents: `/dsi-adr`, `/dsi-spec`, `/dsi-task`
16
40
  - Integration helper for [OpenViking](https://openviking.ai/): `/dsi-ov-setup`
17
41
 
package/bin/dsi.mjs CHANGED
@@ -28,6 +28,7 @@ import { fileURLToPath } from 'node:url';
28
28
  import { parse, stringify } from 'yaml';
29
29
 
30
30
  import { mirrorSkills } from './lib/mirror-skills.mjs';
31
+ import { dshLauncherCmd, parsePluginArgs } from './lib/plugin-chain.mjs';
31
32
 
32
33
  // Layman-facing help: wrapped near 76 columns, continuation lines indented,
33
34
  // the recommended one-liner first. --zai is optional on purpose — no GLM
@@ -61,6 +62,10 @@ const USAGE = [
61
62
  ' --ov install the OpenViking memory plugin into the active dsh',
62
63
  ' profile (dsh plugin ... add). Client only — the memory',
63
64
  ' server itself is set up separately (dsi-ov-setup skill)',
65
+ ' --plugin <add|remove> <pkg> [--profile <p>]',
66
+ ' run the dsh plugin command, then bounce the floor:',
67
+ ' killOrphans + token re-sync + re-serve (The Plugin Rack',
68
+ ' ADR D5). --plugin-restart is the bounce alone.',
64
69
  '',
65
70
  ' Ports: `dsi dsh --sync` serves the DSI page on 5174 and opens your',
66
71
  ' browser there. With --dev the Vite dev server takes 5175 instead',
@@ -80,7 +85,11 @@ const USAGE = [
80
85
  ' install the OpenViking memory plugin into the active',
81
86
  ' dsh profile (idempotent; alone, without --sync)',
82
87
  ' dsi dsh --sync-kills',
83
- ' kill orphaned dsh/dsi processes left by an earlier run'
88
+ ' kill orphaned dsh/dsi processes left by an earlier run',
89
+ ' dsi dsh --plugin add git+https://github.com/Temoa/dsh-rules-paths.git',
90
+ ' install via dsh plugin, then restart the pair (The',
91
+ ' Plugin Rack ADR D5); --plugin-restart bounces alone',
92
+ ' dsi dsh --plugin remove <pkg>'
84
93
  ].join('\n');
85
94
 
86
95
  const command = process.argv[2];
@@ -187,6 +196,38 @@ if (command === 'dsh') {
187
196
  rest.filter((arg) => arg !== '--zai' && arg !== '--dev' && arg !== '--ov'),
188
197
  withDev
189
198
  );
199
+ } else if (flag === '--plugin' || flag === '--plugin-restart') {
200
+ // The Plugin Rack ADR (2026-09-27, D3/D5): the dsh CLI is the writer,
201
+ // dsi owns the bounce. Install/remove delegates to the launcher rule
202
+ // (DSH_WEB_CMD override, else the pinned npx dsh), fails loud, and
203
+ // ONLY a successful apply reaches the restart chain: killOrphans,
204
+ // then the synced pair (dsh web + token re-sync + DSI re-serve).
205
+ const parsed = parsePluginArgs(process.argv.slice(3), {
206
+ envProfile: process.env.DSH_PROFILE ?? 'web'
207
+ });
208
+ if (parsed === null) {
209
+ console.error(`dsi dsh: unknown flag "${flag}"`);
210
+ console.error(USAGE);
211
+ process.exit(64);
212
+ }
213
+ if ('error' in parsed) {
214
+ console.error('dsi dsh: ' + parsed.error);
215
+ process.exit(64);
216
+ }
217
+ if (parsed.action !== 'restart') {
218
+ const cmd = dshLauncherCmd(parsed.profile, parsed.action, parsed.pkg, dshWebVersion());
219
+ console.log(`dsi dsh: plugin ${parsed.action} into the ${parsed.profile} profile (${cmd})`);
220
+ const result = spawnSync('/bin/sh', ['-c', cmd], { stdio: 'inherit' });
221
+ if (result.error !== undefined) throw result.error;
222
+ const code = result.status ?? 1;
223
+ if (code !== 0) {
224
+ console.error(`dsi dsh: plugin ${parsed.action} failed (exit ${code}) — nothing bounced; fix the cause above, then re-run`);
225
+ process.exit(code);
226
+ }
227
+ }
228
+ console.log('dsi dsh: bouncing the floor (orphan sweep + synced pair re-run)');
229
+ killOrphans();
230
+ runSyncedPair([]);
190
231
  } else if (flag === '--sync-kills') {
191
232
  killOrphans();
192
233
  process.exit(0);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * plugin-chain — the testable half of 'dsi dsh --plugin' (The Plugin Rack
3
+ * ADR 2026-09-27, D3/D5): argument validation and the dsh launcher rule
4
+ * (DSH_WEB_CMD override, else the pinned published dsh via npx — the same
5
+ * rule runOvPlugin established). The impure half (spawnSync, killOrphans,
6
+ * runSyncedPair) stays in dsi.mjs; this module never touches a process.
7
+ */
8
+
9
+ /**
10
+ * Parse 'dsi dsh --plugin <add|remove> <pkg> [--profile <p>]' arguments.
11
+ *
12
+ * @param {string[]} argv
13
+ * @param {{ envProfile?: string }} [options]
14
+ * @returns {null | { error: string } | { action: 'add' | 'remove' | 'restart', pkg: string, profile: string }}
15
+ * null = argv is not a --plugin invocation (the caller falls through to
16
+ * its own unknown-flag error); { error } = a --plugin invocation with a
17
+ * bad shape (usage exit 64).
18
+ */
19
+ export function parsePluginArgs(argv, options = {}) {
20
+ const envProfile = options.envProfile ?? 'web';
21
+ const [flag] = argv;
22
+ if (flag !== '--plugin' && flag !== '--plugin-restart') return null;
23
+ const rest = argv.slice(1);
24
+ if (flag === '--plugin-restart') {
25
+ if (rest.length !== 0) return { error: 'usage: dsi dsh --plugin-restart' };
26
+ return { action: 'restart', pkg: '', profile: envProfile };
27
+ }
28
+ const [action, pkg, ...tail] = rest;
29
+ if (action !== 'add' && action !== 'remove') {
30
+ return { error: 'usage: dsi dsh --plugin <add|remove> <pkg> [--profile <p>]' };
31
+ }
32
+ if (!pkg || pkg.startsWith('-')) {
33
+ return { error: 'usage: dsi dsh --plugin <add|remove> <pkg> [--profile <p>]' };
34
+ }
35
+ let profile = envProfile;
36
+ for (let i = 0; i < tail.length; i++) {
37
+ if (tail[i] === '--profile') {
38
+ const value = tail[i + 1];
39
+ if (!value || value.startsWith('-')) return { error: 'usage: --profile needs a profile name' };
40
+ profile = value;
41
+ i++;
42
+ } else {
43
+ return { error: 'unexpected argument: ' + tail[i] };
44
+ }
45
+ }
46
+ return { action, pkg, profile };
47
+ }
48
+
49
+ /**
50
+ * The dsh launcher rule (runOvPlugin's, generalized): DSH_WEB_CMD override,
51
+ * else the pinned published dsh via npx. The webVersion pin comes from the
52
+ * caller (dsi.mjs owns package.json reads).
53
+ *
54
+ * @param {string} profile
55
+ * @param {'add' | 'remove'} action
56
+ * @param {string} pkg
57
+ * @param {string} webVersion
58
+ */
59
+ export function dshLauncherCmd(profile, action, pkg, webVersion) {
60
+ const base = process.env.DSH_WEB_CMD || 'npx --yes @deepseek-ai/dsh@' + (webVersion || 'latest');
61
+ return base + ' plugin --profile ' + profile + ' ' + action + ' ' + pkg;
62
+ }