@xyagent/cli 1.0.0 → 1.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.
Files changed (47) hide show
  1. package/README.md +42 -0
  2. package/bin/agentlink +468 -86
  3. package/package.json +3 -3
  4. package/src/tunnel_service.mjs +17 -1
  5. package/src-ext/bin.mjs +12 -11
  6. package/src-ext/commands/agent.mjs +34 -0
  7. package/src-ext/commands/pair.mjs +122 -23
  8. package/src-ext/commands/service.mjs +1 -1
  9. package/src-ext/core/activeRuns.mjs +26 -9
  10. package/src-ext/core/defaultWorkspace.mjs +43 -13
  11. package/src-ext/core/defaultWorkspaceSync.mjs +20 -0
  12. package/src-ext/core/installationIdentity.mjs +94 -0
  13. package/src-ext/core/mcpRuntimeFanout.mjs +23 -1
  14. package/src-ext/core/pairCodeClient.mjs +48 -8
  15. package/src-ext/core/pairInventory.mjs +31 -6
  16. package/src-ext/core/relayWorker.mjs +21 -1
  17. package/src-ext/core/runtimeRegistry.mjs +180 -0
  18. package/src-ext/core/scanWorkspaces.mjs +163 -23
  19. package/src-ext/core/unifiedDispatchHandler.mjs +9 -2
  20. package/src-ext/postinstall.mjs +14 -0
  21. package/src-ext/runtime/_shared/bridgedSessionLedger.mjs +60 -0
  22. package/src-ext/runtime/_shared/claudeSchemaEvent.mjs +49 -0
  23. package/src-ext/runtime/_shared/headlessCliBridge.mjs +168 -0
  24. package/src-ext/runtime/_shared/jsonMcpConfigAdapter.mjs +70 -0
  25. package/src-ext/runtime/_shared/ndjsonProcess.mjs +141 -0
  26. package/src-ext/runtime/_shared/resolveWorkspaceCwd.mjs +47 -0
  27. package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
  28. package/src-ext/runtime/claude/handleRequest.mjs +15 -37
  29. package/src-ext/runtime/claude/stdoutParser.mjs +8 -3
  30. package/src-ext/runtime/codebuddy/index.mjs +41 -0
  31. package/src-ext/runtime/codex/handleRequest.mjs +13 -35
  32. package/src-ext/runtime/cursor/index.mjs +46 -0
  33. package/src-ext/runtime/cursor/mcpConfigAdapter.mjs +15 -0
  34. package/src-ext/runtime/deepagents/preflight.mjs +57 -0
  35. package/src-ext/runtime/hermes/envSetup.mjs +22 -8
  36. package/src-ext/runtime/hermes/gatewayManager.mjs +239 -3
  37. package/src-ext/runtime/hermes/handleRequest.mjs +13 -0
  38. package/src-ext/runtime/hermes/httpBackend.mjs +12 -1
  39. package/src-ext/runtime/hermes/index.mjs +1 -1
  40. package/src-ext/runtime/hermes/preflight.mjs +2 -1
  41. package/src-ext/runtime/kimi/index.mjs +100 -0
  42. package/src-ext/runtime/openclaw/workspaceContext.mjs +58 -0
  43. package/src-ext/runtime/opencode/index.mjs +48 -0
  44. package/src-ext/runtime/opencode/mcpConfigAdapter.mjs +15 -0
  45. package/src-ext/runtime/opencode/preflight.mjs +72 -0
  46. package/src-ext/runtime/qwen/index.mjs +42 -0
  47. package/src-ext/service/serviceManager.mjs +120 -42
@@ -11,6 +11,7 @@
11
11
 
12
12
  import crypto from "node:crypto";
13
13
  import { execFile } from "node:child_process";
14
+ import { promisify } from "node:util";
14
15
  import fs from "node:fs";
15
16
  import os from "node:os";
16
17
  import path from "node:path";
@@ -22,6 +23,7 @@ const DEFAULT_LIMIT = 50;
22
23
  const MAX_LIMIT = 500;
23
24
  const HARD_TIMEOUT_MS = 5000;
24
25
  const GIT_BRANCH_TIMEOUT_MS = 200;
26
+ const execFileAsync = promisify(execFile);
25
27
 
26
28
  // Runtime metadata — agentKind values must match dart stub ScannedAgentGroup.kind exactly.
