@chenacc/agentbridge 0.2.3 → 0.2.5
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/package.json +2 -2
- package/public/app.js +67 -5
- package/public/styles.css +22 -0
- package/public/transcript.js +80 -15
- package/src/notifier.js +13 -0
- package/src/origin-announcer.js +92 -0
- package/src/server.js +60 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chenacc/agentbridge",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Control local coding agents from a phone through a secure WeChat QR web console.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"verify:responsive": "node scripts/verify-responsive.mjs",
|
|
66
66
|
"verify:guide": "node scripts/verify-product-guide.mjs",
|
|
67
67
|
"verify:release": "node scripts/verify-plugin-release.mjs",
|
|
68
|
-
"check": "node --check src/server.js && node --check src/cli.js && node --check src/config.js && node --check src/cc-switch-models.js && node --check src/workspaces/workspace-catalog.js && node --check src/conversations/conversation-catalog.js && node --check src/adapters/capabilities.js && node --check src/adapters/claude.js && node --check src/adapters/codex.js && node --check src/adapters/deepseek-harness.js && node --check src/adapters/dsh-desktop.js && node --check src/dsh-desktop-channels.js && node --check src/event-store.js && node --check src/notifier.js && node --check src/attachments.js && node --check src/session-manager.js && node --check integrations/dsh-phone/lib/index.js && node --check src/tunnel.js && node --check src/remote.js && node --check src/adapters/zcode-native.js && node --check public/adapter-capabilities.js && node --check public/workspace-navigation.js && node --check public/markdown.js && node --check public/highlight.js && node --check public/transcript.js && node --check public/app.js && node --check scripts/verify-workspace-ui.mjs && node --check scripts/verify-message-blocks.mjs && node --check scripts/verify-background-scrim.mjs && node --check scripts/verify-model-control.mjs && node --check scripts/verify-notifications.mjs && node --check scripts/verify-attachments.mjs && node --check scripts/verify-features.mjs && node --check scripts/verify-quick-nav.mjs && node --check scripts/verify-questions.mjs && node --check scripts/verify-responsive.mjs && node --check scripts/verify-product-guide.mjs && node --check scripts/verify-plugin-release.mjs && node --check scripts/capture-screenshots.mjs",
|
|
68
|
+
"check": "node --check src/server.js && node --check src/cli.js && node --check src/config.js && node --check src/cc-switch-models.js && node --check src/workspaces/workspace-catalog.js && node --check src/conversations/conversation-catalog.js && node --check src/adapters/capabilities.js && node --check src/adapters/claude.js && node --check src/adapters/codex.js && node --check src/adapters/deepseek-harness.js && node --check src/adapters/dsh-desktop.js && node --check src/dsh-desktop-channels.js && node --check src/event-store.js && node --check src/notifier.js && node --check src/attachments.js && node --check src/session-manager.js && node --check integrations/dsh-phone/lib/index.js && node --check src/tunnel.js && node --check src/remote.js && node --check src/origin-announcer.js && node --check src/adapters/zcode-native.js && node --check public/adapter-capabilities.js && node --check public/workspace-navigation.js && node --check public/markdown.js && node --check public/highlight.js && node --check public/transcript.js && node --check public/app.js && node --check scripts/verify-workspace-ui.mjs && node --check scripts/verify-message-blocks.mjs && node --check scripts/verify-background-scrim.mjs && node --check scripts/verify-model-control.mjs && node --check scripts/verify-notifications.mjs && node --check scripts/verify-attachments.mjs && node --check scripts/verify-features.mjs && node --check scripts/verify-quick-nav.mjs && node --check scripts/verify-questions.mjs && node --check scripts/verify-responsive.mjs && node --check scripts/verify-product-guide.mjs && node --check scripts/verify-plugin-release.mjs && node --check scripts/capture-screenshots.mjs",
|
|
69
69
|
"verify:remote": "node scripts/verify-remote-tunnel.mjs",
|
|
70
70
|
"capture:shots": "node scripts/capture-screenshots.mjs"
|
|
71
71
|
},
|
package/public/app.js
CHANGED
|
@@ -310,22 +310,84 @@ async function resolveRequest(requestId, body) {
|
|
|
310
310
|
}
|
|
311
311
|
|
|
312
312
|
async function api(path, options = {}) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
313
|
+
let response;
|
|
314
|
+
try {
|
|
315
|
+
response = await fetch(path, {
|
|
316
|
+
credentials: "same-origin",
|
|
317
|
+
...options,
|
|
318
|
+
headers: options.body ? { "content-type": "application/json", ...(options.headers ?? {}) } : options.headers,
|
|
319
|
+
});
|
|
320
|
+
} catch (error) {
|
|
321
|
+
// No HTTP status at all: the origin itself is unreachable — almost always
|
|
322
|
+
// because the tunnel hostname changed and this page is still on the old one.
|
|
323
|
+
noteConnectionFailure("offline");
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
318
326
|
const body = response.headers.get("content-type")?.includes("application/json") ? await response.json() : null;
|
|
319
327
|
if (!response.ok) {
|
|
328
|
+
if (response.status === 401) noteConnectionFailure("unauthorized");
|
|
320
329
|
const error = new Error(body?.error ?? `请求失败 (${response.status})`);
|
|
321
330
|
error.status = response.status;
|
|
322
331
|
error.code = body?.code;
|
|
323
332
|
error.diagnostics = body?.diagnostics;
|
|
324
333
|
throw error;
|
|
325
334
|
}
|
|
335
|
+
noteConnectionSuccess();
|
|
326
336
|
return body;
|
|
327
337
|
}
|
|
328
338
|
|
|
339
|
+
/**
|
|
340
|
+
* A page that cannot reach its bridge must say so.
|
|
341
|
+
*
|
|
342
|
+
* After a tunnel rebuild the phone keeps rendering the last transcript and the
|
|
343
|
+
* connection chip just says 正在重连 — which reads as "still working" while every
|
|
344
|
+
* request is already failing. Two failures in a row is enough evidence to show a
|
|
345
|
+
* banner that explains what to do, because there is nothing else the page can do:
|
|
346
|
+
* the address it was loaded from no longer exists.
|
|
347
|
+
*/
|
|
348
|
+
const connectionWatch = { failures: 0, banner: null };
|
|
349
|
+
|
|
350
|
+
function noteConnectionFailure(kind) {
|
|
351
|
+
connectionWatch.failures += 1;
|
|
352
|
+
if (connectionWatch.failures < 2) return;
|
|
353
|
+
showStaleBanner(kind);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function noteConnectionSuccess() {
|
|
357
|
+
connectionWatch.failures = 0;
|
|
358
|
+
if (connectionWatch.banner) {
|
|
359
|
+
connectionWatch.banner.remove();
|
|
360
|
+
connectionWatch.banner = null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function showStaleBanner(kind) {
|
|
365
|
+
if (connectionWatch.banner) return;
|
|
366
|
+
const banner = document.createElement("div");
|
|
367
|
+
banner.id = "stale-banner";
|
|
368
|
+
banner.setAttribute("role", "alert");
|
|
369
|
+
banner.style.cssText = [
|
|
370
|
+
"position:fixed", "inset:0 0 auto 0", "z-index:9999",
|
|
371
|
+
"padding:12px 16px", "font:14px/1.5 system-ui,-apple-system,sans-serif",
|
|
372
|
+
"background:#b3261e", "color:#fff", "box-shadow:0 2px 8px rgba(0,0,0,.4)",
|
|
373
|
+
].join(";");
|
|
374
|
+
banner.textContent = kind === "unauthorized"
|
|
375
|
+
? "登录状态已失效:这台手机的配对属于旧地址。请在电脑上重新执行 /phone,扫描新的二维码。"
|
|
376
|
+
: "连不上电脑:二维码地址可能已经更换(隧道重建会换域名)。请在电脑上重新执行 /phone 并扫描新二维码。";
|
|
377
|
+
const dismiss = document.createElement("button");
|
|
378
|
+
dismiss.textContent = "知道了";
|
|
379
|
+
dismiss.style.cssText = "margin-left:12px;padding:4px 10px;border:0;border-radius:6px;background:rgba(255,255,255,.2);color:#fff;font:inherit";
|
|
380
|
+
dismiss.addEventListener("click", () => {
|
|
381
|
+
banner.remove();
|
|
382
|
+
connectionWatch.banner = null;
|
|
383
|
+
// Keep counting: a still-broken page will show it again on the next failure.
|
|
384
|
+
connectionWatch.failures = 0;
|
|
385
|
+
});
|
|
386
|
+
banner.appendChild(dismiss);
|
|
387
|
+
document.body.appendChild(banner);
|
|
388
|
+
connectionWatch.banner = banner;
|
|
389
|
+
}
|
|
390
|
+
|
|
329
391
|
function toast(message) {
|
|
330
392
|
const element = $("#toast");
|
|
331
393
|
element.textContent = message;
|
package/public/styles.css
CHANGED
|
@@ -412,6 +412,28 @@ select { appearance: none; background-image: linear-gradient(45deg, transparent
|
|
|
412
412
|
.event-entry.tool .event-body { color: var(--ink-2); font-size: 13px; } /* 底板是浅色玻璃,次级文字不能再用 45% 黑 */
|
|
413
413
|
.event-entry.error .event-body { color: var(--err); }
|
|
414
414
|
|
|
415
|
+
/* 中间过程只占一行:说清在用什么工具做什么,细节折进去。手机上一屏能看更多对话。 */
|
|
416
|
+
.tool-line { margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
417
|
+
.event-entry.tool-compact .event-body,
|
|
418
|
+
.event-entry.step .event-body { padding-top: 1px; }
|
|
419
|
+
.step-detail, .tool-output { margin-top: 4px; }
|
|
420
|
+
.step-detail > summary,
|
|
421
|
+
.tool-output > summary {
|
|
422
|
+
cursor: pointer;
|
|
423
|
+
font-size: 12px;
|
|
424
|
+
color: var(--ink-3, var(--ink-2));
|
|
425
|
+
opacity: .82;
|
|
426
|
+
list-style: none;
|
|
427
|
+
}
|
|
428
|
+
.step-detail > summary::-webkit-details-marker,
|
|
429
|
+
.tool-output > summary::-webkit-details-marker { display: none; }
|
|
430
|
+
.step-detail > summary::before,
|
|
431
|
+
.tool-output > summary::before { content: "▸ "; }
|
|
432
|
+
.step-detail[open] > summary::before,
|
|
433
|
+
.tool-output[open] > summary::before { content: "▾ "; }
|
|
434
|
+
.step-detail .message-markdown,
|
|
435
|
+
.tool-output pre { margin-top: 6px; opacity: .9; font-size: 12px; }
|
|
436
|
+
|
|
415
437
|
/* ---------- 无底板可读性:给字形描边,而不是给文字垫一层遮罩 ---------- */
|
|
416
438
|
/* 文字层底板保持透明(用户要求),可读性全部来自整页遮罩的地板——**不再给字形描边**:
|
|
417
439
|
之前那圈 1px + 2px 的白色 text-shadow 会把每个字的边缘糊掉,这正是"文字很模糊"的来源。
|
package/public/transcript.js
CHANGED
|
@@ -221,26 +221,43 @@ function entryFor(event, label, text, className, supportsMarkdown, { formatMessa
|
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
/**
|
|
224
|
-
* Tool calls get
|
|
225
|
-
*
|
|
226
|
-
*
|
|
224
|
+
* Tool calls get one line that says what is being done, and the raw material
|
|
225
|
+
* behind a disclosure.
|
|
226
|
+
*
|
|
227
|
+
* The phone is read at a glance, on a narrow screen, often while walking: the
|
|
228
|
+
* command and its output are the least useful thing on it. So the row is
|
|
229
|
+
* "调用 Bash · 检查端口占用" and everything else waits behind 详情 — still one tap
|
|
230
|
+
* away for the moment it matters, never in the way.
|
|
227
231
|
*/
|
|
232
|
+
function toolSummary(event) {
|
|
233
|
+
const payload = event.payload ?? {};
|
|
234
|
+
const name = String(payload.type ?? payload.toolName ?? payload.name ?? "工具");
|
|
235
|
+
// "What is it doing" is the title when the Agent gave us one. A raw command is not a
|
|
236
|
+
// summary — the whole point of this row is to keep it out of the reader's way — so the
|
|
237
|
+
// command contributes at most its program name.
|
|
238
|
+
const stated = String(payload.title ?? payload.description ?? "").trim();
|
|
239
|
+
const command = String(payload.command ?? "").trim();
|
|
240
|
+
const program = command ? command.split(/\s+/)[0] : "";
|
|
241
|
+
const purpose = stated
|
|
242
|
+
? (stated.length > 60 ? `${stated.slice(0, 60)}…` : stated)
|
|
243
|
+
: (program ? `${program} …` : "");
|
|
244
|
+
const suffix = payload.status ? ` · ${payload.status}` : "";
|
|
245
|
+
return { name, purpose, suffix };
|
|
246
|
+
}
|
|
247
|
+
|
|
228
248
|
function toolEntry(event, label, text, options) {
|
|
229
249
|
const cached = cachedEntry(options.cache, event);
|
|
230
250
|
if (cached) return cached;
|
|
231
|
-
const body = String(text ?? "");
|
|
232
|
-
const
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
? `<
|
|
251
|
+
const body = String(text ?? "").trim();
|
|
252
|
+
const { name, purpose, suffix } = toolSummary(event);
|
|
253
|
+
const headline = purpose ? `调用 ${name} · ${purpose}${suffix}` : `调用 ${name}${suffix}`;
|
|
254
|
+
const details = body
|
|
255
|
+
? `<details class="tool-output"><summary>详情</summary><pre>${escapeHtml(body)}</pre></details>`
|
|
236
256
|
: "";
|
|
237
|
-
const shown = long
|
|
238
|
-
? `<details class="tool-output"><summary>展开完整输出(${body.length} 字符)</summary><pre>${escapeHtml(body)}</pre></details>`
|
|
239
|
-
: `<div class="message-markdown"><p>${escapeHtml(body)}</p></div>`;
|
|
240
257
|
const entry = {
|
|
241
258
|
key: entryKey(event),
|
|
242
|
-
className: "tool",
|
|
243
|
-
html: `<time class="event-time">${formatTime(event.time, options.formatMessageTime)}</time><div class="event-body"><strong>${escapeHtml(label)}</strong>${
|
|
259
|
+
className: "tool tool-compact",
|
|
260
|
+
html: `<time class="event-time">${formatTime(event.time, options.formatMessageTime)}</time><div class="event-body"><strong>${escapeHtml(label)}</strong><p class="tool-line">${escapeHtml(headline)}</p>${details}</div>`,
|
|
244
261
|
};
|
|
245
262
|
return rememberEntry(options.cache, event, entry);
|
|
246
263
|
}
|
|
@@ -307,6 +324,25 @@ function inputEntry(event, options, settledOutcome) {
|
|
|
307
324
|
return rememberEntry(options.cache, event, entry, cacheKey);
|
|
308
325
|
}
|
|
309
326
|
|
|
327
|
+
/**
|
|
328
|
+
* A row that stays out of the way until asked: the summary line is what the
|
|
329
|
+
* reader needs, the text is one tap down.
|
|
330
|
+
*
|
|
331
|
+
* Intermediate assistant messages are progress, not answers. Rendering them in
|
|
332
|
+
* full turned one request into a wall of overlapping drafts on a phone screen.
|
|
333
|
+
*/
|
|
334
|
+
function collapsedEntry(event, label, summary, text, className, options) {
|
|
335
|
+
const cached = cachedEntry(options.cache, event);
|
|
336
|
+
if (cached) return cached;
|
|
337
|
+
const content = renderMessageMarkdown(String(text ?? ""), options.math ? { math: options.math } : undefined);
|
|
338
|
+
const entry = {
|
|
339
|
+
key: entryKey(event),
|
|
340
|
+
className,
|
|
341
|
+
html: `<time class="event-time">${formatTime(event.time, options.formatMessageTime)}</time><div class="event-body"><strong>${escapeHtml(label)}</strong><details class="step-detail"><summary>${escapeHtml(summary)}</summary><div class="message-markdown">${content}</div></details></div>`,
|
|
342
|
+
};
|
|
343
|
+
return rememberEntry(options.cache, event, entry);
|
|
344
|
+
}
|
|
345
|
+
|
|
310
346
|
/** Turn a session's events into the list of transcript entries to display. */
|
|
311
347
|
export function buildEntries(relevant, options = {}) {
|
|
312
348
|
const statusLabel = options.statusLabel ?? ((kind) => DEFAULT_STATUS_LABELS[kind] ?? kind);
|
|
@@ -321,6 +357,14 @@ export function buildEntries(relevant, options = {}) {
|
|
|
321
357
|
settledInputs.set(event.payload.requestId, event.payload.outcome ?? "answered");
|
|
322
358
|
}
|
|
323
359
|
}
|
|
360
|
+
// Only the last assistant message of a turn is the answer; everything before it is
|
|
361
|
+
// the work that produced it.
|
|
362
|
+
const finalAssistantOfTurn = new Map();
|
|
363
|
+
for (const event of relevant) {
|
|
364
|
+
if (event.kind === "message.completed" && event.payload?.role === "assistant") {
|
|
365
|
+
finalAssistantOfTurn.set(event.turnId, event.eventId ?? entryKey(event));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
324
368
|
|
|
325
369
|
const renderedDeltas = new Set();
|
|
326
370
|
const entries = [];
|
|
@@ -328,7 +372,15 @@ export function buildEntries(relevant, options = {}) {
|
|
|
328
372
|
if (event.kind === "message.delta") {
|
|
329
373
|
if (completedTurns.has(event.turnId) || renderedDeltas.has(event.turnId)) continue;
|
|
330
374
|
renderedDeltas.add(event.turnId);
|
|
331
|
-
|
|
375
|
+
// Live text is progress, so it stays collapsed until the answer itself lands.
|
|
376
|
+
entries.push(collapsedEntry(
|
|
377
|
+
event,
|
|
378
|
+
"Agent 正在回复",
|
|
379
|
+
"正在生成回答…(点开可看实时输出)",
|
|
380
|
+
textByTurn.get(event.turnId) ?? "",
|
|
381
|
+
"assistant streaming",
|
|
382
|
+
settings,
|
|
383
|
+
));
|
|
332
384
|
} else if (event.kind === "message.completed") {
|
|
333
385
|
// An assistant message that carried only tool calls has no text. Rendering it makes
|
|
334
386
|
// an empty "Agent" bubble between the tool rows is pure noise, so skip it — unless it
|
|
@@ -336,7 +388,20 @@ export function buildEntries(relevant, options = {}) {
|
|
|
336
388
|
const messageText = String(event.payload.text ?? "");
|
|
337
389
|
const messageAttachments = Array.isArray(event.payload.attachments) ? event.payload.attachments : [];
|
|
338
390
|
if (!messageText.trim() && messageAttachments.length === 0) continue;
|
|
339
|
-
|
|
391
|
+
if (event.payload.role === "user") {
|
|
392
|
+
entries.push(entryFor(event, "你", messageText, "user", true, settings));
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const isFinal = finalAssistantOfTurn.get(event.turnId) === (event.eventId ?? entryKey(event));
|
|
396
|
+
if (isFinal || messageAttachments.length > 0) {
|
|
397
|
+
// The answer, and anything carrying an attachment: both are content the reader
|
|
398
|
+
// came for, so they render in full.
|
|
399
|
+
entries.push(entryFor(event, "Agent", messageText, "assistant", true, settings));
|
|
400
|
+
} else {
|
|
401
|
+
const lead = messageText.trim().split(/\r?\n/).find((line) => line.trim()) ?? "";
|
|
402
|
+
const summary = lead.length > 48 ? `中间过程 · ${lead.slice(0, 48)}…` : `中间过程 · ${lead || "已处理"}`;
|
|
403
|
+
entries.push(collapsedEntry(event, "Agent", summary, messageText, "assistant step", settings));
|
|
404
|
+
}
|
|
340
405
|
} else if (event.kind === "tool.started" || event.kind === "tool.completed") {
|
|
341
406
|
const label = event.kind.endsWith("started") ? "工具开始" : "工具完成";
|
|
342
407
|
const detail = event.payload.command ? String(event.payload.command) : `${event.payload.type ?? "tool"}${event.payload.status ? ` · ${event.payload.status}` : ""}`;
|
package/src/notifier.js
CHANGED
|
@@ -63,6 +63,19 @@ export function describeNotification(kind, payload = {}, context = {}) {
|
|
|
63
63
|
case "notify.test": {
|
|
64
64
|
return { title: "🔔 AgentBridge 通知测试", text: [`${where}`, "如果你看到这条消息,通知链路是通的。"].filter(Boolean).join("\n") };
|
|
65
65
|
}
|
|
66
|
+
case "bridge.moved": {
|
|
67
|
+
// The one message a phone cannot get any other way: it is still sitting on
|
|
68
|
+
// the old address, so its own page can no longer be told anything.
|
|
69
|
+
return {
|
|
70
|
+
title: "🔄 AgentBridge 地址已更换",
|
|
71
|
+
text: [
|
|
72
|
+
`${where}`,
|
|
73
|
+
"隧道重建后二维码地址变了,手机上的旧链接已经打不开。",
|
|
74
|
+
`新地址:${clip(payload.origin ?? "", 200)}`,
|
|
75
|
+
"在电脑上重新执行 /phone 并扫描新二维码,或直接打开上面的新地址。",
|
|
76
|
+
].filter(Boolean).join("\n"),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
66
79
|
default:
|
|
67
80
|
return { title: DEFAULT_TITLES[kind] ?? "AgentBridge", text: where };
|
|
68
81
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tell an already-paired phone that the bridge moved.
|
|
3
|
+
*
|
|
4
|
+
* A Cloudflare quick tunnel hands out a new hostname every time the tunnel is
|
|
5
|
+
* rebuilt, and the bridge is rebuilt often (desktop restart, tunnel restart). The
|
|
6
|
+
* phone is sitting on the old origin: its page still renders, every request from
|
|
7
|
+
* it fails, and nothing on the desktop says so. The QR code is not much help
|
|
8
|
+
* either — the person is not looking at the desktop any more.
|
|
9
|
+
*
|
|
10
|
+
* So the address change is announced over the channel that still works when the
|
|
11
|
+
* web page cannot: the push notification the user configured. One message per
|
|
12
|
+
* change, only when a device actually exists to tell, and never on the first run
|
|
13
|
+
* (nothing has "moved" yet).
|
|
14
|
+
*
|
|
15
|
+
* Everything that touches the machine is injectable because this decides whether a
|
|
16
|
+
* third party gets a message, which is worth testing without a network.
|
|
17
|
+
*/
|
|
18
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
export function announcedOriginPath(dataDir) {
|
|
22
|
+
return path.join(dataDir, "advertised-origin.json");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function readAnnouncedOrigin(dataDir, { readFile = readFileSync } = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(String(readFile(announcedOriginPath(dataDir), "utf8")));
|
|
28
|
+
return typeof parsed?.origin === "string" ? parsed.origin : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writeAnnouncedOrigin(dataDir, origin, { writeFile = writeFileSync, rename = renameSync, now = () => new Date().toISOString() } = {}) {
|
|
35
|
+
const target = announcedOriginPath(dataDir);
|
|
36
|
+
const temporary = `${target}.tmp`;
|
|
37
|
+
writeFile(temporary, JSON.stringify({ origin, recordedAt: now() }, null, 2), "utf8");
|
|
38
|
+
rename(temporary, target);
|
|
39
|
+
return origin;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Whether this machine has ever recorded an address (i.e. it is not the first run). */
|
|
43
|
+
export function hasAnnouncedBefore(dataDir, options = {}) {
|
|
44
|
+
return readAnnouncedOrigin(dataDir, options) !== null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Compare the address with the last one this bridge announced.
|
|
49
|
+
*
|
|
50
|
+
* Returns `{ announced, reason }`. A failure to notify is not thrown: the bridge is
|
|
51
|
+
* already serving, and a dead webhook must not be able to stop that.
|
|
52
|
+
*/
|
|
53
|
+
export async function announceOriginChange({
|
|
54
|
+
dataDir,
|
|
55
|
+
origin,
|
|
56
|
+
notifier = null,
|
|
57
|
+
deviceCount = 0,
|
|
58
|
+
machineName = "",
|
|
59
|
+
log = () => {},
|
|
60
|
+
read = readAnnouncedOrigin,
|
|
61
|
+
write = writeAnnouncedOrigin,
|
|
62
|
+
} = {}) {
|
|
63
|
+
if (!dataDir || !origin) return { announced: false, reason: "missing-argument" };
|
|
64
|
+
|
|
65
|
+
const previous = read(dataDir);
|
|
66
|
+
if (previous === origin) return { announced: false, reason: "unchanged" };
|
|
67
|
+
|
|
68
|
+
// Record first: a crash mid-announcement must not turn one tunnel restart into a
|
|
69
|
+
// notification loop on every subsequent launch.
|
|
70
|
+
write(dataDir, origin);
|
|
71
|
+
|
|
72
|
+
if (!previous) {
|
|
73
|
+
log(`Recorded the first advertised address: ${origin}`);
|
|
74
|
+
return { announced: false, reason: "first-run" };
|
|
75
|
+
}
|
|
76
|
+
if (deviceCount <= 0) return { announced: false, reason: "no-devices" };
|
|
77
|
+
if (!notifier?.enabled) return { announced: false, reason: "notifications-disabled" };
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const result = await notifier.send(
|
|
81
|
+
"bridge.moved",
|
|
82
|
+
{ previous, origin },
|
|
83
|
+
{ sessionTitle: machineName || "AgentBridge", adapterId: "agentbridge" },
|
|
84
|
+
);
|
|
85
|
+
if (result?.sent) log(`Announced the new address to ${deviceCount} paired device(s): ${origin}`);
|
|
86
|
+
else log(`Address change not announced: ${result?.reason ?? "unknown"}`);
|
|
87
|
+
return { announced: result?.sent === true, reason: result?.reason ?? null, previous };
|
|
88
|
+
} catch (error) {
|
|
89
|
+
log(`Address change announcement failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
90
|
+
return { announced: false, reason: "send-failed", previous };
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import QRCode from "qrcode";
|
|
7
7
|
import { loadConfig, isLoopback } from "./config.js";
|
|
8
|
+
import { announceOriginChange } from "./origin-announcer.js";
|
|
8
9
|
import { EventStore } from "./event-store.js";
|
|
9
10
|
import { PairingManager } from "./pairing.js";
|
|
10
11
|
import { AdapterRegistry } from "./adapter-registry.js";
|
|
@@ -33,6 +34,9 @@ function json(res, statusCode, value, headers = {}) {
|
|
|
33
34
|
res.writeHead(statusCode, {
|
|
34
35
|
"content-type": "application/json; charset=utf-8",
|
|
35
36
|
"cache-control": "no-store",
|
|
37
|
+
// A renewal cookie rides every authenticated JSON response when the session
|
|
38
|
+
// was just extended; explicit per-handler cookies (logout, revoke) still win.
|
|
39
|
+
...(res.renewCookie ? { "set-cookie": res.renewCookie } : {}),
|
|
36
40
|
...headers,
|
|
37
41
|
});
|
|
38
42
|
res.end(JSON.stringify(value));
|
|
@@ -601,6 +605,16 @@ export async function createBridge(options = {}) {
|
|
|
601
605
|
// must not consume a pairing code, mint a device, or take the lease away
|
|
602
606
|
// from the phone that is already driving this conversation.
|
|
603
607
|
const reconnecting = body.reconnect === true;
|
|
608
|
+
// An automatic restore (the desktop app just restarted and is opening a
|
|
609
|
+
// conversation the phone already owns) passes `existingOnly`: it must bind
|
|
610
|
+
// the bridge session that already exists and never create a new one, or
|
|
611
|
+
// simply opening a never-paired conversation would fill the phone's
|
|
612
|
+
// workspace drawer with sessions nobody asked for.
|
|
613
|
+
if (reconnecting && body.existingOnly === true
|
|
614
|
+
&& !sessions.findByProvider("deepseek-harness-desktop", providerSessionId)) {
|
|
615
|
+
dshDesktopChannels.revoke(registration.id);
|
|
616
|
+
throw Object.assign(new Error("No AgentBridge session is bound to this conversation"), { statusCode: 404 });
|
|
617
|
+
}
|
|
604
618
|
const device = reconnecting ? null : pairing.createSession("DSH Desktop /phone");
|
|
605
619
|
let session;
|
|
606
620
|
try {
|
|
@@ -638,6 +652,27 @@ export async function createBridge(options = {}) {
|
|
|
638
652
|
});
|
|
639
653
|
}
|
|
640
654
|
|
|
655
|
+
// Which conversations should a freshly started desktop companion re-attach?
|
|
656
|
+
//
|
|
657
|
+
// After DSH Desktop updates or restarts, the bridge still has the sessions
|
|
658
|
+
// and history the phone was using, but the desktop-side channel is gone and
|
|
659
|
+
// nothing re-registers it: the phone reads "桌面端未连接" until someone runs
|
|
660
|
+
// /phone again. The companion asks this endpoint on startup and whenever a
|
|
661
|
+
// conversation is opened, and re-registers only these — a never-paired
|
|
662
|
+
// conversation stays silent instead of appearing as a new session.
|
|
663
|
+
if (req.method === "GET" && url.pathname === "/api/local/dsh-desktop/expected") {
|
|
664
|
+
if (!isLoopback(req.socket.remoteAddress)) throw Object.assign(new Error("Local access only"), { statusCode: 403 });
|
|
665
|
+
const providerSessionIds = [];
|
|
666
|
+
for (const session of sessions.list()) {
|
|
667
|
+
if (session.adapterId !== "deepseek-harness-desktop") continue;
|
|
668
|
+
const providerSessionId = session.providerSessionId;
|
|
669
|
+
if (!providerSessionId) continue;
|
|
670
|
+
if (dshDesktopChannels.isOnline(providerSessionId)) continue;
|
|
671
|
+
providerSessionIds.push(providerSessionId);
|
|
672
|
+
}
|
|
673
|
+
return json(res, 200, { providerSessionIds: [...new Set(providerSessionIds)] });
|
|
674
|
+
}
|
|
675
|
+
|
|
641
676
|
if (req.method === "POST" && url.pathname === "/api/pairings/exchange") {
|
|
642
677
|
requireAccessKey(req, url, config);
|
|
643
678
|
requireSameOrigin(req);
|
|
@@ -703,6 +738,19 @@ export async function createBridge(options = {}) {
|
|
|
703
738
|
const device = pairing.authenticate(requestToken(req));
|
|
704
739
|
if (!device) throw Object.assign(new Error("Authentication required"), { statusCode: 401 });
|
|
705
740
|
|
|
741
|
+
// Sliding renewal: a phone that keeps using the console is never asked to
|
|
742
|
+
// scan a QR again merely because its original window ran out. The refreshed
|
|
743
|
+
// cookie rides whichever response this request produces (JSON or SSE), so
|
|
744
|
+
// opening the app once is enough for the device to stay paired. Idle
|
|
745
|
+
// sessions still expire on the normal TTL.
|
|
746
|
+
if (pairing.renewIfNeeded(device.id)) {
|
|
747
|
+
res.renewCookie = sessionCookie(
|
|
748
|
+
requestToken(req),
|
|
749
|
+
config.secureCookies && !isLoopback(req.socket.remoteAddress),
|
|
750
|
+
config.sessionTtlMs,
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
706
754
|
if (req.method === "GET" && url.pathname === "/api/devices") {
|
|
707
755
|
return json(res, 200, { devices: pairing.listDevices(), currentDeviceId: device.id });
|
|
708
756
|
}
|
|
@@ -820,6 +868,7 @@ export async function createBridge(options = {}) {
|
|
|
820
868
|
"cache-control": "no-cache, no-transform",
|
|
821
869
|
connection: "keep-alive",
|
|
822
870
|
"x-accel-buffering": "no",
|
|
871
|
+
...(res.renewCookie ? { "set-cookie": res.renewCookie } : {}),
|
|
823
872
|
});
|
|
824
873
|
res.write(": connected\n\n");
|
|
825
874
|
const writeEvent = (event) => {
|
|
@@ -1005,6 +1054,17 @@ export async function startBridge() {
|
|
|
1005
1054
|
+ "(set it in the desktop console, or point AGENTBRIDGE_NOTIFY_IMPORT at an older .agentbridge folder)",
|
|
1006
1055
|
);
|
|
1007
1056
|
}
|
|
1057
|
+
// A quick tunnel gets a new hostname every time it is rebuilt, and the phone that
|
|
1058
|
+
// is already paired is sitting on the old one: its own page can no longer be told
|
|
1059
|
+
// anything, so the change goes out over the push channel instead.
|
|
1060
|
+
void announceOriginChange({
|
|
1061
|
+
dataDir: bridge.config.dataDir,
|
|
1062
|
+
origin: bridge.config.advertisedOrigin,
|
|
1063
|
+
notifier: bridge.notifier,
|
|
1064
|
+
deviceCount: bridge.pairing.listDevices().length,
|
|
1065
|
+
machineName: bridge.config.machineName,
|
|
1066
|
+
log: (message) => console.log(message),
|
|
1067
|
+
}).catch((error) => console.warn(`Address announcement skipped: ${error.message}`));
|
|
1008
1068
|
// Silence here used to mean a QR encoding an address the phone could never reach.
|
|
1009
1069
|
if (new URL(bridge.config.advertisedOrigin).hostname === "127.0.0.1") {
|
|
1010
1070
|
console.warn(
|