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

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 (2) hide show
  1. package/lib/index.js +116 -41
  2. package/package.json +2 -2
package/lib/index.js CHANGED
@@ -155,10 +155,20 @@ export function apply(ctx) {
155
155
  const followers = new Map() // sessionId -> Set<wake>
156
156
  const FOLLOW_EVENTS = new Set(['turn/start', 'turn/end', 'goal/change'])
157
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) }
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)
159
165
  // Sessions whose agent waits for a person (agent/status waiting), and how often each began to.
160
166
  const waitingSessions = new Set()
161
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
162
172
 
163
173
  // --- run bookkeeping ------------------------------------------------------
164
174
 
@@ -451,8 +461,8 @@ export function apply(ctx) {
451
461
 
452
462
  ctx.on('agent/status', guard('agent/status', ({ agent, status }) => {
453
463
  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)
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) }
456
466
  wakeFollowers(agent.id)
457
467
  const receivers = receiversFor(agent.id)
458
468
  if (status === 'running' && receivers.length) lastRunningAt.set(agent.id, Date.now())
@@ -591,7 +601,8 @@ export function apply(ctx) {
591
601
  const sessionId = request?.agent?.id
592
602
  const asked = sessionId && Array.isArray(request.questions) ? outputReceiversFor(sessionId) : []
593
603
  // 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()
604
+ const followed = followers.has(sessionId) || (followedUntil.get(sessionId) ?? 0) > Date.now()
605
+ if (!asked.length && !(followed && Array.isArray(request.questions))) return next()
595
606
  const entry = { id: `q-${crypto.randomBytes(4).toString('hex')}`, sessionId, items: request.questions, answers: new Map(), runs: asked }
596
607
  const ours = new Promise((resolve) => { entry.resolve = resolve })
597
608
  questions.set(sessionId, entry)
@@ -779,31 +790,30 @@ export function apply(ctx) {
779
790
 
780
791
  const clipText = (s, n) => (typeof s === 'string' && s.length > n ? `${s.slice(0, n)}…` : s ?? null)
781
792
 
782
- // The session's current goal, or null (none, or this Harness has no goals).
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).
783
795
  async function goalOf(sessionId) {
784
796
  let current
797
+ if (typeof controller.projections !== 'function') return null
785
798
  try {
786
- const p = typeof controller.projections === 'function' ? await controller.projections({ sessionId }, signal()) : null
799
+ const p = await controller.projections({ sessionId }, signal())
787
800
  // The projection's wire view is the current goal itself (null when none); the stored
788
801
  // state, as the projection cache keeps it, wraps it in { current, seenGoalIds, failure }.
789
802
  const v = p?.values?.goal
790
803
  current = v && typeof v === 'object' && 'current' in v ? v.current : v
791
- } catch { return null }
804
+ } catch { return undefined }
792
805
  const g = current?.goal
793
806
  if (!g?.id) return null
794
- return {
807
+ const view = {
795
808
  id: g.id, phase: g.phase, objective: clipText(g.objective, 300),
796
809
  rounds_started: current.roundsStarted ?? 0, max_rounds: g.maxGoalRounds ?? null,
797
810
  activation: activationOf(sessionId, g.id),
798
811
  ...(g.blockedReason ? { blocked_reason: { code: g.blockedReason.code, message: g.blockedReason.message } } : {}),
799
812
  }
813
+ // When it last changed, for follow() only (left out of results).
814
+ return Object.defineProperty(view, 'changedAt', { value: Number(current.updatedAt) || 0 })
800
815
  }
801
816
 
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
817
 
808
818
  // A session log's turns, oldest first: when each ended, its goal round (when a goal started
809
819
  // it), its closing text and the tools it called.
@@ -820,24 +830,36 @@ export function apply(ctx) {
820
830
  if (text) cur.text = text
821
831
  for (const call of toolCallsOf(content)) { const n = call.name ?? call.kind; cur.tools[n] = (cur.tools[n] ?? 0) + 1 }
822
832
  } 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 })
833
+ ended.push({ ...cur, ended_at: iso(e.time) ?? new Date().toISOString(), end: e.data?.reason?.kind ?? null })
825
834
  cur = null
826
835
  }
827
836
  }
828
837
  return { ended, running: cur !== null, runningSince: cur?.started_at ?? null }
829
838
  }
830
839
 
