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 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.9.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`。写成可以独立开工的工单: