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.
- package/lib/index.js +116 -41
- 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
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
804
|
+
} catch { return undefined }
|
|
792
805
|
const g = current?.goal
|
|
793
806
|
if (!g?.id) return null
|
|
794
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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).
|
|
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
|
|
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
|
|
871
|
-
|
|
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
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
887
|
-
|
|
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 (
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
|
1197
|
-
|
|
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
|
|
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
|
|
23
|
+
"dsh-baton-mcp": "0.6.5"
|
|
24
24
|
},
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"dsh": {
|