@xyagent/cli 0.0.1 → 1.1.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/README.md +42 -0
- package/bin/agentlink +1002 -245
- package/bin/agentlink-agent +0 -0
- package/bin/agentlink-mcp-stdio +0 -0
- package/package.json +5 -3
- package/src/core.mjs +26 -0
- package/src/tunnel_service.mjs +17 -1
- package/src-ext/bin.mjs +12 -12
- package/src-ext/commands/agent.mjs +33 -10
- package/src-ext/commands/pair.mjs +122 -23
- package/src-ext/commands/service.mjs +1 -1
- package/src-ext/core/activeRuns.mjs +26 -9
- package/src-ext/core/agentlinkToolsBootstrap.mjs +1 -1
- package/src-ext/core/autoTunnelDetector.mjs +6 -1
- package/src-ext/core/autoTunnelWiring.mjs +1 -1
- package/src-ext/core/bridgeSelfUninstall.mjs +1 -1
- package/src-ext/core/defaultWorkspace.mjs +43 -13
- package/src-ext/core/defaultWorkspaceSync.mjs +20 -0
- package/src-ext/core/installationIdentity.mjs +94 -0
- package/src-ext/core/mcpRuntimeFanout.mjs +23 -1
- package/src-ext/core/pairCodeClient.mjs +48 -8
- package/src-ext/core/pairInventory.mjs +31 -6
- package/src-ext/core/relayWorker.mjs +22 -2
- package/src-ext/core/runtimeRegistry.mjs +180 -0
- package/src-ext/core/scanDeeplink.mjs +67 -0
- package/src-ext/core/scanPairFlow.mjs +28 -0
- package/src-ext/core/scanQrRenderer.mjs +40 -0
- package/src-ext/core/scanWorkspaces.mjs +163 -23
- package/src-ext/core/unifiedDispatchHandler.mjs +12 -5
- package/src-ext/core/usageReporter.mjs +1 -2
- package/src-ext/openclaw-plugin/envelope-builder.cjs +2 -2
- package/src-ext/openclaw-plugin/todoTranslatorUtils.cjs +1 -1
- package/src-ext/runtime/_shared/bridgedSessionLedger.mjs +60 -0
- package/src-ext/runtime/_shared/claudeSchemaEvent.mjs +49 -0
- package/src-ext/runtime/_shared/headlessCliBridge.mjs +168 -0
- package/src-ext/runtime/_shared/jsonMcpConfigAdapter.mjs +70 -0
- package/src-ext/runtime/_shared/ndjsonProcess.mjs +141 -0
- package/src-ext/runtime/_shared/relayObjectToBlock.mjs +9 -11
- package/src-ext/runtime/_shared/resolveWorkspaceCwd.mjs +47 -0
- package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
- package/src-ext/runtime/_shared/todoTranslatorUtils.mjs +1 -1
- package/src-ext/runtime/claude/handleRequest.mjs +15 -37
- package/src-ext/runtime/claude/launcher.mjs +0 -6
- package/src-ext/runtime/claude/stdoutParser.mjs +8 -3
- package/src-ext/runtime/codebuddy/index.mjs +41 -0
- package/src-ext/runtime/codex/handleRequest.mjs +13 -35
- package/src-ext/runtime/cursor/index.mjs +46 -0
- package/src-ext/runtime/cursor/mcpConfigAdapter.mjs +15 -0
- package/src-ext/runtime/deepagents/preflight.mjs +57 -0
- package/src-ext/runtime/hermes/envSetup.mjs +22 -8
- package/src-ext/runtime/hermes/gatewayManager.mjs +239 -3
- package/src-ext/runtime/hermes/handleRequest.mjs +13 -0
- package/src-ext/runtime/hermes/httpBackend.mjs +12 -1
- package/src-ext/runtime/hermes/index.mjs +1 -1
- package/src-ext/runtime/hermes/preflight.mjs +2 -1
- package/src-ext/runtime/kimi/index.mjs +100 -0
- package/src-ext/runtime/openclaw/buildOpenclawDaemonInput.mjs +0 -6
- package/src-ext/runtime/openclaw/workspaceContext.mjs +58 -0
- package/src-ext/runtime/opencode/index.mjs +48 -0
- package/src-ext/runtime/opencode/mcpConfigAdapter.mjs +15 -0
- package/src-ext/runtime/opencode/preflight.mjs +72 -0
- package/src-ext/runtime/qwen/index.mjs +42 -0
- package/src-ext/service/serviceManager.mjs +120 -42
- package/src-shared/envelope_builder.mjs +2 -2
- package/src-ext/runtime/picoclaw/constants.mjs +0 -39
- package/src-ext/runtime/picoclaw/handleRequest.mjs +0 -289
- package/src-ext/runtime/picoclaw/index.mjs +0 -314
- package/src-ext/runtime/picoclaw/pairFlow.mjs +0 -224
- package/src-ext/runtime/picoclaw/state.mjs +0 -78
- package/src-ext/runtime/picoclaw/todoTranslator.mjs +0 -67
- package/src-ext/runtime/picoclaw/translator.mjs +0 -272
- package/src-ext/runtime/picoclaw/wsClient.mjs +0 -129
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
// 巴布(PicoClaw)桥接入口 — daemon 主循环。
|
|
2
|
-
//
|
|
3
|
-
// 与 hermes/claude/codex 的差异:
|
|
4
|
-
// - 配对流程不是 `agentlink pair <code>` 由用户在 cli 输入,而是 daemon 启动后
|
|
5
|
-
// 自动调 server `/v1/pair-codes/picoclaw/create` 拿 QR PNG → 写到本地约定路径
|
|
6
|
-
// 由屏幕显示模块独立读取 → App 扫码 → server consume → cli 通过 fetch_token
|
|
7
|
-
// poll status 拿到 bridge_token。全程零命令行交互。
|
|
8
|
-
//
|
|
9
|
-
// - 与本地的 PicoClaw daemon (默认 ws://127.0.0.1:18790/pico/ws) 建立 WebSocket
|
|
10
|
-
// 连接,把用户消息翻译为 message.send 发出去;接收 message.create / .update
|
|
11
|
-
// 翻译成 OpenClaw 风格 native event 透传给服务端 long-poll 通道。
|
|
12
|
-
//
|
|
13
|
-
// - daemon 主循环:
|
|
14
|
-
// 1. readState() 拿 bridge_token
|
|
15
|
-
// 2. 没有 → pair flow → writeState
|
|
16
|
-
// 3. 有 → 启 worker(long-poll + WS),失败/被撤销时 clearState 回到 1
|
|
17
|
-
//
|
|
18
|
-
// 使用入口: `agentlink-agent picoclaw`
|
|
19
|
-
|
|
20
|
-
import process from "node:process";
|
|
21
|
-
|
|
22
|
-
import { createLogger } from "../../../src-shared/logger.mjs";
|
|
23
|
-
import { newCid } from "../../../src-shared/correlation.mjs";
|
|
24
|
-
import { createSessionSlot, SESSION_STATES } from "../../core/sessionSlot.mjs";
|
|
25
|
-
import { createRelayWorker } from "../../core/relayWorker.mjs";
|
|
26
|
-
import { installAutoTunnel } from "../../core/autoTunnelWiring.mjs";
|
|
27
|
-
import { createUsageReporter } from "../../core/usageReporter.mjs";
|
|
28
|
-
import { resolveRelayUrl } from "../../core/pairCodeClient.mjs";
|
|
29
|
-
|
|
30
|
-
import {
|
|
31
|
-
PAIR_STATUS_POLL_INTERVAL_SEC,
|
|
32
|
-
PAIR_STATUS_POLL_MAX_SEC,
|
|
33
|
-
RUNTIME_NAME,
|
|
34
|
-
WS_RECONNECT_INITIAL_MS,
|
|
35
|
-
WS_RECONNECT_MAX_MS,
|
|
36
|
-
} from "./constants.mjs";
|
|
37
|
-
import { readState, writeState, clearState } from "./state.mjs";
|
|
38
|
-
import {
|
|
39
|
-
createPairCode,
|
|
40
|
-
pollPairStatus,
|
|
41
|
-
writeQRImage,
|
|
42
|
-
clearQRImage,
|
|
43
|
-
writeDevPairInfo,
|
|
44
|
-
clearDevPairInfo,
|
|
45
|
-
} from "./pairFlow.mjs";
|
|
46
|
-
import { connect as wsConnect } from "./wsClient.mjs";
|
|
47
|
-
import { translatePicoMessage } from "./translator.mjs";
|
|
48
|
-
import { createPicoHandleRequest } from "./handleRequest.mjs";
|
|
49
|
-
import { createPicoTodoTranslator } from "./todoTranslator.mjs";
|
|
50
|
-
|
|
51
|
-
export async function runPicoclawBridge({ options, positional, log }) {
|
|
52
|
-
const relayBase = resolveRelayUrl(options.relay);
|
|
53
|
-
const log0 = log || createLogger({ comp: "bridge", cid: newCid() }).child({ rt: RUNTIME_NAME });
|
|
54
|
-
log0.info("bridge.preflight.pass", "picoclaw daemon entering main loop", { relay: relayBase });
|
|
55
|
-
|
|
56
|
-
let shuttingDown = false;
|
|
57
|
-
process.once("SIGINT", () => { shuttingDown = true; });
|
|
58
|
-
process.once("SIGTERM", () => { shuttingDown = true; });
|
|
59
|
-
|
|
60
|
-
// daemon 主循环 — 每次循环代表一次"配对 + 长轮询"周期。
|
|
61
|
-
// 解绑或鉴权失效会导致内层 worker 退出并清 state,主循环回到 0 重新生成 QR。
|
|
62
|
-
while (!shuttingDown) {
|
|
63
|
-
let st = readState();
|
|
64
|
-
if (!st) {
|
|
65
|
-
// 没绑定 → 走 pair flow
|
|
66
|
-
try {
|
|
67
|
-
st = await runPairFlow({ relayBase, options, log: log0 });
|
|
68
|
-
} catch (err) {
|
|
69
|
-
log0.warn("picoclaw.pair.failed", "pair flow failed; retry in 5s", { err: String(err?.message || err) });
|
|
70
|
-
await sleep(5000);
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
writeState(st);
|
|
74
|
-
log0.info("picoclaw.pair.persisted", "bridge_token saved, entering worker", { gw_id: st.gw_id });
|
|
75
|
-
}
|
|
76
|
-
// 启 worker — 内部异常或 token 撤销返回 reason
|
|
77
|
-
let exitReason;
|
|
78
|
-
try {
|
|
79
|
-
exitReason = await runWorkerWithBridge({ state: st, log: log0 });
|
|
80
|
-
} catch (err) {
|
|
81
|
-
log0.error("picoclaw.worker.fatal", "worker fatal", { err: String(err?.message || err) });
|
|
82
|
-
exitReason = "fatal";
|
|
83
|
-
}
|
|
84
|
-
log0.info("picoclaw.worker.exited", "worker exited", { reason: exitReason });
|
|
85
|
-
if (exitReason === "revoked" || exitReason === "auth-failed") {
|
|
86
|
-
clearState();
|
|
87
|
-
log0.info("picoclaw.state.cleared", "bridge_token revoked; back to pair loop");
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
if (shuttingDown) break;
|
|
91
|
-
// 网络抖动等 — 不清 state,直接重启 worker
|
|
92
|
-
await sleep(2000);
|
|
93
|
-
}
|
|
94
|
-
log0.info("bridge.shutdown", "picoclaw daemon exiting");
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ── pair flow ────────────────────────────────────────────────────
|
|
98
|
-
|
|
99
|
-
async function runPairFlow({ relayBase, options, log }) {
|
|
100
|
-
// 1. 调 server 创建 pair-code
|
|
101
|
-
const created = await createPairCode({
|
|
102
|
-
relayUrl: relayBase,
|
|
103
|
-
label: options.label,
|
|
104
|
-
deviceId: options.deviceId || process.env.PICOCLAW_DEVICE_ID || "",
|
|
105
|
-
fetchImpl: globalThis.fetch,
|
|
106
|
-
log,
|
|
107
|
-
});
|
|
108
|
-
// 2. 写 QR PNG 到约定路径(屏幕模块读)
|
|
109
|
-
const qrPath = writeQRImage(created.qr_png_base64, options.qrPath);
|
|
110
|
-
log.info("picoclaw.pair.qr.written", "qr image written", { path: qrPath, code: created.code });
|
|
111
|
-
// 2.5 dev 测试钩子:把 code + fetch_token + relay + 完整 deeplink 写到本地,
|
|
112
|
-
// 让 e2e 测试脚本 / iOS 模拟器粘贴测试扮演 App 角色用。
|
|
113
|
-
// (仅当 BUBU_DEV_DUMP_PAIR_INFO=1 时生效;生产环境严禁开启)
|
|
114
|
-
const devPath = writeDevPairInfo({
|
|
115
|
-
code: created.code,
|
|
116
|
-
fetch_token: created.fetch_token,
|
|
117
|
-
relay: relayBase,
|
|
118
|
-
label: options.label,
|
|
119
|
-
expires_at: created.expires_at,
|
|
120
|
-
qr_payload: created.qr_payload, // 完整 deeplink,粘贴到 App 输入框直接绑定
|
|
121
|
-
});
|
|
122
|
-
if (devPath) {
|
|
123
|
-
log.info("picoclaw.pair.devinfo.written", "dev pair-info written for test", { path: devPath });
|
|
124
|
-
}
|
|
125
|
-
// 3. poll status
|
|
126
|
-
try {
|
|
127
|
-
const result = await pollPairStatus({
|
|
128
|
-
relayUrl: relayBase,
|
|
129
|
-
code: created.code,
|
|
130
|
-
fetchToken: created.fetch_token,
|
|
131
|
-
intervalSec: created.poll_interval_seconds || PAIR_STATUS_POLL_INTERVAL_SEC,
|
|
132
|
-
maxSec: PAIR_STATUS_POLL_MAX_SEC,
|
|
133
|
-
log,
|
|
134
|
-
});
|
|
135
|
-
// 4. 配对成功 — 清 QR 图(屏幕切换为已绑定画面;具体显示模块自己处理)
|
|
136
|
-
clearQRImage(options.qrPath);
|
|
137
|
-
clearDevPairInfo();
|
|
138
|
-
return {
|
|
139
|
-
gw_id: result.gw_id,
|
|
140
|
-
bridge_token: result.bridge_token,
|
|
141
|
-
relay_url: relayBase,
|
|
142
|
-
client_user_id: result.client_user_id,
|
|
143
|
-
label: result.label,
|
|
144
|
-
};
|
|
145
|
-
} catch (err) {
|
|
146
|
-
clearQRImage(options.qrPath);
|
|
147
|
-
clearDevPairInfo();
|
|
148
|
-
throw err;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ── worker(long-poll + WS) ─────────────────────────────────────
|
|
153
|
-
|
|
154
|
-
async function runWorkerWithBridge({ state, log }) {
|
|
155
|
-
const sessLog = log.child({ gw: state.gw_id });
|
|
156
|
-
const worker = createRelayWorker({
|
|
157
|
-
relayUrl: state.relay_url,
|
|
158
|
-
gwId: state.gw_id,
|
|
159
|
-
bridgeToken: state.bridge_token,
|
|
160
|
-
runtime: RUNTIME_NAME,
|
|
161
|
-
log: sessLog,
|
|
162
|
-
});
|
|
163
|
-
installAutoTunnel({ relayUrl: state.relay_url, log: sessLog });
|
|
164
|
-
|
|
165
|
-
// ── PicoClaw WS — 自带重连 ──
|
|
166
|
-
const wsHolder = createReconnectingWS({ log: sessLog });
|
|
167
|
-
|
|
168
|
-
// 当前 in-flight turn 的 ctx — handleRequest 期间唯一非空
|
|
169
|
-
const activeTurnRef = { current: null };
|
|
170
|
-
const slot = createSessionSlot({ log: sessLog, name: RUNTIME_NAME });
|
|
171
|
-
slot.transition(SESSION_STATES.READY, "picoclaw-no-spawn");
|
|
172
|
-
|
|
173
|
-
const usage = createUsageReporter({
|
|
174
|
-
runtime: RUNTIME_NAME,
|
|
175
|
-
log: sessLog,
|
|
176
|
-
publishEvent: (event) => {
|
|
177
|
-
if (!activeTurnRef.current) return Promise.resolve(false);
|
|
178
|
-
return worker.publishEvent(activeTurnRef.current.ctx.requestId, event);
|
|
179
|
-
},
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
// 连 WS
|
|
183
|
-
try {
|
|
184
|
-
await wsHolder.connectOnce();
|
|
185
|
-
} catch (err) {
|
|
186
|
-
sessLog.warn("picoclaw.ws.first_connect.err", "initial ws connect failed", { err: String(err?.message || err) });
|
|
187
|
-
// 不致命 — handleRequest 时会再触发重连。
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// todo translator(ws 路径):拦截 update_plan,阻止透传原始 tool_call 事件
|
|
191
|
-
// ws 路径不走 envelope 系统,所以只做拦截(不发 todo_list block)
|
|
192
|
-
const wsTodoTranslator = createPicoTodoTranslator("ws_path");
|
|
193
|
-
|
|
194
|
-
// 把 ws 收到的消息分发给当前 turn
|
|
195
|
-
wsHolder.onMessage((picoMsg) => {
|
|
196
|
-
const activeTurn = activeTurnRef.current;
|
|
197
|
-
if (!activeTurn) {
|
|
198
|
-
sessLog.warn("picoclaw.ws.msg.no_turn", "received pico message but no active turn", { type: picoMsg?.type });
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
// todo translator 拦截:update_plan 消息不透传(阻止 function_call_arguments.delta chunk)
|
|
202
|
-
const intercepted = wsTodoTranslator.intercept(picoMsg);
|
|
203
|
-
if (intercepted.length > 0 && intercepted[0] !== picoMsg) {
|
|
204
|
-
// 拦截到 update_plan,静默丢弃(ws 路径无 envelope 能力)
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
const events = translatePicoMessage(picoMsg, activeTurn.translatorState);
|
|
208
|
-
if (!events.length) return;
|
|
209
|
-
void publishEvents(activeTurn, events).catch((err) => {
|
|
210
|
-
sessLog.warn("picoclaw.publish.err", "publish failed", { err: String(err?.message || err) });
|
|
211
|
-
});
|
|
212
|
-
if (activeTurn.translatorState.finalized) {
|
|
213
|
-
const turn = activeTurnRef.current;
|
|
214
|
-
activeTurnRef.current = null;
|
|
215
|
-
turn.resolve(); // wake the awaiting handleRequest
|
|
216
|
-
}
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// ── handleRequest: dispatch 走 unifiedHandler,普通请求走原有 ws 路径 ──
|
|
220
|
-
const handleRequest = createPicoHandleRequest({
|
|
221
|
-
wsHolder,
|
|
222
|
-
log: sessLog,
|
|
223
|
-
activeTurnRef,
|
|
224
|
-
dispatchHandlerOptions: {
|
|
225
|
-
relayUrl: state.relay_url,
|
|
226
|
-
bridgeToken: state.bridge_token,
|
|
227
|
-
},
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
// Wrap to add usage tracking for non-dispatch turns.
|
|
231
|
-
const handleRequestWithUsage = async (request, ctx) => {
|
|
232
|
-
const turnStartedAt = Date.now();
|
|
233
|
-
await handleRequest(request, ctx);
|
|
234
|
-
usage.addCost({ turns: 1, duration_ms: Date.now() - turnStartedAt });
|
|
235
|
-
usage.schedulePublish("picoclaw.turn_end");
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
// 启 long-poll 循环。worker.run() 在 401/410 时会抛带特征的错误。
|
|
239
|
-
let exitReason = "loop-exit";
|
|
240
|
-
try {
|
|
241
|
-
await worker.run(handleRequestWithUsage);
|
|
242
|
-
} catch (err) {
|
|
243
|
-
const msg = String(err?.message || err);
|
|
244
|
-
if (/401|403|410|revoked|unauthorized/i.test(msg)) {
|
|
245
|
-
exitReason = "revoked";
|
|
246
|
-
} else {
|
|
247
|
-
exitReason = "fatal";
|
|
248
|
-
sessLog.error("picoclaw.worker.err", "worker errored", { err: msg });
|
|
249
|
-
}
|
|
250
|
-
} finally {
|
|
251
|
-
try { wsHolder.close("worker-exit"); } catch {}
|
|
252
|
-
try { usage.dispose(); } catch {}
|
|
253
|
-
}
|
|
254
|
-
return exitReason;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ── helpers ──────────────────────────────────────────────────────
|
|
258
|
-
|
|
259
|
-
async function publishEvents({ ctx }, events) {
|
|
260
|
-
for (const e of events) {
|
|
261
|
-
if (!e) continue;
|
|
262
|
-
await ctx.publishEvent(e);
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function createReconnectingWS({ log }) {
|
|
267
|
-
let conn = null;
|
|
268
|
-
const messageHandlers = new Set();
|
|
269
|
-
let backoff = WS_RECONNECT_INITIAL_MS;
|
|
270
|
-
|
|
271
|
-
async function connectOnce() {
|
|
272
|
-
if (conn && conn.readyState === 1) return conn;
|
|
273
|
-
conn = await wsConnect({ log });
|
|
274
|
-
backoff = WS_RECONNECT_INITIAL_MS;
|
|
275
|
-
conn.onMessage((m) => {
|
|
276
|
-
for (const h of messageHandlers) {
|
|
277
|
-
try { h(m); } catch (err) {
|
|
278
|
-
log.warn("picoclaw.ws.handler.err", "handler threw", { err: String(err?.message || err) });
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
});
|
|
282
|
-
conn.onClose(() => {
|
|
283
|
-
conn = null;
|
|
284
|
-
// 异步重连,不阻塞 ws.onClose 回调
|
|
285
|
-
setTimeout(() => {
|
|
286
|
-
connectOnce().catch((err) => {
|
|
287
|
-
log.warn("picoclaw.ws.reconnect.err", "reconnect failed", { err: String(err?.message || err) });
|
|
288
|
-
backoff = Math.min(backoff * 2, WS_RECONNECT_MAX_MS);
|
|
289
|
-
});
|
|
290
|
-
}, backoff);
|
|
291
|
-
});
|
|
292
|
-
return conn;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
return {
|
|
296
|
-
connectOnce,
|
|
297
|
-
isOpen() { return conn && conn.readyState === 1; },
|
|
298
|
-
send(msg) {
|
|
299
|
-
if (!conn || conn.readyState !== 1) {
|
|
300
|
-
throw new Error("picoclaw ws not open");
|
|
301
|
-
}
|
|
302
|
-
conn.send(msg);
|
|
303
|
-
},
|
|
304
|
-
onMessage(fn) { messageHandlers.add(fn); return () => messageHandlers.delete(fn); },
|
|
305
|
-
close(reason) {
|
|
306
|
-
try { conn?.close(reason); } catch {}
|
|
307
|
-
conn = null;
|
|
308
|
-
},
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function sleep(ms) {
|
|
313
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
314
|
-
}
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
// 巴布配对流程。
|
|
2
|
-
//
|
|
3
|
-
// 步骤(daemon 主循环未持有 token 时执行):
|
|
4
|
-
//
|
|
5
|
-
// 1. POST <relay>/v1/pair-codes/picoclaw/create
|
|
6
|
-
// Body: { label, ttl_seconds, device_id }
|
|
7
|
-
// Resp: { code, qr_payload, qr_png_base64, fetch_token, expires_at, poll_interval_seconds }
|
|
8
|
-
//
|
|
9
|
-
// 2. 把 qr_png_base64 解码写到 QR_PNG_OUTPUT_PATH(通常 /tmp/bubu/pair-qr.png)。
|
|
10
|
-
// 巴布开发板的屏幕显示模块独立读取这个文件,在屏幕上展示 QR。
|
|
11
|
-
//
|
|
12
|
-
// 3. 每 poll_interval_seconds 调一次:
|
|
13
|
-
// GET <relay>/v1/pair-codes/picoclaw/<code>/status
|
|
14
|
-
// Header: X-Pair-Fetch-Token: <fetch_token>
|
|
15
|
-
// Resp:
|
|
16
|
-
// { state: "pending", expires_at }
|
|
17
|
-
// { state: "consumed", gw_id, bridge_token, client_user_id, runtime, label }
|
|
18
|
-
// { state: "expired" }
|
|
19
|
-
//
|
|
20
|
-
// 4. consumed 命中 → 返回 {gw_id, bridge_token, client_user_id, label, relay_url}
|
|
21
|
-
// expired → 抛错,主循环重新走步骤 1
|
|
22
|
-
// poll 超时 → 抛错,主循环重新走步骤 1
|
|
23
|
-
//
|
|
24
|
-
// 全程不需要用户在 cli 上输入任何东西;所有信息走二维码 ↔ App。
|
|
25
|
-
|
|
26
|
-
import fs from "node:fs";
|
|
27
|
-
import path from "node:path";
|
|
28
|
-
import process from "node:process";
|
|
29
|
-
|
|
30
|
-
import {
|
|
31
|
-
PAIR_STATUS_POLL_INTERVAL_SEC,
|
|
32
|
-
PAIR_STATUS_POLL_MAX_SEC,
|
|
33
|
-
QR_PNG_OUTPUT_PATH,
|
|
34
|
-
RUNTIME_NAME,
|
|
35
|
-
} from "./constants.mjs";
|
|
36
|
-
|
|
37
|
-
const PAIR_LABEL_DEFAULT = "我的巴布";
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* 调 server 创建一个 picoclaw pair-code。
|
|
41
|
-
* @returns {Promise<{code, qr_payload, qr_png_base64, fetch_token, expires_at, poll_interval_seconds}>}
|
|
42
|
-
*/
|
|
43
|
-
export async function createPairCode({ relayUrl, label, deviceId, ttlSeconds, fetchImpl, log }) {
|
|
44
|
-
const f = fetchImpl || globalThis.fetch;
|
|
45
|
-
const url = `${trimRight(relayUrl)}/v1/pair-codes/picoclaw/create`;
|
|
46
|
-
const body = {
|
|
47
|
-
label: label || PAIR_LABEL_DEFAULT,
|
|
48
|
-
device_id: deviceId || "",
|
|
49
|
-
ttl_seconds: ttlSeconds > 0 ? ttlSeconds : undefined,
|
|
50
|
-
};
|
|
51
|
-
const res = await f(url, {
|
|
52
|
-
method: "POST",
|
|
53
|
-
headers: { "Content-Type": "application/json" },
|
|
54
|
-
body: JSON.stringify(body),
|
|
55
|
-
});
|
|
56
|
-
if (!res.ok) {
|
|
57
|
-
const text = await safeText(res);
|
|
58
|
-
throw new Error(`picoclaw pair-code create failed: HTTP ${res.status} ${text}`);
|
|
59
|
-
}
|
|
60
|
-
const j = await res.json();
|
|
61
|
-
if (!j || j.status !== "ok" || !j.code || !j.fetch_token || !j.qr_png_base64) {
|
|
62
|
-
throw new Error(`picoclaw pair-code create: malformed response ${JSON.stringify(j).slice(0, 200)}`);
|
|
63
|
-
}
|
|
64
|
-
if (log) {
|
|
65
|
-
log.info("picoclaw.pair.created", "pair-code created", {
|
|
66
|
-
code: j.code, expires_at: j.expires_at,
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
return j;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* 把 base64 PNG 写到约定路径,屏幕模块读取。
|
|
74
|
-
*/
|
|
75
|
-
export function writeQRImage(b64, outputPath) {
|
|
76
|
-
const target = outputPath || QR_PNG_OUTPUT_PATH;
|
|
77
|
-
const dir = path.dirname(target);
|
|
78
|
-
try {
|
|
79
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
80
|
-
} catch {}
|
|
81
|
-
const buf = Buffer.from(b64, "base64");
|
|
82
|
-
fs.writeFileSync(target, buf);
|
|
83
|
-
return target;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* 删除 QR 图片(配对成功后清理)。
|
|
88
|
-
*/
|
|
89
|
-
export function clearQRImage(outputPath) {
|
|
90
|
-
const target = outputPath || QR_PNG_OUTPUT_PATH;
|
|
91
|
-
try {
|
|
92
|
-
fs.unlinkSync(target);
|
|
93
|
-
} catch {}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* dev 测试钩子:把 code + fetch_token + relay 等关键信息以 plain JSON 写到本地,
|
|
98
|
-
* 让端到端测试脚本能扮演 App 角色拿到 code 调 consume。
|
|
99
|
-
*
|
|
100
|
-
* 仅当环境变量 `BUBU_DEV_DUMP_PAIR_INFO=1` 时才写。
|
|
101
|
-
* **生产环境严禁开启** —— fetch_token 落地会被任何能读这台机器文件的进程拿走。
|
|
102
|
-
*
|
|
103
|
-
* 输出路径默认 /tmp/bubu/pair-info.json,可由 `BUBU_DEV_PAIR_INFO_PATH` 覆盖。
|
|
104
|
-
*/
|
|
105
|
-
export function writeDevPairInfo(info) {
|
|
106
|
-
if (process.env.BUBU_DEV_DUMP_PAIR_INFO !== "1") return null;
|
|
107
|
-
const target =
|
|
108
|
-
process.env.BUBU_DEV_PAIR_INFO_PATH?.trim() || "/tmp/bubu/pair-info.json";
|
|
109
|
-
const dir = path.dirname(target);
|
|
110
|
-
try {
|
|
111
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
112
|
-
} catch {}
|
|
113
|
-
const obj = {
|
|
114
|
-
code: info.code,
|
|
115
|
-
fetch_token: info.fetch_token,
|
|
116
|
-
relay: info.relay,
|
|
117
|
-
label: info.label || "",
|
|
118
|
-
expires_at: info.expires_at || "",
|
|
119
|
-
// qr_payload 是完整 deeplink(agentlink://pair/picoclaw?...),直接粘到
|
|
120
|
-
// App 输入框就能完成绑定,省去自己拼 URL 的麻烦。
|
|
121
|
-
qr_payload: info.qr_payload || "",
|
|
122
|
-
written_at: new Date().toISOString(),
|
|
123
|
-
warning: "BUBU_DEV_DUMP_PAIR_INFO=1; do NOT enable in production.",
|
|
124
|
-
};
|
|
125
|
-
try {
|
|
126
|
-
fs.writeFileSync(target, JSON.stringify(obj, null, 2) + "\n", "utf8");
|
|
127
|
-
return target;
|
|
128
|
-
} catch {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* 删除 dev pair-info(配对成功后清理 fetch_token 落地文件)。
|
|
135
|
-
*/
|
|
136
|
-
export function clearDevPairInfo() {
|
|
137
|
-
if (process.env.BUBU_DEV_DUMP_PAIR_INFO !== "1") return;
|
|
138
|
-
const target =
|
|
139
|
-
process.env.BUBU_DEV_PAIR_INFO_PATH?.trim() || "/tmp/bubu/pair-info.json";
|
|
140
|
-
try {
|
|
141
|
-
fs.unlinkSync(target);
|
|
142
|
-
} catch {}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* 轮询 status 端点直到 consumed 或 expired。
|
|
147
|
-
* @returns {Promise<{gw_id, bridge_token, client_user_id, runtime, label}>} consumed payload
|
|
148
|
-
*/
|
|
149
|
-
export async function pollPairStatus({ relayUrl, code, fetchToken, intervalSec, maxSec, fetchImpl, log, signal }) {
|
|
150
|
-
const f = fetchImpl || globalThis.fetch;
|
|
151
|
-
const interval = (intervalSec > 0 ? intervalSec : PAIR_STATUS_POLL_INTERVAL_SEC) * 1000;
|
|
152
|
-
const maxMs = (maxSec > 0 ? maxSec : PAIR_STATUS_POLL_MAX_SEC) * 1000;
|
|
153
|
-
const startedAt = Date.now();
|
|
154
|
-
const url = `${trimRight(relayUrl)}/v1/pair-codes/picoclaw/${encodeURIComponent(code)}/status`;
|
|
155
|
-
for (;;) {
|
|
156
|
-
if (signal?.aborted) {
|
|
157
|
-
throw new Error("picoclaw pair poll aborted");
|
|
158
|
-
}
|
|
159
|
-
let res;
|
|
160
|
-
try {
|
|
161
|
-
res = await f(url, {
|
|
162
|
-
method: "GET",
|
|
163
|
-
headers: { "X-Pair-Fetch-Token": fetchToken },
|
|
164
|
-
});
|
|
165
|
-
} catch (err) {
|
|
166
|
-
// 网络抖动 — 等下次重试,日志一下
|
|
167
|
-
if (log) log.warn("picoclaw.pair.poll.err", "status poll error", { err: String(err?.message || err) });
|
|
168
|
-
await sleep(interval);
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
if (res.status === 401) {
|
|
172
|
-
throw new Error("picoclaw pair status: fetch_token rejected (401)");
|
|
173
|
-
}
|
|
174
|
-
if (!res.ok) {
|
|
175
|
-
const text = await safeText(res);
|
|
176
|
-
if (log) log.warn("picoclaw.pair.poll.bad", "non-ok status", { status: res.status, text });
|
|
177
|
-
await sleep(interval);
|
|
178
|
-
continue;
|
|
179
|
-
}
|
|
180
|
-
const j = await res.json().catch(() => null);
|
|
181
|
-
if (!j || typeof j.state !== "string") {
|
|
182
|
-
await sleep(interval);
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
if (j.state === "consumed") {
|
|
186
|
-
if (!j.bridge_token || !j.gw_id) {
|
|
187
|
-
throw new Error("picoclaw pair status: consumed but missing bridge_token/gw_id");
|
|
188
|
-
}
|
|
189
|
-
return {
|
|
190
|
-
gw_id: j.gw_id,
|
|
191
|
-
bridge_token: j.bridge_token,
|
|
192
|
-
client_user_id: j.client_user_id || "",
|
|
193
|
-
runtime: j.runtime || RUNTIME_NAME,
|
|
194
|
-
label: j.label || "",
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
if (j.state === "expired") {
|
|
198
|
-
throw new Error("picoclaw pair-code expired before consume");
|
|
199
|
-
}
|
|
200
|
-
// pending
|
|
201
|
-
if (Date.now() - startedAt >= maxMs) {
|
|
202
|
-
throw new Error("picoclaw pair-code poll timeout");
|
|
203
|
-
}
|
|
204
|
-
await sleep(interval);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// ── helpers ──────────────────────────────────────────────────────
|
|
209
|
-
|
|
210
|
-
function trimRight(s) {
|
|
211
|
-
return String(s || "").replace(/\/+$/, "");
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function sleep(ms) {
|
|
215
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
async function safeText(res) {
|
|
219
|
-
try {
|
|
220
|
-
return await res.text();
|
|
221
|
-
} catch {
|
|
222
|
-
return "";
|
|
223
|
-
}
|
|
224
|
-
}
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// 巴布(PicoClaw)本地配对状态持久化。
|
|
2
|
-
//
|
|
3
|
-
// 文件: ~/.agentlink/picoclaw-state.json
|
|
4
|
-
//
|
|
5
|
-
// 内容:
|
|
6
|
-
// {
|
|
7
|
-
// gw_id, bridge_token, relay_url, client_user_id, label, runtime: "picoclaw",
|
|
8
|
-
// saved_at: <ISO timestamp>
|
|
9
|
-
// }
|
|
10
|
-
//
|
|
11
|
-
// 缺/损坏时返回 null,daemon 主循环会触发新一轮 pair-code create + QR 显示。
|
|
12
|
-
|
|
13
|
-
import fs from "node:fs";
|
|
14
|
-
import os from "node:os";
|
|
15
|
-
import path from "node:path";
|
|
16
|
-
|
|
17
|
-
const STATE_DIR = path.join(os.homedir(), ".agentlink");
|
|
18
|
-
const STATE_FILE = path.join(STATE_DIR, "picoclaw-state.json");
|
|
19
|
-
|
|
20
|
-
function ensureDir() {
|
|
21
|
-
try {
|
|
22
|
-
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
23
|
-
return true;
|
|
24
|
-
} catch {
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function readState() {
|
|
30
|
-
try {
|
|
31
|
-
const raw = fs.readFileSync(STATE_FILE, "utf8");
|
|
32
|
-
const obj = JSON.parse(raw);
|
|
33
|
-
if (!obj || typeof obj !== "object") return null;
|
|
34
|
-
if (obj.runtime !== "picoclaw") return null;
|
|
35
|
-
const gwId = String(obj.gw_id || "").trim();
|
|
36
|
-
const relayUrl = String(obj.relay_url || "").trim();
|
|
37
|
-
const bridgeToken = String(obj.bridge_token || "").trim();
|
|
38
|
-
if (!gwId || !relayUrl || !bridgeToken) return null;
|
|
39
|
-
return {
|
|
40
|
-
gw_id: gwId,
|
|
41
|
-
bridge_token: bridgeToken,
|
|
42
|
-
relay_url: relayUrl,
|
|
43
|
-
client_user_id: String(obj.client_user_id || "").trim(),
|
|
44
|
-
label: String(obj.label || "").trim(),
|
|
45
|
-
saved_at: obj.saved_at || null,
|
|
46
|
-
};
|
|
47
|
-
} catch {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function writeState(state) {
|
|
53
|
-
if (!ensureDir()) return false;
|
|
54
|
-
const obj = {
|
|
55
|
-
runtime: "picoclaw",
|
|
56
|
-
gw_id: state.gw_id,
|
|
57
|
-
bridge_token: state.bridge_token,
|
|
58
|
-
relay_url: state.relay_url,
|
|
59
|
-
client_user_id: state.client_user_id || "",
|
|
60
|
-
label: state.label || "",
|
|
61
|
-
saved_at: new Date().toISOString(),
|
|
62
|
-
};
|
|
63
|
-
try {
|
|
64
|
-
fs.writeFileSync(STATE_FILE, JSON.stringify(obj, null, 2) + "\n", "utf8");
|
|
65
|
-
return true;
|
|
66
|
-
} catch {
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function clearState() {
|
|
72
|
-
try {
|
|
73
|
-
fs.unlinkSync(STATE_FILE);
|
|
74
|
-
return true;
|
|
75
|
-
} catch {
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* todoTranslator.mjs — picoclaw runtime update_plan → todo_list block translator
|
|
3
|
-
*
|
|
4
|
-
* 按 translator-spec.md 和 npm-cli-todo-translator-picoclaw spec 实现:
|
|
5
|
-
* - 识别 PicoMessage type==="message.update" && payload.kind==="tool_calls"
|
|
6
|
-
* - 检查 tool_calls 中是否含 update_plan
|
|
7
|
-
* - 若含:转写为 content_block.started + content_block.completed,拦截不透传
|
|
8
|
-
* - 否则:原样透传(返回 [msg])
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
buildBlockStarted,
|
|
13
|
-
buildBlockCompleted,
|
|
14
|
-
newBlockId,
|
|
15
|
-
} from "../../../src-shared/envelope_builder.mjs";
|
|
16
|
-
import { buildTodoItems } from "../_shared/todoTranslatorUtils.mjs";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* 创建 picoclaw update_plan translator 实例。
|
|
20
|
-
*
|
|
21
|
-
* @param {string} runId 当前 run 的 run_id,用于构造 envelope
|
|
22
|
-
* @returns {{ intercept(msg: object): object[] }}
|
|
23
|
-
*/
|
|
24
|
-
export function createPicoTodoTranslator(runId) {
|
|
25
|
-
return {
|
|
26
|
-
/**
|
|
27
|
-
* @param {object} msg PicoMessage({ type, id, session_id, payload })
|
|
28
|
-
* @returns {object[]} 2 个 envelope(update_plan)或 [msg](透传)
|
|
29
|
-
*/
|
|
30
|
-
intercept(msg) {
|
|
31
|
-
if (!msg || typeof msg !== "object") return [msg];
|
|
32
|
-
|
|
33
|
-
// 只处理 message.update(kind=tool_calls)
|
|
34
|
-
if (msg.type !== "message.update") return [msg];
|
|
35
|
-
const payload = msg.payload;
|
|
36
|
-
if (!payload || payload.kind !== "tool_calls") return [msg];
|
|
37
|
-
|
|
38
|
-
// 从 tool_calls 数组中找 update_plan
|
|
39
|
-
const toolCalls = Array.isArray(payload.tool_calls)
|
|
40
|
-
? payload.tool_calls
|
|
41
|
-
: (Array.isArray(payload.content) ? payload.content : []);
|
|
42
|
-
const updatePlanCall = toolCalls.find(
|
|
43
|
-
(tc) => tc && typeof tc === "object" && tc.name === "update_plan",
|
|
44
|
-
);
|
|
45
|
-
if (!updatePlanCall) return [msg];
|
|
46
|
-
|
|
47
|
-
// 解析 arguments
|
|
48
|
-
let args = {};
|
|
49
|
-
if (updatePlanCall.arguments) {
|
|
50
|
-
try {
|
|
51
|
-
args = JSON.parse(String(updatePlanCall.arguments));
|
|
52
|
-
} catch { /* 保持空对象 */ }
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// plan 数组映射为 TodoItem[](explanation 忽略)
|
|
56
|
-
const rawPlan = Array.isArray(args.plan) ? args.plan : [];
|
|
57
|
-
const items = buildTodoItems(rawPlan);
|
|
58
|
-
const blockId = newBlockId();
|
|
59
|
-
const block = { id: blockId, type: "todo_list", items };
|
|
60
|
-
|
|
61
|
-
return [
|
|
62
|
-
buildBlockStarted(runId, block),
|
|
63
|
-
buildBlockCompleted(runId, { ...block }),
|
|
64
|
-
];
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
}
|