@deepseek-insight/dsi 0.5.38 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +89 -2
  5. package/bin/lib/plugin-chain.mjs +86 -0
  6. package/build/client/_app/immutable/assets/{2.B3K-kE1m.css → 2.DEBHmc3y.css} +1 -1
  7. package/build/client/_app/immutable/chunks/BVBc9rJx.js +2 -0
  8. package/build/client/_app/immutable/chunks/BdHmBwz-.js +1 -0
  9. package/build/client/_app/immutable/chunks/{8isMKkmK.js → DNY2-FaC.js} +1 -1
  10. package/build/client/_app/immutable/chunks/oLHjdJ-M.js +1 -0
  11. package/build/client/_app/immutable/entry/{app.CpbSsqDG.js → app.C1j_PXOJ.js} +2 -2
  12. package/build/client/_app/immutable/entry/start.Dyarj9we.js +1 -0
  13. package/build/client/_app/immutable/nodes/{1.C4SBbU1i.js → 1.DB7DKw94.js} +1 -1
  14. package/build/client/_app/immutable/nodes/{2.C1u8HG5a.js → 2.D9qSqOKy.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-Ca_6TJ1k.js} +2 -2
  21. package/build/server/chunks/chunks/{state.js-BeFSJ3AU.js → state.js-BrbF5k84.js} +2 -2
  22. package/build/server/chunks/entries/endpoints/api/plugins/apply/_server.ts.js-CpO--Jwf.js +125 -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-C9GhKb8G.js} +3 -3
  31. package/build/server/chunks/entries/pages/{_page.svelte.js-DoOQ17mk.js → _page.svelte.js-6A1VYbL2.js} +1114 -104
  32. package/build/server/chunks/{handler-UO5maDaQ.js → handler-ChXGVK9Y.js} +3 -3
  33. package/build/server/chunks/{index.js-sK5zTj9E.js → index.js-5P_bGLRx.js} +2 -2
  34. package/build/server/chunks/{manifest.js-DdUlckhQ.js → manifest.js-B3ooI-FL.js} +30 -9
  35. package/build/server/chunks/nodes/1.js-Dcb4_Gzq.js +9 -0
  36. package/build/server/chunks/nodes/{2.js-ClaC6DDN.js → 2.js-C4WQL8rj.js} +4 -4
  37. package/package.json +1 -1
  38. package/template/.dsi/resources/plugins-reff.md +4 -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,53 @@ 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
