dsh-computers 0.0.0-stage → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PROTOCOL.md +694 -0
- package/README.md +176 -2
- package/agent/README.md +383 -0
- package/agent/dsh-computers-agent.mjs +2808 -0
- package/client.js +3986 -0
- package/cordis.patch.yml +25 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +241 -0
- package/lib/access.js +397 -0
- package/lib/fs-ops.js +162 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +587 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +1084 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +190 -0
- package/lib/tools.js +993 -0
- package/lib/version.js +62 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# dsh-computers: one Host row that registers the computer registry, the
|
|
2
|
+
# agent-facing endpoints, the model-facing tools and the settings page.
|
|
3
|
+
#
|
|
4
|
+
# role:
|
|
5
|
+
# hub - this instance only controls machines (the server); no local runtime card
|
|
6
|
+
# runtime - this instance only exposes itself to a hub (a desktop Mac); no computer list
|
|
7
|
+
# both - default: both cards; the machine can be controlled AND control others
|
|
8
|
+
#
|
|
9
|
+
# The default work layout is one server with role: hub and every other machine
|
|
10
|
+
# left on the default, connecting out to the server. Topology is hub-and-spoke
|
|
11
|
+
# only - a runtime never accepts inbound connections.
|
|
12
|
+
- insert:
|
|
13
|
+
- id: dsh-computers
|
|
14
|
+
name: dsh-computers
|
|
15
|
+
config:
|
|
16
|
+
role: both
|
|
17
|
+
publicUrl: ''
|
|
18
|
+
stateDir: ''
|
|
19
|
+
keyTtlMinutes: 15
|
|
20
|
+
heartbeatMs: 20000
|
|
21
|
+
execTimeoutMs: 120000
|
|
22
|
+
execEnabled: true
|
|
23
|
+
promoteOnTimeout: true
|
|
24
|
+
minAgentVersion: ''
|
|
25
|
+
maxOutputBytes: 1048576
|
package/docs/deploy.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# 部署与重启
|
|
2
|
+
|
|
3
|
+
插件更新后,**Host 端(插件主进程)必须重启**才会加载新代码;浏览器端只需刷新页面(Client 代码会随刷新重新拉取)。
|
|
4
|
+
|
|
5
|
+
重启会断开所有已连接电脑的 SSE 长连接,但它们会自动重连(1s→30s 退避),**不需要重新 enroll**;会话与电脑注册表都是持久化的。
|
|
6
|
+
|
|
7
|
+
日常最省事的做法是把 `dsh web` 交给进程管理器,之后重启只需一条命令。
|
|
8
|
+
|
|
9
|
+
## Linux:systemd(推荐)
|
|
10
|
+
|
|
11
|
+
```ini
|
|
12
|
+
# /etc/systemd/system/dsh-web.service
|
|
13
|
+
[Unit]
|
|
14
|
+
Description=DSH Web
|
|
15
|
+
After=network-online.target
|
|
16
|
+
Wants=network-online.target
|
|
17
|
+
|
|
18
|
+
[Service]
|
|
19
|
+
Type=simple
|
|
20
|
+
User=YOUR_USER
|
|
21
|
+
WorkingDirectory=/home/YOUR_USER
|
|
22
|
+
# 监听对外地址;反向代理场景可用 127.0.0.1:8080,只让代理连进来
|
|
23
|
+
ExecStart=/usr/local/bin/dsh web --host 127.0.0.1 --port 8080
|
|
24
|
+
Restart=always
|
|
25
|
+
RestartSec=3
|
|
26
|
+
# 让 Host 端能读到代理传来的 X-Forwarded-*(本插件据此生成对外地址)
|
|
27
|
+
Environment=NODE_ENV=production
|
|
28
|
+
|
|
29
|
+
[Install]
|
|
30
|
+
WantedBy=multi-user.target
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
sudo systemctl daemon-reload
|
|
35
|
+
sudo systemctl enable --now dsh-web
|
|
36
|
+
sudo systemctl restart dsh-web # ← 以后更新插件后只需要这一条
|
|
37
|
+
journalctl -u dsh-web -f # 看日志
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
> 只给当前用户装可以用 `systemctl --user`,配 `loginctl enable-linger YOUR_USER` 让它开机自启。
|
|
41
|
+
|
|
42
|
+
## macOS:launchd
|
|
43
|
+
|
|
44
|
+
```xml
|
|
45
|
+
<!-- ~/Library/LaunchAgents/com.dsh.web.plist -->
|
|
46
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
47
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
48
|
+
<plist version="1.0"><dict>
|
|
49
|
+
<key>Label</key><string>com.dsh.web</string>
|
|
50
|
+
<key>ProgramArguments</key>
|
|
51
|
+
<array>
|
|
52
|
+
<string>/usr/local/bin/dsh</string><string>web</string>
|
|
53
|
+
<string>--host</string><string>127.0.0.1</string>
|
|
54
|
+
<string>--port</string><string>8080</string>
|
|
55
|
+
</array>
|
|
56
|
+
<key>RunAtLoad</key><true/>
|
|
57
|
+
<key>KeepAlive</key><true/>
|
|
58
|
+
<key>StandardOutPath</key><string>/tmp/dsh-web.log</string>
|
|
59
|
+
<key>StandardErrorPath</key><string>/tmp/dsh-web.log</string>
|
|
60
|
+
</dict></plist>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.dsh.web.plist
|
|
65
|
+
launchctl kickstart -k gui/$(id -u)/com.dsh.web # ← 重启
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Docker
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
docker compose restart dsh-web
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 前台 / tmux(不装管理器)
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Ctrl-C 后重跑同一行;或用 tmux 保留会话
|
|
78
|
+
tmux new -s dsh 'dsh web --host 127.0.0.1 --port 8080'
|
|
79
|
+
tmux attach -t dsh # 之后 Ctrl-C、↑、Enter 即可重启
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 关于「插件市场」里的重启入口
|
|
83
|
+
|
|
84
|
+
插件市场在检测到「需重启后生效」时会提供重启入口,但它**只在自己不是被监督进程时**才自行重启;检测到 systemd 时会把重启交给 systemd(它不能杀掉自己的 cgroup)。所以:
|
|
85
|
+
|
|
86
|
+
- 前台 / tmux 跑 → 用市场的重启入口,或 Ctrl-C 重跑;
|
|
87
|
+
- systemd / launchd / Docker / pm2 托管 → 用上面的 `restart` 命令。
|
|
88
|
+
|
|
89
|
+
## 更新插件的正确姿势
|
|
90
|
+
|
|
91
|
+
profile 里的 git 依赖会被钉到 spec + commit,所以**用同样的 spec 再装一次不会更新**(返回 `already-installed`):
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# 换 spec = 真正更新(不用先卸载)
|
|
95
|
+
dsh plugin install github:9Ashwin/dsh-computers#v1.0.2
|
|
96
|
+
|
|
97
|
+
# 或者装分支,让插件市场的「检查更新」能发现新提交
|
|
98
|
+
dsh plugin install github:9Ashwin/dsh-computers
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
装 tag 的实例永远不会被市场判为"有更新"(tag 本身不动),要升级只能显式换 tag。装分支则可以在市场里一键检查更新。
|
|
102
|
+
|
|
103
|
+
更新完 → 重启 Host(本节上面的命令)→ 刷新浏览器。
|
package/icon.svg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Computers">
|
|
2
|
+
<rect x="4" y="10" width="40" height="27" rx="4" fill="none" stroke="#247bbf" stroke-width="3.5"/>
|
|
3
|
+
<path d="M4 30h40" stroke="#247bbf" stroke-width="3.5" stroke-linecap="round"/>
|
|
4
|
+
<path d="M17 37v5h14v-5" fill="none" stroke="#247bbf" stroke-width="3.5" stroke-linejoin="round"/>
|
|
5
|
+
<rect x="12" y="42" width="24" height="3.5" rx="1.75" fill="#247bbf"/>
|
|
6
|
+
<rect x="50" y="26" width="10" height="26" rx="2.5" fill="none" stroke="#247bbf" stroke-width="3.5"/>
|
|
7
|
+
<circle cx="55" cy="46.5" r="1.6" fill="#247bbf"/>
|
|
8
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-computers — connect the user's own Macs (and other machines) to a
|
|
3
|
+
* DSH Web deployment so the agent can run shell commands on them.
|
|
4
|
+
*
|
|
5
|
+
* This is the Host half: a persisted registry, the SSE hub the machine agents
|
|
6
|
+
* connect to, the browser settings API, and the two model-facing tools. The
|
|
7
|
+
* wire contract lives in PROTOCOL.md; the machine agent is served from
|
|
8
|
+
* `agent/dsh-computers-agent.mjs` and the settings page from `client.js`.
|
|
9
|
+
*
|
|
10
|
+
* Plain ESM, Node builtins only — no `@deepseek-ai/*` imports, so the bundle
|
|
11
|
+
* loads in any profile.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createAccessControl, createAccessStore } from './lib/access.js'
|
|
15
|
+
import { createHub } from './lib/hub.js'
|
|
16
|
+
import { createRoutes, createRuntimeRoutes, createRuntimeStatusRoutes } from './lib/routes.js'
|
|
17
|
+
import { createRuntimeManager } from './lib/runtime.js'
|
|
18
|
+
import { createFsOps } from './lib/fs-ops.js'
|
|
19
|
+
import { createSelectionStore } from './lib/selection.js'
|
|
20
|
+
import { majorMinor } from './lib/version.js'
|
|
21
|
+
import { resolveStateDir, createStore } from './lib/state.js'
|
|
22
|
+
import { createTools } from './lib/tools.js'
|
|
23
|
+
|
|
24
|
+
/** Cordis plugin name. */
|
|
25
|
+
export const name = 'dsh-computers'
|
|
26
|
+
|
|
27
|
+
/** Required host capabilities; `systemPrompt` and `connection` are optional. */
|
|
28
|
+
export const inject = ['tools', 'webServer']
|
|
29
|
+
|
|
30
|
+
/** Version reported by `/api/dsh-computers/health` (and asserted by the smoke harness). */
|
|
31
|
+
export const PLUGIN_VERSION = '1.3.0'
|
|
32
|
+
|
|
33
|
+
/** The machine agent bundle, served at request time (never imported). */
|
|
34
|
+
const AGENT_URL = new URL('./agent/dsh-computers-agent.mjs', import.meta.url)
|
|
35
|
+
|
|
36
|
+
/** Protocol defaults (PROTOCOL §8); `''` means "use the default". */
|
|
37
|
+
const ROLES = Object.freeze(['hub', 'runtime', 'both'])
|
|
38
|
+
|
|
39
|
+
const DEFAULTS = Object.freeze({
|
|
40
|
+
role: 'both',
|
|
41
|
+
publicUrl: '',
|
|
42
|
+
promoteOnTimeout: true,
|
|
43
|
+
minAgentVersion: '',
|
|
44
|
+
stateDir: '',
|
|
45
|
+
keyTtlMinutes: 15,
|
|
46
|
+
heartbeatMs: 20_000,
|
|
47
|
+
execTimeoutMs: 120_000,
|
|
48
|
+
execEnabled: true,
|
|
49
|
+
maxOutputBytes: 1_048_576,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/** Bounds that keep a hand-edited config from producing an unusable host. */
|
|
53
|
+
const LIMITS = Object.freeze({
|
|
54
|
+
keyTtlMinutes: [1, 10_080], // 1 minute .. 7 days
|
|
55
|
+
heartbeatMs: [100, 3_600_000], // 100 ms .. 1 hour
|
|
56
|
+
execTimeoutMs: [100, 86_400_000], // 100 ms .. 24 hours
|
|
57
|
+
maxOutputBytes: [64, 64 * 1024 * 1024], // 64 B .. 64 MiB
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/** Throw a clear TypeError instead of letting a bad value surface as a 500 later. */
|
|
61
|
+
function configError(message) {
|
|
62
|
+
return new TypeError(`dsh-computers: ${message}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Validate one bounded integer option. */
|
|
66
|
+
function boundedInteger(raw, key, fallback) {
|
|
67
|
+
if (raw === undefined || raw === null || raw === '') return fallback
|
|
68
|
+
const value = typeof raw === 'number' ? raw : Number(raw)
|
|
69
|
+
const [min, max] = LIMITS[key]
|
|
70
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
71
|
+
throw configError(`${key} must be an integer between ${min} and ${max} (received ${JSON.stringify(raw)})`)
|
|
72
|
+
}
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Validate the plugin config by hand (schemastery is available only to some
|
|
78
|
+
* host compositions, so this file must not depend on it).
|
|
79
|
+
* @param {unknown} raw - config passed by cordis.patch.yml.
|
|
80
|
+
* @returns {object} resolved config with defaults applied.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveConfig(raw) {
|
|
83
|
+
if (raw === undefined || raw === null) raw = {}
|
|
84
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) throw configError('config must be an object')
|
|
85
|
+
|
|
86
|
+
let publicUrl = DEFAULTS.publicUrl
|
|
87
|
+
if (raw.publicUrl !== undefined && raw.publicUrl !== null && raw.publicUrl !== '') {
|
|
88
|
+
if (typeof raw.publicUrl !== 'string') throw configError('publicUrl must be a string')
|
|
89
|
+
const trimmed = raw.publicUrl.trim().replace(/\/+$/, '')
|
|
90
|
+
let parsed
|
|
91
|
+
try {
|
|
92
|
+
parsed = new URL(trimmed)
|
|
93
|
+
} catch {
|
|
94
|
+
throw configError(`publicUrl must be an absolute http(s) URL (received ${JSON.stringify(raw.publicUrl)})`)
|
|
95
|
+
}
|
|
96
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
97
|
+
throw configError('publicUrl must use http or https')
|
|
98
|
+
}
|
|
99
|
+
publicUrl = trimmed
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let stateDir = DEFAULTS.stateDir
|
|
103
|
+
if (raw.stateDir !== undefined && raw.stateDir !== null && raw.stateDir !== '') {
|
|
104
|
+
if (typeof raw.stateDir !== 'string') throw configError('stateDir must be a string')
|
|
105
|
+
stateDir = raw.stateDir.trim()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let role = DEFAULTS.role
|
|
109
|
+
if (raw.role !== undefined && raw.role !== null) {
|
|
110
|
+
if (typeof raw.role !== 'string' || !ROLES.includes(raw.role)) {
|
|
111
|
+
throw configError(`role must be one of ${ROLES.join(', ')} (received ${JSON.stringify(raw.role)})`)
|
|
112
|
+
}
|
|
113
|
+
role = raw.role
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let promoteOnTimeout = DEFAULTS.promoteOnTimeout
|
|
117
|
+
if (raw.promoteOnTimeout !== undefined && raw.promoteOnTimeout !== null) {
|
|
118
|
+
if (typeof raw.promoteOnTimeout !== 'boolean') throw configError('promoteOnTimeout must be a boolean')
|
|
119
|
+
promoteOnTimeout = raw.promoteOnTimeout
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let minAgentVersion = DEFAULTS.minAgentVersion
|
|
123
|
+
if (raw.minAgentVersion !== undefined && raw.minAgentVersion !== null && raw.minAgentVersion !== '') {
|
|
124
|
+
if (typeof raw.minAgentVersion !== 'string') throw configError('minAgentVersion must be a string')
|
|
125
|
+
const trimmed = raw.minAgentVersion.trim()
|
|
126
|
+
if (!/^v?\d+(\.\d+){0,2}$/.test(trimmed)) {
|
|
127
|
+
throw configError(`minAgentVersion must look like 1.2 or 1.2.3 (received ${JSON.stringify(raw.minAgentVersion)})`)
|
|
128
|
+
}
|
|
129
|
+
minAgentVersion = trimmed
|
|
130
|
+
}
|
|
131
|
+
// Default gate: the plugin's own major.minor (§11.6.5).
|
|
132
|
+
if (minAgentVersion === '') minAgentVersion = majorMinor(PLUGIN_VERSION)
|
|
133
|
+
|
|
134
|
+
let execEnabled = DEFAULTS.execEnabled
|
|
135
|
+
if (raw.execEnabled !== undefined && raw.execEnabled !== null) {
|
|
136
|
+
if (typeof raw.execEnabled !== 'boolean') throw configError('execEnabled must be a boolean')
|
|
137
|
+
execEnabled = raw.execEnabled
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return Object.freeze({
|
|
141
|
+
role,
|
|
142
|
+
publicUrl,
|
|
143
|
+
promoteOnTimeout,
|
|
144
|
+
minAgentVersion,
|
|
145
|
+
stateDir,
|
|
146
|
+
keyTtlMinutes: boundedInteger(raw.keyTtlMinutes, 'keyTtlMinutes', DEFAULTS.keyTtlMinutes),
|
|
147
|
+
heartbeatMs: boundedInteger(raw.heartbeatMs, 'heartbeatMs', DEFAULTS.heartbeatMs),
|
|
148
|
+
execTimeoutMs: boundedInteger(raw.execTimeoutMs, 'execTimeoutMs', DEFAULTS.execTimeoutMs),
|
|
149
|
+
execEnabled,
|
|
150
|
+
maxOutputBytes: boundedInteger(raw.maxOutputBytes, 'maxOutputBytes', DEFAULTS.maxOutputBytes),
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Mount the plugin.
|
|
156
|
+
* @param {object} ctx - Cordis context with `tools` and `webServer`.
|
|
157
|
+
* @param {unknown} [config] - plugin config (PROTOCOL §8).
|
|
158
|
+
*/
|
|
159
|
+
export function apply(ctx, config) {
|
|
160
|
+
const resolved = resolveConfig(config)
|
|
161
|
+
const onWarn = (message) => {
|
|
162
|
+
try {
|
|
163
|
+
// The host's logger is not part of the injected contract; a warning on
|
|
164
|
+
// stderr is enough and never fatal.
|
|
165
|
+
console.warn(message)
|
|
166
|
+
} catch {
|
|
167
|
+
// A host with a broken console must not lose the plugin.
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const stateDir = resolveStateDir(resolved.stateDir)
|
|
172
|
+
|
|
173
|
+
// ---- hub half (PROTOCOL §1-§5, §8) ------------------------------------
|
|
174
|
+
// role 'runtime' is the controlled machine only: it must not serve the hub
|
|
175
|
+
// API, register computer_* tools or contribute a prompt section.
|
|
176
|
+
if (resolved.role !== 'runtime') {
|
|
177
|
+
const store = createStore({ stateDir, onWarn })
|
|
178
|
+
const selections = createSelectionStore({ stateDir, onWarn })
|
|
179
|
+
const access = createAccessControl({ ctx, store: createAccessStore({ stateDir, onWarn }) })
|
|
180
|
+
const hub = createHub({
|
|
181
|
+
store,
|
|
182
|
+
heartbeatMs: resolved.heartbeatMs,
|
|
183
|
+
maxOutputBytes: resolved.maxOutputBytes,
|
|
184
|
+
onWarn,
|
|
185
|
+
})
|
|
186
|
+
const fsOps = createFsOps({ hub, onWarn })
|
|
187
|
+
|
|
188
|
+
// Routes first (the browser page and the agents can arrive immediately),
|
|
189
|
+
// then the tools and the prompt section.
|
|
190
|
+
createRoutes({
|
|
191
|
+
ctx,
|
|
192
|
+
config: resolved,
|
|
193
|
+
store,
|
|
194
|
+
hub,
|
|
195
|
+
selections,
|
|
196
|
+
access,
|
|
197
|
+
fsOps,
|
|
198
|
+
role: resolved.role,
|
|
199
|
+
version: PLUGIN_VERSION,
|
|
200
|
+
agentUrl: AGENT_URL,
|
|
201
|
+
onWarn,
|
|
202
|
+
})
|
|
203
|
+
createTools({ ctx, config: resolved, store, hub, selections, access, fsOps, version: PLUGIN_VERSION, onWarn })
|
|
204
|
+
|
|
205
|
+
// Every registered route is its own effect; this one owns the timers and
|
|
206
|
+
// the in-flight exec promises so a plugin reload cannot leak either.
|
|
207
|
+
ctx.effect(() => () => {
|
|
208
|
+
fsOps.dispose()
|
|
209
|
+
hub.dispose()
|
|
210
|
+
}, 'dsh-computers: dispose hub and pending executions')
|
|
211
|
+
} else {
|
|
212
|
+
// role 'runtime': the settings client still learns its role from /state
|
|
213
|
+
// and can probe /health; the hub API itself stays unregistered.
|
|
214
|
+
createRuntimeStatusRoutes({
|
|
215
|
+
ctx,
|
|
216
|
+
config: resolved,
|
|
217
|
+
role: resolved.role,
|
|
218
|
+
version: PLUGIN_VERSION,
|
|
219
|
+
onWarn,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- runtime half (PROTOCOL §10) --------------------------------------
|
|
224
|
+
// role 'hub' is a controller only: it must never dial out as a computer.
|
|
225
|
+
if (resolved.role !== 'hub') {
|
|
226
|
+
const runtime = createRuntimeManager({
|
|
227
|
+
config: resolved,
|
|
228
|
+
stateDir,
|
|
229
|
+
version: PLUGIN_VERSION,
|
|
230
|
+
agentModuleUrl: AGENT_URL,
|
|
231
|
+
onWarn,
|
|
232
|
+
})
|
|
233
|
+
createRuntimeRoutes({ ctx, config: resolved, runtime, role: resolved.role, onWarn })
|
|
234
|
+
|
|
235
|
+
// Starting DSH starts the runtime: the switch survives restarts, so an
|
|
236
|
+
// enabled runtime.json reconnects on load without any user action.
|
|
237
|
+
void runtime.autoStart()
|
|
238
|
+
|
|
239
|
+
ctx.effect(() => () => runtime.dispose(), 'dsh-computers: dispose embedded runtime')
|
|
240
|
+
}
|
|
241
|
+
}
|