dsh-plugin-subagent-bridge 0.14.0 → 0.14.2

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 (2) hide show
  1. package/index.js +84 -4
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -309,6 +309,57 @@ const TOOLS = [
309
309
  },
310
310
  ]
311
311
 
312
+ /**
313
+ * Take over the questions a worker asks, so the calling agent can answer them.
314
+ *
315
+ * `ask_user_question` pauses the tool call until something answers on the
316
+ * `user-questions/request` waterfall. The Web UI answers from the browser; for a worker
317
+ * nobody is watching, the bridge answers instead and forwards the question to its caller.
318
+ * @param state - bridge state.
319
+ * @param agent - live agent of the worker session.
320
+ */
321
+ function attachQuestionAnswerer(state, agent) {
322
+ if (agent?.ctx === undefined || typeof agent.ctx.on !== 'function') return
323
+ state.openQuestions ??= new Map()
324
+ state.questionAnswerers ??= new Map()
325
+ const sessionId = String(agent.id)
326
+ if (state.questionAnswerers.has(sessionId)) return
327
+ const handler = (request, next) => {
328
+ const questions = Array.isArray(request?.questions) ? request.questions : []
329
+ if (questions.length === 0) return next()
330
+ return new Promise((resolve, reject) => {
331
+ const waiter = {
332
+ questions,
333
+ settle(answer) {
334
+ state.openQuestions.delete(sessionId)
335
+ waiter.cleanup()
336
+ resolve(answer)
337
+ },
338
+ fail(error) {
339
+ state.openQuestions.delete(sessionId)
340
+ waiter.cleanup()
341
+ reject(error)
342
+ },
343
+ }
344
+ const signal = request?.signal
345
+ const onAbort = () => waiter.fail(new Error('the question was withdrawn'))
346
+ waiter.cleanup = () => signal?.removeEventListener?.('abort', onAbort)
347
+ if (signal?.aborted === true) {
348
+ waiter.fail(new Error('the question was withdrawn'))
349
+ return
350
+ }
351
+ signal?.addEventListener?.('abort', onAbort)
352
+ state.openQuestions.set(sessionId, waiter)
353
+ })
354
+ }
355
+ try {
356
+ const off = agent.ctx.on('user-questions/request', handler)
357
+ state.questionAnswerers.set(sessionId, typeof off === 'function' ? off : () => {})
358
+ } catch {
359
+ /* An older Host without the waterfall: the continued-state path still works. */
360
+ }
361
+ }
362
+
312
363
  /**
313
364
  * The question a worker is currently blocked on, if any.
314
365
  * `ask_user_question` records a tool call and waits for an answer; while it waits the call
@@ -321,13 +372,33 @@ function askable(state, ctx, targets) {
321
372
  for (const target of targets) {
322
373
  const resolved = resolveWorker(state, target)
323
374
  const sessionId = resolved === undefined ? target : resolved.sessionId
324
- const asked = pendingQuestion(ctx, sessionId)
375
+ const asked = pendingQuestion(ctx, sessionId, state)
325
376
  if (asked !== undefined) return { worker: target, ...asked }
326
377
  }
327
378
  return undefined
328
379
  }
329
380
 
330
- export function pendingQuestion(ctx, sessionId) {
381
+ export function pendingQuestion(ctx, sessionId, state) {
382
+ const open = state?.openQuestions?.get(sessionId)
383
+ if (open !== undefined) {
384
+ return {
385
+ callId: `open:${sessionId}`,
386
+ questions: open.questions.map((question) => ({
387
+ id: String(question?.id ?? ''),
388
+ question: String(question?.question ?? ''),
389
+ ...(typeof question?.header === 'string' ? { header: question.header } : {}),
390
+ ...(question?.multiSelect === true ? { multiSelect: true } : {}),
391
+ ...(Array.isArray(question?.options)
392
+ ? {
393
+ options: question.options.map((option) => ({
394
+ label: String(option?.label ?? ''),
395
+ ...(typeof option?.description === 'string' ? { description: option.description } : {}),
396
+ })),
397
+ }
398
+ : {}),
399
+ })),
400
+ }
401
+ }
331
402
  const session = ctx.get('sessions')?.get?.(sessionId)
332
403
  if (session === undefined || session === null) return undefined
333
404
  const settled = new Set()
@@ -393,7 +464,7 @@ export function apply(ctx, config = {}) {
393
464
  const settings = resolveSettings(config)
394
465
  if (settings.enabled !== true) return
395
466
 
396
- const state = { dispatched: new Set(), children: new Set() }
467
+ const state = { dispatched: new Set(), children: new Set(), openQuestions: new Map(), questionAnswerers: new Map() }
397
468
  const api = createApi(ctx, state, settings)
398
469
  let bridge
399
470
 
@@ -890,6 +961,7 @@ export function createApi(ctx, state, settings) {
890
961
  }
891
962
 
892
963
  const appliedModel = await applyModelSpec(ctx, sessionId, model, reasoningEffort)
964
+ attachQuestionAnswerer(state, ctx.get('agents')?.get?.(sessionId))
893
965
  const before = Number(sessions()?.get?.(sessionId)?.seq ?? 0)
894
966
  const requestId = randomUUID()
895
967
  const accepted = await service.prompt(
@@ -998,6 +1070,7 @@ export function createApi(ctx, state, settings) {
998
1070
  const appliedModel = await applyModelSpec(ctx, targetId, model, reasoningEffort)
999
1071
  /* DSH delivers a prompt either queued behind the running turn or steered into it. */
1000
1072
  const deliveryMode = mode === 'steer' ? 'steer' : 'queue'
1073
+ attachQuestionAnswerer(state, ctx.get('agents')?.get?.(targetId))
1001
1074
  const turnBefore = lastTurn(ctx, targetId)
1002
1075
  /* A steered message is absorbed by the turn that is already running, so waiting for
1003
1076
  * a strictly newer turn would hang until the timeout even after the answer landed. */
@@ -1068,6 +1141,7 @@ export function createApi(ctx, state, settings) {
1068
1141
  let settled = false
1069
1142
  for (const target of targets) {
1070
1143
  const resolved = resolveWorker(state, target)
1144
+ if (resolved !== undefined) attachQuestionAnswerer(state, ctx.get('agents')?.get?.(resolved.sessionId))
1071
1145
  const sessionId = resolved === undefined ? target : resolved.sessionId
1072
1146
  const value = resolved === undefined ? 'not_found' : workerStatus(ctx, sessionId, state, settings)
1073
1147
  if (resolved !== undefined) await rememberOutcome(settings, state, sessionId, value)
@@ -1104,7 +1178,13 @@ export function createApi(ctx, state, settings) {
1104
1178
  async answer({ worker, sessionId, answers }) {
1105
1179
  const target = worker === undefined ? sessionId : (resolveWorker(state, worker)?.sessionId ?? worker)
1106
1180
  if (typeof target !== 'string' || target === '') throw new Error('name a worker or a sessionId')
1107
- const asked = pendingQuestion(ctx, target)
1181
+ const open = state.openQuestions?.get(target)
1182
+ if (open !== undefined) {
1183
+ /* The tool call is still blocked, so hand the batch straight to its waiter. */
1184
+ open.settle({ answers })
1185
+ return { answered: true, callId: `open:${target}`, sessionId: target }
1186
+ }
1187
+ const asked = pendingQuestion(ctx, target, state)
1108
1188
  if (asked === undefined) throw new Error('that worker is not waiting on a question')
1109
1189
  const service = ctx.get('userQuestions')
1110
1190
  const agent = ctx.get('agents')?.get?.(target)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subagent-bridge",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
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": [