dsh-plugin-subagent-bridge 0.12.2 → 0.13.1

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
@@ -56,6 +56,55 @@ dsh_task({ prompt: "…", cwd: "/repo/clawer" })
56
56
  - 返回结果里带上 `workspace: { id, title, path }`,方便确认落在哪个项目;
57
57
  - 项目操作失败不影响派发本身(分组是外观,不因此让任务失败)。
58
58
 
59
+ ## 排队还是插话(`mode`)
60
+
61
+ DSH 的「繁忙时的发送行为」只有两种,桥把它们原样交给调用方:
62
+
63
+ | `mode` | DSH 里的名字 | 行为 |
64
+ | --- | --- | --- |
65
+ | `queue`(默认) | 排队发送 | 等当前回合跑完,再作为一个新回合处理 |
66
+ | `steer` | 插话发送 | 并入当前回合,模型在下一步就能看到 |
67
+
68
+ `dsh_task`、`dsh_spawn`、`dsh_followup` 都支持:
69
+
70
+ ```js
71
+ dsh_followup({ worker: "w1", message: "接下来只改 src 下的文件", mode: "steer" })
72
+ dsh_task({ sessionId: "session-…", prompt: "停一下,改成只跑单测", mode: "steer" })
73
+ ```
74
+
75
+ 两点实测结论:
76
+
77
+ 1. **`steer` 不打断正在执行的工具**。实测 worker 正在跑 `sleep 90`,`steer` 的消息投递进去了,但要等那次工具调用返回、模型走到下一步才被看到——整整 70 秒。真想立刻停,是 DSH 界面上另一个动作(取消),不是投递模式。
78
+ 2. **`steer` 不新开回合**(turn 编号不变),所以等待条件必须是「当前回合结束」。早期实现等的是「下一个回合」,答案都回来了、调用还在等,最后 120 秒超时。
79
+
80
+ `dsh_task` 对正在运行的会话默认会拒绝,错误信息里会提示改用 `steer`。
81
+
82
+ 第三个细节是返回值:插话的回复和原任务在**同一个回合**里,所以不能简单取"这一轮最后一条 assistant 文本"——那可能是原任务后面的步骤。桥会在插话后定位到**自己那条 user/message**,只取它之后的 assistant 文本作为回答。
83
+
84
+ ## 创建项目(workspace)
85
+
86
+ DSH 的侧边栏分组是"项目(workspace)",和会话的工作目录是两回事:新建会话默认落在「未分组」,除非它所在的目录已经是一个项目。
87
+
88
+ `dsh_task` / `dsh_spawn` 的 `workspace` 参数把这一步也接管了:
89
+
90
+ ```js
91
+ // 目录不是项目 → 用它创建项目(标题取目录名),会话直接归到项目下
92
+ dsh_task({ prompt: "…", cwd: "/repo/clawer", workspace: true })
93
+
94
+ // 用指定标题创建
95
+ dsh_spawn({ task_name: "w1", message: "…", cwd: "/repo/clawer", workspace: "Clawer" })
96
+
97
+ // 不传 → 只复用已有项目;目录没有项目就保持未分组(和以前一样)
98
+ dsh_task({ prompt: "…", cwd: "/repo/clawer" })
99
+ ```
100
+
101
+ 行为细节:
102
+
103
+ - 目录不存在时先递归创建,再做项目登记(登记要求目录已存在);
104
+ - 目录已经是项目 → **直接复用,不改标题**(底层 `create` 对同一路径返回已有记录);
105
+ - 返回结果里带上 `workspace: { id, title, path }`,方便确认落在哪个项目;
106
+ - 项目操作失败不影响派发本身(分组是外观,不因此让任务失败)。
107
+
59
108
  ## 排队还是立刻打断(`mode`)
60
109
 
61
110
  DSH 的 prompt 有两种投递模式,桥把它们交给调用方选:
package/index.js CHANGED
@@ -117,9 +117,9 @@ const TOOLS = [
117
117
  },
118
118
  mode: {
119
119
  type: 'string',
120
- enum: ['queue', 'steer', 'interrupt'],
120
+ enum: ['queue', 'steer'],
121
121
  description:
122
- 'delivery: "queue" (default) waits for the running turn to finish; "steer" inserts the message into that turn (its current step still completes); "interrupt" cancels the running turn first, then sends.',
122
+ 'How a busy session receives this: "queue" (default) waits for the running turn to finish, "steer" inserts the message into that turn so the model sees it at the next step.',
123
123
  },
