@whaletalk/dsh-codex-collab 0.1.0 → 0.1.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.
@@ -0,0 +1,208 @@
1
+ /**
2
+ * 会话目标的纯决策层。
3
+ *
4
+ * 这里不 import 任何 DSH 模块、不碰 IO,只做判定与文本提取,因此可以在
5
+ * node:test 里直接测。dsh-bridge.mjs 只负责把这里的结论接到真实服务上。
6
+ *
7
+ * 设计要点(对应"找到原对话"与"接通原对话"是两件事):
8
+ * - 目标显式:worker(新建,现有行为)与 session(接已有会话)是两个判别分支;
9
+ * - 无歧义才动手:查询命中 0 条 / 多条都不猜,返回稳定 reason 让调用方决定;
10
+ * - session 目标绝不静默降级为新建;
11
+ * - 所有失败都带稳定 reason,调用方不用解析中文文案。
12
+ */
13
+
14
+ /** 全部稳定失败码。UI/CLI/MCP 都按这些码分支,不解析文案。 */
15
+ export const REASONS = {
16
+ INVALID_TARGET: 'invalid-target',
17
+ CONTROLLER_UNAVAILABLE: 'session-controller-unavailable',
18
+ QUERY_EMPTY: 'session-query-empty',
19
+ AMBIGUOUS: 'session-ambiguous',
20
+ NOT_FOUND: 'session-not-found',
21
+ BUSY: 'session-busy',
22
+ WRITER_HELD: 'session-writer-held',
23
+ ARCHIVED: 'session-archived',
24
+ NOT_ACCEPTED: 'session-not-accepted',
25
+ TIMEOUT: 'session-timeout',
26
+ CANCEL_REFUSED: 'session-cancel-refused',
27
+ BAD_REQUEST: 'session-bad-request',
28
+ GATEWAY_INTERNAL: 'session-gateway-internal',
29
+ DISPATCH_FAILED: 'session-dispatch-failed',
30
+ }
31
+
32
+ /** 候选列表回传给调用方时的上限,避免把整个会话库倒出去。 */
33
+ export const MAX_CANDIDATES = 10
34
+
35
+ const nonEmpty = (v) => typeof v === 'string' && v.trim() !== ''
36
+
37
+ /** 把文本块树拍平成纯文本(bridge 的既有语义,抽出来以便复用与单测)。 */
38
+ export const textOfBlocks = (blocks) => {
39
+ if (blocks === undefined || blocks === null) return ''
40
+ let out = ''
41
+ const list = Array.isArray(blocks) ? blocks : [blocks]
42
+ for (const b of list) {
43
+ if (b === undefined || b === null || typeof b !== 'object') continue
44
+ if (b.type === 'text' && typeof b.text === 'string') out += b.text
45
+ if (Array.isArray(b.content)) out += textOfBlocks(b.content)
46
+ }
47
+ return out
48
+ }
49
+
50
+ /** 从一条会话事件里取助手文本;非助手消息返回空串。 */
51
+ export const assistantTextOf = (event) => {
52
+ if (event === undefined || event === null || event.type !== 'assistant/message') return ''
53
+ const data = event.data
54
+ const msg = data && data.message !== undefined ? data.message : data
55
+ const content = msg && msg.content !== undefined ? msg.content : data && data.content
56
+ return textOfBlocks(content)
57
+ }
58
+
59
+ /** 某会话的基线:事件条数 + 助手文本总长。派发前取一次,用来切分"这一轮的输出"。 */
60
+ export const baselineOf = (events) => {
61
+ const list = Array.isArray(events) ? events : []
62
+ let textLen = 0
63
+ for (const e of list) textLen += assistantTextOf(e).length
64
+ return { count: list.length, textLen }
65
+ }
66
+
67
+ /**
68
+ * 取出基线之后新增的助手文本。
69
+ * 返回新的基线,调用方直接替换即可(与 bridge 里 lastSeen/seenCount 的用法一致)。
70
+ */
71
+ export const extractNewAssistantText = (events, baseline) => {
72
+ const list = Array.isArray(events) ? events : []
73
+ const base = baseline === undefined || baseline === null ? { count: 0, textLen: 0 } : baseline
74
+ let text = ''
75
+ for (const e of list) text += assistantTextOf(e)
76
+ const fresh = text.length > base.textLen ? text.slice(base.textLen) : ''
77
+ return { text: fresh, total: text, baseline: { count: list.length, textLen: text.length } }
78
+ }
79
+
80
+ /**
81
+ * 判定一次派发的目标。
82
+ *
83
+ * 接受两种书写:显式 `target` 对象(HTTP/原生工具),或扁平的 `sessionId`/`sessionQuery`
84
+ * 参数(MCP 工具更好用)。两者同时给出且互相矛盾时按 `invalid-target` 拒绝,绝不猜。
85
+ *
86
+ * @returns {{ok:true, target:object, deliver:'queue'|'steer', notes:string[]}}
87
+ * | {{ok:false, reason:string, message:string}}
88
+ */
89
+ export const classifyTarget = (input) => {
90
+ const raw = input === undefined || input === null ? {} : input
91
+ const notes = []
92
+ const flatSessionId = nonEmpty(raw.sessionId) ? raw.sessionId.trim() : undefined
93
+ const flatQuery = nonEmpty(raw.sessionQuery) ? raw.sessionQuery.trim() : undefined
94
+ const t = raw.target
95
+
96
+ // ---- 显式 target 对象 ----
97
+ if (t !== undefined && t !== null) {
98
+ if (typeof t !== 'object' || Array.isArray(t)) {
99
+ return { ok: false, reason: REASONS.INVALID_TARGET, message: 'target must be an object' }
100
+ }
101
+ const kind = t.kind
102
+ if (kind === 'worker') {
103
+ if (nonEmpty(t.sessionId) || nonEmpty(t.query)) {
104
+ return { ok: false, reason: REASONS.INVALID_TARGET, message: 'worker target must not carry session fields' }
105
+ }
106
+ return { ok: true, target: { kind: 'worker', cwd: t.cwd, lane: t.lane }, deliver: 'queue', notes }
107
+ }
108
+ if (kind !== 'session') {
109
+ return { ok: false, reason: REASONS.INVALID_TARGET, message: 'target.kind must be worker or session' }
110
+ }
111
+ const id = nonEmpty(t.sessionId) ? t.sessionId.trim() : undefined
112
+ const query = nonEmpty(t.query) ? t.query.trim() : undefined
113
+ if (id !== undefined && query !== undefined) {
114
+ return { ok: false, reason: REASONS.INVALID_TARGET, message: 'session target takes sessionId or query, not both' }
115
+ }
116
+ if (id === undefined && query === undefined) {
117
+ return { ok: false, reason: REASONS.INVALID_TARGET, message: 'session target needs sessionId or query' }
118
+ }
119
+ // session 语义自带工作目录与模型:混用会让人以为还能控制它们。
120
+ for (const [field, value] of [['cwd', raw.cwd], ['lane', raw.lane], ['model', raw.model]]) {
121
+ if (nonEmpty(value)) {
122
+ return { ok: false, reason: REASONS.INVALID_TARGET, message: `${field} is not accepted with a session target` }
123
+ }
124
+ }
125
+ if (raw.commit === true) {
126
+ return { ok: false, reason: REASONS.INVALID_TARGET, message: 'commit is not accepted with a session target' }
127
+ }
128
+ const deliver = raw.deliver === undefined ? 'queue' : raw.deliver
129
+ if (deliver !== 'queue' && deliver !== 'steer') {
130
+ return { ok: false, reason: REASONS.INVALID_TARGET, message: "deliver must be 'queue' or 'steer'" }
131
+ }
132
+ if (nonEmpty(raw.history) || (Array.isArray(raw.history) && raw.history.length > 0)) {
133
+ notes.push('history ignored for session targets')
134
+ }
135
+ return { ok: true, target: { kind: 'session', sessionId: id, query }, deliver, notes }
136
+ }
137
+
138
+ // ---- 扁平写法(MCP 工具的 sessionId/sessionQuery)----
139
+ if (flatSessionId !== undefined || flatQuery !== undefined) {
140
+ if (flatSessionId !== undefined && flatQuery !== undefined) {
141
+ return { ok: false, reason: REASONS.INVALID_TARGET, message: 'pass sessionId or sessionQuery, not both' }
142
+ }
143
+ if (nonEmpty(raw.cwd) || nonEmpty(raw.lane)) {
144
+ return { ok: false, reason: REASONS.INVALID_TARGET, message: 'cwd/lane are not accepted with a session target' }
145
+ }
146
+ const deliver = raw.deliver === undefined ? 'queue' : raw.deliver
147
+ if (deliver !== 'queue' && deliver !== 'steer') {
148
+ return { ok: false, reason: REASONS.INVALID_TARGET, message: "deliver must be 'queue' or 'steer'" }
149
+ }
150
+ if (Array.isArray(raw.history) && raw.history.length > 0) notes.push('history ignored for session targets')
151
+ return { ok: true, target: { kind: 'session', sessionId: flatSessionId, query: flatQuery }, deliver, notes }
152
+ }
153
+
154
+ // ---- 默认:worker(与 0.1.1 完全一致)----
155
+ return { ok: true, target: { kind: 'worker', cwd: raw.cwd, lane: raw.lane }, deliver: 'queue', notes }
156
+ }
157
+
158
+ /**
159
+ * 从搜索结果里挑出唯一目标。0 条与多条都不猜:多条时把候选回传,由调用方指定 id。
160
+ * 显式 sessionId 优先,完全不查注册表。
161
+ */
162
+ export const selectSession = (hits, target) => {
163
+ const t = target === undefined || target === null ? {} : target
164
+ if (nonEmpty(t.sessionId)) {
165
+ return { ok: true, sessionId: t.sessionId.trim(), snippet: undefined, candidates: [] }
166
+ }
167
+ const list = Array.isArray(hits) ? hits.filter((h) => h !== null && typeof h === 'object' && nonEmpty(h.sessionId)) : []
168
+ const candidates = list.slice(0, MAX_CANDIDATES).map((h) => ({ sessionId: h.sessionId, snippet: typeof h.snippet === 'string' ? h.snippet.slice(0, 200) : '' }))
169
+ if (list.length === 0) {
170
+ return { ok: false, reason: REASONS.QUERY_EMPTY, message: `no session matched ${JSON.stringify(t.query)}`, candidates: [] }
171
+ }
172
+ if (list.length > 1) {
173
+ return { ok: false, reason: REASONS.AMBIGUOUS, message: `${list.length} sessions matched ${JSON.stringify(t.query)}; pass sessionId`, candidates }
174
+ }
175
+ return { ok: true, sessionId: list[0].sessionId, snippet: candidates[0].snippet, candidates }
176
+ }
177
+
178
+ /** 把 sessionController 抛出的 RemoteError 映射成稳定 reason。 */
179
+ export const mapControllerError = (error) => {
180
+ const code = (error !== null && typeof error === 'object' && typeof error.code === 'string' ? error.code : '')
181
+ || (error !== null && typeof error === 'object' && error.cause !== null && typeof error.cause === 'object' && typeof error.cause.code === 'string' ? error.cause.code : '')
182
+ switch (code) {
183
+ case 'session/not-found': return REASONS.NOT_FOUND
184
+ case 'session/agent-busy': return REASONS.BUSY
185
+ case 'session/writer-held': return REASONS.WRITER_HELD
186
+ case 'session/invalid-time-zone': return REASONS.INVALID_TARGET
187
+ case 'gateway/bad-request': return REASONS.BAD_REQUEST
188
+ case 'gateway/internal': return REASONS.GATEWAY_INTERNAL
189
+ default: return REASONS.DISPATCH_FAILED
190
+ }
191
+ }
192
+
193
+ /** 任务在 HTTP 行上的回显:调用方一眼能看出这次是"新建"还是"接进哪个会话"。 */
194
+ export const taskResponse = (task) => {
195
+ const out = { taskId: task.taskId, status: task.status, kind: task.kind }
196
+ const target = task.target !== undefined && task.target !== null
197
+ ? task.target
198
+ // 兼容 0.1.1 之前落下的记录(如 review 任务):没有 target 字段时按 worker 回显。
199
+ : { kind: 'worker', cwd: task.cwd, lane: task.lane }
200
+ if (target.kind === 'session') {
201
+ out.target = { kind: 'session', sessionId: target.sessionId, deliver: task.deliver }
202
+ if (target.snippet !== undefined) out.target.snippet = target.snippet
203
+ } else {
204
+ out.target = { kind: 'worker', cwd: target.cwd, lane: target.lane }
205
+ }
206
+ if (Array.isArray(task.notes) && task.notes.length > 0) out.notes = task.notes
207
+ return out
208
+ }
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.2.0'
9
+ const SERVER_VERSION = '0.1.2'
10
10
  const PROTOCOL_VERSION = '2024-11-05'