831
- const MAX_ROUNDS_SHOWN = 10
840
+ const MAX_ROUNDS_SHOWN = 100
832
841
  const shownRound = (t) => ({
833
842
  turn: t.turn, ...(t.round != null ? { goal_round: t.round } : {}), ended_at: t.ended_at,
834
843
  ...(t.end && t.end !== 'completed' ? { ended_as: t.end } : {}),
835
844
  final_text: clipText(t.text, 1500), ...(Object.keys(t.tools).length ? { tools: t.tools } : {}),
836
845
  })
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 } : {}),
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 } : {}),
840
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
+ }
841
863
 
842
864
  // Where a follow wait has got to: rounds already reported, and when it last reported.
843
865
  const cursorOf = (mark) => `${mark.rounds}:${mark.at}`
@@ -846,16 +868,24 @@ export function apply(ctx) {
846
868
  return m ? { rounds: Number(m[1]), at: Number(m[2]) } : null
847
869
  }
848
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
849
873
  // The question or Harness UI wait a follower was already told about (one report per episode).
850
- const noticed = new Map() // sessionId -> key
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
851
880
 
852
881
  // 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
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
854
884
  // have passed since the last report, with every round since then. Both return at once when
855
885
  // Harness needs a person, or the goal will not go on by itself (disarmed; when that cannot
856
886
  // be read, no new round for GOAL_QUIET_MS). The cursor carries what was reported, so a
857
887
  // 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 }) {
888
+ async function follow({ session_id, until = 'goal', cursor, since_ms, every_s = 600, timeout_s = 300 }) {
859
889
  if (!session_id) throw new Error('session_id is required')
860
890
  if (until !== 'goal' && until !== 'turn') throw new Error('until must be goal or turn')
861
891
  if (typeof controller.projections !== 'function') throw new Error('This DeepSeek Harness version has no session projections, so its goals cannot be followed.')
@@ -863,34 +893,72 @@ export function apply(ctx) {
863
893
  const seconds = Number.isFinite(Number(timeout_s)) ? Math.min(Math.max(Number(timeout_s), 0), MAX_WAIT_S) : 300
864
894
  const deadline = Date.now() + seconds * 1000
865
895
  let mark = parseCursor(cursor)
866
- const startedAt = Date.now()
896
+ const since = !mark && Number(since_ms) > 0 ? Number(since_ms) : null
897
+ let sawGoal = false
867
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
868
903
  const [goal, log] = await Promise.all([goalOf(session_id), controller.inspect(session_id, signal())])
869
904
  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() }
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
872
919
  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
- })
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
+ }
877
931
  // A goal gone since a call that had one was cleared. Without one so far, the turn that
878
932
  // 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'))
933
+ const goalChanges = (log?.events ?? []).filter((e) => e.type === 'goal/change')
934
+ const hadGoal = sawGoal || ((!!cursor || !!since) && goalChanges.length > 0)
880
935
  const endedBecause = !hadGoal ? null : !goal ? 'cleared' : goal.phase !== 'active' ? goal.phase : null
881
936
  const lastEnd = ended.length ? Date.parse(ended.at(-1).ended_at) : 0
882
- const quietSince = Math.max(lastEnd, startedAt)
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
883
948
  if (!hadGoal) {
884
949
  if (!running || stopping) return out('no_goal', { ended_because: null })
885
950
  } 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 })
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 })
888
954
  } 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)
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() })
894
962
  return out('needs_input', question ? { needs_input: 'answer', question: publicQuestion(question) } : { needs_input: 'harness-ui' })
895
963
  }
896
964
  if (goal.activation === 'disarmed') return out('needs_resume')
@@ -898,8 +966,10 @@ export function apply(ctx) {
898
966
  if (until === 'turn' && fresh.length && (lastEnd >= mark.at + every || (Date.now() >= mark.at + every && !running))) return out('report')
899
967
  }
900
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
901
971
  // 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] : [])]
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] : [])]
903
973
  const wakeAt = Math.max(Date.now() + 50, Math.min(...due.filter((t) => t > Date.now())))
904
974
  await new Promise((resolve) => {
905
975
  const set = followers.get(session_id) ?? new Set()
@@ -1192,9 +1262,14 @@ export function apply(ctx) {
1192
1262
  }
1193
1263
 
1194
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.
1195
1268
  const goalField = async (run) => {
1196
- const goal = run.state === 'completed' ? await openGoal(run.sessionId) : null
1197
- return goal ? { goal } : {}
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 } : {}
1198
1273
  }
1199
1274
 
1200
1275
  // What a session is doing right now, as Harness reports it for archiving:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-baton-host",
3
- "version": "0.6.5-next.1",
3
+ "version": "0.6.5",
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.5-next.1"
23
+ "dsh-baton-mcp": "0.6.5"
24
24
  },
25
25
  "license": "MIT",
26
26
  "dsh": {