+ // Dev-floor detection must run BEFORE the sweep kills what it would
230
+ // detect (RCA 2026-09-27, second order): the DSI_DEV env marker only
231
+ // exists on floors launched by THIS CLI's dev launcher — a floor from
232
+ // before the marker existed, or from plain `pnpm dev`, carries none.
233
+ // The vite dev process itself is visible to pgrep (unlike the npx
234
+ // shims — different argv rewriting), so ask the process table.
235
+ const dev = bounceDevMode(parsed.dev === true, isDevFloorAlive);
236
+ killOrphans();
237
+ // The pkill sweep cannot see every floor shape (RCA 2026-09-27: the
238
+ // npx shims — 'npm exec @deepseek-ai/dsh…' and '.bin/dsh web' — are
239
+ // invisible to pkill -f on macOS, so the dsh host survived holding
240
+ // 3080 and preflightPort exited 64, killing the detached chain
241
+ // silently). The bounce chain owns the DSI ports by contract, so
242
+ // free them by listener, not by name.
243
+ freeFloorPorts([3080, 5174, 5175]);
244
+ // Restore the SAME UI mode the floor was serving.
245
+ runSyncedPair([], dev);
190
246
  } else if (flag === '--sync-kills') {
191
247
  killOrphans();
192
248
  process.exit(0);
@@ -356,6 +412,33 @@ function killOrphans() {
356
412
  console.log('dsi dsh: orphan sweep done (dsh-web-synced.sh, dsh bin, dsi server)');
357
413
  }
358
414
 
415
+ /** SIGTERM whatever LISTENS on these ports: the bounce chain's name-blind
416
+ * second sweep (RCA 2026-09-27). pkill -f cannot match every floor shape
417
+ * (the npx shim processes are invisible to it on macOS), so a surviving
418
+ * dsh host held 3080, preflightPort exited 64, and the detached chain died
419
+ * silently. Only the bounce uses this; a manual --sync preflight still
420
+ * REFUSES a busy port it does not own. */
421
+ function freeFloorPorts(ports) {
422
+ for (const port of ports) {
423
+ try {
424
+ spawnSync('/bin/sh', ['-c', 'lsof -ti tcp:' + port + ' -sTCP:LISTEN | xargs kill 2>/dev/null || true'], { stdio: 'pipe' });
425
+ } catch {
426
+ // no lsof (or no xargs): the pattern sweep above and the 5s
427
+ // preflight wait remain the honest fallbacks.
428
+ }
429
+ }
430
+ }
431
+
432
+ /** True when a vite dev server of THIS checkout is alive. Only the
433
+ * checkout's own vite matches — a foreign project's dev server never
434
+ * carries the deepseek-insight path segment. pgrep sees this process
435
+ * (it does not rewrite argv the way the npm/npx shims do), so the probe
436
+ * is reliable where the orphan sweep's name matching is not. */
437
+ function isDevFloorAlive() {
438
+ const probe = spawnSync('pgrep', ['-f', 'deepseek-insight/node_modules/vite/bin/vite.js'], { stdio: 'pipe' });
439
+ return (probe.status ?? 1) === 0;
440
+ }
441
+
359
442
  /** Add the ZAI provider and default model to ~/.dsh/settings.yaml when
360
443
  * absent. Existing entries always win — an already-configured host is
361
444
  * rewritten only when at least one block was added. Exits non-zero when
@@ -488,7 +571,11 @@ function startDsiDevServer(defaultPort = '5175') {
488
571
  env: {
489
572
  ...process.env,
490
573
  // Same first-start bootstrap seam as the built server.
491
- DSI_TEMPLATE_PATH: process.env.DSI_TEMPLATE_PATH ?? path.join(packageRoot, 'template', '.dsi')
574
+ DSI_TEMPLATE_PATH: process.env.DSI_TEMPLATE_PATH ?? path.join(packageRoot, 'template', '.dsi'),
575
+ // Dev-floor marker (RCA 2026-09-27): the plugin-rack bounce reads
576
+ // this from the API server's env to re-serve --dev instead of the
577
+ // stale build. Nothing else may set it.
578
+ DSI_DEV: '1'
492
579
  }
493
580
  });
494
581
  }
@@ -0,0 +1,86 @@
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, dev?: boolean }}
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
+ // --dev is the only accepted extra: the bounce must restore the SAME
26
+ // UI mode the floor was serving (RCA 2026-09-27 — a dev-mode floor
27
+ // bounced into build mode came back on 5174 while the browser sat on
28
+ // 5175's corpse; the UI mode rides the DSI_DEV env marker).
29
+ const dev = rest.includes('--dev');
30
+ if (rest.some((arg) => arg !== '--dev')) return { error: 'usage: dsi dsh --plugin-restart [--dev]' };
31
+ return { action: 'restart', pkg: '', profile: envProfile, dev };
32
+ }
33
+ const [action, pkg, ...tail] = rest;
34
+ if (action !== 'add' && action !== 'remove') {
35
+ return { error: 'usage: dsi dsh --plugin <add|remove> <pkg> [--profile <p>]' };
36
+ }
37
+ if (!pkg || pkg.startsWith('-')) {
38
+ return { error: 'usage: dsi dsh --plugin <add|remove> <pkg> [--profile <p>]' };
39
+ }
40
+ let profile = envProfile;
41
+ for (let i = 0; i < tail.length; i++) {
42
+ if (tail[i] === '--profile') {
43
+ const value = tail[i + 1];
44
+ if (!value || value.startsWith('-')) return { error: 'usage: --profile needs a profile name' };
45
+ profile = value;
46
+ i++;
47
+ } else {
48
+ return { error: 'unexpected argument: ' + tail[i] };
49
+ }
50
+ }
51
+ return { action, pkg, profile };
52
+ }
53
+
54
+ /**
55
+ * The bounce's dev-mode decision (RCA 2026-09-27, second order): an explicit
56
+ * --dev flag wins, otherwise the chain asks whether a dev floor is alive.
57
+ * Pure: the process-table probe is injected so the real pgrep stays in
58
+ * dsi.mjs's impure half.
59
+ *
60
+ * @param {boolean} flagDev — the parsed `--plugin-restart --dev` flag
61
+ * @param {() => boolean} devFloorAlive — probe for a running vite dev server
62
+ * @returns {boolean}
63
+ */
64
+ export function bounceDevMode(flagDev, devFloorAlive) {
65
+ if (flagDev) return true;
66
+ try {
67
+ return devFloorAlive() === true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * The dsh launcher rule (runOvPlugin's, generalized): DSH_WEB_CMD override,
75
+ * else the pinned published dsh via npx. The webVersion pin comes from the
76
+ * caller (dsi.mjs owns package.json reads).
77
+ *
78
+ * @param {string} profile
79
+ * @param {'add' | 'remove'} action
80
+ * @param {string} pkg
81
+ * @param {string} webVersion
82
+ */
83
+ export function dshLauncherCmd(profile, action, pkg, webVersion) {
84
+ const base = process.env.DSH_WEB_CMD || 'npx --yes @deepseek-ai/dsh@' + (webVersion || 'latest');
85
+ return base + ' plugin --profile ' + profile + ' ' + action + ' ' + pkg;
86
+ }