dsh-plugin-subagent-bridge 0.9.0 → 0.10.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/README.md +21 -0
- package/index.js +264 -0
- package/package.json +1 -1
- package/skills/deepseek-worker/SKILL.md +22 -0
package/README.md
CHANGED
|
@@ -11,6 +11,27 @@
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
+
## 让 DSH 派活给 Claude
|
|
15
|
+
|
|
16
|
+
反向那一侧以前只有 Codex。现在多了 `claude_dispatch`:DSH 里的 agent 可以把任务交给 Claude Code,拿回最终回答。
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
claude_dispatch({ prompt: "审一下这个 PR 的风险", cwd: "/repo", resume: "a4925654-…", model: "sonnet" })
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| 参数 | 说明 |
|
|
23
|
+
| --- | --- |
|
|
24
|
+
| `prompt` | 必填,任务正文 |
|
|
25
|
+
| `cwd` | 工作目录;默认用当前会话目录 |
|
|
26
|
+
| `resume` | Claude Code 的 session id,续接同一段对话 |
|
|
27
|
+
| `model` | 模型别名或 id;不传则用配置,再退回 Claude 自己的设置 |
|
|
28
|
+
|
|
29
|
+
返回 `{ ok, text, sessionId, durationMs }`,`sessionId` 就是下次 `resume` 用的那个。
|
|
30
|
+
|
|
31
|
+
和 `codex_dispatch` 的差别只在底层:Claude Code 的 `-p` 模式**一次输出一个 JSON 对象**(`{type:"result", result, session_id, is_error}`),不像 Codex 是事件流。所以解析器只看 stdout 的 JSON,**stderr 的警告不算失败**——比如模型名带 `[1M]` 后缀时 CLI 会警告,但仍然正常返回。
|
|
32
|
+
|
|
33
|
+
配置项:`claudeBin`(默认 `claude`)、`claudeModel`(默认空,即用 CLI 自己的设置)、`claudeTimeoutMs`(默认 300 秒,上限 240 秒夹取)。
|
|
34
|
+
|
|
14
35
|
## 创建项目(workspace)
|
|
15
36
|
|
|
16
37
|
DSH 的侧边栏分组是"项目(workspace)",和会话的工作目录是两回事:新建会话默认落在「未分组」,除非它所在的目录已经是一个项目。
|
package/index.js
CHANGED
|
@@ -50,6 +50,14 @@ const MAX_RESULT_CHARS = 20000
|
|
|
50
50
|
const MAX_WAIT_SECONDS = 55
|
|
51
51
|
|
|
52
52
|
/** Where a `codex` binary commonly lives when the Host's PATH is minimal. */
|
|
53
|
+
const CLAUDE_CANDIDATES = [
|
|
54
|
+
'~/.local/bin/claude',
|
|
55
|
+
'~/.claude/local/claude',
|
|
56
|
+
'/opt/homebrew/bin/claude',
|
|
57
|
+
'/usr/local/bin/claude',
|
|
58
|
+
'~/.npm-global/bin/claude',
|
|
59
|
+
]
|
|
60
|
+
|
|
53
61
|
const CODEX_CANDIDATES = [
|
|
54
62
|
'~/Library/pnpm/bin/codex',
|
|
55
63
|
'~/.local/bin/codex',
|
|
@@ -67,6 +75,9 @@ const DEFAULTS = {
|
|
|
67
75
|
codexBin: 'codex',
|
|
68
76
|
codexSandbox: 'workspace-write',
|
|
69
77
|
codexTimeoutMs: 300000,
|
|
78
|
+
claudeBin: 'claude',
|
|
79
|
+
claudeModel: '',
|
|
80
|
+
claudeTimeoutMs: 300000,
|
|
70
81
|
taskTimeoutSeconds: MAX_BLOCKING_SECONDS,
|
|
71
82
|
waitTimeoutMs: MAX_BLOCKING_SECONDS * 1000,
|
|
72
83
|
maxWorkers: 12,
|
|
@@ -356,6 +367,62 @@ export function apply(ctx, config = {}) {
|
|
|
356
367
|
'subagent-bridge: codex tool',
|
|
357
368
|
)
|
|
358
369
|
})
|
|
370
|
+
|
|
371
|
+
/* Reverse direction for Claude Code: the same shape, a different CLI. */
|
|
372
|
+
ctx.inject(['tools'], (toolCtx) => {
|
|
373
|
+
toolCtx.effect(
|
|
374
|
+
() =>
|
|
375
|
+
toolCtx.tools.register({
|
|
376
|
+
name: 'claude_dispatch',
|
|
377
|
+
description:
|
|
378
|
+
'Hand a task to Claude Code (a separate coding agent) and return its final answer. Runs `claude -p` in the working directory; use `resume` with a previous sessionId to continue that conversation.',
|
|
379
|
+
parameters: {
|
|
380
|
+
type: 'object',
|
|
381
|
+
properties: {
|
|
382
|
+
prompt: { type: 'string', description: 'Task text for Claude Code.' },
|
|
383
|
+
cwd: { type: 'string', description: 'Working directory. Defaults to this session working directory.' },
|
|
384
|
+
resume: { type: 'string', description: 'Claude Code session id to continue instead of starting a new one.' },
|
|
385
|
+
model: { type: 'string', description: 'Model alias or id for this run. Defaults to the plugin config, then to Claude Code own settings.' },
|
|
386
|
+
},
|
|
387
|
+
required: ['prompt'],
|
|
388
|
+
additionalProperties: false,
|
|
389
|
+
},
|
|
390
|
+
output: {
|
|
391
|
+
schema: {
|
|
392
|
+
type: 'object',
|
|
393
|
+
properties: {
|
|
394
|
+
ok: { type: 'boolean' },
|
|
395
|
+
text: { type: 'string' },
|
|
396
|
+
sessionId: { type: 'string' },
|
|
397
|
+
exitCode: { type: 'number' },
|
|
398
|
+
durationMs: { type: 'number' },
|
|
399
|
+
error: { type: 'string' },
|
|
400
|
+
},
|
|
401
|
+
required: ['ok', 'text'],
|
|
402
|
+
additionalProperties: false,
|
|
403
|
+
},
|
|
404
|
+
render(_args, value) {
|
|
405
|
+
const result = value ?? {}
|
|
406
|
+
if (result.ok !== true) {
|
|
407
|
+
return [{ type: 'text', text: `claude_dispatch failed: ${String(result.error ?? 'unknown error')}` }]
|
|
408
|
+
}
|
|
409
|
+
const meta = [
|
|
410
|
+
typeof result.sessionId === 'string' ? `session ${result.sessionId}` : undefined,
|
|
411
|
+
typeof result.durationMs === 'number' ? `${Math.round(result.durationMs / 1000)}s` : undefined,
|
|
412
|
+
]
|
|
413
|
+
.filter((part) => part !== undefined)
|
|
414
|
+
.join(' · ')
|
|
415
|
+
const body = String(result.text ?? '')
|
|
416
|
+
return [{ type: 'text', text: meta === '' ? body : `${body}\n\n[claude ${meta}]` }]
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
timeoutMs: settings.claudeTimeoutMs + 15000,
|
|
420
|
+
isConcurrencySafe: () => false,
|
|
421
|
+
execute: (args, exec) => api.dispatchClaude(args, exec),
|
|
422
|
+
}),
|
|
423
|
+
'subagent-bridge: claude tool',
|
|
424
|
+
)
|
|
425
|
+
})
|
|
359
426
|
}
|
|
360
427
|
|
|
361
428
|
/**
|
|
@@ -912,6 +979,28 @@ export function createApi(ctx, state, settings) {
|
|
|
912
979
|
}
|
|
913
980
|
},
|
|
914
981
|
|
|
982
|
+
async dispatchClaude(args, exec) {
|
|
983
|
+
const prompt = requireString(args?.prompt, 'prompt')
|
|
984
|
+
const depth = guardDepth(state, settings, callerDepth(state, exec?.agent?.id), 'claude_dispatch')
|
|
985
|
+
takeDispatchSlot(state, settings)
|
|
986
|
+
const cwd = await resolveCwd(ctx, args?.cwd, exec)
|
|
987
|
+
enterChain(state, depth, settings.claudeTimeoutMs + 5000)
|
|
988
|
+
try {
|
|
989
|
+
return await runClaude({
|
|
990
|
+
bin: resolveClaude(settings.claudeBin),
|
|
991
|
+
prompt,
|
|
992
|
+
cwd,
|
|
993
|
+
resume: typeof args?.resume === 'string' && args.resume !== '' ? args.resume : undefined,
|
|
994
|
+
model: typeof args?.model === 'string' && args.model !== '' ? args.model : settings.claudeModel,
|
|
995
|
+
timeoutMs: settings.claudeTimeoutMs,
|
|
996
|
+
signal: exec?.signal,
|
|
997
|
+
children: state.children,
|
|
998
|
+
})
|
|
999
|
+
} finally {
|
|
1000
|
+
leaveChain(state)
|
|
1001
|
+
}
|
|
1002
|
+
},
|
|
1003
|
+
|
|
915
1004
|
async dispatchCodex(args, exec) {
|
|
916
1005
|
const prompt = requireString(args?.prompt, 'prompt')
|
|
917
1006
|
const depth = guardDepth(state, settings, callerDepth(state, exec?.agent?.id), 'codex_dispatch')
|
|
@@ -1620,6 +1709,174 @@ export function resolveCodex(configured) {
|
|
|
1620
1709
|
return undefined
|
|
1621
1710
|
}
|
|
1622
1711
|
|
|
1712
|
+
|
|
1713
|
+
/**
|
|
1714
|
+
* Locate the Claude Code executable.
|
|
1715
|
+
* @param configured - configured path or bare command name.
|
|
1716
|
+
* @returns the command to spawn, or undefined.
|
|
1717
|
+
*/
|
|
1718
|
+
export function resolveClaude(configured) {
|
|
1719
|
+
const configuredValue = typeof configured === 'string' ? configured : ''
|
|
1720
|
+
/* An explicit path is authoritative: never silently run a different binary. */
|
|
1721
|
+
if (configuredValue.includes('/')) {
|
|
1722
|
+
const expanded = expandHome(configuredValue)
|
|
1723
|
+
return existsSync(expanded) ? expanded : undefined
|
|
1724
|
+
}
|
|
1725
|
+
for (const directory of String(process.env.PATH ?? '').split(':')) {
|
|
1726
|
+
if (directory !== '' && existsSync(join(directory, 'claude'))) return 'claude'
|
|
1727
|
+
}
|
|
1728
|
+
for (const candidate of CLAUDE_CANDIDATES) {
|
|
1729
|
+
const expanded = expandHome(candidate)
|
|
1730
|
+
if (existsSync(expanded)) return expanded
|
|
1731
|
+
}
|
|
1732
|
+
return undefined
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/**
|
|
1736
|
+
* Run one `claude -p` turn and return its final answer.
|
|
1737
|
+
*
|
|
1738
|
+
* Claude Code prints a single JSON object in print mode, so the whole answer arrives at
|
|
1739
|
+
* once — unlike Codex, which streams events. Anything on stderr is advisory: the CLI
|
|
1740
|
+
* warns about an unrecognised model suffix while still succeeding, so only the JSON
|
|
1741
|
+
* envelope decides the outcome.
|
|
1742
|
+
* @param options - binary, prompt, cwd, resume id, model, timeout, signal, child registry.
|
|
1743
|
+
* @returns `{ ok, text, sessionId?, durationMs, exitCode, error? }`.
|
|
1744
|
+
*/
|
|
1745
|
+
export async function runClaude({
|
|
1746
|
+
bin,
|
|
1747
|
+
prompt,
|
|
1748
|
+
cwd,
|
|
1749
|
+
resume,
|
|
1750
|
+
model,
|
|
1751
|
+
timeoutMs,
|
|
1752
|
+
signal,
|
|
1753
|
+
children,
|
|
1754
|
+
spawnImpl = spawn,
|
|
1755
|
+
}) {
|
|
1756
|
+
const started = Date.now()
|
|
1757
|
+
if (bin === undefined) {
|
|
1758
|
+
return { ok: false, text: '', exitCode: -1, durationMs: 0, error: 'claude executable not found; set config.claudeBin' }
|
|
1759
|
+
}
|
|
1760
|
+
const args = ['-p', prompt, '--output-format', 'json']
|
|
1761
|
+
if (typeof resume === 'string' && resume !== '') args.unshift('--resume', resume)
|
|
1762
|
+
if (typeof model === 'string' && model !== '') args.push('--model', model)
|
|
1763
|
+
|
|
1764
|
+
const outcome = await new Promise((resolve) => {
|
|
1765
|
+
let child
|
|
1766
|
+
try {
|
|
1767
|
+
child = spawnImpl(bin, args, { cwd: cwd ?? homedir(), stdio: ['ignore', 'pipe', 'pipe'] })
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
resolve({ exitCode: -1, stdout: '', stderr: String(error), error: String(error) })
|
|
1770
|
+
return
|
|
1771
|
+
}
|
|
1772
|
+
children?.add(child)
|
|
1773
|
+
let stdout = ''
|
|
1774
|
+
let stderr = ''
|
|
1775
|
+
let settled = false
|
|
1776
|
+
const finish = (value) => {
|
|
1777
|
+
if (settled) return
|
|
1778
|
+
settled = true
|
|
1779
|
+
clearTimeout(timer)
|
|
1780
|
+
signal?.removeEventListener?.('abort', abort)
|
|
1781
|
+
children?.delete(child)
|
|
1782
|
+
resolve(value)
|
|
1783
|
+
}
|
|
1784
|
+
const kill = (reason) => {
|
|
1785
|
+
try {
|
|
1786
|
+
child.kill('SIGTERM')
|
|
1787
|
+
} catch {
|
|
1788
|
+
/* Already gone. */
|
|
1789
|
+
}
|
|
1790
|
+
setTimeout(() => {
|
|
1791
|
+
try {
|
|
1792
|
+
child.kill('SIGKILL')
|
|
1793
|
+
} catch {
|
|
1794
|
+
/* Already gone. */
|
|
1795
|
+
}
|
|
1796
|
+
}, 5000).unref?.()
|
|
1797
|
+
finish({ exitCode: -1, stdout, stderr, error: reason })
|
|
1798
|
+
}
|
|
1799
|
+
const timer = setTimeout(() => kill(`claude timed out after ${timeoutMs} ms`), Math.max(1000, timeoutMs ?? 300000))
|
|
1800
|
+
const abort = () => kill('cancelled by the caller')
|
|
1801
|
+
if (signal?.aborted === true) abort()
|
|
1802
|
+
else signal?.addEventListener?.('abort', abort)
|
|
1803
|
+
child.stdout?.on('data', (chunk) => {
|
|
1804
|
+
stdout += chunk.toString('utf8')
|
|
1805
|
+
})
|
|
1806
|
+
child.stderr?.on('data', (chunk) => {
|
|
1807
|
+
stderr += chunk.toString('utf8')
|
|
1808
|
+
})
|
|
1809
|
+
child.on('error', (error) => finish({ exitCode: -1, stdout, stderr, error: error.message }))
|
|
1810
|
+
child.on('close', (code) => finish({ exitCode: code ?? -1, stdout, stderr }))
|
|
1811
|
+
})
|
|
1812
|
+
|
|
1813
|
+
const parsed = parseClaudeOutput(outcome.stdout)
|
|
1814
|
+
const durationMs = Date.now() - started
|
|
1815
|
+
const build = (ok, error) => ({
|
|
1816
|
+
ok,
|
|
1817
|
+
text: parsed.text,
|
|
1818
|
+
exitCode: ok ? 0 : outcome.exitCode,
|
|
1819
|
+
durationMs,
|
|
1820
|
+
...(parsed.sessionId === undefined ? {} : { sessionId: parsed.sessionId }),
|
|
1821
|
+
...(error === undefined ? {} : { error }),
|
|
1822
|
+
})
|
|
1823
|
+
if (outcome.error !== undefined) return build(false, outcome.error)
|
|
1824
|
+
if (parsed.error !== undefined) return build(false, parsed.error)
|
|
1825
|
+
if (outcome.exitCode !== 0) {
|
|
1826
|
+
const detail = outcome.stderr.trim().split('\n').slice(-3).join('\n')
|
|
1827
|
+
return build(false, `claude exited with ${outcome.exitCode}${detail === '' ? '' : `: ${detail}`}`)
|
|
1828
|
+
}
|
|
1829
|
+
return build(true, undefined)
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* Read the print-mode JSON envelope.
|
|
1834
|
+
* @param stdout - raw standard output.
|
|
1835
|
+
* @returns `{ text, sessionId?, error? }`.
|
|
1836
|
+
*/
|
|
1837
|
+
export function parseClaudeOutput(stdout) {
|
|
1838
|
+
const raw = String(stdout ?? '').trim()
|
|
1839
|
+
if (raw === '') return { text: '', error: 'claude printed nothing' }
|
|
1840
|
+
/* A single object is the documented shape, but the CLI writes advisory lines to the
|
|
1841
|
+
* same stream, and those lines can carry braces of their own. Parse whole output first,
|
|
1842
|
+
* then scan lines backwards for the envelope that carries a result. */
|
|
1843
|
+
const lines = raw.split('\n').map((line) => line.trim()).filter((line) => line !== '')
|
|
1844
|
+
const candidates = [...lines].reverse()
|
|
1845
|
+
let payload
|
|
1846
|
+
let lastError
|
|
1847
|
+
for (const candidate of candidates) {
|
|
1848
|
+
if (!candidate.startsWith('{') || !candidate.endsWith('}')) continue
|
|
1849
|
+
try {
|
|
1850
|
+
const parsed = JSON.parse(candidate)
|
|
1851
|
+
if (typeof parsed === 'object' && parsed !== null && typeof parsed.type === 'string') {
|
|
1852
|
+
payload = parsed
|
|
1853
|
+
break
|
|
1854
|
+
}
|
|
1855
|
+
} catch (error) {
|
|
1856
|
+
lastError = error
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
if (payload === undefined) {
|
|
1860
|
+
if (lastError !== undefined) {
|
|
1861
|
+
return { text: '', error: `claude printed invalid JSON: ${String(lastError?.message ?? lastError)}` }
|
|
1862
|
+
}
|
|
1863
|
+
return { text: '', error: `claude printed no result envelope: ${raw.slice(0, 200)}` }
|
|
1864
|
+
}
|
|
1865
|
+
const text = typeof payload.result === 'string' ? payload.result.trim() : ''
|
|
1866
|
+
const sessionId = typeof payload.session_id === 'string' && payload.session_id !== '' ? payload.session_id : undefined
|
|
1867
|
+
const failed = payload.is_error === true || (typeof payload.subtype === 'string' && payload.subtype !== 'success')
|
|
1868
|
+
const build = {
|
|
1869
|
+
text,
|
|
1870
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
1871
|
+
}
|
|
1872
|
+
if (failed !== true) return build
|
|
1873
|
+
const detail =
|
|
1874
|
+
typeof payload.result === 'string' && payload.result.trim() !== ''
|
|
1875
|
+
? payload.result.trim()
|
|
1876
|
+
: `claude reported ${String(payload.subtype ?? payload.terminal_reason ?? 'an error')}`
|
|
1877
|
+
return { ...build, error: detail }
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1623
1880
|
/**
|
|
1624
1881
|
* Read a file, tolerating absence.
|
|
1625
1882
|
* @param path - file path.
|
|
@@ -1799,6 +2056,8 @@ function resolveSettings(config) {
|
|
|
1799
2056
|
}
|
|
1800
2057
|
if (key === 'codexBin' && typeof value === 'string' && value !== '') merged.codexBin = value
|
|
1801
2058
|
if (key === 'codexSandbox' && typeof value === 'string' && value !== '') merged.codexSandbox = value
|
|
2059
|
+
if (key === 'claudeBin' && typeof value === 'string' && value !== '') merged.claudeBin = value
|
|
2060
|
+
if (key === 'claudeModel' && typeof value === 'string') merged.claudeModel = value
|
|
1802
2061
|
if (key === 'workersFile' && typeof value === 'string' && value !== '') merged.workersFile = expandHome(value)
|
|
1803
2062
|
if (key === 'maxHops') {
|
|
1804
2063
|
const hops = Number(value)
|
|
@@ -1832,6 +2091,11 @@ function resolveSettings(config) {
|
|
|
1832
2091
|
const seconds = Number(value)
|
|
1833
2092
|
if (Number.isFinite(seconds) && seconds > 0) merged.taskTimeoutSeconds = Math.min(MAX_BLOCKING_SECONDS, seconds)
|
|
1834
2093
|
}
|
|
2094
|
+
if (key === 'claudeTimeoutMs') {
|
|
2095
|
+
const ms = Number(value)
|
|
2096
|
+
if (Number.isFinite(ms) && ms >= 1000) merged.claudeTimeoutMs = Math.min(ms, MAX_BLOCKING_SECONDS * 1000)
|
|
2097
|
+
continue
|
|
2098
|
+
}
|
|
1835
2099
|
if (key === 'codexTimeoutMs') {
|
|
1836
2100
|
const ms = Number(value)
|
|
1837
2101
|
if (Number.isFinite(ms) && ms > 0) merged.codexTimeoutMs = ms
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-subagent-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Subagent bridge: lets Codex delegate a task to DeepSeek Harness the way it would spawn a subagent (one call, waits for the answer), plus a codex_dispatch tool for the reverse direction.",
|
|
6
6
|
"keywords": [
|
|
@@ -148,6 +148,28 @@ DSH 自己的委派深度上限是 1:worker(depth 0)能开子 agent(dept
|
|
|
148
148
|
写错了(`/glm-5.3`、`a/b/c`)桥会在**发 prompt 之前**报错,不会白跑一轮。
|
|
149
149
|
|
|
150
150
|
|
|
151
|
+
## 新建项目:`workspace`
|
|
152
|
+
|
|
153
|
+
DSH 侧边栏的分组叫「项目(workspace)」,和会话的工作目录是两回事。桥默认只**复用**已有项目:`cwd` 恰好是一个项目的目录,会话就归进去;否则落在「未分组」。
|
|
154
|
+
|
|
155
|
+
要在 DSH 里**新建**一个和 Codex 这边对等的项目,加 `workspace`:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{ "prompt": "…", "cwd": "/repo/clawer", "workspace": true }
|
|
159
|
+
{ "prompt": "…", "cwd": "/repo/clawer", "workspace": "Clawer" }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- `true` → 用 `cwd` 建项目,标题取目录名;
|
|
163
|
+
- 字符串 → 同上,但用这个标题;
|
|
164
|
+
- 目录已是项目 → **复用,不改标题**,不会重复创建;
|
|
165
|
+
- 目录不存在 → 先建目录再登记项目;
|
|
166
|
+
- 不传 → 只复用,没有项目就未分组(默认行为)。
|
|
167
|
+
|
|
168
|
+
**什么时候传**:用户说「创建一个 xxx 项目」「在 DSH 里也建一个」「别让它落到未分组」时。单纯派活、目录已经是项目时不用传——传了也只是复用。
|
|
169
|
+
|
|
170
|
+
返回里带 `workspace: { id, title, path }`,可以据此确认落在哪个项目下。
|
|
171
|
+
|
|
172
|
+
|
|
151
173
|
## 怎么写 prompt
|
|
152
174
|
|
|
153
175
|
worker **看不到你这段对话**,它只有 `prompt` 和 `cwd`。写成可以独立开工的工单:
|