dsh-plugin-subagent-bridge 0.13.0 → 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 +44 -0
  2. package/index.js +145 -3
  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 的「繁忙时的发送行为」只有两种,桥把它们原样交给调用方:
@@ -79,6 +121,8 @@ dsh_task({ sessionId: "session-…", prompt: "停一下,改成只跑单测", m
79
121
 
80
122
  `dsh_task` 对正在运行的会话默认会拒绝,错误信息里会提示改用 `steer`。
81
123
 
124
+ 第三个细节是返回值:插话的回复和原任务在**同一个回合**里,所以不能简单取"这一轮最后一条 assistant 文本"——那可能是原任务后面的步骤。桥会在插话后定位到**自己那条 user/message**,只取它之后的 assistant 文本作为回答。
125
+
82
126
  ## 创建项目(workspace)
83
127
 
84
128
  DSH 的侧边栏分组是"项目(workspace)",和会话的工作目录是两回事:新建会话默认落在「未分组」,除非它所在的目录已经是一个项目。
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,81 @@ 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
+
367
+ /**
368
+ * Sequence number of the first message the caller itself delivered after `sinceSeq`.
369
+ * With steer the reply shares the running turn, so "the last assistant text of the turn"
370
+ * can be a step of the original task; anchoring on our own message keeps it exact.
371
+ * @param ctx - Host context.
372
+ * @param sessionId - session to read.
373
+ * @param sinceSeq - sequence captured before the prompt was delivered.
374
+ * @returns the sequence, or the original value when nothing matches.
375
+ */
376
+ function firstDeliveredMessageSeq(ctx, sessionId, sinceSeq) {
377
+ const session = ctx.get('sessions')?.get?.(sessionId)
378
+ if (session === undefined || session === null) return sinceSeq
379
+ for (const event of session.snapshotEvents()) {
380
+ if (Number(event.seq) <= sinceSeq) continue
381
+ if (event.type !== 'user/message') continue
382
+ return Number(event.seq)
383
+ }
384
+ return sinceSeq
385
+ }
386
+
281
387
  /**
282
388
  * Start the bridge.
283
389
  * @param ctx - Host context.
@@ -639,6 +745,20 @@ async function callTool(id, params, api, signal) {
639
745
  const worker = requireString(args.worker, 'worker')
640
746
  return ok(id, text(await api.close({ worker })))
641
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
+ }
642
762
  case 'dsh_list_workers':
643
763
  return ok(id, text(await api.listWorkers()))
644
764
  case 'dsh_wait': {
@@ -908,7 +1028,10 @@ export function createApi(ctx, state, settings) {
908
1028
  }
909
1029
 
910
1030
  const end = await finished
911
- const collected = finalAnswer(ctx, targetId, before, settings)
1031
+ /* A steered reply lives inside the turn that was already running, so anchor the
1032
+ * answer on the message we delivered instead of the turn's last assistant text. */
1033
+ const answerFrom = deliveryMode === 'steer' ? firstDeliveredMessageSeq(ctx, targetId, before) : before
1034
+ const collected = finalAnswer(ctx, targetId, answerFrom, settings)
912
1035
  const turnReason = end?.reason?.kind ?? null
913
1036
  const durationMs = Date.now() - started
914
1037
  const status = end === undefined ? 'timeout' : turnReason === 'completed' ? 'completed' : `turn-${String(turnReason)}`
@@ -954,18 +1077,23 @@ export function createApi(ctx, state, settings) {
954
1077
  if (typeof value !== 'string' || final) settled = true
955
1078
  }
956
1079
  const waitedMs = Date.now() - started
957
- 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 }) }
958
1082
  if (waitedMs >= budget) {
959
1083
  const still = targets.filter((target) => status[target] === 'running' || status[target] === 'pending')
960
1084
  return {
961
1085
  status,
962
1086
  timedOut: true,
963
1087
  waitedMs,
1088
+ ...(asked === undefined ? {} : { pendingQuestion: asked }),
964
1089
  ...(still.length === 0
965
1090
  ? {}
966
1091
  : {
967
1092
  running: true,
968
- 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`,
969
1097
  }),
970
1098
  }
971
1099
  }
@@ -973,6 +1101,20 @@ export function createApi(ctx, state, settings) {
973
1101
  }
974
1102
  },
975
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
+
976
1118
  async dispatch({ sessionId, text, mode }) {
977
1119
  const service = controller()
978
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.0",
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": [