@xyagent/cli 0.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/bin/agentlink +1002 -245
- package/bin/agentlink-agent +0 -0
- package/bin/agentlink-mcp-stdio +0 -0
- package/package.json +5 -3
- package/src/core.mjs +26 -0
- package/src/tunnel_service.mjs +17 -1
- package/src-ext/bin.mjs +12 -12
- package/src-ext/commands/agent.mjs +33 -10
- package/src-ext/commands/pair.mjs +122 -23
- package/src-ext/commands/service.mjs +1 -1
- package/src-ext/core/activeRuns.mjs +26 -9
- package/src-ext/core/agentlinkToolsBootstrap.mjs +1 -1
- package/src-ext/core/autoTunnelDetector.mjs +6 -1
- package/src-ext/core/autoTunnelWiring.mjs +1 -1
- package/src-ext/core/bridgeSelfUninstall.mjs +1 -1
- package/src-ext/core/defaultWorkspace.mjs +43 -13
- package/src-ext/core/defaultWorkspaceSync.mjs +20 -0
- package/src-ext/core/installationIdentity.mjs +94 -0
- package/src-ext/core/mcpRuntimeFanout.mjs +23 -1
- package/src-ext/core/pairCodeClient.mjs +48 -8
- package/src-ext/core/pairInventory.mjs +31 -6
- package/src-ext/core/relayWorker.mjs +22 -2
- package/src-ext/core/runtimeRegistry.mjs +180 -0
- package/src-ext/core/scanDeeplink.mjs +67 -0
- package/src-ext/core/scanPairFlow.mjs +28 -0
- package/src-ext/core/scanQrRenderer.mjs +40 -0
- package/src-ext/core/scanWorkspaces.mjs +163 -23
- package/src-ext/core/unifiedDispatchHandler.mjs +12 -5
- package/src-ext/core/usageReporter.mjs +1 -2
- package/src-ext/openclaw-plugin/envelope-builder.cjs +2 -2
- package/src-ext/openclaw-plugin/todoTranslatorUtils.cjs +1 -1
- package/src-ext/runtime/_shared/bridgedSessionLedger.mjs +60 -0
- package/src-ext/runtime/_shared/claudeSchemaEvent.mjs +49 -0
- package/src-ext/runtime/_shared/headlessCliBridge.mjs +168 -0
- package/src-ext/runtime/_shared/jsonMcpConfigAdapter.mjs +70 -0
- package/src-ext/runtime/_shared/ndjsonProcess.mjs +141 -0
- package/src-ext/runtime/_shared/relayObjectToBlock.mjs +9 -11
- package/src-ext/runtime/_shared/resolveWorkspaceCwd.mjs +47 -0
- package/src-ext/runtime/_shared/slashCommandRouter.mjs +10 -0
- package/src-ext/runtime/_shared/todoTranslatorUtils.mjs +1 -1
- package/src-ext/runtime/claude/handleRequest.mjs +15 -37
- package/src-ext/runtime/claude/launcher.mjs +0 -6
- package/src-ext/runtime/claude/stdoutParser.mjs +8 -3
- package/src-ext/runtime/codebuddy/index.mjs +41 -0
- package/src-ext/runtime/codex/handleRequest.mjs +13 -35
- package/src-ext/runtime/cursor/index.mjs +46 -0
- package/src-ext/runtime/cursor/mcpConfigAdapter.mjs +15 -0
- package/src-ext/runtime/deepagents/preflight.mjs +57 -0
- package/src-ext/runtime/hermes/envSetup.mjs +22 -8
- package/src-ext/runtime/hermes/gatewayManager.mjs +239 -3
- package/src-ext/runtime/hermes/handleRequest.mjs +13 -0
- package/src-ext/runtime/hermes/httpBackend.mjs +12 -1
- package/src-ext/runtime/hermes/index.mjs +1 -1
- package/src-ext/runtime/hermes/preflight.mjs +2 -1
- package/src-ext/runtime/kimi/index.mjs +100 -0
- package/src-ext/runtime/openclaw/buildOpenclawDaemonInput.mjs +0 -6
- package/src-ext/runtime/openclaw/workspaceContext.mjs +58 -0
- package/src-ext/runtime/opencode/index.mjs +48 -0
- package/src-ext/runtime/opencode/mcpConfigAdapter.mjs +15 -0
- package/src-ext/runtime/opencode/preflight.mjs +72 -0
- package/src-ext/runtime/qwen/index.mjs +42 -0
- package/src-ext/service/serviceManager.mjs +120 -42
- package/src-shared/envelope_builder.mjs +2 -2
- package/src-ext/runtime/picoclaw/constants.mjs +0 -39
- package/src-ext/runtime/picoclaw/handleRequest.mjs +0 -289
- package/src-ext/runtime/picoclaw/index.mjs +0 -314
- package/src-ext/runtime/picoclaw/pairFlow.mjs +0 -224
- package/src-ext/runtime/picoclaw/state.mjs +0 -78
- package/src-ext/runtime/picoclaw/todoTranslator.mjs +0 -67
- package/src-ext/runtime/picoclaw/translator.mjs +0 -272
- package/src-ext/runtime/picoclaw/wsClient.mjs +0 -129
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const SERVER_NAME = "agentlink";
|
|
5
|
+
|
|
6
|
+
export function createJsonMcpConfigAdapter({ resolvePath, readServers, writeServers, desiredEntry }) {
|
|
7
|
+
async function read() {
|
|
8
|
+
return await fs.readFile(resolvePath(), "utf8");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function register({ args, log } = {}) {
|
|
12
|
+
const configPath = resolvePath();
|
|
13
|
+
const config = await readConfig(configPath);
|
|
14
|
+
const servers = readServers(config);
|
|
15
|
+
const desired = desiredEntry(args || []);
|
|
16
|
+
if (JSON.stringify(servers[SERVER_NAME]) === JSON.stringify(desired)) {
|
|
17
|
+
return { changed: false, configPath, mode: "noop_idempotent" };
|
|
18
|
+
}
|
|
19
|
+
servers[SERVER_NAME] = desired;
|
|
20
|
+
writeServers(config, servers);
|
|
21
|
+
await writeConfig(configPath, config);
|
|
22
|
+
log?.info?.("mcp.fanout.json.write", "wrote agentlink MCP entry", { configPath });
|
|
23
|
+
return { changed: true, configPath };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function deregister() {
|
|
27
|
+
const configPath = resolvePath();
|
|
28
|
+
let config;
|
|
29
|
+
try {
|
|
30
|
+
config = await readConfig(configPath, { requireExisting: true });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error?.code === "ENOENT") return { changed: false, configPath, mode: "noop_no_config" };
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
const servers = readServers(config);
|
|
36
|
+
if (!(SERVER_NAME in servers)) return { changed: false, configPath, mode: "noop_not_registered" };
|
|
37
|
+
delete servers[SERVER_NAME];
|
|
38
|
+
writeServers(config, servers);
|
|
39
|
+
await writeConfig(configPath, config);
|
|
40
|
+
return { changed: true, configPath };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function status() {
|
|
44
|
+
const configPath = resolvePath();
|
|
45
|
+
try {
|
|
46
|
+
const config = await readConfig(configPath, { requireExisting: true });
|
|
47
|
+
return { installed: true, registered: SERVER_NAME in readServers(config), configPath };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error?.code === "ENOENT") return { installed: true, registered: false, configPath };
|
|
50
|
+
return { installed: true, registered: false, configPath, error: error?.message };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { read, register, deregister, status };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readConfig(configPath, { requireExisting = false } = {}) {
|
|
58
|
+
try {
|
|
59
|
+
const raw = await fs.readFile(configPath, "utf8");
|
|
60
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (error?.code === "ENOENT" && !requireExisting) return {};
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function writeConfig(configPath, config) {
|
|
68
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true, mode: 0o700 });
|
|
69
|
+
await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
70
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
|
|
4
|
+
const VERSION_PROBE_TIMEOUT_MS = 5_000;
|
|
5
|
+
const MAX_STDERR_CHARS = 8_000;
|
|
6
|
+
export const PROCESS_TERMINATION_GRACE_MS = 3_000;
|
|
7
|
+
// 子进程连续无任何 stdout/stderr 输出超过该上限视为卡死:kill 进程树并抛错,
|
|
8
|
+
// 由 dispatch 层上报 run.failed —— 否则 run 永久 running,客户端只能无限转圈。
|
|
9
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000;
|
|
10
|
+
|
|
11
|
+
function signalProcessTree(child, signal) {
|
|
12
|
+
if (!child?.pid) return;
|
|
13
|
+
if (process.platform === "win32") {
|
|
14
|
+
const args = ["/pid", String(child.pid), "/t"];
|
|
15
|
+
if (signal === "SIGKILL") args.push("/f");
|
|
16
|
+
spawnSync("taskkill", args, { windowsHide: true, stdio: "ignore" });
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
process.kill(-child.pid, signal);
|
|
21
|
+
} catch {
|
|
22
|
+
try { child.kill(signal); } catch {}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function probeBinary(binary) {
|
|
27
|
+
const result = spawnSync(binary, ["--version"], {
|
|
28
|
+
encoding: "utf8",
|
|
29
|
+
timeout: VERSION_PROBE_TIMEOUT_MS,
|
|
30
|
+
windowsHide: true,
|
|
31
|
+
});
|
|
32
|
+
if (result.error || result.status !== 0) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: result.error?.code === "ENOENT" ? `找不到 ${binary}` : String(result.stderr || result.error || "version probe failed").trim(),
|
|
36
|
+
hint: `请先安装 ${binary},并确认它在 PATH 中。`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, version: String(result.stdout || result.stderr || "").trim().split(/\r?\n/)[0] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function runNdjsonProcess({
|
|
43
|
+
binary,
|
|
44
|
+
args,
|
|
45
|
+
cwd,
|
|
46
|
+
env,
|
|
47
|
+
parseEvent,
|
|
48
|
+
onChunk,
|
|
49
|
+
onSession,
|
|
50
|
+
onProcess,
|
|
51
|
+
terminationGraceMs = PROCESS_TERMINATION_GRACE_MS,
|
|
52
|
+
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
53
|
+
}) {
|
|
54
|
+
const childEnv = env ? { ...process.env, ...env } : process.env;
|
|
55
|
+
const child = spawn(binary, args, {
|
|
56
|
+
cwd,
|
|
57
|
+
env: childEnv,
|
|
58
|
+
shell: false,
|
|
59
|
+
windowsHide: true,
|
|
60
|
+
detached: process.platform !== "win32",
|
|
61
|
+
// stdin 必须是 EOF:留 pipe 不关闭会让子进程(如 opencode run)等 stdin
|
|
62
|
+
// 读到 EOF 才开始干活 → 永久卡死(手动跑是 TTY 不受影响,headless 必挂)。
|
|
63
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
64
|
+
});
|
|
65
|
+
let exited = false;
|
|
66
|
+
let aborted = false;
|
|
67
|
+
let timedOut = false;
|
|
68
|
+
let forceTimer = null;
|
|
69
|
+
const killTree = () => {
|
|
70
|
+
signalProcessTree(child, "SIGTERM");
|
|
71
|
+
forceTimer = setTimeout(() => {
|
|
72
|
+
if (!exited) signalProcessTree(child, "SIGKILL");
|
|
73
|
+
}, terminationGraceMs);
|
|
74
|
+
forceTimer.unref?.();
|
|
75
|
+
};
|
|
76
|
+
const terminate = () => {
|
|
77
|
+
if (exited || aborted) return;
|
|
78
|
+
aborted = true;
|
|
79
|
+
killTree();
|
|
80
|
+
};
|
|
81
|
+
// 空闲看门狗:任何 stdout/stderr 输出都会重置;超时视为子进程卡死。
|
|
82
|
+
let idleTimer = null;
|
|
83
|
+
const armIdleTimer = () => {
|
|
84
|
+
if (!idleTimeoutMs) return;
|
|
85
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
86
|
+
idleTimer = setTimeout(() => {
|
|
87
|
+
if (exited || aborted || timedOut) return;
|
|
88
|
+
timedOut = true;
|
|
89
|
+
killTree();
|
|
90
|
+
}, idleTimeoutMs);
|
|
91
|
+
idleTimer.unref?.();
|
|
92
|
+
};
|
|
93
|
+
armIdleTimer();
|
|
94
|
+
onProcess?.({ pid: child.pid, terminate });
|
|
95
|
+
const exitPromise = new Promise((resolve, reject) => {
|
|
96
|
+
child.once("error", reject);
|
|
97
|
+
child.once("close", (code, signal) => {
|
|
98
|
+
exited = true;
|
|
99
|
+
if (forceTimer) clearTimeout(forceTimer);
|
|
100
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
101
|
+
resolve({ code, signal });
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
let stderr = "";
|
|
105
|
+
let structuredError = "";
|
|
106
|
+
let reportedFailure = false;
|
|
107
|
+
child.stdout.on("data", armIdleTimer);
|
|
108
|
+
child.stderr.setEncoding("utf8");
|
|
109
|
+
child.stderr.on("data", (chunk) => {
|
|
110
|
+
armIdleTimer();
|
|
111
|
+
stderr = (stderr + chunk).slice(-MAX_STDERR_CHARS);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const lines = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
115
|
+
for await (const line of lines) {
|
|
116
|
+
if (!line.trim()) continue;
|
|
117
|
+
let event;
|
|
118
|
+
try { event = JSON.parse(line); }
|
|
119
|
+
catch { continue; }
|
|
120
|
+
const eventError = event?.error?.message || event?.message?.error?.message;
|
|
121
|
+
if (eventError) structuredError = String(eventError);
|
|
122
|
+
const sessionId = event.sessionID || event.session_id;
|
|
123
|
+
if (sessionId) onSession?.(String(sessionId));
|
|
124
|
+
const chunks = parseEvent(event);
|
|
125
|
+
for (const chunk of Array.isArray(chunks) ? chunks : (chunks ? [chunks] : [])) {
|
|
126
|
+
if (chunk?.event === "response.failed") reportedFailure = true;
|
|
127
|
+
await onChunk(chunk);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const exit = await exitPromise;
|
|
132
|
+
if (aborted) return { aborted: true, exit };
|
|
133
|
+
if (timedOut) {
|
|
134
|
+
throw new Error(`${binary} 连续 ${Math.round(idleTimeoutMs / 1000)}s 无输出,判定卡死,已超时终止`);
|
|
135
|
+
}
|
|
136
|
+
if (exit.code !== 0) {
|
|
137
|
+
if (reportedFailure) return;
|
|
138
|
+
throw new Error(structuredError || stderr.trim() || `${binary} exited with code ${exit.code ?? "unknown"}${exit.signal ? ` (${exit.signal})` : ""}`);
|
|
139
|
+
}
|
|
140
|
+
return { aborted: false, exit };
|
|
141
|
+
}
|
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
* { type: "image"|"attachment", source: { kind: "url", url, media_type, attachment_kind }, name?, mime?, size? }
|
|
13
13
|
*
|
|
14
14
|
* @param {object} opts
|
|
15
|
-
* @param {string} opts.runtimeHint - "claude"|"openclaw"|"
|
|
15
|
+
* @param {string} opts.runtimeHint - "claude"|"openclaw"|"codex"|"hermes"
|
|
16
16
|
* @param {string[]|null} opts.modelCaps - modalities 数组(如 ["text","image","pdf"])。
|
|
17
|
-
* null 表示跳过 caps
|
|
17
|
+
* null 表示跳过 caps 检查。
|
|
18
18
|
* 缺失时默认 ["text"]。
|
|
19
19
|
* @param {Function} opts.fetchAndBase64Encode - async (url, opts) => { base64, mediaType, bytes }
|
|
20
20
|
* @param {Function} [opts.fetchAndDecodeText] - async (url, opts) => string
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
* { type: "input_image", source: { type: "base64", media_type, data } }
|
|
27
27
|
* { type: "input_file", source: { type: "base64", media_type: "application/pdf", filename, data } }
|
|
28
28
|
* { type: "input_text", text }
|
|
29
|
-
* { type: "url_ref", url, media_type } // picoclaw only
|
|
30
29
|
*
|
|
31
30
|
* ErrorResult:
|
|
32
31
|
* { error: { code: "caps_mismatch", message, model?, missing_modality } }
|
|
@@ -70,8 +69,12 @@ function resolveAttachmentKind(block) {
|
|
|
70
69
|
|
|
71
70
|
export async function relayObjectToBlock(block, opts = {}) {
|
|
72
71
|
const {
|
|
73
|
-
runtimeHint
|
|
74
|
-
|
|
72
|
+
// 曾经唯一消费 runtimeHint 的分支(url_ref 直通)是移除硬件方案时删掉的
|
|
73
|
+
// 专属逻辑;所有真实 runtime(openclaw/claude/hermes)现在走同一条
|
|
74
|
+
// fetch+base64 路径,不再需要按 runtime 分叉。保留在签名里是为了不动调用方
|
|
75
|
+
// (openclaw/claude/hermes 三处仍显式传它),留作未来按 runtime 差异化的挂钩点。
|
|
76
|
+
runtimeHint: _runtimeHint,
|
|
77
|
+
modelCaps, // null = skip caps check; undefined/missing = default ["text"]
|
|
75
78
|
fetchAndBase64Encode,
|
|
76
79
|
fetchAndDecodeText,
|
|
77
80
|
} = opts;
|
|
@@ -107,12 +110,7 @@ export async function relayObjectToBlock(block, opts = {}) {
|
|
|
107
110
|
};
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
// 2.
|
|
111
|
-
if (runtimeHint === "picoclaw") {
|
|
112
|
-
return { type: "url_ref", url, media_type: mediaType };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// 3. fetch + base64
|
|
113
|
+
// 2. fetch + base64
|
|
116
114
|
try {
|
|
117
115
|
const { base64, mediaType: resolvedMime, bytes } = await fetchAndBase64Encode(url, {
|
|
118
116
|
expectedMime: mediaType,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resolveWorkspaceCwd.mjs — headless NDJSON runtime 的 spawn cwd 解析。
|
|
3
|
+
*
|
|
4
|
+
* 与 claude/codex handleRequest 的解析链对齐:
|
|
5
|
+
* 1. workspace_path 非空 → expandHome + existsSync → 用它(线程绑定工作区)
|
|
6
|
+
* 2. workspace_path 给定但无效/目录不存在 → 稳定失败,绝不 fallback
|
|
7
|
+
* 3. workspace_path 未给 → 默认工作区
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { statSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { expandHome } from "../../core/pathExpand.mjs";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{
|
|
17
|
+
* workspacePath?: string | null,
|
|
18
|
+
* fallbackWorkspace: string,
|
|
19
|
+
* runtime: string,
|
|
20
|
+
* log?: { info?: Function, warn?: Function },
|
|
21
|
+
* }} opts
|
|
22
|
+
* @returns {{ cwd: string, source: "workspace_path" | "default" }}
|
|
23
|
+
*/
|
|
24
|
+
export function resolveWorkspaceCwd({ workspacePath, fallbackWorkspace, runtime, log }) {
|
|
25
|
+
const requested = typeof workspacePath === "string" ? workspacePath.trim() : "";
|
|
26
|
+
if (requested) {
|
|
27
|
+
const resolved = expandHome(requested);
|
|
28
|
+
const requestedAbsolute = path.isAbsolute(requested) || requested === "~" || requested.startsWith("~/");
|
|
29
|
+
if (requestedAbsolute && path.isAbsolute(resolved)) {
|
|
30
|
+
let isDirectory = false;
|
|
31
|
+
try { isDirectory = statSync(resolved).isDirectory(); } catch {}
|
|
32
|
+
if (isDirectory) {
|
|
33
|
+
log?.info?.(`${runtime}.spawn.cwd.workspace`, "using workspace_path as cwd", { cwd: resolved });
|
|
34
|
+
return { cwd: resolved, source: "workspace_path" };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
log?.warn?.(`${runtime}.spawn.cwd.unavailable`, "requested workspace context is unavailable", {
|
|
38
|
+
requested: true,
|
|
39
|
+
});
|
|
40
|
+
const error = new Error(`${runtime}: requested workspace context is unavailable`);
|
|
41
|
+
error.code = "workspace_context_unavailable";
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
const fallback = path.resolve(fallbackWorkspace);
|
|
45
|
+
log?.info?.(`${runtime}.spawn.cwd.default`, "no workspace_path, using default workspace", { cwd: fallback });
|
|
46
|
+
return { cwd: fallback, source: "default" };
|
|
47
|
+
}
|
|
@@ -115,6 +115,16 @@ export async function classifySlashCommand(input, runtime, bridgeCtx) {
|
|
|
115
115
|
// 命中(用户自定义 ~/.claude/commands / skill 命令)→ 透传真跑;
|
|
116
116
|
// 未命中 → __default__ 合成提示,避免英文 "Unknown command" 直出。
|
|
117
117
|
const bare = command.slice(1);
|
|
118
|
+
// system/init 在部分 Claude/Node 组合下会复用 CLI 进程级缓存,刚创建的
|
|
119
|
+
// workspace command 即使 forceFresh 也可能暂时缺席。磁盘 marker 是这里的
|
|
120
|
+
// 权威兜底,且只检查由 SLASH_RE 限定过的安全文件名。
|
|
121
|
+
const workspaceCommand = ctx?.cwd
|
|
122
|
+
? join(ctx.cwd, ".claude", "commands", `${bare}.md`)
|
|
123
|
+
: null;
|
|
124
|
+
const userCommand = join(homedir(), ".claude", "commands", `${bare}.md`);
|
|
125
|
+
if ((workspaceCommand && existsSync(workspaceCommand)) || existsSync(userCommand)) {
|
|
126
|
+
return { kind: "passthrough", command };
|
|
127
|
+
}
|
|
118
128
|
const hitKnown = (init) => {
|
|
119
129
|
const known = Array.isArray(init?.slash_commands) ? init.slash_commands : [];
|
|
120
130
|
return known.some((c) => String(c).toLowerCase() === bare);
|
|
@@ -72,7 +72,7 @@ export function normalizeStatus(raw) {
|
|
|
72
72
|
* rawItems 中每项支持字段:
|
|
73
73
|
* - id?: string(有则保留,无则生成 td_{idx})
|
|
74
74
|
* - content?: string(主内容字段,Claude/Hermes 用)
|
|
75
|
-
* - step?: string(Codex
|
|
75
|
+
* - step?: string(Codex 用,作为 content 的 fallback)
|
|
76
76
|
* - status?: string(经 normalizeStatus 归一)
|
|
77
77
|
*
|
|
78
78
|
* @param {Array<{id?: string, content?: string, step?: string, status?: string}>} rawItems
|
|
@@ -24,8 +24,8 @@ import { probeClaudeSystemInit } from "../_shared/cliExec.mjs";
|
|
|
24
24
|
import { normalizeClaudeLine, createLineStreamParser } from "./stdoutParser.mjs";
|
|
25
25
|
import { createUnifiedDispatchHandler } from "../../core/unifiedDispatchHandler.mjs";
|
|
26
26
|
import { scanWorkspacesByRuntime } from "../../core/scanWorkspaces.mjs";
|
|
27
|
-
import { expandHome } from "../../core/pathExpand.mjs";
|
|
28
27
|
import { ensureDefaultWorkspace, defaultWorkspacePath } from "../../core/defaultWorkspace.mjs";
|
|
28
|
+
import { resolveWorkspaceCwd } from "../_shared/resolveWorkspaceCwd.mjs";
|
|
29
29
|
import { createWorkspace as _defaultCreateWorkspace } from "../../core/createWorkspace.mjs";
|
|
30
30
|
import { createClaudeTodoTranslator } from "./todoTranslator.mjs";
|
|
31
31
|
import { createAskUserQuestionTranslator, aggregateAnswers, buildOptionMaps } from "./askUserQuestionTranslator.mjs";
|
|
@@ -257,38 +257,22 @@ export function createClaudeHandleRequest({ cwd, log, usage, bridgeCtx, dispatch
|
|
|
257
257
|
|
|
258
258
|
// 默认 openclawInvoke:spawn claude --print 子进程,解析 stdout,翻译事件
|
|
259
259
|
// Task 3.5: 加入 blocks 参数(来自 dispatch payload),透传给 writeUserMessage 以支持多模态
|
|
260
|
-
// workspace-aware cwd
|
|
261
|
-
//
|
|
262
|
-
const defaultOpenclawInvoke = async ({ thread_id, run_id, input, blocks = [], workspace_path: reqWorkspacePath, session_key = null, onChunk: _rawOnChunk }) => {
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
// 2. workspace_path 给定但不存在 → fallback 到 defaultWorkspace,emit cwd_fallback 可观测
|
|
266
|
-
// 3. workspace_path 未给 → 直接用 defaultWorkspace
|
|
267
|
-
// 注:factory cwd 不再作为兜底(保留作显式 --cwd 入口)
|
|
260
|
+
// workspace-aware cwd:显式 workspace_path 必须是可用绝对目录;缺失时才使用
|
|
261
|
+
// defaultWorkspace(不再用工厂 cwd,修 service 模式 "/" bug)。
|
|
262
|
+
const defaultOpenclawInvoke = async ({ thread_id, run_id, input, blocks = [], workspace_path: reqWorkspacePath, resume_session_id: resumeSessionIDFromRelay = null, session_key = null, onChunk: _rawOnChunk }) => {
|
|
263
|
+
// v2 workspace_path 是强约束:不可用时稳定失败,不得退回默认目录。
|
|
264
|
+
// route shell 未带 workspace_path 时继续使用原默认工作区。
|
|
268
265
|
let spawnCwd;
|
|
269
|
-
let cwdFallback = null;
|
|
270
266
|
|
|
271
267
|
const { existsSync } = await import("node:fs");
|
|
272
268
|
|
|
273
269
|
if (reqWorkspacePath && typeof reqWorkspacePath === "string" && reqWorkspacePath.trim()) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
} else {
|
|
281
|
-
// workspace_path 给定但不存在 → fallback 到默认工作区,fallback 可观测
|
|
282
|
-
spawnCwd = await ensureDefaultWorkspace("claude");
|
|
283
|
-
cwdFallback = {
|
|
284
|
-
actual_cwd: spawnCwd,
|
|
285
|
-
requested_cwd: reqWorkspacePath,
|
|
286
|
-
reason: `workspace_path '${reqWorkspacePath}' does not exist after expand, falling back to default workspace`,
|
|
287
|
-
};
|
|
288
|
-
log.warn?.("claude.spawn.cwd.not_found", "workspace_path does not exist, falling back to default workspace", {
|
|
289
|
-
workspace_path: reqWorkspacePath, fallback_cwd: spawnCwd,
|
|
290
|
-
});
|
|
291
|
-
}
|
|
270
|
+
({ cwd: spawnCwd } = resolveWorkspaceCwd({
|
|
271
|
+
workspacePath: reqWorkspacePath,
|
|
272
|
+
fallbackWorkspace: cwd,
|
|
273
|
+
runtime: "claude",
|
|
274
|
+
log,
|
|
275
|
+
}));
|
|
292
276
|
} else {
|
|
293
277
|
// 未给 workspace_path → 用默认工作区(lazy ensure,兜底 service 模式 "/" bug)
|
|
294
278
|
spawnCwd = await ensureDefaultWorkspace("claude");
|
|
@@ -320,14 +304,6 @@ export function createClaudeHandleRequest({ cwd, log, usage, bridgeCtx, dispatch
|
|
|
320
304
|
}
|
|
321
305
|
: _rawOnChunk;
|
|
322
306
|
|
|
323
|
-
// fallback 可观测:workspace_path 给定但不存在时,emit run.started 含 cwd_fallback
|
|
324
|
-
if (cwdFallback) {
|
|
325
|
-
await onChunk({
|
|
326
|
-
event: "__envelopes",
|
|
327
|
-
envelopes: [buildRunStarted(run_id, { options: { cwd_fallback: cwdFallback } })],
|
|
328
|
-
});
|
|
329
|
-
}
|
|
330
|
-
|
|
331
307
|
// approval 模式资源(在 try 块外声明,以便 finally 块访问)
|
|
332
308
|
let _approvalPermServer = null;
|
|
333
309
|
let _approvalMcpConfigPath = null;
|
|
@@ -977,6 +953,7 @@ export function createClaudeHandleRequest({ cwd, log, usage, bridgeCtx, dispatch
|
|
|
977
953
|
});
|
|
978
954
|
resumeHandle.child.once("error", () => resolve(-1));
|
|
979
955
|
});
|
|
956
|
+
await resumeParser.drain();
|
|
980
957
|
|
|
981
958
|
// 新 session_id 写入 threadSessionMap
|
|
982
959
|
if (resumeCode === 0 && resumeSawNonSynthetic && resumePendingSessionId && thread_id) {
|
|
@@ -1036,6 +1013,7 @@ export function createClaudeHandleRequest({ cwd, log, usage, bridgeCtx, dispatch
|
|
|
1036
1013
|
});
|
|
1037
1014
|
handle.child.once("error", () => resolve(-1));
|
|
1038
1015
|
});
|
|
1016
|
+
await parser.drain();
|
|
1039
1017
|
childExited = true;
|
|
1040
1018
|
unregister();
|
|
1041
1019
|
|
|
@@ -1048,7 +1026,7 @@ export function createClaudeHandleRequest({ cwd, log, usage, bridgeCtx, dispatch
|
|
|
1048
1026
|
}
|
|
1049
1027
|
|
|
1050
1028
|
// 第一次尝试:用现有 session_id(如果有)
|
|
1051
|
-
let resumeSessionId = thread_id ? threadSessionMap.get(thread_id) :
|
|
1029
|
+
let resumeSessionId = thread_id ? threadSessionMap.get(thread_id) || resumeSessionIDFromRelay : resumeSessionIDFromRelay;
|
|
1052
1030
|
let result = await attemptOnce({ resumeSessionId, isRetry: false });
|
|
1053
1031
|
|
|
1054
1032
|
// 兜底 1:spawn 失败直接报。
|
|
@@ -285,12 +285,6 @@ export async function writeUserMessage(child, text, blocks = [], { _fetchAndBase
|
|
|
285
285
|
case "input_text":
|
|
286
286
|
content.push({ type: "text", text: result.text });
|
|
287
287
|
break;
|
|
288
|
-
case "url_ref":
|
|
289
|
-
// picoclaw only — claude runtime never returns url_ref from relayObjectToBlock;
|
|
290
|
-
// if this branch is somehow hit, drop to a safe description with NO URL in LLM context
|
|
291
|
-
// (铁律 #1: signed URL must not appear in LLM input). (CR-003)
|
|
292
|
-
content.push({ type: "text", text: `[image] (preview unavailable for this runtime)` });
|
|
293
|
-
break;
|
|
294
288
|
default:
|
|
295
289
|
break;
|
|
296
290
|
}
|
|
@@ -258,6 +258,10 @@ export function normalizeClaudeLine(line) {
|
|
|
258
258
|
export function createLineStreamParser({ onObject, onInvalid, log, bufferLimit = 1 << 20 }) {
|
|
259
259
|
const decoder = new TextDecoder("utf-8");
|
|
260
260
|
let buf = "";
|
|
261
|
+
// stdout 的 data 回调是同步的,但 onObject 会异步发布 relay envelope。
|
|
262
|
+
// 必须串行排队并允许调用方在 child exit 后 drain,否则 run.completed 可能
|
|
263
|
+
// 越过仍在途的 delta,relay 按状态机拒绝后 App 就永久停在 streaming。
|
|
264
|
+
let pending = Promise.resolve();
|
|
261
265
|
const pLog = log ? log.child({ comp: "stdout" }) : null;
|
|
262
266
|
|
|
263
267
|
function feedLine(line) {
|
|
@@ -281,13 +285,12 @@ export function createLineStreamParser({ onObject, onInvalid, log, bufferLimit =
|
|
|
281
285
|
pLog?.debug("stdout.line.parsed", "line parsed", {
|
|
282
286
|
type: obj.type || "", has_message: !!obj.message,
|
|
283
287
|
});
|
|
284
|
-
|
|
285
|
-
catch (err) {
|
|
288
|
+
pending = pending.then(() => onObject(obj)).catch((err) => {
|
|
286
289
|
pLog?.error("error.caught", "onObject threw", {
|
|
287
290
|
where: "stdoutParser.onObject",
|
|
288
291
|
err: err?.message || String(err),
|
|
289
292
|
});
|
|
290
|
-
}
|
|
293
|
+
});
|
|
291
294
|
}
|
|
292
295
|
|
|
293
296
|
return {
|
|
@@ -316,6 +319,8 @@ export function createLineStreamParser({ onObject, onInvalid, log, bufferLimit =
|
|
|
316
319
|
buf = "";
|
|
317
320
|
}
|
|
318
321
|
},
|
|
322
|
+
/** 等待已解析行的异步 onObject 全部按输入顺序处理完。 */
|
|
323
|
+
drain() { return pending; },
|
|
319
324
|
};
|
|
320
325
|
}
|
|
321
326
|
|