@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.
Files changed (72) hide show
  1. package/README.md +42 -0
  2. package/bin/agentlink +1002 -245
  3. package/bin/agentlink-agent +0 -0
  4. package/bin/agentlink-mcp-stdio +0 -0
  5. package/package.json +5 -3
  6. package/src/core.mjs +26 -0
  7. package/src/tunnel_service.mjs +17 -1
  8. package/src-ext/bin.mjs +12 -12
  9. package/src-ext/commands/agent.mjs +33 -10
  10. package/src-ext/commands/pair.mjs +122 -23
  11. package/src-ext/commands/service.mjs +1 -1
  12. package/src-ext/core/activeRuns.mjs +26 -9
  13. package/src-ext/core/agentlinkToolsBootstrap.mjs +1 -1
  14. package/src-ext/core/autoTunnelDetector.mjs +6 -1
  15. package/src-ext/core/autoTunnelWiring.mjs +1 -1
  16. package/src-ext/core/bridgeSelfUninstall.mjs +1 -1
  17. package/src-ext/core/defaultWorkspace.mjs +43 -13
  18. package/src-ext/core/defaultWorkspaceSync.mjs +20 -0
  19. package/src-ext/core/installationIdentity.mjs +94 -0
  20. package/src-ext/core/mcpRuntimeFanout.mjs +23 -1
  21. package/src-ext/core/pairCodeClient.mjs +48 -8
  22. package/src-ext/core/pairInventory.mjs +31 -6
  23. package/src-ext/core/relayWorker.mjs +22 -2
  24. package/src-ext/core/runtimeRegistry.mjs +180 -0
  25. package/src-ext/core/scanDeeplink.mjs +67 -0
  26. package/src-ext/core/scanPairFlow.mjs +28 -0
  27. package/src-ext/core/scanQrRenderer.mjs +40 -0
  28. package/src-ext/core/scanWorkspaces.mjs +163 -23
  29. package/src-ext/core/unifiedDispatchHandler.mjs +12 -5
  30. package/src-ext/core/usageReporter.mjs +1 -2
  31. package/src-ext/openclaw-plugin/envelope-builder.cjs +2 -2
  32. package/src-ext/openclaw-plugin/todoTranslatorUtils.cjs +1 -1
  33. package/src-ext/runtime/_shared/bridgedSessionLedger.mjs +60 -0
  34. package/src-ext/runtime/_shared/claudeSchemaEvent.mjs +49 -0
  35. package/src-ext/runtime/_shared/headlessCliBridge.mjs +168 -0
  36. package/src-ext/runtime/_shared/jsonMcpConfigAdapter.mjs +70 -0
  37. package/src-ext/runtime/_shared/ndjsonProcess.mjs +141 -0
  38. package/src-ext/runtime/_shared/relayObjectToBlock.mjs +9 -11
  39. package/src-ext/runtime/_shared/resolveWorkspaceCwd.mjs +47 -0
  40. package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
  41. package/src-ext/runtime/_shared/todoTranslatorUtils.mjs +1 -1
  42. package/src-ext/runtime/claude/handleRequest.mjs +15 -37
  43. package/src-ext/runtime/claude/launcher.mjs +0 -6
  44. package/src-ext/runtime/claude/stdoutParser.mjs +8 -3
  45. package/src-ext/runtime/codebuddy/index.mjs +41 -0
  46. package/src-ext/runtime/codex/handleRequest.mjs +13 -35
  47. package/src-ext/runtime/cursor/index.mjs +46 -0
  48. package/src-ext/runtime/cursor/mcpConfigAdapter.mjs +15 -0
  49. package/src-ext/runtime/deepagents/preflight.mjs +57 -0
  50. package/src-ext/runtime/hermes/envSetup.mjs +22 -8
  51. package/src-ext/runtime/hermes/gatewayManager.mjs +239 -3
  52. package/src-ext/runtime/hermes/handleRequest.mjs +13 -0
  53. package/src-ext/runtime/hermes/httpBackend.mjs +12 -1
  54. package/src-ext/runtime/hermes/index.mjs +1 -1
  55. package/src-ext/runtime/hermes/preflight.mjs +2 -1
  56. package/src-ext/runtime/kimi/index.mjs +100 -0
  57. package/src-ext/runtime/openclaw/buildOpenclawDaemonInput.mjs +0 -6
  58. package/src-ext/runtime/openclaw/workspaceContext.mjs +58 -0
  59. package/src-ext/runtime/opencode/index.mjs +48 -0
  60. package/src-ext/runtime/opencode/mcpConfigAdapter.mjs +15 -0
  61. package/src-ext/runtime/opencode/preflight.mjs +72 -0
  62. package/src-ext/runtime/qwen/index.mjs +42 -0
  63. package/src-ext/service/serviceManager.mjs +120 -42
  64. package/src-shared/envelope_builder.mjs +2 -2
  65. package/src-ext/runtime/picoclaw/constants.mjs +0 -39
  66. package/src-ext/runtime/picoclaw/handleRequest.mjs +0 -289
  67. package/src-ext/runtime/picoclaw/index.mjs +0 -314
  68. package/src-ext/runtime/picoclaw/pairFlow.mjs +0 -224
  69. package/src-ext/runtime/picoclaw/state.mjs +0 -78
  70. package/src-ext/runtime/picoclaw/todoTranslator.mjs +0 -67
  71. package/src-ext/runtime/picoclaw/translator.mjs +0 -272
  72. package/src-ext/runtime/picoclaw/wsClient.mjs +0 -129