124
124
 
125
125
  reasoningEffort: {
@@ -160,9 +160,9 @@ const TOOLS = [
160
160
  },
161
161
  mode: {
162
162
  type: 'string',
163
- enum: ['queue', 'steer', 'interrupt'],
163
+ enum: ['queue', 'steer'],
164
164
  description:
165
- 'delivery: "queue" (default) waits for the running turn to finish; "steer" inserts the message into that turn (its current step still completes); "interrupt" cancels the running turn first, then sends.',
165
+ 'How a busy session receives this: "queue" (default) waits for the running turn to finish, "steer" inserts the message into that turn so the model sees it at the next step.',
166
166
  },
167
167
 
168
168
  reasoningEffort: { type: 'string', description: 'Reasoning level for `model`, when that model offers levels.' },
@@ -187,9 +187,9 @@ const TOOLS = [
187
187
  reasoningEffort: { type: 'string', description: 'Reasoning level for `model`, when that model offers levels.' },
188
188
  mode: {
189
189
  type: 'string',
190
- enum: ['queue', 'steer', 'interrupt'],
190
+ enum: ['queue', 'steer'],
191
191
  description:
192
- 'delivery: "queue" (default) waits for the running turn to finish; "steer" inserts the message into that turn (its current step still completes); "interrupt" cancels the running turn first, then sends.',
192
+ 'How a busy session receives this: "queue" (default) waits for the running turn to finish, "steer" inserts the message into that turn so the model sees it at the next step.',
193
193
  },
194
194
  },
195
195
  required: ['worker', 'message'],
@@ -278,6 +278,26 @@ const TOOLS = [
278
278
  },
279
279
  ]
280
280
 
281
+ /**
282
+ * Sequence number of the first message the caller itself delivered after `sinceSeq`.
283
+ * With steer the reply shares the running turn, so "the last assistant text of the turn"
284
+ * can be a step of the original task; anchoring on our own message keeps it exact.
285
+ * @param ctx - Host context.
286
+ * @param sessionId - session to read.
287
+ * @param sinceSeq - sequence captured before the prompt was delivered.
288
+ * @returns the sequence, or the original value when nothing matches.
289
+ */
290
+ function firstDeliveredMessageSeq(ctx, sessionId, sinceSeq) {
291
+ const session = ctx.get('sessions')?.get?.(sessionId)
292
+ if (session === undefined || session === null) return sinceSeq
293
+ for (const event of session.snapshotEvents()) {
294
+ if (Number(event.seq) <= sinceSeq) continue
295
+ if (event.type !== 'user/message') continue
296
+ return Number(event.seq)
297
+ }
298
+ return sinceSeq
299
+ }
300
+
281
301
  /**
282
302
  * Start the bridge.
283
303
  * @param ctx - Host context.
@@ -610,7 +630,7 @@ async function callTool(id, params, api, signal) {
610
630
  from: args.from,
611
631
  cwd: args.cwd,
612
632
  workspace: args.workspace === true || (typeof args.workspace === 'string' && args.workspace !== '') ? args.workspace : undefined,
613
- mode: args.mode === 'steer' || args.mode === 'interrupt' ? args.mode : undefined,
633
+ mode: args.mode === 'steer' ? 'steer' : undefined,
614
634
  model: typeof args.model === 'string' && args.model !== '' ? args.model : undefined,
615
635
  reasoningEffort:
616
636
  typeof args.reasoningEffort === 'string' && args.reasoningEffort !== '' ? args.reasoningEffort : undefined,
@@ -627,7 +647,7 @@ async function callTool(id, params, api, signal) {
627
647
  await api.followup({
628
648
  worker,
629
649
  message,
630
- mode: args.mode === 'steer' || args.mode === 'interrupt' ? args.mode : undefined,
650
+ mode: args.mode === 'steer' ? 'steer' : undefined,
631
651
  model: typeof args.model === 'string' && args.model !== '' ? args.model : undefined,
632
652
  reasoningEffort:
633
653
  typeof args.reasoningEffort === 'string' && args.reasoningEffort !== '' ? args.reasoningEffort : undefined,
@@ -666,7 +686,7 @@ async function callTool(id, params, api, signal) {
666
686
  sessionId,
667
687
  agentPreset: typeof args.agentPreset === 'string' && args.agentPreset !== '' ? args.agentPreset : undefined,
668
688
  workspace: args.workspace === true || (typeof args.workspace === 'string' && args.workspace !== '') ? args.workspace : undefined,
669
- mode: args.mode === 'steer' || args.mode === 'interrupt' ? args.mode : undefined,
689
+ mode: args.mode === 'steer' ? 'steer' : undefined,
670
690
  model: typeof args.model === 'string' && args.model !== '' ? args.model : undefined,
671
691
  reasoningEffort:
672
692
  typeof args.reasoningEffort === 'string' && args.reasoningEffort !== '' ? args.reasoningEffort : undefined,
@@ -869,30 +889,16 @@ export function createApi(ctx, state, settings) {
869
889
  }
870
890
  if (typeof targetId !== 'string' || targetId === '') throw new Error('session controller did not return a session id')
871
891
  created = true
872
- } else if (mode !== 'steer' && mode !== 'interrupt' && ctx.get('agents')?.get?.(targetId)?.status === 'running') {
892
+ } else if (mode !== 'steer' && ctx.get('agents')?.get?.(targetId)?.status === 'running') {
873
893
  /* Steering exists precisely to reach a running session; queueing into one would
874
894
  * silently wait for a turn this call is not tracking. */
