dsh-baton-host 0.6.0-next.0 → 0.6.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/lib/client.js CHANGED
@@ -54,6 +54,8 @@ window.__ModuleLoader__.load({
54
54
  notInstalled: '未安装',
55
55
  notRegistered: '未注册',
56
56
  and: '和',
57
+ hookTitle: 'Codex 还差一步',
58
+ hookText: '打开 Codex → Settings → Hooks,信任 Stop 钩子“dsh-baton: waiting for the Harness run”。信任后,Codex 等 DeepSeek 时不再消耗额度。',
57
59
  }
58
60
  const en = {
59
61
  sentFrom: 'Sent by {name} from another chat',
@@ -74,6 +76,8 @@ window.__ModuleLoader__.load({
74
76
  notInstalled: 'not installed',
75
77
  notRegistered: 'not registered',
76
78
  and: 'and',
79
+ hookTitle: 'One more step for Codex',
80
+ hookText: 'Open Codex → Settings → Hooks and trust the Stop hook “dsh-baton: waiting for the Harness run”. Once trusted, Codex no longer uses your quota while it waits for DeepSeek.',
77
81
  }
78
82
 
79
83
  // --- Plugins page ------------------------------------------------------------
@@ -94,7 +98,12 @@ window.__ModuleLoader__.load({
94
98
  if (res.ok) next = (await res.json()).registration
95
99
  } catch { /* not served yet */ }
96
100
  if (stopped) return
97
- if (next && next.state !== 'pending') return setReg(next)
101
+ if (next && next.state !== 'pending') {
102
+ setReg(next)
103
+ // Waiting for the user to trust the Codex hook: look again, so the note goes once they do.
104
+ if (untrusted(next)) setTimeout(poll, 4000)
105
+ return
106
+ }
98
107
  if (++tries >= 30) return setReg({ state: 'unknown' })
99
108
  setTimeout(poll, 700)
100
109
  }
@@ -104,6 +113,30 @@ window.__ModuleLoader__.load({
104
113
  return reg
105
114
  }
106
115
 
116
+ const untrusted = (reg) => Boolean(reg?.codexHook?.installed && !reg.codexHook.trusted)
117
+
118
+ // Codex runs dsh-baton's Stop hook only once the user trusts it in Codex; until then
119
+ // Codex spends model requests while it waits. Shown until they do, in Harness's accent.
120
+ function TrustNote({ reg, t }) {
121
+ if (!untrusted(reg)) return null
122
+ const accent = 'var(--dsw-alias-state-business-primary)'
123
+ return h('div', {
124
+ role: 'note', 'data-dsh-baton-trust': '',
125
+ style: {
126
+ display: 'flex', gap: 10, alignItems: 'flex-start', marginTop: 8, padding: '10px 12px', borderRadius: 8,
127
+ background: `color-mix(in srgb, ${accent} 7%, transparent)`,
128
+ border: `1px solid color-mix(in srgb, ${accent} 22%, transparent)`,
129
+ color: 'var(--dsw-alias-label-secondary)', fontSize: 13, lineHeight: '20px', textAlign: 'start',
130
+ },
131
+ },
132
+ h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true, style: { flex: 'none', marginTop: 2, color: accent } },
133
+ h('path', { d: 'M8 1.75 2.75 3.9v3.6c0 3.1 2.2 5.6 5.25 6.75 3.05-1.15 5.25-3.65 5.25-6.75V3.9z', stroke: 'currentColor', strokeWidth: 1.3, strokeLinejoin: 'round' }),
134
+ h('path', { d: 'm5.6 8.1 1.7 1.7 3.2-3.4', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round' })),
135
+ h('div', null,
136
+ h('div', { style: { color: 'var(--dsw-alias-label-primary)', fontWeight: 600 } }, t('hookTitle')),
137
+ h('div', null, t('hookText'))))
138
+ }
139
+
107
140
  // What to tell the user: [title or null, text]. null while the host is still working.
108
141
  function describe(reg, t) {
109
142
  if (!reg || reg.state === 'pending') return [null, t('pending')]
@@ -122,16 +155,18 @@ window.__ModuleLoader__.load({
122
155
  // Shown once, right after "Enable now".
123
156
  function wrapActivation(t) {
124
157
  return function DshBatonActivation({ onDismiss }) {
125
- const [title, text] = describe(useRegistration(), t)
158
+ const reg = useRegistration()
159
+ const [title, text] = describe(reg, t)
126
160
  if (ui?.Modal && ui?.Button) {
127
161
  return h(ui.Modal, {
128
162
  open: true, title: title ?? t('readyTitle'), closeLabel: t('ok'), onClose: onDismiss,
129
163
  footer: h(ui.Button, { variant: 'primary', 'data-modal-autofocus': true, onClick: onDismiss }, t('ok')),
130
- }, h('p', null, text))
164
+ }, h('p', null, text), h(TrustNote, { reg, t }))
131
165
  }
132
166
  return h('div', { role: 'status', style: { margin: '12px 0', padding: 12, borderRadius: 8, border: '1px solid var(--dsw-alias-border-l3)', color: 'var(--dsw-alias-label-primary)' } },
133
167
  title ? h('strong', null, title) : null,
134
168
  h('p', { style: { margin: '6px 0' } }, text),
169
+ h(TrustNote, { reg, t }),
135
170
  h('button', { type: 'button', onClick: onDismiss }, t('ok')))
136
171
  }
137
172
  }
@@ -147,7 +182,8 @@ window.__ModuleLoader__.load({
147
182
  return h('div', { style: { display: 'flex', flexDirection: 'column', gap: 4, color: 'var(--dsw-alias-label-secondary)' } },
148
183
  h('strong', { style: { color: 'var(--dsw-alias-label-primary)' } }, t('status')),
149
184
  rows.length ? h('span', null, rows.join(' · ')) : null,
150
- h('span', null, text))
185
+ h('span', null, text),
186
+ h(TrustNote, { reg, t }))
151
187
  }
152
188
  }
153
189
 
package/lib/index.js CHANGED
@@ -132,6 +132,8 @@ export function apply(ctx) {
132
132
  const registry = ctx.workspaceRegistry
133
133
  // Harness's settings service, when this Harness has one: read for its busy-send setting.
134
134
  let settings = null
135
+ // Harness's default-model service (see defaultModel()), when this Harness has one.
136
+ let defaultModelService = null
135
137
  const runs = new Map() // runId -> run
136
138
  let stopping = false // set when the plugin unloads: release every wait()
137
139
  const waiters = new Map() // runId -> Set<resolve>
@@ -142,6 +144,7 @@ export function apply(ctx) {
142
144
  run_id: run.id, session_id: run.sessionId, state: run.state, started_at: run.startedAt, ended_at: run.endedAt,
143
145
  message_preview: run.preview, final_text: run.finalText, turn_end: run.turnEnd, error: run.error,
144
146
  ...(run.sender ? { sender: run.sender } : {}),
147
+ ...(run.model ? { model: run.model } : {}),
145
148
  ...(run.waitingOn ? { waiting_on: run.waitingOn } : {}),
146
149
  ...(run.supersededBy ? { superseded_by: run.supersededBy } : {}),
147
150
  ...(run.humanInputs?.length ? { human_interjections: run.humanInputs } : {}),
@@ -677,6 +680,7 @@ export function apply(ctx) {
677
680
  session_id: item.sessionId, cwd: item.cwd ?? null, title: item.title ?? item.projections?.values?.title ?? null,
678
681
  updated_at: iso(item.updatedAt), running: !!item.running, blank: !!item.blank,
679
682
  bridge_runs: activeRunsFor(item.sessionId).map((r) => r.id),
683
+ model: shownModel(item.projections?.values?.modelSelection?.next ?? item.projections?.values?.modelSelection?.lastUsed),
680
684
  }
681
685
  if (want && (!row.cwd || normalizePath(row.cwd) !== want)) continue
682
686
  if (q && !`${row.title ?? ''} ${row.cwd ?? ''} ${row.session_id}`.toLowerCase().includes(q)) continue
@@ -704,7 +708,133 @@ export function apply(ctx) {
704
708
  return { session_id, ...(isArchived(session_id) ? { archived: true } : {}), cwd: meta?.cwd ?? null, title, total_messages: messages.length, messages: messages.slice(-last) }
705
709
  }
706
710
 
707
- async function send({ message, session_id, cwd, workspace_id, mode: asked, sender }) {
711
+ // --- models -----------------------------------------------------------------
712
+ // Harness's own catalog decides which names exist: "V4 Pro", "v4-pro" and "DeepSeek-V4-Pro"
713
+ // all name the model whose id or name reads that way once case, spaces, hyphens,
714
+ // underscores and a leading "deepseek" are left out. No model name is written here.
715
+
716
+ const MODELS_UNSUPPORTED = 'This DeepSeek Harness version cannot choose a model per session: its session controller has no modelCatalog/selectModel. Leave out model and reasoning_effort, or update Harness.'
717
+ const modelKey = (s) => String(s ?? '').toLowerCase().replace(/[\s_-]+/g, '').replace(/^deepseek/, '')
718
+ // Harness's default-model service comes through inject (below); reading it off ctx
719
+ // without that throws in Harness, so that read is only a fallback.
720
+ const defaultModel = () => {
721
+ if (defaultModelService) return defaultModelService
722
+ try { return ctx.agentDefaultModel ?? null } catch { return null }
723
+ }
724
+ const canSelect = () => typeof controller.modelCatalog === 'function' && typeof controller.selectModel === 'function'
725
+ const shownModel = (s) => (s ? { provider: s.provider, model: s.model, reasoning_effort: s.reasoningEffort ?? null } : null)
726
+
727
+ async function catalog() {
728
+ if (typeof controller.modelCatalog !== 'function') throw new Error(MODELS_UNSUPPORTED)
729
+ return controller.modelCatalog()
730
+ }
731
+
732
+ async function models() {
733
+ const c = await catalog()
734
+ return {
735
+ default: shownModel(c.default),
736
+ models: c.groups.flatMap((g) => g.models.map((m) => ({
737
+ provider: g.id, model: m.id, name: m.name ?? m.id,
738
+ ...(m.reasoning ? { reasoning_efforts: m.reasoning.efforts.map((e) => e.id), default_effort: m.reasoning.defaultEffort ?? null } : {}),
739
+ }))),
740
+ ...(c.failures?.length ? { failures: c.failures.map((f) => ({ provider: f.id, error: f.message })) } : {}),
741
+ }
742
+ }
743
+
744
+ const listed = (c) => c.groups.flatMap((g) => g.models.map((m) => `${g.id}/${m.id}${m.name && m.name !== m.id ? ` (${m.name})` : ''}`)).join(', ') || 'none'
745
+
746
+ // One model, or an error naming what is there: never a near miss in its place.
747
+ // "provider/model" picks between providers that offer the same model.
748
+ function matchModel(c, asked) {
749
+ const slash = String(asked).lastIndexOf('/')
750
+ const provider = slash > 0 ? modelKey(String(asked).slice(0, slash)) : null
751
+ const want = modelKey(slash > 0 ? String(asked).slice(slash + 1) : asked)
752
+ const hits = c.groups.flatMap((g) => (provider && modelKey(g.id) !== provider ? [] : g.models
753
+ .filter((m) => modelKey(m.id) === want || modelKey(m.name) === want)
754
+ .map((m) => ({ provider: g.id, entry: m }))))
755
+ if (!want || !hits.length) throw new Error(`No model in Harness matches ${JSON.stringify(asked)}. Available: ${listed(c)}.`)
756
+ if (hits.length > 1) throw new Error(`${JSON.stringify(asked)} matches several models: ${hits.map((h) => `${h.provider}/${h.entry.id}`).join(', ')}. Pass one of them as provider/model.`)
757
+ return hits[0]
758
+ }
759
+
760
+ function matchEffort(hit, asked) {
761
+ const efforts = hit.entry.reasoning?.efforts ?? []
762
+ if (!efforts.length) throw new Error(`${hit.provider}/${hit.entry.id} has no reasoning effort to choose.`)
763
+ const e = efforts.find((x) => modelKey(x.id) === modelKey(asked) || modelKey(x.name) === modelKey(asked))
764
+ if (!e) throw new Error(`${hit.provider}/${hit.entry.id} has no reasoning effort ${JSON.stringify(asked)}; it has ${efforts.map((x) => x.id).join(', ')}.`)
765
+ return e.id
766
+ }
767
+
768
+ // What a session will run with next: its own choice or last request, else Harness's default.
769
+ async function sessionModel(sessionId) {
770
+ try {
771
+ const p = typeof controller.projections === 'function' ? await controller.projections({ sessionId }, signal()) : null
772
+ const s = p?.values?.modelSelection
773
+ return s?.next ?? s?.lastUsed ?? null
774
+ } catch { return null }
775
+ }
776
+
777
+ // selectModel also saves the pick as Harness's default model (in the background).
778
+ // Every pick goes through this queue and the default it found is saved back after
779
+ // it. Harness commits default saves in the order they are made, so the restore lands
780
+ // after the pick's save. The default is read once per busy spell: a pick that starts
781
+ // while another is still restoring would otherwise read that other pick as "the default".
782
+ let modelQueue = Promise.resolve()
783
+ let busyPicks = 0
784
+ let keptDefault = null
785
+ function keepingDefault(fn) {
786
+ const dm = defaultModel()
787
+ if (typeof dm?.currentSelection !== 'function' || typeof dm?.saveSelection !== 'function') {
788
+ return Promise.reject(new Error('This DeepSeek Harness version does not let dsh-baton keep its default model while choosing one for a session, so no model was chosen. Leave out model and reasoning_effort, or update Harness.'))
789
+ }
790
+ busyPicks++
791
+ const step = modelQueue.then(async () => {
792
+ keptDefault ??= dm.currentSelection()
793
+ try { return await fn(keptDefault) } finally {
794
+ await dm.saveSelection(keptDefault).catch((error) => logError('restore default model', error))
795
+ }
796
+ }).finally(() => { if (--busyPicks === 0) keptDefault = null })
797
+ modelQueue = step.catch(() => {})
798
+ return step
799
+ }
800
+
801
+ // Work out the model a send asks for before anything is created, so a name that
802
+ // matches nothing (or several) fails the send with the list of what is there.
803
+ async function planModel(sessionId, { model, reasoning_effort }) {
804
+ if (model == null && reasoning_effort == null) return null
805
+ if (!canSelect()) throw new Error(MODELS_UNSUPPORTED)
806
+ const c = await catalog()
807
+ let hit
808
+ if (model != null) hit = matchModel(c, model)
809
+ else {
810
+ // Effort alone: the model the session runs with now (a new one: Harness's default).
811
+ const current = (sessionId && (await sessionModel(sessionId))) || c.default
812
+ const entry = c.groups.find((g) => g.id === current?.provider)?.models.find((m) => m.id === current?.model)
813
+ if (!entry) throw new Error(`The model ${current?.provider}/${current?.model} is not in Harness's catalog now; pass model too. Available: ${listed(c)}.`)
814
+ hit = { provider: current.provider, entry }
815
+ }
816
+ return { provider: hit.provider, model: hit.entry.id, ...(reasoning_effort != null ? { reasoningEffort: matchEffort(hit, reasoning_effort) } : {}) }
817
+ }
818
+
819
+ // Put the session on its model before the prompt goes in. Without a plan, a new session
820
+ // is pinned to the default as it stands, so a pick running for another send cannot leak
821
+ // into it (Harness reads the default only when the first request is built); an existing
822
+ // one keeps its own. The pin is best effort: if it fails, the session just uses the default.
823
+ async function applyModel(sessionId, plan, isNew) {
824
+ if (plan) {
825
+ const { selected } = await keepingDefault(() => controller.selectModel({ sessionId, ...plan }))
826
+ return { model: shownModel(selected), source: 'requested' }
827
+ }
828
+ if (isNew && canSelect() && defaultModel()) {
829
+ try {
830
+ const { selected } = await keepingDefault((kept) => controller.selectModel({ sessionId, ...kept }))
831
+ return { model: shownModel(selected), source: 'default' }
832
+ } catch (error) { logError('pin default model', error) }
833
+ }
834
+ return { model: shownModel(await sessionModel(sessionId)), source: isNew ? 'default' : 'session' }
835
+ }
836
+
837
+ async function send({ message, session_id, cwd, workspace_id, mode: asked, sender, model, reasoning_effort }) {
708
838
  if (!message || !message.trim()) throw new Error('message is required')
709
839
  if (asked != null && asked !== 'followup' && asked !== 'steer') throw new Error('mode must be followup or steer')
710
840
  // Without a mode, a message to a busy session does what Harness's own busy-send setting says.
@@ -718,18 +848,25 @@ export function apply(ctx) {
718
848
  if (sessionId && isArchived(sessionId)) {
719
849
  throw new Error(`Session ${sessionId} is archived. Unarchive it in Harness, or start a new session with cwd or workspace_id.`)
720
850
  }
721
- if (!sessionId) {
722
- if (!cwd && !workspace_id) throw new Error('A new session needs cwd or workspace_id. Pass session_id to continue an existing one.')
851
+ if (!sessionId && !cwd && !workspace_id) throw new Error('A new session needs cwd or workspace_id. Pass session_id to continue an existing one.')
852
+ const plan = await planModel(sessionId, { model, reasoning_effort })
853
+ const isNew = !sessionId
854
+ if (isNew) {
723
855
  // A folder that is not a workspace yet is registered first, so the new session shows in the sidebar.
724
856
  workspace = workspace_id ? { id: workspace_id } : await createWorkspace({ path: cwd, create_dir: false })
725
857
  sessionId = (await controller.create({ workspaceId: workspace.id })).sessionId
726
858
  }
859
+ // A model that cannot be set fails the send before the prompt goes in.
860
+ const chosen = await applyModel(sessionId, plan, isNew).catch((error) => {
861
+ throw new Error(`${isNew ? `Session ${sessionId} was created, but no message was sent: ` : 'No message was sent: '}the model could not be set (${error?.message ?? error})`)
862
+ })
727
863
  const ahead = activeRunsFor(sessionId).map((r) => r.id)
728
864
  const run = {
729
865
  id: `hrun-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`,
730
866
  sessionId, requestId: requestIdFor(sender), ...(sender ? { sender } : {}), mode, state: 'queued', sawPrompt: false,
731
867
  startedAt: new Date().toISOString(), endedAt: null, preview: message.slice(0, 200),
732
868
  events: [], finalText: null, turnEnd: null, error: null,
869
+ ...(chosen.model ? { model: chosen.model } : {}),
733
870
  }
734
871
  remember(run)
735
872
  try {
@@ -742,6 +879,8 @@ export function apply(ctx) {
742
879
  ...publicRun(run),
743
880
  mode,
744
881
  mode_source: modeSource,
882
+ model: chosen.model,
883
+ model_source: chosen.source,
745
884
  ...(ahead.length ? { other_active_runs: ahead } : {}),
746
885
  ...(workspace ? { workspace } : {}),
747
886
  }
@@ -861,6 +1000,7 @@ export function apply(ctx) {
861
1000
  create_workspace: createWorkspace,
862
1001
  list_sessions: listSessions,
863
1002
  read_session: readSession,
1003
+ models,
864
1004
  send,
865
1005
  status: ({ run_id, tail }) => publicRun(getRun(run_id), tail ?? 20),
866
1006
  wait,
@@ -921,17 +1061,21 @@ export function apply(ctx) {
921
1061
  // entry that is already right is left untouched. The Plugins page reads the outcome from
922
1062
  // GET /dsh-baton/status (lib/client.js). DSH_BATON_AUTO_REGISTER=0 turns it off.
923
1063
  let registration = { state: 'pending' }
1064
+ // Whether Codex's Stop hook is trusted yet: read on each status request, so the Plugins
1065
+ // page notices the user trusting it in Codex.
1066
+ let hookStatus = null
924
1067
  if (setting('AUTO_REGISTER') !== '0') {
925
1068
  setImmediate(guard('register', async () => {
926
1069
  const root = serverRoot()
927
1070
  if (!root) { registration = { state: 'done', ok: false, error: 'no-server', clients: [] }; return }
928
- const { autoRegister, harnessRuntime } = await import(pathToFileURL(path.join(root, 'src', 'register.mjs')).href)
1071
+ const { autoRegister, harnessRuntime, codexHookStatus } = await import(pathToFileURL(path.join(root, 'src', 'register.mjs')).href)
1072
+ hookStatus = codexHookStatus ?? null
929
1073
  // Inside the desktop app, its own binary can stand in for a missing Node.js.
930
1074
  const fallback = process.versions.electron && harnessRuntime ? harnessRuntime(process.execPath) : null
931
1075
  const result = autoRegister({ root, harnessDir: harnessDir(), fallback })
932
1076
  registration = { state: 'done', ...result }
933
1077
  const summary = result.clients.map((c) => `${c.label}: ${c.state}${c.error ? ` (${c.error})` : ''}`).join(', ')
934
- ctx.logger?.[result.ok ? 'info' : 'error']?.(`dsh-baton-host MCP registration: ${summary || result.error}`)
1078
+ ctx.logger?.[result.ok ? 'info' : 'error']?.(`dsh-baton-host MCP registration: ${summary || result.error}${result.codexHook ? `; Codex Stop hook: ${result.codexHook}` : ''}`)
935
1079
  }))
936
1080
  } else {
937
1081
  registration = { state: 'off' }
@@ -941,6 +1085,10 @@ export function apply(ctx) {
941
1085
  settings = child.settings
942
1086
  child.effect(() => () => { if (settings === child.settings) settings = null }, 'dsh-baton-host settings')
943
1087
  })
1088
+ ctx.inject(['agentDefaultModel'], (child) => {
1089
+ defaultModelService = child.agentDefaultModel
1090
+ child.effect(() => () => { if (defaultModelService === child.agentDefaultModel) defaultModelService = null }, 'dsh-baton-host default model')
1091
+ })
944
1092
  ctx.inject(['webServer'], (web) => {
945
1093
  web.effect(() => web.webServer.register({
946
1094
  kind: 'exact',
@@ -948,7 +1096,9 @@ export function apply(ctx) {
948
1096
  handler: (request, response) => {
949
1097
  const { state, ok, error, version, runtime, clients = [] } = registration
950
1098
  response.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
951
- response.end(JSON.stringify({ version: VERSION, registration: { state, ok, error, version, runtime, clients: clients.map(({ id, label, state }) => ({ id, label, state })) } }))
1099
+ let codexHook = null
1100
+ try { codexHook = hookStatus?.() ?? null } catch { /* config unreadable: say nothing */ }
1101
+ response.end(JSON.stringify({ version: VERSION, registration: { state, ok, error, version, runtime, clients: clients.map(({ id, label, state }) => ({ id, label, state })), codexHook } }))
952
1102
  },
953
1103
  }), 'dsh-baton-host status route')
954
1104
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-baton-host",
3
- "version": "0.6.0-next.0",
3
+ "version": "0.6.1",
4
4
  "description": "DeepSeek Harness plugin for dsh-baton: adding it is the whole install. Switched on, it registers the dsh-baton MCP server for Claude Code and Codex, then takes the baton over a local, owner-only control socket, runs it in a specific session through the host's own session controller, and hands the result back when the turn is done.",
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.0-next.0"
23
+ "dsh-baton-mcp": "0.6.1"
24
24
  },
25
25
  "license": "MIT",
26
26
  "dsh": {