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.
- package/LICENSE +21 -0
- package/PROTOCOL.md +559 -0
- package/README.md +147 -2
- package/agent/README.md +276 -0
- package/agent/dsh-computers-agent.mjs +1993 -0
- package/client.js +3219 -0
- package/cordis.patch.yml +23 -0
- package/docs/deploy.md +103 -0
- package/icon.svg +8 -0
- package/index.js +211 -0
- package/lib/http.js +761 -0
- package/lib/hub.js +381 -0
- package/lib/keys.js +134 -0
- package/lib/routes.js +697 -0
- package/lib/runtime.js +469 -0
- package/lib/selection.js +205 -0
- package/lib/state.js +181 -0
- package/lib/tools.js +394 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +56 -4
package/lib/state.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side persisted registry (PROTOCOL §2).
|
|
3
|
+
*
|
|
4
|
+
* One JSON document under `${stateDir || $DSH_HOME/computers}/state.json`,
|
|
5
|
+
* written atomically (same-directory tmp file + rename) with mode 0600. The
|
|
6
|
+
* loaded document is the single source of truth in memory; every mutation goes
|
|
7
|
+
* through `mutate()` so prune + persist cannot be forgotten.
|
|
8
|
+
*
|
|
9
|
+
* Node builtins only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
13
|
+
import { randomBytes } from 'node:crypto'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
/** On-disk schema version (the browser API reports its own version 2). */
|
|
18
|
+
export const STATE_VERSION = 1
|
|
19
|
+
|
|
20
|
+
/** A consumed key and an expired key are both dropped one hour after the fact. */
|
|
21
|
+
export const PENDING_KEY_PRUNE_MS = 60 * 60 * 1000
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the state directory: explicit config first, then `$DSH_HOME`, then
|
|
25
|
+
* `~/.dsh` (the harness default).
|
|
26
|
+
* @param {string | undefined} configured - `config.stateDir`.
|
|
27
|
+
* @returns {string} absolute directory path.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveStateDir(configured) {
|
|
30
|
+
if (typeof configured === 'string' && configured.trim() !== '') return configured.trim()
|
|
31
|
+
const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
|
|
32
|
+
? process.env.DSH_HOME.trim()
|
|
33
|
+
: join(homedir(), '.dsh')
|
|
34
|
+
return join(home, 'computers')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A fresh, empty registry document. */
|
|
38
|
+
export function emptyState() {
|
|
39
|
+
return { version: STATE_VERSION, computers: [], pendingKeys: [] }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Drop pending keys the protocol lets us forget: one whose `usedAt` is older
|
|
44
|
+
* than an hour, or whose `expiresAt` passed more than an hour ago.
|
|
45
|
+
* @param {object} doc - registry document, mutated in place.
|
|
46
|
+
* @param {number} nowMs - current epoch milliseconds.
|
|
47
|
+
* @returns {number} how many entries were dropped.
|
|
48
|
+
*/
|
|
49
|
+
export function prunePendingKeys(doc, nowMs) {
|
|
50
|
+
const before = doc.pendingKeys.length
|
|
51
|
+
doc.pendingKeys = doc.pendingKeys.filter((entry) => {
|
|
52
|
+
if (entry === null || typeof entry !== 'object') return false
|
|
53
|
+
const usedAt = typeof entry.usedAt === 'string' ? Date.parse(entry.usedAt) : Number.NaN
|
|
54
|
+
if (typeof entry.usedAt === 'string' && Number.isFinite(usedAt)) return nowMs - usedAt <= PENDING_KEY_PRUNE_MS
|
|
55
|
+
const expiresAt = typeof entry.expiresAt === 'string' ? Date.parse(entry.expiresAt) : Number.NaN
|
|
56
|
+
if (!Number.isFinite(expiresAt)) return false
|
|
57
|
+
return nowMs - expiresAt <= PENDING_KEY_PRUNE_MS
|
|
58
|
+
})
|
|
59
|
+
return before - doc.pendingKeys.length
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Coerce an arbitrary parsed value into a registry document we can trust
|
|
64
|
+
* enough to serve. Unknown shapes are dropped rather than crashing the host.
|
|
65
|
+
* @param {unknown} raw - parsed JSON (or undefined when the file was missing).
|
|
66
|
+
* @returns {object} a valid registry document.
|
|
67
|
+
*/
|
|
68
|
+
export function normalizeState(raw) {
|
|
69
|
+
const doc = emptyState()
|
|
70
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return doc
|
|
71
|
+
if (Array.isArray(raw.computers)) {
|
|
72
|
+
doc.computers = raw.computers.filter((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry))
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(raw.pendingKeys)) {
|
|
75
|
+
doc.pendingKeys = raw.pendingKeys.filter((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry))
|
|
76
|
+
}
|
|
77
|
+
return doc
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create the registry store for one plugin mount.
|
|
82
|
+
*
|
|
83
|
+
* `now` is injectable so expiry/prune rules are testable without a clock.
|
|
84
|
+
* @param {object} options - store options.
|
|
85
|
+
* @param {string} options.stateDir - resolved directory (see {@link resolveStateDir}).
|
|
86
|
+
* @param {() => number} [options.now] - epoch-millisecond clock.
|
|
87
|
+
* @param {(message: string) => void} [options.onWarn] - warning sink.
|
|
88
|
+
* @returns {object} the store.
|
|
89
|
+
*/
|
|
90
|
+
export function createStore({ stateDir, now = Date.now, onWarn = () => {} }) {
|
|
91
|
+
const file = join(stateDir, 'state.json')
|
|
92
|
+
let doc = emptyState()
|
|
93
|
+
|
|
94
|
+
/** Read and parse the document; a corrupt file is set aside, never fatal. */
|
|
95
|
+
const load = () => {
|
|
96
|
+
let raw
|
|
97
|
+
try {
|
|
98
|
+
raw = readFileSync(file, 'utf8')
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error && error.code !== 'ENOENT') {
|
|
101
|
+
onWarn(`dsh-computers: cannot read ${file}: ${error.message}`)
|
|
102
|
+
}
|
|
103
|
+
doc = emptyState()
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
doc = normalizeState(JSON.parse(raw))
|
|
108
|
+
} catch {
|
|
109
|
+
// A half-written legacy file must not brick the plugin: keep it for
|
|
110
|
+
// inspection and start from an empty registry.
|
|
111
|
+
const aside = `${file}.corrupt-${now()}`
|
|
112
|
+
try {
|
|
113
|
+
renameSync(file, aside)
|
|
114
|
+
onWarn(`dsh-computers: ${file} was not valid JSON; moved to ${aside}`)
|
|
115
|
+
} catch {
|
|
116
|
+
onWarn(`dsh-computers: ${file} was not valid JSON`)
|
|
117
|
+
}
|
|
118
|
+
doc = emptyState()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Write the document atomically: tmp file in the same directory, then rename. */
|
|
123
|
+
const persist = () => {
|
|
124
|
+
try {
|
|
125
|
+
mkdirSync(stateDir, { recursive: true, mode: 0o700 })
|
|
126
|
+
const tmp = join(stateDir, `.state.json.${process.pid}.${randomBytes(4).toString('hex')}.tmp`)
|
|
127
|
+
writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
128
|
+
try {
|
|
129
|
+
chmodSync(tmp, 0o600)
|
|
130
|
+
} catch {
|
|
131
|
+
// Platforms without POSIX modes (Windows) simply skip this.
|
|
132
|
+
}
|
|
133
|
+
renameSync(tmp, file)
|
|
134
|
+
} catch (error) {
|
|
135
|
+
onWarn(`dsh-computers: cannot write ${file}: ${error && error.message ? error.message : error}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
load()
|
|
140
|
+
prunePendingKeys(doc, now())
|
|
141
|
+
persist()
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
/** Absolute state directory. */
|
|
145
|
+
dir: stateDir,
|
|
146
|
+
/** Absolute state file path. */
|
|
147
|
+
file,
|
|
148
|
+
/** Read-only view of the live document (callers must not mutate it). */
|
|
149
|
+
read: () => doc,
|
|
150
|
+
/** Deep copy for responses that must not alias live state. */
|
|
151
|
+
snapshot: () => JSON.parse(JSON.stringify(doc)),
|
|
152
|
+
/**
|
|
153
|
+
* Run one atomic read-modify-write: prune, apply `change`, persist.
|
|
154
|
+
* @param {(document: object) => unknown} change - mutation callback.
|
|
155
|
+
* @returns {unknown} whatever `change` returned.
|
|
156
|
+
*/
|
|
157
|
+
mutate(change) {
|
|
158
|
+
const result = change(doc)
|
|
159
|
+
prunePendingKeys(doc, now())
|
|
160
|
+
persist()
|
|
161
|
+
return result
|
|
162
|
+
},
|
|
163
|
+
/**
|
|
164
|
+
* Refresh a computer's `lastSeenAt` (stream open, heartbeat, result POST).
|
|
165
|
+
* @param {string} computerId - computer to touch.
|
|
166
|
+
* @param {string} [at] - ISO timestamp; defaults to now.
|
|
167
|
+
* @returns {boolean} whether the computer exists.
|
|
168
|
+
*/
|
|
169
|
+
touch(computerId, at = new Date(now()).toISOString()) {
|
|
170
|
+
const computer = doc.computers.find((entry) => entry.id === computerId)
|
|
171
|
+
if (computer === undefined) return false
|
|
172
|
+
computer.lastSeenAt = at
|
|
173
|
+
persist()
|
|
174
|
+
return true
|
|
175
|
+
},
|
|
176
|
+
/** Re-read the file from disk (used by tooling and the smoke harness). */
|
|
177
|
+
reload: load,
|
|
178
|
+
/** Expose the clock in use. */
|
|
179
|
+
now,
|
|
180
|
+
}
|
|
181
|
+
}
|
package/lib/tools.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The model-facing half of dsh-computers (PROTOCOL §5): `computer_list`,
|
|
3
|
+
* `computer_exec`, and the short system-prompt section that makes the tools
|
|
4
|
+
* discoverable.
|
|
5
|
+
*
|
|
6
|
+
* Tool definitions are registered as raw JSON Schema objects (no
|
|
7
|
+
* `@deepseek-ai/*` imports, so the bundle loads in any profile). Argument
|
|
8
|
+
* validation therefore lives here and produces messages the model can act on.
|
|
9
|
+
*
|
|
10
|
+
* Node builtins only.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { HubError } from './hub.js'
|
|
14
|
+
import { normalizeSessionId } from './selection.js'
|
|
15
|
+
|
|
16
|
+
/** Hard cap for one rendered tool result (PROTOCOL §5.2: ~20 000 chars). */
|
|
17
|
+
const MAX_RENDER_CHARS = 20_000
|
|
18
|
+
|
|
19
|
+
/** Smallest accepted per-call timeout; below this the shell cannot start. */
|
|
20
|
+
const MIN_TIMEOUT_MS = 100
|
|
21
|
+
|
|
22
|
+
/** Largest accepted per-call timeout (30 minutes). */
|
|
23
|
+
const MAX_TIMEOUT_MS = 30 * 60 * 1000
|
|
24
|
+
|
|
25
|
+
/** Cap on prompt lines / list rows so the context stays small. */
|
|
26
|
+
const MAX_LISTED_COMPUTERS = 12
|
|
27
|
+
|
|
28
|
+
const COMPUTER_ITEM_SCHEMA = {
|
|
29
|
+
type: 'object',
|
|
30
|
+
properties: {
|
|
31
|
+
id: { type: 'string', description: 'Stable computer id (cmp_...).' },
|
|
32
|
+
name: { type: 'string', description: 'Display name shown in the Computers settings page.' },
|
|
33
|
+
online: { type: 'boolean', description: 'Whether the machine currently holds a live connection.' },
|
|
34
|
+
platformLabel: { type: 'string', description: 'macOS, Linux, Windows or Unknown.' },
|
|
35
|
+
os: { type: 'string', description: 'Node os.platform() value.' },
|
|
36
|
+
hostname: { type: 'string', description: 'Machine hostname as reported at enrollment.' },
|
|
37
|
+
lastSeenAt: {
|
|
38
|
+
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
39
|
+
description: 'ISO timestamp of the last stream/heartbeat/result (null when unknown).',
|
|
40
|
+
},
|
|
41
|
+
agentVersion: {
|
|
42
|
+
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
43
|
+
description: 'Version of the agent running on that machine (null when unknown).',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
required: ['id', 'name', 'online', 'platformLabel', 'os', 'hostname', 'lastSeenAt', 'agentVersion'],
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const LIST_OUTPUT_SCHEMA = {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
computers: { type: 'array', items: COMPUTER_ITEM_SCHEMA, description: 'Every enrolled computer, online or not.' },
|
|
54
|
+
selectedComputerId: {
|
|
55
|
+
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
56
|
+
description: 'Computer pinned for this session (composer target picker), or null.',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
required: ['computers', 'selectedComputerId'],
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const EXEC_PARAMETERS_SCHEMA = {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: {
|
|
66
|
+
computer: {
|
|
67
|
+
type: 'string',
|
|
68
|
+
description: 'Optional target: a computer id or name from computer_list (an unambiguous name prefix is accepted). Omit it to use the computer the user pinned in the composer for this session.',
|
|
69
|
+
},
|
|
70
|
+
command: { type: 'string', description: 'Shell command to run on that computer.' },
|
|
71
|
+
cwd: { type: 'string', description: 'Working directory for the command; defaults to the home directory.' },
|
|
72
|
+
timeoutMs: {
|
|
73
|
+
type: 'number',
|
|
74
|
+
description: 'Timeout in milliseconds (defaults to the host exec timeout). The command is cancelled when it expires.',
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
required: ['command'],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const EXEC_OUTPUT_SCHEMA = {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: {
|
|
84
|
+
computer: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: { id: { type: 'string' }, name: { type: 'string' } },
|
|
87
|
+
required: ['id', 'name'],
|
|
88
|
+
additionalProperties: false,
|
|
89
|
+
},
|
|
90
|
+
command: { type: 'string' },
|
|
91
|
+
exitCode: { oneOf: [{ type: 'number' }, { type: 'null' }], description: 'Exit code, or null when the process died by signal.' },
|
|
92
|
+
stdout: { type: 'string' },
|
|
93
|
+
stderr: { type: 'string' },
|
|
94
|
+
durationMs: { type: 'number' },
|
|
95
|
+
ok: { type: 'boolean' },
|
|
96
|
+
timedOut: { type: 'boolean' },
|
|
97
|
+
truncated: { type: 'boolean' },
|
|
98
|
+
},
|
|
99
|
+
required: ['computer', 'command', 'exitCode', 'stdout', 'stderr', 'durationMs', 'ok', 'timedOut', 'truncated'],
|
|
100
|
+
additionalProperties: false,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One text block, as every tool renderer returns. */
|
|
104
|
+
const textBlock = (text) => [{ type: 'text', text }]
|
|
105
|
+
|
|
106
|
+
/** Trim one rendered result to the protocol's display budget. */
|
|
107
|
+
function boundRender(text) {
|
|
108
|
+
return text.length <= MAX_RENDER_CHARS
|
|
109
|
+
? text
|
|
110
|
+
: `${text.slice(0, MAX_RENDER_CHARS)}\n… output truncated for display (${text.length - MAX_RENDER_CHARS} more characters)`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Markdown table for `computer_list`, with the session's pinned target marked
|
|
115
|
+
* by a leading `*` (and named up front, so the model knows what a bare
|
|
116
|
+
* `computer_exec` will hit).
|
|
117
|
+
*/
|
|
118
|
+
function renderComputerList(value) {
|
|
119
|
+
const computers = Array.isArray(value?.computers) ? value.computers : []
|
|
120
|
+
if (computers.length === 0) {
|
|
121
|
+
return 'No computers are connected yet. Ask the user to open Settings → 电脑 and use “连接电脑”.'
|
|
122
|
+
}
|
|
123
|
+
const selectedId = typeof value?.selectedComputerId === 'string' ? value.selectedComputerId : null
|
|
124
|
+
const selected = selectedId === null ? undefined : computers.find((computer) => computer.id === selectedId)
|
|
125
|
+
const rows = computers.slice(0, MAX_LISTED_COMPUTERS).map((computer) => {
|
|
126
|
+
const status = computer.online === true ? 'online' : 'offline'
|
|
127
|
+
const lastSeen = typeof computer.lastSeenAt === 'string' ? computer.lastSeenAt : '—'
|
|
128
|
+
const name = computer.id === selectedId ? `* ${computer.name}` : computer.name
|
|
129
|
+
return `| ${name} | ${computer.platformLabel} | ${status} | ${lastSeen} |`
|
|
130
|
+
})
|
|
131
|
+
const lines = []
|
|
132
|
+
if (selected !== undefined) {
|
|
133
|
+
lines.push(`Pinned target for this session: **${selected.name}** (\`computer_exec\` without \`computer\` runs here).`, '')
|
|
134
|
+
}
|
|
135
|
+
lines.push('| Name | Platform | Status | Last seen |', '|---|---|---|---|', ...rows)
|
|
136
|
+
const hidden = computers.length - Math.min(computers.length, MAX_LISTED_COMPUTERS)
|
|
137
|
+
let table = lines.join('\n')
|
|
138
|
+
if (hidden > 0) table += `\n… and ${hidden} more (only the first ${MAX_LISTED_COMPUTERS} are shown)`
|
|
139
|
+
if (selected !== undefined) table += `\n\nRows marked * are selected in the composer for this session.`
|
|
140
|
+
return table
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The protocol's `$ command` / status / stdout / stderr layout. */
|
|
144
|
+
function renderExec(value) {
|
|
145
|
+
const computer = value?.computer ?? {}
|
|
146
|
+
const status = [
|
|
147
|
+
`exit ${value?.exitCode ?? 'signal'}`,
|
|
148
|
+
`${value?.durationMs ?? 0}ms`,
|
|
149
|
+
computer.name ?? computer.id ?? 'unknown computer',
|
|
150
|
+
]
|
|
151
|
+
if (value?.timedOut === true) status.push('timed out')
|
|
152
|
+
if (value?.truncated === true) status.push('output truncated')
|
|
153
|
+
const lines = [`$ ${value?.command ?? ''}`, status.join(' · ')]
|
|
154
|
+
const stdout = typeof value?.stdout === 'string' ? value.stdout : ''
|
|
155
|
+
const stderr = typeof value?.stderr === 'string' ? value.stderr : ''
|
|
156
|
+
if (stdout !== '') lines.push('', stdout.replace(/\n+$/, ''))
|
|
157
|
+
if (stderr !== '') lines.push('', 'stderr:', stderr.replace(/\n+$/, ''))
|
|
158
|
+
return boundRender(lines.join('\n'))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Register the tools and the system-prompt section for one plugin mount.
|
|
163
|
+
* @param {object} options - wiring.
|
|
164
|
+
* @param {object} options.ctx - Cordis context (tools + optional systemPrompt).
|
|
165
|
+
* @param {object} options.config - resolved plugin config.
|
|
166
|
+
* @param {object} options.store - registry store.
|
|
167
|
+
* @param {object} options.hub - SSE/exec hub.
|
|
168
|
+
* @param {(message: string) => void} [options.onWarn] - warning sink.
|
|
169
|
+
*/
|
|
170
|
+
export function createTools({ ctx, config, store, hub, selections = null, onWarn = () => {} }) {
|
|
171
|
+
/** Resolve `computer` by exact id, exact name, then unique substring. */
|
|
172
|
+
const resolveComputer = (query) => {
|
|
173
|
+
const computers = store.read().computers
|
|
174
|
+
if (computers.length === 0) {
|
|
175
|
+
throw new Error('no computers are connected to this deployment; ask the user to connect one from Settings → 电脑')
|
|
176
|
+
}
|
|
177
|
+
const needle = query.trim()
|
|
178
|
+
const lower = needle.toLowerCase()
|
|
179
|
+
const byId = computers.find((computer) => computer.id === needle)
|
|
180
|
+
if (byId !== undefined) return byId
|
|
181
|
+
const byName = computers.find((computer) => String(computer.name).toLowerCase() === lower)
|
|
182
|
+
if (byName !== undefined) return byName
|
|
183
|
+
const candidates = computers.filter((computer) =>
|
|
184
|
+
String(computer.name).toLowerCase().includes(lower) || String(computer.id).toLowerCase().includes(lower))
|
|
185
|
+
if (candidates.length === 1) return candidates[0]
|
|
186
|
+
if (candidates.length > 1) {
|
|
187
|
+
const names = candidates.map((computer) => `${computer.name} (${computer.id})`).join(', ')
|
|
188
|
+
throw new Error(`computer '${query}' is ambiguous; it matches ${names}. Pass the exact id or name.`)
|
|
189
|
+
}
|
|
190
|
+
const available = computers.map((computer) => `${computer.name} (${computer.id})`).join(', ')
|
|
191
|
+
throw new Error(`no computer matches '${query}'; available computers: ${available}. Run computer_list for details.`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The caller's session identity.
|
|
196
|
+
*
|
|
197
|
+
* `exec.agent.id` IS the session-backed Agent identity (`SessionId`), and the
|
|
198
|
+
* live runtime face additionally exposes the same value as `agent.session.id`
|
|
199
|
+
* (see core/agent/src/types.ts: `readonly id: SessionId`, "Session-backed
|
|
200
|
+
* Agent identity"). Both are read so a future handle shape cannot silently
|
|
201
|
+
* disable the composer default.
|
|
202
|
+
*/
|
|
203
|
+
const sessionIdOf = (exec) => {
|
|
204
|
+
const agent = exec?.agent
|
|
205
|
+
if (agent === undefined || agent === null) return null
|
|
206
|
+
const fromSession = agent.session?.id
|
|
207
|
+
const raw = typeof fromSession === 'string' ? fromSession : agent.id
|
|
208
|
+
return normalizeSessionId(raw)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The session's pinned computer id, but only while that computer exists. */
|
|
212
|
+
const selectedComputerId = (exec) => {
|
|
213
|
+
const sessionId = sessionIdOf(exec)
|
|
214
|
+
if (sessionId === null || selections === null) return null
|
|
215
|
+
const stored = selections.get(sessionId)
|
|
216
|
+
if (stored === null) return null
|
|
217
|
+
return store.read().computers.some((computer) => computer.id === stored) ? stored : null
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Explicit argument wins; otherwise the computer pinned in the composer. */
|
|
221
|
+
const resolveTargetComputer = (query, exec) => {
|
|
222
|
+
if (query !== undefined) return resolveComputer(query)
|
|
223
|
+
const selectedId = selectedComputerId(exec)
|
|
224
|
+
if (selectedId === null) {
|
|
225
|
+
throw new Error('no computer selected: pass `computer` (an id or name from computer_list), or pick a target computer in the composer')
|
|
226
|
+
}
|
|
227
|
+
const selected = store.read().computers.find((computer) => computer.id === selectedId)
|
|
228
|
+
if (selected === undefined) {
|
|
229
|
+
throw new Error('the computer selected in the composer no longer exists; run computer_list and pick another target')
|
|
230
|
+
}
|
|
231
|
+
return selected
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Validate `computer_exec` arguments into a normalized shape. */
|
|
235
|
+
const validateExecArgs = (args) => {
|
|
236
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) {
|
|
237
|
+
throw new Error('computer_exec expects an object with "command"')
|
|
238
|
+
}
|
|
239
|
+
let computer
|
|
240
|
+
if (args.computer !== undefined && args.computer !== null) {
|
|
241
|
+
if (typeof args.computer !== 'string') throw new Error('computer_exec "computer" must be a string when provided')
|
|
242
|
+
const trimmed = args.computer.trim()
|
|
243
|
+
computer = trimmed === '' ? undefined : trimmed
|
|
244
|
+
}
|
|
245
|
+
const command = typeof args.command === 'string' ? args.command : ''
|
|
246
|
+
if (command.trim() === '') throw new Error('computer_exec requires "command": a non-empty shell command')
|
|
247
|
+
let cwd = null
|
|
248
|
+
if (args.cwd !== undefined && args.cwd !== null) {
|
|
249
|
+
if (typeof args.cwd !== 'string') throw new Error('computer_exec "cwd" must be a string')
|
|
250
|
+
cwd = args.cwd.trim() === '' ? null : args.cwd
|
|
251
|
+
}
|
|
252
|
+
let requestedTimeout = null
|
|
253
|
+
if (args.timeoutMs !== undefined && args.timeoutMs !== null) {
|
|
254
|
+
if (typeof args.timeoutMs !== 'number' || !Number.isFinite(args.timeoutMs)) {
|
|
255
|
+
throw new Error('computer_exec "timeoutMs" must be a finite number of milliseconds')
|
|
256
|
+
}
|
|
257
|
+
requestedTimeout = Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.trunc(args.timeoutMs)))
|
|
258
|
+
}
|
|
259
|
+
return { computer, command, cwd, timeoutMs: requestedTimeout ?? config.execTimeoutMs }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Map a hub failure onto actionable model-facing text (PROTOCOL §5.2). */
|
|
263
|
+
const actionable = (error, computer, timeoutMs) => {
|
|
264
|
+
if (error instanceof HubError) {
|
|
265
|
+
switch (error.code) {
|
|
266
|
+
case 'offline':
|
|
267
|
+
return new Error(`computer '${computer.name}' is offline; run computer_list to see which computers are connected, and ask the user to wake or reconnect it`)
|
|
268
|
+
case 'timeout':
|
|
269
|
+
return new Error(`computer '${computer.name}' did not finish within ${timeoutMs}ms; the command was cancelled. Retry with a larger timeoutMs, or run a shorter command`)
|
|
270
|
+
case 'aborted':
|
|
271
|
+
return new Error(`computer_exec on '${computer.name}' was aborted by the caller, and the command was cancelled on that machine`)
|
|
272
|
+
case 'disposed':
|
|
273
|
+
return new Error('the dsh-computers host plugin is shutting down; try again in a moment')
|
|
274
|
+
default:
|
|
275
|
+
return error
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return error instanceof Error ? error : new Error(String(error))
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Each registration is wrapped in its own effect: the tool registry also
|
|
282
|
+
// owns it, but an explicit effect makes the reload path deterministic.
|
|
283
|
+
ctx.effect(() => ctx.tools.register({
|
|
284
|
+
name: 'computer_list',
|
|
285
|
+
description: 'List the computers connected to this DSH Web deployment (the user\'s own machines), with their platform, online state and last-seen time. Call this before computer_exec to get a valid computer id or name.',
|
|
286
|
+
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
|
287
|
+
output: {
|
|
288
|
+
schema: LIST_OUTPUT_SCHEMA,
|
|
289
|
+
render: (_args, value) => textBlock(renderComputerList(value)),
|
|
290
|
+
},
|
|
291
|
+
async execute(_args, exec) {
|
|
292
|
+
const selectedId = selectedComputerId(exec)
|
|
293
|
+
const computers = store.read().computers.map((computer) => ({
|
|
294
|
+
id: computer.id,
|
|
295
|
+
name: computer.name,
|
|
296
|
+
online: hub.isOnline(computer),
|
|
297
|
+
platformLabel: computer.platformLabel ?? 'Unknown',
|
|
298
|
+
os: computer.os ?? 'unknown',
|
|
299
|
+
hostname: computer.hostname ?? '',
|
|
300
|
+
lastSeenAt: typeof computer.lastSeenAt === 'string' ? computer.lastSeenAt : null,
|
|
301
|
+
agentVersion: typeof computer.agentVersion === 'string' ? computer.agentVersion : null,
|
|
302
|
+
}))
|
|
303
|
+
return { computers, selectedComputerId: selectedId }
|
|
304
|
+
},
|
|
305
|
+
}), 'dsh-computers: computer_list tool')
|
|
306
|
+
|
|
307
|
+
ctx.effect(() => ctx.tools.register({
|
|
308
|
+
name: 'computer_exec',
|
|
309
|
+
description: 'Run one shell command on one of the user\'s connected computers and return its exit code, stdout and stderr. The command runs in that machine\'s own shell (POSIX `sh -lc` on macOS/Linux, `cmd.exe` on Windows); there is no streaming, and the output is capped by the host. The machine must be online — call computer_list first.',
|
|
310
|
+
parameters: EXEC_PARAMETERS_SCHEMA,
|
|
311
|
+
output: {
|
|
312
|
+
schema: EXEC_OUTPUT_SCHEMA,
|
|
313
|
+
render: (_args, value) => textBlock(renderExec(value)),
|
|
314
|
+
},
|
|
315
|
+
async execute(args, exec) {
|
|
316
|
+
if (config.execEnabled !== true) {
|
|
317
|
+
throw new Error('computer_exec is disabled on this host (dsh-computers config execEnabled: false)')
|
|
318
|
+
}
|
|
319
|
+
const input = validateExecArgs(args)
|
|
320
|
+
const computer = resolveTargetComputer(input.computer, exec)
|
|
321
|
+
if (!hub.isOnline(computer)) {
|
|
322
|
+
throw new Error(`computer '${computer.name}' is offline; run computer_list to see which computers are connected, and ask the user to wake or reconnect it`)
|
|
323
|
+
}
|
|
324
|
+
let result
|
|
325
|
+
try {
|
|
326
|
+
result = await hub.dispatch({
|
|
327
|
+
computerId: computer.id,
|
|
328
|
+
name: computer.name,
|
|
329
|
+
command: input.command,
|
|
330
|
+
cwd: input.cwd,
|
|
331
|
+
timeoutMs: input.timeoutMs,
|
|
332
|
+
signal: exec?.signal,
|
|
333
|
+
})
|
|
334
|
+
} catch (error) {
|
|
335
|
+
throw actionable(error, computer, input.timeoutMs)
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
computer: { id: computer.id, name: computer.name },
|
|
339
|
+
command: input.command,
|
|
340
|
+
exitCode: result.exitCode,
|
|
341
|
+
stdout: result.stdout,
|
|
342
|
+
stderr: result.stderr,
|
|
343
|
+
durationMs: result.durationMs,
|
|
344
|
+
ok: result.ok === true && result.timedOut !== true,
|
|
345
|
+
timedOut: result.timedOut === true,
|
|
346
|
+
truncated: result.truncated === true,
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
}), 'dsh-computers: computer_exec tool')
|
|
350
|
+
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
// System prompt section (PROTOCOL §5.3)
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// `systemPrompt` is not in `inject` (it is optional for this plugin), so it
|
|
355
|
+
// is looked up defensively; a host without it keeps working unchanged.
|
|
356
|
+
try {
|
|
357
|
+
const systemPrompt = ctx.get('systemPrompt')
|
|
358
|
+
if (systemPrompt !== undefined && systemPrompt !== null && typeof systemPrompt.section === 'function') {
|
|
359
|
+
// The nearest centrally allocated slot is COMPUTER_USE; fall back to its
|
|
360
|
+
// numeric value when the host's table predates that name.
|
|
361
|
+
let order = 3000
|
|
362
|
+
try {
|
|
363
|
+
const resolved = typeof systemPrompt.getSectionOrder === 'function'
|
|
364
|
+
? systemPrompt.getSectionOrder('TOOL_COMPUTER_USE')
|
|
365
|
+
: undefined
|
|
366
|
+
if (Number.isFinite(resolved)) order = resolved
|
|
367
|
+
} catch {
|
|
368
|
+
// Unknown placement name: keep the numeric fallback.
|
|
369
|
+
}
|
|
370
|
+
// Wrapped in an effect: a plugin reload must remove the old section, or
|
|
371
|
+
// the duplicate name 'computers' throws and the section silently vanishes.
|
|
372
|
+
ctx.effect(() => systemPrompt.section({
|
|
373
|
+
name: 'computers',
|
|
374
|
+
order,
|
|
375
|
+
text: () => {
|
|
376
|
+
const computers = store.read().computers
|
|
377
|
+
if (computers.length === 0) return ''
|
|
378
|
+
const lines = [
|
|
379
|
+
'You can run shell commands on the user\'s connected computers with `computer_exec`.',
|
|
380
|
+
'Call `computer_list` to see their ids, names and online state.',
|
|
381
|
+
'The user can pin a target computer in the composer; `computer_exec` without a `computer` argument runs on that pinned computer.',
|
|
382
|
+
]
|
|
383
|
+
for (const computer of computers.slice(0, MAX_LISTED_COMPUTERS)) {
|
|
384
|
+
const state = hub.isOnline(computer) ? 'online' : 'offline'
|
|
385
|
+
lines.push(`- ${computer.name} (${computer.platformLabel ?? 'Unknown'}, ${state})`)
|
|
386
|
+
}
|
|
387
|
+
return lines.join('\n')
|
|
388
|
+
},
|
|
389
|
+
}), 'dsh-computers: prompt section')
|
|
390
|
+
}
|
|
391
|
+
} catch (error) {
|
|
392
|
+
onWarn(`dsh-computers: could not register the system prompt section: ${error && error.message ? error.message : error}`)
|
|
393
|
+
}
|
|
394
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-computers",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Connect your own machines to a DSH Web deployment and run commands on them from the agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "9Ashwin",
|
|
8
|
+
"homepage": "https://github.com/9Ashwin/dsh-computers#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/9Ashwin/dsh-computers.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"dsh",
|
|
15
|
+
"dsh-plugin",
|
|
16
|
+
"remote",
|
|
17
|
+
"computers",
|
|
18
|
+
"agents"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./index.js",
|
|
22
|
+
"./client": "./client.js",
|
|
23
|
+
"./locale/*.json": "./locale/*.json",
|
|
24
|
+
"./icon": "./icon.svg"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"index.js",
|
|
28
|
+
"lib/*.js",
|
|
29
|
+
"agent/*.mjs",
|
|
30
|
+
"agent/*.md",
|
|
31
|
+
"client.js",
|
|
32
|
+
"cordis.patch.yml",
|
|
33
|
+
"locale/*.json",
|
|
34
|
+
"icon.svg",
|
|
35
|
+
"PROTOCOL.md",
|
|
36
|
+
"README.md",
|
|
37
|
+
"docs/*.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"dsh": {
|
|
47
|
+
"bundle": {
|
|
48
|
+
"patch": "./cordis.patch.yml"
|
|
49
|
+
},
|
|
50
|
+
"client": {
|
|
51
|
+
"platform": "web",
|
|
52
|
+
"inject": [
|
|
53
|
+
"@deepseek-ai/dsh-client-ui-settings",
|
|
54
|
+
"@deepseek-ai/dsh-client-locale"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|