@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.
- package/README.md +42 -0
- package/bin/agentlink +468 -86
- package/package.json +3 -3
- package/src/tunnel_service.mjs +17 -1
- package/src-ext/bin.mjs +12 -11
- package/src-ext/commands/agent.mjs +34 -0
- 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/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 +21 -1
- package/src-ext/core/runtimeRegistry.mjs +180 -0
- package/src-ext/core/scanWorkspaces.mjs +163 -23
- package/src-ext/core/unifiedDispatchHandler.mjs +9 -2
- package/src-ext/postinstall.mjs +14 -0
- 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/resolveWorkspaceCwd.mjs +47 -0
- package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
- package/src-ext/runtime/claude/handleRequest.mjs +15 -37
- 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/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
|
@@ -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 =
|
|
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
|
-
|
|
389
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
797
|
+
if (!kind) return null;
|
|
772
798
|
const lower = String(kind).toLowerCase().trim();
|
|
773
|
-
return RUNTIME_KIND_MAP[lower] ??
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|