@chenacc/dsh-agentbridge 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +77 -2
- package/cordis.patch.yml +5 -0
- package/lib/index.js +862 -0
- package/package.json +56 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chenacc1
|
|
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,78 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @chenacc/dsh-agentbridge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
AgentBridge 的 DSH Desktop 集成插件。在某个项目会话里执行 `/phone`,把**屏幕上那一条** live Agent 会话接到手机控制台:手机扫码后可以发消息、审批、切换模型、停止任务,桌面与手机看到同一份事件流。
|
|
4
|
+
|
|
5
|
+
- 精确绑定当前会话:会话 ID、项目目录、provider / 模型都取自真实会话,不是新建一个"类似的"会话。
|
|
6
|
+
- 电脑始终是唯一执行端:凭据和文件访问留在本机,手机只下发命令。
|
|
7
|
+
|
|
8
|
+
## 前置条件(重要)
|
|
9
|
+
|
|
10
|
+
这个插件**只是一个桥接层**,它自己不是服务端。它需要本机运行 AgentBridge 主程序(默认 `http://127.0.0.1:8787`):
|
|
11
|
+
|
|
12
|
+
```powershell
|
|
13
|
+
git clone https://github.com/chenacc1/agentbridge.git
|
|
14
|
+
cd agentbridge
|
|
15
|
+
npm install
|
|
16
|
+
npm start # 或双击仓库里的「启动 AgentBridge.cmd」
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
没有这一步,`/phone` 会返回"无法连接 AgentBridge",插件不会自己下载或启动任何服务。
|
|
20
|
+
|
|
21
|
+
要求:DSH Desktop 已安装(存在 `%USERPROFILE%\.dsh\profiles\desktop\package.json`),Node.js 22+。
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
|
|
25
|
+
通过 DSH Desktop 的插件市场安装,或手动装进当前 profile:
|
|
26
|
+
|
|
27
|
+
```powershell
|
|
28
|
+
cd "$env:USERPROFILE\.dsh\profiles\desktop"
|
|
29
|
+
pnpm add @chenacc/dsh-agentbridge@latest
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
然后确认 profile 的 `package.json` 里有:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "@chenacc/dsh-agentbridge"] } }
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
改完重启 DSH Desktop 才会加载命令。
|
|
41
|
+
|
|
42
|
+
## 使用
|
|
43
|
+
|
|
44
|
+
1. 确保 AgentBridge 已经在跑(见上方前置条件),手机与电脑在同一可信 Wi-Fi 或已配好远程通道。
|
|
45
|
+
2. 在 DSH Desktop 里打开目标项目会话,输入 `/phone`。
|
|
46
|
+
3. 终端里出现一次性二维码(5 分钟有效、只能用一次)。用微信扫一扫,进入手机控制台。
|
|
47
|
+
4. 配对成功后收藏手机页显示的固定回访地址,之后从收藏进入,不要再扫。
|
|
48
|
+
|
|
49
|
+
## 配置
|
|
50
|
+
|
|
51
|
+
| 变量 | 默认值 | 含义 |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| `AGENTBRIDGE_ORIGIN` | `http://127.0.0.1:8787` | 插件连接的 AgentBridge 地址;桥换了端口或走了隧道时设置它 |
|
|
54
|
+
|
|
55
|
+
变量要设在**真正启动 DSH Desktop 的环境里**(插件读 `process.env`),不是设在跑 bridge 的终端里。
|
|
56
|
+
|
|
57
|
+
## 卸载
|
|
58
|
+
|
|
59
|
+
在插件市场里卸载,或:
|
|
60
|
+
|
|
61
|
+
```powershell
|
|
62
|
+
cd "$env:USERPROFILE\.dsh\profiles\desktop"
|
|
63
|
+
pnpm remove @chenacc/dsh-agentbridge
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
并把 `dsh.profile.bundles` 里的 `@chenacc/dsh-agentbridge` 删掉,然后重启 DSH Desktop。
|
|
67
|
+
|
|
68
|
+
## 兼容性
|
|
69
|
+
|
|
70
|
+
| 版本 | 需要 |
|
|
71
|
+
|---|---|
|
|
72
|
+
| 插件 0.1.x | DSH Desktop(`@deepseek-ai/dsh-commands`、`@deepseek-ai/cordis` 由 DSH 提供)、AgentBridge `0.1.x` |
|
|
73
|
+
|
|
74
|
+
插件与 bridge 之间有一份集成版本号握手:bridge 侧要求的最低集成版本比插件旧时,手机端会提示"桌面端集成是旧版",升级插件即可。
|
|
75
|
+
|
|
76
|
+
## 许可证
|
|
77
|
+
|
|
78
|
+
MIT
|
package/cordis.patch.yml
ADDED
package/lib/index.js
ADDED
|
@@ -0,0 +1,862 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const name = "agentbridge-dsh-phone";
|
|
4
|
+
// `agents` is not optional: creating a conversation from the phone asks the agent
|
|
5
|
+
// service to open one, and touching a service that was not injected throws
|
|
6
|
+
// `cannot get property "agents" without inject` — which is what the phone showed
|
|
7
|
+
// as a failed "新建对话" instead of the friendly message below.
|
|
8
|
+
const inject = ["commands", "apiProxy", "llm", "agents"];
|
|
9
|
+
const DEFAULT_ORIGIN = "http://127.0.0.1:8787";
|
|
10
|
+
const POLL_DELAY_MS = 800;
|
|
11
|
+
const EVENT_BATCH_DELAY_MS = 120;
|
|
12
|
+
const APPROVAL_PREFIX = "approval:";
|
|
13
|
+
// The bridge accepts at most 1 MB per /events POST (readJson limit), and this batch
|
|
14
|
+
// carries a JSON wrapper on top of the events. 448 KB leaves headroom, and it is also
|
|
15
|
+
// the cap for a single shrunken event, so one giant tool output can never wedge the
|
|
16
|
+
// queue behind a batch the bridge would refuse.
|
|
17
|
+
const EVENT_POST_BUDGET_BYTES = 448 * 1024;
|
|
18
|
+
const EVENT_MAX_ITEMS = 200;
|
|
19
|
+
const EVENT_EVENT_BUDGET_BYTES = 384 * 1024;
|
|
20
|
+
// A string longer than this in one event is clipped before it crosses the wire: the
|
|
21
|
+
// phone renders a summary of tool output, not a faithful copy, and a clipped giant is
|
|
22
|
+
// better than a batch that the bridge rejects.
|
|
23
|
+
const EVENT_STRING_CLIP = 2_048;
|
|
24
|
+
// Bumped whenever the integration gains something the phone relies on. The bridge
|
|
25
|
+
// compares it with the version it needs, because this file is *copied* into the DSH
|
|
26
|
+
// profile: changing the repository does nothing until install:dsh-phone runs again.
|
|
27
|
+
const INTEGRATION_VERSION = 6;
|
|
28
|
+
// Reconnect pacing. The first attempt is immediate (a bridge restart is usually already
|
|
29
|
+
// over by the time the next poll fails), then it backs off to `RECONNECT_MAX_MS`.
|
|
30
|
+
const RECONNECT_MIN_MS = 1_000;
|
|
31
|
+
const RECONNECT_MAX_MS = 30_000;
|
|
32
|
+
|
|
33
|
+
function sleep(ms, signal) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
if (signal.aborted) return resolve();
|
|
36
|
+
const timer = setTimeout(resolve, ms);
|
|
37
|
+
signal.addEventListener("abort", () => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
resolve();
|
|
40
|
+
}, { once: true });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function textFromError(error) {
|
|
45
|
+
return error instanceof Error ? error.message : String(error);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether the bridge was simply not there.
|
|
50
|
+
*
|
|
51
|
+
* The distinction matters for the message the user sees: a refused connection
|
|
52
|
+
* means the plugin loaded fine and only the local bridge is missing, which is a
|
|
53
|
+
* setup step — while a 401 from a live bridge is a different problem entirely.
|
|
54
|
+
*/
|
|
55
|
+
function isBridgeAbsent(error) {
|
|
56
|
+
const cause = error && typeof error === "object" ? error.cause?.code ?? "" : "";
|
|
57
|
+
return /ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|UND_ERR_CONNECT|fetch failed|socket hang up/i
|
|
58
|
+
.test(`${textFromError(error)} ${cause}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The setup steps a user needs when /phone finds nothing listening. */
|
|
62
|
+
function bridgeSetupText(origin) {
|
|
63
|
+
return [
|
|
64
|
+
`无法连接 AgentBridge 桥(${origin})。插件本身已经加载,缺的是电脑上这个本地服务。`,
|
|
65
|
+
"",
|
|
66
|
+
"在电脑上开一个终端,第一次使用时依次执行:",
|
|
67
|
+
"1) git clone https://github.com/chenacc1/agentbridge.git",
|
|
68
|
+
"2) cd agentbridge && npm install",
|
|
69
|
+
"3) npm start",
|
|
70
|
+
"",
|
|
71
|
+
"已经下载过这个仓库的话,进到那个目录执行 npm start 就行。",
|
|
72
|
+
`桥启动后终端里会打印 ${origin},那时再敲一次 /phone。`,
|
|
73
|
+
"",
|
|
74
|
+
"桥不在默认端口的话,先设置环境变量 AGENTBRIDGE_ORIGIN 指向实际地址,再重启 DSH Desktop。",
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function clipped(value, limit) {
|
|
79
|
+
return typeof value === "string" ? value.slice(0, limit) : "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Bound one event's wire size.
|
|
84
|
+
*
|
|
85
|
+
* The bridge rejects a batch over its limit, and a rejected batch is replayed whole
|
|
86
|
+
* on the next flush — so an event that is too big by itself wedges the channel: the
|
|
87
|
+
* same batch fails again and again, nothing ever advances, and the phone goes blind.
|
|
88
|
+
* That loop is what used to fill the desktop log with `Request body is too large`.
|
|
89
|
+
*
|
|
90
|
+
* Clipping long strings keeps the event useful (the phone only ever shows a summary
|
|
91
|
+
* of tool output); the last resort is a stub carrying just the type, because a queued
|
|
92
|
+
* stub is progress while a queued giant is not.
|
|
93
|
+
*/
|
|
94
|
+
function shrinkEventForWire(event) {
|
|
95
|
+
if (JSON.stringify(event).length <= EVENT_EVENT_BUDGET_BYTES) return event;
|
|
96
|
+
const copy = { ...event };
|
|
97
|
+
if (copy.data !== undefined && typeof copy.data === "object") copy.data = { ...copy.data };
|
|
98
|
+
const clipStrings = (value, limit) => {
|
|
99
|
+
if (typeof value === "string") return value.length > limit ? `${value.slice(0, limit)}…[clipped]` : value;
|
|
100
|
+
if (Array.isArray(value)) return value.map((item) => clipStrings(item, limit));
|
|
101
|
+
if (value && typeof value === "object") {
|
|
102
|
+
const result = {};
|
|
103
|
+
for (const [key, child] of Object.entries(value)) result[key] = clipStrings(child, limit);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
};
|
|
108
|
+
copy.data = clipStrings(copy.data, EVENT_STRING_CLIP);
|
|
109
|
+
if (JSON.stringify(copy).length <= EVENT_EVENT_BUDGET_BYTES) return copy;
|
|
110
|
+
// Pathological: still over the budget after clipping. The phone only needs the
|
|
111
|
+
// event's type to keep the transcript consistent; the payload is display-only.
|
|
112
|
+
return { type: copy.type, time: copy.time, data: { note: "事件过大,手机侧省略了内容" } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Project one DSH mux frame onto the interaction record the bridge ingests.
|
|
117
|
+
*
|
|
118
|
+
* Only this session's frames matter, and only the four interaction payloads:
|
|
119
|
+
* everything else on the stream is ordinary conversation traffic that already
|
|
120
|
+
* travels on the event channel. Fields are copied out one by one because the
|
|
121
|
+
* frame carries live DSH objects that must not be handed to another process.
|
|
122
|
+
*/
|
|
123
|
+
export function interactionRecord(envelope, sessionId) {
|
|
124
|
+
const payload = envelope?.payload;
|
|
125
|
+
const rpcId = typeof envelope?.rpcId === "string" ? envelope.rpcId : "";
|
|
126
|
+
if (!payload || !rpcId || payload.sessionId !== sessionId) return null;
|
|
127
|
+
if (payload.type === "question/requested") {
|
|
128
|
+
const questions = Array.isArray(payload.questions) ? payload.questions : [];
|
|
129
|
+
const projected = questions.slice(0, 10).flatMap((question, index) => {
|
|
130
|
+
if (!question || typeof question !== "object") return [];
|
|
131
|
+
const questionText = clipped(question.question, 4_000);
|
|
132
|
+
if (!questionText) return [];
|
|
133
|
+
const options = Array.isArray(question.options) ? question.options.slice(0, 25).flatMap((option) => {
|
|
134
|
+
const label = clipped(option?.label, 200);
|
|
135
|
+
if (!label) return [];
|
|
136
|
+
const description = clipped(option?.description, 500);
|
|
137
|
+
return [{ label, ...(description ? { description } : {}) }];
|
|
138
|
+
}) : [];
|
|
139
|
+
const header = clipped(question.header, 200);
|
|
140
|
+
return [{
|
|
141
|
+
id: clipped(question.id, 100) || `q${index + 1}`,
|
|
142
|
+
question: questionText,
|
|
143
|
+
...(header ? { header } : {}),
|
|
144
|
+
...(options.length ? { options } : {}),
|
|
145
|
+
...(question.multiSelect === true ? { multiSelect: true } : {}),
|
|
146
|
+
}];
|
|
147
|
+
});
|
|
148
|
+
if (!projected.length) return null;
|
|
149
|
+
return { kind: "question-requested", requestId: rpcId, questions: projected };
|
|
150
|
+
}
|
|
151
|
+
if (payload.type === "approval/requested") {
|
|
152
|
+
return {
|
|
153
|
+
kind: "approval-requested",
|
|
154
|
+
requestId: rpcId,
|
|
155
|
+
dshApprovalId: clipped(payload.approvalId, 200),
|
|
156
|
+
toolName: clipped(payload.toolName, 200) || "tool",
|
|
157
|
+
...(payload.reason ? { reason: clipped(payload.reason, 2_000) } : {}),
|
|
158
|
+
...(payload.callId ? { callId: clipped(payload.callId, 200) } : {}),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (payload.type === "question/resolved") {
|
|
162
|
+
return { kind: "question-resolved", requestId: clipped(payload.questionRpcId, 200), outcome: clipped(payload.outcome, 40) };
|
|
163
|
+
}
|
|
164
|
+
if (payload.type === "approval/resolved") {
|
|
165
|
+
return {
|
|
166
|
+
kind: "approval-resolved",
|
|
167
|
+
requestId: "",
|
|
168
|
+
dshApprovalId: clipped(payload.approvalId, 200),
|
|
169
|
+
outcome: clipped(payload.outcome, 40),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function deepFreeze(value) {
|
|
176
|
+
if (!value || typeof value !== "object" || Object.isFrozen(value)) return value;
|
|
177
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
178
|
+
return Object.freeze(value);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function createUserMessage(text) {
|
|
182
|
+
return deepFreeze({
|
|
183
|
+
id: randomUUID(),
|
|
184
|
+
role: "user",
|
|
185
|
+
content: [{ type: "text", text }],
|
|
186
|
+
source: { kind: "user" },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function selectionFromAgent(agent) {
|
|
191
|
+
let logged;
|
|
192
|
+
try {
|
|
193
|
+
logged = agent.session.requestHeader?.()?.config;
|
|
194
|
+
} catch {
|
|
195
|
+
logged = undefined;
|
|
196
|
+
}
|
|
197
|
+
const provider = agent.options?.provider || logged?.provider;
|
|
198
|
+
const model = agent.options?.model || logged?.model;
|
|
199
|
+
if (!provider || !model) return null;
|
|
200
|
+
const reasoningEffort = agent.options?.reasoningEffort || logged?.reasoningEffort;
|
|
201
|
+
return { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function compactModelDirectory(value, current, extraFailures = []) {
|
|
205
|
+
const groups = Array.isArray(value?.groups) ? value.groups.slice(0, 30).map((group) => ({
|
|
206
|
+
id: group.id,
|
|
207
|
+
name: group.name,
|
|
208
|
+
models: Array.isArray(group.models) ? group.models.slice(0, 200).map((model) => ({
|
|
209
|
+
id: model.id,
|
|
210
|
+
name: model.name,
|
|
211
|
+
...(model.reasoning ? { reasoning: {
|
|
212
|
+
efforts: Array.isArray(model.reasoning.efforts) ? model.reasoning.efforts.slice(0, 20) : [],
|
|
213
|
+
...(model.reasoning.defaultEffort ? { defaultEffort: model.reasoning.defaultEffort } : {}),
|
|
214
|
+
} } : {}),
|
|
215
|
+
})) : [],
|
|
216
|
+
})) : [];
|
|
217
|
+
const failures = [...extraFailures, ...(Array.isArray(value?.failures) ? value.failures : [])].slice(0, 30);
|
|
218
|
+
return {
|
|
219
|
+
current,
|
|
220
|
+
routable: current ? groups.some((group) => group.id === current.provider) : false,
|
|
221
|
+
groups,
|
|
222
|
+
failures,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function readResponse(response) {
|
|
227
|
+
const body = await response.json().catch(() => ({}));
|
|
228
|
+
if (!response.ok) {
|
|
229
|
+
// The status matters to the caller: 401/403/404/409 mean "this channel no
|
|
230
|
+
// longer exists", which is the signal to re-register instead of retrying.
|
|
231
|
+
const error = new Error(body.error || `AgentBridge request failed (${response.status})`);
|
|
232
|
+
error.status = response.status;
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
return body;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isStaleChannel(error) {
|
|
239
|
+
return [401, 403, 404, 409, 410].includes(Number(error?.status));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export class PhoneConnections {
|
|
243
|
+
constructor(ctx, options = {}) {
|
|
244
|
+
this.ctx = ctx;
|
|
245
|
+
this.bySession = new Map();
|
|
246
|
+
// One lifetime per conversation, independent of any single channel connection.
|
|
247
|
+
// A reconnect tears the connection down (aborting its controller) *before* it knows
|
|
248
|
+
// whether the bridge is back, so a retry loop tied to that controller could only
|
|
249
|
+
// ever run one attempt — the exact bug that left a conversation dead after a manual
|
|
250
|
+
// bridge restart. Only the desktop disposing the conversation ends a lifetime.
|
|
251
|
+
this.lifetimes = new Map();
|
|
252
|
+
this.reconnectMinMs = options.reconnectMinMs ?? RECONNECT_MIN_MS;
|
|
253
|
+
this.reconnectMaxMs = options.reconnectMaxMs ?? RECONNECT_MAX_MS;
|
|
254
|
+
// Failed interaction cards survive a reconnect, so they are keyed to the
|
|
255
|
+
// conversation, not to the channel that tried to carry them.
|
|
256
|
+
this.interactionScopes = new Map();
|
|
257
|
+
this.liveModelSelections = new WeakMap();
|
|
258
|
+
this.ctx.on("session/event", (session, event) => this.captureEvent(session, event));
|
|
259
|
+
this.ctx.on("agent/disposed", ({ agent }) => this.dispose(agent.session.id));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
lifetimeFor(sessionId) {
|
|
263
|
+
let lifetime = this.lifetimes.get(sessionId);
|
|
264
|
+
if (!lifetime) {
|
|
265
|
+
lifetime = new AbortController();
|
|
266
|
+
this.lifetimes.set(sessionId, lifetime);
|
|
267
|
+
}
|
|
268
|
+
return lifetime;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async connect(agent, options = {}) {
|
|
272
|
+
this.close(agent.session.id);
|
|
273
|
+
const origin = String(process.env.AGENTBRIDGE_ORIGIN || DEFAULT_ORIGIN).replace(/\/$/, "");
|
|
274
|
+
const modelDirectory = await this.modelDirectory(agent);
|
|
275
|
+
// A conversation the phone asked for registers against the session it already
|
|
276
|
+
// opened, so the bridge binds that one instead of minting a second pairing.
|
|
277
|
+
const bridgeSessionId = typeof options.bridgeSessionId === "string" ? options.bridgeSessionId : "";
|
|
278
|
+
// A reconnect must not consume a pairing code or add a device: the phone that
|
|
279
|
+
// already owns this conversation keeps owning it.
|
|
280
|
+
const reconnecting = options.reconnecting === true;
|
|
281
|
+
const response = await fetch(`${origin}/api/local/dsh-desktop/register`, {
|
|
282
|
+
method: "POST",
|
|
283
|
+
headers: { "content-type": "application/json" },
|
|
284
|
+
body: JSON.stringify({
|
|
285
|
+
sessionId: agent.session.id,
|
|
286
|
+
cwd: agent.session.header.cwd,
|
|
287
|
+
title: agent.session.header.title,
|
|
288
|
+
provider: agent.options.provider,
|
|
289
|
+
model: agent.options.model,
|
|
290
|
+
modelDirectory,
|
|
291
|
+
integrationVersion: INTEGRATION_VERSION,
|
|
292
|
+
...(bridgeSessionId ? { bridgeSessionId } : {}),
|
|
293
|
+
...(reconnecting ? { reconnect: true } : {}),
|
|
294
|
+
}),
|
|
295
|
+
});
|
|
296
|
+
const registration = await readResponse(response);
|
|
297
|
+
const state = {
|
|
298
|
+
agent,
|
|
299
|
+
origin,
|
|
300
|
+
channel: registration.channel,
|
|
301
|
+
controller: new AbortController(),
|
|
302
|
+
lifetime: this.lifetimeFor(agent.session.id),
|
|
303
|
+
cursor: 0,
|
|
304
|
+
pendingEvents: [],
|
|
305
|
+
phoneMessageIds: new Set(),
|
|
306
|
+
// rpc ids this phone already answered, so DSH's own "resolved" echo is
|
|
307
|
+
// not forwarded back and rendered as a second, duplicate resolution.
|
|
308
|
+
answered: new Set(),
|
|
309
|
+
eventTimer: null,
|
|
310
|
+
flushing: false,
|
|
311
|
+
retryPending: false,
|
|
312
|
+
};
|
|
313
|
+
this.bySession.set(agent.session.id, state);
|
|
314
|
+
void this.poll(state);
|
|
315
|
+
void this.watchInteractions(state);
|
|
316
|
+
return registration;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Re-register after the bridge forgot this channel.
|
|
321
|
+
*
|
|
322
|
+
* A bridge restart, a crash, or a replaced channel all look the same from here:
|
|
323
|
+
* every call 401/404/409. Before this, that state was permanent — the only fix
|
|
324
|
+
* was walking back to the computer and running /phone again — so the agent looks
|
|
325
|
+
* dead from the phone while the desktop is perfectly healthy. Reconnecting keeps
|
|
326
|
+
* the same DSH conversation and the same bridge session.
|
|
327
|
+
*
|
|
328
|
+
* There is deliberately no attempt cap. Stopping the bridge by hand (安全停止) and
|
|
329
|
+
* starting it again is a human-scale pause that easily outlives a minute, and giving
|
|
330
|
+
* up then left the conversation dead until someone walked back to the desktop. The
|
|
331
|
+
* only bound is the interval, so a bridge that stays down is polled at most every
|
|
332
|
+
* `RECONNECT_MAX_MS`, and disposing the conversation still stops the loop.
|
|
333
|
+
*/
|
|
334
|
+
async ensureConnection(state) {
|
|
335
|
+
const lifetime = state.lifetime ?? this.lifetimeFor(state.agent.session.id);
|
|
336
|
+
if (lifetime.signal.aborted) return false;
|
|
337
|
+
if (state.reconnecting) return state.reconnecting;
|
|
338
|
+
state.reconnecting = (async () => {
|
|
339
|
+
let delay = 0;
|
|
340
|
+
let attempt = 0;
|
|
341
|
+
while (!lifetime.signal.aborted) {
|
|
342
|
+
// First attempt is immediate (a bridge restart is usually already over);
|
|
343
|
+
// later ones back off so a permanently dead bridge is not hammered.
|
|
344
|
+
if (delay > 0) await sleep(delay, lifetime.signal);
|
|
345
|
+
delay = Math.min(Math.max(delay * 2, this.reconnectMinMs), this.reconnectMaxMs);
|
|
346
|
+
if (lifetime.signal.aborted) return false;
|
|
347
|
+
attempt += 1;
|
|
348
|
+
try {
|
|
349
|
+
await this.connect(state.agent, { reconnecting: true });
|
|
350
|
+
this.ctx.logger.warn?.(`AgentBridge: reconnected "${state.agent.session.header.title ?? state.agent.session.id}" after the bridge forgot the channel (attempt ${attempt})`);
|
|
351
|
+
return true;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
// One line per attempt would bury the desktop log during a long outage.
|
|
354
|
+
if (attempt === 1 || attempt % 10 === 0) {
|
|
355
|
+
this.ctx.logger.warn?.(`AgentBridge reconnect attempt ${attempt} failed: ${textFromError(error)} (still retrying)`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
})().finally(() => { state.reconnecting = null; });
|
|
361
|
+
return state.reconnecting;
|
|
362
|
+
}
|
|
363
|
+
// Interaction retries live on the *lifetime*, not on the channel state. A reconnect
|
|
364
|
+
// (or a dropped channel) throws the state away; a question that failed to cross the
|
|
365
|
+
// wire must survive that, otherwise the phone sees the card a second after the
|
|
366
|
+
// desktop — or not at all, which is the bug this queue removes.
|
|
367
|
+
#interactionScope(sessionId) {
|
|
368
|
+
let scope = this.interactionScopes.get(sessionId);
|
|
369
|
+
if (!scope) {
|
|
370
|
+
scope = { queue: [], draining: false };
|
|
371
|
+
this.interactionScopes.set(sessionId, scope);
|
|
372
|
+
}
|
|
373
|
+
return scope;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Questions and approvals never reach the session event log with an
|
|
377
|
+
// answerable id, so they are read from DSH's own client stream and answered
|
|
378
|
+
// through `apiProxy.respond`. Same seam the desktop window uses, which is what
|
|
379
|
+
// lets a phone answer a question the desktop is also showing.
|
|
380
|
+
async watchInteractions(state) {
|
|
381
|
+
const events = this.ctx.apiProxy?.events;
|
|
382
|
+
if (typeof events?.mux !== "function") return;
|
|
383
|
+
const sessionId = state.agent.session.id;
|
|
384
|
+
const scope = this.#interactionScope(sessionId);
|
|
385
|
+
// Cards that failed before this channel existed are still owed to the phone.
|
|
386
|
+
void this.#drainInteractions(state, scope, sessionId);
|
|
387
|
+
try {
|
|
388
|
+
for await (const envelope of events.mux({}, state.controller.signal)) {
|
|
389
|
+
if (state.controller.signal.aborted) return;
|
|
390
|
+
const record = interactionRecord(envelope, sessionId);
|
|
391
|
+
if (!record) continue;
|
|
392
|
+
if (this.#claimEcho(state, record)) continue;
|
|
393
|
+
scope.queue.push(record);
|
|
394
|
+
void this.#drainInteractions(state, scope, sessionId);
|
|
395
|
+
}
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (state.controller.signal.aborted) return;
|
|
398
|
+
if (isStaleChannel(error)) {
|
|
399
|
+
await this.ensureConnection(state);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
this.ctx.logger.warn(`AgentBridge interaction stream failed: ${textFromError(error)}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async #drainInteractions(state, scope, sessionId) {
|
|
407
|
+
if (scope.draining) return;
|
|
408
|
+
scope.draining = true;
|
|
409
|
+
try {
|
|
410
|
+
while (scope.queue.length > 0) {
|
|
411
|
+
// Always post through whatever channel owns the conversation right now:
|
|
412
|
+
// a reconnect replaced `state`, and the new channel is the only one that
|
|
413
|
+
// the bridge still knows.
|
|
414
|
+
const current = this.bySession.get(sessionId);
|
|
415
|
+
if (!current || current.controller.signal.aborted) return;
|
|
416
|
+
try {
|
|
417
|
+
await this.postInteractions(current, scope.queue[0]);
|
|
418
|
+
scope.queue.shift();
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (this.bySession.get(sessionId) !== current) return; // replaced; the new drain takes over
|
|
421
|
+
if (isStaleChannel(error)) {
|
|
422
|
+
await this.ensureConnection(current);
|
|
423
|
+
return; // the new state's watchInteractions drains this scope
|
|
424
|
+
}
|
|
425
|
+
// Keep the failed card at the head and retry it: losing it is the bug
|
|
426
|
+
// this queue exists to remove.
|
|
427
|
+
this.ctx.logger.warn(`AgentBridge interaction sync failed: ${textFromError(error)} (kept in the queue)`);
|
|
428
|
+
await sleep(1_000, current.controller.signal);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} finally {
|
|
432
|
+
scope.draining = false;
|
|
433
|
+
// A newer channel may have taken over while we were waiting; whoever owns
|
|
434
|
+
// the conversation now finishes the queue.
|
|
435
|
+
if (scope.queue.length > 0 && this.interactionScopes.has(sessionId)) {
|
|
436
|
+
const current = this.bySession.get(sessionId);
|
|
437
|
+
if (current) void this.#drainInteractions(current, scope, sessionId);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Drop the resolution echo of an interaction this phone answered itself.
|
|
443
|
+
#claimEcho(state, record) {
|
|
444
|
+
if (record.kind === "question-resolved" && state.answered.has(record.requestId)) {
|
|
445
|
+
state.answered.delete(record.requestId);
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
if (record.kind === "approval-resolved" && state.answered.has(`${APPROVAL_PREFIX}${record.dshApprovalId}`)) {
|
|
449
|
+
state.answered.delete(`${APPROVAL_PREFIX}${record.dshApprovalId}`);
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async postInteractions(state, record) {
|
|
456
|
+
if (!state.channel.interactionsPath) return;
|
|
457
|
+
const response = await fetch(`${state.origin}${state.channel.interactionsPath}`, {
|
|
458
|
+
method: "POST",
|
|
459
|
+
headers: {
|
|
460
|
+
authorization: `Bearer ${state.channel.token}`,
|
|
461
|
+
"content-type": "application/json",
|
|
462
|
+
},
|
|
463
|
+
body: JSON.stringify({ interactions: [record] }),
|
|
464
|
+
signal: state.controller.signal,
|
|
465
|
+
});
|
|
466
|
+
await readResponse(response);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async respond(rpcId, result) {
|
|
470
|
+
if (typeof this.ctx.apiProxy?.respond !== "function") throw new Error("This DSH Desktop build cannot answer questions from the phone");
|
|
471
|
+
return await this.ctx.apiProxy.respond({ type: "client-response", rpcId, result });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async modelDirectory(agent) {
|
|
475
|
+
const current = selectionFromAgent(agent);
|
|
476
|
+
try {
|
|
477
|
+
const response = await this.ctx.apiProxy.sessions.models({
|
|
478
|
+
rpcId: randomUUID(),
|
|
479
|
+
payload: { sessionId: agent.session.id },
|
|
480
|
+
});
|
|
481
|
+
const result = response?.result;
|
|
482
|
+
if (!result?.ok) {
|
|
483
|
+
if (result?.error?.code === "session-not-found") {
|
|
484
|
+
return await this.hostModelDirectory(current, result.error.message);
|
|
485
|
+
}
|
|
486
|
+
throw new Error(result?.error?.message || "DSH Desktop did not provide its model directory");
|
|
487
|
+
}
|
|
488
|
+
return result.value;
|
|
489
|
+
} catch (error) {
|
|
490
|
+
return {
|
|
491
|
+
current,
|
|
492
|
+
routable: false,
|
|
493
|
+
groups: [],
|
|
494
|
+
failures: [{ id: "dsh-model-directory", name: "DSH Desktop", message: textFromError(error) }],
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async hostModelDirectory(current, sessionError) {
|
|
500
|
+
const response = await this.ctx.apiProxy.llm.models({ rpcId: randomUUID(), payload: {} });
|
|
501
|
+
const result = response?.result;
|
|
502
|
+
if (!result?.ok) throw new Error(result?.error?.message || "DSH Desktop did not provide its host model directory");
|
|
503
|
+
return compactModelDirectory(result.value, current, [{
|
|
504
|
+
id: "dsh-session-models",
|
|
505
|
+
name: "DSH Desktop",
|
|
506
|
+
message: `当前 UI 会话不在 session.models 注册表中,已改用 DSH 全局模型目录:${sessionError}`,
|
|
507
|
+
}]);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
liveModelSelection(agent) {
|
|
511
|
+
const existing = this.liveModelSelections.get(agent);
|
|
512
|
+
if (existing) return existing;
|
|
513
|
+
const selection = { current: selectionFromAgent(agent), assembled: undefined };
|
|
514
|
+
agent.ctx.on("system-prompt/assemble", async (_assembly, _context, next) => {
|
|
515
|
+
const selected = selection.current;
|
|
516
|
+
const assembled = await next();
|
|
517
|
+
selection.assembled = selected;
|
|
518
|
+
if (!selected) return assembled;
|
|
519
|
+
return { ...assembled, variables: { ...assembled.variables, provider: selected.provider, model: selected.model } };
|
|
520
|
+
});
|
|
521
|
+
agent.ctx.on("agent/request", async (_payload, next) => {
|
|
522
|
+
const resolved = await next();
|
|
523
|
+
const selected = selection.assembled;
|
|
524
|
+
if (!selected) return resolved;
|
|
525
|
+
const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved;
|
|
526
|
+
return {
|
|
527
|
+
...withoutInheritedEffort,
|
|
528
|
+
provider: selected.provider,
|
|
529
|
+
model: selected.model,
|
|
530
|
+
...(selected.reasoningEffort ? { reasoningEffort: selected.reasoningEffort } : {}),
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
this.liveModelSelections.set(agent, selection);
|
|
534
|
+
return selection;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async selectModel(agent, requested) {
|
|
538
|
+
const response = await this.ctx.apiProxy.sessions.selectModel({
|
|
539
|
+
rpcId: randomUUID(),
|
|
540
|
+
payload: { sessionId: agent.session.id, ...requested },
|
|
541
|
+
});
|
|
542
|
+
const result = response?.result;
|
|
543
|
+
if (result?.ok) return result.value.selected;
|
|
544
|
+
if (result?.error?.code !== "session-not-found") {
|
|
545
|
+
throw new Error(result?.error?.message || "DSH Desktop rejected the model selection");
|
|
546
|
+
}
|
|
547
|
+
const resolved = await this.ctx.llm.resolveCallConfig(requested);
|
|
548
|
+
const selected = {
|
|
549
|
+
provider: resolved.provider,
|
|
550
|
+
model: resolved.model,
|
|
551
|
+
...(resolved.reasoningEffort ? { reasoningEffort: resolved.reasoningEffort } : {}),
|
|
552
|
+
};
|
|
553
|
+
this.liveModelSelection(agent).current = selected;
|
|
554
|
+
return selected;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async reportCommandResult(state, requestId, result) {
|
|
558
|
+
const response = await fetch(`${state.origin}${state.channel.resultsPath}`, {
|
|
559
|
+
method: "POST",
|
|
560
|
+
headers: {
|
|
561
|
+
authorization: `Bearer ${state.channel.token}`,
|
|
562
|
+
"content-type": "application/json",
|
|
563
|
+
},
|
|
564
|
+
body: JSON.stringify({ requestId, result }),
|
|
565
|
+
signal: state.controller.signal,
|
|
566
|
+
});
|
|
567
|
+
await readResponse(response);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Open a new conversation inside this desktop app and hand it to the phone.
|
|
572
|
+
*
|
|
573
|
+
* The conversation has to be a real DSH session, so it is created through the
|
|
574
|
+
* agent service rather than faked in the bridge. Registering it against the
|
|
575
|
+
* bridge session the phone already opened is what resolves the phone's create
|
|
576
|
+
* call; if anything here fails the reason travels back as the command result.
|
|
577
|
+
*/
|
|
578
|
+
async createSession(state, command) {
|
|
579
|
+
try {
|
|
580
|
+
// Read defensively: a build that does not inject `agents` throws on the bare
|
|
581
|
+
// property access, and that raw Cordis error was all the phone ever saw. The
|
|
582
|
+
// try/catch keeps the explanatory error reachable in that case too.
|
|
583
|
+
let service = null;
|
|
584
|
+
try {
|
|
585
|
+
service = this.ctx.agents ?? null;
|
|
586
|
+
} catch {
|
|
587
|
+
service = null;
|
|
588
|
+
}
|
|
589
|
+
const create = typeof service?.create === "function" ? service.create.bind(service) : null;
|
|
590
|
+
if (!create) throw new Error("这个 DSH Desktop 版本不支持从手机新建会话(缺少 ctx.agents.create)");
|
|
591
|
+
const cwd = typeof command.cwd === "string" && command.cwd ? command.cwd : state.agent.session.header.cwd;
|
|
592
|
+
if (!cwd) throw new Error("这个会话没有项目目录,无法新建");
|
|
593
|
+
const sessionId = `session-${randomUUID()}`;
|
|
594
|
+
const options = state.agent.options ?? {};
|
|
595
|
+
const handle = await create({
|
|
596
|
+
sessionId,
|
|
597
|
+
meta: { cwd },
|
|
598
|
+
...(options.provider && options.model
|
|
599
|
+
? { agentOptions: { ...options, provider: options.provider, model: options.model } }
|
|
600
|
+
: {}),
|
|
601
|
+
});
|
|
602
|
+
const agent = handle?.agent;
|
|
603
|
+
if (!agent) throw new Error("DSH Desktop 没有返回新建的会话");
|
|
604
|
+
await this.connect(agent, { bridgeSessionId: command.bridgeSessionId });
|
|
605
|
+
await this.reportCommandResult(state, command.requestId, { ok: true, sessionId: agent.session.id });
|
|
606
|
+
} catch (error) {
|
|
607
|
+
await this.reportCommandResult(state, command.requestId, { ok: false, error: textFromError(error) }).catch(() => undefined);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async answerQuestion(state, command) {
|
|
612
|
+
const cancelled = command.kind === "cancel-question";
|
|
613
|
+
const answers = Array.isArray(command.answers) ? command.answers : [];
|
|
614
|
+
// Claim the rpc id before answering: DSH broadcasts `question/resolved`
|
|
615
|
+
// while `respond()` is still settling, and the echo must not be forwarded
|
|
616
|
+
// as a second resolution for this same card.
|
|
617
|
+
state.answered.add(command.rpcId);
|
|
618
|
+
try {
|
|
619
|
+
const receipt = await this.respond(command.rpcId, cancelled
|
|
620
|
+
? { ok: false, error: { code: "cancelled", message: "the user skipped the question" } }
|
|
621
|
+
: { ok: true, value: { sessionId: state.agent.session.id, answer: { answers } } });
|
|
622
|
+
if (!receipt?.accepted) {
|
|
623
|
+
state.answered.delete(command.rpcId);
|
|
624
|
+
await this.reportCommandResult(state, command.requestId, {
|
|
625
|
+
ok: false,
|
|
626
|
+
error: `DSH Desktop did not accept this answer (${receipt?.reason ?? "unknown"})`,
|
|
627
|
+
});
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
await this.reportCommandResult(state, command.requestId, { ok: true });
|
|
631
|
+
} catch (error) {
|
|
632
|
+
state.answered.delete(command.rpcId);
|
|
633
|
+
await this.reportCommandResult(state, command.requestId, { ok: false, error: textFromError(error) });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async answerApproval(state, command) {
|
|
638
|
+
const key = `${APPROVAL_PREFIX}${command.dshApprovalId}`;
|
|
639
|
+
state.answered.add(key);
|
|
640
|
+
try {
|
|
641
|
+
const receipt = await this.respond(command.rpcId, {
|
|
642
|
+
ok: true,
|
|
643
|
+
value: {
|
|
644
|
+
sessionId: state.agent.session.id,
|
|
645
|
+
approvalId: command.dshApprovalId,
|
|
646
|
+
outcome: command.outcome === "allowed-once" ? "allowed-once" : "rejected",
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
if (!receipt?.accepted) {
|
|
650
|
+
state.answered.delete(key);
|
|
651
|
+
await this.reportCommandResult(state, command.requestId, {
|
|
652
|
+
ok: false,
|
|
653
|
+
error: `DSH Desktop did not accept this decision (${receipt?.reason ?? "unknown"})`,
|
|
654
|
+
});
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
await this.reportCommandResult(state, command.requestId, { ok: true });
|
|
658
|
+
} catch (error) {
|
|
659
|
+
state.answered.delete(key);
|
|
660
|
+
await this.reportCommandResult(state, command.requestId, { ok: false, error: textFromError(error) });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Tear the channel down; the conversation itself lives on (a reconnect reuses it). */
|
|
665
|
+
close(sessionId) {
|
|
666
|
+
const state = this.bySession.get(sessionId);
|
|
667
|
+
if (!state) return;
|
|
668
|
+
state.controller.abort();
|
|
669
|
+
if (state.eventTimer) clearTimeout(state.eventTimer);
|
|
670
|
+
this.bySession.delete(sessionId);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** The desktop let go of the conversation: stop every loop, including reconnect. */
|
|
674
|
+
dispose(sessionId) {
|
|
675
|
+
this.lifetimes.get(sessionId)?.abort();
|
|
676
|
+
this.lifetimes.delete(sessionId);
|
|
677
|
+
// A disposed conversation can no longer answer its own questions, so the queue
|
|
678
|
+
// that would deliver them goes with it.
|
|
679
|
+
this.interactionScopes.delete(sessionId);
|
|
680
|
+
this.close(sessionId);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
captureEvent(session, event) {
|
|
684
|
+
const state = this.bySession.get(session.id);
|
|
685
|
+
if (!state || state.controller.signal.aborted) return;
|
|
686
|
+
const origin = event.type === "user/message" && state.phoneMessageIds.delete(event.data?.id)
|
|
687
|
+
? "phone"
|
|
688
|
+
: "desktop";
|
|
689
|
+
// Shrink before queueing: a giant event wedges the channel if it reaches the
|
|
690
|
+
// wire inside a rejected batch, and then every later flush replays it.
|
|
691
|
+
state.pendingEvents.push({ event: shrinkEventForWire(event), origin });
|
|
692
|
+
this.scheduleFlush(state);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Only one batch may be in flight at a time. Overlapping flushes used to let a
|
|
696
|
+
// newer batch overtake an older one, which reordered a turn's streamed text on
|
|
697
|
+
// the phone and made the bridge treat the older batch as already seen.
|
|
698
|
+
scheduleFlush(state) {
|
|
699
|
+
if (state.flushing || state.retryPending || state.eventTimer || state.controller.signal.aborted) return;
|
|
700
|
+
const delay = state.pendingEvents.length >= 50 ? 0 : EVENT_BATCH_DELAY_MS;
|
|
701
|
+
state.eventTimer = setTimeout(() => {
|
|
702
|
+
state.eventTimer = null;
|
|
703
|
+
void this.flushEvents(state);
|
|
704
|
+
}, delay);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async flushEvents(state) {
|
|
708
|
+
if (state.flushing || state.retryPending || state.controller.signal.aborted || state.pendingEvents.length === 0) return;
|
|
709
|
+
state.flushing = true;
|
|
710
|
+
let failure = null;
|
|
711
|
+
// One batch per POST, chosen by byte budget as well as item count: the bridge
|
|
712
|
+
// refuses a whole batch that overruns its limit, and a rejected batch is replayed
|
|
713
|
+
// in full on the next flush — so an oversized event wedged the channel behind
|
|
714
|
+
// itself in an endless `Request body is too large` loop. Each item here was
|
|
715
|
+
// already shrunk at capture time, so a batch under the budget always gets through.
|
|
716
|
+
while (state.pendingEvents.length > 0 && !state.controller.signal.aborted && !failure) {
|
|
717
|
+
const batch = [];
|
|
718
|
+
let bytes = 12; // `{"events":[`
|
|
719
|
+
for (let count = 0; count < EVENT_MAX_ITEMS && state.pendingEvents.length > 0; count += 1) {
|
|
720
|
+
const item = state.pendingEvents.shift();
|
|
721
|
+
const itemBytes = JSON.stringify(item).length + 1;
|
|
722
|
+
if (bytes + itemBytes > EVENT_POST_BUDGET_BYTES && batch.length > 0) {
|
|
723
|
+
state.pendingEvents.unshift(item);
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
batch.push(item);
|
|
727
|
+
bytes += itemBytes;
|
|
728
|
+
}
|
|
729
|
+
// should not happen: items are shrunk at capture time; a lone item over the
|
|
730
|
+
// budget would wedge the loop, so send it as-is and let the bridge's own cap
|
|
731
|
+
// (1 MB, above the per-item budget) be the safety net.
|
|
732
|
+
if (batch.length === 0) {
|
|
733
|
+
batch.push(state.pendingEvents.shift());
|
|
734
|
+
}
|
|
735
|
+
let failed = null;
|
|
736
|
+
try {
|
|
737
|
+
const response = await fetch(`${state.origin}${state.channel.eventsPath}`, {
|
|
738
|
+
method: "POST",
|
|
739
|
+
headers: {
|
|
740
|
+
authorization: `Bearer ${state.channel.token}`,
|
|
741
|
+
"content-type": "application/json",
|
|
742
|
+
},
|
|
743
|
+
body: JSON.stringify({ events: batch }),
|
|
744
|
+
signal: state.controller.signal,
|
|
745
|
+
});
|
|
746
|
+
await readResponse(response);
|
|
747
|
+
} catch (error) {
|
|
748
|
+
failed = error;
|
|
749
|
+
state.pendingEvents.unshift(...batch);
|
|
750
|
+
}
|
|
751
|
+
if (failed) {
|
|
752
|
+
failure = failed;
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
if (state.controller.signal.aborted) break;
|
|
756
|
+
}
|
|
757
|
+
state.flushing = false;
|
|
758
|
+
if (state.controller.signal.aborted) return;
|
|
759
|
+
|
|
760
|
+
if (failure && isStaleChannel(failure)) {
|
|
761
|
+
await this.ensureConnection(state);
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (failure) {
|
|
765
|
+
this.ctx.logger.warn(`AgentBridge event sync failed: ${textFromError(failure)}`);
|
|
766
|
+
// Hold everything back until the failed batch has been retried, so the
|
|
767
|
+
// bridge never receives a newer batch ahead of an older one.
|
|
768
|
+
state.retryPending = true;
|
|
769
|
+
await sleep(1_000, state.controller.signal);
|
|
770
|
+
state.retryPending = false;
|
|
771
|
+
if (!state.controller.signal.aborted) void this.flushEvents(state);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (state.pendingEvents.length) this.scheduleFlush(state);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async poll(state) {
|
|
778
|
+
while (!state.controller.signal.aborted) {
|
|
779
|
+
try {
|
|
780
|
+
const response = await fetch(`${state.origin}${state.channel.pollPath}?after=${state.cursor}`, {
|
|
781
|
+
headers: { authorization: `Bearer ${state.channel.token}` },
|
|
782
|
+
signal: state.controller.signal,
|
|
783
|
+
});
|
|
784
|
+
const body = await readResponse(response);
|
|
785
|
+
for (const command of body.commands || []) {
|
|
786
|
+
if (command.kind === "message") {
|
|
787
|
+
const message = createUserMessage(command.text);
|
|
788
|
+
state.phoneMessageIds.add(message.id);
|
|
789
|
+
state.agent.followup(message);
|
|
790
|
+
} else if (command.kind === "cancel") {
|
|
791
|
+
state.agent.cancel({ kind: "user" }, { keepInbox: true });
|
|
792
|
+
} else if (command.kind === "select-model") {
|
|
793
|
+
try {
|
|
794
|
+
const selected = await this.selectModel(state.agent, {
|
|
795
|
+
provider: command.selection?.provider,
|
|
796
|
+
model: command.selection?.model,
|
|
797
|
+
...(command.selection?.reasoningEffort ? { reasoningEffort: command.selection.reasoningEffort } : {}),
|
|
798
|
+
});
|
|
799
|
+
await this.reportCommandResult(state, command.requestId, { ok: true, selected });
|
|
800
|
+
} catch (error) {
|
|
801
|
+
await this.reportCommandResult(state, command.requestId, { ok: false, error: textFromError(error) });
|
|
802
|
+
}
|
|
803
|
+
} else if (command.kind === "answer-question" || command.kind === "cancel-question") {
|
|
804
|
+
await this.answerQuestion(state, command);
|
|
805
|
+
} else if (command.kind === "create-session") {
|
|
806
|
+
await this.createSession(state, command);
|
|
807
|
+
} else if (command.kind === "answer-approval") {
|
|
808
|
+
await this.answerApproval(state, command);
|
|
809
|
+
}
|
|
810
|
+
state.cursor = Math.max(state.cursor, command.seq);
|
|
811
|
+
}
|
|
812
|
+
} catch (error) {
|
|
813
|
+
if (state.controller.signal.aborted) return;
|
|
814
|
+
if (isStaleChannel(error)) {
|
|
815
|
+
await this.ensureConnection(state);
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
this.ctx.logger.warn(`AgentBridge command polling failed: ${textFromError(error)}`);
|
|
819
|
+
}
|
|
820
|
+
await sleep(POLL_DELAY_MS, state.controller.signal);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function apply(ctx) {
|
|
826
|
+
const connections = new PhoneConnections(ctx);
|
|
827
|
+
ctx.commands.register({
|
|
828
|
+
name: "phone",
|
|
829
|
+
description: "pair this exact DSH Desktop conversation with a phone",
|
|
830
|
+
recordInput: false,
|
|
831
|
+
handler: async ({ agent }) => {
|
|
832
|
+
if (!agent.session.header.cwd) {
|
|
833
|
+
return { kind: "error", text: "当前会话没有项目目录,无法绑定手机。请先在项目文件夹中打开会话。" };
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
const registration = await connections.connect(agent);
|
|
837
|
+
const model = [agent.options.provider, agent.options.model].filter(Boolean).join(" / ") || "当前 DSH 配置";
|
|
838
|
+
return {
|
|
839
|
+
kind: "success",
|
|
840
|
+
text: [
|
|
841
|
+
"手机接管二维码已生成(5 分钟内、仅可使用一次):",
|
|
842
|
+
``,
|
|
843
|
+
registration.pairing.url,
|
|
844
|
+
`项目:${registration.project.cwd}`,
|
|
845
|
+
`会话:${agent.session.id}`,
|
|
846
|
+
`模型:${model}`,
|
|
847
|
+
].join("\n\n"),
|
|
848
|
+
};
|
|
849
|
+
} catch (error) {
|
|
850
|
+
const origin = String(process.env.AGENTBRIDGE_ORIGIN || DEFAULT_ORIGIN).replace(/\/$/, "");
|
|
851
|
+
return {
|
|
852
|
+
kind: "error",
|
|
853
|
+
text: isBridgeAbsent(error)
|
|
854
|
+
? bridgeSetupText(origin)
|
|
855
|
+
: `无法连接 AgentBridge:${textFromError(error)}。请确认本机 ${origin} 正在运行。`,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export { apply, inject, name };
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chenacc/dsh-agentbridge",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "AgentBridge for DSH Desktop: run /phone in a conversation to pair a phone console with that exact live Agent.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"lib/index.js",
|
|
13
|
+
"cordis.patch.yml",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"keywords": [
|
|
18
|
+
"dsh-plugin",
|
|
19
|
+
"deepseek-harness",
|
|
20
|
+
"dsh-desktop",
|
|
21
|
+
"agentbridge",
|
|
22
|
+
"phone",
|
|
23
|
+
"remote-control",
|
|
24
|
+
"qr-pairing"
|
|
25
|
+
],
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/chenacc1/agentbridge.git",
|
|
29
|
+
"directory": "integrations/dsh-phone"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/chenacc1/agentbridge/tree/main/integrations/dsh-phone#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/chenacc1/agentbridge/issues"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"dsh": {
|
|
42
|
+
"bundle": {
|
|
43
|
+
"patch": "./cordis.patch.yml"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@deepseek-ai/dsh-commands": "^0.1.0-rc.7",
|
|
48
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"@deepseek-ai/dsh-commands": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"@deepseek-ai/cordis": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|