clawmatrix 0.1.23 → 0.2.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/src/audit.ts ADDED
@@ -0,0 +1,42 @@
1
+ /** Structured audit logger for security events. */
2
+
3
+ export type AuditEvent =
4
+ | "conn_open" // New inbound WS connection
5
+ | "conn_rate_limited" // Connection rejected by rate limiter
6
+ | "auth_success" // HMAC authentication succeeded
7
+ | "auth_failure" // HMAC authentication failed
8
+ | "auth_timeout" // Authentication timed out
9
+ | "peer_join" // Peer joined the mesh
10
+ | "peer_leave" // Peer left / disconnected
11
+ | "conn_close"; // Connection closed
12
+
13
+ export interface AuditEntry {
14
+ ts: string; // ISO timestamp
15
+ event: AuditEvent;
16
+ ip?: string; // Remote IP (if available)
17
+ nodeId?: string; // Remote nodeId (if known)
18
+ detail?: string; // Extra context
19
+ }
20
+
21
+ export type AuditSink = (entry: AuditEntry) => void;
22
+
23
+ /** Default sink: structured JSON to stderr via console.warn (won't mix with stdout output). */
24
+ const defaultSink: AuditSink = (entry) => {
25
+ console.warn(`[clawmatrix:audit] ${JSON.stringify(entry)}`);
26
+ };
27
+
28
+ let sink: AuditSink = defaultSink;
29
+
30
+ /** Replace the audit sink (e.g. to write to a file or external service). */
31
+ export function setAuditSink(s: AuditSink) {
32
+ sink = s;
33
+ }
34
+
35
+ /** Emit an audit event. */
36
+ export function audit(event: AuditEvent, fields?: Omit<AuditEntry, "ts" | "event">) {
37
+ sink({
38
+ ts: new Date().toISOString(),
39
+ event,
40
+ ...fields,
41
+ });
42
+ }
package/src/auth.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { createHash, timingSafeEqual as nodeTimingSafeEqual } from "node:crypto";
2
+
1
3
  export function generateNonce(): string {
2
4
  const bytes = crypto.getRandomValues(new Uint8Array(32));
3
5
  return Buffer.from(bytes).toString("hex");
@@ -8,7 +10,6 @@ const KEY_CACHE_MAX = 8;
8
10
  const keyCache = new Map<string, CryptoKey>();
9
11
 
10
12
  function cacheKeyFor(secret: string): string {
11
- const { createHash } = require("node:crypto");
12
13
  return createHash("sha256").update(secret).digest("hex");
13
14
  }
14
15
 
@@ -48,13 +49,11 @@ export async function computeHmac(
48
49
  /** Constant-time string comparison. Uses native crypto.timingSafeEqual with
49
50
  * SHA-256 pre-hash to normalize lengths (avoids length-leak from early return). */
50
51
  export function timingSafeEqual(a: string, b: string): boolean {
51
- const nodeTimingSafeEqual = require("node:crypto").timingSafeEqual;
52
52
  const encoder = new TextEncoder();
53
53
  const bufA = encoder.encode(a);
54
54
  const bufB = encoder.encode(b);
55
55
  if (bufA.byteLength !== bufB.byteLength) {
56
56
  // Hash both to fixed length so we still compare in constant time
57
- const { createHash } = require("node:crypto");
58
57
  const hashA = createHash("sha256").update(bufA).digest();
59
58
  const hashB = createHash("sha256").update(bufB).digest();
60
59
  return nodeTimingSafeEqual(hashA, hashB);
package/src/cli.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import type { Command } from "commander";
2
2
  import { spawnProcess } from "./compat.ts";
3
3
 
4
- async function callGateway(method: string): Promise<unknown> {
5
- const proc = spawnProcess(["openclaw", "gateway", "call", method, "--json"], {
4
+ async function callGateway(method: string, params?: Record<string, unknown>): Promise<unknown> {
5
+ const args = ["openclaw", "gateway", "call", method, "--json"];
6
+ if (params) {
7
+ args.push("--params", JSON.stringify(params));
8
+ }
9
+ const proc = spawnProcess(args, {
6
10
  stdout: "pipe",
7
11
  stderr: "pipe",
8
12
  });
@@ -161,4 +165,74 @@ export const registerClusterCli = ({ program }: { program: Command }) => {
161
165
  }
162
166
  console.log(JSON.stringify(peers, null, 2));
163
167
  });
168
+
169
+ // ── Peer approval commands ──────────────────────────────────────
170
+
171
+ cmd
172
+ .command("approve <approvalId>")
173
+ .description("Approve a pending peer join request")
174
+ .action(async (approvalId: string) => {
175
+ try {
176
+ const result = await callGateway("clawmatrix.approval.resolve", {
177
+ approvalId,
178
+ decision: "approve",
179
+ }) as Record<string, unknown>;
180
+ if (result.ok) {
181
+ console.log(`Approved: ${approvalId}`);
182
+ } else {
183
+ console.log(`Approval not found or already resolved: ${approvalId}`);
184
+ }
185
+ } catch {
186
+ console.log("Could not reach gateway. Is it running?");
187
+ }
188
+ });
189
+
190
+ cmd
191
+ .command("deny <approvalId>")
192
+ .description("Deny a pending peer join request")
193
+ .action(async (approvalId: string) => {
194
+ try {
195
+ const result = await callGateway("clawmatrix.approval.resolve", {
196
+ approvalId,
197
+ decision: "deny",
198
+ }) as Record<string, unknown>;
199
+ if (result.ok) {
200
+ console.log(`Denied: ${approvalId}`);
201
+ } else {
202
+ console.log(`Approval not found or already resolved: ${approvalId}`);
203
+ }
204
+ } catch {
205
+ console.log("Could not reach gateway. Is it running?");
206
+ }
207
+ });
208
+
209
+ const approval = cmd.command("approval").description("Manage peer approvals");
210
+
211
+ approval
212
+ .command("list")
213
+ .description("List approved and pending peers")
214
+ .action(async () => {
215
+ try {
216
+ const data = await callGateway("clawmatrix.approval.list") as Record<string, unknown>;
217
+ console.log(JSON.stringify(data, null, 2));
218
+ } catch {
219
+ console.log("Could not reach gateway. Is it running?");
220
+ }
221
+ });
222
+
223
+ approval
224
+ .command("revoke <nodeId>")
225
+ .description("Revoke an approved peer")
226
+ .action(async (nodeId: string) => {
227
+ try {
228
+ const result = await callGateway("clawmatrix.approval.revoke", { nodeId }) as Record<string, unknown>;
229
+ if (result.ok) {
230
+ console.log(`Revoked: ${nodeId}`);
231
+ } else {
232
+ console.log(`Node not found in approved list: ${nodeId}`);
233
+ }
234
+ } catch {
235
+ console.log("Could not reach gateway. Is it running?");
236
+ }
237
+ });
164
238
  };
@@ -12,8 +12,11 @@ import { PeerManager } from "./peer-manager.ts";
12
12
  import { HandoffManager } from "./handoff.ts";
13
13
  import { ModelProxy } from "./model-proxy.ts";
14
14
  import { ToolProxy, type GatewayInfo } from "./tool-proxy.ts";
15
+ import { AcpProxy, readAllSessionStoresFromDisk } from "./acp-proxy.ts";
16
+ import { TerminalManager } from "./terminal.ts";
15
17
  import { WebHandler } from "./web.ts";
16
18
  import { KnowledgeSync } from "./knowledge-sync.ts";
19
+ import { SentinelManager } from "./sentinel-manager.ts";
17
20
  import type {
18
21
  AnyClusterFrame,
19
22
  HandoffRequest,
@@ -31,6 +34,33 @@ import type {
31
34
  SendMessage,
32
35
  ToolProxyRequest,
33
36
  ToolProxyResponse,
37
+ ToolBatchRequest,
38
+ ToolBatchResponse,
39
+ DiagnosticExecResponse,
40
+ DiagnosticStatusResponse,
41
+ AcpTaskRequest,
42
+ AcpTaskResponse,
43
+ AcpStreamChunk,
44
+ AcpCloseRequest,
45
+ AcpCloseResponse,
46
+ AcpListRequest,
47
+ AcpListResponse,
48
+ AcpResumeRequest,
49
+ AcpResumeResponse,
50
+ AcpCancelRequest,
51
+ AcpCancelResponse,
52
+ AcpSetModeRequest,
53
+ AcpSetModeResponse,
54
+ AcpGetModesRequest,
55
+ AcpGetModesResponse,
56
+ ChatHistoryRequest,
57
+ ChatHistoryResponse,
58
+ TerminalOpenRequest,
59
+ TerminalOpenResponse,
60
+ TerminalData,
61
+ TerminalResize,
62
+ TerminalCloseRequest,
63
+ TerminalCloseResponse,
34
64
  } from "./types.ts";
35
65
 
36
66
  function resolveGatewayInfo(openclawConfig: OpenClawConfig): GatewayInfo {
@@ -52,10 +82,14 @@ export class ClusterRuntime {
52
82
  readonly handoffManager: HandoffManager;
53
83
  readonly modelProxy: ModelProxy;
54
84
  readonly toolProxy: ToolProxy;
85
+ readonly acpProxy: AcpProxy | null;
86
+ readonly terminalManager: TerminalManager;
55
87
  knowledgeSync: KnowledgeSync | null = null;
56
88
  webHandler: WebHandler | null = null;
89
+ private sentinelManager: SentinelManager | null = null;
57
90
  private logger: PluginLogger;
58
91
  private openclawConfig: OpenClawConfig;
92
+ private exitHandler: (() => void) | null = null;
59
93
 
60
94
  constructor(config: ClawMatrixConfig, logger: PluginLogger, openclawConfig: OpenClawConfig, openclawVersion?: string) {
61
95
  this.config = config;
@@ -63,9 +97,13 @@ export class ClusterRuntime {
63
97
  this.openclawConfig = openclawConfig;
64
98
  const gatewayInfo = resolveGatewayInfo(openclawConfig);
65
99
  this.peerManager = new PeerManager(config, openclawVersion);
66
- this.handoffManager = new HandoffManager(config, this.peerManager);
100
+ this.handoffManager = new HandoffManager(config, this.peerManager, gatewayInfo);
67
101
  this.modelProxy = new ModelProxy(config, this.peerManager, gatewayInfo, openclawConfig);
68
102
  this.toolProxy = new ToolProxy(config, this.peerManager, gatewayInfo, logger);
103
+ // Enable ACP proxy if either ClawMatrix config or OpenClaw config has ACP enabled
104
+ const acpEnabled = config.acp?.enabled || (openclawConfig as Record<string, any>).acp?.enabled;
105
+ this.acpProxy = acpEnabled ? new AcpProxy(config, this.peerManager, openclawConfig as Record<string, unknown>, gatewayInfo) : null;
106
+ this.terminalManager = new TerminalManager(config, this.peerManager);
69
107
  }
70
108
 
71
109
  start() {
@@ -76,10 +114,16 @@ export class ClusterRuntime {
76
114
 
77
115
  this.peerManager.on("peerConnected", (nodeId) => {
78
116
  this.logger.info(`[clawmatrix] Peer connected: ${nodeId}`);
117
+ this.refreshDiscoveredModels();
79
118
  });
80
119
 
81
120
  this.peerManager.on("peerDisconnected", (nodeId) => {
82
121
  this.logger.info(`[clawmatrix] Peer disconnected: ${nodeId}`);
122
+ this.refreshDiscoveredModels();
123
+ });
124
+
125
+ this.peerManager.on("peerCapabilitiesChanged", () => {
126
+ this.refreshDiscoveredModels();
83
127
  });
84
128
 
85
129
  // Web dashboard (must be set before peerManager.start() creates the HTTP server)
@@ -98,8 +142,9 @@ export class ClusterRuntime {
98
142
  const stateDir = path.join(workspacePath, ".clawmatrix");
99
143
  this.knowledgeSync = new KnowledgeSync({
100
144
  workspacePath,
101
- storePath: path.join(stateDir, "knowledge.automerge"),
145
+ stateDir,
102
146
  nodeId: this.config.nodeId,
147
+ paths: this.config.knowledge.paths ?? [],
103
148
  debounce: this.config.knowledge.debounce ?? 5000,
104
149
  maxFileSize: this.config.knowledge.maxFileSize ?? 512 * 1024,
105
150
  peerManager: this.peerManager,
@@ -124,6 +169,22 @@ export class ClusterRuntime {
124
169
  this.peerManager.start();
125
170
  this.modelProxy.start();
126
171
 
172
+ // Sentinel: detached subprocess for diagnostics when gateway dies.
173
+ // Default on; starts when not explicitly disabled AND (has outbound peers OR gateway is a listener for port takeover)
174
+ if ((this.config.sentinel?.enabled ?? true) && (this.config.peers.length > 0 || this.config.listen)) {
175
+ this.sentinelManager = new SentinelManager(this.config);
176
+ this.sentinelManager.start();
177
+ this.logger.info(`[clawmatrix] Sentinel started for node "${this.config.nodeId}"`);
178
+ }
179
+
180
+ // Register process exit handlers for emergency cleanup (kill spawned ACP processes, close WS)
181
+ this.exitHandler = () => {
182
+ this.acpProxy?.destroy();
183
+ this.terminalManager.destroy();
184
+ this.handoffManager.destroy();
185
+ };
186
+ process.on("exit", this.exitHandler);
187
+
127
188
  this.logger.info(
128
189
  `[clawmatrix] Node "${this.config.nodeId}" started` +
129
190
  (this.config.listen ? ` (listening on port ${this.config.listenPort})` : "") +
@@ -132,15 +193,30 @@ export class ClusterRuntime {
132
193
  }
133
194
 
134
195
  async stop() {
196
+ // Unregister exit handler since we're doing a clean shutdown
197
+ if (this.exitHandler) {
198
+ process.removeListener("exit", this.exitHandler);
199
+ this.exitHandler = null;
200
+ }
201
+ // NOTE: intentionally do NOT stop sentinel here.
202
+ // Sentinel must survive gateway shutdown — that's its entire purpose.
203
+ // It will be replaced by killOldSentinel() on next gateway start.
135
204
  await this.knowledgeSync?.stop();
136
205
  this.webHandler?.destroy();
137
206
  this.handoffManager.destroy();
207
+ this.acpProxy?.destroy();
208
+ this.terminalManager.destroy();
138
209
  this.modelProxy.stop();
139
210
  this.toolProxy.destroy();
140
211
  await this.peerManager.stop();
141
212
  this.logger.info(`[clawmatrix] Node "${this.config.nodeId}" stopped`);
142
213
  }
143
214
 
215
+ private refreshDiscoveredModels() {
216
+ const peers = this.peerManager.router.getAllPeers();
217
+ this.modelProxy.updateDiscoveredModels(peers);
218
+ }
219
+
144
220
  private resolveWorkspacePath(): string | null {
145
221
  // Read workspace from OpenClaw agent config (first agent or default agent)
146
222
  const agents = (this.openclawConfig as Record<string, unknown>).agents as
@@ -160,7 +236,7 @@ export class ClusterRuntime {
160
236
  }
161
237
 
162
238
  private dispatchFrame(frame: AnyClusterFrame) {
163
- if (frame.type.startsWith("model_")) {
239
+ if (frame.type.startsWith("model_") || frame.type.startsWith("acp_")) {
164
240
  debug("dispatch", `${frame.type} id=${frame.id} from=${frame.from}`);
165
241
  }
166
242
  switch (frame.type) {
@@ -212,6 +288,14 @@ export class ClusterRuntime {
212
288
  case "tool_res":
213
289
  this.toolProxy.handleResponse(frame as ToolProxyResponse);
214
290
  break;
291
+ case "tool_batch_req":
292
+ this.toolProxy.handleBatchRequest(frame as ToolBatchRequest).catch((err) => {
293
+ this.logger.error(`[clawmatrix] Tool batch request error: ${err}`);
294
+ });
295
+ break;
296
+ case "tool_batch_res":
297
+ this.toolProxy.handleBatchResponse(frame as ToolBatchResponse);
298
+ break;
215
299
  case "send":
216
300
  this.handleSendMessage(frame as SendMessage);
217
301
  break;
@@ -220,6 +304,162 @@ export class ClusterRuntime {
220
304
  this.logger.error(`[clawmatrix] Knowledge sync error: ${err}`);
221
305
  });
222
306
  break;
307
+ case "acp_req":
308
+ if (this.acpProxy) {
309
+ this.acpProxy.handleRequest(frame as AcpTaskRequest).catch((err) => {
310
+ this.logger.error(`[clawmatrix] ACP request error: ${err}`);
311
+ });
312
+ } else {
313
+ const af = frame as AcpTaskRequest;
314
+ this.peerManager.sendTo(af.from, {
315
+ type: "acp_res", id: af.id, from: this.config.nodeId, to: af.from,
316
+ timestamp: Date.now(), payload: { success: false, error: "ACP not enabled on this node" },
317
+ } as AcpTaskResponse);
318
+ }
319
+ break;
320
+ case "acp_stream":
321
+ this.acpProxy?.handleStream(frame as AcpStreamChunk);
322
+ break;
323
+ case "acp_res":
324
+ this.acpProxy?.handleResponse(frame as AcpTaskResponse);
325
+ break;
326
+ case "acp_close":
327
+ if (this.acpProxy) {
328
+ this.acpProxy.handleClose(frame as AcpCloseRequest).catch((err) => {
329
+ this.logger.error(`[clawmatrix] ACP close error: ${err}`);
330
+ });
331
+ } else {
332
+ const cf = frame as AcpCloseRequest;
333
+ this.peerManager.sendTo(cf.from, {
334
+ type: "acp_close_res", id: cf.id, from: this.config.nodeId, to: cf.from,
335
+ timestamp: Date.now(), payload: { success: false, error: "ACP not enabled on this node" },
336
+ } as AcpCloseResponse);
337
+ }
338
+ break;
339
+ case "acp_close_res":
340
+ this.acpProxy?.handleCloseResponse(frame as AcpCloseResponse);
341
+ break;
342
+ case "acp_list_req":
343
+ if (this.acpProxy) {
344
+ this.acpProxy.handleListRequest(frame as AcpListRequest).catch((err) => {
345
+ this.logger.error(`[clawmatrix] ACP list error: ${err}`);
346
+ });
347
+ } else {
348
+ // ACP not enabled — still read session stores from disk (read-only discovery)
349
+ const lf = frame as AcpListRequest;
350
+ readAllSessionStoresFromDisk().then((allSessions) => {
351
+ const filtered = lf.payload.agent
352
+ ? allSessions.filter((s) => s.agent === lf.payload.agent)
353
+ : allSessions;
354
+ this.peerManager.sendTo(lf.from, {
355
+ type: "acp_list_res", id: lf.id, from: this.config.nodeId, to: lf.from,
356
+ timestamp: Date.now(), payload: { success: true, sessions: filtered },
357
+ } as AcpListResponse);
358
+ }).catch(() => {
359
+ this.peerManager.sendTo(lf.from, {
360
+ type: "acp_list_res", id: lf.id, from: this.config.nodeId, to: lf.from,
361
+ timestamp: Date.now(), payload: { success: true, sessions: [] },
362
+ } as AcpListResponse);
363
+ });
364
+ }
365
+ break;
366
+ case "acp_list_res":
367
+ this.acpProxy?.handleListResponse(frame as AcpListResponse);
368
+ break;
369
+ case "acp_resume_req":
370
+ if (this.acpProxy) {
371
+ this.acpProxy.handleResumeRequest(frame as AcpResumeRequest).catch((err) => {
372
+ this.logger.error(`[clawmatrix] ACP resume error: ${err}`);
373
+ });
374
+ } else {
375
+ const rf = frame as AcpResumeRequest;
376
+ this.peerManager.sendTo(rf.from, {
377
+ type: "acp_resume_res", id: rf.id, from: this.config.nodeId, to: rf.from,
378
+ timestamp: Date.now(), payload: { success: false, error: "ACP not enabled on this node" },
379
+ } as AcpResumeResponse);
380
+ }
381
+ break;
382
+ case "acp_resume_res":
383
+ this.acpProxy?.handleResumeResponse(frame as AcpResumeResponse);
384
+ break;
385
+ case "acp_cancel":
386
+ if (this.acpProxy) {
387
+ this.acpProxy.handleCancelRequest(frame as AcpCancelRequest).catch((err) => {
388
+ this.logger.error(`[clawmatrix] ACP cancel error: ${err}`);
389
+ });
390
+ } else {
391
+ const cf = frame as AcpCancelRequest;
392
+ this.peerManager.sendTo(cf.from, {
393
+ type: "acp_cancel_res", id: cf.id, from: this.config.nodeId, to: cf.from,
394
+ timestamp: Date.now(), payload: { success: false, error: "ACP not enabled" },
395
+ } as AcpCancelResponse);
396
+ }
397
+ break;
398
+ case "acp_cancel_res":
399
+ this.acpProxy?.handleCancelResponse(frame as AcpCancelResponse);
400
+ break;
401
+ case "acp_set_mode":
402
+ if (this.acpProxy) {
403
+ this.acpProxy.handleSetModeRequest(frame as AcpSetModeRequest).catch((err) => {
404
+ this.logger.error(`[clawmatrix] ACP set mode error: ${err}`);
405
+ });
406
+ } else {
407
+ const mf = frame as AcpSetModeRequest;
408
+ this.peerManager.sendTo(mf.from, {
409
+ type: "acp_set_mode_res", id: mf.id, from: this.config.nodeId, to: mf.from,
410
+ timestamp: Date.now(), payload: { success: false, error: "ACP not enabled" },
411
+ } as AcpSetModeResponse);
412
+ }
413
+ break;
414
+ case "acp_set_mode_res":
415
+ this.acpProxy?.handleSetModeResponse(frame as AcpSetModeResponse);
416
+ break;
417
+ case "acp_get_modes":
418
+ if (this.acpProxy) {
419
+ this.acpProxy.handleGetModesRequest(frame as AcpGetModesRequest).catch((err) => {
420
+ this.logger.error(`[clawmatrix] ACP get modes error: ${err}`);
421
+ });
422
+ } else {
423
+ const gf = frame as AcpGetModesRequest;
424
+ this.peerManager.sendTo(gf.from, {
425
+ type: "acp_get_modes_res", id: gf.id, from: this.config.nodeId, to: gf.from,
426
+ timestamp: Date.now(), payload: { success: false, error: "ACP not enabled" },
427
+ } as AcpGetModesResponse);
428
+ }
429
+ break;
430
+ case "acp_get_modes_res":
431
+ this.acpProxy?.handleGetModesResponse(frame as AcpGetModesResponse);
432
+ break;
433
+ case "chat_history_req":
434
+ if (this.acpProxy) {
435
+ this.acpProxy.handleChatHistoryRequest(frame as ChatHistoryRequest).catch((err) => {
436
+ this.logger.error(`[clawmatrix] Chat history request error: ${err}`);
437
+ });
438
+ } else {
439
+ const hf = frame as ChatHistoryRequest;
440
+ this.peerManager.sendTo(hf.from, {
441
+ type: "chat_history_res", id: hf.id, from: this.config.nodeId, to: hf.from,
442
+ timestamp: Date.now(), payload: { success: true, messages: [] },
443
+ } as ChatHistoryResponse);
444
+ }
445
+ break;
446
+ case "chat_history_res":
447
+ this.acpProxy?.handleChatHistoryResponse(frame as ChatHistoryResponse);
448
+ break;
449
+ case "task_activity":
450
+ // Task activity is a notification consumed by mobile clients directly.
451
+ // No server-side handling needed — relay is handled by PeerManager.
452
+ break;
453
+ case "terminal_open":
454
+ case "terminal_open_res":
455
+ case "terminal_data":
456
+ case "terminal_resize":
457
+ case "terminal_close":
458
+ case "terminal_close_res":
459
+ this.terminalManager.dispatchFrame(
460
+ frame as TerminalOpenRequest | TerminalOpenResponse | TerminalData | TerminalResize | TerminalCloseRequest | TerminalCloseResponse,
461
+ );
462
+ break;
223
463
  }
224
464
  }
225
465
 
package/src/compat.ts CHANGED
@@ -16,12 +16,12 @@ export interface SpawnResult {
16
16
  /** Spawn a subprocess and collect stdout/stderr. */
17
17
  export function spawnProcess(
18
18
  cmd: string[],
19
- opts?: { cwd?: string; stdout?: "pipe" | "ignore"; stderr?: "pipe" | "ignore" },
20
- ): { exited: Promise<number>; stdout: ReadableStream | null; stderr: ReadableStream | null; kill: () => void } {
19
+ opts?: { cwd?: string; stdout?: "pipe" | "ignore"; stderr?: "pipe" | "ignore"; stdin?: "pipe" | "ignore" },
20
+ ): { exited: Promise<number>; stdout: ReadableStream | null; stderr: ReadableStream | null; stdin: WritableStream | null; kill: () => void; pid: number | undefined } {
21
21
  const child = cpSpawn(cmd[0]!, cmd.slice(1), {
22
22
  cwd: opts?.cwd,
23
23
  stdio: [
24
- "ignore",
24
+ opts?.stdin === "pipe" ? "pipe" : "ignore",
25
25
  opts?.stdout === "ignore" ? "ignore" : "pipe",
26
26
  opts?.stderr === "ignore" ? "ignore" : "pipe",
27
27
  ],
@@ -46,11 +46,92 @@ export function spawnProcess(
46
46
  });
47
47
  }
48
48
 
49
+ function nodeWritableToWeb(stream: import("node:stream").Writable | null): WritableStream | null {
50
+ if (!stream) return null;
51
+ return new WritableStream({
52
+ write(chunk: Uint8Array) {
53
+ return new Promise((resolve, reject) => {
54
+ stream.write(chunk, (err) => (err ? reject(err) : resolve()));
55
+ });
56
+ },
57
+ close() {
58
+ return new Promise((resolve) => {
59
+ stream.end(resolve);
60
+ });
61
+ },
62
+ abort() {
63
+ stream.destroy();
64
+ },
65
+ });
66
+ }
67
+
49
68
  return {
50
69
  exited,
51
70
  stdout: nodeStreamToWeb(child.stdout),
52
71
  stderr: nodeStreamToWeb(child.stderr),
72
+ stdin: nodeWritableToWeb(child.stdin),
53
73
  kill: () => child.kill(),
74
+ pid: child.pid,
75
+ };
76
+ }
77
+
78
+ // ── PTY support (optional, requires node-pty) ────────────────────
79
+
80
+ export interface PtyHandle {
81
+ onData(cb: (data: string) => void): void;
82
+ onExit(cb: (info: { exitCode: number; signal?: number }) => void): void;
83
+ write(data: string): void;
84
+ resize(cols: number, rows: number): void;
85
+ kill(): void;
86
+ pid: number;
87
+ }
88
+
89
+ let ptyModule: {
90
+ spawn(
91
+ file: string,
92
+ args: string[],
93
+ opts: { name?: string; cols?: number; rows?: number; cwd?: string; env?: Record<string, string> },
94
+ ): { onData: (cb: (data: string) => void) => { dispose: () => void }; onExit: (cb: (info: { exitCode: number; signal?: number }) => void) => { dispose: () => void }; write: (data: string) => void; resize: (cols: number, rows: number) => void; kill: () => void; pid: number };
95
+ } | null | undefined;
96
+
97
+ function loadPty() {
98
+ if (ptyModule !== undefined) return ptyModule;
99
+ try {
100
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
101
+ ptyModule = require("node-pty");
102
+ } catch {
103
+ ptyModule = null;
104
+ }
105
+ return ptyModule;
106
+ }
107
+
108
+ export function isPtyAvailable(): boolean {
109
+ return loadPty() !== null;
110
+ }
111
+
112
+ export function spawnPty(
113
+ shell: string,
114
+ args: string[],
115
+ opts: { cols?: number; rows?: number; cwd?: string; env?: Record<string, string> },
116
+ ): PtyHandle {
117
+ const pty = loadPty();
118
+ if (!pty) throw new Error("node-pty is not available — install it with: npm install node-pty");
119
+
120
+ const proc = pty.spawn(shell, args, {
121
+ name: "xterm-256color",
122
+ cols: opts.cols ?? 80,
123
+ rows: opts.rows ?? 24,
124
+ cwd: opts.cwd,
125
+ env: opts.env ? { ...process.env, ...opts.env } as Record<string, string> : process.env as Record<string, string>,
126
+ });
127
+
128
+ return {
129
+ onData(cb) { proc.onData(cb); },
130
+ onExit(cb) { proc.onExit(cb); },
131
+ write(data) { proc.write(data); },
132
+ resize(cols, rows) { proc.resize(cols, rows); },
133
+ kill() { proc.kill(); },
134
+ pid: proc.pid,
54
135
  };
55
136
  }
56
137