875
- throw new Error('that session is running; pass mode:"steer" (insert into the turn) or mode:"interrupt" (cancel it first), or use dsh_dispatch plus dsh_read_result')
895
+ throw new Error('that session is running; pass mode:"steer" to insert into that turn, or use dsh_dispatch plus dsh_read_result')
876
896
  }
877
897
 
878
898
  const appliedModel = await applyModelSpec(ctx, targetId, model, reasoningEffort)
879
- const deliveryMode = mode === 'steer' ? 'steer' : mode === 'interrupt' ? 'interrupt' : 'queue'
899
+ /* DSH delivers a prompt either queued behind the running turn or steered into it. */
900
+ const deliveryMode = mode === 'steer' ? 'steer' : 'queue'
880
901
  const turnBefore = lastTurn(ctx, targetId)
881
-
882
- /* `steer` inserts the message into the running turn, but that turn still finishes its
883
- * current step first — an in-flight tool call keeps running. `interrupt` is the hard
884
- * stop: cancel the turn, then send, so the worker drops what it was doing. */
885
- if (deliveryMode === 'interrupt' && ctx.get('agents')?.get?.(targetId)?.status === 'running') {
886
- try {
887
- await service.cancel({ sessionId: targetId })
888
- } catch {
889
- /* Cancelling is best effort: the send below still delivers the message. */
890
- }
891
- /* The abort closes the *current* turn, so accept that number: waiting for a
892
- * strictly newer turn burned the whole grace period before every send. */
893
- await waitForTurnEnd(ctx, targetId, Math.max(0, turnBefore - 1), 15000)
894
- }
895
-
896
902
  /* A steered message is absorbed by the turn that is already running, so waiting for
897
903
  * a strictly newer turn would hang until the timeout even after the answer landed. */
898
904
  const waitAfter = deliveryMode === 'steer' ? Math.max(0, turnBefore - 1) : turnBefore