27
29
  const RUNTIME_META = {
@@ -49,6 +51,11 @@ const RUNTIME_META = {
49
51
  tagline: "Agentlink 内置对话智能体",
50
52
  idPrefix: "hm",
51
53
  },
54
+ Qwen: { agentKind: "Qwen", emoji: "🌊", tagline: "Qwen CLI", idPrefix: "qw" },
55
+ Kimi: { agentKind: "Kimi", emoji: "🌙", tagline: "Kimi CLI", idPrefix: "km" },
56
+ CodeBuddy: { agentKind: "CodeBuddy", emoji: "🤖", tagline: "CodeBuddy", idPrefix: "cb" },
57
+ Cursor: { agentKind: "Cursor", emoji: "⌨️", tagline: "Cursor", idPrefix: "cu" },
58
+ OpenCode: { agentKind: "OpenCode", emoji: "🧩", tagline: "OpenCode", idPrefix: "oo" },
52
59
  };
53
60
 
54
61
  // runtimeKind string → RUNTIME_META key
@@ -58,6 +65,7 @@ const RUNTIME_KIND_MAP = {
58
65
  claude: "Claude Code",
59
66
  codex: "Codex",
60
67
  hermes: "Hermes",
68
+ qwen: "Qwen", kimi: "Kimi", codebuddy: "CodeBuddy", cursor: "Cursor", opencode: "OpenCode",
61
69
  };
62
70
 
63
71
  // ─── utility functions ───────────────────────────────────────────────────────
