dsh-plugin-subagent-bridge 0.13.1 → 0.14.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.
Files changed (3) hide show
  1. package/README.md +42 -0
  2. package/index.js +121 -2
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -56,6 +56,48 @@ dsh_task({ prompt: "…", cwd: "/repo/clawer" })
56
56
  - 返回结果里带上 `workspace: { id, title, path }`,方便确认落在哪个项目;
57
57
  - 项目操作失败不影响派发本身(分组是外观,不因此让任务失败)。
58
58
 
59
+ ## 回答 worker 的问题(`dsh_answer`)
60
+
61
+ worker 需要用户拍板时会调用 `ask_user_question`,然后**卡在那里等**。如果人不在,任务就停住了。
62
+
63
+ 桥把这种情况暴露出来:
64
+
65
+ ```jsonc
66
+ // dsh_wait 的返回
67
+ {
68
+ "status": { "w1": "running" },
69
+ "timedOut": true,
70
+ "pendingQuestion": {
71
+ "callId": "call_abc",
72
+ "questions": [
73
+ {
74
+ "id": "mode",
75
+ "question": "用哪种模式?",
76
+ "header": "Choose Mode",
77
+ "options": [{ "label": "快速", "description": "只跑单测" }, { "label": "完整" }]
78
+ }
79
+ ]
80
+ },
81
+ "next": "blocked on a question: answer it with dsh_answer, or call dsh_wait again"
82
+ }
83
+ ```
84
+
85
+ 调用方(Codex / Claude)看到 `pendingQuestion` 就可以代用户回答:
86
+
87
+ ```jsonc
88
+ dsh_answer({
89
+ "worker": "w1",
90
+ "answers": [{ "id": "mode", "selected": ["快速"] }] // custom 可用于自由文本
91
+ })
92
+ ```
93
+
94
+ 细节:
95
+
96
+ - 一个问题的 id 必须**恰好出现一次**,否则底层会报 `BAD_ANSWER`;
97
+ - `selected` 是选项 label 数组,多选时给多个;跳过一个问题就给空数组;
98
+ - 回答是插进正在等待的那个回合的(底层用 `steer`),worker 立刻继续;
99
+ - 只有真卡在 `ask_user_question` 上才算「等待中」——工具一旦返回结果就不再报。
100
+
59
101
  ## 排队还是插话(`mode`)
60
102
 
61
103
  DSH 的「繁忙时的发送行为」只有两种,桥把它们原样交给调用方:
package/index.js CHANGED
@@ -207,6 +207,37 @@ const TOOLS = [
207
207
  additionalProperties: false,
208
208
  },
209
209
  },
210
+ {
211
+ name: 'dsh_answer',
212
+ description:
213
+ '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.',
214
+ inputSchema: {
215
+ type: 'object',
216
+ properties: {
217
+ worker: { type: 'string', description: 'task_name or session id of the blocked worker.' },
218
+ answers: {
219
+ type: 'array',
220
+ description: 'One entry per pending question.',
221
+ items: {
222
+ type: 'object',
223
+ properties: {
224
+ id: { type: 'string', description: 'Question id, copied from pendingQuestion.' },
225
+ selected: {
226
+ type: 'array',
227
+ items: { type: 'string' },
228
+ description: 'Chosen option labels; empty when the question is being skipped.',
229
+ },
230
+ custom: { type: 'string', description: 'Free-form answer when no option fits.' },
231
+ },
232
+ required: ['id', 'selected'],
233
+ additionalProperties: false,
234
+ },
235
+ },
236
+ },
237
+ required: ['worker', 'answers'],
238
+ additionalProperties: false,
239
+ },
240
+ },
210
241
  {
211
242
  name: 'dsh_list_workers',
212
243
  description: 'List the workers this bridge started, with their task_name, session id, and live status.',
@@ -278,6 +309,61 @@ const TOOLS = [
278
309
  },
279
310
  ]
280
311
 
