@whaletalk/dsh-codex-collab 0.1.6 → 0.1.7

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
@@ -218,8 +218,9 @@ dsh_task({ "instruction": "继续推进第 3 步…", "sessionId": "session-8d48
218
218
  # 新建子代理(默认行为)
219
219
  dsh-task --in "<指令>" --cwd "<目录>" [--lane backend] [--model fast|pro] [--commit]
220
220
 
221
- # 接进已有会话:先找,再派
221
+ # 接进已有会话:先找(或先空跑验证),再派
222
222
  dsh-task --sessions "<标题关键词>" # 只读列候选,拿 sessionId
223
+ dsh-task --dry-run --find "<标题关键词>" # 只解析目标、回显命中,不投递任何消息
223
224
  dsh-task --in "<指令>" --session "<sessionId>" [--steer]
224
225
  dsh-task --in "<指令>" --find "<标题关键词>" # 唯一命中才派;否则退出码 4
225
226
 
@@ -235,13 +236,21 @@ dsh-task --sessions "按文档启动OKX策略实验计划"
235
236
  # → {"items":[{"sessionId":"session-8d481ad9-…","snippet":"按文档启动OKX策略实验计划",
236
237
  # "agentAvailable":true}],"total":1,"matchedBy":"list"}
237
238
 
239
+ # 1b) 没命中时会给近似候选(按标题相似度 + 最近更新排序),换词重查或改用 sessionId——
240
+ # 绝不要发一条消息去"试探命中"(它会落进对方会话并跑成回合,污染对话)
241
+ dsh-task --dry-run --find "量化框架"
242
+ # → {"resolved":false,"reason":"session-query-empty",
243
+ # "candidates":[{"sessionId":"1c0f99f8-…","snippet":"你是一个量化研究项目的技术","score":0.333}, …]}
244
+
238
245
  # 2) 接通(投递进那条会话;这一轮的助手回复作为汇报返回,只回报本轮新增文本)
239
246
  dsh-task --in "<继续推进的指令>" --session "session-8d481ad9-…"
240
247
  ```
241
248
 
242
249
  `matchedBy` 说明"找到"走了哪条路:`search` = 会话搜索索引;`list` = 索引被部署禁用时退回遍历列表 + 标题投影(都不激活 Agent)。
243
250
 
244
- 失败一律返回稳定 `reason` 且**不创建任何会话/工作区**:`session-query-empty`(没命中)、`session-ambiguous`(多条命中,附 `candidates`)、`session-not-found`、`session-busy`、`session-writer-held`(会话被别的写入方占用)、`session-archived`、`session-not-accepted`、`session-timeout`、`session-cancel-refused`(取消会话目标默认被拒,需 `--force`)。
251
+ **`delivered` 与排队语义**(重要):`prompt` 一旦被目标会话接受,就表示消息**已经进了它的队列**,响应里会带 `delivered: true`;此后即便我们的观察超时(会话可能正跑一个很长的回合),也**不要重发**——重发会让同一份内容在用户会话里出现两次。`queue` 模式不再用 120s 判失败:没在 120s 内开跑只记一条 note 并继续等,最终由总预算(默认 1h)收口为 `session-timeout` + `delivered: true`。
252
+
253
+ 失败返回稳定 `reason` 且**不创建任何会话/工作区**:`session-query-empty`(没命中,附 `candidates`)、`session-ambiguous`(多条命中,附 `candidates`)、`session-not-found`、`session-busy`、`session-writer-held`(会话被别的写入方占用)、`session-archived`、`session-not-accepted`(仅当 prompt **未被接受**)、`session-timeout`、`session-cancel-refused`(取消会话目标默认被拒,需 `--force`)。
245
254
 
246
255
  ## 反向通道:让 DSH 调用 Codex
247
256
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Codex 主导的双向编码协作插件:ChatGPT 把编码任务下发给本机 DeepSeek Harness 的持久编码子代理执行,DeepSeek 在共享工作目录 D:\\Harness 中写代码并实时回传进度与汇报,Codex 读文件验收、反馈修正,直到通过。",
5
5
  "author": {
6
6
  "name": "FuJackGao"
@@ -30,10 +30,17 @@ description: "与 DeepSeek Harness 编码协作。当用户要写代码、建项
30
30
  ## 找到已有会话(只读,不创建任何东西)
31
31
 
32
32
  ```bash
33
- dsh-task --sessions "<标题或内容关键词>"
33
+ dsh-task --sessions "<标题或内容关键词>" # 只读列出候选
34
+ dsh-task --dry-run --find "<标题关键词>" # 只解析目标、回显命中结果,不投递
34
35
  ```
35
36
 
36
- 输出候选(`sessionId` + 片段)。**必须唯一命中**才能直接用;多条会返回 `candidates` 让你改用 `--session <id>`。退出码 `4` = 没命中或不唯一。
37
+ 输出候选(`sessionId` + 片段)。**必须唯一命中**才能直接用;多条会返回 `candidates` 让你改用 `--session <id>`。
38
+
39
+ 没命中时会额外给出 `candidates`(按标题相似度 + 最近更新排序)——**换词重查,或直接确认用户指的是哪一条**。
40
+
41
+ > ⛔ **绝不投递任何消息来"验证命中"**(例如发一条「占位/测试/忽略本条」)。真实事故:调用方搜不到标题后发了条占位消息试探,它解析成了用户自己的会话,于是**作为一条真实用户消息落进对话并跑成回合**,会话里的 agent 还以为那是用户发的。验证命中只有两种合法手段:上面的 `--sessions` 和 `--dry-run`。
42
+ >
43
+ > ⛔ **看到 `delivered: true` 就不要重发**。它表示 prompt 已被那条会话接受、进了它的队列;我们只是没等到它开跑(会话可能正忙着一个很长的回合)。重发会让同一份内容在用户会话里出现两次。用 `--status <taskId>` 继续观察。
37
44
 
38
45
  ## 下发任务
39
46
 
@@ -41,6 +41,7 @@ function parseArgs(argv) {
41
41
  else if (a === '--session') { out.sessionId = argv[++i] || '' }
42
42
  else if (a === '--find') { out.find = argv[++i] || '' }
43
43
  else if (a === '--steer') { out.steer = true }
44
+ else if (a === '--dry-run') { out.dryRun = true }
44
45
  else if (a === '--force') { out.force = true }
45
46
  else { rest.push(a) }
46
47
  }
@@ -175,22 +176,53 @@ async function main() {
175
176
  }
176
177
  console.log(JSON.stringify(res.json, null, 2))
177
178
  const items = Array.isArray(res.json.items) ? res.json.items : []
179
+ if (items.length === 0 && Array.isArray(res.json.candidates) && res.json.candidates.length > 0) {
180
+ console.error('未命中。候选(改用 --session <id>,或换个关键词):')
181
+ for (const c of res.json.candidates) console.error(' ' + c.sessionId + ' ' + c.snippet + (c.score === undefined ? '' : ' score=' + c.score))
182
+ console.error('提示: 验证命中用 --dry-run,不要投递消息去试探。')
183
+ }
178
184
  finish(items.length > 0 ? 0 : 4)
179
185
  }
180
186
 
181
187
  const instruction = opts.instruction.trim()
188
+ const cwd = (opts.cwd || process.env.DSH_BRIDGE_CWD || '').trim()
189
+ const sessionId = (opts.sessionId || '').trim()
190
+ const find = (opts.find || '').trim()
191
+ const sessionTarget = sessionId !== '' ? { sessionId } : (find !== '' ? { sessionQuery: find } : null)
192
+
193
+ // --dry-run:只解析目标、回显命中结果,什么都不投递。验证"这条查询会命中哪条会话"就用它,
194
+ // 绝不要投递一条消息去试探(那会落进用户会话并跑成回合,污染对话)。
195
+ if (opts.dryRun) {
196
+ if (instruction === '' && sessionTarget === null && cwd === '') {
197
+ console.error('用法: node task.mjs --dry-run (--find "<标题关键词>" | --session <会话id> | --cwd <目录>)')
198
+ finish(2)
199
+ }
200
+ const res = await request('POST', GATEWAY + '/api/dsh-bridge/task', {
201
+ instruction: instruction === '' ? '(dry-run)' : instruction,
202
+ dryRun: true,
203
+ ...(sessionTarget === null ? (cwd ? { cwd } : {}) : sessionTarget),
204
+ })
205
+ console.log(JSON.stringify(res.json, null, 2))
206
+ if (res.json === null || typeof res.json !== 'object') finish(1)
207
+ if (res.json.resolved !== true) {
208
+ if (Array.isArray(res.json.candidates) && res.json.candidates.length > 0) {
209
+ console.error('未命中。候选(改用 --session <id>,或换个关键词):')
210
+ for (const c of res.json.candidates) console.error(' ' + c.sessionId + ' ' + c.snippet + (c.score === undefined ? '' : ' score=' + c.score))
211
+ }
212
+ finish(4)
213
+ }
214
+ finish(0)
215
+ }
216
+
182
217
  if (!instruction) {
183
218
  console.error('用法: node task.mjs --in "<指令>" [--cwd 目录] [--lane 名] [--model fast|pro] [--commit] [--timeout 秒] [--no-wait]')
184
219
  console.error(' node task.mjs --in "<指令>" (--session <会话id> | --find "<标题关键词>") [--steer] # 投递进已有会话')
185
220
  console.error('查找: node task.mjs --sessions "<标题关键词>" # 只读列出候选, 拿 sessionId 再派活')
221
+ console.error(' node task.mjs --dry-run (--find "<关键词>" | --session <id>) # 只解析目标, 不投递(验证命中用这个)')
186
222
  console.error('管理: node task.mjs --list | --status <taskId> | --cancel <taskId> [--force]')
187
223
  console.error('退出码: 0 成功 / 1 失败 / 2 用法 / 3 超时 / 4 会话未命中或不唯一 / 5 session 目标取消被拒')
188
224
  finish(2)
189
225
  }
190
- const cwd = (opts.cwd || process.env.DSH_BRIDGE_CWD || '').trim()
191
- const sessionId = (opts.sessionId || '').trim()
192
- const find = (opts.find || '').trim()
193
- const sessionTarget = sessionId !== '' ? { sessionId } : (find !== '' ? { sessionQuery: find } : null)
194
226
  if (sessionTarget !== null && (cwd !== '' || opts.commit)) {
195
227
  console.error('task.mjs: --session/--find 不能与 --cwd/--commit 同时使用(已有会话自带工作目录,本工具不替它提交)')
196
228
  finish(2)
@@ -246,6 +278,11 @@ async function main() {
246
278
  console.log(JSON.stringify({ phase: 'finished', ...status }))
247
279
  console.log('=== DeepSeek 汇报 ===')
248
280
  console.log(status.result || status.error || '(无内容)')
281
+ // delivered=true 表示 prompt 已被那条会话接受(进了它的队列):即便我们等超时了,
282
+ // 消息也可能会跑,**不要重发**(重发会让同一份内容在用户会话里出现两次)。
283
+ if (status.status === 'error' && status.delivered === true) {
284
+ console.error('注意: 消息已投递进该会话的队列(delivered=true),只是我们没等到它开跑。不要重发;可用 --status ' + task.taskId + ' 继续观察。')
285
+ }
249
286
  if (status.status === 'done') {
250
287
  // session 目标的记忆就在那条会话里,不再往协作历史里塞一份。
251
288
  if (sessionTarget === null) {
@@ -32,6 +32,7 @@ import {
32
32
  mapControllerError,
33
33
  sessionTitleOf,
34
34
  looksLikeDisabledSearch,
35
+ rankTitleCandidates,
35
36
  taskResponse,
36
37
  } from './session-target.mjs'
37
38
 
@@ -449,6 +450,33 @@ export async function apply(ctx) {
449
450
  }
450
451
  }
451
452
 
453
+ /**
454
+ * 查询没命中时给的"近似候选":读 list(不激活 Agent、不开索引),按 bigram 相似度
455
+ * 与最近更新排序。存在的意义是**不给"探测"留动机**——真实事故里调用方搜不到标题后
456
+ * 发了一条占位消息去试探,那条消息落进用户会话并跑成回合。有了候选,只读就能改词重查。
457
+ */
458
+ const sessionNearMisses = async (query, limit = 5) => {
459
+ const controller = sessionControllerNow()
460
+ if (controller === undefined) return []
461
+ try {
462
+ const value = await controller.list({}, signalOf())
463
+ const items = value !== undefined && value !== null && Array.isArray(value.items) ? value.items : []
464
+ const summaries = []
465
+ for (const summary of items) {
466
+ if (summary === null || typeof summary !== 'object' || typeof summary.sessionId !== 'string') continue
467
+ summaries.push({
468
+ sessionId: summary.sessionId,
469
+ title: sessionTitleOf(summary),
470
+ updatedAt: typeof summary.updatedAt === 'number' ? summary.updatedAt : 0,
471
+ })
472
+ }
473
+ return rankTitleCandidates(query, summaries, limit)
474
+ } catch (e) {
475
+ console.error('[dsh-bridge] near-miss lookup failed:', errText(e).slice(0, 200))
476
+ return []
477
+ }
478
+ }
479
+
452
480
  /** 只读解析:query → search 选一;sessionId → 直接用。任何不确定性都返回 reason。 */
453
481
  const resolveSessionTarget = async (target) => {
454
482
  const controller = sessionControllerNow()
@@ -467,7 +495,13 @@ export async function apply(ctx) {
467
495
  }
468
496
  }
469
497
  const picked = selectSession(hits, target)
470
- if (!picked.ok) return picked
498
+ if (!picked.ok) {
499
+ // 查询落空不是"死路":附上近似候选,调用方只读就能改词重查(别再发消息探测)。
500
+ if (picked.reason === REASONS.QUERY_EMPTY) {
501
+ return { ...picked, candidates: await sessionNearMisses(target.query) }
502
+ }
503
+ return picked
504
+ }
471
505
  let result
472
506
  try {
473
507
  result = await controller.resolveAgent(picked.sessionId)
@@ -494,6 +528,7 @@ export async function apply(ctx) {
494
528
  if (fields.error !== undefined) task.error = fields.error
495
529
  if (fields.reason !== undefined) task.reason = fields.reason
496
530
  if (fields.stopReason !== undefined) task.stopReason = fields.stopReason
531
+ if (fields.delivered !== undefined) task.delivered = fields.delivered
497
532
  pushTask(task.taskId, status === 'done' ? 'result' : 'error', {
498
533
  ...(task.result === undefined ? {} : { text: task.result }),
499
534
  ...(task.error === undefined ? {} : { error: task.error }),
@@ -603,6 +638,8 @@ export async function apply(ctx) {
603
638
  sessionId: resolved.sessionId, taskId: task.taskId, deliver, baseline,
604
639
  total: '', sawRunning: false, activeSince: Date.now(), startedAt: Date.now(),
605
640
  settled: false, abort: undefined, watcher: 'pending',
641
+ // prompt 被接受 = 消息已进会话队列。此后就不该再把它当"没发出去"(见 sweepSessionTargets)。
642
+ delivered: false, queuedNoted: false,
606
643
  }
607
644
  sessionTargets.set(resolved.sessionId, record)
608
645
 
@@ -635,7 +672,9 @@ export async function apply(ctx) {
635
672
  }
636
673
 
637
674
  watchSessionTask(record)
638
- pushTask(task.taskId, 'task-accepted', { target: { kind: 'session', sessionId: resolved.sessionId }, deliver, cwd: resolved.snippet })
675
+ record.delivered = true
676
+ task.delivered = true
677
+ pushTask(task.taskId, 'task-accepted', { target: { kind: 'session', sessionId: resolved.sessionId }, deliver, cwd: resolved.snippet, delivered: true })
639
678
  return { task, failed: null }
640
679
  }
641
680
 
@@ -770,16 +809,39 @@ export async function apply(ctx) {
770
809
  }
771
810
  } catch (e) { /* 读不到就下一轮再试 */ }
772
811
  }
812
+ // 派发超时只在"prompt 没被接受"时才算失败。
813
+ // queue 模式下 prompt 被接受就意味着消息已进队列,会在会话空闲时跑;此时报 error
814
+ // 是误报——实测会话正跑一个 38 分钟的回合,排队等待远超 120s 完全正常,而误报会
815
+ // 直接诱导调用方重发(真实事故:同一条汇报被投了两次)。所以这里只记一条 note,
816
+ // 继续等,由下面的总预算兜底。
773
817
  if (!record.sawRunning && now - record.startedAt > SESSION_DISPATCH_TIMEOUT_MS) {
774
- settleSessionTask(record, 'error', { error: 'the session never started a turn after the prompt was accepted (it may still be queued)', reason: REASONS.NOT_ACCEPTED })
775
- continue
818
+ if (record.delivered === true) {
819
+ if (record.queuedNoted !== true) {
820
+ record.queuedNoted = true
821
+ pushTask(record.taskId, 'note', {
822
+ note: 'QUEUED',
823
+ text: 'prompt 已被接受并进入该会话队列,等它空闲时会跑;不要重发',
824
+ target: { kind: 'session', sessionId: record.sessionId },
825
+ delivered: true,
826
+ })
827
+ }
828
+ } else {
829
+ settleSessionTask(record, 'error', { error: 'the session never accepted the prompt', reason: REASONS.NOT_ACCEPTED, delivered: false })
830
+ continue
831
+ }
776
832
  }
777
833
  if (record.sawRunning && record.activeSince > 0 && now - record.activeSince > SESSION_QUIET_TIMEOUT_MS) {
778
834
  finishSessionTask(record, 'quiet-timeout')
779
835
  continue
780
836
  }
781
837
  if (now - record.startedAt > SESSION_TOTAL_TIMEOUT_MS) {
782
- settleSessionTask(record, 'error', { error: 'session task exceeded its total budget', reason: REASONS.TIMEOUT })
838
+ settleSessionTask(record, 'error', {
839
+ error: record.delivered === true
840
+ ? 'prompt 已投递进该会话队列,但预算内它始终没有开始这一轮(会话可能一直在忙,或未被激活);不要重发,可用 status 继续观察'
841
+ : 'session task exceeded its total budget before the prompt was accepted',
842
+ reason: REASONS.TIMEOUT,
843
+ delivered: record.delivered === true,
844
+ })
783
845
  }
784
846
  }
785
847
  }
@@ -851,14 +913,55 @@ export async function apply(ctx) {
851
913
  const parsed = await readJsonBody(req, res)
852
914
  if (parsed === null) return
853
915
  const instruction = parsed !== null && typeof parsed === 'object' && typeof parsed.instruction === 'string' ? parsed.instruction : ''
854
- if (instruction.trim() === '') { sendJson(res, 400, { error: 'missing instruction' }); return }
916
+ const dryRun = parsed !== null && typeof parsed === 'object' && parsed.dryRun === true
917
+ if (!dryRun && instruction.trim() === '') { sendJson(res, 400, { error: 'missing instruction' }); return }
855
918
  const classified = classifyTarget(parsed)
856
919
  if (!classified.ok) { sendJson(res, 400, { error: classified.message, reason: classified.reason }); return }
920
+ // dryRun:只解析目标、回显它,**不投递任何东西**。存在的意义是让"我这条查询会命中
921
+ // 哪条会话"能纯只读地问出来——真实事故里调用方就是因为没法先验证,才发了一条占位
922
+ // 消息去探测,结果污染了用户会话。
923
+ if (dryRun) {
924
+ if (classified.target.kind === 'session') {
925
+ const resolved = await resolveSessionTarget(classified.target)
926
+ if (!resolved.ok) {
927
+ sendJson(res, 200, {
928
+ dryRun: true,
929
+ resolved: false,
930
+ reason: resolved.reason,
931
+ error: resolved.message,
932
+ ...(Array.isArray(resolved.candidates) && resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}),
933
+ })
934
+ return
935
+ }
936
+ sendJson(res, 200, {
937
+ dryRun: true,
938
+ resolved: true,
939
+ wouldDispatch: true,
940
+ target: { kind: 'session', sessionId: resolved.sessionId, deliver: classified.deliver },
941
+ ...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }),
942
+ ...(resolved.matchedBy === undefined ? {} : { matchedBy: resolved.matchedBy }),
943
+ ...(resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}),
944
+ note: '只解析目标,未投递任何消息',
945
+ })
946
+ return
947
+ }
948
+ const cwd = normalizeCwd(classified.target.cwd)
949
+ const lane = normalizeLane(classified.target.lane)
950
+ sendJson(res, 200, {
951
+ dryRun: true,
952
+ resolved: true,
953
+ wouldDispatch: true,
954
+ target: { kind: 'worker', cwd, lane },
955
+ note: '只解析目标,未派活',
956
+ })
957
+ return
958
+ }
857
959
  const task = await enqueue(instruction, parsed.history, classified.target, parsed.model, 'task', classified.deliver, classified.notes)
858
960
  if (task.status === 'error') {
859
961
  sendJson(res, 400, {
860
962
  error: task.error,
861
963
  reason: task.reason,
964
+ ...(task.delivered === true ? { delivered: true } : {}),
862
965
  ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
863
966
  })
864
967
  return
@@ -939,7 +1042,18 @@ export async function apply(ctx) {
939
1042
  }
940
1043
  }
941
1044
  }
942
- sendJson(res, 200, { items, hasMore: hits.length > items.length, total: hits.length, matchedBy: found.matchedBy })
1045
+ // 没命中不是死路:给近似候选(同一批只读数据,按相似度 + 最近更新排序),
1046
+ // 让调用方改词重查——而不是发一条消息去"探测命中"。
1047
+ const candidates = hits.length === 0 ? await sessionNearMisses(query, limit) : []
1048
+ sendJson(res, 200, {
1049
+ items,
1050
+ hasMore: hits.length > items.length,
1051
+ total: hits.length,
1052
+ matchedBy: found.matchedBy,
1053
+ ...(candidates.length > 0
1054
+ ? { candidates, reason: REASONS.QUERY_EMPTY, note: '标题/目录里都没有这个关键词;candidates 是按相似度与最近更新排的候选,改词重查或直接用 sessionId' }
1055
+ : {}),
1056
+ })
943
1057
  } catch (e) {
944
1058
  sendJson(res, 502, { error: errText(e), reason: mapControllerError(e) })
945
1059
  }
@@ -1042,6 +1156,8 @@ export async function apply(ctx) {
1042
1156
  taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
1043
1157
  target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
1044
1158
  result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
1159
+ // delivered=true 表示 prompt 已被会话接受(进了队列)——即便后面报错也不要重发。
1160
+ ...(task.delivered === undefined ? {} : { delivered: task.delivered }),
1045
1161
  ...(task.matchedBy === undefined ? {} : { matchedBy: task.matchedBy }),
1046
1162
  ...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
1047
1163
  ...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
@@ -1143,24 +1259,48 @@ export async function apply(ctx) {
1143
1259
  // ---- 原生工具(宿主全局工具层)----
1144
1260
  ctx.effect(() => ctx.tools.register(defineTool({
1145
1261
  name: 'dsh_collab_send',
1146
- description: '向 Codex 协作频道发送一条编码指令。默认新建编码子代理在指定工作目录执行;给出 sessionId 或 sessionQuery 时改为投递进那一条已有会话(会话自带的对话与上下文)。指令用中文。',
1262
+ description: '向 Codex 协作频道发送一条编码指令。默认新建编码子代理在指定工作目录执行;给出 sessionId 或 sessionQuery 时改为投递进那一条已有会话(会话自带的对话与上下文)。指令用中文。'
1263
+ + '【硬规则】验证"这条查询会命中哪条会话"必须用 dryRun=true(只解析、不投递);**绝不投递任何消息去试探命中**——那会作为一条真实用户消息落进对方会话并跑成回合,污染对话。'
1264
+ + '【硬规则】返回里 delivered=true 表示消息已被那条会话接受(进了它的队列),即便随后观察到超时或错误也**不要重发**,用 wait=false 记下 taskId 再查状态。'
1265
+ + 'sessionQuery 落空时会返回 candidates(按标题相似度 + 最近更新排序),换词重查或改用 sessionId。',
1147
1266
  parameters: {
1148
1267
  instruction: { type: 'string', required: true, description: '要交给编码子代理执行的指令。' },
1149
1268
  cwd: { type: 'string', description: '工作目录(默认 ' + DEFAULT_WORKSPACE + ')。与 sessionId/sessionQuery 互斥。' },
1150
1269
  lane: { type: 'string', description: '并行通道名(默认 main,同目录不同 lane 并行)。' },
1151
1270
  model: { type: 'string', description: '模型别名: default | fast | pro。' },
1152
1271
  sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建。' },
1153
- sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(唯一命中才生效,多条会返回候选)。' },
1272
+ sessionQuery: { type: 'string', description: '按标题/内容搜索已有会话(唯一命中才生效,多条或落空会返回候选)。' },
1154
1273
  deliver: { type: 'string', description: "投递方式: queue(默认,排队) 或 steer(插入当前回合)。" },
1274
+ dryRun: { type: 'boolean', description: '为 true 时只解析目标并回显(含 matchedBy 与候选),不投递任何消息——验证命中用它。' },
1155
1275
  wait: { type: 'boolean', description: '为 true 时等待结果(最长 15 分钟)。' },
1156
1276
  },
1157
1277
  output: { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }] },
1158
1278
  async execute(args) {
1159
1279
  const classified = classifyTarget(args)
1160
1280
  if (!classified.ok) return { ok: false, reason: classified.reason, error: classified.message }
1281
+ // dryRun:只解析目标、回显,不投递。给调用方一条"先验证命中"的正路,免得它发消息试探。
1282
+ if (args.dryRun === true) {
1283
+ if (classified.target.kind === 'session') {
1284
+ const resolved = await resolveSessionTarget(classified.target)
1285
+ if (!resolved.ok) {
1286
+ return { ok: false, dryRun: true, resolved: false, reason: resolved.reason, error: resolved.message, ...(resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}) }
1287
+ }
1288
+ return {
1289
+ ok: true, dryRun: true, resolved: true,
1290
+ target: { kind: 'session', sessionId: resolved.sessionId, deliver: classified.deliver },
1291
+ ...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }),
1292
+ ...(resolved.matchedBy === undefined ? {} : { matchedBy: resolved.matchedBy }),
1293
+ ...(resolved.candidates.length > 0 ? { candidates: resolved.candidates } : {}),
1294
+ note: '只解析目标,未投递任何消息',
1295
+ }
1296
+ }
1297
+ const cwd = normalizeCwd(classified.target.cwd)
1298
+ const lane = normalizeLane(classified.target.lane)
1299
+ return { ok: true, dryRun: true, resolved: true, target: { kind: 'worker', cwd, lane }, note: '只解析目标,未派活' }
1300
+ }
1161
1301
  const task = await enqueue(args.instruction, undefined, classified.target, args.model, 'task', classified.deliver, classified.notes)
1162
1302
  if (task.status === 'error') {
1163
- return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
1303
+ return { ok: false, taskId: task.taskId, reason: task.reason, error: task.error, ...(task.delivered === true ? { delivered: true } : {}), ...(task.candidates === undefined ? {} : { candidates: task.candidates }) }
1164
1304
  }
1165
1305
  const brief = { taskId: task.taskId, status: task.status, ...taskResponse(task) }
1166
1306
  if (args.wait !== true || timer === undefined) {
@@ -1171,7 +1311,7 @@ export async function apply(ctx) {
1171
1311
  if (Date.now() - t0 > 900000) return { ...brief, status: task.status, result: task.result, note: 'wait 超时' }
1172
1312
  await timer.timeout(2500)
1173
1313
  }
1174
- return { ...brief, status: task.status, result: task.result, error: task.error, reason: task.reason }
1314
+ return { ...brief, status: task.status, result: task.result, error: task.error, reason: task.reason, ...(task.delivered === true ? { delivered: true, resend: false } : {}) }
1175
1315
  },
1176
1316
  })))
1177
1317
 
@@ -232,6 +232,54 @@ export const looksLikeDisabledSearch = (error) => {
232
232
  return /search is disabled/i.test(text)
233
233
  }
234
234
 
235
+ /** 去空白后的字符 bigram 集合(CJK 友好:不需要分词)。 */
236
+ const bigramsOf = (value) => {
237
+ const text = typeof value === 'string' ? value.replace(/\s+/g, '') : ''
238
+ const out = new Set()
239
+ if (text.length === 1) out.add(text)
240
+ for (let i = 0; i + 1 < text.length; i++) out.add(text.slice(i, i + 2))
241
+ return out
242
+ }
243
+
244
+ /** 查询里的 bigram 有多少比例出现在标题里(0..1)。 */
245
+ export const titleSimilarity = (query, title) => {
246
+ const q = bigramsOf(query)
247
+ const t = bigramsOf(title)
248
+ if (q.size === 0 || t.size === 0) return 0
249
+ let shared = 0
250
+ for (const gram of q) if (t.has(gram)) shared++
251
+ return shared / q.size
252
+ }
253
+
254
+ /**
255
+ * `session-query-empty` 时给出的"近似候选"。
256
+ *
257
+ * 起因是一个真实事故:调用方搜「量化框架」没命中,又**没有别的办法确认标题**,于是
258
+ * 发了一条占位消息去"探测命中"——那条消息落进了用户自己的会话并跑成回合,污染了
259
+ * 对话。修法是不给"探测"留动机:查询落空时直接回一批候选标题(同 bigram 相似度排
260
+ * 序,无相似度的按最近更新补位),调用方靠只读信息就能改词重查。
261
+ */
262
+ export const rankTitleCandidates = (query, summaries, limit = 5) => {
263
+ const list = Array.isArray(summaries) ? summaries : []
264
+ const scored = []
265
+ for (const summary of list) {
266
+ if (summary === null || typeof summary !== 'object') continue
267
+ if (typeof summary.sessionId !== 'string' || summary.sessionId === '') continue
268
+ const title = typeof summary.title === 'string' && summary.title !== '' ? summary.title : ''
269
+ const updatedAt = typeof summary.updatedAt === 'number' ? summary.updatedAt : 0
270
+ scored.push({ sessionId: summary.sessionId, snippet: title, score: titleSimilarity(query, title), updatedAt })
271
+ }
272
+ const byRecency = (a, b) => b.updatedAt - a.updatedAt
273
+ const related = scored.filter((s) => s.score > 0).sort((a, b) => b.score - a.score || byRecency(a, b))
274
+ const rest = scored.filter((s) => s.score === 0).sort(byRecency)
275
+ const bound = Math.max(1, Math.min(Number(limit) || 5, MAX_CANDIDATES))
276
+ return [...related, ...rest].slice(0, bound).map((s) => ({
277
+ sessionId: s.sessionId,
278
+ snippet: s.snippet,
279
+ score: Math.round(s.score * 1000) / 1000,
280
+ }))
281
+ }
282
+
235
283
  /** 任务在 HTTP 行上的回显:调用方一眼能看出这次是"新建"还是"接进哪个会话"。 */
236
284
  export const taskResponse = (task) => {
237
285
  const out = { taskId: task.taskId, status: task.status, kind: task.kind }
@@ -246,5 +294,10 @@ export const taskResponse = (task) => {
246
294
  out.target = { kind: 'worker', cwd: target.cwd, lane: target.lane }
247
295
  }
248
296
  if (Array.isArray(task.notes) && task.notes.length > 0) out.notes = task.notes
297
+ // delivered=true:prompt 已被目标会话接受(进了它的队列)。调用方据此知道
298
+ // "别重发"——即便随后我们的观察超时并报错,消息也可能正在队列里等着跑。
299
+ if (task.target !== undefined && task.target !== null && task.target.kind === 'session' && task.delivered === true) {
300
+ out.delivered = true
301
+ }
249
302
  return out
250
303
  }
package/mcp/dsh-mcp.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  // 客户端(Secure MCP Tunnel / cloudflared)桥接给 ChatGPT 等远程产品。
7
7
  const GATEWAY = process.env.DSH_BRIDGE_URL || 'http://127.0.0.1:3080'
8
8
  const SERVER_NAME = 'dsh-bridge'
9
- const SERVER_VERSION = '0.1.6'
9
+ const SERVER_VERSION = '0.1.7'
10
10
  const PROTOCOL_VERSION = '2024-11-05'
11
11
 
12
12
  const log = (...args) => console.error('[dsh-mcp]', ...args)
@@ -20,17 +20,20 @@ const tools = [
20
20
  '参数: instruction(必填,完整中文指令:目标/产出目录/验收标准/约束), cwd(工作目录,默认 D:\\Harness,通常传当前项目目录), ' +
21
21
  'lane(并行通道名,默认 main), model(fast=deepseek-v4-flash 快而省 / pro=deepseek-v4-pro 质量优先), ' +
22
22
  'sessionId(已有会话 id), sessionQuery(按标题/内容搜已有会话,需唯一命中,否则返回候选), deliver(queue=排队(默认) / steer=插入当前回合), ' +
23
- 'wait(默认 true,阻塞至任务完成并返回 DeepSeek 汇报), timeoutSec(默认 600,最长 900)。',
23
+ 'dryRun(true 时只解析目标并回显, 不投递任何消息), ' +
24
+ 'wait(默认 true,阻塞至任务完成并返回 DeepSeek 汇报), timeoutSec(默认 600,最长 900)。' +
25
+ '注意: 验证"这条查询会命中哪条会话"必须用 dryRun 或 dsh_sessions——**绝不要投递一条消息去试探**(那会污染用户会话)。',
24
26
  inputSchema: {
25
27
  type: 'object',
26
28
  properties: {
27
- instruction: { type: 'string', description: '完整中文编码指令(目标、产出目录、验收标准、约束)。' },
29
+ instruction: { type: 'string', description: '完整中文编码指令(目标、产出目录、验收标准、约束)。dryRun 时可省略。' },
28
30
  cwd: { type: 'string', description: '工作目录(绝对路径)。默认 D:\\Harness。与 sessionId/sessionQuery 互斥。' },
29
31
  lane: { type: 'string', description: '并行通道名(默认 main)。' },
30
32
  model: { type: 'string', enum: ['fast', 'pro'], description: '模型别名:fast=deepseek-v4-flash,pro=deepseek-v4-pro。' },
31
33
  sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建子代理。' },
32
- sessionQuery: { type: 'string', description: '按标题或内容搜索已有会话;唯一命中才派发,多条会返回 candidates 让你改用 sessionId。' },
34
+ sessionQuery: { type: 'string', description: '按标题或内容搜索已有会话;唯一命中才派发,多条或落空会返回 candidates 让你改用 sessionId。' },
33
35
  deliver: { type: 'string', enum: ['queue', 'steer'], description: '投递方式:queue 排队(默认),steer 插入当前回合。' },
36
+ dryRun: { type: 'boolean', description: '只解析目标并回显(含命中路径 matchedBy 与候选),不投递任何消息。用来确认命中,替代"发消息探测"。' },
34
37
  wait: { type: 'boolean', description: '默认 true:阻塞直到任务完成并返回汇报。' },
35
38
  timeoutSec: { type: 'number', description: '最长等待秒数(默认 600,最大 900)。' },
36
39
  },
@@ -138,12 +141,19 @@ async function handleToolCall(name, args) {
138
141
  sessionId: args.sessionId,
139
142
  sessionQuery: args.sessionQuery,
140
143
  deliver: args.deliver,
144
+ dryRun: args.dryRun === true,
141
145
  })
146
+ // dryRun 只解析目标,不产生任务:原样回显给你看命中结果。
147
+ if (args.dryRun === true) return { ok: task.resolved === true, phase: 'dry-run', ...task }
142
148
  // 4xx 拒绝(缺目标 / 会话不存在 / 搜索结果不唯一 / 被写锁占用)不带 taskId,绝不静默新建。
143
149
  if (task.taskId === undefined) return { ok: false, phase: 'rejected', ...task }
144
150
  if (task.status === 'error') return { ok: false, ...task }
145
151
  if (args.wait === false) return { ok: true, phase: 'submitted', ...task }
146
152
  const done = await waitTask(task.taskId, args.timeoutSec || 600)
153
+ // 会话目标报错但 delivered=true:消息已在目标会话队列里,不要重发。
154
+ if (done.delivered === true && done.status === 'error') {
155
+ return { ok: false, phase: 'queued-then-failed', resend: false, ...done }
156
+ }
147
157
  return { ok: done.status === 'done', phase: 'finished', ...done }
148
158
  }
149
159
  if (name === 'dsh_sessions') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whaletalk/dsh-codex-collab",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Codex × DeepSeek Harness 双向编码协作:Codex 主导派活与验收,DeepSeek Harness 编码子代理在共享工作目录执行并回传,独立评审子代理实际跑通构建/测试后出具报告。提供 DSH 宿主网关插件、零依赖 MCP 服务器与 Codex 技能三件套。",
5
5
  "license": "MIT",
6
6
  "type": "module",