dsh-baton-host 0.5.7 → 0.5.8

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.
Files changed (3) hide show
  1. package/README.md +31 -3
  2. package/lib/index.js +272 -5
  3. 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, see [WINDOWS.md](https://github.com/JialaoLiu/dsh-baton/blob/main/WINDOWS.md)). 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.
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
- else if (status === 'waiting') run.state = 'waiting' // needs a person in the Harness UI (approval / question)
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 = 'followup', sender }) {
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
- if (isActive(run)) {
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, seconds * 1000)
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.7",
3
+ "version": "0.5.8",
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.7"
23
+ "dsh-baton-mcp": "0.5.8"
24
24
  },
25
25
  "license": "MIT",
26
26
  "dsh": {