312
+ /**
313
+ * The question a worker is currently blocked on, if any.
314
+ * `ask_user_question` records a tool call and waits for an answer; while it waits the call
315
+ * has no result yet, which is exactly the signal used here.
316
+ * @param ctx - Host context.
317
+ * @param sessionId - session to inspect.
318
+ * @returns the pending call and its questions, or undefined.
319
+ */
320
+ function askable(state, ctx, targets) {
321
+ for (const target of targets) {
322
+ const resolved = resolveWorker(state, target)
323
+ const sessionId = resolved === undefined ? target : resolved.sessionId
324
+ const asked = pendingQuestion(ctx, sessionId)
325
+ if (asked !== undefined) return { worker: target, ...asked }
326
+ }
327
+ return undefined
328
+ }
329
+
330
+ export function pendingQuestion(ctx, sessionId) {
331
+ const session = ctx.get('sessions')?.get?.(sessionId)
332
+ if (session === undefined || session === null) return undefined
333
+ const settled = new Set()
334
+ let waiting
335
+ for (const event of session.snapshotEvents()) {
336
+ if (event.type === 'tool/result' && typeof event.data?.callId === 'string') settled.add(event.data.callId)
337
+ if (event.type === 'tool/call' && event.data?.name === 'ask_user_question') waiting = event.data
338
+ }
339
+ if (waiting === undefined || settled.has(waiting.callId)) return undefined
340
+ let parsed
341
+ try {
342
+ parsed = JSON.parse(typeof waiting.arguments === 'string' ? waiting.arguments : '{}')
343
+ } catch {
344
+ return undefined
345
+ }
346
+ const questions = Array.isArray(parsed?.questions) ? parsed.questions : []
347
+ if (questions.length === 0) return undefined
348
+ return {
349
+ callId: String(waiting.callId),
350
+ questions: questions.map((question) => ({
351
+ id: String(question?.id ?? ''),
352
+ question: String(question?.question ?? ''),
353
+ ...(typeof question?.header === 'string' ? { header: question.header } : {}),
354
+ ...(question?.multi_select === true ? { multiSelect: true } : {}),
355
+ ...(Array.isArray(question?.options)
356
+ ? {
357
+ options: question.options.map((option) => ({
358
+ label: String(option?.label ?? ''),
359
+ ...(typeof option?.description === 'string' ? { description: option.description } : {}),
360
+ })),
361
+ }
362
+ : {}),
363
+ })),
364
+ }
365
+ }
366
+
281
367
  /**
282
368
  * Sequence number of the first message the caller itself delivered after `sinceSeq`.
283
369
  * With steer the reply shares the running turn, so "the last assistant text of the turn"
@@ -659,6 +745,20 @@ async function callTool(id, params, api, signal) {
659
745
  const worker = requireString(args.worker, 'worker')
660
746
  return ok(id, text(await api.close({ worker })))
661
747
  }
748
+ case 'dsh_answer': {
749
+ const worker = requireString(args.worker, 'worker')
750
+ if (!Array.isArray(args.answers) || args.answers.length === 0) throw new Error('answers must be a non-empty array')
751
+ const answers = args.answers.map((entry, index) => {
752
+ const questionId = requireString(entry?.id, `answers[${index}].id`)
753
+ if (!Array.isArray(entry?.selected)) throw new Error(`answers[${index}].selected must be an array`)
754
+ return {
755
+ id: questionId,
756
+ selected: entry.selected.map((label, at) => requireString(label, `answers[${index}].selected[${at}]`)),
757
+ ...(typeof entry.custom === 'string' && entry.custom !== '' ? { custom: entry.custom } : {}),
758
+ }
759
+ })
760
+ return ok(id, text(await api.answer({ worker, answers })))
761
+ }
662
762
  case 'dsh_list_workers':
663
763
  return ok(id, text(await api.listWorkers()))
664
764
  case 'dsh_wait': {
@@ -977,18 +1077,23 @@ export function createApi(ctx, state, settings) {
977
1077
  if (typeof value !== 'string' || final) settled = true
978
1078
  }
979
1079
  const waitedMs = Date.now() - started
980
- if (settled) return { status, timedOut: false, waitedMs }
1080
+ const asked = askable(state, ctx, targets)
1081
+ if (settled) return { status, timedOut: false, waitedMs, ...(asked === undefined ? {} : { pendingQuestion: asked }) }
981
1082
  if (waitedMs >= budget) {
982
1083
  const still = targets.filter((target) => status[target] === 'running' || status[target] === 'pending')
983
1084
  return {
984
1085
  status,
985
1086
  timedOut: true,
986
1087
  waitedMs,
1088
+ ...(asked === undefined ? {} : { pendingQuestion: asked }),
987
1089
  ...(still.length === 0
988
1090
  ? {}
989
1091
  : {
990
1092
  running: true,
991
- next: `still running: ${still.join(', ')}; call dsh_wait again with the same targets until none of them are "running"`,
1093
+ next:
1094
+ asked === undefined
1095
+ ? `still running: ${still.join(', ')}; call dsh_wait again with the same targets until none of them are "running"`
1096
+ : `blocked on a question: answer it with dsh_answer, or call dsh_wait again`,
992
1097
  }),
993
1098
  }
994
1099
  }
@@ -996,6 +1101,20 @@ export function createApi(ctx, state, settings) {
996
1101
  }
997
1102
  },
998
1103
 
1104
+ async answer({ worker, sessionId, answers }) {
1105
+ const target = worker === undefined ? sessionId : (resolveWorker(state, worker)?.sessionId ?? worker)
1106
+ if (typeof target !== 'string' || target === '') throw new Error('name a worker or a sessionId')
1107
+ const asked = pendingQuestion(ctx, target)
1108
+ if (asked === undefined) throw new Error('that worker is not waiting on a question')
1109
+ const service = ctx.get('userQuestions')
1110
+ const agent = ctx.get('agents')?.get?.(target)
1111
+ if (service === undefined || service === null || agent === undefined) {
1112
+ throw new Error('the question service is unavailable')
1113
+ }
1114
+ const accepted = service.answer(agent, asked.callId, { answers })
1115
+ return { answered: accepted === true, callId: asked.callId, sessionId: target }
1116
+ },
1117
+
999
1118
  async dispatch({ sessionId, text, mode }) {
1000
1119
  const service = controller()
1001
1120
  if (service === undefined || service === null) throw new Error('session controller is unavailable')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subagent-bridge",
3
- "version": "0.13.1",
3
+ "version": "0.14.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": [