dsh-plugin-subagent-bridge 0.16.0 → 0.17.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,121 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+ import { MAX_BLOCKING_SECONDS, MAX_RESULT_CHARS, MAX_WAIT_SECONDS } from './constants.js'
4
+
5
+ export const DEFAULTS = {
6
+ enabled: true,
7
+ port: 47821,
8
+ endpointFile: join(homedir(), '.dsh', 'bridge', 'endpoint.json'),
9
+ maxResultChars: MAX_RESULT_CHARS,
10
+ readWaitSeconds: 45,
11
+ codexBin: 'codex',
12
+ codexSandbox: 'workspace-write',
13
+ codexTimeoutMs: 300000,
14
+ assumeDefaults: true,
15
+ assumptionPolicy: '',
16
+ claudeBin: 'claude',
17
+ claudeModel: '',
18
+ claudeTimeoutMs: 300000,
19
+ taskTimeoutSeconds: MAX_BLOCKING_SECONDS,
20
+ waitTimeoutMs: MAX_BLOCKING_SECONDS * 1000,
21
+ maxWorkers: 12,
22
+ maxWorkersPerRoot: 4,
23
+ workerTtlMs: 24 * 60 * 60 * 1000,
24
+ maxHops: 1,
25
+ dispatchPerMinute: 10,
26
+ dispatchWindowMs: 60000,
27
+ workersFile: join(homedir(), '.dsh', 'bridge', 'workers.json'),
28
+ }
29
+
30
+ /** MCP tools advertised to clients. */
31
+
32
+ export function resolveSettings(config) {
33
+ const merged = { ...DEFAULTS }
34
+ for (const [key, value] of Object.entries(config ?? {})) {
35
+ if (key === 'port') {
36
+ const port = Number(value)
37
+ if (Number.isInteger(port) && port > 0 && port < 65536) merged.port = port
38
+ continue
39
+ }
40
+ if (key === 'enabled') {
41
+ merged.enabled = value === true || value === 'true'
42
+ continue
43
+ }
44
+ if (key === 'endpointFile' && typeof value === 'string' && value !== '') merged.endpointFile = expandHome(value)
45
+ if (key === 'maxResultChars') {
46
+ const chars = Number(value)
47
+ if (Number.isFinite(chars) && chars > 0) merged.maxResultChars = chars
48
+ }
49
+ if (key === 'readWaitSeconds') {
50
+ const seconds = Number(value)
51
+ if (Number.isFinite(seconds) && seconds >= 0) merged.readWaitSeconds = Math.min(MAX_WAIT_SECONDS, seconds)
52
+ }
53
+ if (key === 'codexBin' && typeof value === 'string' && value !== '') merged.codexBin = value
54
+ if (key === 'codexSandbox' && typeof value === 'string' && value !== '') merged.codexSandbox = value
55
+ if (key === 'assumeDefaults') {
56
+ merged.assumeDefaults = value === true || value === 'true'
57
+ continue
58
+ }
59
+ if (key === 'assumptionPolicy' && typeof value === 'string') {
60
+ merged.assumptionPolicy = value.trim()
61
+ continue
62
+ }
63
+ if (key === 'claudeBin' && typeof value === 'string' && value !== '') merged.claudeBin = value
64
+ if (key === 'claudeModel' && typeof value === 'string') merged.claudeModel = value
65
+ if (key === 'workersFile' && typeof value === 'string' && value !== '') merged.workersFile = expandHome(value)
66
+ if (key === 'maxHops') {
67
+ const hops = Number(value)
68
+ if (Number.isInteger(hops) && hops >= 0) merged.maxHops = hops
69
+ }
70
+ if (key === 'dispatchPerMinute') {
71
+ const count = Number(value)
72
+ if (Number.isInteger(count) && count > 0) merged.dispatchPerMinute = count
73
+ }
74
+ if (key === 'dispatchWindowMs') {
75
+ const ms = Number(value)
76
+ if (Number.isFinite(ms) && ms > 0) merged.dispatchWindowMs = ms
77
+ }
78
+ if (key === 'maxWorkersPerRoot') {
79
+ const count = Number(value)
80
+ if (Number.isInteger(count) && count > 0) merged.maxWorkersPerRoot = count
81
+ }
82
+ if (key === 'workerTtlMs') {
83
+ const ms = Number(value)
84
+ if (Number.isFinite(ms) && ms > 0) merged.workerTtlMs = ms
85
+ }
86
+ if (key === 'maxWorkers') {
87
+ const count = Number(value)
88
+ if (Number.isInteger(count) && count > 0) merged.maxWorkers = count
89
+ }
90
+ if (key === 'waitTimeoutMs') {
91
+ const ms = Number(value)
92
+ if (Number.isFinite(ms) && ms > 0) merged.waitTimeoutMs = Math.min(MAX_BLOCKING_SECONDS * 1000, Math.max(1000, ms))
93
+ }
94
+ if (key === 'taskTimeoutSeconds') {
95
+ const seconds = Number(value)
96
+ if (Number.isFinite(seconds) && seconds > 0) merged.taskTimeoutSeconds = Math.min(MAX_BLOCKING_SECONDS, seconds)
97
+ }
98
+ if (key === 'claudeTimeoutMs') {
99
+ const ms = Number(value)
100
+ if (Number.isFinite(ms) && ms >= 1000) merged.claudeTimeoutMs = Math.min(ms, MAX_BLOCKING_SECONDS * 1000)
101
+ continue
102
+ }
103
+ if (key === 'codexTimeoutMs') {
104
+ const ms = Number(value)
105
+ if (Number.isFinite(ms) && ms > 0) merged.codexTimeoutMs = ms
106
+ }
107
+ }
108
+ return merged
109
+ }
110
+
111
+ /**
112
+ * Expand a leading `~`.
113
+ * @param path - configured path.
114
+ * @returns the absolute path.
115
+ */
116
+
117
+ export function expandHome(path) {
118
+ if (path === '~') return homedir()
119
+ if (path.startsWith('~/')) return join(homedir(), path.slice(2))
120
+ return path
121
+ }
package/src/tools.js ADDED
@@ -0,0 +1,228 @@
1
+ import { MAX_WAIT_SECONDS } from './constants.js'
2
+
3
+ export const TOOLS = [
4
+ {
5
+ name: 'dsh_task',
6
+ description:
7
+ 'Delegate one self-contained task to DeepSeek Harness and wait for the finished answer, exactly like spawning a subagent: the bridge opens a fresh DSH session in `cwd`, delivers the task, blocks up to 240 seconds, and returns the answer. A result with status "timeout" and running:true means the worker is STILL WORKING: keep the turn open and call dsh_wait or dsh_task again until it is no longer running. Never pass a timeout above 240 seconds — the MCP client aborts tool calls after 300. The prompt is delivered verbatim as an ordinary user message in that session, so write it as a work order for a colleague: do not mention your own tools, skills, or role framing — the worker has no such skill and will only waste a turn looking for it.',
8
+ inputSchema: {
9
+ type: 'object',
10
+ properties: {
11
+ prompt: { type: 'string', description: 'Self-contained task for the DSH worker, including acceptance criteria.' },
12
+ cwd: { type: 'string', description: 'Working directory the worker runs in. Defaults to the DSH default directory.' },
13
+ sessionId: {
14
+ type: 'string',
15
+ description: 'Continue an existing idle session (multi-turn worker) instead of opening a new one.',
16
+ },
17
+ agentPreset: { type: 'string', description: 'Agent preset for a new session.' },
18
+ model: {
19
+ type: 'string',
20
+ description:
21
+ 'Model to run this worker on, as "provider/model" (for example "zai-coding-cn/glm-5.3"). A bare model id keeps the current default provider. Omit to use whatever the session already uses.',
22
+ },
23
+ workspace: {
24
+ type: ['boolean', 'string'],
25
+ description:
26
+ 'Grouping for the new session. true creates a DSH project (workspace) for `cwd`, titled after its last path segment, when none owns that directory yet; a string uses that title instead. Omit (or false) to only reuse an existing project, leaving the session ungrouped when the directory has none.',
27
+ },
28
+ mode: {
29
+ type: 'string',
30
+ enum: ['queue', 'steer'],
31
+ description:
32
+ 'How a busy session receives this: "queue" (default) waits for the running turn to finish, "steer" inserts the message into that turn so the model sees it at the next step.',
33
+ },
34
+
35
+ reasoningEffort: {
36
+ type: 'string',
37
+ description: 'Reasoning level for `model`, if that model offers levels (low, medium, high, max...).',
38
+ },
39
+ timeoutSeconds: { type: 'number', description: 'How long to wait for the turn to finish. Default 300, max 600.' },
40
+ },
41
+ required: ['prompt'],
42
+ additionalProperties: false,
43
+ },
44
+ },
45
+ {
46
+ name: 'dsh_spawn',
47
+ description:
48
+ 'Start a worker and return immediately with its task_name, so several workers can run in parallel. This tool does NOT wait and the result is never pushed back, so you must collect it in this same turn with dsh_wait (each call blocks up to 240 seconds; repeat until every target is no longer "running") before you end the turn. Use fork_turns "all" with `from` to start the worker from a copy of an existing DSH session.',
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {
52
+ task_name: { type: 'string', description: 'Lowercase letters, digits, and underscores.' },
53
+ message: { type: 'string', description: 'Self-contained work order for the worker.' },
54
+ fork_turns: {
55
+ type: 'string',
56
+ enum: ['none', 'all'],
57
+ description: 'none starts empty (default); all forks the session named by `from`.',
58
+ },
59
+ from: { type: 'string', description: 'Session id to fork when fork_turns is "all".' },
60
+ cwd: { type: 'string', description: 'Working directory for a fresh worker.' },
61
+ model: {
62
+ type: 'string',
63
+ description:
64
+ 'Model for this worker, as "provider/model" (for example "opencode-go/deepseek-v4.1-flash"). A bare model id keeps the current default provider.',
65
+ },
66
+ workspace: {
67
+ type: ['boolean', 'string'],
68
+ description:
69
+ 'Grouping for the new worker. true creates a DSH project (workspace) for `cwd` when none owns it; a string uses that title. Omit to only reuse an existing project.',
70
+ },
71
+ mode: {
72
+ type: 'string',
73
+ enum: ['queue', 'steer'],
74
+ description:
75
+ 'How a busy session receives this: "queue" (default) waits for the running turn to finish, "steer" inserts the message into that turn so the model sees it at the next step.',
76
+ },
77
+
78
+ reasoningEffort: { type: 'string', description: 'Reasoning level for `model`, when that model offers levels.' },
79
+ },
80
+ required: ['task_name', 'message'],
81
+ additionalProperties: false,
82
+ },
83
+ },
84
+ {
85
+ name: 'dsh_followup',
86
+ description:
87
+ 'Give an existing worker another task and trigger a turn. The worker keeps its own conversation, so this is cheaper than spawning a new one.',
88
+ inputSchema: {
89
+ type: 'object',
90
+ properties: {
91
+ worker: { type: 'string', description: 'task_name from dsh_spawn, or a session id.' },
92
+ message: { type: 'string', description: 'Follow-up work order.' },
93
+ model: {
94
+ type: 'string',
95
+ description: 'Switch this worker to another model before the turn, as "provider/model". Omit to keep its current one.',
96
+ },
97
+ reasoningEffort: { type: 'string', description: 'Reasoning level for `model`, when that model offers levels.' },
98
+ mode: {
99
+ type: 'string',
100
+ enum: ['queue', 'steer'],
101
+ description:
102
+ 'How a busy session receives this: "queue" (default) waits for the running turn to finish, "steer" inserts the message into that turn so the model sees it at the next step.',
103
+ },
104
+ },
105
+ required: ['worker', 'message'],
106
+ additionalProperties: false,
107
+ },
108
+ },
109
+ {
110
+ name: 'dsh_close',
111
+ description:
112
+ 'Close a worker and free its concurrency slot. Finished workers still count against the limit until closed. Closing does not delete the session.',
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: { worker: { type: 'string', description: 'task_name or session id.' } },
116
+ required: ['worker'],
117
+ additionalProperties: false,
118
+ },
119
+ },
120
+ {
121
+ name: 'dsh_answer',
122
+ description:
123
+ 'Answer a question a worker is blocked on (ask_user_question) on the user behalf. dsh_wait reports such a question as pendingQuestion; every question id in it must appear exactly once in answers.',
124
+ inputSchema: {
125
+ type: 'object',
126
+ properties: {
127
+ worker: { type: 'string', description: 'task_name or session id of the blocked worker.' },
128
+ answers: {
129
+ type: 'array',
130
+ description: 'One entry per pending question.',
131
+ items: {
132
+ type: 'object',
133
+ properties: {
134
+ id: { type: 'string', description: 'Question id, copied from pendingQuestion.' },
135
+ selected: {
136
+ type: 'array',
137
+ items: { type: 'string' },
138
+ description: 'Chosen option labels; empty when the question is being skipped.',
139
+ },
140
+ custom: { type: 'string', description: 'Free-form answer when no option fits.' },
141
+ },
142
+ required: ['id', 'selected'],
143
+ additionalProperties: false,
144
+ },
145
+ },
146
+ },
147
+ required: ['worker', 'answers'],
148
+ additionalProperties: false,
149
+ },
150
+ },
151
+ {
152
+ name: 'dsh_list_workers',
153
+ description: 'List the workers this bridge started, with their task_name, session id, and live status.',
154
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
155
+ },
156
+ {
157
+ name: 'dsh_dispatch',
158
+ description:
159
+ 'Deliver one message into a DeepSeek Harness session as the user and start a turn. Returns the request id and the session event sequence at dispatch time; pass that sequence to dsh_read_result to collect the answer.',
160
+ inputSchema: {
161
+ type: 'object',
162
+ properties: {
163
+ sessionId: { type: 'string', description: 'Target DSH session id, from dsh_list_sessions.' },
164
+ text: { type: 'string', description: 'Message text to deliver.' },
165
+ mode: {
166
+ type: 'string',
167
+ enum: ['queue', 'steer'],
168
+ description: 'queue delivers as the next user turn; steer adds it to a running turn. Defaults to queue.',
169
+ },
170
+ },
171
+ required: ['sessionId', 'text'],
172
+ additionalProperties: false,
173
+ },
174
+ },
175
+ {
176
+ name: 'dsh_wait',
177
+ description:
178
+ 'Wait until any of the given workers reaches a final state, then return per-worker status. A finished worker carries its final answer only. Long waits beat polling: pass timeoutMs: 240000, and when the reply still lists "running" workers, call this again instead of ending the turn.',
179
+ inputSchema: {
180
+ type: 'object',
181
+ properties: {
182
+ targets: {
183
+ type: 'array',
184
+ items: { type: 'string' },
185
+ description: 'Session ids returned by dsh_task or dsh_dispatch.',
186
+ },
187
+ timeoutMs: {
188
+ type: 'number',
189
+ description: 'Wait timeout in milliseconds. Default 30000, min 1000, max 3600000.',
190
+ },
191
+ },
192
+ required: ['targets'],
193
+ additionalProperties: false,
194
+ },
195
+ },
196
+ {
197
+ name: 'dsh_read_result',
198
+ description:
199
+ 'Long-poll one session for assistant text produced after the given event sequence. Returns as soon as text appears, when the turn ends, or when the wait expires. Only sessions this bridge dispatched to are readable.',
200
+ inputSchema: {
201
+ type: 'object',
202
+ properties: {
203
+ sessionId: { type: 'string', description: 'Session id returned by dsh_dispatch.' },
204
+ sinceSeq: { type: 'number', description: 'The seqAtDispatch value returned by dsh_dispatch.' },
205
+ waitSeconds: {
206
+ type: 'number',
207
+ description: `How long to wait for the answer, at most ${MAX_WAIT_SECONDS}. Defaults to the plugin config.`,
208
+ },
209
+ },
210
+ required: ['sessionId', 'sinceSeq'],
211
+ additionalProperties: false,
212
+ },
213
+ },
214
+ {
215
+ name: 'dsh_list_sessions',
216
+ description:
217
+ 'List DeepSeek Harness sessions with metadata only: id, title, working directory, running state, and last activity. Never returns message content.',
218
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
219
+ },
220
+ ]
221
+
222
+ /**
223
+ * Standing instruction appended to a delegated task.
224
+ *
225
+ * A worker that stops to ask a question needs somebody at the UI; when the caller is
226
+ * another agent, nobody is. The caller would rather have a reasonable assumption and a
227
+ * finished result, so the worker is told to make one and report it.
228
+ */
package/src/utils.js ADDED
@@ -0,0 +1,55 @@
1
+ export function text(value) {
2
+ return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }
3
+ }
4
+
5
+ /**
6
+ * Build a JSON-RPC success response.
7
+ * @param id - request id.
8
+ * @param result - result payload.
9
+ * @returns the response.
10
+ */
11
+
12
+ export function ok(id, result) {
13
+ return { jsonrpc: '2.0', id, result }
14
+ }
15
+
16
+ /**
17
+ * Build a JSON-RPC error response.
18
+ * @param id - request id.
19
+ * @param code - JSON-RPC error code.
20
+ * @param message - human readable message.
21
+ * @returns the response.
22
+ */
23
+
24
+ export function errorResponse(id, code, message) {
25
+ return { jsonrpc: '2.0', id, error: { code, message } }
26
+ }
27
+
28
+ /**
29
+ * Merge row configuration over the defaults.
30
+ * @param config - row configuration.
31
+ * @returns resolved settings.
32
+ */
33
+
34
+ export function requireString(value, label) {
35
+ if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} must be a non-empty string`)
36
+ return value
37
+ }
38
+
39
+ /**
40
+ * Wait one short interval.
41
+ * @param ms - milliseconds.
42
+ * @returns a promise that settles after the interval.
43
+ */
44
+
45
+ export function sleep(ms) {
46
+ return new Promise((resolve) => {
47
+ setTimeout(resolve, ms)
48
+ })
49
+ }
50
+
51
+ /**
52
+ * Wrap a JSON value as MCP text content.
53
+ * @param value - JSON-serializable value.
54
+ * @returns the tool result.
55
+ */