11
11
 
12
12
  const log = (...args) => console.error('[dsh-mcp]', ...args)
@@ -15,23 +15,43 @@ const tools = [
15
15
  {
16
16
  name: 'dsh_task',
17
17
  description:
18
- '把编码任务交给本机 DeepSeek Harness 的编码子代理执行(DeepSeek 写代码,ChatGPT/Codex 验收)。' +
18
+ '把编码任务交给本机 DeepSeek Harness 执行(DeepSeek 写代码,ChatGPT/Codex 验收)。两种目标:默认新建编码子代理在 cwd 里干活;' +
19
+ '给出 sessionId 或 sessionQuery 时改为**投递进那一条已有会话**(保留它自己的上下文,不新建会话/工作区)。' +
19
20
  '参数: instruction(必填,完整中文指令:目标/产出目录/验收标准/约束), cwd(工作目录,默认 D:\\Harness,通常传当前项目目录), ' +
20
21
  'lane(并行通道名,默认 main), model(fast=deepseek-v4-flash 快而省 / pro=deepseek-v4-pro 质量优先), ' +
22
+ 'sessionId(已有会话 id), sessionQuery(按标题/内容搜已有会话,需唯一命中,否则返回候选), deliver(queue=排队(默认) / steer=插入当前回合), ' +
21
23
  'wait(默认 true,阻塞至任务完成并返回 DeepSeek 汇报), timeoutSec(默认 600,最长 900)。',
22
24
  inputSchema: {
23
25
  type: 'object',
24
26
  properties: {
25
27
  instruction: { type: 'string', description: '完整中文编码指令(目标、产出目录、验收标准、约束)。' },
26
- cwd: { type: 'string', description: '工作目录(绝对路径)。默认 D:\\Harness。' },
28
+ cwd: { type: 'string', description: '工作目录(绝对路径)。默认 D:\\Harness。与 sessionId/sessionQuery 互斥。' },
27
29
  lane: { type: 'string', description: '并行通道名(默认 main)。' },
28
30
  model: { type: 'string', enum: ['fast', 'pro'], description: '模型别名:fast=deepseek-v4-flash,pro=deepseek-v4-pro。' },
31
+ sessionId: { type: 'string', description: '已有会话 id:把指令投递进这条会话,而不是新建子代理。' },
32
+ sessionQuery: { type: 'string', description: '按标题或内容搜索已有会话;唯一命中才派发,多条会返回 candidates 让你改用 sessionId。' },
33
+ deliver: { type: 'string', enum: ['queue', 'steer'], description: '投递方式:queue 排队(默认),steer 插入当前回合。' },
29
34
  wait: { type: 'boolean', description: '默认 true:阻塞直到任务完成并返回汇报。' },
30
35
  timeoutSec: { type: 'number', description: '最长等待秒数(默认 600,最大 900)。' },
31
36
  },
32
37
  required: ['instruction'],
33
38
  },
34
39
  },
40
+ {
41
+ name: 'dsh_sessions',
42
+ description:
43
+ '只读查找本机 DSH 的已有会话(不创建、不投递)。用 sessionQuery 按标题/内容搜,或用 sessionId 直接解析某一条;' +
44
+ '拿到 sessionId 后交给 dsh_task 即可把任务投递进那条会话。参数: query, sessionId, limit(默认 10), inspect(默认 false,为 true 时探测前 5 条能否接)。',
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: {
48
+ query: { type: 'string', description: '按标题/内容搜索(与 sessionId 二选一)。' },
49
+ sessionId: { type: 'string', description: '直接解析某条会话(与 query 二选一)。' },
50
+ limit: { type: 'number', description: '最多返回条数(默认 10,最大 50)。' },
51
+ inspect: { type: 'boolean', description: '为 true 时对前 5 条探测 agentAvailable / problem(默认 false)。' },
52
+ },
53
+ },
54
+ },
35
55
  {
36
56
  name: 'dsh_task_status',
37
57
  description: '查询 DeepSeek 协作任务的当前状态与结果。参数: taskId(必填)。',
@@ -43,10 +63,13 @@ const tools = [
43
63
  },
44
64
  {
45
65
  name: 'dsh_task_cancel',
46
- description: '取消一个运行中的 DeepSeek 协作任务。参数: taskId(必填)。',
66
+ description: '取消一个运行中的 DeepSeek 协作任务。参数: taskId(必填), force(session 目标的任务默认拒绝取消,因为它会打断用户自己的回合;确认要打断时传 true)。',
47
67
  inputSchema: {
48
68
  type: 'object',
49
- properties: { taskId: { type: 'string', description: '任务 ID。' } },
69
+ properties: {
70
+ taskId: { type: 'string', description: '任务 ID。' },
71
+ force: { type: 'boolean', description: '仅对投递进已有会话的任务有意义:true 才真正打断该会话。' },
72
+ },
50
73
  required: ['taskId'],
51
74
  },
52
75
  },
@@ -112,18 +135,36 @@ async function handleToolCall(name, args) {
112
135
  cwd: args.cwd,
113
136
  lane: args.lane,
114
137
  model: args.model,
138
+ sessionId: args.sessionId,
139
+ sessionQuery: args.sessionQuery,
140
+ deliver: args.deliver,
115
141
  })
