@whaletalk/dsh-codex-collab 0.1.3 → 0.1.4
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
|
@@ -202,12 +202,17 @@ dsh-task --in "<继续推进的指令>" --session "session-8d481ad9-..."
|
|
|
202
202
|
|
|
203
203
|
## 已知限制
|
|
204
204
|
|
|
205
|
-
- 网关插件需要 `webServer` 服务(由 `dsh-web-app` 提供),因此**只能挂 web / desktop profile**;headless profile 会一直 pending。session
|
|
205
|
+
- 网关插件需要 `webServer` 服务(由 `dsh-web-app` 提供),因此**只能挂 web / desktop profile**;headless profile 会一直 pending。session 目标还需要 `sessionController`——它由组合**异步注册**(`webserver → web-runtime → connection → file-upload → session-controller`),所以插件是惰性取用,注册前调用会返回 `session-controller-unavailable`(稍后重试即可)
|
|
206
|
+
- **`sessionController.search` 可能被部署禁用**(`session-query` 索引 `openAt: "never"`)。此时按标题查找自动退回 `list`(读持久化 header + 标题投影,同样不激活 Agent),响应里的 `matchedBy` 标明走了哪条路
|
|
207
|
+
- **不能投递进子代理会话**:`resolveAgent` 对 subagent 路由拥有的会话返回 `session-busy: owned by subagent routing`;只能接普通会话
|
|
208
|
+
- `POST /api/dsh-bridge/sessions {"create": true, "cwd": "..."}` 可新建一条空白会话(探针/一次性任务用),它不会碰任何已有会话
|
|
206
209
|
- **同一会话同时只允许一个在途 bridge 任务**:DSH 自己会排队,但无法可靠地把两次并发派活的回报各自归属,所以第二个会被明确拒绝(`session-busy`)而不是猜
|
|
207
210
|
- **冷会话可能接不上**:DSH 激活 Agent 需要会话投影(`Agent activation requires a projected Session observation`),未激活的会话会返回 `session-not-activatable`;归档会话的回合会以 `blocked` 收口(`session-archived`)
|
|
208
211
|
- **评审不支持会话目标**:评审员必须与编码会话隔离,"独立评审"才成立
|
|
209
212
|
- session 目标不使用 `cwd`/`lane`/`model`/`--commit`(会话自带这些语义),传了会被判 `invalid-target`
|
|
210
213
|
- 网关重启会丢失内存中的任务表(含 session 观察器),这是既有设计
|
|
214
|
+
- **DSH profile 的 pnpm 供应链冷却**:新发布的版本约 24 小时内会被 `minimumReleaseAge` 拒绝安装(`ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION`);管理器会把它写进 `pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude` 才放行。刚发版就装不上属正常,不是包的问题
|
|
215
|
+
- 升级已装组合包 = **卸载 → 重新安装**;卸载会把该包从 `dsh.profile.bundles` 移除,所以重装后**要确认它被重新选中**,否则插件不会挂载(表现为所有 `/api/dsh-bridge/*` 都落到网关鉴权层返回 401)
|
|
211
216
|
- Codex Desktop Windows 本地市场技能注入 bug(见上,MCP 通道不受影响)
|
|
212
217
|
- 动态插件环境无 `AbortSignal`,冷恢复失败时自动降级为一次性执行;宿主组合持久化版无此问题
|
|
213
218
|
- ChatGPT Plus 写入型 MCP 的开放度取决于 OpenAI 产品策略
|
|
@@ -84,5 +84,7 @@ dsh-task --cancel <taskId> --force # 取消 session 目标任务(会打断
|
|
|
84
84
|
|
|
85
85
|
- 连接失败:DeepSeek Harness 未运行(网关应监听本机端口,默认 3080;挂在 web/desktop profile 时也可能就是 GUI 的端口)。
|
|
86
86
|
- 派 session 任务返回 `session-writer-held` / `session-busy`:那条会话被别的写入方或另一个 bridge 任务占用;稍后重试,或改用 worker 目标。
|
|
87
|
+
- 返回 `session-busy: owned by subagent routing`:那是子代理会话,**不能作为投递目标**;只能接普通会话。
|
|
87
88
|
- 返回 `session-not-activatable` / `session-archived`:该会话当前接不了(冷会话未能激活,或已归档);请用户先在客户端打开它,或解除归档。
|
|
89
|
+
- 某些部署禁用了搜索(会话索引 `openAt: never`):`--sessions` 会自动退回 `list` + 标题投影匹配,响应里的 `matchedBy` 会写 `list`。
|
|
88
90
|
- 读不到 `--cwd` 目录:提示用户在 Codex 设置中把该目录加入可访问/可写目录。
|
package/harness/dsh-bridge.mjs
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
classifyTarget,
|
|
30
30
|
selectSession,
|
|
31
31
|
mapControllerError,
|
|
32
|
+
sessionTitleOf,
|
|
33
|
+
looksLikeDisabledSearch,
|
|
32
34
|
taskResponse,
|
|
33
35
|
} from './session-target.mjs'
|
|
34
36
|
|
|
@@ -408,6 +410,44 @@ export async function apply(ctx) {
|
|
|
408
410
|
return items.filter((it) => it !== null && typeof it === 'object' && typeof it.sessionId === 'string' && it.sessionId !== '')
|
|
409
411
|
}
|
|
410
412
|
|
|
413
|
+
/**
|
|
414
|
+
* 退回路径:不打开 search 索引,直接遍历 list(读持久化 header + 投影缓存)。
|
|
415
|
+
* 有些部署把 session-query 索引配成 openAt: "never",此时 search 永远失败,
|
|
416
|
+
* 而按标题找会话仍必须可用——list 就是不激活 Agent 也能拿到标题的那条路。
|
|
417
|
+
*/
|
|
418
|
+
const sessionList = async (query) => {
|
|
419
|
+
const controller = sessionControllerNow()
|
|
420
|
+
if (controller === undefined) {
|
|
421
|
+
throw Object.assign(new Error('sessionController service is not mounted in this profile'), { code: 'bridge/no-controller' })
|
|
422
|
+
}
|
|
423
|
+
const needle = query.toLowerCase()
|
|
424
|
+
const value = await controller.list({}, signalOf())
|
|
425
|
+
const items = value !== undefined && value !== null && Array.isArray(value.items) ? value.items : []
|
|
426
|
+
const out = []
|
|
427
|
+
for (const summary of items) {
|
|
428
|
+
if (summary === null || typeof summary !== 'object' || typeof summary.sessionId !== 'string') continue
|
|
429
|
+
const title = sessionTitleOf(summary)
|
|
430
|
+
if (title !== '' && title.toLowerCase().includes(needle)) {
|
|
431
|
+
out.push({ sessionId: summary.sessionId, snippet: title })
|
|
432
|
+
continue
|
|
433
|
+
}
|
|
434
|
+
if (typeof summary.cwd === 'string' && summary.cwd !== '' && summary.cwd.toLowerCase().includes(needle)) {
|
|
435
|
+
out.push({ sessionId: summary.sessionId, snippet: summary.cwd })
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return out
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** 先 search,失败就退回 list;返回 {hits, matchedBy} 让调用方看得见走了哪条路。 */
|
|
442
|
+
const sessionFind = async (query) => {
|
|
443
|
+
try {
|
|
444
|
+
return { hits: await sessionSearch(query), matchedBy: 'search' }
|
|
445
|
+
} catch (e) {
|
|
446
|
+
if (!looksLikeDisabledSearch(e)) throw e
|
|
447
|
+
return { hits: await sessionList(query), matchedBy: 'list' }
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
411
451
|
/** 只读解析:query → search 选一;sessionId → 直接用。任何不确定性都返回 reason。 */
|
|
412
452
|
const resolveSessionTarget = async (target) => {
|
|
413
453
|
const controller = sessionControllerNow()
|
|
@@ -415,9 +455,12 @@ export async function apply(ctx) {
|
|
|
415
455
|
return { ok: false, reason: REASONS.CONTROLLER_UNAVAILABLE, message: 'sessionController service is not mounted in this profile', candidates: [] }
|
|
416
456
|
}
|
|
417
457
|
let hits = []
|
|
458
|
+
let matchedBy = 'sessionId'
|
|
418
459
|
if (target.sessionId === undefined) {
|
|
419
460
|
try {
|
|
420
|
-
|
|
461
|
+
const found = await sessionFind(target.query)
|
|
462
|
+
hits = found.hits
|
|
463
|
+
matchedBy = found.matchedBy
|
|
421
464
|
} catch (e) {
|
|
422
465
|
return { ok: false, reason: REASONS.GATEWAY_INTERNAL, message: errText(e), candidates: [] }
|
|
423
466
|
}
|
|
@@ -433,7 +476,7 @@ export async function apply(ctx) {
|
|
|
433
476
|
if (result !== undefined && result !== null && result.error !== undefined) {
|
|
434
477
|
return { ok: false, reason: mapControllerError(result.error), message: errText(result.error), candidates: picked.candidates }
|
|
435
478
|
}
|
|
436
|
-
return { ok: true, sessionId: picked.sessionId, snippet: picked.snippet, candidates: picked.candidates }
|
|
479
|
+
return { ok: true, sessionId: picked.sessionId, snippet: picked.snippet, candidates: picked.candidates, matchedBy }
|
|
437
480
|
}
|
|
438
481
|
|
|
439
482
|
const sessionPendingTask = (sessionId) => tasks.find((t) => (t.status === 'accepted' || t.status === 'running') && t.target !== undefined && t.target.kind === 'session' && t.target.sessionId === sessionId)
|
|
@@ -532,6 +575,7 @@ export async function apply(ctx) {
|
|
|
532
575
|
task.target.sessionId = resolved.sessionId
|
|
533
576
|
if (resolved.snippet !== undefined) task.target.snippet = resolved.snippet
|
|
534
577
|
if (resolved.candidates.length > 0) task.candidates = resolved.candidates
|
|
578
|
+
if (resolved.matchedBy !== undefined) task.matchedBy = resolved.matchedBy
|
|
535
579
|
|
|
536
580
|
const inFlight = sessionPendingTask(resolved.sessionId)
|
|
537
581
|
if (inFlight !== undefined && inFlight.taskId !== task.taskId) {
|
|
@@ -846,6 +890,17 @@ export async function apply(ctx) {
|
|
|
846
890
|
}
|
|
847
891
|
const query = typeof parsed.query === 'string' && parsed.query.trim() !== '' ? parsed.query.trim() : undefined
|
|
848
892
|
const sessionId = typeof parsed.sessionId === 'string' && parsed.sessionId.trim() !== '' ? parsed.sessionId.trim() : undefined
|
|
893
|
+
// create 是显式动作:为探针/一次性任务建一条空白会话,不去碰任何已有会话。
|
|
894
|
+
if (parsed.create === true) {
|
|
895
|
+
const cwd = typeof parsed.cwd === 'string' && parsed.cwd.trim() !== '' ? parsed.cwd.trim() : undefined
|
|
896
|
+
try {
|
|
897
|
+
const created = await controller.create(cwd === undefined ? {} : { cwd }, signalOf())
|
|
898
|
+
sendJson(res, 201, { sessionId: created !== undefined && created !== null ? created.sessionId : undefined, agentPreset: created === undefined || created === null ? undefined : created.agentPreset })
|
|
899
|
+
} catch (e) {
|
|
900
|
+
sendJson(res, 502, { error: errText(e), reason: mapControllerError(e) })
|
|
901
|
+
}
|
|
902
|
+
return
|
|
903
|
+
}
|
|
849
904
|
if (query === undefined && sessionId === undefined) {
|
|
850
905
|
sendJson(res, 400, { error: 'query or sessionId required', reason: REASONS.INVALID_TARGET })
|
|
851
906
|
return
|
|
@@ -861,7 +916,9 @@ export async function apply(ctx) {
|
|
|
861
916
|
sendJson(res, 200, { items: [{ sessionId: resolved.sessionId, ...(resolved.snippet === undefined ? {} : { snippet: resolved.snippet }), agentAvailable: true }], hasMore: false })
|
|
862
917
|
return
|
|
863
918
|
}
|
|
864
|
-
|
|
919
|
+
// search 失败就退回 list:有些部署把 session-query 索引配成 openAt "never"。
|
|
920
|
+
const found = await sessionFind(query)
|
|
921
|
+
const hits = found.hits
|
|
865
922
|
const items = hits.slice(0, limit).map((h) => ({
|
|
866
923
|
sessionId: h.sessionId,
|
|
867
924
|
snippet: typeof h.snippet === 'string' ? h.snippet.slice(0, 200) : '',
|
|
@@ -880,7 +937,7 @@ export async function apply(ctx) {
|
|
|
880
937
|
}
|
|
881
938
|
}
|
|
882
939
|
}
|
|
883
|
-
sendJson(res, 200, { items, hasMore: hits.length > items.length, total: hits.length })
|
|
940
|
+
sendJson(res, 200, { items, hasMore: hits.length > items.length, total: hits.length, matchedBy: found.matchedBy })
|
|
884
941
|
} catch (e) {
|
|
885
942
|
sendJson(res, 502, { error: errText(e), reason: mapControllerError(e) })
|
|
886
943
|
}
|
|
@@ -983,6 +1040,7 @@ export async function apply(ctx) {
|
|
|
983
1040
|
taskId: task.taskId, status: task.status, kind: task.kind, cwd: task.cwd, lane: task.lane,
|
|
984
1041
|
target: task.target, deliver: task.deliver, instruction: task.instruction.slice(0, 300),
|
|
985
1042
|
result: task.result, error: task.error, reason: task.reason, fallback: task.fallback,
|
|
1043
|
+
...(task.matchedBy === undefined ? {} : { matchedBy: task.matchedBy }),
|
|
986
1044
|
...(Array.isArray(task.candidates) && task.candidates.length > 0 ? { candidates: task.candidates } : {}),
|
|
987
1045
|
...(record === undefined ? {} : { watcher: record.watcher, sawRunning: record.sawRunning }),
|
|
988
1046
|
})
|
|
@@ -190,6 +190,34 @@ export const mapControllerError = (error) => {
|
|
|
190
190
|
}
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/**
|
|
194
|
+
* 从 SessionSummary 里取标题。
|
|
195
|
+
*
|
|
196
|
+
* 标题不在 summary 顶层:它来自投影缓存(`projections.values.title`),而且不同 DSH
|
|
197
|
+
* 版本可能给字符串或 `{ val }` 包装,两种都认。`list` 在**不激活 Agent、也不打开
|
|
198
|
+
* search 索引**的前提下读持久化 header 与投影缓存,因此在 search 被部署禁用
|
|
199
|
+
* (session-query 索引 openAt: "never")时,它是唯一还能按标题找会话的路径。
|
|
200
|
+
*/
|
|
201
|
+
export const sessionTitleOf = (summary) => {
|
|
202
|
+
if (summary === null || typeof summary !== 'object') return ''
|
|
203
|
+
const projections = summary.projections
|
|
204
|
+
if (projections === null || typeof projections !== 'object') return ''
|
|
205
|
+
const values = projections.values
|
|
206
|
+
if (values === null || typeof values !== 'object') return ''
|
|
207
|
+
const title = values.title
|
|
208
|
+
if (typeof title === 'string') return title
|
|
209
|
+
if (title !== null && typeof title === 'object' && typeof title.val === 'string') return title.val
|
|
210
|
+
return ''
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** search 在本部署被禁用时的报错特征(session-query 索引 openAt: "never")。 */
|
|
214
|
+
export const looksLikeDisabledSearch = (error) => {
|
|
215
|
+
const text = error !== null && typeof error === 'object' && typeof error.message === 'string'
|
|
216
|
+
? error.message
|
|
217
|
+
: String(error === undefined || error === null ? '' : error)
|
|
218
|
+
return /search is disabled/i.test(text)
|
|
219
|
+
}
|
|
220
|
+
|
|
193
221
|
/** 任务在 HTTP 行上的回显:调用方一眼能看出这次是"新建"还是"接进哪个会话"。 */
|
|
194
222
|
export const taskResponse = (task) => {
|
|
195
223
|
const out = { taskId: task.taskId, status: task.status, kind: task.kind }
|
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.
|
|
9
|
+
const SERVER_VERSION = '0.1.4'
|
|
10
10
|
const PROTOCOL_VERSION = '2024-11-05'
|
|
11
11
|
|
|
12
12
|
const log = (...args) => console.error('[dsh-mcp]', ...args)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@whaletalk/dsh-codex-collab",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Codex × DeepSeek Harness 双向编码协作:Codex 主导派活与验收,DeepSeek Harness 编码子代理在共享工作目录执行并回传,独立评审子代理实际跑通构建/测试后出具报告。提供 DSH 宿主网关插件、零依赖 MCP 服务器与 Codex 技能三件套。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|