dsh-baton-host 0.6.4 → 0.6.5-next.1

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 +5 -2
  2. package/lib/index.js +252 -13
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -32,14 +32,16 @@ That is the whole install: switched on, the plugin registers the dsh-baton MCP s
32
32
 
33
33
  装好就完成了:插件启用时会为 Claude Code 和 Codex 注册 dsh-baton MCP 服务器(`dsh-baton-mcp` 包,随插件一起安装),Harness 里会提示。新开的对话就有 `dsh_*` 工具。没能注册的话,运行 `npx -y dsh-baton-mcp setup`。详见 [dsh-baton](https://github.com/JialaoLiu/dsh-baton/blob/main/README.zh-CN.md#安装细节)。
34
34
 
35
- Update / 更新: uninstall, then add it again; the MCP server is updated with it / 先卸载再重新添加,MCP 服务器会一起更新.
35
+ Codex needs nothing extra: from 0.6.4 it waits for a run in one `dsh_wait` call of up to 59 minutes, with no hook to trust. / Codex 不用额外设置:从 0.6.4 起一次 `dsh_wait` 最长等 59 分钟,不需要信任钩子。
36
+
37
+ Update / 更新: uninstall, then add it again; the MCP server is updated with it. Right after a release, type the exact version, such as `dsh-baton-host@0.6.4`. / 先卸载再重新添加,MCP 服务器会一起更新;刚发版时请输入完整版本号,例如 `dsh-baton-host@0.6.4`。
36
38
 
37
39
  Remove / 卸载: uninstall, then `npx -y dsh-baton-mcp setup --remove` / 卸载插件后运行 `npx -y dsh-baton-mcp setup --remove`.
38
40
 
39
41
  ## How it works / 工作原理
40
42
 
41
43
  <p align="center">
42
- <img src="https://raw.githubusercontent.com/JialaoLiu/dsh-baton/main/assets/readme/en/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">
44
+ <img src="https://raw.githubusercontent.com/JialaoLiu/dsh-baton/main/assets/readme/en/architecture.gif" 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
45
  </p>
44
46
 
45
47
  ## Socket protocol / 套接字协议
@@ -53,6 +55,7 @@ Newline-delimited JSON: request `{"id", "method", "params"}`, reply `{"id", "res
53
55
  | `create_workspace` | `path`, `title?`, `create_dir?` (default `true`) |
54
56
  | `list_sessions` | `cwd?`, `query?`, `limit?` |
55
57
  | `read_session` | `session_id`, `last?` |
58
+ | `models` | — |
56
59
  | `send` | `message`, and `session_id` or `cwd` or `workspace_id`; `mode?` (`followup` \| `steer`; left out, Harness's busy-send setting, else `followup`) |
57
60
  | `status` | `run_id`, `tail?` |
58
61
  | `wait` | `run_id`, `timeout_s?` |
package/lib/index.js CHANGED
@@ -49,6 +49,8 @@ const probe = (sock) => new Promise((resolve) => {
49
49
 
50
50
  // Expand `~`, resolve, and follow symlinks when the path exists, so the same
51
51
  // folder always compares equal (on macOS /tmp is a link to /private/tmp).
52
+ const isDirectory = (p) => { try { return fs.statSync(p).isDirectory() } catch { return false } }
53
+
52
54
  function normalizePath(p) {
53
55
  const abs = path.resolve(p.replace(/^~(?=$|[\\/])/, os.homedir()))
54
56
  try { return fs.realpathSync.native(abs) } catch { return abs }
@@ -149,6 +151,14 @@ export function apply(ctx) {
149
151
  const runs = new Map() // runId -> run
150
152
  let stopping = false // set when the plugin unloads: release every wait()
151
153
  const waiters = new Map() // runId -> Set<resolve>
154
+ // Followers of a session's Harness goal (see follow()): woken by what may end their wait.
155
+ const followers = new Map() // sessionId -> Set<wake>
156
+ const FOLLOW_EVENTS = new Set(['turn/start', 'turn/end', 'goal/change'])
157
+ // A moment's delay, so the event that woke a follower is in the log it reads.
158
+ const wakeFollowers = (sessionId) => { if (followers.has(sessionId)) setTimeout(guard('wake followers', () => { for (const wake of [...(followers.get(sessionId) ?? [])]) wake() }), 50) }
159
+ // Sessions whose agent waits for a person (agent/status waiting), and how often each began to.
160
+ const waitingSessions = new Set()
161
+ const uiEpisodes = new Map() // sessionId -> count
152
162
 
153
163
  // --- run bookkeeping ------------------------------------------------------
154
164
 
@@ -286,6 +296,7 @@ export function apply(ctx) {
286
296
 
287
297
  ctx.on('session/event', guard('session/event', (session, event) => {
288
298
  const data = event.data ?? {}
299
+ if (FOLLOW_EVENTS.has(event.type)) wakeFollowers(session.id)
289
300
  // Turn state is kept only for a turn in progress and dropped at its end.
290
301
  if (event.type === 'turn/start') turns.set(session.id, { hasOutput: false })
291
302
  else if (event.type === 'assistant/message' && turns.has(session.id)) turns.get(session.id).hasOutput = true
@@ -321,7 +332,9 @@ export function apply(ctx) {
321
332
  pushEvent(r, { type: 'human_input', text })
322
333
  }
323
334
  }
324
- if (data.source?.kind === 'user' && opensTurn && run?.mode !== 'steer') {
335
+ // A round a Harness goal starts by itself counts like a user-started turn: the run whose
336
+ // turn just ended keeps that turn's reply instead of running on with the goal.
337
+ if ((data.source?.kind === 'user' || data.source?.kind === 'goal') && opensTurn && run?.mode !== 'steer') {
325
338
  // A new user-started turn: runs whose own turn already ended keep their
326
339
  // result instead of absorbing this turn's output, but still wait for any
327
340
  // sub-agents or jobs they started. Turns woken by sub-agent results carry
@@ -330,7 +343,7 @@ export function apply(ctx) {
330
343
  let closed = false
331
344
  for (const prev of active) {
332
345
  if (prev !== run && prev.sawPrompt && prev.turnEnd && !prev.outputClosed) {
333
- Object.assign(prev, { outputClosed: true, supersededBy: run?.id ?? 'user' })
346
+ Object.assign(prev, { outputClosed: true, supersededBy: run?.id ?? (data.source.kind === 'goal' ? 'goal' : 'user') })
334
347
  closed = true
335
348
  }
336
349
  }
@@ -438,6 +451,9 @@ export function apply(ctx) {
438
451
 
439
452
  ctx.on('agent/status', guard('agent/status', ({ agent, status }) => {
440
453
  if (!agent) return
454
+ if (status === 'waiting') { if (!waitingSessions.has(agent.id)) uiEpisodes.set(agent.id, (uiEpisodes.get(agent.id) ?? 0) + 1); waitingSessions.add(agent.id) }
455
+ else waitingSessions.delete(agent.id)
456
+ wakeFollowers(agent.id)
441
457
  const receivers = receiversFor(agent.id)
442
458
  if (status === 'running' && receivers.length) lastRunningAt.set(agent.id, Date.now())
443
459
  for (const run of outputReceiversFor(agent.id)) {
@@ -574,10 +590,12 @@ export function apply(ctx) {
574
590
  function onQuestion(request, next) {
575
591
  const sessionId = request?.agent?.id
576
592
  const asked = sessionId && Array.isArray(request.questions) ? outputReceiversFor(sessionId) : []
577
- if (!asked.length) return next()
593
+ // Taken up too when someone follows the session's goal: the follow wait brings it back.
594
+ if (!asked.length && !(followers.has(sessionId) && Array.isArray(request.questions))) return next()
578
595
  const entry = { id: `q-${crypto.randomBytes(4).toString('hex')}`, sessionId, items: request.questions, answers: new Map(), runs: asked }
579
596
  const ours = new Promise((resolve) => { entry.resolve = resolve })
580
597
  questions.set(sessionId, entry)
598
+ wakeFollowers(sessionId)
581
599
  for (const run of asked) {
582
600
  run.question = publicQuestion(entry)
583
601
  pushEvent(run, { type: 'question', question_id: entry.id, count: entry.items.length })
@@ -598,6 +616,7 @@ export function apply(ctx) {
598
616
  const fromBaton = ours.then((value) => { ui.abort(new Error('answered through dsh-baton')); return value })
599
617
  return Promise.race([theirs.then(answered('harness')), fromBaton.then(answered('dsh-baton')), aborted]).finally(() => {
600
618
  if (questions.get(sessionId) === entry) questions.delete(sessionId)
619
+ wakeFollowers(sessionId)
601
620
  for (const run of asked) {
602
621
  if (run.question?.question_id !== entry.id) continue
603
622
  delete run.question
@@ -607,15 +626,22 @@ export function apply(ctx) {
607
626
  })
608
627
  }
609
628
 
610
- // Answer the open question of a run's turn. answers: [{ id?, choose?: [number | label],
611
- // text?, skip? }]; questions not answered yet stay open until every one has an answer.
612
- function answerQuestion({ run_id, answers }) {
613
- const run = getRun(run_id)
614
- const entry = questions.get(run.sessionId)
615
- if (!isActive(run) || !entry || run.question?.question_id !== entry.id) {
629
+ // Answer the open question of a run's turn, or of a session a follow wait reported it for.
630
+ // answers: [{ id?, choose?: [number | label], text?, skip? }]; questions not answered yet
631
+ // stay open until every one has an answer.
632
+ function answerQuestion({ run_id, session_id, answers }) {
633
+ if (!run_id && !session_id) throw new Error('run_id or session_id is required')
634
+ const run = run_id ? getRun(run_id) : null
635
+ const entry = questions.get(run?.sessionId ?? session_id)
636
+ if (run && (!isActive(run) || !entry || run.question?.question_id !== entry.id)) {
616
637
  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.`)