@@ -900,7 +906,7 @@ export function createApi(ctx, state, settings) {
900
906
  const finished = waitForTurnEnd(ctx, targetId, waitAfter, seconds * 1000)
901
907
  const requestId = randomUUID()
902
908
  const accepted = await service.prompt(
903
- { sessionId: targetId, requestId, mode: deliveryMode === 'steer' ? 'steer' : 'queue', content: [{ type: 'text', text: prompt }] },
909
+ { sessionId: targetId, requestId, mode: deliveryMode, content: [{ type: 'text', text: prompt }] },
904
910
  AbortSignal.timeout(30000),
905
911
  )
906
912
  state.dispatched.add(targetId)
@@ -922,7 +928,10 @@ export function createApi(ctx, state, settings) {
922
928
  }
923
929
 
924
930
  const end = await finished
925
- const collected = finalAnswer(ctx, targetId, before, settings)
931
+ /* A steered reply lives inside the turn that was already running, so anchor the
932
+ * answer on the message we delivered instead of the turn's last assistant text. */
933
+ const answerFrom = deliveryMode === 'steer' ? firstDeliveredMessageSeq(ctx, targetId, before) : before
934
+ const collected = finalAnswer(ctx, targetId, answerFrom, settings)
926
935
  const turnReason = end?.reason?.kind ?? null
927
936
  const durationMs = Date.now() - started
928
937
  const status = end === undefined ? 'timeout' : turnReason === 'completed' ? 'completed' : `turn-${String(turnReason)}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-subagent-bridge",
3
- "version": "0.12.2",
3
+ "version": "0.13.1",
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": [
@@ -224,34 +224,26 @@ dsh_followup({ "worker": "audit_api", "message": "把第 3 条展开,给出补
224
224
 
225
225
  `dsh_followup` 复用该 worker 的会话,比新开一个便宜。也可以用 `dsh_task({ "sessionId": …, "prompt": … })` 做同样的事(阻塞版)。
226
226
 
227
- ## 改主意时:排队还是立刻打断
227
+ ## 改主意时:排队还是插话
228
228
 
229
- 默认 `dsh_followup` / `dsh_task` 是**排队**:等 worker 当前这一轮跑完再处理。但有时候用户就是要它立刻停——改方向、别继续跑、先汇报。这时用 `mode`:
229
+ 默认是**排队发送**:等 worker 当前这一轮跑完,再作为新的一轮处理。DSH 的「繁忙时的发送行为」还提供**插话发送**,桥用 `mode` 暴露它:
230
230
 
231
231
  ```jsonc
232
- // 排进队列,等当前回合结束(默认)
232
+ // 排队(默认):等这一轮结束
233
233
  dsh_followup({ "worker": "w1", "message": "跑完顺手看一下测试覆盖率" })
234
234
 
235
- // 插进当前回合:当前那一步工具调用仍会跑完
236
- dsh_followup({ "worker": "w1", "message": "补充一句:只改 src 下的文件", "mode": "steer" })
237
-
238
- // 立刻打断:先取消当前回合,再发消息开新回合
239
- dsh_followup({ "worker": "w1", "message": "停,先汇报现在的结果", "mode": "interrupt" })
235
+ // 插话:并入当前这一轮,模型下一步就能看到
236
+ dsh_followup({ "worker": "w1", "message": "接下来只改 src 下的文件,别动 packages", "mode": "steer" })
240
237
  ```
241
238
 
242
239
  | `mode` | 行为 | 什么时候用 |
243
240
  | --- | --- | --- |
244
- | `queue`(默认) | 等这一轮结束 | 补充要求,不急着停 |
245
- | `steer` | 并入当前回合,**当前那一步还会跑完** | 想到补充,能等这一下 |
246
- | `interrupt` | 先 `cancel`,再发消息 | 真的要停 |
247
-
248
- 实测差别(worker 正在跑 `sleep 120`):
249
-
250
- - `steer` → 消息要等那一步工具调用结束才被看到,**70 秒**才回应;
251
- - `interrupt` → 先 aborted 当前回合,再开新回合,**10 秒左右**返回。
241
+ | `queue`(默认) | 等这一轮结束,新开一轮 | 「顺便」「另外」 |
242
+ | `steer` | 并入当前一轮,**turn 不重开** | 「接下来注意…」「只改某某」 |
252
243
 
253
- 用户说「停下来」「别跑了」「先别管这个」时用 `interrupt`;说「顺便」「另外」时用默认 `queue` 就够了。`dsh_task` 对**正在运行**的会话默认会拒绝,拒绝信息里也会提示这两个选项。
244
+ **`steer` 不会打断正在执行的工具**:worker 若正卡在 `sleep 90` 这类调用里,消息要等它返回到下一步才被看到(实测 70 秒)。要它立刻停,那是用户在 DSH 界面上按取消,不是这里的投递模式——不要用 `steer` 假装能"打断"。
254
245
 
246
+ 用户说「停下来/别做了」时,正确反应是:告诉用户这会让当前这轮中止,或者用 `dsh_wait` 等它自然结束——不要依赖 `mode` 去实现"立即停"。
255
247
 
256
248
  ## 查现在有哪些 worker
257
249