@sessionbus/dsh 0.1.0-pre.10

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/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # sessionbus for DSH
2
+
3
+ `@sessionbus/dsh` connects DSH roots to sessionbus and runs daemon-managed DSH lanes.
4
+ It depends on the exact `@sessionbus/kit` version `0.5.5`.
5
+ It supports DeepSeek Harness `0.1.5-rc.2` and later; tested versions are
6
+ `0.1.5-rc.2`, `0.1.6-alpha.1`, and `0.1.6-alpha.2`.
7
+ A DSH profile must install every DSH package at one uniform DSH version; adding
8
+ one prerelease package can otherwise pull newer prereleases through DSH's caret peers.
9
+ The default single-tool permission grant relies on DSH's `tools/pre-execute`
10
+ waterfall, present since the `0.1.5-rc.2` peer floor.
11
+ `@sessionbus/dsh` is a pkg.pr.new preview until a separately reviewed trusted-
12
+ publishing workflow exists; its first registry version must be published manually
13
+ before trusted publishing can be configured.
14
+ The plugin reads the token once, deletes it from process.env, and retains it
15
+ nowhere in the plugin; DSH's immutable launch snapshot keeps it for the process
16
+ lifetime (trusted host).
17
+
18
+ ## Installation
19
+
20
+ See [Installing a DSH lane host](docs/HOST-INSTALL.md) for the complete
21
+ preflight, installation, verification, and rollback procedure.
22
+ Its provider-parity step must be completed for both the lane and plain peer
23
+ profiles before either one runs a model turn.
24
+
25
+ Create the base-only lane profile for the package-owned `sessionbus-dsh`
26
+ product with:
27
+
28
+ ```sh
29
+ dsh plugin --profile sessionbus add @sessionbus/dsh && dsh plugin --profile sessionbus exec sessionbus-dsh-install
30
+ ```
31
+
32
+ On a host that already uses dashi, one product can own both peers and lanes:
33
+
34
+ ```sh
35
+ dsh plugin --profile sessionbus add @sessionbus/dsh && dsh plugin --profile sessionbus exec sessionbus-dsh-install --product dashi
36
+ ```
37
+
38
+ In that form `SESSIONBUS_PRODUCTS` contains `dashi`, not `sessionbus-dsh`,
39
+ and the daemon maps it to `@antst/dashi-launcher`'s `dashi` bin.
40
+
41
+ Add the peer plugin to another profile, such as `web`, with:
42
+
43
+ ```sh
44
+ dsh plugin --profile web add @sessionbus/dsh && dsh plugin --profile web exec sessionbus-dsh-install --product dsh web
45
+ ```
46
+
47
+ The installer writes only profile-local rows and leaves an existing
48
+ `sessionbus` row's other fields unchanged while adding or updating its required
49
+ `product`. With no profile arguments it configures only the `sessionbus` lane
50
+ profile and derives product `sessionbus-dsh`. Use product `dashi` for the dashi
51
+ lane variant, `dsh` for a standalone web or custom peer profile, or another
52
+ stable operator-chosen identifier matching `^[a-z0-9][a-z0-9-]{0,31}$`.
53
+ `@antst/dashi-app@0.1.0-alpha.20` and later own their dashi-profile row and
54
+ exact plugin dependency; do not run this installer against that profile.
55
+ Non-web profiles also receive the no-upload provider required by DSH's Session
56
+ Controller; ordinary text prompts work while file-upload receipts are rejected.
57
+
58
+ For the two-product form, register the daemon's `sessionbus-dsh` product as the
59
+ package's `sessionbus-dsh` bin. For the one-product form, register only `dashi`
60
+ as the `dashi` bin. Both launchers select the installed `sessionbus` profile
61
+ when given a launch token. Install the selected launcher alongside `dsh` at the
62
+ host level and put their shared `node_modules/.bin` on the daemon service's
63
+ `PATH`; the `dashi` launcher resolves its child `dsh` by name.
64
+
65
+ Uninstall without invoking DSH by running the installed bin from the profile:
66
+
67
+ ```sh
68
+ pnpm --dir "$DSH_HOME/profiles/web" exec sessionbus-dsh-install --remove web
69
+ ```
70
+
71
+ When the DSH CLI works, `dsh plugin --profile web exec
72
+ sessionbus-dsh-install --remove web` is the equivalent convenience form. The
73
+ installer removes the package first, then strips its managed `sessionbus` and
74
+ `file-uploads-none` rows. Managed rows carry an ownership comment; removal
75
+ refuses a matching unmarked row instead of deleting someone else's config.
76
+
77
+ `SESSIONBUS_GROUPS` configures peer identities only. Lane membership is owned
78
+ by the daemon; the plugin accepts and does not consume groups in `session.open`.
79
+
80
+ ## Run results
81
+
82
+ A DSH error before its input commit returns `failed`, native stop reason `error`, and result `<code>: <message>` verbatim from the durable `turn/end`.
83
+
84
+ See [Lane without a TUI](docs/LANE-WITHOUT-TUI.md) for the daemon launch contract.
package/bin.mjs ADDED
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { install, remove } from "./install.mjs";
3
+
4
+ const profiles = [];
5
+ let product;
6
+ let removing = false;
7
+ try {
8
+ for (let index = 2; index < process.argv.length; index++) {
9
+ if (process.argv[index] === "--remove") removing = true;
10
+ else if (process.argv[index] !== "--product") profiles.push(process.argv[index]);
11
+ else if ((product = process.argv[++index]) === undefined) throw new Error("--product requires a value");
12
+ }
13
+ if (removing) remove(profiles);
14
+ else install(profiles, { product });
15
+ } catch (error) {
16
+ process.stderr.write(`sessionbus-dsh-install: ${error.message}\n`);
17
+ process.exitCode = error.exitCode ?? 1;
18
+ }
@@ -0,0 +1,7 @@
1
+ # Lane without a TUI
2
+
3
+ The Sessionbus daemon starts a DSH lane directly; dashi is not involved. Its
4
+ `sessionbus-dsh` product command executes the package-owned `sessionbus-dsh` bin
5
+ with `SESSIONBUS_LAUNCH_TOKEN` set and supplies no CLI task. The bin selects
6
+ `dsh --profile sessionbus`; the profile's `product: sessionbus-dsh` row identifies
7
+ the launched product and its resident base-only DSH app.
package/install.mjs ADDED
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { createRequire } from "node:module";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ const marker = "# sessionbus-dsh-install owned";
11
+ const profilePatch = (product) => `- id: system-prompt ${marker}
12
+ config:
13
+ persona: >-
14
+ You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
15
+ - id: session-title-llm ${marker}
16
+ disabled: true
17
+ - id: permission ${marker}
18
+ config:
19
+ presets:
20
+ read-only: { sandbox: read-only, approval: ask }
21
+ workspace-write: { sandbox: workspace-write, approval: ask }
22
+ workspace-write-noninteractive: { sandbox: workspace-write, approval: never }
23
+ danger-full-access: { sandbox: danger-full-access, approval: never }
24
+
25
+ - insert:
26
+ - { id: workspace, name: '@deepseek-ai/dsh-workspace' } ${marker}
27
+ - { id: file-uploads-none, name: '@antst/dsh-file-uploads-none' } ${marker}
28
+ - { id: session-controller, name: '@deepseek-ai/dsh-api-session-controller' } ${marker}
29
+ - id: sessionbus ${marker}
30
+ name: '@sessionbus/dsh'
31
+ config: { mode: lane, product: ${product} }
32
+ `;
33
+ const peerPatch = (product) => `- insert:
34
+ - { id: sessionbus, name: '@sessionbus/dsh', config: { product: ${product} } } ${marker}
35
+ `;
36
+ const noUploadsPatch = `- insert:
37
+ - { id: file-uploads-none, name: '@antst/dsh-file-uploads-none' } ${marker}
38
+ `;
39
+ const rowID = (name) => new RegExp(`(?:^\\s*-\\s*|[{,]\\s*)['"]?id['"]?\\s*:\\s*['"]?${name}['"]?(?=\\s|[,}]|$)`, "mu");
40
+ const sessionbusID = /(?:^\s*-\s*|[{,]\s*)['"]?id['"]?\s*:\s*['"]?sessionbus['"]?(?=\s|[,}]|$)/mu;
41
+ const noUploadsID = /(?:^\s*-\s*|[{,]\s*)['"]?id['"]?\s*:\s*['"]?file-uploads-none['"]?(?=\s|[,}]|$)/mu;
42
+ const productPattern = /^[a-z0-9][a-z0-9-]{0,31}$/u;
43
+ const ownedRows = ["system-prompt", "session-title-llm", "permission", "workspace", "file-uploads-none", "session-controller", "sessionbus"].map(name => [name, rowID(name)]);
44
+ const ownedIDs = ownedRows.map(([, id]) => id);
45
+ const peerRows = (name) => [ownedRows.at(-1), ...(name === "web" ? [] : [ownedRows[4]])];
46
+ const refusal = (message) => Object.assign(new Error(message), { exitCode: 2 });
47
+ function writeChanged(file, body) {
48
+ if (!existsSync(file) || readFileSync(file, "utf8") !== body) writeFileSync(file, body);
49
+ }
50
+
51
+ function convergePatch(file, id, patch) {
52
+ const old = existsSync(file) ? readFileSync(file, "utf8") : "";
53
+ if (id.test(old)) return writeChanged(file, markOwned(old, id));
54
+ const empty = /^(?:\s*#.*\n)*\s*\[\]\s*$/u.test(old);
55
+ writeChanged(file, `${empty ? "" : old.trimEnd() + (old.trim() ? "\n\n" : "")}${patch}`);
56
+ }
57
+
58
+ function markOwned(body, id) {
59
+ return body.split("\n").map(line => id.test(line) && !line.includes(marker) ? `${line} ${marker}` : line).join("\n");
60
+ }
61
+
62
+ function withProduct(body, product) {
63
+ const lines = body.split("\n");
64
+ const row = lines.findIndex((line) => sessionbusID.test(line));
65
+ if (row < 0) return body;
66
+ const value = /(['"]?product['"]?\s*:\s*)(?:'[^']*'|"[^"]*"|[^,\s}]+)/u;
67
+ const column = lines[row].search(/['"]?id['"]?\s*:/u);
68
+ const open = lines[row].lastIndexOf("{", column);
69
+ if (open >= 0) {
70
+ if (value.test(lines[row])) lines[row] = lines[row].replace(value, `$1${product}`);
71
+ else if (/['"]?config['"]?\s*:\s*\{/u.test(lines[row])) lines[row] = lines[row].replace(/(['"]?config['"]?\s*:\s*\{\s*)/u, `$1product: ${product}, `);
72
+ else {
73
+ let depth = 0, close = -1;
74
+ for (let index = open; index < lines[row].length; index++) {
75
+ if (lines[row][index] === "{") depth++;
76
+ if (lines[row][index] === "}" && --depth === 0) { close = index; break; }
77
+ }
78
+ lines[row] = `${lines[row].slice(0, close)}, config: { product: ${product} }${lines[row].slice(close)}`;
79
+ }
80
+ return lines.join("\n");
81
+ }
82
+ const indent = lines[row].match(/^\s*/u)[0].length;
83
+ let end = row + 1;
84
+ while (end < lines.length && !new RegExp(`^\\s{0,${indent}}-\\s+`, "u").test(lines[end])) end++;
85
+ for (let index = row; index < end; index++) {
86
+ if (value.test(lines[index])) { lines[index] = lines[index].replace(value, `$1${product}`); return lines.join("\n"); }
87
+ }
88
+ const config = lines.slice(row, end).findIndex((line) => /^\s*['"]?config['"]?\s*:/u.test(line));
89
+ if (config >= 0) {
90
+ const index = row + config;
91
+ if (lines[index].includes("{")) lines[index] = lines[index].replace(/\{\s*/u, `{ product: ${product}, `);
92
+ else lines.splice(index + 1, 0, `${lines[index].match(/^\s*/u)[0]} product: ${product}`);
93
+ } else {
94
+ if (end === lines.length && lines.at(-1) === "") end--;
95
+ lines.splice(end, 0, `${" ".repeat(indent + 2)}config: { product: ${product} }`);
96
+ }
97
+ return lines.join("\n");
98
+ }
99
+
100
+ function convergeSessionbus(file, product) {
101
+ const old = existsSync(file) ? readFileSync(file, "utf8") : "";
102
+ if (sessionbusID.test(old)) return writeChanged(file, withProduct(markOwned(old, sessionbusID), product));
103
+ convergePatch(file, sessionbusID, peerPatch(product));
104
+ }
105
+
106
+ function packageDir(anchor, name) {
107
+ if (!anchor) return;
108
+ for (const search of createRequire(anchor).resolve.paths(name) ?? []) {
109
+ const candidate = path.join(search, name);
110
+ if (existsSync(path.join(candidate, "package.json"))) return candidate;
111
+ }
112
+ }
113
+
114
+ function installAnchor() {
115
+ for (const directory of String(process.env.PATH || "").split(path.delimiter)) {
116
+ const command = path.resolve(directory, process.platform === "win32" ? "dsh.cmd" : "dsh");
117
+ const installed = existsSync(command) && packageDir(command, "@deepseek-ai/dsh");
118
+ if (installed) return path.join(installed, "package.json");
119
+ }
120
+ }
121
+
122
+ function bundleConflict(manifest, manifestFile, wanted, anchor) {
123
+ // Match dsh-app-boot profile.ts:751-798: installation anchor, profile anchor, then dsh.bundle.patch.
124
+ for (const bundle of manifest?.dsh?.profile?.bundles ?? []) {
125
+ const directory = packageDir(anchor, bundle) || packageDir(manifestFile, bundle);
126
+ if (!directory) continue;
127
+ const declared = JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8")).dsh?.bundle?.patch;
128
+ if (typeof declared !== "string") continue;
129
+ const body = readFileSync(path.join(directory, declared), "utf8").split("\n").filter(line => !line.trimStart().startsWith("#")).join("\n");
130
+ const row = wanted.find(([, id]) => id.test(body));
131
+ if (row) return { bundle, row: row[0] };
132
+ }
133
+ }
134
+
135
+ function withoutOwnedRows(body) {
136
+ const lines = body.split(/(?<=\n)/u);
137
+ for (let row = lines.length - 1; row >= 0; row--) {
138
+ const text = lines[row].replace(/\r?\n$/u, "");
139
+ if (!text.includes(marker) || !ownedIDs.some((id) => id.test(text))) continue;
140
+ const indent = text.match(/^\s*/u)[0].length;
141
+ let end = row + 1;
142
+ if (!text.includes("{")) {
143
+ while (end < lines.length) {
144
+ const next = lines[end].replace(/\r?\n$/u, "");
145
+ const nextIndent = next.match(/^\s*/u)[0].length;
146
+ if ((/^\s*-\s+/u.test(next) || /^\s*#/u.test(next)) && nextIndent <= indent) break;
147
+ end++;
148
+ }
149
+ }
150
+ lines.splice(row, end - row);
151
+ }
152
+ for (let row = lines.length - 1; row >= 0; row--) {
153
+ const match = /^(\s*)-\s+insert:\s*(?:#.*)?(?:\r?\n)?$/u.exec(lines[row]);
154
+ if (!match) continue;
155
+ let child = row + 1;
156
+ while (child < lines.length && /^\s*(?:#.*)?(?:\r?\n)?$/u.test(lines[child])) child++;
157
+ if (child === lines.length || lines[child].match(/^\s*/u)[0].length <= match[1].length) {
158
+ if (row > 0 && /^\s*(?:\r?\n)?$/u.test(lines[row - 1])) lines.splice(row - 1, 2);
159
+ else lines.splice(row, 1);
160
+ }
161
+ }
162
+ const result = lines.join("");
163
+ return result.trim() ? result : "[]\n";
164
+ }
165
+
166
+ function validProfile(name) {
167
+ return name && !name.includes("/") && !name.includes("\\") && name !== "." && name !== ".." && name !== "node_modules";
168
+ }
169
+
170
+ export function install(profileNames = [], options = {}) {
171
+ const home = options.home || process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
172
+ const root = options.root || path.dirname(fileURLToPath(import.meta.url));
173
+ const run = options.run || ((args) => spawnSync("dsh", args, { cwd: root, encoding: "utf8" }));
174
+ const names = [...new Set(profileNames.length ? profileNames : ["sessionbus"])];
175
+ const laneProduct = options.product ?? "sessionbus-dsh";
176
+ if (options.product !== undefined && (typeof options.product !== "string" || !productPattern.test(options.product))) throw new Error(`invalid product ${JSON.stringify(options.product)}; expected ^[a-z0-9][a-z0-9-]{0,31}$`);
177
+ if (names.includes("sessionbus") && !["sessionbus-dsh", "dashi"].includes(laneProduct)) throw new Error("the sessionbus profile product must be sessionbus-dsh or dashi");
178
+ if (names.some((name) => name !== "sessionbus") && options.product === undefined) throw new Error("--product is required for peer profiles");
179
+ const anchor = options.installAnchor || installAnchor();
180
+ for (const name of names) {
181
+ if (!validProfile(name)) throw new Error(`invalid profile name ${JSON.stringify(name)}`);
182
+ const profile = path.join(home, "profiles", name);
183
+ const manifestFile = path.join(profile, "package.json");
184
+ if (!existsSync(manifestFile)) continue;
185
+ const conflict = bundleConflict(JSON.parse(readFileSync(manifestFile, "utf8")), manifestFile, peerRows(name), anchor);
186
+ if (conflict) throw refusal(`profile ${JSON.stringify(name)} bundle ${JSON.stringify(conflict.bundle)} already provides row ${JSON.stringify(conflict.row)}`);
187
+ }
188
+ for (const name of names) {
189
+ const profile = path.join(home, "profiles", name);
190
+ const manifestFile = path.join(profile, "package.json");
191
+ const existed = existsSync(manifestFile);
192
+ let manifest = existed ? JSON.parse(readFileSync(manifestFile, "utf8")) : null;
193
+ if (!manifest?.dependencies?.["@sessionbus/dsh"]) {
194
+ const result = run(["plugin", "--profile", name, "add", "@sessionbus/dsh"]);
195
+ if (result.status !== 0) throw new Error(String(result.stderr || "dsh plugin add failed").trim());
196
+ manifest = JSON.parse(readFileSync(manifestFile, "utf8"));
197
+ }
198
+ if (name !== "sessionbus") {
199
+ const patch = path.join(profile, "cordis.patch.yml");
200
+ convergeSessionbus(patch, options.product);
201
+ if (name !== "web") convergePatch(patch, noUploadsID, noUploadsPatch);
202
+ continue;
203
+ }
204
+ if (!existed) manifest = {
205
+ name: "dsh-profile-sessionbus", private: true,
206
+ dependencies: { "@sessionbus/dsh": manifest.dependencies["@sessionbus/dsh"] },
207
+ dsh: { profile: { bundles: ["@deepseek-ai/dsh-base"], patchReload: "startup" } },
208
+ };
209
+ else {
210
+ manifest.dsh ??= {};
211
+ manifest.dsh.profile ??= {};
212
+ manifest.dsh.profile.bundles ??= [];
213
+ if (!Array.isArray(manifest.dsh.profile.bundles)) throw new Error("sessionbus profile bundles must be an array");
214
+ if (!manifest.dsh.profile.bundles.includes("@deepseek-ai/dsh-base")) manifest.dsh.profile.bundles.push("@deepseek-ai/dsh-base");
215
+ }
216
+ writeChanged(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
217
+ writeChanged(path.join(profile, "cordis.patch.yml"), profilePatch(laneProduct));
218
+ }
219
+ }
220
+
221
+ export function remove(profileNames = [], options = {}) {
222
+ if (profileNames.length === 0) throw new Error("--remove requires at least one profile");
223
+ const home = options.home || process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
224
+ const run = options.run || ((args, cwd) => spawnSync("pnpm", args, { cwd, encoding: "utf8" }));
225
+ const names = [...new Set(profileNames)];
226
+ for (const name of names) {
227
+ if (!validProfile(name)) throw new Error(`invalid profile name ${JSON.stringify(name)}`);
228
+ const patch = path.join(home, "profiles", name, "cordis.patch.yml");
229
+ if (!existsSync(patch)) continue;
230
+ const lines = readFileSync(patch, "utf8").split("\n");
231
+ const rows = name === "sessionbus" ? ownedRows : peerRows(name);
232
+ const unowned = rows.find(([, id]) => lines.some(line => id.test(line) && !line.includes(marker)));
233
+ if (unowned) throw refusal(`profile ${JSON.stringify(name)} row ${JSON.stringify(unowned[0])} has no sessionbus-dsh-install ownership marker`);
234
+ }
235
+ for (const name of names) {
236
+ const profile = path.join(home, "profiles", name);
237
+ const result = run(["remove", "@sessionbus/dsh"], profile);
238
+ if (result.status !== 0) throw new Error(String(result.stderr || result.error?.message || "pnpm remove failed").trim());
239
+ const patch = path.join(profile, "cordis.patch.yml");
240
+ if (existsSync(patch)) writeChanged(patch, withoutOwnedRows(readFileSync(patch, "utf8")));
241
+ }
242
+ }
package/launcher.mjs ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // Signal and exit forwarding mirrors @antst/dashi-launcher's bin/dashi.js.
3
+ import { spawn } from "node:child_process";
4
+
5
+ const args = process.env.SESSIONBUS_LAUNCH_TOKEN === undefined
6
+ ? process.argv.slice(2)
7
+ : ["--profile", "sessionbus", ...process.argv.slice(2)];
8
+ const child = spawn("dsh", args, { stdio: "inherit" });
9
+ const signals = ["SIGINT", "SIGTERM"];
10
+ const forwards = signals.map((signal) => [signal, () => child.kill(signal)]);
11
+ for (const [signal, forward] of forwards) process.on(signal, forward);
12
+
13
+ let failed = false;
14
+ child.on("error", (error) => {
15
+ failed = true;
16
+ const missing = error.code === "ENOENT";
17
+ process.stderr.write(missing
18
+ ? "sessionbus-dsh: dsh not found; install it with 'npm install --global @deepseek-ai/dsh'\n"
19
+ : `sessionbus-dsh: could not start dsh: ${error.message}\n`);
20
+ process.exitCode = missing ? 127 : 1;
21
+ });
22
+ child.on("close", (code, signal) => {
23
+ for (const [name, forward] of forwards) process.off(name, forward);
24
+ if (failed) return;
25
+ if (code !== null) process.exitCode = code;
26
+ else if (signal !== null) process.kill(process.pid, signal);
27
+ });
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@sessionbus/dsh",
3
+ "version": "0.1.0-pre.10",
4
+ "type": "commonjs",
5
+ "main": "plugin.cjs",
6
+ "exports": "./plugin.cjs",
7
+ "bin": {
8
+ "sessionbus-dsh": "launcher.mjs",
9
+ "sessionbus-dsh-install": "bin.mjs"
10
+ },
11
+ "files": ["README.md", "docs/LANE-WITHOUT-TUI.md", "plugin.cjs", "launcher.mjs", "bin.mjs", "install.mjs"],
12
+ "scripts": {
13
+ "test": "node --test plugin.test.cjs install.test.mjs launcher.test.mjs package.test.cjs rename.test.mjs"
14
+ },
15
+ "dependencies": {
16
+ "@antst/dsh-file-uploads-none": "0.1.0-alpha.18",
17
+ "@sessionbus/kit": "0.5.5"
18
+ },
19
+ "peerDependencies": {
20
+ "@deepseek-ai/cordis": "^4.0.2",
21
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.3",
22
+ "@deepseek-ai/dsh-agent": ">=0.1.5-rc.2",
23
+ "@deepseek-ai/dsh-api-session-controller": ">=0.1.5-rc.2",
24
+ "@deepseek-ai/dsh-cmdline": ">=0.1.5-rc.2",
25
+ "@deepseek-ai/dsh-commands": ">=0.1.5-rc.2",
26
+ "@deepseek-ai/dsh-launch-environment": ">=0.1.5-rc.2",
27
+ "@deepseek-ai/dsh-llm": ">=0.1.5-rc.2",
28
+ "@deepseek-ai/dsh-permission-presets": ">=0.1.5-rc.2",
29
+ "@deepseek-ai/dsh-session": ">=0.1.5-rc.2",
30
+ "@deepseek-ai/dsh-session-title": ">=0.1.5-rc.2",
31
+ "@deepseek-ai/dsh-tools": ">=0.1.5-rc.2",
32
+ "@deepseek-ai/dsh-workspace": ">=0.1.5-rc.2"
33
+ },
34
+ "publishConfig": { "access": "public" },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/antst/sessionbus-dsh.git"
38
+ }
39
+ }
package/plugin.cjs ADDED
@@ -0,0 +1,409 @@
1
+ "use strict";
2
+
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+ const kit = require("@sessionbus/kit");
6
+ const version = require("./package.json").version;
7
+ const { ACTIONS } = kit;
8
+
9
+ // Match the MCP tool's closed union; the public kit validates each action.
10
+ function argumentSchema() {
11
+ const properties = {};
12
+ for (const field of ["session_id", "host", "message", "target", "group", "product", "name", "resume_session_id", "notify_target", "input", "run_id"]) properties[field] = { type: "string" };
13
+ for (const field of ["targets", "extra_groups"]) properties[field] = { type: "array", items: { type: "string" } };
14
+ for (const field of ["persistent", "notify", "forget"]) properties[field] = { type: "boolean" };
15
+ for (const field of ["auto_close_ms", "timeout_ms"]) properties[field] = { type: "integer" };
16
+ properties.idle_message = { type: "string", enum: ["stage", "run"] };
17
+ properties.trace = { type: "string", enum: ["off", "events", "content"] };
18
+ properties.mode = { type: "string", enum: ["off", "events", "content"] };
19
+ const open = {};
20
+ for (const field of ["cwd", "permission_mode", "model", "reasoning_effort"]) open[field] = { type: "string" };
21
+ open.arguments = { type: "array", items: { type: "string" } };
22
+ properties.open = { type: "object", additionalProperties: false, properties: open };
23
+ return { type: "object", additionalProperties: false, properties, description: "Use only the fields listed for the selected action in the tool description. send has no summary field; put the complete content in message." };
24
+ }
25
+
26
+ function assertKnownArguments(value, schema, location = "arguments") {
27
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`sessionbus ${location} must be an object`);
28
+ for (const field of Object.keys(value)) {
29
+ if (!Object.hasOwn(schema.properties, field)) throw new Error(`sessionbus ${location}.${field} is not supported`);
30
+ const property = schema.properties[field];
31
+ if (property.type === "object") assertKnownArguments(value[field], property, `${location}.${field}`);
32
+ }
33
+ }
34
+
35
+ const name = "sessionbus-dsh";
36
+ const productPattern = /^[a-z0-9][a-z0-9-]{0,31}$/u;
37
+ const inject = [
38
+ "agents", "appReady", "appExit", "commands", "permissionPresets",
39
+ "sessionController", "sessions", "sessionTitle", "tools",
40
+ ];
41
+ function deferred() {
42
+ let resolve, reject;
43
+ const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
44
+ return { promise, resolve, reject };
45
+ }
46
+
47
+ function text(value) {
48
+ return typeof value === "string" && value.length > 0 && value.trim() === value && !/[\0\r\n]/u.test(value);
49
+ }
50
+
51
+ function clean(error) {
52
+ return String(error?.message || error || "DSH plugin failed").replace(/[\0\r\n]/gu, " ").slice(0, 4096);
53
+ }
54
+
55
+ function readConfiguration(ctx, config = {}, ambient = process.env) {
56
+ if (config.product === undefined) throw new Error("product is required; re-run sessionbus-dsh-install --product <name> <profile>");
57
+ if (typeof config.product !== "string" || !productPattern.test(config.product)) throw new Error("product must match ^[a-z0-9][a-z0-9-]{0,31}$");
58
+ const launch = ctx.launchEnvironment || ctx.get?.("launchEnvironment");
59
+ const value = (key) => {
60
+ if (launch) return launch.get(key)?.value;
61
+ return Object.hasOwn(ambient, key) ? ambient[key] : undefined;
62
+ };
63
+ const token = value("SESSIONBUS_LAUNCH_TOKEN");
64
+ delete process.env.SESSIONBUS_LAUNCH_TOKEN;
65
+ const mode = token === undefined ? "peer" : "lane";
66
+ if (config.mode !== undefined && config.mode !== mode) throw new Error(`mode ${config.mode} conflicts with launch environment`);
67
+ let groups = mode === "lane" ? [] : Object.hasOwn(config, "groups") ? config.groups : JSON.parse(value("SESSIONBUS_GROUPS") || "[]");
68
+ if (!Array.isArray(groups) || groups.some((group) => !text(group)) || new Set(groups).size !== groups.length) throw new Error("groups are invalid");
69
+ const socket = Object.hasOwn(config, "socket") ? config.socket : value("SESSIONBUS_SOCKET") || path.join(value("XDG_STATE_HOME") || path.join(value("HOME") || os.homedir(), ".local/state"), "sessionbus/run/presence.sock");
70
+ const localKey = Object.hasOwn(config, "local_key") ? config.local_key : value("SESSIONBUS_LOCAL_KEY");
71
+ if (!text(socket) || localKey !== undefined && !text(localKey)) throw new Error("connection settings are invalid");
72
+ return { settings: { mode, product: config.product, groups: [...groups], socket, localKey }, token };
73
+ }
74
+
75
+ function captureContext(ctx) {
76
+ const services = Object.fromEntries(inject.map((service) => [service, ctx[service]]));
77
+ let launchEnvironment;
78
+ try { launchEnvironment = ctx.launchEnvironment || ctx.get?.("launchEnvironment"); } catch {}
79
+ const fiber = ctx.fiber;
80
+ return {
81
+ active: () => fiber?.uid !== null,
82
+ context: {
83
+ ...services,
84
+ launchEnvironment,
85
+ on: ctx.on.bind(ctx),
86
+ effect: ctx.effect.bind(ctx),
87
+ },
88
+ };
89
+ }
90
+
91
+ function settings(ctx, config = {}, ambient = process.env) {
92
+ return readConfiguration(ctx, config, ambient).settings;
93
+ }
94
+
95
+ function connectionEnvironment(config, token) {
96
+ return {
97
+ SESSIONBUS_SOCKET: config.socket,
98
+ ...(config.localKey === undefined ? {} : { SESSIONBUS_LOCAL_KEY: config.localKey }),
99
+ ...(token === undefined ? {} : { SESSIONBUS_LAUNCH_TOKEN: token }),
100
+ };
101
+ }
102
+
103
+ function nativeName(value) {
104
+ const at = value.lastIndexOf("@");
105
+ return at < 0 ? value : value.slice(0, at);
106
+ }
107
+
108
+ function textOf(message) {
109
+ return Array.isArray(message?.content) ? message.content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("") : "";
110
+ }
111
+
112
+ function terminal(reason) {
113
+ const native_stop_reason = reason?.kind;
114
+ if (native_stop_reason === "completed") return { outcome: "completed", native_stop_reason };
115
+ if (native_stop_reason === "aborted" || native_stop_reason === "interrupted") return { outcome: "interrupted", native_stop_reason };
116
+ if (["blocked", "error", "max-tokens"].includes(native_stop_reason)) return { outcome: "failed", native_stop_reason };
117
+ throw new Error(`unknown DSH turn end reason ${JSON.stringify(reason)}`);
118
+ }
119
+
120
+ class NativeSession {
121
+ constructor(ctx, createUserMessage) {
122
+ this.ctx = ctx;
123
+ this.createUserMessage = createUserMessage;
124
+ this.receipts = new Map();
125
+ this.removeEvents = ctx.on("session/event", (session, event) => this.event(session, event), { global: true });
126
+ }
127
+
128
+ async open(request) {
129
+ const options = request.open || {};
130
+ let resolved;
131
+ if (request.resume_session_id) {
132
+ resolved = await this.ctx.sessionController.resolveAgent(request.resume_session_id);
133
+ } else {
134
+ const created = await this.ctx.sessionController.create(options.cwd ? { cwd: options.cwd } : {});
135
+ resolved = await this.ctx.sessionController.resolveAgent(created.sessionId);
136
+ }
137
+ if (resolved.error) throw resolved.error;
138
+ const agent = resolved.agent;
139
+ const expected = request.resume_session_id || agent.id;
140
+ if (agent.id !== expected || agent.session.id !== expected) throw new Error("DSH resolved a different native session");
141
+ if (options.cwd !== undefined && agent.session.header.cwd !== options.cwd) throw new Error("DSH resolved a different native cwd");
142
+ await this.ctx.sessionController.rename({ sessionId: agent.id, title: nativeName(request.name) });
143
+ if (options.model !== undefined || options.reasoning_effort !== undefined) {
144
+ const model = options.model || `${agent.options?.provider || ""}/${agent.options?.model || ""}`;
145
+ const slash = model.indexOf("/");
146
+ if (slash < 1 || slash === model.length - 1) throw new Error(`unsupported value model=${model}`);
147
+ await this.ctx.sessionController.selectModel({ sessionId: agent.id, provider: model.slice(0, slash), model: model.slice(slash + 1), ...(options.reasoning_effort === undefined ? {} : { reasoningEffort: options.reasoning_effort }) });
148
+ }
149
+ if (options.permission_mode !== undefined) {
150
+ if (!this.ctx.permissionPresets.names.includes(options.permission_mode)) throw new Error(`unsupported value permission_mode=${options.permission_mode}`);
151
+ this.ctx.permissionPresets.set(agent.session, options.permission_mode);
152
+ }
153
+ await this.ctx.sessions.flush(agent.session);
154
+ this.agent = agent;
155
+ return { session_id: agent.id };
156
+ }
157
+
158
+ message(body) {
159
+ return this.createUserMessage({ content: [{ type: "text", text: body }], source: { kind: "plugin", plugin: name, form: "relay" } });
160
+ }
161
+
162
+ receipt(message, session, cancel) {
163
+ if (cancel?.aborted) throw cancel.reason || new Error("cancelled");
164
+ const accepted = { ...deferred(), session };
165
+ const abort = () => accepted.reject(cancel.reason || new Error("cancelled"));
166
+ cancel?.addEventListener("abort", abort, { once: true });
167
+ accepted.promise.catch(() => {});
168
+ accepted.close = () => {
169
+ cancel?.removeEventListener("abort", abort);
170
+ if (this.receipts.get(message.id) === accepted) this.receipts.delete(message.id);
171
+ };
172
+ this.receipts.set(message.id, accepted);
173
+ return accepted;
174
+ }
175
+
176
+ event(session, event) {
177
+ if (event.type === "agent/inbox/spliced") {
178
+ for (const message of event.data.inserted || []) {
179
+ const receipt = this.receipts.get(message.id);
180
+ if (receipt?.session === session) receipt.resolve();
181
+ }
182
+ }
183
+ const run = this.active;
184
+ if (!run || session !== this.agent?.session) return;
185
+ if (event.type === "turn/start") run.openTurn = event.data.turn;
186
+ if (event.type === "user/message" && event.data.id === run.message.id) {
187
+ if (!Number.isSafeInteger(run.openTurn)) {
188
+ run.endError = new Error("DSH consumed input outside a turn");
189
+ return;
190
+ }
191
+ run.turn = run.openTurn;
192
+ }
193
+ if (event.type === "assistant/message" && run.turn === event.data.turn) run.output += textOf(event.data.message);
194
+ const earlyError = event.type === "turn/end" && run.turn === null && run.openTurn === event.data.turn && event.data.reason?.kind === "error";
195
+ if (event.type === "turn/end" && (run.turn === event.data.turn || earlyError)) {
196
+ try {
197
+ run.end = terminal(event.data.reason);
198
+ if (earlyError) run.output = `${event.data.reason.error.code}: ${event.data.reason.error.message}`;
199
+ }
200
+ catch (error) { run.endError = error; }
201
+ }
202
+ }
203
+
204
+ async run(cancel, token, input) {
205
+ if (token.Interrupted()) return { outcome: "interrupted", result: "" };
206
+ // @sessionbus/kit sdk/js/index.js:108-120 passes its text or delivery seed here.
207
+ const delivery = typeof input?.delivery?.body === "string" ? input.delivery : undefined;
208
+ const body = typeof input === "string" ? input : typeof input?.text === "string" ? input.text : delivery?.body;
209
+ if (body === undefined) throw new Error("sessionbus received an unexpected run input seed shape");
210
+ const message = this.message(body);
211
+ const record = { message, openTurn: null, turn: null, output: "", cancelled: deferred() };
212
+ const receipt = this.receipt(message, this.agent.session, cancel);
213
+ this.active = record;
214
+ token.Native = record;
215
+ record.cancelled.promise.catch(() => {});
216
+ const abort = () => { record.cancelled.reject(cancel.reason || new Error("cancelled")); this.receipts.get(message.id)?.reject(cancel.reason || new Error("cancelled")); };
217
+ cancel.addEventListener("abort", abort, { once: true });
218
+ try {
219
+ try { this.agent.followup(message); }
220
+ catch (error) { receipt.reject(error); throw error; }
221
+ await receipt.promise;
222
+ if (delivery) await token.ReportDelivery({ disposition: "injected" });
223
+ const idle = this.agent.whenIdle();
224
+ idle.catch(() => {});
225
+ await Promise.race([idle, record.cancelled.promise]);
226
+ if (record.endError) throw record.endError;
227
+ if (record.end) return { ...record.end, result: record.output };
228
+ if (record.cancelReason) return { outcome: "interrupted", native_stop_reason: record.cancelReason, result: record.output };
229
+ throw new Error("DSH reached idle without turn/end");
230
+ } finally {
231
+ receipt.close();
232
+ cancel.removeEventListener("abort", abort);
233
+ if (this.active === record) this.active = null;
234
+ if (token.Native === record) token.Native = null;
235
+ }
236
+ }
237
+
238
+ interrupt(_cancel, token) {
239
+ if (token.Native) {
240
+ token.Native.cancelReason = "aborted:user";
241
+ this.agent.cancel({ kind: "user" }, { keepInbox: true });
242
+ }
243
+ }
244
+
245
+ async deliver(cancel, request, agent = this.agent) {
246
+ const message = this.message(request.body);
247
+ if (agent.status === "running") {
248
+ const receipt = this.receipt(message, agent.session, cancel);
249
+ try {
250
+ try { agent.steer(message); }
251
+ catch (error) { receipt.reject(error); throw error; }
252
+ await receipt.promise;
253
+ } finally { receipt.close(); }
254
+ } else {
255
+ const event = await agent.session.append("user/message", message, { surfaceOp: "append" });
256
+ if (event?.type !== "user/message" || event.data?.id !== message.id) throw new Error("DSH did not commit the delivered message");
257
+ }
258
+ return { disposition: "injected" };
259
+ }
260
+
261
+ async close() {
262
+ if (!this.agent) return;
263
+ if (this.agent.status === "running") this.agent.cancel({ kind: "disposed" });
264
+ await this.agent.whenIdle();
265
+ await this.ctx.sessions.flush(this.agent.session);
266
+ this.removeEvents();
267
+ }
268
+ }
269
+
270
+ function identity(ctx, agent, product, groups, title) {
271
+ const sessionID = String(agent?.session?.id || "");
272
+ if (!text(sessionID)) return;
273
+ const cwd = agent?.session?.header?.cwd;
274
+ if (!text(cwd)) throw new Error("DSH root identity is incomplete");
275
+ const current = title === undefined ? ctx.sessionTitle.get(agent.session)?.title : title;
276
+ const provider = agent.options?.provider;
277
+ const model = agent.options?.model;
278
+ return { product, session_id: sessionID, ...(text(current) ? { name: current } : {}), groups: [...groups], info: { cwd, ...(text(provider) && text(model) ? { model: `${provider}/${model}` } : {}) } };
279
+ }
280
+
281
+ function createRuntime(ctx, config, dependencies, prepared) {
282
+ const configured = prepared || readConfiguration(ctx, config, dependencies.ambient);
283
+ const values = configured.settings;
284
+ const active = dependencies.active || (() => true);
285
+ let launchToken = configured.token;
286
+ const native = new NativeSession(ctx, dependencies.createUserMessage);
287
+ const peers = new Map();
288
+ let worker;
289
+ let workerExit = Promise.resolve();
290
+ let ready = false;
291
+ let warned = false;
292
+ const warn = (error) => { if (active() && !warned) { warned = true; dependencies.stderr(`sessionbus: ${clean(error)}\n`); } };
293
+ const root = (agent) => ctx.agents.roots().includes(agent);
294
+ const present = (agent) => {
295
+ if (values.mode !== "peer" || !ready || !root(agent) || peers.has(agent)) return;
296
+ try {
297
+ const current = identity(ctx, agent, values.product, values.groups);
298
+ if (!current) return;
299
+ const peer = dependencies.connectPeer(current, (cancel, request) => native.deliver(cancel, request, agent), connectionEnvironment(values));
300
+ peers.set(agent, { peer, identity: current, rehello: Promise.resolve() });
301
+ } catch (error) { warn(error); }
302
+ };
303
+ const forget = (agent) => { peers.get(agent)?.peer.shutdown(); peers.delete(agent); };
304
+ let removeCreated = () => {}, removeDisposed = () => {}, removeTitle = () => {};
305
+ if (values.mode === "peer") {
306
+ removeCreated = ctx.on("agent/created", ({ agent }) => present(agent));
307
+ removeDisposed = ctx.on("agent/disposed", ({ agent }) => forget(agent));
308
+ removeTitle = ctx.on("session/event", (session, event) => {
309
+ if (event.type !== "session/title") return;
310
+ const agent = ctx.agents.get(session.id);
311
+ const record = peers.get(agent);
312
+ if (!agent || agent.session !== session) return;
313
+ if (!record) return present(agent);
314
+ const next = identity(ctx, agent, values.product, values.groups, event.data.title);
315
+ if (!next) return;
316
+ const replace = next.session_id !== record.identity.session_id;
317
+ record.identity = next;
318
+ // kit rehello is (signal, name, info); a changed durable id needs a full replacement.
319
+ record.rehello = record.rehello.then(() => replace ? record.peer.replace(next) : record.peer.rehello(undefined, next.name, next.info)).catch(warn);
320
+ }, { global: true });
321
+ }
322
+ const caller = (agent) => worker && native.agent === agent ? worker.caller : peers.get(agent)?.peer.caller;
323
+ const argumentsSchema = argumentSchema();
324
+ const execute = (argumentsValue, execution) => {
325
+ const client = caller(execution?.agent);
326
+ if (!client) throw new Error("sessionbus requires an exact live DSH root");
327
+ const args = argumentsValue.arguments || {};
328
+ assertKnownArguments(args, argumentsSchema);
329
+ return client.action(argumentsValue.action, args);
330
+ };
331
+ let removeGrant;
332
+ try {
333
+ // The peer floor guarantees this waterfall; this catch covers registration errors, not feature detection.
334
+ removeGrant = ctx.on("tools/pre-execute", async (execution, next) =>
335
+ execution.name === "sessionbus" ? { kind: "allow" } : next(), { prepend: true });
336
+ } catch (error) { throw new Error(`cannot grant sessionbus tool permission: ${clean(error)}`); }
337
+ const removeTool = ctx.tools.register(dependencies.defineTool({
338
+ name: "sessionbus",
339
+ description: "List, message, spawn and control Sessionbus sessions. Use trace mode off, events or content to configure live parent tracing for a direct child; spawn trace sets its initial mode.",
340
+ parameters: { action: { type: "string", enum: ACTIONS, required: true }, arguments: argumentsSchema },
341
+ output: { schema: { type: "object", additionalProperties: true, properties: {} }, render: (_args, result) => [{ type: "text", text: JSON.stringify(result) }] },
342
+ execute,
343
+ }));
344
+ const removeCommand = ctx.commands.register({
345
+ name: "sessionbus",
346
+ description: "list sessionbus sessions",
347
+ handler: async (invocation) => ({ kind: "success", text: JSON.stringify(await execute({ action: "list", arguments: {} }, { agent: invocation.agent })) }),
348
+ });
349
+ const start = () => {
350
+ ready = true;
351
+ if (values.mode === "lane") {
352
+ const environment = connectionEnvironment(values, launchToken);
353
+ launchToken = undefined;
354
+ worker = dependencies.serveWorker({
355
+ hello: () => ({ product: values.product, version, supported_open_fields: ["cwd", "permission_mode", "model", "reasoning_effort"], extra_arguments: [] }),
356
+ open: (_cancel, request) => native.open(request),
357
+ run: (cancel, token, input) => native.run(cancel, token, input),
358
+ interrupt: (cancel, token) => native.interrupt(cancel, token),
359
+ deliver: (cancel, request) => native.deliver(cancel, request),
360
+ close: () => native.close(),
361
+ }, environment);
362
+ workerExit = worker.closed.then(async () => {
363
+ const failure = await worker.serving?.catch((error) => error);
364
+ if (!active()) return;
365
+ if (failure && !worker.opened) { warn(failure); ctx.appExit(1); } else ctx.appExit(0);
366
+ });
367
+ } else {
368
+ for (const agent of ctx.agents.roots()) present(agent);
369
+ }
370
+ };
371
+ const removeReady = ctx.appReady.onReady(start);
372
+ const close = () => {
373
+ removeReady(); removeCreated(); removeDisposed(); removeTitle(); removeCommand(); removeGrant(); removeTool();
374
+ for (const agent of peers.keys()) forget(agent);
375
+ worker?.shutdown(); native.removeEvents();
376
+ };
377
+ ctx.effect(() => close, "sessionbus-dsh.lifecycle");
378
+ return { close, execute, native, peers, settings: values, start, get worker() { return worker; }, get workerExit() { return workerExit; } };
379
+ }
380
+
381
+ async function activate(ctx, config = {}, dependencies = {}, prepared) {
382
+ const configured = prepared || readConfiguration(ctx, config, dependencies.ambient);
383
+ const [{ createUserMessage }, { defineTool }] = await Promise.all([
384
+ dependencies.createUserMessage ? { createUserMessage: dependencies.createUserMessage } : (dependencies.importLLM || (() => import("@deepseek-ai/dsh-llm")))(),
385
+ dependencies.defineTool ? { defineTool: dependencies.defineTool } : (dependencies.importTools || (() => import("@deepseek-ai/dsh-tools")))(),
386
+ ]);
387
+ if (dependencies.active && !dependencies.active()) return;
388
+ return createRuntime(ctx, config, { ...kit, active: () => true, stderr: (line) => process.stderr.write(line), ...dependencies, createUserMessage, defineTool }, configured);
389
+ }
390
+
391
+ function apply(ctx, config = {}, dependencies = {}) {
392
+ let captured, prepared;
393
+ try {
394
+ captured = captureContext(ctx);
395
+ prepared = readConfiguration(captured.context, config, dependencies.ambient);
396
+ } catch (error) {
397
+ (dependencies.stderr || ((line) => process.stderr.write(line)))(`sessionbus: ${clean(error)}\n`);
398
+ try { ctx.appExit(1); } catch {}
399
+ return;
400
+ }
401
+ const runtimeDependencies = { stderr: (line) => process.stderr.write(line), ...dependencies, active: captured.active };
402
+ void activate(captured.context, config, runtimeDependencies, prepared).catch((error) => {
403
+ if (!captured.active()) return;
404
+ runtimeDependencies.stderr(`sessionbus: ${clean(error)}\n`);
405
+ captured.context.appExit(1);
406
+ });
407
+ }
408
+
409
+ module.exports = { ACTIONS, NativeSession, activate, apply, connectionEnvironment, createRuntime, identity, inject, name, settings, terminal };