@@ -0,0 +1,40 @@
1
+ // `agentlink scan` 终端二维码渲染 —— 封装 `qrcode-terminal`(`small: true`)。
2
+ //
3
+ // spec S6(二维码展示兜底):终端不支持 ASCII 二维码渲染时(渲染抛错),
4
+ // SHALL 回退为打印 payload 文本,不因渲染失败中断配对流程。因此本函数
5
+ // **永不抛错**——渲染失败被内部吞掉并转成文本兜底。
6
+
7
+ import qrcodeTerminal from "qrcode-terminal";
8
+
9
+ /**
10
+ * 在终端渲染 payload 的 ASCII 二维码;渲染失败时回退打印 payload 文本。
11
+ *
12
+ * @param {string} payload 二维码内容(qr_payload deeplink)
13
+ * @param {{
14
+ * generateImpl?: (input: string, opts: {small: boolean}, cb: (output: string) => void) => void,
15
+ * print?: (s: string) => void,
16
+ * warn?: (s: string) => void,
17
+ * }} [deps]
18
+ * @returns {{ ok: boolean, mode: "qr" | "text", error?: string }}
19
+ */
20
+ export function renderQrToTerminal(payload, { generateImpl, print, warn } = {}) {
21
+ // NB: must call through `qrcodeTerminal.generate(...)`, not a bare
22
+ // reference to the method — qrcode-terminal's generate() reads `this.error`
23
+ // internally, and a detached reference loses that binding (`this` would be
24
+ // undefined under ESM's implicit strict mode), throwing on every call.
25
+ const generate = generateImpl || ((input, opts, cb) => qrcodeTerminal.generate(input, opts, cb));
26
+ const doPrint = print || ((s) => console.log(s));
27
+ const doWarn = warn || ((s) => console.warn(s));
28
+ const text = payload == null ? "" : String(payload);
29
+ try {
30
+ generate(text, { small: true }, (output) => {
31
+ doPrint(output);
32
+ });
33
+ return { ok: true, mode: "qr" };
34
+ } catch (err) {
35
+ const message = String(err?.message || err);
36
+ doWarn(`⚠ 二维码渲染失败,回退为文本(仍可完成配对):${message}`);
37
+ doPrint(text);
38
+ return { ok: false, mode: "text", error: message };
39
+ }
40
+ }
@@ -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。
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Injection points for testing:
11
11
  * producerFactory({ relayUrl, bridgeToken, threadId }) → { postEvents }
