@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,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"|"picoclaw"|"codex"|"hermes"
15
+ * @param {string} opts.runtimeHint - "claude"|"openclaw"|"codex"|"hermes"
16
16
  * @param {string[]|null} opts.modelCaps - modalities 数组(如 ["text","image","pdf"])。
17
- * null 表示跳过 caps 检查(picoclaw)。
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
- modelCaps, // null = skip caps check (picoclaw); undefined/missing = default ["text"]
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. picoclaw url_ref(无 fetch)
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/picoclaw 用,作为 content 的 fallback)
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: workspace_path 非空且目录存在,spawn 用它作 cwd;
261
- // 否则 fallback 到 defaultWorkspace(不再用工厂 cwd,修 service 模式 "/" bug)。
262
- const defaultOpenclawInvoke = async ({ thread_id, run_id, input, blocks = [], workspace_path: reqWorkspacePath, session_key = null, onChunk: _rawOnChunk }) => {
263
- // per-request cwd 解析(align-runtime-default-workspace):
264
- // 1. workspace_path 非空 expandHome + existsSync → 用它
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
- const resolved = expandHome(reqWorkspacePath.trim());
275
- if (existsSync(resolved)) {
276
- spawnCwd = resolved;
277
- log.info?.("claude.spawn.cwd.workspace", "using workspace_path as cwd", {
278
- cwd: resolved, source: "workspace_path",
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) : null;
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
- try { onObject(obj); }
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