dsh-baton-host 0.0.1 → 0.4.0
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/LICENSE +21 -0
- package/README.md +37 -1
- package/cordis.patch.yml +4 -0
- package/lib/client.js +98 -0
- package/lib/index.js +661 -0
- package/lib/socket.js +55 -0
- package/package.json +57 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 JialaoLiu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
1
|
# dsh-baton-host
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek Harness plugin for [dsh-baton](https://github.com/JialaoLiu/dsh-baton#readme). It runs inside the Harness host (desktop app or `dsh web`) and serves a local, owner-only Unix socket (`~/.dsh-baton/host.sock`; a per-user named pipe on Windows, see [WINDOWS.md](https://github.com/JialaoLiu/dsh-baton/blob/main/WINDOWS.md)). Through it, an MCP client can list and create workspaces, create sessions, continue a specific session, and wait for its turn to finish. All of this goes through Harness's own session controller, so the messages show up live in the Harness UI.
|
|
4
|
+
|
|
5
|
+
DeepSeek Harness 插件,配合 [dsh-baton](https://github.com/JialaoLiu/dsh-baton/blob/main/README.zh-CN.md) 使用。它运行在 Harness 内部,只开一个当前用户才能访问的本地套接字,让 MCP 客户端能新建工作区和对话、续接指定对话、等待任务完成。所有操作都走 Harness 官方会话服务,界面实时可见。
|
|
6
|
+
|
|
7
|
+
## Install / 安装
|
|
8
|
+
|
|
9
|
+
Harness → Plugins / 插件 → Add plugin / 添加插件 → registry Official npm registry → `dsh-baton-host` → Install / 安装 → Enable now / 立即启用
|
|
10
|
+
|
|
11
|
+
Update / 更新: uninstall, then add it again / 先卸载再重新添加. Keep it on the same version as `dsh-baton-mcp` / 版本与 `dsh-baton-mcp` 保持一致.
|
|
12
|
+
|
|
13
|
+
Then connect the MCP server / 然后接入 MCP 服务器: see [dsh-baton](https://github.com/JialaoLiu/dsh-baton#readme).
|
|
14
|
+
|
|
15
|
+
## Socket protocol / 套接字协议
|
|
16
|
+
|
|
17
|
+
Newline-delimited JSON: request `{"id", "method", "params"}`, reply `{"id", "result"}` or `{"id", "error"}`.
|
|
18
|
+
|
|
19
|
+
| Method | Params |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `ping` | — |
|
|
22
|
+
| `workspaces` | — |
|
|
23
|
+
| `create_workspace` | `path`, `title?`, `create_dir?` (default `true`) |
|
|
24
|
+
| `list_sessions` | `cwd?`, `query?`, `limit?` |
|
|
25
|
+
| `read_session` | `session_id`, `last?` |
|
|
26
|
+
| `send` | `message`, and `session_id` or `cwd` or `workspace_id`; `mode?` (`followup` \| `steer`) |
|
|
27
|
+
| `status` | `run_id`, `tail?` |
|
|
28
|
+
| `wait` | `run_id`, `timeout_s?` |
|
|
29
|
+
| `activity` | `session_id` |
|
|
30
|
+
| `cancel` | `run_id` or `session_id` |
|
|
31
|
+
| `rename_session` | `session_id`, `title` |
|
|
32
|
+
| `rename_workspace` | `workspace_id` or `path`, `title` |
|
|
33
|
+
| `list_runs` | `limit?` |
|
|
34
|
+
|
|
35
|
+
`DSH_BATON_SOCKET` / `DSH_BATON_HOME` in the Harness process environment move the socket.
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/client.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// dsh-baton-host, browser half: labels chat messages sent through dsh-baton with
|
|
2
|
+
// their sender ("Sent by Claude from another chat"). The host half puts the sender
|
|
3
|
+
// into the prompt's request id ("dsh-baton.<sender>.<uuid>"); the chat records it as
|
|
4
|
+
// the user message's source.rpcId, so the label survives reloads with no extra state.
|
|
5
|
+
//
|
|
6
|
+
// It wraps the chat's own user-message renderer: the wrapper registers at a lower
|
|
7
|
+
// slot priority (the lowest renders), draws the label, then renders the shipped
|
|
8
|
+
// renderer unchanged. If the wrapper throws, the slot retires it and the shipped
|
|
9
|
+
// renderer takes over again.
|
|
10
|
+
//
|
|
11
|
+
// Hand-written in the client module format (no build step): the host serves this
|
|
12
|
+
// file as the package's "./client" export.
|
|
13
|
+
window.__ModuleLoader__.load({
|
|
14
|
+
id: 'dsh-baton-host',
|
|
15
|
+
factory: (require) => {
|
|
16
|
+
const module = { exports: {} }
|
|
17
|
+
const { createElement: h } = require('react')
|
|
18
|
+
|
|
19
|
+
const SLOT = 'conversation.chat.node'
|
|
20
|
+
const KEYS = ['user', 'steering']
|
|
21
|
+
const PRIORITY = -100
|
|
22
|
+
const NS = 'dsh-baton'
|
|
23
|
+
// dsh-bridge: messages sent before the project was renamed.
|
|
24
|
+
const RPC_RE = /^dsh-(?:baton|bridge)\.([a-z0-9-]{1,32})\./
|
|
25
|
+
// Codex shows as ChatGPT, the way the ChatGPT app labels its own cross-chat messages.
|
|
26
|
+
const NAMES = { claude: 'Claude', codex: 'ChatGPT' }
|
|
27
|
+
|
|
28
|
+
const zh = { sentFrom: '由 {name} 从另一个聊天发送' }
|
|
29
|
+
const en = { sentFrom: 'Sent by {name} from another chat' }
|
|
30
|
+
|
|
31
|
+
function senderOf(node) {
|
|
32
|
+
const rpcId = node?.data?.source?.rpcId
|
|
33
|
+
const m = typeof rpcId === 'string' ? RPC_RE.exec(rpcId) : null
|
|
34
|
+
return m ? m[1] : null
|
|
35
|
+
}
|
|
36
|
+
const nameOf = (sender) => NAMES[sender] ?? sender.charAt(0).toUpperCase() + sender.slice(1)
|
|
37
|
+
|
|
38
|
+
// Sender marks, drawn in the label's text colour like ChatGPT's own "sent from another chat" line.
|
|
39
|
+
const MARKS = {
|
|
40
|
+
claude: { viewBox: '0 0 248 248', d: 'M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z' },
|
|
41
|
+
codex: { viewBox: '0 0 24 24', d: 'M9.20509 8.76514V6.50548C9.20509 6.31517 9.27649 6.17237 9.44293 6.07733L13.9861 3.46091C14.6046 3.10416 15.342 2.93772 16.103 2.93772C18.9573 2.93772 20.7651 5.14986 20.7651 7.50457C20.7651 7.67101 20.7651 7.86132 20.7412 8.05164L16.0316 5.29243C15.7462 5.12599 15.4607 5.12599 15.1753 5.29243L9.20509 8.76514ZM19.8136 17.566V12.1665C19.8136 11.8334 19.6708 11.5955 19.3854 11.4291L13.4152 7.95637L15.3656 6.83836C15.5321 6.74332 15.6749 6.74332 15.8413 6.83836L20.3845 9.45477C21.6928 10.216 22.5728 11.8334 22.5728 13.4032C22.5728 15.2108 21.5025 16.8759 19.8136 17.5657V17.566ZM7.80173 12.8088L5.8513 11.6671C5.68486 11.5721 5.61346 11.4293 5.61346 11.239V6.00616C5.61346 3.46114 7.56389 1.53436 10.2042 1.53436C11.2033 1.53436 12.1307 1.86746 12.9159 2.46205L8.2301 5.17374C7.94475 5.34018 7.80196 5.57801 7.80196 5.91112V12.809L7.80173 12.8088ZM12 15.2349L9.20509 13.6651V10.3352L12 8.76537L14.7947 10.3352V13.6651L12 15.2349ZM13.7958 22.4659C12.7967 22.4659 11.8693 22.1328 11.0841 21.5382L15.7699 18.8265C16.0553 18.6601 16.198 18.4223 16.198 18.0892V11.1912L18.1723 12.3329C18.3388 12.4279 18.4102 12.5707 18.4102 12.7611V17.9939C18.4102 20.5389 16.4359 22.4657 13.7958 22.4657V22.4659ZM8.15848 17.1617L3.61528 14.5453C2.30696 13.784 1.42701 12.1667 1.42701 10.5969C1.42701 8.76537 2.52115 7.12417 4.20987 6.43431V11.8575C4.20987 12.1906 4.35266 12.4284 4.63802 12.5948L10.5846 16.0437L8.63415 17.1617C8.46771 17.2567 8.32492 17.2567 8.15848 17.1617ZM7.897 21.0626C5.20919 21.0626 3.23488 19.0407 3.23488 16.5432C3.23488 16.3529 3.25875 16.1626 3.2824 15.9723L7.96817 18.684C8.25352 18.8504 8.53911 18.8504 8.82447 18.684L14.7947 15.2351V17.4948C14.7947 17.6851 14.7233 17.8279 14.5568 17.9229L10.0136 20.5394C9.39518 20.8961 8.6578 21.0626 7.89677 21.0626H7.897ZM13.7958 23.8929C16.6739 23.8929 19.0762 21.8475 19.6235 19.1358C22.2874 18.4459 24 15.9484 24 13.4034C24 11.7383 23.2865 10.121 22.002 8.95546C22.121 8.45591 22.1924 7.95637 22.1924 7.45705C22.1924 4.05573 19.4331 1.51048 16.2458 1.51048C15.6037 1.51048 14.9852 1.60553 14.3668 1.81971C13.2963 0.773101 11.8215 0.107117 10.2042 0.107117C7.32606 0.107117 4.92383 2.15259 4.37653 4.86428C1.7126 5.55414 0 8.05164 0 10.5967C0 12.2617 0.713506 13.8791 1.99795 15.0446C1.87904 15.5441 1.80764 16.0437 1.80764 16.543C1.80764 19.9443 4.56685 22.4896 7.75421 22.4896C8.39632 22.4896 9.01478 22.3945 9.63324 22.1803C10.7035 23.2269 12.1783 23.8929 13.7958 23.8929Z' },
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Any other sender: a speech bubble with an arrow, "came in from another conversation".
|
|
45
|
+
function Icon({ sender }) {
|
|
46
|
+
const box = { width: 14, height: 14, 'aria-hidden': true, style: { flex: 'none' } }
|
|
47
|
+
const mark = MARKS[sender]
|
|
48
|
+
if (mark) return h('svg', { ...box, viewBox: mark.viewBox }, h('path', { d: mark.d, fill: 'currentColor' }))
|
|
49
|
+
return h('svg', { ...box, viewBox: '0 0 16 16', fill: 'none' },
|
|
50
|
+
h('path', { d: 'M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z', stroke: 'currentColor', strokeWidth: 1.2, strokeLinejoin: 'round' }),
|
|
51
|
+
h('path', { d: 'M6 7h4M8.5 5.5 10 7 8.5 8.5', stroke: 'currentColor', strokeWidth: 1.2, strokeLinecap: 'round', strokeLinejoin: 'round' }))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function Label({ sender, text }) {
|
|
55
|
+
return h('div', {
|
|
56
|
+
'data-dsh-baton-sender': '',
|
|
57
|
+
style: {
|
|
58
|
+
display: 'flex', alignItems: 'center', gap: 6, alignSelf: 'flex-end',
|
|
59
|
+
color: 'var(--dsw-alias-label-tertiary)',
|
|
60
|
+
fontSize: 'var(--dsh-content-font-size-secondary, 13px)', lineHeight: '20px',
|
|
61
|
+
marginBottom: 4, paddingRight: 4,
|
|
62
|
+
},
|
|
63
|
+
}, h(Icon, { sender }), h('span', null, text))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The renderer this wrapper shadows: the best-ranked entry for the key that is not ours.
|
|
67
|
+
function shipped(ctx, key, own) {
|
|
68
|
+
return ctx.slots.entries(SLOT)
|
|
69
|
+
.filter((e) => e.options?.key === key && e.component !== own && typeof e.component !== 'string')
|
|
70
|
+
.sort((a, b) => (a.options?.priority ?? 0) - (b.options?.priority ?? 0))[0]?.component
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function wrapperFor(ctx, key, t) {
|
|
74
|
+
function DshBatonUserMessage(props) {
|
|
75
|
+
const Inner = shipped(ctx, key, DshBatonUserMessage)
|
|
76
|
+
if (!Inner) return null
|
|
77
|
+
const sender = senderOf(props.node)
|
|
78
|
+
if (!sender) return h(Inner, props)
|
|
79
|
+
return h('div', { style: { display: 'flex', flexDirection: 'column' } },
|
|
80
|
+
h(Label, { sender, text: t('sentFrom', { name: nameOf(sender) }) }),
|
|
81
|
+
h(Inner, props))
|
|
82
|
+
}
|
|
83
|
+
return DshBatonUserMessage
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports.name = 'dsh-baton-host'
|
|
87
|
+
module.exports.inject = ['slots', 'locale']
|
|
88
|
+
module.exports.apply = function apply(ctx) {
|
|
89
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-baton: dictionaries')
|
|
90
|
+
const t = ctx.locale.bind(NS)
|
|
91
|
+
ctx.slots.inject(SLOT, function* () {
|
|
92
|
+
// locale 'chat': the shipped renderer reads its strings through the `t` it is handed.
|
|
93
|
+
for (const key of KEYS) yield ctx.slots.register({ name: SLOT, key, priority: PRIORITY, locale: 'chat' }, wrapperFor(ctx, key, t))
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
return module.exports
|
|
97
|
+
},
|
|
98
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
// dsh-baton-host: a DeepSeek Harness plugin that runs inside the Harness host
|
|
2
|
+
// (desktop app or `dsh web`) and serves a local control socket, so an outside
|
|
3
|
+
// orchestrator can list sessions, continue a specific session, and learn when
|
|
4
|
+
// its turn finishes. Everything goes through the host's own session controller,
|
|
5
|
+
// so prompts show up live in the Harness UI exactly like typed messages.
|
|
6
|
+
//
|
|
7
|
+
// Plugin form: named exports only (name / inject / apply), no default export.
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import net from 'node:net'
|
|
10
|
+
import os from 'node:os'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import crypto from 'node:crypto'
|
|
13
|
+
import { socketEndpoint, batonHome, socketPath as socketPathSetting } from './socket.js'
|
|
14
|
+
|
|
15
|
+
export const name = 'dsh-baton-host'
|
|
16
|
+
export const inject = ['sessionController', 'workspaceRegistry']
|
|
17
|
+
|
|
18
|
+
const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
19
|
+
const MAX_EVENTS = 200
|
|
20
|
+
const MAX_RUNS = 100
|
|
21
|
+
const QUIET_CONFIRM_MS = 2000
|
|
22
|
+
const CANCEL_ATTEMPTS = 3
|
|
23
|
+
const CANCEL_SETTLE_MS = 6000
|
|
24
|
+
const ACTIVITY_MAX_ERRORS = 10
|
|
25
|
+
const MAX_REQUEST_BYTES = 4 * 1024 * 1024
|
|
26
|
+
const MAX_WAIT_S = 24 * 3600
|
|
27
|
+
const MAX_HUMAN_INPUTS = 10
|
|
28
|
+
const PERSIST_DELAY_MS = 1000
|
|
29
|
+
// A relayed prompt's request id carries its sender ("dsh-baton.<sender>.<uuid>"), so the
|
|
30
|
+
// client half (lib/client.js) can label the message in the chat without any extra state.
|
|
31
|
+
const SENDER_RE = /^[a-z0-9-]{1,32}$/
|
|
32
|
+
const requestIdFor = (sender) => (sender ? `dsh-baton.${sender}.${crypto.randomUUID()}` : crypto.randomUUID())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
const IS_WIN = process.platform === 'win32'
|
|
36
|
+
|
|
37
|
+
// A Unix socket file on macOS / Linux, a per-user named pipe on Windows (see socket.js).
|
|
38
|
+
function socketPath() {
|
|
39
|
+
return socketEndpoint(socketPathSetting())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const probe = (sock) => new Promise((resolve) => {
|
|
43
|
+
const conn = net.connect(sock)
|
|
44
|
+
conn.on('connect', () => { conn.destroy(); resolve(true) })
|
|
45
|
+
conn.on('error', () => resolve(false))
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Expand `~`, resolve, and follow symlinks when the path exists, so the same
|
|
49
|
+
// folder always compares equal (on macOS /tmp is a link to /private/tmp).
|
|
50
|
+
function normalizePath(p) {
|
|
51
|
+
const abs = path.resolve(p.replace(/^~(?=$|[\\/])/, os.homedir()))
|
|
52
|
+
try { return fs.realpathSync.native(abs) } catch { return abs }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Prepare the socket path without touching anything that is not ours: only our
|
|
56
|
+
// own directory gets 0700, only a stale socket is removed, and a live host that
|
|
57
|
+
// already answers there is left alone.
|
|
58
|
+
async function claimSocket(sock) {
|
|
59
|
+
// A named pipe has no file to own, chmod or clean up; it disappears with its
|
|
60
|
+
// server. Only refuse to start when another host already answers on it.
|
|
61
|
+
if (IS_WIN) {
|
|
62
|
+
if (await probe(sock)) throw new Error(`another dsh-baton-host already serves ${sock}; set DSH_BATON_SOCKET for a second host`)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const dir = path.dirname(sock)
|
|
66
|
+
const ours = dir === batonHome() || !fs.existsSync(dir)
|
|
67
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
68
|
+
if (ours) fs.chmodSync(dir, 0o700)
|
|
69
|
+
let st
|
|
70
|
+
try { st = fs.lstatSync(sock) } catch { return }
|
|
71
|
+
if (!st.isSocket()) throw new Error(`${sock} exists and is not a socket; point DSH_BATON_SOCKET somewhere else`)
|
|
72
|
+
const live = await probe(sock)
|
|
73
|
+
if (live) throw new Error(`another dsh-baton-host already serves ${sock}; set DSH_BATON_SOCKET for a second host`)
|
|
74
|
+
fs.unlinkSync(sock)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parts = (content) => (Array.isArray(content) ? content.filter((c) => c && typeof c === 'object') : [])
|
|
78
|
+
|
|
79
|
+
const textOf = (content) => parts(content).filter((c) => c.type === 'text').map((c) => c.text).join('\n')
|
|
80
|
+
|
|
81
|
+
function toolCallsOf(content) {
|
|
82
|
+
return parts(content).filter((c) => c.type !== 'text' && c.type !== 'reasoning')
|
|
83
|
+
.map((c) => ({ kind: c.type, name: c.name ?? c.toolName, input: c.input ?? c.arguments }))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const iso = (t) => (typeof t === 'number' ? new Date(t).toISOString() : t ?? null)
|
|
87
|
+
|
|
88
|
+
export function apply(ctx) {
|
|
89
|
+
// This code runs inside the Harness host process: an uncaught error here can
|
|
90
|
+
// take Harness down. Every event listener and timer goes through guard().
|
|
91
|
+
const guard = (label, fn) => (...args) => {
|
|
92
|
+
try {
|
|
93
|
+
const result = fn(...args)
|
|
94
|
+
if (result && typeof result.catch === 'function') result.catch((error) => logError(label, error))
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logError(label, error)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const logError = (label, error) => {
|
|
100
|
+
try { ctx.logger?.error?.(`dsh-baton-host ${label}: ${error?.stack ?? error}`) } catch { /* logging must never throw */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const controller = ctx.sessionController
|
|
104
|
+
const registry = ctx.workspaceRegistry
|
|
105
|
+
const runs = new Map() // runId -> run
|
|
106
|
+
const waiters = new Map() // runId -> Set<resolve>
|
|
107
|
+
|
|
108
|
+
// --- run bookkeeping ------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const publicRun = (run, tail = 20) => ({
|
|
111
|
+
run_id: run.id, session_id: run.sessionId, state: run.state, started_at: run.startedAt, ended_at: run.endedAt,
|
|
112
|
+
message_preview: run.preview, final_text: run.finalText, turn_end: run.turnEnd, error: run.error,
|
|
113
|
+
...(run.sender ? { sender: run.sender } : {}),
|
|
114
|
+
...(run.waitingOn ? { waiting_on: run.waitingOn } : {}),
|
|
115
|
+
...(run.supersededBy ? { superseded_by: run.supersededBy } : {}),
|
|
116
|
+
...(run.humanInputs?.length ? { human_interjections: run.humanInputs } : {}),
|
|
117
|
+
...(tail ? { recent_events: run.events.slice(-tail) } : {}),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const ACTIVE = ['queued', 'running', 'waiting']
|
|
121
|
+
const isActive = (run) => ACTIVE.includes(run.state)
|
|
122
|
+
|
|
123
|
+
function settle(run, state, extra = {}) {
|
|
124
|
+
if (!isActive(run)) return
|
|
125
|
+
Object.assign(run, extra, { state, endedAt: new Date().toISOString() })
|
|
126
|
+
for (const resolve of waiters.get(run.id) ?? []) resolve()
|
|
127
|
+
waiters.delete(run.id)
|
|
128
|
+
persist(run)
|
|
129
|
+
if (!activeRunsFor(run.sessionId).length) forgetSession(run.sessionId)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Per-session bookkeeping only lives while the session has active runs, so a
|
|
133
|
+
// host that stays up for days does not accumulate state for idle sessions.
|
|
134
|
+
function forgetSession(sessionId) {
|
|
135
|
+
clearTimeout(quietTimers.get(sessionId))
|
|
136
|
+
quietTimers.delete(sessionId)
|
|
137
|
+
quietSinceBySession.delete(sessionId)
|
|
138
|
+
lastRunningAt.delete(sessionId)
|
|
139
|
+
activityErrors.delete(sessionId)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Active relayed runs of one session, oldest first. Several can be active at
|
|
143
|
+
// once: a followup waits in Harness's queue behind the current turn, and a
|
|
144
|
+
// steer joins the turn that is already running.
|
|
145
|
+
const activeRunsFor = (sessionId) => [...runs.values()].filter((r) => r.sessionId === sessionId && isActive(r))
|
|
146
|
+
// Runs whose prompt has entered the conversation and that are not finished yet.
|
|
147
|
+
const receiversFor = (sessionId) => activeRunsFor(sessionId).filter((r) => r.sawPrompt)
|
|
148
|
+
// Of those, the runs the current turn's output belongs to. A run is closed for
|
|
149
|
+
// output once a newer user-started turn begins; it may still wait for its
|
|
150
|
+
// sub-agents or jobs before it completes.
|
|
151
|
+
const outputReceiversFor = (sessionId) => receiversFor(sessionId).filter((r) => !r.outputClosed)
|
|
152
|
+
|
|
153
|
+
function pushEvent(run, e) {
|
|
154
|
+
run.events.push(e)
|
|
155
|
+
if (run.events.length > MAX_EVENTS) run.events.splice(0, run.events.length - MAX_EVENTS)
|
|
156
|
+
persist(run)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- persistence: run records survive a Harness restart -----------------------
|
|
160
|
+
// One JSON file per run under <baton home>/host-runs, written at most once a
|
|
161
|
+
// second per change burst. Enabled only once this host owns the socket, so a
|
|
162
|
+
// second host never overwrites the first one's records.
|
|
163
|
+
|
|
164
|
+
const store = { dir: path.join(batonHome(), 'host-runs'), enabled: false, dirty: new Set(), timer: null }
|
|
165
|
+
const RUN_FILE = /^(hrun-[\w-]+)\.json$/
|
|
166
|
+
|
|
167
|
+
function persist(run) {
|
|
168
|
+
if (!store.enabled) return
|
|
169
|
+
store.dirty.add(run.id)
|
|
170
|
+
if (!store.timer) store.timer = setTimeout(guard('persist runs', flushRuns), PERSIST_DELAY_MS)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function writeRunFile(run) {
|
|
174
|
+
const file = path.join(store.dir, `${run.id}.json`)
|
|
175
|
+
const tmp = `${file}.tmp`
|
|
176
|
+
fs.writeFileSync(tmp, JSON.stringify(run), { mode: 0o600 })
|
|
177
|
+
fs.renameSync(tmp, file)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function flushRuns() {
|
|
181
|
+
clearTimeout(store.timer)
|
|
182
|
+
store.timer = null
|
|
183
|
+
for (const id of store.dirty) {
|
|
184
|
+
const run = runs.get(id)
|
|
185
|
+
try {
|
|
186
|
+
if (run) writeRunFile(run)
|
|
187
|
+
else fs.rmSync(path.join(store.dir, `${id}.json`), { force: true })
|
|
188
|
+
} catch (error) { logError('persist run', error) }
|
|
189
|
+
}
|
|
190
|
+
store.dirty.clear()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Load earlier records. A run that was active when Harness went away cannot
|
|
194
|
+
// finish any more (Harness ends the turn on restart), so it is marked interrupted.
|
|
195
|
+
function enableStore() {
|
|
196
|
+
fs.mkdirSync(store.dir, { recursive: true, mode: 0o700 })
|
|
197
|
+
if (!IS_WIN) fs.chmodSync(store.dir, 0o700)
|
|
198
|
+
const loaded = []
|
|
199
|
+
for (const name of fs.readdirSync(store.dir)) {
|
|
200
|
+
const m = RUN_FILE.exec(name)
|
|
201
|
+
if (!m) { if (name.endsWith('.tmp')) fs.rmSync(path.join(store.dir, name), { force: true }); continue }
|
|
202
|
+
try {
|
|
203
|
+
const run = JSON.parse(fs.readFileSync(path.join(store.dir, name), 'utf8'))
|
|
204
|
+
if (run?.id === m[1]) loaded.push(run)
|
|
205
|
+
} catch { fs.rmSync(path.join(store.dir, name), { force: true }) }
|
|
206
|
+
}
|
|
207
|
+
loaded.sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)))
|
|
208
|
+
for (const old of loaded.splice(0, Math.max(0, loaded.length - MAX_RUNS))) fs.rmSync(path.join(store.dir, `${old.id}.json`), { force: true })
|
|
209
|
+
store.enabled = true
|
|
210
|
+
for (const run of loaded) {
|
|
211
|
+
if (runs.has(run.id)) continue
|
|
212
|
+
run.events ??= []
|
|
213
|
+
runs.set(run.id, run)
|
|
214
|
+
if (isActive(run)) {
|
|
215
|
+
Object.assign(run, { state: 'interrupted', endedAt: new Date().toISOString(), error: 'Harness restarted while this run was active; its turn was stopped.' })
|
|
216
|
+
persist(run)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Per-session turn state: whether the current turn has produced assistant
|
|
222
|
+
// output yet. A user message before any output opens the turn (a queued
|
|
223
|
+
// followup, or someone typing in the Harness UI); one after it is a steer.
|
|
224
|
+
const turns = new Map() // sessionId -> { hasOutput }
|
|
225
|
+
|
|
226
|
+
ctx.on('session/event', guard('session/event', (session, event) => {
|
|
227
|
+
const data = event.data ?? {}
|
|
228
|
+
// Turn state is kept only for a turn in progress and dropped at its end.
|
|
229
|
+
if (event.type === 'turn/start') turns.set(session.id, { hasOutput: false })
|
|
230
|
+
else if (event.type === 'assistant/message' && turns.has(session.id)) turns.get(session.id).hasOutput = true
|
|
231
|
+
const active = activeRunsFor(session.id)
|
|
232
|
+
if (event.type === 'turn/end' && !active.some((r) => r.sawPrompt)) turns.delete(session.id)
|
|
233
|
+
if (!active.length) return
|
|
234
|
+
if (event.type === 'user/message') {
|
|
235
|
+
const run = active.find((r) => r.requestId === data.source?.rpcId)
|
|
236
|
+
const opensTurn = !turns.get(session.id)?.hasOutput
|
|
237
|
+
// A user message no relayed run sent: someone typing in the Harness UI (or
|
|
238
|
+
// another client). Mid-turn it joins the work of the runs in progress, so
|
|
239
|
+
// they record it: their result then reflects that input too.
|
|
240
|
+
const fromBridge = run || [...runs.values()].some((r) => r.requestId === data.source?.rpcId)
|
|
241
|
+
if (data.source?.kind === 'user' && !fromBridge && !opensTurn) {
|
|
242
|
+
const text = textOf(data.content).slice(0, 500)
|
|
243
|
+
for (const r of outputReceiversFor(session.id)) {
|
|
244
|
+
r.humanInputs = [...(r.humanInputs ?? []), { time: iso(event.time) ?? new Date().toISOString(), text }].slice(-MAX_HUMAN_INPUTS)
|
|
245
|
+
pushEvent(r, { type: 'human_input', text })
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (data.source?.kind === 'user' && opensTurn && run?.mode !== 'steer') {
|
|
249
|
+
// A new user-started turn: runs whose own turn already ended keep their
|
|
250
|
+
// result instead of absorbing this turn's output, but still wait for any
|
|
251
|
+
// sub-agents or jobs they started. Turns woken by sub-agent results carry
|
|
252
|
+
// no user message, and a relayed steer joins the current work, so neither
|
|
253
|
+
// closes earlier runs.
|
|
254
|
+
let closed = false
|
|
255
|
+
for (const prev of active) {
|
|
256
|
+
if (prev !== run && prev.sawPrompt && prev.turnEnd && !prev.outputClosed) {
|
|
257
|
+
Object.assign(prev, { outputClosed: true, supersededBy: run?.id ?? 'user' })
|
|
258
|
+
closed = true
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (closed) scheduleQuietCheck(session.id, 0)
|
|
262
|
+
}
|
|
263
|
+
if (run) { Object.assign(run, { sawPrompt: true, state: 'running' }); persist(run) }
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
const receivers = outputReceiversFor(session.id)
|
|
267
|
+
if (!receivers.length) return
|
|
268
|
+
if (event.type === 'assistant/message') {
|
|
269
|
+
const content = data.message?.content
|
|
270
|
+
const text = textOf(content)
|
|
271
|
+
for (const run of receivers) {
|
|
272
|
+
for (const call of toolCallsOf(content)) pushEvent(run, { type: 'tool_call', ...call })
|
|
273
|
+
if (text) {
|
|
274
|
+
pushEvent(run, { type: 'text', text })
|
|
275
|
+
run.finalText = text
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} else if (event.type === 'tool/result') {
|
|
279
|
+
for (const run of receivers) pushEvent(run, { type: 'tool_result', status: data.status ?? (data.error ? 'error' : 'completed') })
|
|
280
|
+
} else if (event.type === 'turn/end') {
|
|
281
|
+
// The turn that carried these prompts is over. A failed turn ends the run; a
|
|
282
|
+
// completed one ends it only once the session is quiet (no sub-agents or jobs
|
|
283
|
+
// left), because Harness wakes the agent again when those report back.
|
|
284
|
+
const reason = data.reason ?? null
|
|
285
|
+
for (const run of receivers) { run.turnEnd = reason; persist(run) }
|
|
286
|
+
if (reason?.kind && reason.kind !== 'completed') for (const run of receivers) settle(run, 'failed')
|
|
287
|
+
else scheduleQuietCheck(session.id, 1000)
|
|
288
|
+
turns.delete(session.id)
|
|
289
|
+
}
|
|
290
|
+
}))
|
|
291
|
+
|
|
292
|
+
// --- completion: wait until the session has no running turn, sub-agents or jobs
|
|
293
|
+
|
|
294
|
+
const quietTimers = new Map() // sessionId -> timeout
|
|
295
|
+
const quietSinceBySession = new Map() // sessionId -> ms when activity was first seen empty
|
|
296
|
+
const lastRunningAt = new Map() // sessionId -> ms of the last running status
|
|
297
|
+
const activityErrors = new Map() // sessionId -> consecutive activity read failures
|
|
298
|
+
|
|
299
|
+
function scheduleQuietCheck(sessionId, delayMs) {
|
|
300
|
+
clearTimeout(quietTimers.get(sessionId))
|
|
301
|
+
quietTimers.set(sessionId, setTimeout(guard('quiet check', () => checkQuiet(sessionId)), delayMs))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function sessionActivity(sessionId) {
|
|
305
|
+
return ctx.waterfall('workspace/session-activity', { sessionId }, () => Promise.resolve([]))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function checkQuiet(sessionId) {
|
|
309
|
+
quietTimers.delete(sessionId)
|
|
310
|
+
const receivers = receiversFor(sessionId)
|
|
311
|
+
if (!receivers.length) return
|
|
312
|
+
let items
|
|
313
|
+
try {
|
|
314
|
+
items = await sessionActivity(sessionId)
|
|
315
|
+
if (!Array.isArray(items)) throw new Error('session activity is not a list')
|
|
316
|
+
activityErrors.delete(sessionId)
|
|
317
|
+
} catch (error) {
|
|
318
|
+
// Unknown is not idle: keep waiting and retry; give up only after repeated failures.
|
|
319
|
+
const count = (activityErrors.get(sessionId) ?? 0) + 1
|
|
320
|
+
activityErrors.set(sessionId, count)
|
|
321
|
+
const message = `could not read session activity: ${error?.message ?? error}`
|
|
322
|
+
if (count >= ACTIVITY_MAX_ERRORS) {
|
|
323
|
+
activityErrors.delete(sessionId)
|
|
324
|
+
for (const run of receivers) settle(run, 'completed', { error: `${message} (gave up after ${count} tries; sub-agents may still be running)` })
|
|
325
|
+
} else {
|
|
326
|
+
for (const run of receivers) run.error = message
|
|
327
|
+
scheduleQuietCheck(sessionId, 3000)
|
|
328
|
+
}
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
const summary = (list) => list.map((g) => ({ kind: g.kind, count: g.items?.length ?? 0, labels: (g.items ?? []).map((i) => i.label ?? i.id) }))
|
|
332
|
+
// Closed runs: the running turn belongs to a newer run, so only their
|
|
333
|
+
// background work (sub-agents, jobs, reminders) keeps them open.
|
|
334
|
+
const background = items.filter((g) => g.kind !== 'turn')
|
|
335
|
+
for (const run of receivers.filter((r) => r.outputClosed)) {
|
|
336
|
+
if (background.length === 0) settle(run, 'completed', { waitingOn: null, error: null })
|
|
337
|
+
else { run.waitingOn = summary(background); persist(run) }
|
|
338
|
+
}
|
|
339
|
+
// A run whose own turn has not ended (and that has not seen the agent idle) is not done yet.
|
|
340
|
+
const open = receivers.filter((r) => !r.outputClosed && (r.turnEnd || r.idleSeen))
|
|
341
|
+
if (items.length === 0 && open.length) {
|
|
342
|
+
// After waiting on sub-agents or jobs, Harness wakes the agent for a final
|
|
343
|
+
// turn shortly after they finish. Confirm the quiet holds for a moment (no
|
|
344
|
+
// new running status) so that last turn is not missed.
|
|
345
|
+
const waited = open.some((r) => r.waitingOn)
|
|
346
|
+
const quietSince = quietSinceBySession.get(sessionId)
|
|
347
|
+
if (waited && (quietSince === undefined || (lastRunningAt.get(sessionId) ?? 0) > quietSince)) {
|
|
348
|
+
quietSinceBySession.set(sessionId, Date.now())
|
|
349
|
+
scheduleQuietCheck(sessionId, QUIET_CONFIRM_MS)
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
quietSinceBySession.delete(sessionId)
|
|
353
|
+
for (const run of open) settle(run, 'completed', { waitingOn: null, error: null })
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
quietSinceBySession.delete(sessionId)
|
|
357
|
+
for (const run of open) run.waitingOn = items.length ? summary(items) : run.waitingOn
|
|
358
|
+
if (receiversFor(sessionId).length) scheduleQuietCheck(sessionId, 3000)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
ctx.effect(() => () => { for (const t of quietTimers.values()) clearTimeout(t) }, 'dsh-baton-host quiet timers')
|
|
362
|
+
|
|
363
|
+
ctx.on('agent/status', guard('agent/status', ({ agent, status }) => {
|
|
364
|
+
if (!agent) return
|
|
365
|
+
const receivers = receiversFor(agent.id)
|
|
366
|
+
if (status === 'running' && receivers.length) lastRunningAt.set(agent.id, Date.now())
|
|
367
|
+
for (const run of outputReceiversFor(agent.id)) {
|
|
368
|
+
if (status === 'running') run.state = 'running'
|
|
369
|
+
else if (status === 'waiting') run.state = 'waiting' // needs a person in the Harness UI (approval / question)
|
|
370
|
+
else if (status === 'error') settle(run, 'failed')
|
|
371
|
+
else if (status === 'idle') run.idleSeen = true
|
|
372
|
+
}
|
|
373
|
+
if (status === 'idle' && receivers.length) scheduleQuietCheck(agent.id, 0)
|
|
374
|
+
}))
|
|
375
|
+
|
|
376
|
+
ctx.on('agent/error', guard('agent/error', ({ agent, error }) => {
|
|
377
|
+
for (const run of agent ? outputReceiversFor(agent.id) : []) run.error = String(error?.message ?? error)
|
|
378
|
+
}))
|
|
379
|
+
|
|
380
|
+
// --- operations -----------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
const signal = () => new AbortController().signal
|
|
383
|
+
|
|
384
|
+
function workspaces() {
|
|
385
|
+
return registry.list().map((w) => ({ id: w.id, path: w.path, title: w.title, session_count: w.sessionIds.length }))
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Register a folder as a sidebar workspace (idempotent: an already registered
|
|
389
|
+
// path returns its existing workspace). Creates the folder when asked.
|
|
390
|
+
async function createWorkspace({ path: dir, title, create_dir = true }) {
|
|
391
|
+
if (!dir) throw new Error('path is required')
|
|
392
|
+
if (create_dir) fs.mkdirSync(normalizePath(dir), { recursive: true })
|
|
393
|
+
const abs = normalizePath(dir)
|
|
394
|
+
const existed = await registry.resolveByPath(abs).catch(() => undefined)
|
|
395
|
+
const ws = existed ?? (await registry.create(abs, title))
|
|
396
|
+
return { id: ws.id, path: ws.path, title: ws.title, created: !existed }
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// archivedSessionIds may be any iterable, so read it through a Set.
|
|
400
|
+
const isArchived = (sessionId) => new Set(registry.archivedSessionIds ?? []).has(sessionId)
|
|
401
|
+
|
|
402
|
+
async function listSessions({ cwd, query, limit = 20, include_subagents = false } = {}) {
|
|
403
|
+
const { items } = await controller.list({}, signal())
|
|
404
|
+
const want = cwd ? normalizePath(cwd) : null
|
|
405
|
+
const q = query?.toLowerCase()
|
|
406
|
+
const archived = new Set(registry.archivedSessionIds)
|
|
407
|
+
const out = []
|
|
408
|
+
for (const item of items) {
|
|
409
|
+
if (archived.has(item.sessionId)) continue
|
|
410
|
+
if (item.parentSessionId && !include_subagents) continue
|
|
411
|
+
const row = {
|
|
412
|
+
session_id: item.sessionId, cwd: item.cwd ?? null, title: item.title ?? item.projections?.values?.title ?? null,
|
|
413
|
+
updated_at: iso(item.updatedAt), running: !!item.running, blank: !!item.blank,
|
|
414
|
+
bridge_runs: activeRunsFor(item.sessionId).map((r) => r.id),
|
|
415
|
+
}
|
|
416
|
+
if (want && (!row.cwd || normalizePath(row.cwd) !== want)) continue
|
|
417
|
+
if (q && !`${row.title ?? ''} ${row.cwd ?? ''} ${row.session_id}`.toLowerCase().includes(q)) continue
|
|
418
|
+
out.push(row)
|
|
419
|
+
if (out.length >= limit) break
|
|
420
|
+
}
|
|
421
|
+
return out
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function readSession({ session_id, last = 10 }) {
|
|
425
|
+
const { meta, events } = await controller.inspect(session_id, signal())
|
|
426
|
+
const messages = []
|
|
427
|
+
let title = null
|
|
428
|
+
for (const e of events) {
|
|
429
|
+
if (e.type === 'session/title') title = e.data?.title ?? title
|
|
430
|
+
else if (e.type === 'user/message' && e.data?.source?.kind === 'user') {
|
|
431
|
+
messages.push({ role: 'user', time: iso(e.time), text: textOf(e.data.content) })
|
|
432
|
+
} else if (e.type === 'assistant/message') {
|
|
433
|
+
const content = e.data?.message?.content
|
|
434
|
+
const tools = toolCallsOf(content).map((t) => t.name ?? t.kind)
|
|
435
|
+
const text = textOf(content)
|
|
436
|
+
if (text || tools.length) messages.push({ role: 'assistant', time: iso(e.time), text, ...(tools.length ? { tools } : {}) })
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return { session_id, ...(isArchived(session_id) ? { archived: true } : {}), cwd: meta?.cwd ?? null, title, total_messages: messages.length, messages: messages.slice(-last) }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function send({ message, session_id, cwd, workspace_id, mode = 'followup', sender }) {
|
|
443
|
+
if (!message || !message.trim()) throw new Error('message is required')
|
|
444
|
+
if (sender != null && !SENDER_RE.test(sender)) throw new Error('sender must be 1-32 characters of a-z, 0-9 or -')
|
|
445
|
+
let sessionId = session_id
|
|
446
|
+
let workspace = null
|
|
447
|
+
// Harness never runs a prompt sent to an archived session: the run would stay queued forever.
|
|
448
|
+
if (sessionId && isArchived(sessionId)) {
|
|
449
|
+
throw new Error(`Session ${sessionId} is archived. Unarchive it in Harness, or start a new session with cwd or workspace_id.`)
|
|
450
|
+
}
|
|
451
|
+
if (!sessionId) {
|
|
452
|
+
if (!cwd && !workspace_id) throw new Error('A new session needs cwd or workspace_id. Pass session_id to continue an existing one.')
|
|
453
|
+
// A folder that is not a workspace yet is registered first, so the new session shows in the sidebar.
|
|
454
|
+
workspace = workspace_id ? { id: workspace_id } : await createWorkspace({ path: cwd, create_dir: false })
|
|
455
|
+
sessionId = (await controller.create({ workspaceId: workspace.id })).sessionId
|
|
456
|
+
}
|
|
457
|
+
const ahead = activeRunsFor(sessionId).map((r) => r.id)
|
|
458
|
+
const run = {
|
|
459
|
+
id: `hrun-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`,
|
|
460
|
+
sessionId, requestId: requestIdFor(sender), ...(sender ? { sender } : {}), mode, state: 'queued', sawPrompt: false,
|
|
461
|
+
startedAt: new Date().toISOString(), endedAt: null, preview: message.slice(0, 200),
|
|
462
|
+
events: [], finalText: null, turnEnd: null, error: null,
|
|
463
|
+
}
|
|
464
|
+
remember(run)
|
|
465
|
+
try {
|
|
466
|
+
await controller.prompt({ sessionId, requestId: run.requestId, content: [{ type: 'text', text: message }], mode }, signal())
|
|
467
|
+
} catch (error) {
|
|
468
|
+
settle(run, 'failed', { error: String(error?.message ?? error) })
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
...publicRun(run),
|
|
472
|
+
mode,
|
|
473
|
+
...(ahead.length ? { other_active_runs: ahead } : {}),
|
|
474
|
+
...(workspace ? { workspace } : {}),
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Keep at most MAX_RUNS records, dropping the oldest finished one; active runs are never evicted.
|
|
479
|
+
function remember(run) {
|
|
480
|
+
runs.set(run.id, run)
|
|
481
|
+
persist(run)
|
|
482
|
+
if (runs.size <= MAX_RUNS) return
|
|
483
|
+
for (const [id, r] of runs) {
|
|
484
|
+
if (!isActive(r)) { runs.delete(id); persist({ id }); return }
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function getRun(runId) {
|
|
489
|
+
const run = runs.get(runId)
|
|
490
|
+
if (!run) throw new Error(`Unknown run: ${runId} (only the latest 100 runs are kept)`)
|
|
491
|
+
return run
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function wait({ run_id, timeout_s = 300 }) {
|
|
495
|
+
const run = getRun(run_id)
|
|
496
|
+
const seconds = Number.isFinite(Number(timeout_s)) ? Math.min(Math.max(Number(timeout_s), 0), MAX_WAIT_S) : 300
|
|
497
|
+
if (isActive(run)) {
|
|
498
|
+
await new Promise((resolve) => {
|
|
499
|
+
const set = waiters.get(run.id) ?? new Set()
|
|
500
|
+
waiters.set(run.id, set)
|
|
501
|
+
const done = () => {
|
|
502
|
+
clearTimeout(timer)
|
|
503
|
+
set.delete(done)
|
|
504
|
+
resolve()
|
|
505
|
+
}
|
|
506
|
+
const timer = setTimeout(done, seconds * 1000)
|
|
507
|
+
set.add(done)
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
const done = !isActive(run)
|
|
511
|
+
return { ...publicRun(run), ...(done ? {} : { timed_out: true }) }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// What a session is doing right now, as Harness reports it for archiving:
|
|
515
|
+
// its running turn, sub-agents (any depth), background jobs and reminders.
|
|
516
|
+
async function activity({ session_id }) {
|
|
517
|
+
if (!session_id) throw new Error('session_id is required')
|
|
518
|
+
const items = await sessionActivity(session_id)
|
|
519
|
+
return {
|
|
520
|
+
session_id,
|
|
521
|
+
...(isArchived(session_id) ? { archived: true } : {}),
|
|
522
|
+
busy: items.length > 0,
|
|
523
|
+
activity: items,
|
|
524
|
+
bridge_runs: activeRunsFor(session_id).map((r) => ({ run_id: r.id, state: r.state, in_conversation: r.sawPrompt })),
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Stop everything a session is doing (its turn, sub-agents, jobs, reminders)
|
|
529
|
+
// through the same providers Harness uses when archiving, without archiving it.
|
|
530
|
+
async function cancel({ run_id, session_id }) {
|
|
531
|
+
const run = run_id ? getRun(run_id) : null
|
|
532
|
+
const sessionId = run?.sessionId ?? session_id
|
|
533
|
+
if (!sessionId) throw new Error('run_id or session_id is required')
|
|
534
|
+
// A stop that arrives while sub-agents are still starting can be lost: the turn
|
|
535
|
+
// carries on and starts them again. Re-issue it until the session is quiet.
|
|
536
|
+
let attempts = 0
|
|
537
|
+
let still
|
|
538
|
+
while (attempts < CANCEL_ATTEMPTS) {
|
|
539
|
+
attempts++
|
|
540
|
+
await registry.stopSessionActivity(sessionId)
|
|
541
|
+
for (const r of receiversFor(sessionId)) settle(r, 'cancelled')
|
|
542
|
+
const deadline = Date.now() + CANCEL_SETTLE_MS
|
|
543
|
+
do {
|
|
544
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
545
|
+
still = await activity({ session_id: sessionId })
|
|
546
|
+
} while (still.busy && Date.now() < deadline)
|
|
547
|
+
if (!still.busy) break
|
|
548
|
+
}
|
|
549
|
+
// The named run never reached the conversation (still queued, or sent to a session
|
|
550
|
+
// that will not run it): settle it too, so nothing waits on it forever.
|
|
551
|
+
if (run && isActive(run) && !run.sawPrompt) settle(run, 'cancelled')
|
|
552
|
+
return {
|
|
553
|
+
session_id: sessionId,
|
|
554
|
+
...(run ? { run: publicRun(run, 0) } : {}),
|
|
555
|
+
attempts,
|
|
556
|
+
still_busy: still.busy,
|
|
557
|
+
activity: still.activity,
|
|
558
|
+
queued_runs: activeRunsFor(sessionId).filter((r) => !r.sawPrompt).map((r) => r.id),
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function renameSession({ session_id, title }) {
|
|
563
|
+
if (!session_id || !title?.trim()) throw new Error('session_id and title are required')
|
|
564
|
+
const result = await controller.rename({ sessionId: session_id, title: title.trim() })
|
|
565
|
+
return { session_id, title: result?.title ?? title.trim() }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function renameWorkspace({ workspace_id, path: dir, title }) {
|
|
569
|
+
if (!title?.trim()) throw new Error('title is required')
|
|
570
|
+
const ws = workspace_id ? registry.get(workspace_id) : dir ? await registry.resolveByPath(normalizePath(dir)).catch(() => undefined) : undefined
|
|
571
|
+
if (!ws) throw new Error(`Workspace not found: ${workspace_id ?? dir}`)
|
|
572
|
+
await ws.setTitle(title.trim())
|
|
573
|
+
return { id: ws.id, path: ws.path, title: ws.title }
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const methods = {
|
|
577
|
+
ping: () => ({ name, version: VERSION, pid: process.pid }),
|
|
578
|
+
workspaces,
|
|
579
|
+
create_workspace: createWorkspace,
|
|
580
|
+
list_sessions: listSessions,
|
|
581
|
+
read_session: readSession,
|
|
582
|
+
send,
|
|
583
|
+
status: ({ run_id, tail }) => publicRun(getRun(run_id), tail ?? 20),
|
|
584
|
+
wait,
|
|
585
|
+
cancel,
|
|
586
|
+
activity,
|
|
587
|
+
rename_session: renameSession,
|
|
588
|
+
rename_workspace: renameWorkspace,
|
|
589
|
+
list_runs: ({ limit = 10 } = {}) => [...runs.values()].reverse().slice(0, limit).map((r) => publicRun(r, 0)),
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// --- local socket (owner-only) ---------------------------------------------
|
|
593
|
+
|
|
594
|
+
const connections = new Set()
|
|
595
|
+
|
|
596
|
+
function serve(conn) {
|
|
597
|
+
let buf = ''
|
|
598
|
+
connections.add(conn)
|
|
599
|
+
conn.on('close', () => connections.delete(conn))
|
|
600
|
+
conn.setEncoding('utf8')
|
|
601
|
+
conn.on('error', () => {})
|
|
602
|
+
conn.on('data', (chunk) => {
|
|
603
|
+
buf += chunk
|
|
604
|
+
if (buf.length > MAX_REQUEST_BYTES) {
|
|
605
|
+
conn.destroy()
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
let nl
|
|
609
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
610
|
+
const line = buf.slice(0, nl)
|
|
611
|
+
buf = buf.slice(nl + 1)
|
|
612
|
+
if (line.trim()) handle(line)
|
|
613
|
+
}
|
|
614
|
+
})
|
|
615
|
+
async function handle(line) {
|
|
616
|
+
try { await answer(line) } catch (error) { logError('socket request', error) }
|
|
617
|
+
}
|
|
618
|
+
async function answer(line) {
|
|
619
|
+
let req
|
|
620
|
+
try { req = JSON.parse(line) } catch { return conn.write(JSON.stringify({ id: null, error: 'parse error' }) + '\n') }
|
|
621
|
+
try {
|
|
622
|
+
const fn = typeof req.method === 'string' && Object.hasOwn(methods, req.method) ? methods[req.method] : undefined
|
|
623
|
+
if (!fn) throw new Error(`Unknown method: ${req.method}`)
|
|
624
|
+
const result = await fn(req.params ?? {})
|
|
625
|
+
if (!conn.destroyed) conn.write(JSON.stringify({ id: req.id, result }) + '\n')
|
|
626
|
+
} catch (error) {
|
|
627
|
+
if (!conn.destroyed) conn.write(JSON.stringify({ id: req?.id ?? null, error: String(error?.message ?? error) }) + '\n')
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
ctx.effect(() => {
|
|
634
|
+
const sock = socketPath()
|
|
635
|
+
let server = null
|
|
636
|
+
let disposed = false
|
|
637
|
+
claimSocket(sock).then(() => {
|
|
638
|
+
if (disposed) return
|
|
639
|
+
try { enableStore() } catch (error) { logError('run store', error) }
|
|
640
|
+
server = net.createServer(serve)
|
|
641
|
+
server.on('error', (error) => ctx.logger?.error?.(`dsh-baton-host socket error: ${error.message}`))
|
|
642
|
+
server.listen(sock, () => {
|
|
643
|
+
if (!IS_WIN) try { fs.chmodSync(sock, 0o600) } catch { /* best effort */ }
|
|
644
|
+
ctx.logger?.info?.(`dsh-baton-host listening on ${sock}`)
|
|
645
|
+
})
|
|
646
|
+
}).catch((error) => ctx.logger?.error?.(`dsh-baton-host not started: ${error.message}`))
|
|
647
|
+
return () => {
|
|
648
|
+
disposed = true
|
|
649
|
+
if (store.enabled) { try { flushRuns() } catch (error) { logError('persist runs', error) } }
|
|
650
|
+
store.enabled = false
|
|
651
|
+
// Release callers blocked in wait() and close open connections, so an
|
|
652
|
+
// unloaded or reloaded plugin leaves no timers or sockets behind.
|
|
653
|
+
for (const set of waiters.values()) for (const done of [...set]) done()
|
|
654
|
+
waiters.clear()
|
|
655
|
+
for (const conn of connections) conn.destroy()
|
|
656
|
+
if (!server) return
|
|
657
|
+
server.close()
|
|
658
|
+
if (!IS_WIN) try { if (fs.lstatSync(sock).isSocket()) fs.unlinkSync(sock) } catch { /* gone */ }
|
|
659
|
+
}
|
|
660
|
+
}, 'dsh-baton-host socket')
|
|
661
|
+
}
|
package/lib/socket.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Shared by the host plugin and the MCP server / CLI (src/core.mjs imports it):
|
|
2
|
+
// settings, the home folder, and where the control socket lives.
|
|
3
|
+
//
|
|
4
|
+
// The project was called dsh-bridge before it became dsh-baton. For one version the
|
|
5
|
+
// old names keep working: DSH_BRIDGE_* settings (with a warning), an existing
|
|
6
|
+
// ~/.dsh-bridge home, and the old pipe name on Windows, so an MCP server that is
|
|
7
|
+
// already updated still reaches a host plugin that is not restarted yet.
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import crypto from 'node:crypto'
|
|
12
|
+
|
|
13
|
+
const warned = new Set()
|
|
14
|
+
|
|
15
|
+
// DSH_BATON_<name>, else the old DSH_BRIDGE_<name>.
|
|
16
|
+
export function setting(name, env = process.env) {
|
|
17
|
+
if (env[`DSH_BATON_${name}`] !== undefined) return env[`DSH_BATON_${name}`]
|
|
18
|
+
const old = env[`DSH_BRIDGE_${name}`]
|
|
19
|
+
if (old !== undefined && env === process.env && !warned.has(name)) {
|
|
20
|
+
warned.add(name)
|
|
21
|
+
process.emitWarning(`DSH_BRIDGE_${name} is deprecated; rename it to DSH_BATON_${name}`)
|
|
22
|
+
}
|
|
23
|
+
return old
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ~/.dsh-baton, unless only an earlier ~/.dsh-bridge exists: then keep using that, so
|
|
27
|
+
// run records and the socket stay where an installed host plugin expects them.
|
|
28
|
+
export function batonHome(env = process.env, home = os.homedir()) {
|
|
29
|
+
const set = setting('HOME', env)
|
|
30
|
+
if (set) return set
|
|
31
|
+
const fresh = path.join(home, '.dsh-baton')
|
|
32
|
+
const old = path.join(home, '.dsh-bridge')
|
|
33
|
+
return !fs.existsSync(fresh) && fs.existsSync(old) ? old : fresh
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PIPE_PREFIX = /^\\\\[.?]\\pipe\\/i
|
|
37
|
+
|
|
38
|
+
// A Unix socket file on macOS and Linux. Node cannot listen on a socket file on
|
|
39
|
+
// Windows, so there the same path maps to a named pipe: \\.\pipe\dsh-baton-<hash of
|
|
40
|
+
// the path>. The default path sits in the user's own home folder, so the pipe name is
|
|
41
|
+
// per user. DSH_BATON_SOCKET may be either a path (mapped the same way) or a pipe name.
|
|
42
|
+
export function socketEndpoint(p, platform = process.platform, prefix = 'dsh-baton') {
|
|
43
|
+
if (platform !== 'win32' || PIPE_PREFIX.test(p)) return p
|
|
44
|
+
const key = path.win32.resolve(p).toLowerCase()
|
|
45
|
+
return `\\\\.\\pipe\\${prefix}-${crypto.createHash('sha256').update(key).digest('hex').slice(0, 16)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const socketPath = (env = process.env) => setting('SOCKET', env) || path.join(batonHome(env), 'host.sock')
|
|
49
|
+
|
|
50
|
+
// Where a client looks for the host: the current endpoint, then the one a host
|
|
51
|
+
// plugin from before the rename listens on (differs only on Windows).
|
|
52
|
+
export function hostEndpoints(env = process.env, platform = process.platform) {
|
|
53
|
+
const p = socketPath(env)
|
|
54
|
+
return [...new Set([socketEndpoint(p, platform), socketEndpoint(p, platform, 'dsh-bridge')])]
|
|
55
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-baton-host",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "DeepSeek Harness plugin for dsh-baton: takes the baton from Claude Code or Codex 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
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"default": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"dsh": {
|
|
24
|
+
"manifestVersion": 1,
|
|
25
|
+
"engines": {
|
|
26
|
+
"dsh": ">=0.2.1-rc.2"
|
|
27
|
+
},
|
|
28
|
+
"bundle": {
|
|
29
|
+
"patch": "./cordis.patch.yml"
|
|
30
|
+
},
|
|
31
|
+
"client": {
|
|
32
|
+
"platform": "web",
|
|
33
|
+
"inject": [
|
|
34
|
+
"@deepseek-ai/dsh-client-locale",
|
|
35
|
+
"@deepseek-ai/dsh-client-ui-renderer",
|
|
36
|
+
"@deepseek-ai/dsh-client-ui-chat"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"deepseek-harness",
|
|
42
|
+
"dsh",
|
|
43
|
+
"dsh-plugin",
|
|
44
|
+
"mcp",
|
|
45
|
+
"relay",
|
|
46
|
+
"baton"
|
|
47
|
+
],
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.15"
|
|
50
|
+
},
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/JialaoLiu/dsh-baton.git",
|
|
54
|
+
"directory": "host"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/JialaoLiu/dsh-baton#readme",
|
|
57
|
+
"scripts": {
|
|
58
|
+
"prepublishOnly": "node ../scripts/version.mjs"
|
|
59
|
+
}
|
|
8
60
|
}
|