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