@@ -166,6 +174,7 @@ export async function readOpenClawWorkspaces({
166
174
  limit,
167
175
  includeBranch,
168
176
  workspaceRoot,
177
+ openclawHome,
169
178
  }) {
170
179
  const warnings = [];
171
180
 
@@ -174,7 +183,7 @@ export async function readOpenClawWorkspaces({
174
183
  dirs = [path.resolve(workspaceRoot)];
175
184
  } else {
176
185
  try {
177
- dirs = resolveOpenClawAgentWorkspaceDirs();
186
+ dirs = readJsonlCwds(openclawHome ?? path.join(resolveOpenClawHomeDir(), "agents"));
178
187
  } catch (err) {
179
188
  warnings.push(`OpenClaw: failed to resolve workspace dirs: ${err.message}`);
180
189
  return { workspaces: [], warnings, truncated: false };
@@ -216,6 +225,29 @@ export async function readOpenClawWorkspaces({
216
225
  return { workspaces, warnings, truncated };
217
226
  }
218
227
 
228
+ function readJsonlCwds(root) {
229
+ const found = new Set();
230
+ const visit = (dir) => {
231
+ let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
232
+ for (const entry of entries) {
233
+ const item = path.join(dir, entry.name);
234
+ if (entry.isDirectory()) visit(item);
235
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
236
+ try {
237
+ for (const line of fs.readFileSync(item, "utf8").split(/\r?\n/)) {
238
+ if (!line.trim()) continue;
239
+ const row = JSON.parse(line);
240
+ const cwd = row?.cwd ?? row?.message?.cwd ?? row?.context?.cwd;
241
+ if (typeof cwd === "string" && cwd) found.add(path.resolve(cwd));
242
+ }
243
+ } catch {}
244
+ }
245
+ }
246
+ };
247
+ visit(root);
248
+ return [...found];
249
+ }
250
+
219
251
  // ─── Claude Code reader ───────────────────────────────────────────────────────
220
252
 
221
253
  /**
@@ -385,31 +417,19 @@ export async function readCodexWorkspaces({
385
417
  const cwdByPath = new Map(); // absolutePath → true (for dedupe)
386
418
 
387
419
  for (const dir of sessionDirs) {
388
- let files;
389
- try {
390
- files = fs.readdirSync(dir);
391
- } catch (err) {
392
- if (err.code === "EACCES" || err.code === "EPERM") {
393
- warnings.push(`Codex: permission denied reading ${dir}: ${err.message}`);
394
- }
395
- // ENOENT: dir doesn't exist — skip silently
396
- continue;
397
- }
398
-
399
- for (const file of files) {
400
- if (!file.endsWith(".jsonl")) continue;
401
- const filePath = path.join(dir, file);
420
+ if (!fs.existsSync(dir)) continue;
421
+ for (const filePath of findFiles(dir, (name) => name.endsWith(".jsonl"))) {
402
422
  try {
403
423
  const content = fs.readFileSync(filePath, "utf8");
404
424
  const firstLine = content.split("\n")[0].trim();
405
425
  if (!firstLine) continue;
406
426
  const parsed = JSON.parse(firstLine);
407
- const cwd = parsed?.cwd;
427
+ const cwd = parsed?.cwd ?? parsed?.payload?.cwd;
408
428
  if (typeof cwd === "string" && cwd) {
409
429
  cwdByPath.set(path.resolve(cwd.replace(/^~/, home)), true);
410
430
  }
411
431
  } catch (err) {
412
- warnings.push(`Codex: failed to parse first line of ${file}: ${err.message}`);
432
+ warnings.push(`Codex: failed to parse session: ${err.message}`);
413
433
  }
414
434
  }
415
435
  }
@@ -714,7 +734,10 @@ export async function readHermesWorkspaces({
714
734
  */
715
735
  export async function scanWorkspacesByRuntime({ runtimeKind, gatewayId, params }) {
716
736
  const kind = normalizeRuntimeKind(runtimeKind);
717
- const meta = RUNTIME_META[kind] ?? RUNTIME_META["OpenClaw"];
737
+ if (!kind) {
738
+ return { ok: true, agentKind: String(runtimeKind || ""), emoji: "", tagline: "", host: hostMeta(gatewayId), workspaces: [], truncated: false, scannedAt: new Date().toISOString(), warnings: [`unsupported runtime: ${runtimeKind}`] };
739
+ }
740
+ const meta = RUNTIME_META[kind];
718
741
  const limit = clampLimit(params?.limit ?? DEFAULT_LIMIT);
719
742
  const includeBranch = params?.includeBranch === true;
720
743
 
@@ -723,6 +746,9 @@ export async function scanWorkspacesByRuntime({ runtimeKind, gatewayId, params }
723
746
  if (params?.hermesProfilesRoot != null) {
724
747
  readerArgs.hermesProfilesRoot = params.hermesProfilesRoot;
725
748
  }
749
+ for (const key of ["root", "openclawHome", "hermesDbPath", "opencodeDbPath", "codexHome"]) {
750
+ if (params?.[key] != null) readerArgs[key] = params[key];
751
+ }
726
752
 
727
753
  // Wrap reader in 5s hard timeout
728
754
  const readerPromise = runReader(kind, readerArgs);
@@ -768,19 +794,24 @@ function clampLimit(raw) {
768
794
  }
769
795
 
770
796
  function normalizeRuntimeKind(kind) {
771
- if (!kind) return "OpenClaw";
797
+ if (!kind) return null;
772
798
  const lower = String(kind).toLowerCase().trim();
773
- return RUNTIME_KIND_MAP[lower] ?? "OpenClaw";
799
+ return RUNTIME_KIND_MAP[lower] ?? null;
774
800
  }
775
801
 
776
802
  async function runReader(kind, args) {
777
803
  try {
778
804
  switch (kind) {
779
805
  case "OpenClaw": return await readOpenClawWorkspaces(args);
780
- case "Claude Code": return await readClaudeWorkspaces(args);
806
+ case "Claude Code": return await readDirectoryNameWorkspaces(args, "Claude Code", path.join(os.homedir(), ".claude", "projects"), true);
781
807
  case "Codex": return await readCodexWorkspaces(args);
782
- case "Hermes": return await readHermesWorkspaces(args);
783
- default: return await readOpenClawWorkspaces(args);
808
+ case "Hermes": return args.hermesProfilesRoot ? await readHermesWorkspaces(args) : await readHermesDbWorkspaces(args);
809
+ case "Qwen": return await readEncodedProjectWorkspaces(args, "Qwen", path.join(os.homedir(), ".qwen", "projects"));
810
+ case "Kimi": return await readKimiWorkspaces(args);
811
+ case "CodeBuddy": return await readCodeBuddyWorkspaces(args);
812
+ case "Cursor": return await readCursorWorkspaces(args);
813
+ case "OpenCode": return await readOpenCodeWorkspaces(args);
814
+ default: return { workspaces: [], warnings: [`unsupported runtime: ${kind}`], truncated: false };
784
815
  }
785
816
  } catch (err) {
786
817
  // Permission errors degrade to warnings
@@ -791,6 +822,115 @@ async function runReader(kind, args) {
791
822
  }
792
823
  }
793
824
 
825
+ async function workspaceEntries(paths, meta, limit, includeBranch) {
826
+ const selected = [...new Set(paths.map((p) => path.resolve(p)))].slice(0, clampLimit(limit));
827
+ const branches = await resolveBranches(selected, includeBranch);
828
+ return selected.map((absolutePath, i) => ({ id: pathToId(meta.idPrefix, absolutePath), name: path.basename(absolutePath), path: absolutePath, absolutePath, branch: branches[i], lastUsedAtMs: null }));
829
+ }
830
+
831
+ async function readProjectDirectoryWorkspaces({ limit, includeBranch, root }, kind, defaultRoot) {
832
+ const dir = root ?? defaultRoot; let entries = [];
833
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return { workspaces: [], warnings: [], truncated: false }; }
834
+ const paths = entries.filter((e) => e.isDirectory()).map((e) => path.join(dir, e.name));
835
+ return { workspaces: await workspaceEntries(paths, RUNTIME_META[kind], limit, includeBranch), warnings: [], truncated: paths.length > clampLimit(limit) };
836
+ }
837
+
838
+ async function readDirectoryNameWorkspaces(args, kind, defaultRoot, decode) {
839
+ let entries = []; const root = args.root ?? defaultRoot;
840
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return { workspaces: [], warnings: [], truncated: false }; }
841
+ const paths = entries.filter((e) => e.isDirectory()).map((e) => decode ? path.resolve("/" + e.name.replace(/^-/, "").replace(/-/g, path.sep)) : path.join(root, e.name));
842
+ return { workspaces: await workspaceEntries(paths, RUNTIME_META[kind], args.limit, args.includeBranch), warnings: [], truncated: paths.length > clampLimit(args.limit) };
843
+ }
844
+
845
+ async function readEncodedProjectWorkspaces(args, kind, root) {
846
+ let entries = []; try { entries = fs.readdirSync(args.root ?? root, { withFileTypes: true }); } catch { return { workspaces: [], warnings: [], truncated: false }; }
847
+ const paths = entries.filter((e) => e.isDirectory()).map((e) => path.resolve("/" + e.name.replace(/^-/, "").replace(/-/g, path.sep)));
848
+ return { workspaces: await workspaceEntries(paths, RUNTIME_META[kind], args.limit, args.includeBranch), warnings: [], truncated: paths.length > clampLimit(args.limit) };
849
+ }
850
+
851
+ function findFiles(root, predicate) {
852
+ const files = [];
853
+ const visit = (dir) => { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const item = path.join(dir, entry.name); if (entry.isDirectory()) visit(item); else if (entry.isFile() && predicate(entry.name)) files.push(item); } };
854
+ visit(root); return files;
855
+ }
856
+
857
+ async function readKimiWorkspaces(args) {
858
+ const root = args.root ?? path.join(os.homedir(), ".kimi-code", "sessions");
859
+ const paths = [];
860
+ for (const file of findFiles(root, (name) => name === "state.json")) { try { const cwd = JSON.parse(fs.readFileSync(file, "utf8"))?.cwd; if (typeof cwd === "string" && cwd) paths.push(cwd); } catch {} }
861
+ return { workspaces: await workspaceEntries(paths, RUNTIME_META.Kimi, args.limit, args.includeBranch), warnings: [], truncated: paths.length > clampLimit(args.limit) };
862
+ }
863
+
864
+ async function readCodeBuddyWorkspaces(args) {
865
+ const root = args.root ?? path.join(os.homedir(), ".codebuddy", "projects");
866
+ const paths = [];
867
+ for (const file of findFiles(root, (name) => name.endsWith(".jsonl"))) { try { for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) { const cwd = JSON.parse(line || "{}")?.cwd; if (typeof cwd === "string" && cwd) paths.push(cwd); } } catch {} }
868
+ return { workspaces: await workspaceEntries(paths, RUNTIME_META.CodeBuddy, args.limit, args.includeBranch), warnings: [], truncated: paths.length > clampLimit(args.limit) };
869
+ }
870
+
871
+ async function readCursorWorkspaces(args) {
872
+ const root = args.root ?? path.join(os.homedir(), ".cursor", "projects");
873
+ let entries = []; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return { workspaces: [], warnings: [], truncated: false }; }
874
+ const projects = entries.filter((entry) =>
875
+ entry.isDirectory() && fs.existsSync(path.join(root, entry.name, "agent-transcripts")),
876
+ );
877
+ const paths = [];
878
+ let unresolvedCount = 0;
879
+ for (const project of projects) {
880
+ const resolved = resolveCursorProjectSlug(project.name);
881
+ if (resolved) paths.push(resolved);
882
+ else unresolvedCount += 1;
883
+ }
884
+ const warnings = unresolvedCount > 0
885
+ ? [`${unresolvedCount} Cursor project${unresolvedCount === 1 ? " was" : "s were"} skipped because ${unresolvedCount === 1 ? "its" : "their"} slug could not be resolved to an existing directory`]
886
+ : [];
887
+ return { workspaces: await workspaceEntries(paths, RUNTIME_META.Cursor, args.limit, args.includeBranch), warnings, truncated: paths.length > clampLimit(args.limit) };
888
+ }
889
+
890
+ // Cursor replaces path separators and punctuation (`/`, `-`, `_`, `.`) with
891
+ // the same `-` in project directory names. That encoding is lossy: never turn
892
+ // every dash back into `/`. Instead, walk the real directory tree from `/` and
893
+ // accept a candidate only when every component exists on disk.
894
+ const CURSOR_SLUG_JOINERS = ["-", "_", ".", ""];
895
+ function resolveCursorProjectSlug(slug) {
896
+ const segments = String(slug ?? "").replace(/^-+/, "").split("-").filter(Boolean);
897
+ if (segments.length === 0) return null;
898
+
899
+ const walk = (base, index) => {
900
+ if (index >= segments.length) return isExistingDirectory(base) ? base : null;
901
+ for (let end = segments.length; end > index; end -= 1) {
902
+ const parts = segments.slice(index, end);
903
+ for (const joiner of CURSOR_SLUG_JOINERS) {
904
+ const candidate = path.join(base, parts.join(joiner));
905
+ if (!isExistingDirectory(candidate)) continue;
906
+ const resolved = walk(candidate, end);
907
+ if (resolved) return resolved;
908
+ }
909
+ }
910
+ return null;
911
+ };
912
+
913
+ return walk(path.parse(process.cwd()).root, 0);
914
+ }
915
+
916
+ function isExistingDirectory(candidate) {
917
+ try { return fs.statSync(candidate).isDirectory(); } catch { return false; }
918
+ }
919
+
920
+ async function readSqlitePaths(dbPath, sql) {
921
+ try { const { stdout } = await execFileAsync("sqlite3", [dbPath, "-noheader", "-separator", "\t", sql], { timeout: HARD_TIMEOUT_MS }); return stdout.split(/\r?\n/).filter(Boolean).map((line) => line.split("\t")[0]).filter(Boolean); } catch { return []; }
922
+ }
923
+ async function readHermesDbWorkspaces(args) {
924
+ const db = args.hermesDbPath ?? path.join(os.homedir(), ".hermes", "state.db");
925
+ const paths = await readSqlitePaths(db, "SELECT cwd FROM sessions WHERE cwd IS NOT NULL AND cwd != '' GROUP BY cwd ORDER BY COUNT(*) DESC");
926
+ return { workspaces: await workspaceEntries(paths, RUNTIME_META.Hermes, args.limit, args.includeBranch), warnings: [], truncated: paths.length > clampLimit(args.limit) };
927
+ }
928
+ async function readOpenCodeWorkspaces(args) {
929
+ const db = args.opencodeDbPath ?? path.join(os.homedir(), ".local", "share", "opencode", "opencode.db");
930
+ const paths = await readSqlitePaths(db, "SELECT worktree FROM project WHERE worktree IS NOT NULL AND worktree != '' GROUP BY worktree ORDER BY MAX(time_updated) DESC");
931
+ return { workspaces: await workspaceEntries(paths, RUNTIME_META.OpenCode, args.limit, args.includeBranch), warnings: [], truncated: paths.length > clampLimit(args.limit) };
932
+ }
933
+
794
934
  /**
795
935
  * Resolve OpenClaw home dir. Mirrors bin/agentlink resolveOpenClawHome():
796
936
  * OPENCLAW_HOME 环境变量优先,否则 <home>/.openclaw。
@@ -265,7 +265,7 @@ export function createUnifiedDispatchHandler({
265
265
  return false; // not a dispatch request — let caller handle it
266
266
  }
267
267
 
268
- const { thread_id, run_id, input, blocks = [], workspace_path } = request.request ?? {};
268
+ const { thread_id, run_id, input, blocks = [], workspace_path, resume_session_id } = request.request ?? {};
269
269
 
270
270
  // Idempotency check
271
271
  if (dispatchedRunIds.has(run_id)) {
@@ -352,6 +352,10 @@ export function createUnifiedDispatchHandler({
352
352
  input: effectiveInput,
353
353
  blocks: processedBlocks,
354
354
  workspace_path: workspace_path || null,
355
+ // relay only supplies this for the newest completed history import.
356
+ // Runtime adapters seed it only if their thread has no active local
357
+ // session, preventing later imported history from switching context.
358
+ resume_session_id: resume_session_id || null,
355
359
  // relay 信封顶层 sessionKey(App 侧 abort RPC 用它定位要杀的 run)
356
360
  session_key: request.sessionKey || null,
357
361
  onChunk: async (chunk) => {
@@ -423,8 +427,11 @@ export function createUnifiedDispatchHandler({
423
427
  });
424
428
  }
425
429
  try {
430
+ const failureCode = err?.code === "workspace_context_unavailable"
431
+ ? "workspace_context_unavailable"
432
+ : "runtime.crash";
426
433
  await producer.postEvents([
427
- buildRunFailed(run_id, "runtime.crash", String(err?.message || err)),
434
+ buildRunFailed(run_id, failureCode, String(err?.message || err)),
428
435
  ]);
429
436
  } catch (postErr) {
430
437
  log.error("dispatch.post_failed.error", "failed to post run.failed envelope", {
@@ -0,0 +1,14 @@
1
+ // npm postinstall 入口(openspec m-20260817-windows-runtime-parity 追加修复)。
2
+ //
3
+ // 🔴 为什么是独立文件而不是 package.json 里的 `node -e '...'` 内联:
4
+ // 内联版用单引号包 JS 代码,Windows 上 npm 跑生命周期脚本走 cmd.exe,
5
+ // 单引号不是引号 —— 代码带着字面引号被 node 当模块路径解析,postinstall
6
+ // 必然 exit 1,进而把整个 `npm i -g @xyagent/cli` 判为安装失败
7
+ // (2026-08-18 Windows 真机复现;macOS 的 sh 认单引号所以从未暴露)。
8
+ // 脚本文件零引号问题,且放在 src-ext/ 下(package.json files 白名单已包含)。
9
+ //
10
+ // 行为与原内联版完全一致:best-effort 重启守护进程,任何失败都吞掉 ——
11
+ // postinstall 绝不能因此把安装本身搞失败。
12
+ import("./core/daemonRestart.mjs")
13
+ .then((m) => m.kickstartDaemons())
14
+ .catch(() => {});
@@ -0,0 +1,60 @@
1
+ // 桥接会话账本 —— 「重复导入」问题的方案 ①(打标侧)。
2
+ //
3
+ // 背景:经 AgentLink 桥接的实时对话会同时被 CLI 记进本机会话文件,且该文件与
4
+ // 用户手动 CLI 会话**无任何可区分标记**(实测 CodeBuddy 两类文件的
5
+ // `providerData.agent` 均为 "cli")。桌面端「重新导入历史」会把已经通过实时
6
+ // 链路存在于线程里的同一段话再追加一遍。
7
+ //
8
+ // 方案:bridge 每捕获到一个 CLI 原生 session id(onSession / resume_hint),
9
+ // 就把 (runtime, session_id) 追加进本账本;桌面端导入扫描时读它,命中的
10
+ // 会话文件整个跳过 —— 那段对话已经在线程里了。
11
+ //
12
+ // 三条口径:
13
+ // 1. 账本位置**固定** `~/.agentlink/bridged-cli-sessions.ndjson`,不跟 bridge
14
+ // 的 --debug state 走:CLI 本机历史本来就不分 debug,账本也不该分。
15
+ // 2. 打标是 best-effort:写失败(磁盘满 / 权限)静默吞掉,绝不影响对话链路;
16
+ // 漏标的代价只是「那一条会话可能被重复导入」,远小于打断实时对话。
17
+ // 3. 只追加不清理:账本行是 40 字节量级,一万条才 400KB,不值得引入压缩逻辑。
18
+
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+
23
+ /** 测试用环境变量:覆盖账本路径(生产不设,走 ~/.agentlink 固定位置)。 */
24
+ const LEDGER_PATH_ENV = "AGENTLINK_BRIDGED_LEDGER";
25
+
26
+ /** 进程内已记录的 session id —— 同一会话每轮对话都会报同一个 id,只落一行。 */
27
+ const recorded = new Set();
28
+
29
+ export function bridgedSessionLedgerPath() {
30
+ const override = process.env[LEDGER_PATH_ENV];
31
+ if (override) return override;
32
+ return path.join(os.homedir(), ".agentlink", "bridged-cli-sessions.ndjson");
33
+ }
34
+
35
+ /**
36
+ * 记录一个经桥接产生的 CLI 原生 session id。幂等(进程内去重 + 跨进程靠
37
+ * 桌面端读取时按 session_id 建集合,重复行无害)。
38
+ */
39
+ export function recordBridgedSession(runtime, sessionId) {
40
+ const id = String(sessionId || "").trim();
41
+ if (!id) return;
42
+ const key = `${runtime}${id}`;
43
+ if (recorded.has(key)) return;
44
+ recorded.add(key);
45
+ try {
46
+ const ledger = bridgedSessionLedgerPath();
47
+ fs.mkdirSync(path.dirname(ledger), { recursive: true });
48
+ fs.appendFileSync(
49
+ ledger,
50
+ `${JSON.stringify({ runtime: String(runtime || ""), session_id: id, recorded_at: new Date().toISOString() })}\n`,
51
+ );
52
+ } catch {
53
+ // best-effort:见文件头口径 2。
54
+ }
55
+ }
56
+
57
+ /** 测试用:清空进程内去重缓存(账本文件由测试自己管理)。 */
58
+ export function resetBridgedSessionCacheForTest() {
59
+ recorded.clear();
60
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Qwen Code 与 CodeBuddy 的 stream-json 都采用 Claude 风格消息信封:
3
+ * system(init) → assistant/user 消息 → result。这里直接翻成 unified chunks,
4
+ * 避免把两套官方兼容协议各写一遍。
5
+ */
6
+ export function translateClaudeSchemaEvent(event) {
7
+ if (!event || typeof event !== "object") return null;
8
+ if (event.type === "result") {
9
+ if (event.is_error || event.subtype === "error") {
10
+ const message = event.error?.message || event.result || "runtime failed";
11
+ return { event: "response.failed", error: { message: String(message) } };
12
+ }
13
+ return { event: "response.completed" };
14
+ }
15
+
16
+ const message = event.message;
17
+ const blocks = Array.isArray(message?.content) ? message.content : [];
18
+ const parent = event.parent_tool_use_id || null;
19
+ const withParent = (chunk) => parent ? { ...chunk, parent_tool_use_id: parent } : chunk;
20
+ const chunks = [];
21
+
22
+ if (event.type === "assistant") {
23
+ for (const block of blocks) {
24
+ if (block?.type === "text" && block.text) {
25
+ chunks.push(withParent({ event: "response.output_text.delta", delta: String(block.text) }));
26
+ } else if ((block?.type === "thinking" || block?.type === "reasoning") && block.text) {
27
+ chunks.push(withParent({ event: "response.reasoning_summary_text.delta", delta: String(block.text) }));
28
+ } else if (block?.type === "tool_use") {
29
+ const callId = String(block.id || "");
30
+ const name = String(block.name || "unknown");
31
+ const input = block.input && typeof block.input === "object" ? block.input : {};
32
+ chunks.push(withParent({ event: "response.tool_use.start", call_id: callId, name, input }));
33
+ chunks.push(withParent({ event: "response.tool_use.completed", call_id: callId, name, input }));
34
+ }
35
+ }
36
+ } else if (event.type === "user") {
37
+ for (const block of blocks) {
38
+ if (block?.type !== "tool_result") continue;
39
+ const text = typeof block.content === "string" ? block.content : "";
40
+ chunks.push(withParent({
41
+ event: "response.tool_result",
42
+ call_id: String(block.tool_use_id || ""),
43
+ output: text ? [{ type: "text", format: "plain", text }] : [],
44
+ is_error: !!block.is_error,
45
+ }));
46
+ }
47
+ }
48
+ return chunks;
49
+ }
@@ -0,0 +1,168 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+
4
+ import { bootstrapAgentlinkTools } from "../../core/agentlinkToolsBootstrap.mjs";
5
+ import { createActiveRunRegistry } from "../../core/activeRuns.mjs";
6
+ import { installAutoTunnel } from "../../core/autoTunnelWiring.mjs";
7
+ import { runWorkerWithRevokeHandling } from "../../core/bridgeSelfUninstall.mjs";
8
+ import { ensureDefaultWorkspace } from "../../core/defaultWorkspace.mjs";
9
+ import { resolveSession } from "../../core/pairCodeClient.mjs";
10
+ import { createRelayWorker } from "../../core/relayWorker.mjs";
11
+ import { createUnifiedDispatchHandler } from "../../core/unifiedDispatchHandler.mjs";
12
+ import { scanWorkspacesByRuntime } from "../../core/scanWorkspaces.mjs";
13
+
14
+ export function createHeadlessRuntimeController({ log } = {}) {
15
+ const activeRuns = createActiveRunRegistry({ log });
16
+
17
+ async function invoke(invokeImpl, args) {
18
+ let unregister = () => {};
19
+ try {
20
+ return await invokeImpl({
21
+ ...args,
22
+ onProcess: (control) => {
23
+ unregister();
24
+ unregister = activeRuns.register({
25
+ sessionKey: args.session_key ?? "",
26
+ threadId: args.thread_id ?? "",
27
+ runId: args.run_id ?? "",
28
+ kill: (reason) => control.terminate(reason),
29
+ });
30
+ args.onProcess?.(control);
31
+ },
32
+ });
33
+ } finally {
34
+ unregister();
35
+ }
36
+ }
37
+
38
+ function handleRPC(method, params = {}) {
39
+ if (method !== "sessions.abort" && method !== "chat.abort") return null;
40
+ const result = activeRuns.abort({
41
+ key: params.key ?? params.sessionKey ?? "",
42
+ runId: params.runId ?? "",
43
+ });
44
+ if (result.aborted === 0 && !result.status) {
45
+ return { ok: true, ...result, status: "no-active-run" };
46
+ }
47
+ return { ok: true, ...result };
48
+ }
49
+
50
+ return {
51
+ invoke,
52
+ handleRPC,
53
+ shutdown: (reason) => activeRuns.abortAll(reason),
54
+ size: () => activeRuns.size(),
55
+ };
56
+ }
57
+
58
+ /** Shared long-poll bridge for headless NDJSON runtimes such as Cursor/OpenCode. */
59
+ export async function runHeadlessCliBridge({ runtime, options, log, preflight, createInvoke }) {
60
+ const pre = preflight({ log });
61
+ if (!pre.ok) {
62
+ process.stderr.write(`${runtime} preflight failed: ${pre.error}\n`);
63
+ if (pre.hint) process.stderr.write(`${pre.hint}\n`);
64
+ process.exitCode = 65;
65
+ return;
66
+ }
67
+
68
+ let session;
69
+ try {
70
+ session = await resolveSession({ runtime, options, log });
71
+ } catch (err) {
72
+ process.stderr.write(`${err?.message || err}\n`);
73
+ process.exitCode = 65;
74
+ return;
75
+ }
76
+ if (!session.bridgeToken) {
77
+ process.stderr.write(`缺少 bridge_token,请重新运行 agentlink pair <code> -r ${runtime}\n`);
78
+ process.exitCode = 65;
79
+ return;
80
+ }
81
+
82
+ const fallbackWorkspace = path.resolve(options.cwd || await ensureDefaultWorkspace(runtime));
83
+ const sessLog = log.child({ gw: session.gwId });
84
+ const worker = createRelayWorker({
85
+ relayUrl: session.relayUrl,
86
+ gwId: session.gwId,
87
+ bridgeToken: session.bridgeToken,
88
+ runtime,
89
+ log: sessLog,
90
+ });
91
+ installAutoTunnel({ relayUrl: session.relayUrl, log: sessLog });
92
+
93
+ let lastRequestId = null;
94
+ let toolsLifecycle = null;
95
+ try {
96
+ toolsLifecycle = await bootstrapAgentlinkTools({
97
+ runtime,
98
+ gwId: session.gwId,
99
+ relayUrl: session.relayUrl,
100
+ bridgeToken: session.bridgeToken,
101
+ worker,
102
+ log: sessLog,
103
+ version: pre.version || "",
104
+ getLastRequestId: () => lastRequestId,
105
+ });
106
+ } catch (err) {
107
+ sessLog.warn("agentlink_tools.bootstrap.failed", "continuing without tools", {
108
+ err: err?.message || String(err),
109
+ });
110
+ }
111
+
112
+ const controller = createHeadlessRuntimeController({ log: sessLog });
113
+ const invoke = createInvoke({ fallbackWorkspace, log: sessLog, preflightResult: pre });
114
+ const dispatch = createUnifiedDispatchHandler({
115
+ relayUrl: session.relayUrl,
116
+ bridgeToken: session.bridgeToken,
117
+ gatewayId: session.gwId,
118
+ log: sessLog,
119
+ openclawInvoke: (args) => controller.invoke(invoke, args),
120
+ });
121
+ async function handleRequest(request, ctx) {
122
+ lastRequestId = ctx.requestId;
123
+ if (request?.request?.__relayKind === "rpc") {
124
+ if (request.request.method === "workspaces.scan") {
125
+ const result = await scanWorkspacesByRuntime({
126
+ runtimeKind: runtime,
127
+ gatewayId: session.gwId,
128
+ params: request.request.params ?? {},
129
+ });
130
+ await ctx.publishEvent({ event: "response", statusCode: 200, contentType: "application/json; charset=utf-8", body: JSON.stringify(result) });
131
+ await ctx.publishEvent({ event: "end" });
132
+ return;
133
+ }
134
+ const result = controller.handleRPC(request.request.method, request.request.params ?? {});
135
+ if (result) {
136
+ await ctx.publishEvent({
137
+ event: "response",
138
+ statusCode: 200,
139
+ contentType: "application/json; charset=utf-8",
140
+ body: JSON.stringify(result),
141
+ });
142
+ await ctx.publishEvent({ event: "end" });
143
+ return;
144
+ }
145
+ }
146
+ const consumed = await dispatch(request, ctx);
147
+ if (!consumed) {
148
+ throw new Error(`${runtime} 暂不支持该请求类型`);
149
+ }
150
+ }
151
+
152
+ let shuttingDown = false;
153
+ async function shutdown(reason) {
154
+ if (shuttingDown) return;
155
+ shuttingDown = true;
156
+ controller.shutdown(reason);
157
+ worker.stop(reason);
158
+ try { await toolsLifecycle?.stop?.(); } catch {}
159
+ }
160
+ process.once("SIGINT", () => { void shutdown("SIGINT"); });
161
+ process.once("SIGTERM", () => { void shutdown("SIGTERM"); });
162
+
163
+ sessLog.info("bridge.loop.entered", `${runtime} worker entering long-poll loop`, {
164
+ cwd: fallbackWorkspace,
165
+ version: pre.version || "",
166
+ });
167
+ await runWorkerWithRevokeHandling({ worker, handler: handleRequest, runtime, log: sessLog });
168
+ }