dsh-computers 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
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
+ 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,211 @@
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 { createHub } from './lib/hub.js'
15
+ import { createRoutes, createRuntimeRoutes, createRuntimeStatusRoutes } from './lib/routes.js'
16
+ import { createRuntimeManager } from './lib/runtime.js'
17
+ import { createSelectionStore } from './lib/selection.js'
18
+ import { resolveStateDir, createStore } from './lib/state.js'
19
+ import { createTools } from './lib/tools.js'
20
+
21
+ /** Cordis plugin name. */
22
+ export const name = 'dsh-computers'
23
+
24
+ /** Required host capabilities; `systemPrompt` and `connection` are optional. */
25
+ export const inject = ['tools', 'webServer']
26
+
27
+ /** Version reported by `/api/dsh-computers/health` (and asserted by the smoke harness). */
28
+ export const PLUGIN_VERSION = '1.1.0'
29
+
30
+ /** The machine agent bundle, served at request time (never imported). */
31
+ const AGENT_URL = new URL('./agent/dsh-computers-agent.mjs', import.meta.url)
32
+
33
+ /** Protocol defaults (PROTOCOL §8); `''` means "use the default". */
34
+ const ROLES = Object.freeze(['hub', 'runtime', 'both'])
35
+
36
+ const DEFAULTS = Object.freeze({
37
+ role: 'both',
38
+ publicUrl: '',
39
+ stateDir: '',
40
+ keyTtlMinutes: 15,
41
+ heartbeatMs: 20_000,
42
+ execTimeoutMs: 120_000,
43
+ execEnabled: true,
44
+ maxOutputBytes: 1_048_576,
45
+ })
46
+
47
+ /** Bounds that keep a hand-edited config from producing an unusable host. */
48
+ const LIMITS = Object.freeze({
49
+ keyTtlMinutes: [1, 10_080], // 1 minute .. 7 days
50
+ heartbeatMs: [100, 3_600_000], // 100 ms .. 1 hour
51
+ execTimeoutMs: [100, 86_400_000], // 100 ms .. 24 hours
52
+ maxOutputBytes: [64, 64 * 1024 * 1024], // 64 B .. 64 MiB
53
+ })
54
+
55
+ /** Throw a clear TypeError instead of letting a bad value surface as a 500 later. */
56
+ function configError(message) {
57
+ return new TypeError(`dsh-computers: ${message}`)
58
+ }
59
+
60
+ /** Validate one bounded integer option. */
61
+ function boundedInteger(raw, key, fallback) {
62
+ if (raw === undefined || raw === null || raw === '') return fallback
63
+ const value = typeof raw === 'number' ? raw : Number(raw)
64
+ const [min, max] = LIMITS[key]
65
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
66
+ throw configError(`${key} must be an integer between ${min} and ${max} (received ${JSON.stringify(raw)})`)
67
+ }
68
+ return value
69
+ }
70
+
71
+ /**
72
+ * Validate the plugin config by hand (schemastery is available only to some
73
+ * host compositions, so this file must not depend on it).
74
+ * @param {unknown} raw - config passed by cordis.patch.yml.
75
+ * @returns {object} resolved config with defaults applied.
76
+ */
77
+ export function resolveConfig(raw) {
78
+ if (raw === undefined || raw === null) raw = {}
79
+ if (typeof raw !== 'object' || Array.isArray(raw)) throw configError('config must be an object')
80
+
81
+ let publicUrl = DEFAULTS.publicUrl
82
+ if (raw.publicUrl !== undefined && raw.publicUrl !== null && raw.publicUrl !== '') {
83
+ if (typeof raw.publicUrl !== 'string') throw configError('publicUrl must be a string')
84
+ const trimmed = raw.publicUrl.trim().replace(/\/+$/, '')
85
+ let parsed
86
+ try {
87
+ parsed = new URL(trimmed)
88
+ } catch {
89
+ throw configError(`publicUrl must be an absolute http(s) URL (received ${JSON.stringify(raw.publicUrl)})`)
90
+ }
91
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
92
+ throw configError('publicUrl must use http or https')
93
+ }
94
+ publicUrl = trimmed
95
+ }
96
+
97
+ let stateDir = DEFAULTS.stateDir
98
+ if (raw.stateDir !== undefined && raw.stateDir !== null && raw.stateDir !== '') {
99
+ if (typeof raw.stateDir !== 'string') throw configError('stateDir must be a string')
100
+ stateDir = raw.stateDir.trim()
101
+ }
102
+
103
+ let role = DEFAULTS.role
104
+ if (raw.role !== undefined && raw.role !== null) {
105
+ if (typeof raw.role !== 'string' || !ROLES.includes(raw.role)) {
106
+ throw configError(`role must be one of ${ROLES.join(', ')} (received ${JSON.stringify(raw.role)})`)
107
+ }
108
+ role = raw.role
109
+ }
110
+
111
+ let execEnabled = DEFAULTS.execEnabled
112
+ if (raw.execEnabled !== undefined && raw.execEnabled !== null) {
113
+ if (typeof raw.execEnabled !== 'boolean') throw configError('execEnabled must be a boolean')
114
+ execEnabled = raw.execEnabled
115
+ }
116
+
117
+ return Object.freeze({
118
+ role,
119
+ publicUrl,
120
+ stateDir,
121
+ keyTtlMinutes: boundedInteger(raw.keyTtlMinutes, 'keyTtlMinutes', DEFAULTS.keyTtlMinutes),
122
+ heartbeatMs: boundedInteger(raw.heartbeatMs, 'heartbeatMs', DEFAULTS.heartbeatMs),
123
+ execTimeoutMs: boundedInteger(raw.execTimeoutMs, 'execTimeoutMs', DEFAULTS.execTimeoutMs),
124
+ execEnabled,
125
+ maxOutputBytes: boundedInteger(raw.maxOutputBytes, 'maxOutputBytes', DEFAULTS.maxOutputBytes),
126
+ })
127
+ }
128
+
129
+ /**
130
+ * Mount the plugin.
131
+ * @param {object} ctx - Cordis context with `tools` and `webServer`.
132
+ * @param {unknown} [config] - plugin config (PROTOCOL §8).
133
+ */
134
+ export function apply(ctx, config) {
135
+ const resolved = resolveConfig(config)
136
+ const onWarn = (message) => {
137
+ try {
138
+ // The host's logger is not part of the injected contract; a warning on
139
+ // stderr is enough and never fatal.
140
+ console.warn(message)
141
+ } catch {
142
+ // A host with a broken console must not lose the plugin.
143
+ }
144
+ }
145
+
146
+ const stateDir = resolveStateDir(resolved.stateDir)
147
+
148
+ // ---- hub half (PROTOCOL §1-§5, §8) ------------------------------------
149
+ // role 'runtime' is the controlled machine only: it must not serve the hub
150
+ // API, register computer_* tools or contribute a prompt section.
151
+ if (resolved.role !== 'runtime') {
152
+ const store = createStore({ stateDir, onWarn })
153
+ const selections = createSelectionStore({ stateDir, onWarn })
154
+ const hub = createHub({
155
+ store,
156
+ heartbeatMs: resolved.heartbeatMs,
157
+ maxOutputBytes: resolved.maxOutputBytes,
158
+ onWarn,
159
+ })
160
+
161
+ // Routes first (the browser page and the agents can arrive immediately),
162
+ // then the tools and the prompt section.
163
+ createRoutes({
164
+ ctx,
165
+ config: resolved,
166
+ store,
167
+ hub,
168
+ selections,
169
+ role: resolved.role,
170
+ version: PLUGIN_VERSION,
171
+ agentUrl: AGENT_URL,
172
+ onWarn,
173
+ })
174
+ createTools({ ctx, config: resolved, store, hub, selections, onWarn })
175
+
176
+ // Every registered route is its own effect; this one owns the timers and
177
+ // the in-flight exec promises so a plugin reload cannot leak either.
178
+ ctx.effect(() => () => {
179
+ hub.dispose()
180
+ }, 'dsh-computers: dispose hub and pending executions')
181
+ } else {
182
+ // role 'runtime': the settings client still learns its role from /state
183
+ // and can probe /health; the hub API itself stays unregistered.
184
+ createRuntimeStatusRoutes({
185
+ ctx,
186
+ config: resolved,
187
+ role: resolved.role,
188
+ version: PLUGIN_VERSION,
189
+ onWarn,
190
+ })
191
+ }
192
+
193
+ // ---- runtime half (PROTOCOL §10) --------------------------------------
194
+ // role 'hub' is a controller only: it must never dial out as a computer.
195
+ if (resolved.role !== 'hub') {
196
+ const runtime = createRuntimeManager({
197
+ config: resolved,
198
+ stateDir,
199
+ version: PLUGIN_VERSION,
200
+ agentModuleUrl: AGENT_URL,
201
+ onWarn,
202
+ })
203
+ createRuntimeRoutes({ ctx, config: resolved, runtime, role: resolved.role, onWarn })
204
+
205
+ // Starting DSH starts the runtime: the switch survives restarts, so an
206
+ // enabled runtime.json reconnects on load without any user action.
207
+ void runtime.autoStart()
208
+
209
+ ctx.effect(() => () => runtime.dispose(), 'dsh-computers: dispose embedded runtime')
210
+ }
211
+ }