142
+ // 4xx 拒绝(缺目标 / 会话不存在 / 搜索结果不唯一 / 被写锁占用)不带 taskId,绝不静默新建。
143
+ if (task.taskId === undefined) return { ok: false, phase: 'rejected', ...task }
116
144
  if (task.status === 'error') return { ok: false, ...task }
117
145
  if (args.wait === false) return { ok: true, phase: 'submitted', ...task }
118
146
  const done = await waitTask(task.taskId, args.timeoutSec || 600)
119
147
  return { ok: done.status === 'done', phase: 'finished', ...done }
120
148
  }
149
+ if (name === 'dsh_sessions') {
150
+ if (args.query === undefined && args.sessionId === undefined) {
151
+ return { ok: false, error: 'query or sessionId is required' }
152
+ }
153
+ const found = await callGateway('/api/dsh-bridge/sessions', {
154
+ query: args.query,
155
+ sessionId: args.sessionId,
156
+ limit: args.limit,
157
+ inspect: args.inspect,
158
+ })
159
+ if (Array.isArray(found.items)) return { ok: true, ...found }
160
+ return { ok: false, ...found }
161
+ }
121
162
  if (name === 'dsh_task_status') {
122
163
  const res = await fetch(GATEWAY + '/api/dsh-bridge/status?taskId=' + encodeURIComponent(args.taskId))
123
164
  return res.json()
124
165
  }
125
166
  if (name === 'dsh_task_cancel') {
126
- return callGateway('/api/dsh-bridge/cancel', { taskId: args.taskId })
167
+ return callGateway('/api/dsh-bridge/cancel', { taskId: args.taskId, force: args.force === true })
127
168
  }
128
169
  if (name === 'dsh_review') {
129
170
  const task = await callGateway('/api/dsh-bridge/review', { cwd: args.cwd, diff: args.diff, focus: args.focus })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whaletalk/dsh-codex-collab",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Codex × DeepSeek Harness 双向编码协作:Codex 主导派活与验收,DeepSeek Harness 编码子代理在共享工作目录执行并回传,独立评审子代理实际跑通构建/测试后出具报告。提供 DSH 宿主网关插件、零依赖 MCP 服务器与 Codex 技能三件套。",
5
5
  "license": "MIT",
6
6
  "type": "module",