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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +5 -2
  2. package/lib/index.js +327 -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,24 @@ 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
+ // Each such event also counts up the session's generation (50 ms later, once the log has it), so
159
+ // a follow that was reading the log when it came reads again instead of sleeping past it.
160
+ const eventGen = new Map() // sessionId -> count
161
+ const wakeFollowers = (sessionId) => setTimeout(guard('wake followers', () => {
162
+ eventGen.set(sessionId, (eventGen.get(sessionId) ?? 0) + 1)
163
+ for (const wake of [...(followers.get(sessionId) ?? [])]) wake()
164
+ }), 50)
165
+ // Sessions whose agent waits for a person (agent/status waiting), and how often each began to.
166
+ const waitingSessions = new Set()
167
+ const uiEpisodes = new Map() // sessionId -> count
168
+ const waitingSince = new Map() // sessionId -> ms the current wait began
169
+ // Sessions a follow wait is on, until when: a question that comes between two calls of one
170
+ // follow (a client's chunks, its round trip) is still taken up for dsh_answer.
171
+ const followedUntil = new Map() // sessionId -> ms
152
172
 
153
173
  // --- run bookkeeping ------------------------------------------------------
154
174
 
@@ -286,6 +306,7 @@ export function apply(ctx) {
286
306
 
287
307
  ctx.on('session/event', guard('session/event', (session, event) => {
288
308
  const data = event.data ?? {}
309
+ if (FOLLOW_EVENTS.has(event.type)) wakeFollowers(session.id)
289
310
  // Turn state is kept only for a turn in progress and dropped at its end.
290
311
  if (event.type === 'turn/start') turns.set(session.id, { hasOutput: false })
291
312
  else if (event.type === 'assistant/message' && turns.has(session.id)) turns.get(session.id).hasOutput = true
@@ -321,7 +342,9 @@ export function apply(ctx) {
321
342
  pushEvent(r, { type: 'human_input', text })
322
343
  }
323
344
  }
324
- if (data.source?.kind === 'user' && opensTurn && run?.mode !== 'steer') {
345
+ // A round a Harness goal starts by itself counts like a user-started turn: the run whose
346
+ // turn just ended keeps that turn's reply instead of running on with the goal.
347
+ if ((data.source?.kind === 'user' || data.source?.kind === 'goal') && opensTurn && run?.mode !== 'steer') {
325
348
  // A new user-started turn: runs whose own turn already ended keep their
326
349
  // result instead of absorbing this turn's output, but still wait for any
327
350
  // sub-agents or jobs they started. Turns woken by sub-agent results carry
@@ -330,7 +353,7 @@ export function apply(ctx) {
330
353
  let closed = false
331
354
  for (const prev of active) {
332
355
  if (prev !== run && prev.sawPrompt && prev.turnEnd && !prev.outputClosed) {
333
- Object.assign(prev, { outputClosed: true, supersededBy: run?.id ?? 'user' })
356
+ Object.assign(prev, { outputClosed: true, supersededBy: run?.id ?? (data.source.kind === 'goal' ? 'goal' : 'user') })
334
357
  closed = true
335
358
  }
336
359
  }
@@ -438,6 +461,9 @@ export function apply(ctx) {
438
461
 
439
462
  ctx.on('agent/status', guard('agent/status', ({ agent, status }) => {
440
463
  if (!agent) return
464
+ if (status === 'waiting') { if (!waitingSessions.has(agent.id)) { uiEpisodes.set(agent.id, (uiEpisodes.get(agent.id) ?? 0) + 1); waitingSince.set(agent.id, Date.now()) } waitingSessions.add(agent.id) }
465
+ else { waitingSessions.delete(agent.id); waitingSince.delete(agent.id) }
466
+ wakeFollowers(agent.id)
441
467
  const receivers = receiversFor(agent.id)
442
468
  if (status === 'running' && receivers.length) lastRunningAt.set(agent.id, Date.now())
443
469
  for (const run of outputReceiversFor(agent.id)) {
@@ -574,10 +600,13 @@ export function apply(ctx) {
574
600
  function onQuestion(request, next) {
575
601
  const sessionId = request?.agent?.id
576
602
  const asked = sessionId && Array.isArray(request.questions) ? outputReceiversFor(sessionId) : []
577
- if (!asked.length) return next()
603
+ // Taken up too when someone follows the session's goal: the follow wait brings it back.
604
+ const followed = followers.has(sessionId) || (followedUntil.get(sessionId) ?? 0) > Date.now()
605
+ if (!asked.length && !(followed && Array.isArray(request.questions))) return next()
578
606
  const entry = { id: `q-${crypto.randomBytes(4).toString('hex')}`, sessionId, items: request.questions, answers: new Map(), runs: asked }
579
607
  const ours = new Promise((resolve) => { entry.resolve = resolve })
580
608
  questions.set(sessionId, entry)
609
+ wakeFollowers(sessionId)
581
610
  for (const run of asked) {
582
611
  run.question = publicQuestion(entry)
583
612
  pushEvent(run, { type: 'question', question_id: entry.id, count: entry.items.length })
@@ -598,6 +627,7 @@ export function apply(ctx) {
598
627
  const fromBaton = ours.then((value) => { ui.abort(new Error('answered through dsh-baton')); return value })
599
628
  return Promise.race([theirs.then(answered('harness')), fromBaton.then(answered('dsh-baton')), aborted]).finally(() => {
600
629
  if (questions.get(sessionId) === entry) questions.delete(sessionId)
630
+ wakeFollowers(sessionId)
601
631
  for (const run of asked) {
602
632
  if (run.question?.question_id !== entry.id) continue
603
633
  delete run.question
@@ -607,15 +637,22 @@ export function apply(ctx) {
607
637
  })
608
638
  }
609
639
 
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) {
640
+ // Answer the open question of a run's turn, or of a session a follow wait reported it for.
641
+ // answers: [{ id?, choose?: [number | label], text?, skip? }]; questions not answered yet
642
+ // stay open until every one has an answer.
643
+ function answerQuestion({ run_id, session_id, answers }) {
644
+ if (!run_id && !session_id) throw new Error('run_id or session_id is required')
645
+ const run = run_id ? getRun(run_id) : null
646
+ const entry = questions.get(run?.sessionId ?? session_id)
647
+ if (run && (!isActive(run) || !entry || run.question?.question_id !== entry.id)) {
616
648
  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
649
  throw new Error(`Run ${run.id} is ${run.state} and has no open question to answer.`)
618
650
  }
651
+ if (!entry) {
652
+ 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.`)
653
+ throw new Error(`Session ${session_id} has no open question to answer.`)
654
+ }
655
+ const shown = run ? publicRun(run, 0) : { session_id }
619
656
  if (!Array.isArray(answers) || !answers.length) throw new Error('answers must list at least one answer')
620
657
  const staged = new Map()
621
658
  for (const a of answers) {
@@ -639,10 +676,10 @@ export function apply(ctx) {
639
676
  const remaining = entry.items.filter((q) => !entry.answers.has(q.id)).map((q) => q.id)
640
677
  if (remaining.length) {
641
678
  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 }
679
+ return { ...shown, submitted: false, remaining }
643
680
  }
644
681
  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)) }
682
+ return { ...shown, submitted: true, answers: entry.items.map((q) => entry.answers.get(q.id)) }
646
683
  }
647
684
 
648
685
  ctx.on('user-questions/request', guardWaterfall(onQuestion), { global: true, prepend: true })
@@ -720,6 +757,249 @@ export function apply(ctx) {
720
757
  return { session_id, ...(isArchived(session_id) ? { archived: true } : {}), cwd: meta?.cwd ?? null, title, total_messages: messages.length, messages: messages.slice(-last) }
721
758
  }
722
759
 
760
+ // --- Harness goals --------------------------------------------------------------
761
+ // A session with a goal starts its next round by itself a few ms after a turn ends, until
762
+ // the goal is complete, paused or blocked, or its rounds run out. The goal's durable state is
763
+ // a session projection (values.goal). Whether this process will really go on with it
764
+ // (activation: armed, or disarmed, e.g. after a Harness restart until a person resumes it)
765
+ // is not in it: only Harness's goals service and its goal/activation-changed event say.
766
+
767
+ let goalsService = null
768
+ let agentsService = null
769
+ const activations = new Map() // sessionId -> 'armed' | 'disarmed', from goal/activation-changed
770
+ ctx.on('goal/activation-changed', guard('goal activation', ({ sessionId, goal } = {}) => {
771
+ if (!sessionId) return
772
+ if (goal?.activation) activations.set(sessionId, goal.activation)
773
+ else activations.delete(sessionId)
774
+ wakeFollowers(sessionId)
775
+ }))
776
+
777
+ // armed, disarmed, or null when this Harness does not say. A session not loaded (no live
778
+ // agent, as after a Harness restart until it is opened) runs nothing: disarmed.
779
+ function activationOf(sessionId, goalId) {
780
+ try {
781
+ if (agentsService && typeof goalsService?.get === 'function') {
782
+ const agent = agentsService.get(sessionId)
783
+ if (!agent) return 'disarmed'
784
+ const view = goalsService.get(agent)
785
+ if (view?.id === goalId && view.activation) return view.activation
786
+ }
787
+ } catch { /* not live here: the event is all there is */ }
788
+ return activations.get(sessionId) ?? null
789
+ }
790
+
791
+ const clipText = (s, n) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}…` : s ?? null)
792
+
793
+ // The session's current goal, null (none, or this Harness has no goals), or undefined when
794
+ // the projection could not be read just now (not the same as no goal).
795
+ async function goalOf(sessionId) {
796
+ let current
797
+ if (typeof controller.projections !== 'function') return null
798
+ try {
799
+ const p = await controller.projections({ sessionId }, signal())
800
+ // The projection's wire view is the current goal itself (null when none); the stored
801
+ // state, as the projection cache keeps it, wraps it in { current, seenGoalIds, failure }.
802
+ const v = p?.values?.goal
803
+ current = v && typeof v === 'object' && 'current' in v ? v.current : v
804
+ } catch { return undefined }
805
+ const g = current?.goal
806
+ if (!g?.id) return null
807
+ const view = {
808
+ id: g.id, phase: g.phase, objective: clipText(g.objective, 300),
809
+ rounds_started: current.roundsStarted ?? 0, max_rounds: g.maxGoalRounds ?? null,
810
+ activation: activationOf(sessionId, g.id),
811
+ ...(g.blockedReason ? { blocked_reason: { code: g.blockedReason.code, message: g.blockedReason.message } } : {}),
812
+ }
813
+ // When it last changed, for follow() only (left out of results).
814
+ return Object.defineProperty(view, 'changedAt', { value: Number(current.updatedAt) || 0 })
815
+ }
816
+
817
+
818
+ // A session log's turns, oldest first: when each ended, its goal round (when a goal started
819
+ // it), its closing text and the tools it called.
820
+ function turnsOf(events) {
821
+ const ended = []
822
+ let cur = null
823
+ for (const e of events ?? []) {
824
+ 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 }
825
+ else if (!cur) continue
826
+ else if (e.type === 'user/message' && e.data?.source?.kind === 'goal') cur.round = e.data.source.round ?? null
827
+ else if (e.type === 'assistant/message') {
828
+ const content = e.data?.message?.content
829
+ const text = textOf(content)
830
+ if (text) cur.text = text
831
+ for (const call of toolCallsOf(content)) { const n = call.name ?? call.kind; cur.tools[n] = (cur.tools[n] ?? 0) + 1 }
832
+ } else if (e.type === 'turn/end') {
833
+ ended.push({ ...cur, ended_at: iso(e.time) ?? new Date().toISOString(), end: e.data?.reason?.kind ?? null })
834
+ cur = null
835
+ }
836
+ }
837
+ return { ended, running: cur !== null, runningSince: cur?.started_at ?? null }
838
+ }
839
+
840
+ const MAX_ROUNDS_SHOWN = 100
841
+ const shownRound = (t) => ({
842
+ turn: t.turn, ...(t.round != null ? { goal_round: t.round } : {}), ended_at: t.ended_at,
843
+ ...(t.end && t.end !== 'completed' ? { ended_as: t.end } : {}),
844
+ final_text: clipText(t.text, 1500), ...(Object.keys(t.tools).length ? { tools: t.tools } : {}),
845
+ })
846
+ // An earlier round of a report, in one line: the latest round's reply usually sums up the
847
+ // goal so far, and a client reads any round in full with dsh_read_session.
848
+ const digestRound = (t) => ({
849
+ turn: t.turn, ...(t.round != null ? { goal_round: t.round } : {}),
850
+ ...(t.end && t.end !== 'completed' ? { ended_as: t.end } : {}),
851
+ summary: clipText(t.text?.replace(/\s+/g, ' ').trim() || null, 100), ...(Object.keys(t.tools).length ? { tools: t.tools } : {}),
852
+ })
853
+ // Every round since the last report: the latest in full, the ones before as digests (up to
854
+ // MAX_ROUNDS_SHOWN, then a count), so no round goes missing however long the wait was.
855
+ const shownRounds = (list) => {
856
+ const earlier = list.slice(0, -1)
857
+ const kept = earlier.slice(-(MAX_ROUNDS_SHOWN - 1))
858
+ return {
859
+ rounds: [...kept.map(digestRound), ...list.slice(-1).map(shownRound)],
860
+ ...(earlier.length > kept.length ? { earlier_rounds_not_shown: earlier.length - kept.length } : {}),
861
+ }
862
+ }
863
+
864
+ // Where a follow wait has got to: rounds already reported, and when it last reported.
865
+ const cursorOf = (mark) => `${mark.rounds}:${mark.at}`
866
+ function parseCursor(cursor) {
867
+ const m = /^(\d+):(\d+)$/.exec(String(cursor ?? ''))
868
+ return m ? { rounds: Number(m[1]), at: Number(m[2]) } : null
869
+ }
870
+ const GOAL_QUIET_MS = Number(process.env.DSH_BATON_GOAL_QUIET_MS) || 5 * 60_000
871
+ // How long a stopped goal waits for the round that stopped it to show its end in the log.
872
+ const GOAL_SETTLE_MS = Number(process.env.DSH_BATON_GOAL_SETTLE_MS) || 15_000
873
+ // The question or Harness UI wait a follower was already told about (one report per episode).
874
+ const noticed = new Map() // sessionId -> { key, at }
875
+ // A question still open is reported again to a call that comes back after this long (the
876
+ // report may never have reached anyone); a Harness UI wait once per episode.
877
+ const RENOTICE_MS = 60_000
878
+ // A waiting status with no question yet: the question usually follows within this time.
879
+ const QUESTION_GRACE_MS = 500
880
+
881
+ // Wait on a session's goal without sending anything. until "goal": until the goal stops
882
+ // (complete, paused, blocked, cleared). since_ms (without a cursor): count from then, e.g.
883
+ // a run's start, so its own round is in the report too. until "turn": at the first round end once every_s
884
+ // have passed since the last report, with every round since then. Both return at once when
885
+ // Harness needs a person, or the goal will not go on by itself (disarmed; when that cannot
886
+ // be read, no new round for GOAL_QUIET_MS). The cursor carries what was reported, so a
887
+ // next call, in either mode, neither repeats nor misses a round.
888
+ async function follow({ session_id, until = 'goal', cursor, since_ms, every_s = 600, timeout_s = 300 }) {
889
+ if (!session_id) throw new Error('session_id is required')
890
+ if (until !== 'goal' && until !== 'turn') throw new Error('until must be goal or turn')
891
+ if (typeof controller.projections !== 'function') throw new Error('This DeepSeek Harness version has no session projections, so its goals cannot be followed.')
892
+ const every = Math.max(1, Number(every_s) || 600) * 1000
893
+ const seconds = Number.isFinite(Number(timeout_s)) ? Math.min(Math.max(Number(timeout_s), 0), MAX_WAIT_S) : 300
894
+ const deadline = Date.now() + seconds * 1000
895
+ let mark = parseCursor(cursor)
896
+ const since = !mark && Number(since_ms) > 0 ? Number(since_ms) : null
897
+ let sawGoal = false
898
+ for (;;) {
899
+ // Followed from now on (a question that comes while the log is read is taken up), and
900
+ // the events so far, to tell whether one came during the read.
901
+ followedUntil.set(session_id, Date.now() + 60_000)
902
+ const gen = eventGen.get(session_id) ?? 0
903
+ const [goal, log] = await Promise.all([goalOf(session_id), controller.inspect(session_id, signal())])
904
+ const { ended, running, runningSince } = turnsOf(log?.events)
905
+ // A first call counts from the start of the round under way, so what it already wrote is
906
+ // reported; one that comes after the goal stopped, from the start of the last round.
907
+ mark ??= since
908
+ ? { rounds: ended.filter((t) => Date.parse(t.ended_at) < since).length, at: since }
909
+ : goal && goal.phase !== 'active' && !running && ended.length
910
+ ? { rounds: ended.length - 1, at: ended.at(-1).started_at ?? Date.parse(ended.at(-1).ended_at) }
911
+ : { rounds: ended.length, at: running && runningSince ? Math.min(runningSince, Date.now()) : Date.now() }
912
+ // The goal could not be read just now: decide nothing, look again shortly.
913
+ if (goal === undefined) {
914
+ if (stopping || Date.now() >= deadline) return { session_id, state: 'following', until, cwd: log?.meta?.cwd ?? null, since: iso(mark.at), rounds_since_report: ended.length - Math.min(mark.rounds, ended.length), timed_out: true, cursor: cursorOf(mark) }
915
+ await new Promise((r) => setTimeout(r, 2000))
916
+ continue
917
+ }
918
+ if (goal) sawGoal = true
919
+ const fresh = ended.slice(Math.min(mark.rounds, ended.length))
920
+ // A call that comes back in between (timed out, or Harness needs a person) names how many
921
+ // rounds wait, not their texts, and keeps the cursor: the rounds come once, in the report.
922
+ const out = (state, extra = {}) => {
923
+ const between = !!extra.timed_out || state === 'needs_input'
924
+ followedUntil.set(session_id, Date.now() + 60_000)
925
+ return {
926
+ session_id, state, until, goal, cwd: log?.meta?.cwd ?? null, since: iso(mark.at),
927
+ ...(between ? { rounds_since_report: fresh.length } : shownRounds(fresh)), ...extra,
928
+ cursor: between ? cursorOf(mark) : cursorOf({ rounds: ended.length, at: Date.now() }),
929
+ }
930
+ }
931
+ // A goal gone since a call that had one was cleared. Without one so far, the turn that
932
+ // sends "/goal …" creates it partway through: no_goal only once no turn is running.
933
+ const goalChanges = (log?.events ?? []).filter((e) => e.type === 'goal/change')
934
+ const hadGoal = sawGoal || ((!!cursor || !!since) && goalChanges.length > 0)
935
+ const endedBecause = !hadGoal ? null : !goal ? 'cleared' : goal.phase !== 'active' ? goal.phase : null
936
+ const lastEnd = ended.length ? Date.parse(ended.at(-1).ended_at) : 0
937
+ // From state the cursor carries, so the quiet also adds up across a client's short calls.
938
+ const quietSince = Math.max(lastEnd, mark.at)
939
+ // The round that stopped the goal (or used its last round) often still finishes its reply
940
+ // after that, and the log may not show that round running yet: settled once no round runs
941
+ // and one has ended since the goal changed, or GOAL_SETTLE_MS after the change.
942
+ // A cleared goal has no view left: when its last change was logged.
943
+ const changedAt = goal?.changedAt ?? (Date.parse(iso(goalChanges.at(-1)?.time)) || 0)
944
+ const settled = !running && (lastEnd >= changedAt || Date.now() - changedAt >= GOAL_SETTLE_MS)
945
+ const atLimit = !!(goal?.max_rounds && goal.rounds_started >= goal.max_rounds)
946
+ const question = questions.get(session_id)
947
+ const waitedFor = waitingSessions.has(session_id) ? Date.now() - (waitingSince.get(session_id) ?? 0) : null
948
+ if (!hadGoal) {
949
+ if (!running || stopping) return out('no_goal', { ended_because: null })
950
+ } else if (endedBecause) {
951
+ // Out of time before that round ends: a timed-out call, so the next one (same cursor)
952
+ // still waits for it; ending here would lose its reply.
953
+ if (settled || stopping) return out('goal_ended', { ended_because: endedBecause })
954
+ } else {
955
+ if (atLimit && settled) return out('goal_ended', { ended_because: 'round_limit' })
956
+ // A waiting status with no question yet is an approval in the Harness UI only once the
957
+ // question had time to come; an open question again to a call that comes back later.
958
+ const key = question ? `q:${question.id}` : waitedFor >= QUESTION_GRACE_MS ? `ui:${uiEpisodes.get(session_id)}` : null
959
+ const seen = noticed.get(session_id)
960
+ if (key && (seen?.key !== key || (question && Date.now() - seen.at >= RENOTICE_MS))) {
961
+ noticed.set(session_id, { key, at: Date.now() })
962
+ return out('needs_input', question ? { needs_input: 'answer', question: publicQuestion(question) } : { needs_input: 'harness-ui' })
963
+ }
964
+ if (goal.activation === 'disarmed') return out('needs_resume')
965
+ if (goal.activation == null && !running && Date.now() - quietSince >= GOAL_QUIET_MS) return out('goal_stalled')
966
+ if (until === 'turn' && fresh.length && (lastEnd >= mark.at + every || (Date.now() >= mark.at + every && !running))) return out('report')
967
+ }
968
+ if (stopping || Date.now() >= deadline) return out('following', { timed_out: true })
969
+ // Something happened while the log was read: read it again rather than sleep past it.
970
+ if ((eventGen.get(session_id) ?? 0) !== gen) continue
971
+ // Sleep until something happens, the next report is due, or the quiet runs out.
972
+ const due = [deadline, ...(until === 'turn' ? [mark.at + every] : []), ...(goal && goal.activation == null && !running ? [quietSince + GOAL_QUIET_MS] : []), ...((endedBecause || atLimit) && changedAt ? [changedAt + GOAL_SETTLE_MS] : []), ...(waitedFor != null && waitedFor < QUESTION_GRACE_MS ? [Date.now() - waitedFor + QUESTION_GRACE_MS] : [])]
973
+ const wakeAt = Math.max(Date.now() + 50, Math.min(...due.filter((t) => t > Date.now())))
974
+ await new Promise((resolve) => {
975
+ const set = followers.get(session_id) ?? new Set()
976
+ followers.set(session_id, set)
977
+ const done = () => {
978
+ clearTimeout(timer)
979
+ set.delete(done)
980
+ if (!set.size && followers.get(session_id) === set) followers.delete(session_id)
981
+ resolve()
982
+ }
983
+ const timer = setTimeout(done, wakeAt - Date.now())
984
+ set.add(done)
985
+ })
986
+ }
987
+ }
988
+
989
+ // A session at a glance, for a client that holds only its id: what it is doing, its goal
990
+ // and the reply of its last turn.
991
+ async function sessionStatus({ session_id }) {
992
+ const [act, goal, log] = await Promise.all([activity({ session_id }), goalOf(session_id), controller.inspect(session_id, signal()).catch(() => null)])
993
+ const { ended, running } = turnsOf(log?.events)
994
+ const question = questions.get(session_id)
995
+ return {
996
+ ...act, turn_running: running,
997
+ ...(goal ? { goal } : {}),
998
+ ...(question ? { needs_input: 'answer', question: publicQuestion(question) } : waitingSessions.has(session_id) ? { needs_input: 'harness-ui' } : {}),
999
+ ...(ended.length ? { last_turn: shownRound(ended.at(-1)) } : {}),
1000
+ }
1001
+ }
1002
+
723
1003
  // --- models -----------------------------------------------------------------
724
1004
  // Harness's own catalog decides which names exist: "V4 Pro", "v4-pro" and "DeepSeek-V4-Pro"
725
1005
  // all name the model whose id or name reads that way once case, spaces, hyphens,
@@ -885,6 +1165,15 @@ export function apply(ctx) {
885
1165
  throw new Error(`Session ${sessionId} is archived. Unarchive it in Harness, or start a new session with cwd or workspace_id.`)
886
1166
  }
887
1167
  if (!sessionId && !cwd && !workspace_id) throw new Error('A new session needs cwd or workspace_id. Pass session_id to continue an existing one.')
1168
+ // A missing folder is not created: a mistyped path, or a project on a drive that is not
1169
+ // connected, would get an empty folder (under /Volumes, even). Checked before the registry,
1170
+ // which answers a bare ENOENT, or nothing at all for a folder registered while it was there.
1171
+ if (!sessionId) {
1172
+ const dir = workspace_id ? registry.get?.(workspace_id)?.path : normalizePath(cwd)
1173
+ if (dir && !isDirectory(dir)) {
1174
+ 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.`)
1175
+ }
1176
+ }
888
1177
  const plan = await planModel(sessionId, { model, reasoning_effort })
889
1178
  const isNew = !sessionId
890
1179
  if (isNew) {
@@ -969,7 +1258,18 @@ export function apply(ctx) {
969
1258
  run.waitNoticed = true
970
1259
  return { ...publicRun(run), needs_input: run.question ? 'answer' : 'harness-ui' }
971
1260
  }
972
- return { ...publicRun(run), ...(done ? {} : { timed_out: true }) }
1261
+ return { ...publicRun(run), ...(done ? await goalField(run) : { timed_out: true }) }
1262
+ }
1263
+
1264
+ // A run that completed in a session with a goal: the goal goes on after it.
1265
+ // The session's goal, for whoever waited on a run: one that goes on after it, or one that
1266
+ // stopped since the run began (its later rounds belong to the run's work: ended_since_run).
1267
+ // A goal complete before the run is left out.
1268
+ const goalField = async (run) => {
1269
+ const goal = run.state === 'completed' ? await goalOf(run.sessionId) : null
1270
+ if (!goal) return {}
1271
+ const since = goal.phase !== 'active' && goal.changedAt >= (Date.parse(run.startedAt) || Infinity)
1272
+ return goal.phase !== 'complete' || since ? { goal: since ? { ...goal, ended_since_run: true } : goal } : {}
973
1273
  }
974
1274
 
975
1275
  // What a session is doing right now, as Harness reports it for archiving:
@@ -1045,7 +1345,12 @@ export function apply(ctx) {
1045
1345
  read_session: readSession,
1046
1346
  models,
1047
1347
  send,
1048
- status: ({ run_id, tail }) => publicRun(getRun(run_id), tail ?? 20),
1348
+ status: async ({ run_id, session_id, tail }) => {
1349
+ if (!run_id && session_id) return sessionStatus({ session_id })
1350
+ const run = getRun(run_id)
1351
+ return { ...publicRun(run, tail ?? 20), ...(isActive(run) ? {} : await goalField(run)) }
1352
+ },
1353
+ follow,
1049
1354
  wait,
1050
1355
  cancel,
1051
1356
  activity,
@@ -1132,6 +1437,14 @@ export function apply(ctx) {
1132
1437
  defaultModelService = child.agentDefaultModel
1133
1438
  child.effect(() => () => { if (defaultModelService === child.agentDefaultModel) defaultModelService = null }, 'dsh-baton-host default model')
1134
1439
  })
1440
+ ctx.inject(['goals'], (child) => {
1441
+ goalsService = child.goals
1442
+ child.effect(() => () => { if (goalsService === child.goals) goalsService = null }, 'dsh-baton-host goals')
1443
+ })
1444
+ ctx.inject(['agents'], (child) => {
1445
+ agentsService = child.agents
1446
+ child.effect(() => () => { if (agentsService === child.agents) agentsService = null }, 'dsh-baton-host agents')
1447
+ })
1135
1448
  ctx.inject(['webServer'], (web) => {
1136
1449
  web.effect(() => web.webServer.register({
1137
1450
  kind: 'exact',
@@ -1168,6 +1481,7 @@ export function apply(ctx) {
1168
1481
  // unloaded or reloaded plugin leaves no timers or sockets behind.
1169
1482
  for (const set of waiters.values()) for (const done of [...set]) done()
1170
1483
  waiters.clear()
1484
+ for (const set of followers.values()) for (const done of [...set]) done()
1171
1485
  for (const conn of connections) conn.destroy()
1172
1486
  if (!server) return
1173
1487
  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.2",
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.2"
24
24
  },
25
25
  "license": "MIT",
26
26
  "dsh": {