clawmatrix 0.1.6 → 0.1.11

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.
@@ -6,6 +6,7 @@ import type {
6
6
  } from "openclaw/plugin-sdk";
7
7
  import type { ClawMatrixConfig } from "./config.ts";
8
8
  import { spawnProcess } from "./compat.ts";
9
+ import { debug } from "./debug.ts";
9
10
  import { PeerManager } from "./peer-manager.ts";
10
11
  import { HandoffManager } from "./handoff.ts";
11
12
  import { ModelProxy } from "./model-proxy.ts";
@@ -14,6 +15,7 @@ import type {
14
15
  AnyClusterFrame,
15
16
  HandoffRequest,
16
17
  HandoffResponse,
18
+ HandoffStreamChunk,
17
19
  ModelRequest,
18
20
  ModelResponse,
19
21
  ModelStreamChunk,
@@ -87,12 +89,18 @@ export class ClusterRuntime {
87
89
  }
88
90
 
89
91
  private dispatchFrame(frame: AnyClusterFrame) {
92
+ if (frame.type.startsWith("model_")) {
93
+ debug("dispatch", `${frame.type} id=${frame.id} from=${(frame as ClusterFrame).from}`);
94
+ }
90
95
  switch (frame.type) {
91
96
  case "handoff_req":
92
97
  this.handoffManager.handleRequest(frame as HandoffRequest).catch((err) => {
93
98
  this.logger.error(`[clawmatrix] Handoff request error: ${err}`);
94
99
  });
95
100
  break;
101
+ case "handoff_stream":
102
+ this.handoffManager.handleStream(frame as HandoffStreamChunk);
103
+ break;
96
104
  case "handoff_res":
97
105
  this.handoffManager.handleResponse(frame as HandoffResponse);
98
106
  break;
@@ -139,10 +147,13 @@ export class ClusterRuntime {
139
147
  }
140
148
 
141
149
  // Fire-and-forget: inject message via openclaw agent CLI
142
- spawnProcess(["openclaw", "agent", "--agent", agent.id, "--message", message], {
150
+ const proc = spawnProcess(["openclaw", "agent", "--agent", agent.id, "--message", message], {
143
151
  stdout: "ignore",
144
152
  stderr: "ignore",
145
153
  });
154
+ proc.exited.catch((err) => {
155
+ this.logger.error(`[clawmatrix] Failed to inject message to agent "${agent.id}": ${err}`);
156
+ });
146
157
  }
147
158
  }
148
159
 
package/src/config.ts CHANGED
@@ -6,6 +6,21 @@ const AgentInfoSchema = z.object({
6
6
  tags: z.array(z.string()).default([]),
7
7
  });
8
8
 
9
+ const ModelCompatSchema = z.object({
10
+ supportsTools: z.boolean().optional(),
11
+ supportsStore: z.boolean().optional(),
12
+ supportsDeveloperRole: z.boolean().optional(),
13
+ supportsReasoningEffort: z.boolean().optional(),
14
+ supportsUsageInStreaming: z.boolean().optional(),
15
+ supportsStrictMode: z.boolean().optional(),
16
+ maxTokensField: z.enum(["max_completion_tokens", "max_tokens"]).optional(),
17
+ thinkingFormat: z.enum(["openai", "zai", "qwen"]).optional(),
18
+ requiresToolResultName: z.boolean().optional(),
19
+ requiresAssistantAfterToolResult: z.boolean().optional(),
20
+ requiresThinkingAsText: z.boolean().optional(),
21
+ requiresMistralToolIds: z.boolean().optional(),
22
+ }).optional();
23
+
9
24
  const ModelParamsSchema = {
10
25
  api: z.enum([
11
26
  "openai-completions", "openai-responses", "openai-codex-responses",
@@ -22,6 +37,7 @@ const ModelParamsSchema = {
22
37
  cacheRead: z.number(),
23
38
  cacheWrite: z.number(),
24
39
  }).optional(),
40
+ compat: ModelCompatSchema,
25
41
  };
26
42
 
27
43
  const ModelInfoSchema = z.object({
@@ -63,6 +79,7 @@ export const ClawMatrixConfigSchema = z.object({
63
79
  tags: z.array(z.string()).default([]),
64
80
  proxyPort: z.number().default(19001),
65
81
  toolProxy: ToolProxyConfigSchema.optional(),
82
+ handoffTimeout: z.number().default(600_000),
66
83
  });
67
84
 
68
85
  export type ClawMatrixConfig = z.infer<typeof ClawMatrixConfigSchema>;
package/src/connection.ts CHANGED
@@ -284,6 +284,7 @@ export class Connection extends EventEmitter<ConnectionEvents> {
284
284
  // already closed
285
285
  }
286
286
  this.emit("close", code, reason);
287
+ this.removeAllListeners();
287
288
  }
288
289
 
289
290
  get isOpen(): boolean {
package/src/debug.ts ADDED
@@ -0,0 +1,5 @@
1
+ const enabled = process.env.CLAWMATRIX_DEBUG !== "0";
2
+
3
+ export function debug(tag: string, msg: string) {
4
+ if (enabled) console.error(`[clawmatrix:${tag}] ${msg}`);
5
+ }
package/src/handoff.ts CHANGED
@@ -4,10 +4,10 @@ import { spawnProcess } from "./compat.ts";
4
4
  import type {
5
5
  HandoffRequest,
6
6
  HandoffResponse,
7
- AnyClusterFrame,
7
+ HandoffStreamChunk,
8
8
  } from "./types.ts";
9
9
 
10
- const HANDOFF_TIMEOUT = 120_000; // 2 minutes
10
+ const DEFAULT_HANDOFF_TIMEOUT = 600_000; // 10 minutes (resets on each stream chunk)
11
11
  const MAX_RETRIES = 2;
12
12
 
13
13
  interface PendingHandoff {
@@ -18,6 +18,7 @@ interface PendingHandoff {
18
18
  retriesLeft: number;
19
19
  task: string;
20
20
  context?: string;
21
+ accumulated: string;
21
22
  }
22
23
 
23
24
  export class HandoffManager {
@@ -54,24 +55,9 @@ export class HandoffManager {
54
55
  const id = crypto.randomUUID();
55
56
 
56
57
  return new Promise<HandoffResponse["payload"]>((resolve, reject) => {
57
- const timer = setTimeout(() => {
58
- this.pending.delete(id);
59
- this.peerManager.router.markFailed(id);
60
-
61
- // Retry with failover
62
- if (retriesLeft > 0) {
63
- const nextRoute = this.peerManager.router.resolveAgent(target);
64
- if (nextRoute && nextRoute.nodeId !== targetNodeId) {
65
- this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1)
66
- .then(resolve)
67
- .catch(reject);
68
- return;
69
- }
70
- }
71
- reject(new Error(`Handoff to "${target}" timed out`));
72
- }, HANDOFF_TIMEOUT);
58
+ const timer = this.createTimeout(id, targetNodeId, target, task, context, retriesLeft, resolve, reject);
73
59
 
74
- this.pending.set(id, { resolve, reject, timer, target, retriesLeft, task, context });
60
+ this.pending.set(id, { resolve, reject, timer, target, retriesLeft, task, context, accumulated: "" });
75
61
 
76
62
  const frame: HandoffRequest = {
77
63
  type: "handoff_req",
@@ -102,6 +88,57 @@ export class HandoffManager {
102
88
  });
103
89
  }
104
90
 
91
+ private createTimeout(
92
+ id: string,
93
+ targetNodeId: string,
94
+ target: string,
95
+ task: string,
96
+ context: string | undefined,
97
+ retriesLeft: number,
98
+ resolve: (result: HandoffResponse["payload"]) => void,
99
+ reject: (error: Error) => void,
100
+ ): ReturnType<typeof setTimeout> {
101
+ return setTimeout(() => {
102
+ this.pending.delete(id);
103
+ this.peerManager.router.markFailed(id);
104
+
105
+ // Retry with failover
106
+ if (retriesLeft > 0) {
107
+ const nextRoute = this.peerManager.router.resolveAgent(target);
108
+ if (nextRoute && nextRoute.nodeId !== targetNodeId) {
109
+ this.sendHandoff(nextRoute.nodeId, target, task, context, retriesLeft - 1)
110
+ .then(resolve)
111
+ .catch(reject);
112
+ return;
113
+ }
114
+ }
115
+ reject(new Error(`Handoff to "${target}" timed out`));
116
+ }, this.config.handoffTimeout ?? DEFAULT_HANDOFF_TIMEOUT);
117
+ }
118
+
119
+ /** Handle incoming stream chunk — reset timeout & accumulate. */
120
+ handleStream(frame: HandoffStreamChunk) {
121
+ if (this.peerManager.router.isFailed(frame.id)) return;
122
+
123
+ const pending = this.pending.get(frame.id);
124
+ if (!pending) return;
125
+
126
+ pending.accumulated += frame.payload.delta;
127
+
128
+ // Reset timeout — the remote agent is still working
129
+ clearTimeout(pending.timer);
130
+ pending.timer = this.createTimeout(
131
+ frame.id,
132
+ frame.from,
133
+ pending.target,
134
+ pending.task,
135
+ pending.context,
136
+ pending.retriesLeft,
137
+ pending.resolve,
138
+ pending.reject,
139
+ );
140
+ }
141
+
105
142
  /** Handle incoming handoff response. */
106
143
  handleResponse(frame: HandoffResponse) {
107
144
  if (this.peerManager.router.isFailed(frame.id)) return;
@@ -111,6 +148,12 @@ export class HandoffManager {
111
148
 
112
149
  clearTimeout(pending.timer);
113
150
  this.pending.delete(frame.id);
151
+
152
+ // If the response has no result but we accumulated stream data, use that
153
+ if (frame.payload.success && !frame.payload.result && pending.accumulated) {
154
+ frame.payload.result = pending.accumulated;
155
+ }
156
+
114
157
  pending.resolve(frame.payload);
115
158
  }
116
159
 
@@ -153,7 +196,8 @@ export class HandoffManager {
153
196
  { stdout: "pipe", stderr: "pipe" },
154
197
  );
155
198
 
156
- const stdout = proc.stdout ? await new Response(proc.stdout).text() : "";
199
+ // Stream stdout chunks back to the caller
200
+ const fullOutput = await this.streamStdout(proc.stdout, id, from);
157
201
  const exitCode = await proc.exited;
158
202
 
159
203
  if (exitCode !== 0) {
@@ -171,7 +215,7 @@ export class HandoffManager {
171
215
  success: true,
172
216
  nodeId: this.config.nodeId,
173
217
  agent: agent.id,
174
- result: stdout.trim(),
218
+ result: fullOutput.trim(),
175
219
  },
176
220
  } satisfies HandoffResponse);
177
221
  } catch (err) {
@@ -191,9 +235,55 @@ export class HandoffManager {
191
235
  }
192
236
  }
193
237
 
238
+ /** Read stdout incrementally, sending handoff_stream chunks to the caller. */
239
+ private async streamStdout(
240
+ stdout: ReadableStream | null,
241
+ handoffId: string,
242
+ to: string,
243
+ ): Promise<string> {
244
+ if (!stdout) return "";
245
+
246
+ const reader = stdout.getReader();
247
+ const decoder = new TextDecoder();
248
+ let full = "";
249
+
250
+ try {
251
+ while (true) {
252
+ const { done, value } = await reader.read();
253
+ if (done) break;
254
+
255
+ const chunk = decoder.decode(value, { stream: true });
256
+ full += chunk;
257
+
258
+ this.peerManager.sendTo(to, {
259
+ type: "handoff_stream",
260
+ id: handoffId,
261
+ from: this.config.nodeId,
262
+ to,
263
+ timestamp: Date.now(),
264
+ payload: { delta: chunk, done: false },
265
+ } satisfies HandoffStreamChunk);
266
+ }
267
+ } finally {
268
+ reader.releaseLock();
269
+ }
270
+
271
+ // Send final done marker
272
+ this.peerManager.sendTo(to, {
273
+ type: "handoff_stream",
274
+ id: handoffId,
275
+ from: this.config.nodeId,
276
+ to,
277
+ timestamp: Date.now(),
278
+ payload: { delta: "", done: true },
279
+ } satisfies HandoffStreamChunk);
280
+
281
+ return full;
282
+ }
283
+
194
284
  /** Clean up on shutdown. */
195
285
  destroy() {
196
- for (const [id, pending] of this.pending) {
286
+ for (const [, pending] of this.pending) {
197
287
  clearTimeout(pending.timer);
198
288
  pending.reject(new Error("Shutting down"));
199
289
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
1
+ import type { OpenClawPluginApi, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
2
2
  import { ClawMatrixConfigSchema, parseConfig } from "./config.ts";
3
3
  import { createClusterService, getClusterRuntime } from "./cluster-service.ts";
4
4
  import { createClusterHandoffTool } from "./tools/cluster-handoff.ts";
@@ -41,18 +41,51 @@ const plugin = {
41
41
  // Background service: manages mesh connections, WS listener, heartbeat
42
42
  api.registerService(createClusterService(config, api.config));
43
43
 
44
- // Model provider: register clawmatrix as a provider pointing to local HTTP proxy
45
- api.registerProvider({
46
- id: "clawmatrix",
47
- label: "ClawMatrix Cluster",
48
- docsPath: "/plugins/clawmatrix",
49
- auth: [],
50
- models: {
51
- baseUrl: `http://127.0.0.1:${config.proxyPort}`,
52
- api: "openai-completions",
53
- models: getAllClusterModels(config),
54
- },
55
- });
44
+ // Model providers: register per-node providers so models are accessed as nodeId/modelId
45
+ const baseUrl = `http://127.0.0.1:${config.proxyPort}/v1`;
46
+ const modelsByNode = groupModelsByNode(config);
47
+
48
+ // Patch openclaw config so auth resolution can find a dummy API key.
49
+ // resolveApiKeyForProvider checks cfg.models.providers, not the plugin registry.
50
+ // We must patch BOTH api.config (cfgAtStart) AND the runtimeConfigSnapshot
51
+ // (returned by loadConfig()) because activateSecretsRuntimeSnapshot clones the
52
+ // config before plugins load, so api.config and the snapshot are separate objects.
53
+ const patchProviders = (cfg: Record<string, unknown>) => {
54
+ const models = ((cfg).models ??= {}) as Record<string, unknown>;
55
+ const providers = (models.providers ??= {}) as Record<string, unknown>;
56
+ for (const [nodeId, nodeModels] of Object.entries(modelsByNode)) {
57
+ if (!providers[nodeId]) {
58
+ providers[nodeId] = { baseUrl, apiKey: "sk-clawmatrix-proxy", api: "openai-completions", models: nodeModels };
59
+ }
60
+ }
61
+ };
62
+
63
+ patchProviders(api.config as Record<string, unknown>);
64
+
65
+ // Also patch the runtime config snapshot (loadConfig returns it by reference).
66
+ // api.runtime.config.loadConfig() returns runtimeConfigSnapshot directly.
67
+ try {
68
+ const snapshot = api.runtime.config.loadConfig();
69
+ if (snapshot && snapshot !== api.config) {
70
+ patchProviders(snapshot as Record<string, unknown>);
71
+ }
72
+ } catch {
73
+ // Best-effort; api.config patch is the fallback
74
+ }
75
+
76
+ for (const [nodeId, models] of Object.entries(modelsByNode)) {
77
+ api.registerProvider({
78
+ id: nodeId,
79
+ label: `ClawMatrix: ${nodeId}`,
80
+ docsPath: "/plugins/clawmatrix",
81
+ auth: [],
82
+ models: {
83
+ baseUrl,
84
+ apiKey: "sk-clawmatrix-proxy",
85
+ models,
86
+ },
87
+ });
88
+ }
56
89
 
57
90
  // Agent tools
58
91
  api.registerTool(createClusterHandoffTool(), { optional: true });
@@ -63,6 +96,57 @@ const plugin = {
63
96
  api.registerTool(createClusterWriteTool(), { optional: true });
64
97
  api.registerTool(createClusterToolTool(), { optional: true });
65
98
 
99
+ // Gateway methods (queried by CLI via `openclaw gateway call`)
100
+ api.registerGatewayMethod(
101
+ "clawmatrix.status",
102
+ ({ respond }: GatewayRequestHandlerOptions) => {
103
+ try {
104
+ const runtime = getClusterRuntime();
105
+ const peers = runtime.peerManager.router.getAllPeers();
106
+ respond(true, {
107
+ nodeId: config.nodeId,
108
+ listen: config.listen ? config.listenPort : false,
109
+ proxyPort: config.proxyPort,
110
+ agents: config.agents.map((a) => ({ id: a.id, description: a.description })),
111
+ models: config.models.map((m) => ({ id: m.id })),
112
+ tags: config.tags,
113
+ peers: peers.map((p) => ({
114
+ nodeId: p.nodeId,
115
+ agents: p.agents,
116
+ models: p.models,
117
+ tags: p.tags,
118
+ connected: !!p.connection?.isOpen,
119
+ reachableVia: p.reachableVia,
120
+ latencyMs: p.latencyMs,
121
+ })),
122
+ });
123
+ } catch {
124
+ respond(false, { error: "ClawMatrix service not running" });
125
+ }
126
+ },
127
+ );
128
+
129
+ api.registerGatewayMethod(
130
+ "clawmatrix.peers",
131
+ ({ respond }: GatewayRequestHandlerOptions) => {
132
+ try {
133
+ const runtime = getClusterRuntime();
134
+ const peers = runtime.peerManager.router.getAllPeers().map((p) => ({
135
+ nodeId: p.nodeId,
136
+ agents: p.agents,
137
+ models: p.models,
138
+ tags: p.tags,
139
+ connected: !!p.connection?.isOpen,
140
+ reachableVia: p.reachableVia,
141
+ latencyMs: p.latencyMs,
142
+ }));
143
+ respond(true, peers);
144
+ } catch {
145
+ respond(true, []);
146
+ }
147
+ },
148
+ );
149
+
66
150
  // CLI subcommand
67
151
  api.registerCli(registerClusterCli, { commands: ["clawmatrix"] });
68
152
 
@@ -117,17 +201,8 @@ const plugin = {
117
201
  },
118
202
  };
119
203
 
120
- function getAllClusterModels(config: ReturnType<typeof parseConfig>) {
121
- // models = models this node serves to the cluster
122
- // proxyModels = remote models this node wants to consume from the cluster
123
- // Both need to be registered with the OpenClaw provider so it routes requests to our local proxy.
124
- const seen = new Set<string>();
125
- const all = [...config.models, ...config.proxyModels].filter((m) => {
126
- if (seen.has(m.id)) return false;
127
- seen.add(m.id);
128
- return true;
129
- });
130
- return all.map((m) => ({
204
+ function formatModel(m: { id: string; description?: string; api?: string; reasoning?: boolean; input?: string[]; cost?: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow?: number; maxTokens?: number; compat?: Record<string, unknown> }) {
205
+ const result: Record<string, unknown> = {
131
206
  id: m.id,
132
207
  name: m.description ?? m.id,
133
208
  api: m.api ?? ("openai-completions" as const),
@@ -136,7 +211,27 @@ function getAllClusterModels(config: ReturnType<typeof parseConfig>) {
136
211
  cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
137
212
  contextWindow: m.contextWindow ?? 128_000,
138
213
  maxTokens: m.maxTokens ?? 4096,
139
- }));
214
+ };
215
+ if (m.compat) result.compat = m.compat;
216
+ return result;
217
+ }
218
+
219
+ function groupModelsByNode(config: ReturnType<typeof parseConfig>): Record<string, ReturnType<typeof formatModel>[]> {
220
+ const result: Record<string, ReturnType<typeof formatModel>[]> = {};
221
+
222
+ // Local models served by this node
223
+ for (const m of config.models) {
224
+ const nodeId = config.nodeId;
225
+ (result[nodeId] ??= []).push(formatModel(m));
226
+ }
227
+
228
+ // Remote models consumed from peers (proxyModels have nodeId)
229
+ for (const m of config.proxyModels) {
230
+ const nodeId = m.nodeId;
231
+ (result[nodeId] ??= []).push(formatModel(m));
232
+ }
233
+
234
+ return result;
140
235
  }
141
236
 
142
237
  export default plugin;
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Local tool execution for exec/read/write/edit.
3
+ *
4
+ * These tools cannot go through the Gateway HTTP `/tools/invoke` endpoint
5
+ * because it only exposes `createOpenClawTools()` (safe tools), not
6
+ * `createOpenClawCodingTools()` (which includes exec/read/write/edit).
7
+ *
8
+ * Instead, we execute them directly in the same Node.js process.
9
+ * - exec: simplified child_process.spawn (no sandbox/PTY/background)
10
+ * - read/write/edit: reuse @mariozechner/pi-coding-agent factories
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+ import {
15
+ createReadTool,
16
+ createWriteTool,
17
+ createEditTool,
18
+ } from "@mariozechner/pi-coding-agent";
19
+
20
+ // ── Types ──────────────────────────────────────────────────────────
21
+
22
+ interface ExecParams {
23
+ command: string;
24
+ workdir?: string;
25
+ env?: Record<string, string>;
26
+ timeout?: number;
27
+ }
28
+
29
+ type ToolResult = Record<string, unknown>;
30
+
31
+ // ── Constants ──────────────────────────────────────────────────────
32
+
33
+ const LOCAL_TOOLS = new Set(["exec", "read", "write", "edit"]);
34
+ const DEFAULT_EXEC_TIMEOUT = 300; // seconds
35
+ const MAX_OUTPUT_BYTES = 512 * 1024; // 512KB
36
+
37
+ // ── Public API ─────────────────────────────────────────────────────
38
+
39
+ export function isLocalTool(tool: string): boolean {
40
+ return LOCAL_TOOLS.has(tool);
41
+ }
42
+
43
+ export async function executeLocally(
44
+ tool: string,
45
+ params: Record<string, unknown>,
46
+ ): Promise<ToolResult> {
47
+ switch (tool) {
48
+ case "exec":
49
+ return executeExec(params as unknown as ExecParams);
50
+ case "read":
51
+ return executePiTool("read", params);
52
+ case "write":
53
+ return executePiTool("write", params);
54
+ case "edit":
55
+ return executePiTool("edit", params);
56
+ default:
57
+ throw new Error(`Unknown local tool: ${tool}`);
58
+ }
59
+ }
60
+
61
+ // ── exec: simplified child_process.spawn ───────────────────────────
62
+
63
+ async function executeExec(params: ExecParams): Promise<ToolResult> {
64
+ const { command, workdir, env, timeout } = params;
65
+ if (!command) throw new Error("exec: command is required");
66
+
67
+ const shell = process.env.SHELL || "/bin/bash";
68
+ const timeoutMs = (timeout ?? DEFAULT_EXEC_TIMEOUT) * 1000;
69
+
70
+ return new Promise<ToolResult>((resolve, reject) => {
71
+ const child = spawn(shell, ["-c", command], {
72
+ cwd: workdir || process.cwd(),
73
+ env: env ? { ...process.env, ...env } : process.env,
74
+ stdio: ["ignore", "pipe", "pipe"],
75
+ detached: true,
76
+ });
77
+
78
+ let stdout = "";
79
+ let stderr = "";
80
+ let killed = false;
81
+
82
+ child.stdout?.on("data", (chunk: Buffer) => {
83
+ stdout += chunk.toString();
84
+ if (stdout.length > MAX_OUTPUT_BYTES) {
85
+ stdout = stdout.slice(0, MAX_OUTPUT_BYTES);
86
+ }
87
+ });
88
+
89
+ child.stderr?.on("data", (chunk: Buffer) => {
90
+ stderr += chunk.toString();
91
+ if (stderr.length > MAX_OUTPUT_BYTES) {
92
+ stderr = stderr.slice(0, MAX_OUTPUT_BYTES);
93
+ }
94
+ });
95
+
96
+ const timer = setTimeout(() => {
97
+ killed = true;
98
+ // Kill the process group
99
+ try {
100
+ process.kill(-child.pid!, "SIGKILL");
101
+ } catch {
102
+ child.kill("SIGKILL");
103
+ }
104
+ }, timeoutMs);
105
+
106
+ child.on("close", (code, signal) => {
107
+ clearTimeout(timer);
108
+ const truncated =
109
+ stdout.length >= MAX_OUTPUT_BYTES || stderr.length >= MAX_OUTPUT_BYTES;
110
+ resolve({
111
+ exitCode: code ?? 1,
112
+ stdout,
113
+ stderr,
114
+ ...(signal && { signal }),
115
+ ...(killed && { timedOut: true }),
116
+ ...(truncated && { truncated: true }),
117
+ });
118
+ });
119
+
120
+ child.on("error", (err) => {
121
+ clearTimeout(timer);
122
+ reject(new Error(`exec: failed to spawn: ${err.message}`));
123
+ });
124
+ });
125
+ }
126
+
127
+ // ── read/write/edit: reuse pi-coding-agent factories ───────────────
128
+
129
+ const piToolCache = new Map<string, { execute: Function }>();
130
+
131
+ function getPiTool(name: string): { execute: Function } {
132
+ let tool = piToolCache.get(name);
133
+ if (tool) return tool;
134
+
135
+ const cwd = process.cwd();
136
+ switch (name) {
137
+ case "read":
138
+ tool = createReadTool(cwd);
139
+ break;
140
+ case "write":
141
+ tool = createWriteTool(cwd);
142
+ break;
143
+ case "edit":
144
+ tool = createEditTool(cwd);
145
+ break;
146
+ default:
147
+ throw new Error(`Unknown pi tool: ${name}`);
148
+ }
149
+
150
+ piToolCache.set(name, tool);
151
+ return tool;
152
+ }
153
+
154
+ async function executePiTool(
155
+ name: string,
156
+ params: Record<string, unknown>,
157
+ ): Promise<ToolResult> {
158
+ const tool = getPiTool(name);
159
+ const toolCallId = crypto.randomUUID();
160
+
161
+ const result = (await tool.execute(toolCallId, params)) as {
162
+ content: Array<{ type: string; text?: string }>;
163
+ details: unknown;
164
+ };
165
+
166
+ // Flatten content array to a single text result
167
+ const text = result.content
168
+ .filter((c) => c.type === "text" && c.text)
169
+ .map((c) => c.text)
170
+ .join("\n");
171
+
172
+ return {
173
+ content: text,
174
+ details: result.details,
175
+ };
176
+ }