617
638
  throw new Error(`Run ${run.id} is ${run.state} and has no open question to answer.`)
618
639
  }
640
+ if (!entry) {
641
+ if (waitingSessions.has(session_id)) throw new Error(`Session ${session_id} is waiting for something in the Harness UI (an approval, for example) that cannot be answered from here; tell the user.`)
642
+ throw new Error(`Session ${session_id} has no open question to answer.`)
643
+ }
644
+ const shown = run ? publicRun(run, 0) : { session_id }
619
645
  if (!Array.isArray(answers) || !answers.length) throw new Error('answers must list at least one answer')
620
646
  const staged = new Map()
621
647
  for (const a of answers) {
@@ -639,10 +665,10 @@ export function apply(ctx) {
639
665
  const remaining = entry.items.filter((q) => !entry.answers.has(q.id)).map((q) => q.id)
640
666
  if (remaining.length) {
641
667
  for (const r of entry.runs) { if (r.question?.question_id === entry.id) { r.question = publicQuestion(entry); persist(r) } }
642
- return { ...publicRun(run, 0), submitted: false, remaining }
668
+ return { ...shown, submitted: false, remaining }
643
669
  }
644
670
  entry.resolve({ answers: entry.items.map((q) => entry.answers.get(q.id)) })
645
- return { ...publicRun(run, 0), submitted: true, answers: entry.items.map((q) => entry.answers.get(q.id)) }
671
+ return { ...shown, submitted: true, answers: entry.items.map((q) => entry.answers.get(q.id)) }
646
672
  }
647
673
 
648
674
  ctx.on('user-questions/request', guardWaterfall(onQuestion), { global: true, prepend: true })
@@ -720,6 +746,190 @@ export function apply(ctx) {
720
746
  return { session_id, ...(isArchived(session_id) ? { archived: true } : {}), cwd: meta?.cwd ?? null, title, total_messages: messages.length, messages: messages.slice(-last) }
721
747
  }
722
748
 
749
+ // --- Harness goals --------------------------------------------------------------
750
+ // A session with a goal starts its next round by itself a few ms after a turn ends, until
751
+ // the goal is complete, paused or blocked, or its rounds run out. The goal's durable state is
752
+ // a session projection (values.goal). Whether this process will really go on with it
753
+ // (activation: armed, or disarmed, e.g. after a Harness restart until a person resumes it)
754
+ // is not in it: only Harness's goals service and its goal/activation-changed event say.
755
+
756
+ let goalsService = null
757
+ let agentsService = null
758
+ const activations = new Map() // sessionId -> 'armed' | 'disarmed', from goal/activation-changed
759
+ ctx.on('goal/activation-changed', guard('goal activation', ({ sessionId, goal } = {}) => {
760
+ if (!sessionId) return
761
+ if (goal?.activation) activations.set(sessionId, goal.activation)
762
+ else activations.delete(sessionId)
763
+ wakeFollowers(sessionId)
764
+ }))
765
+
766
+ // armed, disarmed, or null when this Harness does not say. A session not loaded (no live
767
+ // agent, as after a Harness restart until it is opened) runs nothing: disarmed.
768
+ function activationOf(sessionId, goalId) {
769
+ try {
770
+ if (agentsService && typeof goalsService?.get === 'function') {
771
+ const agent = agentsService.get(sessionId)
772
+ if (!agent) return 'disarmed'
773
+ const view = goalsService.get(agent)
774
+ if (view?.id === goalId && view.activation) return view.activation
775
+ }
776
+ } catch { /* not live here: the event is all there is */ }
777
+ return activations.get(sessionId) ?? null
778
+ }
779
+
780
+ const clipText = (s, n) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}…` : s ?? null)
781
+
782
+ // The session's current goal, or null (none, or this Harness has no goals).
783
+ async function goalOf(sessionId) {
784
+ let current
785
+ try {
786
+ const p = typeof controller.projections === 'function' ? await controller.projections({ sessionId }, signal()) : null
787
+ // The projection's wire view is the current goal itself (null when none); the stored
788
+ // state, as the projection cache keeps it, wraps it in { current, seenGoalIds, failure }.
789
+ const v = p?.values?.goal
790
+ current = v && typeof v === 'object' && 'current' in v ? v.current : v
791
+ } catch { return null }
792
+ const g = current?.goal
793
+ if (!g?.id) return null
794
+ return {
795
+ id: g.id, phase: g.phase, objective: clipText(g.objective, 300),
796
+ rounds_started: current.roundsStarted ?? 0, max_rounds: g.maxGoalRounds ?? null,
797
+ activation: activationOf(sessionId, g.id),
798
+ ...(g.blockedReason ? { blocked_reason: { code: g.blockedReason.code, message: g.blockedReason.message } } : {}),
799
+ }
800
+ }
801
+
802
+ // A goal that will go on after this point (not complete): worth telling whoever waited on a turn.
803
+ const openGoal = async (sessionId) => {
804
+ const goal = await goalOf(sessionId)
805
+ return goal && goal.phase !== 'complete' ? goal : null
806
+ }
807
+
808
+ // A session log's turns, oldest first: when each ended, its goal round (when a goal started
809
+ // it), its closing text and the tools it called.
810
+ function turnsOf(events) {
811
+ const ended = []
812
+ let cur = null
813
+ for (const e of events ?? []) {
814
+ if (e.type === 'turn/start') cur = { turn: e.data?.turn ?? ended.length + 1, round: null, text: null, tools: {}, started_at: Date.parse(iso(e.time)) || null }
815
+ else if (!cur) continue
816
+ else if (e.type === 'user/message' && e.data?.source?.kind === 'goal') cur.round = e.data.source.round ?? null
817
+ else if (e.type === 'assistant/message') {
818
+ const content = e.data?.message?.content
819
+ const text = textOf(content)
820
+ if (text) cur.text = text
821
+ for (const call of toolCallsOf(content)) { const n = call.name ?? call.kind; cur.tools[n] = (cur.tools[n] ?? 0) + 1 }
822
+ } else if (e.type === 'turn/end') {
823
+ const { started_at, ...turn } = cur
824
+ ended.push({ ...turn, ended_at: iso(e.time) ?? new Date().toISOString(), end: e.data?.reason?.kind ?? null })
825
+ cur = null
826
+ }
827
+ }
828
+ return { ended, running: cur !== null, runningSince: cur?.started_at ?? null }
829
+ }
830
+
831
+ const MAX_ROUNDS_SHOWN = 10
832
+ const shownRound = (t) => ({
833
+ turn: t.turn, ...(t.round != null ? { goal_round: t.round } : {}), ended_at: t.ended_at,
834
+ ...(t.end && t.end !== 'completed' ? { ended_as: t.end } : {}),
835
+ final_text: clipText(t.text, 1500), ...(Object.keys(t.tools).length ? { tools: t.tools } : {}),
836
+ })
837
+ const shownRounds = (list) => ({
838
+ rounds: list.slice(-MAX_ROUNDS_SHOWN).map(shownRound),
839
+ ...(list.length > MAX_ROUNDS_SHOWN ? { earlier_rounds_not_shown: list.length - MAX_ROUNDS_SHOWN } : {}),
840
+ })
841
+
842
+ // Where a follow wait has got to: rounds already reported, and when it last reported.
843
+ const cursorOf = (mark) => `${mark.rounds}:${mark.at}`
844
+ function parseCursor(cursor) {
845
+ const m = /^(\d+):(\d+)$/.exec(String(cursor ?? ''))
846
+ return m ? { rounds: Number(m[1]), at: Number(m[2]) } : null
847
+ }
848
+ const GOAL_QUIET_MS = Number(process.env.DSH_BATON_GOAL_QUIET_MS) || 5 * 60_000
849
+ // The question or Harness UI wait a follower was already told about (one report per episode).
850
+ const noticed = new Map() // sessionId -> key
851
+
852
+ // Wait on a session's goal without sending anything. until "goal": until the goal stops
853
+ // (complete, paused, blocked, cleared). until "turn": at the first round end once every_s
854
+ // have passed since the last report, with every round since then. Both return at once when
855
+ // Harness needs a person, or the goal will not go on by itself (disarmed; when that cannot
856
+ // be read, no new round for GOAL_QUIET_MS). The cursor carries what was reported, so a
857
+ // next call, in either mode, neither repeats nor misses a round.
858
+ async function follow({ session_id, until = 'goal', cursor, every_s = 600, timeout_s = 300 }) {
859
+ if (!session_id) throw new Error('session_id is required')
860
+ if (until !== 'goal' && until !== 'turn') throw new Error('until must be goal or turn')
861
+ if (typeof controller.projections !== 'function') throw new Error('This DeepSeek Harness version has no session projections, so its goals cannot be followed.')
862
+ const every = Math.max(1, Number(every_s) || 600) * 1000
863
+ const seconds = Number.isFinite(Number(timeout_s)) ? Math.min(Math.max(Number(timeout_s), 0), MAX_WAIT_S) : 300
864
+ const deadline = Date.now() + seconds * 1000
865
+ let mark = parseCursor(cursor)
866
+ const startedAt = Date.now()
867
+ for (;;) {
868
+ const [goal, log] = await Promise.all([goalOf(session_id), controller.inspect(session_id, signal())])
869
+ const { ended, running, runningSince } = turnsOf(log?.events)
870
+ // A first call counts from the start of the round under way, so what it already wrote is reported.
871
+ mark ??= { rounds: ended.length, at: running && runningSince ? Math.min(runningSince, Date.now()) : Date.now() }
872
+ const fresh = ended.slice(Math.min(mark.rounds, ended.length))
873
+ const out = (state, extra = {}) => ({
874
+ session_id, state, until, goal, cwd: log?.meta?.cwd ?? null, since: iso(mark.at), ...shownRounds(fresh), ...extra,
875
+ cursor: extra.timed_out ? cursorOf(mark) : cursorOf({ rounds: ended.length, at: Date.now() }),
876
+ })
877
+ // A goal gone since a call that had one was cleared. Without one so far, the turn that
878
+ // sends "/goal …" creates it partway through: no_goal only once no turn is running.
879
+ const hadGoal = !!goal || (!!cursor && (log?.events ?? []).some((e) => e.type === 'goal/change'))
880
+ const endedBecause = !hadGoal ? null : !goal ? 'cleared' : goal.phase !== 'active' ? goal.phase : null
881
+ const lastEnd = ended.length ? Date.parse(ended.at(-1).ended_at) : 0
882
+ const quietSince = Math.max(lastEnd, startedAt)
883
+ if (!hadGoal) {
884
+ if (!running || stopping) return out('no_goal', { ended_because: null })
885
+ } else if (endedBecause) {
886
+ // The round that completed the goal is often still finishing: its reply goes with the report.
887
+ if (!running || stopping || Date.now() >= deadline) return out('goal_ended', { ended_because: endedBecause })
888
+ } else {
889
+ if (goal.max_rounds && goal.rounds_started >= goal.max_rounds && !running) return out('goal_ended', { ended_because: 'round_limit' })
890
+ const question = questions.get(session_id)
891
+ const key = question ? question.id : waitingSessions.has(session_id) ? `ui:${uiEpisodes.get(session_id)}` : null
892
+ if (key && noticed.get(session_id) !== key) {
893
+ noticed.set(session_id, key)
894
+ return out('needs_input', question ? { needs_input: 'answer', question: publicQuestion(question) } : { needs_input: 'harness-ui' })
895
+ }
896
+ if (goal.activation === 'disarmed') return out('needs_resume')
897
+ if (goal.activation == null && !running && Date.now() - quietSince >= GOAL_QUIET_MS) return out('goal_stalled')
898
+ if (until === 'turn' && fresh.length && (lastEnd >= mark.at + every || (Date.now() >= mark.at + every && !running))) return out('report')
899
+ }
900
+ if (stopping || Date.now() >= deadline) return out('following', { timed_out: true })
901
+ // Sleep until something happens, the next report is due, or the quiet runs out.
902
+ const due = [deadline, ...(until === 'turn' ? [mark.at + every] : []), ...(goal && goal.activation == null && !running ? [quietSince + GOAL_QUIET_MS] : [])]
903
+ const wakeAt = Math.max(Date.now() + 50, Math.min(...due.filter((t) => t > Date.now())))
904
+ await new Promise((resolve) => {
905
+ const set = followers.get(session_id) ?? new Set()
906
+ followers.set(session_id, set)
907
+ const done = () => {
908
+ clearTimeout(timer)
909
+ set.delete(done)
910
+ if (!set.size && followers.get(session_id) === set) followers.delete(session_id)
911
+ resolve()
912
+ }
913
+ const timer = setTimeout(done, wakeAt - Date.now())
914
+ set.add(done)
915
+ })
916
+ }
917
+ }
918
+
919
+ // A session at a glance, for a client that holds only its id: what it is doing, its goal
920
+ // and the reply of its last turn.
921
+ async function sessionStatus({ session_id }) {
922
+ const [act, goal, log] = await Promise.all([activity({ session_id }), goalOf(session_id), controller.inspect(session_id, signal()).catch(() => null)])
923
+ const { ended, running } = turnsOf(log?.events)
924
+ const question = questions.get(session_id)
925
+ return {
926
+ ...act, turn_running: running,
927
+ ...(goal ? { goal } : {}),
928
+ ...(question ? { needs_input: 'answer', question: publicQuestion(question) } : waitingSessions.has(session_id) ? { needs_input: 'harness-ui' } : {}),
929
+ ...(ended.length ? { last_turn: shownRound(ended.at(-1)) } : {}),
930
+ }
931
+ }
932
+
723
933
  // --- models -----------------------------------------------------------------
724
934
  // Harness's own catalog decides which names exist: "V4 Pro", "v4-pro" and "DeepSeek-V4-Pro"
725
935
  // all name the model whose id or name reads that way once case, spaces, hyphens,
@@ -885,6 +1095,15 @@ export function apply(ctx) {
885
1095
  throw new Error(`Session ${sessionId} is archived. Unarchive it in Harness, or start a new session with cwd or workspace_id.`)
886
1096
  }
887
1097
  if (!sessionId && !cwd && !workspace_id) throw new Error('A new session needs cwd or workspace_id. Pass session_id to continue an existing one.')
1098
+ // A missing folder is not created: a mistyped path, or a project on a drive that is not
1099
+ // connected, would get an empty folder (under /Volumes, even). Checked before the registry,
1100
+ // which answers a bare ENOENT, or nothing at all for a folder registered while it was there.
1101
+ if (!sessionId) {
1102
+ const dir = workspace_id ? registry.get?.(workspace_id)?.path : normalizePath(cwd)
1103
+ if (dir && !isDirectory(dir)) {
1104
+ throw new Error(`The folder ${dir}${workspace_id ? ` (workspace ${workspace_id})` : ''} does not exist, so no session was started and nothing was sent. Check the path (a project on an external drive needs that drive connected), or create the folder first.`)
1105
+ }
1106
+ }
888
1107
  const plan = await planModel(sessionId, { model, reasoning_effort })
889
1108
  const isNew = !sessionId
890
1109
  if (isNew) {
@@ -969,7 +1188,13 @@ export function apply(ctx) {
969
1188
  run.waitNoticed = true
970
1189
  return { ...publicRun(run), needs_input: run.question ? 'answer' : 'harness-ui' }
971
1190
  }
972
- return { ...publicRun(run), ...(done ? {} : { timed_out: true }) }
1191
+ return { ...publicRun(run), ...(done ? await goalField(run) : { timed_out: true }) }
1192
+ }
1193
+
1194
+ // A run that completed in a session with a goal: the goal goes on after it.
1195
+ const goalField = async (run) => {
1196
+ const goal = run.state === 'completed' ? await openGoal(run.sessionId) : null
1197
+ return goal ? { goal } : {}
973
1198
  }
974
1199
 
975
1200
  // What a session is doing right now, as Harness reports it for archiving:
@@ -1045,7 +1270,12 @@ export function apply(ctx) {
1045
1270
  read_session: readSession,
1046
1271
  models,
1047
1272
  send,
1048
- status: ({ run_id, tail }) => publicRun(getRun(run_id), tail ?? 20),
1273
+ status: async ({ run_id, session_id, tail }) => {
1274
+ if (!run_id && session_id) return sessionStatus({ session_id })
1275
+ const run = getRun(run_id)
1276
+ return { ...publicRun(run, tail ?? 20), ...(isActive(run) ? {} : await goalField(run)) }
1277
+ },
1278
+ follow,
1049
1279
  wait,
1050
1280
  cancel,
1051
1281
  activity,
@@ -1132,6 +1362,14 @@ export function apply(ctx) {
1132
1362
  defaultModelService = child.agentDefaultModel
1133
1363
  child.effect(() => () => { if (defaultModelService === child.agentDefaultModel) defaultModelService = null }, 'dsh-baton-host default model')
1134
1364
  })
1365
+ ctx.inject(['goals'], (child) => {
1366
+ goalsService = child.goals
1367
+ child.effect(() => () => { if (goalsService === child.goals) goalsService = null }, 'dsh-baton-host goals')
1368
+ })
1369
+ ctx.inject(['agents'], (child) => {
1370
+ agentsService = child.agents
1371
+ child.effect(() => () => { if (agentsService === child.agents) agentsService = null }, 'dsh-baton-host agents')
1372
+ })
1135
1373
  ctx.inject(['webServer'], (web) => {
1136
1374
  web.effect(() => web.webServer.register({
1137
1375
  kind: 'exact',
@@ -1168,6 +1406,7 @@ export function apply(ctx) {
1168
1406
  // unloaded or reloaded plugin leaves no timers or sockets behind.
1169
1407
  for (const set of waiters.values()) for (const done of [...set]) done()
1170
1408
  waiters.clear()
1409
+ for (const set of followers.values()) for (const done of [...set]) done()
1171
1410
  for (const conn of connections) conn.destroy()
1172
1411
  if (!server) return
1173
1412
  server.close()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-baton-host",
3
- "version": "0.6.4",
3
+ "version": "0.6.5-next.1",
4
4
  "description": "DeepSeek Harness plugin for dsh-baton: hand coding tasks from Claude Code or Codex to DeepSeek Harness on your machine. Adding it is the whole install: it registers the dsh-baton MCP server for Claude Code and Codex, runs each task live in the Harness window, wakes the main agent when the run ends and hands back the changed files, the project's own test results and a requirement-by-requirement checklist.",
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.6.4"
23
+ "dsh-baton-mcp": "0.6.5-next.1"
24
24
  },
25
25
  "license": "MIT",
26
26
  "dsh": {