12
- * openclawInvoke({ input, onChunk }) → async void (default: picoclaw wsHolder)
12
+ * openclawInvoke({ input, onChunk }) → async void (default: openclaw wsHolder)
13
13
  * flushPolicy({ size, timeMs }) → 控制 micro-batched flush 触发阈值(默认 {size:1, timeMs:0} —— 即来即发,保流式体验)
14
14
  */
15
15
 
@@ -29,7 +29,7 @@ import { signGetCosObject } from "./cosUploadClient.mjs";
29
29
  // Streaming-first policy: size:1 means every envelope (especially
30
30
  // content_block.delta) posts immediately, so the App SSE stream receives
31
31
  // chunks as the runtime produces them rather than in 8-envelope batches.
32
- // Tests/picoclaw replay can override via flushPolicy if they want fewer POSTs.
32
+ // Tests can override via flushPolicy if they want fewer POSTs.
33
33
  const DEFAULT_FLUSH_POLICY = Object.freeze({ size: 1, timeMs: 0 });
34
34
  const STREAM_DEBUG = process.env.BUBBO_DEBUG_STREAM === "1";
35
35
 
@@ -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)) {
@@ -343,7 +343,7 @@ export function createUnifiedDispatchHandler({
343
343
  let runPausedFromRuntime = false;
344
344
 
345
345
  if (typeof openclawInvoke === "function") {
346
- // Injected (test mode or picoclaw ws integration)
346
+ // Injected (test mode or ws integration)
347
347
  // thread_id 透传给 runtime adapter,让需要"按 thread 维护原生 session
348
348
  // 续接"的 runtime(如 claude --resume <sid>)可以做 mapping。
349
349
  await openclawInvoke({
@@ -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", {
@@ -40,8 +40,7 @@ export function createUsageReporter({ publishEvent, runtime, log, debounceMs = D
40
40
  if (
41
41
  runtime !== "claude" &&
42
42
  runtime !== "codex" &&
43
- runtime !== "hermes" &&
44
- runtime !== "picoclaw"
43
+ runtime !== "hermes"
45
44
  ) {
46
45
  throw new Error(`createUsageReporter: bad runtime ${runtime}`);
47
46
  }
@@ -448,7 +448,7 @@ function translateOpenClawChunkToEnvelope(chunk, runId, blockState) {
448
448
  }
449
449
  envelopes.push(env);
450
450
  } else if (event === "response.function_call_arguments.delta") {
451
- // picoclaw legacy event name → maps to tool_use.start + arguments.delta.
451
+ // legacy event name → maps to tool_use.start + arguments.delta.
452
452
  // chunk shape: { call_id, name, arguments } where arguments is a JSON
453
453
  // fragment to append. We auto-start a tool_use block on first occurrence.
454
454
  const callId = String(chunk.call_id || "");
@@ -540,7 +540,7 @@ function translateOpenClawChunkToEnvelope(chunk, runId, blockState) {
540
540
  envelopes.push(started, completed);
541
541
  } else if (event === "response.output_text.done") {
542
542
  // No incremental envelope needed — full text already accumulated via deltas.
543
- // If runtime carries authoritative full text on .done (e.g. picoclaw), prefer it.
543
+ // If the runtime carries authoritative full text on .done, prefer it.
544
544
  if (typeof chunk.text === "string" && chunk.text.length > 0) {
545
545
  text.content = chunk.text;
546
546
  }
@@ -75,7 +75,7 @@ function normalizeStatus(raw) {
75
75
  * rawItems 中每项支持字段:
76
76
  * - id?: string(有则保留,无则生成 td_{idx})
77
77
  * - content?: string(主内容字段,Claude/Hermes 用)
78
- * - step?: string(Codex/picoclaw 用,作为 content 的 fallback)
78
+ * - step?: string(Codex 用,作为 content 的 fallback)
79
79
  * - status?: string(经 normalizeStatus 归一)
80
80
  *
81
81
  * @param {Array<{id?: string, content?: string, step?: string, status?: string}>} rawItems
@@ -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
+ }