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.
- package/README.md +5 -2
- package/lib/index.js +327 -13
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
611
|
-
// text?, skip? }]; questions not answered yet
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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 { ...
|
|
679
|
+
return { ...shown, submitted: false, remaining }
|
|
643
680
|
}
|
|
644
681
|
entry.resolve({ answers: entry.items.map((q) => entry.answers.get(q.id)) })
|
|
645
|
-
return { ...
|
|
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 ?
|
|
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 }) =>
|
|
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.
|
|
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.
|
|
23
|
+
"dsh-baton-mcp": "0.6.5-next.2"
|
|
24
24
|
},
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"dsh": {
|