dsh-baton-host 0.5.7 → 0.5.9
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 +31 -3
- package/lib/index.js +272 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/JialaoLiu/dsh-baton/main/assets/readme/hero.gif" width="100%" alt="dsh-baton: pass the baton from Claude Code or Codex to DeepSeek Harness, watch it run live, and get it handed back when it's done">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://github.com/JialaoLiu/dsh-baton#readme">dsh-baton</a> · <a href="https://github.com/JialaoLiu/dsh-baton/blob/main/README.zh-CN.md">中文说明</a>
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
macOS · Windows (<a href="https://github.com/JialaoLiu/dsh-baton/blob/main/WINDOWS.md">guide</a>) · no Node.js needed · zero dependencies · MIT
|
|
11
|
+
</p>
|
|
12
|
+
|
|
1
13
|
# dsh-baton-host
|
|
2
14
|
|
|
3
|
-
DeepSeek Harness plugin for [dsh-baton](https://github.com/JialaoLiu/dsh-baton#readme). It runs inside the Harness host (desktop app or `dsh web`) and serves a local, owner-only Unix socket (`~/.dsh-baton/host.sock`; a per-user named pipe on Windows
|
|
15
|
+
The DeepSeek Harness plugin for [dsh-baton](https://github.com/JialaoLiu/dsh-baton#readme). It runs inside the Harness host (desktop app or `dsh web`) and serves a local, owner-only Unix socket (`~/.dsh-baton/host.sock`; a per-user named pipe on Windows). Through it, an MCP client can list and create workspaces, create sessions, continue a specific session, and wait for its turn to finish. All of this goes through Harness's own session controller, so the messages show up live in the Harness UI.
|
|
4
16
|
|
|
5
17
|
DeepSeek Harness 插件,配合 [dsh-baton](https://github.com/JialaoLiu/dsh-baton/blob/main/README.zh-CN.md) 使用。它运行在 Harness 内部,只开一个当前用户才能访问的本地套接字,让 MCP 客户端能新建工作区和对话、续接指定对话、等待任务完成。所有操作都走 Harness 官方会话服务,界面实时可见。
|
|
6
18
|
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="https://raw.githubusercontent.com/JialaoLiu/dsh-baton/main/assets/readme/features.svg" width="100%" alt="Create workspaces and sessions, continue a session, run live in the Harness UI labelled with the sender, get woken when it is done, no extra API key, macOS and Windows with zero dependencies">
|
|
21
|
+
</p>
|
|
22
|
+
|
|
7
23
|
## Install / 安装
|
|
8
24
|
|
|
25
|
+
<p align="center">
|
|
26
|
+
<img src="https://raw.githubusercontent.com/JialaoLiu/dsh-baton/main/assets/readme/install.svg" width="100%" alt="Step 1: in Harness open Plugins, Add plugin, enter dsh-baton-host, Install, Enable now; it registers dsh-baton for Claude Code and Codex. Step 2: in a new chat, ask for dsh_list_workspaces">
|
|
27
|
+
</p>
|
|
28
|
+
|
|
9
29
|
Harness → Plugins / 插件 → Add plugin / 添加插件 → `dsh-baton-host` → Install / 安装 → Enable now / 立即启用
|
|
10
30
|
|
|
11
31
|
That is the whole install: switched on, the plugin registers the dsh-baton MCP server (the `dsh-baton-mcp` package, installed with it) for Claude Code and Codex, user-wide, and Harness says so. New conversations then have the `dsh_*` tools. If it could not register, run `npx -y dsh-baton-mcp setup`. Details: [dsh-baton](https://github.com/JialaoLiu/dsh-baton#install-details).
|
|
@@ -16,6 +36,12 @@ Update / 更新: uninstall, then add it again; the MCP server is updated with it
|
|
|
16
36
|
|
|
17
37
|
Remove / 卸载: uninstall, then `npx -y dsh-baton-mcp setup --remove` / 卸载插件后运行 `npx -y dsh-baton-mcp setup --remove`.
|
|
18
38
|
|
|
39
|
+
## How it works / 工作原理
|
|
40
|
+
|
|
41
|
+
<p align="center">
|
|
42
|
+
<img src="https://raw.githubusercontent.com/JialaoLiu/dsh-baton/main/assets/readme/architecture.svg" width="100%" alt="MCP clients call dsh-baton-mcp over stdio; it reaches the dsh-baton-host plugin inside Harness through an owner-only local socket; the plugin drives Harness sessions live, with dsh headless as fallback">
|
|
43
|
+
</p>
|
|
44
|
+
|
|
19
45
|
## Socket protocol / 套接字协议
|
|
20
46
|
|
|
21
47
|
Newline-delimited JSON: request `{"id", "method", "params"}`, reply `{"id", "result"}` or `{"id", "error"}`.
|
|
@@ -27,14 +53,16 @@ Newline-delimited JSON: request `{"id", "method", "params"}`, reply `{"id", "res
|
|
|
27
53
|
| `create_workspace` | `path`, `title?`, `create_dir?` (default `true`) |
|
|
28
54
|
| `list_sessions` | `cwd?`, `query?`, `limit?` |
|
|
29
55
|
| `read_session` | `session_id`, `last?` |
|
|
30
|
-
| `send` | `message`, and `session_id` or `cwd` or `workspace_id`; `mode?` (`followup` \| `steer`) |
|
|
56
|
+
| `send` | `message`, and `session_id` or `cwd` or `workspace_id`; `mode?` (`followup` \| `steer`; left out, Harness's busy-send setting, else `followup`) |
|
|
31
57
|
| `status` | `run_id`, `tail?` |
|
|
32
58
|
| `wait` | `run_id`, `timeout_s?` |
|
|
33
59
|
| `activity` | `session_id` |
|
|
34
60
|
| `cancel` | `run_id` or `session_id` |
|
|
35
61
|
| `rename_session` | `session_id`, `title` |
|
|
36
62
|
| `rename_workspace` | `workspace_id` or `path`, `title` |
|
|
37
|
-
| `list_runs` | `limit?` |
|
|
63
|
+
| `list_runs` | `session_id?`, `limit?` |
|
|
64
|
+
| `answer` | `run_id`, `answers` (`[{ id?, choose?, text?, skip? }]`) |
|
|
65
|
+
| `update_queued` | `run_id`, `action` (`edit` \| `remove` \| `steer`), `message?` (for `edit`) |
|
|
38
66
|
|
|
39
67
|
`DSH_BATON_SOCKET` / `DSH_BATON_HOME` in the Harness process environment move the socket. `DSH_BATON_AUTO_REGISTER=0` turns off the MCP registration.
|
|
40
68
|
|
package/lib/index.js
CHANGED
|
@@ -122,9 +122,18 @@ export function apply(ctx) {
|
|
|
122
122
|
try { ctx.logger?.error?.(`dsh-baton-host ${label}: ${error?.stack ?? error}`) } catch { /* logging must never throw */ }
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// A waterfall listener must never break Harness's chain: on any error of its own it
|
|
126
|
+
// hands the request on untouched.
|
|
127
|
+
const guardWaterfall = (fn) => function (request, next) {
|
|
128
|
+
try { return fn(request, next) } catch (error) { logError('waterfall', error); return next() }
|
|
129
|
+
}
|
|
130
|
+
|
|
125
131
|
const controller = ctx.sessionController
|
|
126
132
|
const registry = ctx.workspaceRegistry
|
|
133
|
+
// Harness's settings service, when this Harness has one: read for its busy-send setting.
|
|
134
|
+
let settings = null
|
|
127
135
|
const runs = new Map() // runId -> run
|
|
136
|
+
let stopping = false // set when the plugin unloads: release every wait()
|
|
128
137
|
const waiters = new Map() // runId -> Set<resolve>
|
|
129
138
|
|
|
130
139
|
// --- run bookkeeping ------------------------------------------------------
|
|
@@ -136,12 +145,27 @@ export function apply(ctx) {
|
|
|
136
145
|
...(run.waitingOn ? { waiting_on: run.waitingOn } : {}),
|
|
137
146
|
...(run.supersededBy ? { superseded_by: run.supersededBy } : {}),
|
|
138
147
|
...(run.humanInputs?.length ? { human_interjections: run.humanInputs } : {}),
|
|
148
|
+
...(run.question ? { question: run.question } : {}),
|
|
139
149
|
...(tail ? { recent_events: run.events.slice(-tail) } : {}),
|
|
140
150
|
})
|
|
141
151
|
|
|
142
152
|
const ACTIVE = ['queued', 'running', 'waiting']
|
|
143
153
|
const isActive = (run) => ACTIVE.includes(run.state)
|
|
144
154
|
|
|
155
|
+
// Release callers blocked in wait() without ending the run (it needs a person now).
|
|
156
|
+
function wake(run) {
|
|
157
|
+
for (const resolve of [...(waiters.get(run.id) ?? [])]) resolve()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The run now waits for a person. The next wait() returns at once with what is asked
|
|
161
|
+
// (once per episode: a second wait() blocks again until the run moves on).
|
|
162
|
+
function needsPerson(run, delayMs = 0) {
|
|
163
|
+
Object.assign(run, { state: 'waiting', waitNoticed: false })
|
|
164
|
+
persist(run)
|
|
165
|
+
if (delayMs) setTimeout(guard('waiting', () => wake(run)), delayMs)
|
|
166
|
+
else wake(run)
|
|
167
|
+
}
|
|
168
|
+
|
|
145
169
|
function settle(run, state, extra = {}) {
|
|
146
170
|
if (!isActive(run)) return
|
|
147
171
|
Object.assign(run, extra, { state, endedAt: new Date().toISOString() })
|
|
@@ -250,9 +274,24 @@ export function apply(ctx) {
|
|
|
250
274
|
// Turn state is kept only for a turn in progress and dropped at its end.
|
|
251
275
|
if (event.type === 'turn/start') turns.set(session.id, { hasOutput: false })
|
|
252
276
|
else if (event.type === 'assistant/message' && turns.has(session.id)) turns.get(session.id).hasOutput = true
|
|
277
|
+
// A run settled as removed from the queue whose message reaches the conversation
|
|
278
|
+
// after all (the removal check guessed wrong) is taken up again.
|
|
279
|
+
if (event.type === 'user/message') {
|
|
280
|
+
const lost = [...runs.values()].find((r) => r.removedFromQueue && r.state === 'cancelled' && r.sessionId === session.id && r.requestId === data.source?.rpcId)
|
|
281
|
+
if (lost) {
|
|
282
|
+
Object.assign(lost, { state: 'queued', removedFromQueue: false, error: null, endedAt: null })
|
|
283
|
+
pushEvent(lost, { type: 'queue_delivered' })
|
|
284
|
+
}
|
|
285
|
+
}
|
|
253
286
|
const active = activeRunsFor(session.id)
|
|
254
287
|
if (event.type === 'turn/end' && !active.some((r) => r.sawPrompt)) turns.delete(session.id)
|
|
255
288
|
if (!active.length) return
|
|
289
|
+
// Something left Harness's queue: a queued run may have been removed (session stopped,
|
|
290
|
+
// or Remove in the Harness UI) rather than started. Checked once the dust settles.
|
|
291
|
+
if (event.type === 'agent/inbox/spliced' && data.removedCount > 0 && active.some((r) => !r.sawPrompt)) {
|
|
292
|
+
scheduleQueueCheck(session.id)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
256
295
|
if (event.type === 'user/message') {
|
|
257
296
|
const run = active.find((r) => r.requestId === data.source?.rpcId)
|
|
258
297
|
const opensTurn = !turns.get(session.id)?.hasOutput
|
|
@@ -388,7 +427,9 @@ export function apply(ctx) {
|
|
|
388
427
|
if (status === 'running' && receivers.length) lastRunningAt.set(agent.id, Date.now())
|
|
389
428
|
for (const run of outputReceiversFor(agent.id)) {
|
|
390
429
|
if (status === 'running') run.state = 'running'
|
|
391
|
-
|
|
430
|
+
// Needs a person: a question (see user-questions/request) or an approval in the
|
|
431
|
+
// Harness UI. Waiters wake a moment later, so a question that comes with it is on the run.
|
|
432
|
+
else if (status === 'waiting') { if (run.state !== 'waiting') needsPerson(run, 500) }
|
|
392
433
|
else if (status === 'error') settle(run, 'failed')
|
|
393
434
|
else if (status === 'idle') run.idleSeen = true
|
|
394
435
|
}
|
|
@@ -399,6 +440,208 @@ export function apply(ctx) {
|
|
|
399
440
|
for (const run of agent ? outputReceiversFor(agent.id) : []) run.error = String(error?.message ?? error)
|
|
400
441
|
}))
|
|
401
442
|
|
|
443
|
+
// --- Harness's queue (inbox) ------------------------------------------------
|
|
444
|
+
// A followup sent while the session is busy waits in Harness's inbox until the turn
|
|
445
|
+
// ends. Each pending message carries the request id it was sent with (source.rpcId),
|
|
446
|
+
// so a run finds its own message there.
|
|
447
|
+
|
|
448
|
+
const QUEUE_UNSUPPORTED = 'This DeepSeek Harness version cannot change queued messages: its session controller has no projections/updateQueue.'
|
|
449
|
+
const queueTimers = new Map() // sessionId -> timeout
|
|
450
|
+
const QUEUE_RECHECK_MS = Number(process.env.DSH_BATON_QUEUE_RECHECK_MS) || 2000
|
|
451
|
+
|
|
452
|
+
// Messages still pending in a session's inbox, or null when this Harness cannot say.
|
|
453
|
+
async function pendingMessages(sessionId) {
|
|
454
|
+
if (typeof controller.projections !== 'function') return null
|
|
455
|
+
const baseline = await controller.projections({ sessionId }, signal())
|
|
456
|
+
if (baseline === null) return [] // no such session: nothing is pending
|
|
457
|
+
const inbox = baseline?.values?.inbox
|
|
458
|
+
if (!inbox) return null
|
|
459
|
+
return [...(inbox['next-turn'] ?? []), ...(inbox['next-step'] ?? [])]
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Settle the runs whose message left the inbox without reaching the conversation:
|
|
463
|
+
// Harness drops the queue when a session is stopped, and the Harness UI can remove one.
|
|
464
|
+
// A message taken into the conversation also leaves the inbox, and its user/message
|
|
465
|
+
// event can trail behind (edited messages, a busy disk), so a run counts as removed
|
|
466
|
+
// only when it is still missing from both the inbox and the log a moment later.
|
|
467
|
+
async function reconcileQueue(sessionId) {
|
|
468
|
+
let gone = await missingFromQueue(sessionId, activeRunsFor(sessionId).filter((r) => r.admitted && !r.sawPrompt))
|
|
469
|
+
if (!gone.length) return []
|
|
470
|
+
await new Promise((resolve) => setTimeout(resolve, QUEUE_RECHECK_MS))
|
|
471
|
+
gone = await missingFromQueue(sessionId, gone.filter((r) => isActive(r) && !r.sawPrompt))
|
|
472
|
+
for (const run of gone) settle(run, 'cancelled', { error: 'Removed from the Harness queue before it ran.', removedFromQueue: true })
|
|
473
|
+
return gone.map((r) => r.id)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Of these runs, the ones whose message is neither pending in the inbox nor in the
|
|
477
|
+
// session log. Nothing is missing when either cannot be read.
|
|
478
|
+
async function missingFromQueue(sessionId, waiting) {
|
|
479
|
+
if (!waiting.length) return []
|
|
480
|
+
const pending = await pendingMessages(sessionId)
|
|
481
|
+
if (pending === null) return []
|
|
482
|
+
const ids = new Set(pending.map((m) => m?.source?.rpcId))
|
|
483
|
+
const gone = waiting.filter((r) => !ids.has(r.requestId))
|
|
484
|
+
if (!gone.length) return []
|
|
485
|
+
const log = await controller.inspect(sessionId, signal()).catch(() => null)
|
|
486
|
+
if (!Array.isArray(log?.events)) return []
|
|
487
|
+
const delivered = new Set(log.events.filter((e) => e.type === 'user/message').map((e) => e.data?.source?.rpcId))
|
|
488
|
+
return gone.filter((r) => !delivered.has(r.requestId) && !r.sawPrompt)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function scheduleQueueCheck(sessionId) {
|
|
492
|
+
clearTimeout(queueTimers.get(sessionId))
|
|
493
|
+
queueTimers.set(sessionId, setTimeout(guard('queue check', () => { queueTimers.delete(sessionId); return reconcileQueue(sessionId) }), 1500))
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
ctx.effect(() => () => { for (const t of queueTimers.values()) clearTimeout(t) }, 'dsh-baton-host queue timers')
|
|
497
|
+
|
|
498
|
+
// Change a relayed message that is still waiting in Harness's queue: edit its text,
|
|
499
|
+
// remove it, or move it into the running turn as a steer.
|
|
500
|
+
async function updateQueued({ run_id, action, message }) {
|
|
501
|
+
if (!['edit', 'remove', 'steer'].includes(action)) throw new Error('action must be edit, remove or steer')
|
|
502
|
+
if (action === 'edit' && !message?.trim()) throw new Error('edit needs a non-empty message')
|
|
503
|
+
const run = getRun(run_id)
|
|
504
|
+
if (!isActive(run)) throw new Error(`Run ${run.id} is ${run.state}; only a queued run can be changed.`)
|
|
505
|
+
if (run.sawPrompt) throw new Error(`Run ${run.id} has already reached the conversation and can no longer be changed. Send a correction with dsh_send mode steer, or stop the session with dsh_cancel.`)
|
|
506
|
+
if (typeof controller.updateQueue !== 'function') throw new Error(QUEUE_UNSUPPORTED)
|
|
507
|
+
const pending = await pendingMessages(run.sessionId)
|
|
508
|
+
if (pending === null) throw new Error(QUEUE_UNSUPPORTED)
|
|
509
|
+
const item = pending.find((m) => m?.source?.rpcId === run.requestId)
|
|
510
|
+
const gone = `Run ${run.id} is no longer in the Harness queue: it has just started, or it was removed.`
|
|
511
|
+
if (!item) throw new Error(gone)
|
|
512
|
+
const change = action === 'edit' ? { kind: 'edit', content: [{ type: 'text', text: message }] } : { kind: action }
|
|
513
|
+
try {
|
|
514
|
+
await controller.updateQueue({ sessionId: run.sessionId, itemId: item.id, action: change })
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (error?.code === 'session/queue-item-not-found') throw new Error(gone)
|
|
517
|
+
if (error?.code === 'session/steer-unavailable') throw new Error(`The current turn of ${run.sessionId} no longer takes steering; the message stays queued.`)
|
|
518
|
+
throw error
|
|
519
|
+
}
|
|
520
|
+
if (action === 'edit') {
|
|
521
|
+
run.preview = message.slice(0, 200)
|
|
522
|
+
pushEvent(run, { type: 'queue_edit', text: message.slice(0, 500) })
|
|
523
|
+
} else if (action === 'steer') {
|
|
524
|
+
run.mode = 'steer'
|
|
525
|
+
pushEvent(run, { type: 'queue_steer' })
|
|
526
|
+
} else {
|
|
527
|
+
pushEvent(run, { type: 'queue_remove' })
|
|
528
|
+
settle(run, 'cancelled', { error: null })
|
|
529
|
+
}
|
|
530
|
+
return { ...publicRun(run, 0), action }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// --- questions (ask_user_question) ----------------------------------------
|
|
534
|
+
// Harness asks the user through the user-questions/request waterfall, whose answerer
|
|
535
|
+
// is the Harness UI. Listening first, the plugin puts the question on the runs of that
|
|
536
|
+
// turn (so wait() returns with it) and lets either side answer: the Harness UI, or
|
|
537
|
+
// dsh_answer through answerQuestion(). The first answer wins.
|
|
538
|
+
|
|
539
|
+
const questions = new Map() // sessionId -> open question
|
|
540
|
+
const RECOMMENDED = /\(recommended\)|(推荐)|\(推荐\)|推荐/i
|
|
541
|
+
|
|
542
|
+
function publicQuestion(entry) {
|
|
543
|
+
return {
|
|
544
|
+
question_id: entry.id,
|
|
545
|
+
count: entry.items.length,
|
|
546
|
+
questions: entry.items.map((q) => ({
|
|
547
|
+
id: q.id,
|
|
548
|
+
...(q.header ? { header: q.header } : {}),
|
|
549
|
+
question: q.question,
|
|
550
|
+
...(q.detail ? { detail: q.detail } : {}),
|
|
551
|
+
multi_select: !!q.multiSelect,
|
|
552
|
+
options: (q.options ?? []).map((o, i) => ({ number: i + 1, label: o.label, ...(o.description ? { description: o.description } : {}), ...(RECOMMENDED.test(o.label) ? { recommended: true } : {}) })),
|
|
553
|
+
free_text: true,
|
|
554
|
+
...(entry.answers.has(q.id) ? { answered: true } : {}),
|
|
555
|
+
})),
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function onQuestion(request, next) {
|
|
560
|
+
const sessionId = request?.agent?.id
|
|
561
|
+
const asked = sessionId && Array.isArray(request.questions) ? outputReceiversFor(sessionId) : []
|
|
562
|
+
if (!asked.length) return next()
|
|
563
|
+
const entry = { id: `q-${crypto.randomBytes(4).toString('hex')}`, sessionId, items: request.questions, answers: new Map(), runs: asked }
|
|
564
|
+
const ours = new Promise((resolve) => { entry.resolve = resolve })
|
|
565
|
+
questions.set(sessionId, entry)
|
|
566
|
+
for (const run of asked) {
|
|
567
|
+
run.question = publicQuestion(entry)
|
|
568
|
+
pushEvent(run, { type: 'question', question_id: entry.id, count: entry.items.length })
|
|
569
|
+
needsPerson(run)
|
|
570
|
+
}
|
|
571
|
+
// The Harness UI (next) gets its own signal: once dsh_answer wins, aborting it tells
|
|
572
|
+
// the window to drop its question card, which it otherwise keeps open.
|
|
573
|
+
const ui = new AbortController()
|
|
574
|
+
const harnessSignal = request.signal
|
|
575
|
+
request.signal = harnessSignal ? AbortSignal.any([harnessSignal, ui.signal]) : ui.signal
|
|
576
|
+
// With no Harness window to answer, only dsh_answer can: that is not a failure here.
|
|
577
|
+
const theirs = Promise.resolve().then(next).catch((error) => (error?.code === 'NO_PROVIDER' || ui.signal.aborted ? new Promise(() => {}) : Promise.reject(error)))
|
|
578
|
+
const aborted = new Promise((_, reject) => harnessSignal?.addEventListener('abort', () => reject(harnessSignal.reason), { once: true }))
|
|
579
|
+
const answered = (by) => (value) => {
|
|
580
|
+
for (const run of asked) pushEvent(run, { type: 'question_answered', question_id: entry.id, by })
|
|
581
|
+
return value
|
|
582
|
+
}
|
|
583
|
+
const fromBaton = ours.then((value) => { ui.abort(new Error('answered through dsh-baton')); return value })
|
|
584
|
+
return Promise.race([theirs.then(answered('harness')), fromBaton.then(answered('dsh-baton')), aborted]).finally(() => {
|
|
585
|
+
if (questions.get(sessionId) === entry) questions.delete(sessionId)
|
|
586
|
+
for (const run of asked) {
|
|
587
|
+
if (run.question?.question_id !== entry.id) continue
|
|
588
|
+
delete run.question
|
|
589
|
+
if (run.state === 'waiting') run.state = 'running'
|
|
590
|
+
persist(run)
|
|
591
|
+
}
|
|
592
|
+
})
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Answer the open question of a run's turn. answers: [{ id?, choose?: [number | label],
|
|
596
|
+
// text?, skip? }]; questions not answered yet stay open until every one has an answer.
|
|
597
|
+
function answerQuestion({ run_id, answers }) {
|
|
598
|
+
const run = getRun(run_id)
|
|
599
|
+
const entry = questions.get(run.sessionId)
|
|
600
|
+
if (!isActive(run) || !entry || run.question?.question_id !== entry.id) {
|
|
601
|
+
if (run.state === 'waiting') throw new Error(`Run ${run.id} is waiting for something in the Harness UI (an approval, for example) that cannot be answered from here; tell the user.`)
|
|
602
|
+
throw new Error(`Run ${run.id} is ${run.state} and has no open question to answer.`)
|
|
603
|
+
}
|
|
604
|
+
if (!Array.isArray(answers) || !answers.length) throw new Error('answers must list at least one answer')
|
|
605
|
+
const staged = new Map()
|
|
606
|
+
for (const a of answers) {
|
|
607
|
+
const q = a?.id != null ? entry.items.find((item) => item.id === a.id) : entry.items.length === 1 ? entry.items[0] : undefined
|
|
608
|
+
if (!q) throw new Error(a?.id != null ? `No question ${a.id}; the questions are ${entry.items.map((i) => i.id).join(', ')}` : 'id is required when there are several questions')
|
|
609
|
+
const options = q.options ?? []
|
|
610
|
+
const chosen = (a.choose ?? []).map((c) => {
|
|
611
|
+
const byNumber = typeof c === 'number' || /^\d+$/.test(String(c)) ? options[Number(c) - 1] : undefined
|
|
612
|
+
const option = byNumber ?? options.find((o) => o.label === c) ?? options.find((o) => o.label.toLowerCase().replace(RECOMMENDED, '').trim() === String(c).toLowerCase().replace(RECOMMENDED, '').trim())
|
|
613
|
+
if (!option) throw new Error(`Question ${q.id} has no option ${JSON.stringify(c)}; options: ${options.map((o, i) => `${i + 1}. ${o.label}`).join('; ')}`)
|
|
614
|
+
return option.label
|
|
615
|
+
})
|
|
616
|
+
const text = typeof a.text === 'string' && a.text.trim() ? a.text : undefined
|
|
617
|
+
if (a.skip && (chosen.length || text)) throw new Error(`Question ${q.id}: skip cannot come with choose or text`)
|
|
618
|
+
if (!a.skip && !chosen.length && !text) throw new Error(`Question ${q.id}: give choose, text or skip`)
|
|
619
|
+
if (!q.multiSelect && chosen.length > 1) throw new Error(`Question ${q.id} takes one option`)
|
|
620
|
+
// Single choice: typed text replaces the option (Harness's own rule).
|
|
621
|
+
staged.set(q.id, a.skip ? { id: q.id, selected: [] } : !q.multiSelect && text ? { id: q.id, selected: [], custom: text } : { id: q.id, selected: chosen, ...(text ? { custom: text } : {}) })
|
|
622
|
+
}
|
|
623
|
+
for (const [id, item] of staged) entry.answers.set(id, item)
|
|
624
|
+
const remaining = entry.items.filter((q) => !entry.answers.has(q.id)).map((q) => q.id)
|
|
625
|
+
if (remaining.length) {
|
|
626
|
+
for (const r of entry.runs) { if (r.question?.question_id === entry.id) { r.question = publicQuestion(entry); persist(r) } }
|
|
627
|
+
return { ...publicRun(run, 0), submitted: false, remaining }
|
|
628
|
+
}
|
|
629
|
+
entry.resolve({ answers: entry.items.map((q) => entry.answers.get(q.id)) })
|
|
630
|
+
return { ...publicRun(run, 0), submitted: true, answers: entry.items.map((q) => entry.answers.get(q.id)) }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
ctx.on('user-questions/request', guardWaterfall(onQuestion), { global: true, prepend: true })
|
|
634
|
+
|
|
635
|
+
// Harness's own "busy send" setting (Settings → sending while busy: queue or steer),
|
|
636
|
+
// as a dsh-baton mode, or null when this Harness has no such setting to read.
|
|
637
|
+
function harnessBusyMode() {
|
|
638
|
+
try {
|
|
639
|
+
const form = settings?.describe?.()?.find?.((d) => d.ns === 'ui-conversation')
|
|
640
|
+
const value = form?.value?.busyEnter
|
|
641
|
+
return value === 'steer' ? 'steer' : value === 'queue' ? 'followup' : null
|
|
642
|
+
} catch { return null }
|
|
643
|
+
}
|
|
644
|
+
|
|
402
645
|
// --- operations -----------------------------------------------------------
|
|
403
646
|
|
|
404
647
|
const signal = () => new AbortController().signal
|
|
@@ -461,8 +704,13 @@ export function apply(ctx) {
|
|
|
461
704
|
return { session_id, ...(isArchived(session_id) ? { archived: true } : {}), cwd: meta?.cwd ?? null, title, total_messages: messages.length, messages: messages.slice(-last) }
|
|
462
705
|
}
|
|
463
706
|
|
|
464
|
-
async function send({ message, session_id, cwd, workspace_id, mode
|
|
707
|
+
async function send({ message, session_id, cwd, workspace_id, mode: asked, sender }) {
|
|
465
708
|
if (!message || !message.trim()) throw new Error('message is required')
|
|
709
|
+
if (asked != null && asked !== 'followup' && asked !== 'steer') throw new Error('mode must be followup or steer')
|
|
710
|
+
// Without a mode, a message to a busy session does what Harness's own busy-send setting says.
|
|
711
|
+
const fromHarness = asked == null ? harnessBusyMode() : null
|
|
712
|
+
const mode = asked ?? fromHarness ?? 'followup'
|
|
713
|
+
const modeSource = asked != null ? 'explicit' : fromHarness ? 'harness' : 'default'
|
|
466
714
|
if (sender != null && !SENDER_RE.test(sender)) throw new Error('sender must be 1-32 characters of a-z, 0-9 or -')
|
|
467
715
|
let sessionId = session_id
|
|
468
716
|
let workspace = null
|
|
@@ -486,12 +734,14 @@ export function apply(ctx) {
|
|
|
486
734
|
remember(run)
|
|
487
735
|
try {
|
|
488
736
|
await controller.prompt({ sessionId, requestId: run.requestId, content: [{ type: 'text', text: message }], mode }, signal())
|
|
737
|
+
run.admitted = true // in Harness's queue (or conversation) now
|
|
489
738
|
} catch (error) {
|
|
490
739
|
settle(run, 'failed', { error: String(error?.message ?? error) })
|
|
491
740
|
}
|
|
492
741
|
return {
|
|
493
742
|
...publicRun(run),
|
|
494
743
|
mode,
|
|
744
|
+
mode_source: modeSource,
|
|
495
745
|
...(ahead.length ? { other_active_runs: ahead } : {}),
|
|
496
746
|
...(workspace ? { workspace } : {}),
|
|
497
747
|
}
|
|
@@ -516,7 +766,10 @@ export function apply(ctx) {
|
|
|
516
766
|
async function wait({ run_id, timeout_s = 300 }) {
|
|
517
767
|
const run = getRun(run_id)
|
|
518
768
|
const seconds = Number.isFinite(Number(timeout_s)) ? Math.min(Math.max(Number(timeout_s), 0), MAX_WAIT_S) : 300
|
|
519
|
-
|
|
769
|
+
const unseenWait = () => run.state === 'waiting' && !run.waitNoticed
|
|
770
|
+
const deadline = Date.now() + seconds * 1000
|
|
771
|
+
// A wake can also come for another reason (the delayed one for a waiting status): wait on.
|
|
772
|
+
while (isActive(run) && !unseenWait() && !stopping && Date.now() < deadline) {
|
|
520
773
|
await new Promise((resolve) => {
|
|
521
774
|
const set = waiters.get(run.id) ?? new Set()
|
|
522
775
|
waiters.set(run.id, set)
|
|
@@ -525,11 +778,15 @@ export function apply(ctx) {
|
|
|
525
778
|
set.delete(done)
|
|
526
779
|
resolve()
|
|
527
780
|
}
|
|
528
|
-
const timer = setTimeout(done,
|
|
781
|
+
const timer = setTimeout(done, deadline - Date.now())
|
|
529
782
|
set.add(done)
|
|
530
783
|
})
|
|
531
784
|
}
|
|
532
785
|
const done = !isActive(run)
|
|
786
|
+
if (!done && unseenWait()) {
|
|
787
|
+
run.waitNoticed = true
|
|
788
|
+
return { ...publicRun(run), needs_input: run.question ? 'answer' : 'harness-ui' }
|
|
789
|
+
}
|
|
533
790
|
return { ...publicRun(run), ...(done ? {} : { timed_out: true }) }
|
|
534
791
|
}
|
|
535
792
|
|
|
@@ -568,6 +825,8 @@ export function apply(ctx) {
|
|
|
568
825
|
} while (still.busy && Date.now() < deadline)
|
|
569
826
|
if (!still.busy) break
|
|
570
827
|
}
|
|
828
|
+
// Stopping a session also empties Harness's queue: the runs waiting there are over.
|
|
829
|
+
const removed = await reconcileQueue(sessionId).catch((error) => { logError('queue check', error); return [] })
|
|
571
830
|
// The named run never reached the conversation (still queued, or sent to a session
|
|
572
831
|
// that will not run it): settle it too, so nothing waits on it forever.
|
|
573
832
|
if (run && isActive(run) && !run.sawPrompt) settle(run, 'cancelled')
|
|
@@ -578,6 +837,7 @@ export function apply(ctx) {
|
|
|
578
837
|
still_busy: still.busy,
|
|
579
838
|
activity: still.activity,
|
|
580
839
|
queued_runs: activeRunsFor(sessionId).filter((r) => !r.sawPrompt).map((r) => r.id),
|
|
840
|
+
...(removed.length ? { removed_from_queue: removed } : {}),
|
|
581
841
|
}
|
|
582
842
|
}
|
|
583
843
|
|
|
@@ -608,7 +868,9 @@ export function apply(ctx) {
|
|
|
608
868
|
activity,
|
|
609
869
|
rename_session: renameSession,
|
|
610
870
|
rename_workspace: renameWorkspace,
|
|
611
|
-
list_runs: ({ limit = 10 } = {}) => [...runs.values()].reverse().slice(0, limit).map((r) => publicRun(r, 0)),
|
|
871
|
+
list_runs: ({ limit = 10, session_id } = {}) => [...runs.values()].reverse().filter((r) => !session_id || r.sessionId === session_id).slice(0, limit).map((r) => publicRun(r, 0)),
|
|
872
|
+
update_queued: updateQueued,
|
|
873
|
+
answer: answerQuestion,
|
|
612
874
|
}
|
|
613
875
|
|
|
614
876
|
// --- local socket (owner-only) ---------------------------------------------
|
|
@@ -675,6 +937,10 @@ export function apply(ctx) {
|
|
|
675
937
|
registration = { state: 'off' }
|
|
676
938
|
}
|
|
677
939
|
if (typeof ctx.inject === 'function') {
|
|
940
|
+
ctx.inject(['settings'], (child) => {
|
|
941
|
+
settings = child.settings
|
|
942
|
+
child.effect(() => () => { if (settings === child.settings) settings = null }, 'dsh-baton-host settings')
|
|
943
|
+
})
|
|
678
944
|
ctx.inject(['webServer'], (web) => {
|
|
679
945
|
web.effect(() => web.webServer.register({
|
|
680
946
|
kind: 'exact',
|
|
@@ -706,6 +972,7 @@ export function apply(ctx) {
|
|
|
706
972
|
disposed = true
|
|
707
973
|
if (store.enabled) { try { flushRuns() } catch (error) { logError('persist runs', error) } }
|
|
708
974
|
store.enabled = false
|
|
975
|
+
stopping = true
|
|
709
976
|
// Release callers blocked in wait() and close open connections, so an
|
|
710
977
|
// unloaded or reloaded plugin leaves no timers or sockets behind.
|
|
711
978
|
for (const set of waiters.values()) for (const done of [...set]) done()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-baton-host",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
4
4
|
"description": "DeepSeek Harness plugin for dsh-baton: adding it is the whole install. Switched on, it registers the dsh-baton MCP server for Claude Code and Codex, then takes the baton over a local, owner-only control socket, runs it in a specific session through the host's own session controller, and hands the result back when the turn is done.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"LICENSE"
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"dsh-baton-mcp": "0.5.
|
|
23
|
+
"dsh-baton-mcp": "0.5.9"
|
|
24
24
|
},
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"dsh": {
|