clawmatrix 0.2.6 → 0.2.8

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/acp-proxy.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  import type { PeerManager } from "./peer-manager.ts";
9
9
  import type { ClawMatrixConfig } from "./config.ts";
10
10
  import type { GatewayInfo } from "./tool-proxy.ts";
11
- import { spawnProcess, readFileText } from "./compat.ts";
11
+ import { spawnProcess, readFileText, readFileHead, readFileTail } from "./compat.ts";
12
12
  import { debug } from "./debug.ts";
13
13
  import { homedir } from "node:os";
14
14
  import { join } from "node:path";
@@ -57,6 +57,29 @@ const DEFAULT_SESSION_TTL = 1_800_000;
57
57
  const DEFAULT_MAX_SESSIONS = 5;
58
58
  const CLEANUP_INTERVAL = 120_000;
59
59
  const SESSION_INIT_TIMEOUT = 60_000; // 60s timeout for ACP agent initialization
60
+ const REUSE_POOL_TTL = 60_000; // keep oneshot sessions alive for 60s for reuse
61
+ const PREWARM_DELAY = 5_000; // delay before prewarming after a session is used
62
+ const DAEMON_IDLE_TTL = 300_000; // kill daemon after 5 min idle
63
+
64
+ // ── Agent Daemon: long-lived process per agent type ─────────────
65
+
66
+ /**
67
+ * A long-lived ACP agent process that stays alive across multiple sessions.
68
+ * Instead of spawning a new process per session, we reuse the same initialized
69
+ * connection and call newSession() which takes milliseconds vs seconds for a cold spawn.
70
+ */
71
+ interface AgentDaemon {
72
+ agent: string;
73
+ cwd: string;
74
+ conn: ClientSideConnection;
75
+ proc: { kill: () => void; exited: Promise<number> };
76
+ /** Per-session stream callbacks keyed by ACP session ID */
77
+ streamCallbacks: Map<string, ((delta: string, event?: string) => void) | null>;
78
+ lastActiveAt: number;
79
+ idleTimer: ReturnType<typeof setTimeout>;
80
+ /** Set to true when process has exited */
81
+ dead: boolean;
82
+ }
60
83
 
61
84
  // ── Session state on receiver side ───────────────────────────────
62
85
 
@@ -124,6 +147,17 @@ export class AcpProxy {
124
147
  private cleanupTimer: ReturnType<typeof setInterval> | null = null;
125
148
  // Multi-device sync: track which nodes are watching each session
126
149
  private sessionWatchers = new Map<string, Set<string>>();
150
+ // Session reuse pool: oneshot sessions kept alive briefly for reuse
151
+ private reusePool = new Map<string, { session: AcpSession; timer: ReturnType<typeof setTimeout> }>();
152
+ // Prewarm pool: pre-initialized ACP connections ready for immediate use
153
+ private warmPool = new Map<string, AcpSession[]>();
154
+ // Track pending prewarm timers so they can be cancelled on dispose
155
+ private prewarmTimers = new Set<ReturnType<typeof setTimeout>>();
156
+ private disposed = false;
157
+ // Agent daemon pool: long-lived process per agent type, reused across sessions
158
+ private daemons = new Map<string, AgentDaemon>();
159
+ // In-flight daemon acquisition: prevents multiple concurrent spawns for same agent
160
+ private daemonStarting = new Map<string, Promise<AgentDaemon>>();
127
161
 
128
162
  constructor(config: ClawMatrixConfig, peerManager: PeerManager, openclawConfig?: Record<string, unknown>, gatewayInfo?: GatewayInfo) {
129
163
  this.config = config;
@@ -577,7 +611,7 @@ export class AcpProxy {
577
611
  });
578
612
  }
579
613
  if (mode === "oneshot" && task) {
580
- this.destroySession(newSession);
614
+ this.returnToReusePool(newSession, cwd ?? process.cwd());
581
615
  }
582
616
  } else {
583
617
  // Re-create native session
@@ -637,7 +671,7 @@ export class AcpProxy {
637
671
  });
638
672
  }
639
673
  if (mode === "oneshot" && task) {
640
- this.destroySession(session);
674
+ this.returnToReusePool(session, cwd ?? process.cwd());
641
675
  }
642
676
  } else {
643
677
  // Create new native OpenClaw session
@@ -871,6 +905,15 @@ export class AcpProxy {
871
905
  return;
872
906
  }
873
907
 
908
+ if (session.kind !== "acp") {
909
+ this.peerManager.sendTo(from, {
910
+ type: "acp_set_mode_res", id, from: this.config.nodeId, to: from,
911
+ timestamp: Date.now(),
912
+ payload: { success: false, error: "Mode switching not supported for native sessions" },
913
+ } satisfies AcpSetModeResponse);
914
+ return;
915
+ }
916
+
874
917
  try {
875
918
  await session.conn.setSessionMode({
876
919
  sessionId: session.acpSessionId,
@@ -910,8 +953,8 @@ export class AcpProxy {
910
953
  timestamp: Date.now(),
911
954
  payload: {
912
955
  success: true,
913
- modes: session.availableModes,
914
- currentModeId: session.currentModeId,
956
+ modes: session.kind === "acp" ? session.availableModes : undefined,
957
+ currentModeId: session.kind === "acp" ? session.currentModeId : undefined,
915
958
  },
916
959
  } satisfies AcpGetModesResponse);
917
960
  }
@@ -1022,17 +1065,17 @@ export class AcpProxy {
1022
1065
  const sessionsDir = join(agentsDir, agentId, "sessions");
1023
1066
  if (entry.sessionFile) {
1024
1067
  const candidate = join(sessionsDir, entry.sessionFile);
1025
- try { await readFileText(candidate); return candidate; } catch { /* try default */ }
1068
+ try { await access(candidate); return candidate; } catch { /* try default */ }
1026
1069
  }
1027
1070
  const candidate = join(sessionsDir, `${sessionId}.jsonl`);
1028
- try { await readFileText(candidate); return candidate; } catch { /* continue */ }
1071
+ try { await access(candidate); return candidate; } catch { /* continue */ }
1029
1072
  }
1030
1073
  }
1031
1074
  } catch { /* store unreadable */ }
1032
1075
 
1033
1076
  // Fallback: try direct file path
1034
1077
  const candidate = join(agentsDir, agentId, "sessions", `${sessionId}.jsonl`);
1035
- try { await readFileText(candidate); return candidate; } catch { /* next agent */ }
1078
+ try { await access(candidate); return candidate; } catch { /* next agent */ }
1036
1079
  }
1037
1080
  } catch { /* no agents directory */ }
1038
1081
 
@@ -1049,16 +1092,282 @@ export class AcpProxy {
1049
1092
  return null;
1050
1093
  }
1051
1094
 
1095
+ // ── Internal: agent daemon pool ─────────────────────────────────
1096
+
1097
+ /**
1098
+ * Get or create a long-lived daemon for the given agent.
1099
+ * The daemon process stays alive and its initialized connection is reused
1100
+ * for all subsequent newSession() calls — eliminating spawn+init overhead.
1101
+ */
1102
+ private async getOrCreateDaemon(agent: string, cwd: string): Promise<AgentDaemon> {
1103
+ const key = `${agent}:${cwd}`;
1104
+ // Return existing healthy daemon for this agent+cwd
1105
+ const existing = this.daemons.get(key);
1106
+ if (existing && !existing.dead) {
1107
+ existing.lastActiveAt = Date.now();
1108
+ this.resetDaemonIdleTimer(existing);
1109
+ return existing;
1110
+ }
1111
+
1112
+ // Prevent concurrent spawns for the same agent+cwd
1113
+ const inflight = this.daemonStarting.get(key);
1114
+ if (inflight) return inflight;
1115
+
1116
+ const promise = this.spawnDaemon(agent, cwd);
1117
+ this.daemonStarting.set(key, promise);
1118
+ try {
1119
+ const daemon = await promise;
1120
+ return daemon;
1121
+ } finally {
1122
+ this.daemonStarting.delete(key);
1123
+ }
1124
+ }
1125
+
1126
+ private async spawnDaemon(agent: string, cwd: string): Promise<AgentDaemon> {
1127
+ const streamCallbacks = new Map<string, ((delta: string, event?: string) => void) | null>();
1128
+
1129
+ const { conn, proc } = await this.spawnAndInit(agent, cwd, "spawnDaemon", (params) => {
1130
+ const acpSessionId = (params as unknown as { sessionId?: string }).sessionId;
1131
+ if (!acpSessionId) {
1132
+ debug("acp", `[${agent}:daemon] sessionUpdate missing sessionId, cannot route`);
1133
+ return null;
1134
+ }
1135
+ return streamCallbacks.get(acpSessionId) ?? null;
1136
+ });
1137
+
1138
+ debug("acp", `[${agent}] daemon initialized`);
1139
+
1140
+ const key = `${agent}:${cwd}`;
1141
+ const daemon: AgentDaemon = {
1142
+ agent,
1143
+ cwd,
1144
+ conn,
1145
+ proc,
1146
+ streamCallbacks,
1147
+ lastActiveAt: Date.now(),
1148
+ idleTimer: setTimeout(() => {}, 0), // placeholder, reset below
1149
+ dead: false,
1150
+ };
1151
+
1152
+ // Monitor process exit — clean up daemon and any sessions using its connection
1153
+ const onDaemonExit = () => {
1154
+ daemon.dead = true;
1155
+ // Only remove from map if this daemon hasn't been replaced by a new one
1156
+ if (this.daemons.get(key) === daemon) this.daemons.delete(key);
1157
+ // Clean up sessions that were using this daemon's connection
1158
+ for (const [sid, s] of this.sessions) {
1159
+ if (s.kind === "acp" && s.conn === daemon.conn) {
1160
+ this.sessions.delete(sid);
1161
+ debug("acp", `daemon exited: cleaned up session ${sid} (agent=${daemon.agent})`);
1162
+ }
1163
+ }
1164
+ };
1165
+ proc.exited.then(onDaemonExit).catch(onDaemonExit);
1166
+
1167
+ this.resetDaemonIdleTimer(daemon);
1168
+ this.daemons.set(key, daemon);
1169
+ return daemon;
1170
+ }
1171
+
1172
+ private resetDaemonIdleTimer(daemon: AgentDaemon) {
1173
+ clearTimeout(daemon.idleTimer);
1174
+ daemon.idleTimer = setTimeout(() => {
1175
+ if (daemon.dead) return;
1176
+ // Only kill if no active sessions are using this daemon
1177
+ const hasActiveSessions = [...this.sessions.values()].some(
1178
+ (s) => s.kind === "acp" && s.agent === daemon.agent && s.conn === daemon.conn,
1179
+ );
1180
+ if (!hasActiveSessions) {
1181
+ debug("acp", `daemon idle timeout: killing ${daemon.agent} (cwd=${daemon.cwd})`);
1182
+ daemon.dead = true;
1183
+ this.daemons.delete(`${daemon.agent}:${daemon.cwd}`);
1184
+ try { daemon.proc.kill(); } catch { /* best effort */ }
1185
+ } else {
1186
+ // Sessions still active, reschedule
1187
+ this.resetDaemonIdleTimer(daemon);
1188
+ }
1189
+ }, DAEMON_IDLE_TTL);
1190
+ }
1191
+
1192
+ /**
1193
+ * Create a new ACP session using the daemon's long-lived connection.
1194
+ * Falls back to spawnAndConnect if daemon is unavailable.
1195
+ */
1196
+ private async createSessionViaDaemon(
1197
+ agent: string,
1198
+ cwd: string,
1199
+ from: string,
1200
+ ): Promise<AcpSession> {
1201
+ const daemon = await this.getOrCreateDaemon(agent, cwd);
1202
+
1203
+ const response = await daemon.conn.newSession({ cwd, mcpServers: [] });
1204
+ debug("acp", `[${agent}] daemon session created: ${response.sessionId}`);
1205
+
1206
+ const availableModes: AcpModeInfo[] | undefined = response.modes?.availableModes?.map(
1207
+ (m) => ({ id: m.id, name: m.name, description: m.description ?? undefined }),
1208
+ );
1209
+
1210
+ const session: AcpSession = {
1211
+ kind: "acp",
1212
+ sessionId: crypto.randomUUID(),
1213
+ agent,
1214
+ acpSessionId: response.sessionId,
1215
+ conn: daemon.conn,
1216
+ proc: daemon.proc,
1217
+ lastActiveAt: Date.now(),
1218
+ from,
1219
+ setStreamCallback: (cb) => {
1220
+ daemon.streamCallbacks.set(response.sessionId, cb);
1221
+ },
1222
+ availableModes,
1223
+ currentModeId: response.modes?.currentModeId,
1224
+ };
1225
+
1226
+ return session;
1227
+ }
1228
+
1229
+ // ── Internal: session reuse & prewarming ─────────────────────────
1230
+
1231
+ /** Try to grab a reusable session from the reuse pool (same agent, same cwd). */
1232
+ private takeFromReusePool(agent: string, cwd: string): AcpSession | null {
1233
+ const key = `${agent}:${cwd}`;
1234
+ const entry = this.reusePool.get(key);
1235
+ if (!entry) return null;
1236
+ this.reusePool.delete(key);
1237
+ clearTimeout(entry.timer);
1238
+ debug("acp", `reuse pool hit: agent=${agent} cwd=${cwd} sessionId=${entry.session.sessionId}`);
1239
+ return entry.session;
1240
+ }
1241
+
1242
+ /** Find the daemon that owns this session's connection (if any). */
1243
+ private findDaemonByConn(session: AcpSession): AgentDaemon | null {
1244
+ // Fast path: check the exact agent+cwd key
1245
+ for (const daemon of this.daemons.values()) {
1246
+ if (daemon.conn === session.conn) return daemon;
1247
+ }
1248
+ return null;
1249
+ }
1250
+
1251
+ /** Return a completed oneshot session to the reuse pool instead of destroying it. */
1252
+ private returnToReusePool(session: AcpSession, cwd: string) {
1253
+ // Daemon-backed sessions don't need reuse pool — the daemon stays alive
1254
+ const daemon = this.findDaemonByConn(session);
1255
+ if (daemon) {
1256
+ daemon.streamCallbacks.delete(session.acpSessionId);
1257
+ debug("acp", `daemon-backed oneshot done: agent=${session.agent} (daemon stays alive)`);
1258
+ return;
1259
+ }
1260
+
1261
+ const key = `${session.agent}:${cwd}`;
1262
+ // Evict any existing entry for the same key
1263
+ const existing = this.reusePool.get(key);
1264
+ if (existing) {
1265
+ clearTimeout(existing.timer);
1266
+ this.destroySession(existing.session);
1267
+ }
1268
+ const timer = setTimeout(() => {
1269
+ const entry = this.reusePool.get(key);
1270
+ if (entry?.session.sessionId === session.sessionId) {
1271
+ this.reusePool.delete(key);
1272
+ this.destroySession(session);
1273
+ debug("acp", `reuse pool expired: agent=${session.agent} cwd=${cwd}`);
1274
+ }
1275
+ }, REUSE_POOL_TTL);
1276
+ this.reusePool.set(key, { session, timer });
1277
+ debug("acp", `reuse pool stored: agent=${session.agent} cwd=${cwd} ttl=${REUSE_POOL_TTL / 1000}s`);
1278
+ }
1279
+
1280
+ /** Try to grab a pre-warmed session from the warm pool (same agent + cwd). */
1281
+ private takeFromWarmPool(agent: string, cwd: string): AcpSession | null {
1282
+ const key = `${agent}:${cwd}`;
1283
+ const pool = this.warmPool.get(key);
1284
+ if (!pool || pool.length === 0) return null;
1285
+ const session = pool.shift()!;
1286
+ debug("acp", `warm pool hit: agent=${agent} cwd=${cwd} sessionId=${session.sessionId}`);
1287
+ return session;
1288
+ }
1289
+
1290
+ /** Schedule a prewarm for the given agent after a short delay. */
1291
+ private schedulePrewarm(agent: string, cwd: string) {
1292
+ const timer = setTimeout(async () => {
1293
+ this.prewarmTimers.delete(timer);
1294
+ if (this.disposed) return;
1295
+ // Only prewarm if the warm pool is empty for this agent+cwd
1296
+ const key = `${agent}:${cwd}`;
1297
+ const pool = this.warmPool.get(key);
1298
+ if (pool && pool.length > 0) return;
1299
+ try {
1300
+ debug("acp", `prewarming agent=${agent} cwd=${cwd}`);
1301
+ const session = await this.spawnAndInitSession(agent, cwd, "prewarm");
1302
+ if (this.disposed) { this.destroySession(session); return; }
1303
+ if (!this.warmPool.has(key)) this.warmPool.set(key, []);
1304
+ this.warmPool.get(key)!.push(session);
1305
+ debug("acp", `warm pool ready: agent=${agent} sessionId=${session.sessionId}`);
1306
+ } catch (err) {
1307
+ debug("acp", `prewarm failed: agent=${agent} error=${errorMessage(err)}`);
1308
+ }
1309
+ }, PREWARM_DELAY);
1310
+ this.prewarmTimers.add(timer);
1311
+ }
1312
+
1052
1313
  // ── Internal: ACP session management (receiver) ────────────────
1053
1314
 
1054
1315
  private async createSession(agent: string, cwd: string | undefined, from: string): Promise<AcpSession> {
1055
- const cmd = this.resolveCommand(agent);
1056
1316
  const effectiveCwd = cwd ?? process.cwd();
1057
1317
 
1058
- debug("acp", `createSession: spawning ${cmd.join(" ")} in ${effectiveCwd} (for ${from})`);
1318
+ // 1. Try daemon pool first (long-lived process, instant newSession)
1319
+ try {
1320
+ return await this.createSessionViaDaemon(agent, effectiveCwd, from);
1321
+ } catch (err) {
1322
+ debug("acp", `daemon session failed for ${agent}: ${errorMessage(err)}, falling back`);
1323
+ }
1324
+
1325
+ // 2. Check reuse pool (same agent + cwd, still alive)
1326
+ const reused = this.takeFromReusePool(agent, effectiveCwd);
1327
+ if (reused) {
1328
+ reused.from = from;
1329
+ reused.lastActiveAt = Date.now();
1330
+ reused.sessionId = crypto.randomUUID();
1331
+ this.monitorProcess(reused);
1332
+ return reused;
1333
+ }
1334
+
1335
+ // 3. Check warm pool (pre-initialized, same agent + cwd)
1336
+ const warm = this.takeFromWarmPool(agent, effectiveCwd);
1337
+ if (warm) {
1338
+ warm.from = from;
1339
+ warm.lastActiveAt = Date.now();
1340
+ warm.sessionId = crypto.randomUUID();
1341
+ this.monitorProcess(warm);
1342
+ this.schedulePrewarm(agent, effectiveCwd);
1343
+ return warm;
1344
+ }
1345
+
1346
+ // 4. Cold start: spawn + initialize (last resort)
1347
+ const session = await this.spawnAndInitSession(agent, effectiveCwd, from);
1348
+ this.schedulePrewarm(agent, effectiveCwd);
1349
+ return session;
1350
+ }
1351
+
1352
+ /**
1353
+ * Spawn an ACP agent process, set up NDJSON/stdio, and initialize the ACP connection.
1354
+ * Returns the initialized connection and process handle.
1355
+ * Shared by spawnDaemon (daemon pool) and spawnAndConnect (per-session).
1356
+ *
1357
+ * @param resolveStreamCb Called on each sessionUpdate to resolve the stream callback.
1358
+ * For daemons this routes by ACP session ID; for per-session it returns a closure variable.
1359
+ */
1360
+ private async spawnAndInit(
1361
+ agent: string,
1362
+ cwd: string,
1363
+ label: string,
1364
+ resolveStreamCb: (params: SessionNotification) => ((delta: string, event?: string) => void) | null | undefined,
1365
+ ): Promise<{ conn: ClientSideConnection; proc: { kill: () => void; exited: Promise<number> } }> {
1366
+ const cmd = this.resolveCommand(agent);
1367
+ debug("acp", `${label}: spawning ${cmd.join(" ")} in ${cwd}`);
1059
1368
 
1060
1369
  const proc = spawnProcess(cmd, {
1061
- cwd: effectiveCwd,
1370
+ cwd,
1062
1371
  stdout: "pipe",
1063
1372
  stderr: "pipe",
1064
1373
  stdin: "pipe",
@@ -1069,7 +1378,7 @@ export class AcpProxy {
1069
1378
  throw new Error(`Failed to spawn ACP agent "${agent}": no stdio streams`);
1070
1379
  }
1071
1380
 
1072
- // Capture stderr for diagnostics (agent errors, missing API keys, etc.)
1381
+ // Capture stderr for diagnostics
1073
1382
  if (proc.stderr) {
1074
1383
  const reader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
1075
1384
  const decoder = new TextDecoder();
@@ -1085,19 +1394,14 @@ export class AcpProxy {
1085
1394
  })();
1086
1395
  }
1087
1396
 
1088
- // Detect early process exit (e.g., npx not found, package install failure)
1089
1397
  let earlyExit = false;
1090
1398
  const earlyExitPromise = proc.exited.then((code) => {
1091
1399
  earlyExit = true;
1092
1400
  throw new Error(`ACP agent "${agent}" exited during init with code ${code}`);
1093
1401
  });
1094
1402
 
1095
- // Build ACP connection over NDJSON/stdio
1096
1403
  const stream = ndJsonStream(proc.stdin, proc.stdout);
1097
1404
 
1098
- // Accumulated text for streaming back — set per-prompt via closure
1099
- let streamCallback: ((delta: string, event?: string) => void) | null = null;
1100
-
1101
1405
  const conn = new ClientSideConnection(
1102
1406
  (_agentRef) => ({
1103
1407
  requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
@@ -1106,23 +1410,21 @@ export class AcpProxy {
1106
1410
  sessionUpdate: async (params: SessionNotification): Promise<void> => {
1107
1411
  const update = params.update as Record<string, unknown>;
1108
1412
  const updateType = update.sessionUpdate as string;
1109
-
1110
- // Extract text delta from content chunks
1413
+ const cb = resolveStreamCb(params);
1111
1414
  if (updateType === "agent_message_chunk" || updateType === "agent_thought_chunk") {
1112
1415
  const content = (update as { content?: { type?: string; text?: string } }).content;
1113
1416
  if (content?.type === "text" && content.text) {
1114
- streamCallback?.(content.text, updateType);
1417
+ cb?.(content.text, updateType);
1115
1418
  }
1116
1419
  } else if (updateType === "tool_call") {
1117
1420
  const toolName = (update as { title?: string }).title
1118
1421
  || (update as { toolCallId?: string }).toolCallId
1119
1422
  || "unknown";
1120
- debug("acp", `tool_call update: title=${JSON.stringify((update as any).title)} toolCallId=${JSON.stringify((update as any).toolCallId)} keys=${Object.keys(update).join(",")}`);
1121
- streamCallback?.(`\n[tool_call: ${toolName}]\n`, updateType);
1423
+ cb?.(`\n[tool_call: ${toolName}]\n`, updateType);
1122
1424
  } else if (updateType === "tool_call_update") {
1123
1425
  const status = (update as { status?: string }).status;
1124
1426
  if (status === "completed" || status === "error") {
1125
- streamCallback?.("", "tool_result");
1427
+ cb?.("", "tool_result");
1126
1428
  }
1127
1429
  }
1128
1430
  },
@@ -1130,74 +1432,86 @@ export class AcpProxy {
1130
1432
  stream,
1131
1433
  );
1132
1434
 
1133
- // Initialize with timeout — prevents hanging if agent process is stuck
1435
+ let initTimer: ReturnType<typeof setTimeout>;
1134
1436
  const initTimeout = new Promise<never>((_, reject) => {
1135
- setTimeout(() => reject(new Error(
1437
+ initTimer = setTimeout(() => reject(new Error(
1136
1438
  `ACP agent "${agent}" initialization timed out after ${SESSION_INIT_TIMEOUT / 1000}s`,
1137
1439
  )), SESSION_INIT_TIMEOUT);
1138
1440
  });
1139
1441
 
1140
- const initAndSession = async () => {
1141
- // Initialize the ACP connection
1142
- debug("acp", `[${agent}] initializing ACP connection...`);
1143
- await conn.initialize({
1144
- protocolVersion: 1,
1145
- clientInfo: { name: "ClawMatrix", version: "0.1.0" },
1146
- clientCapabilities: {},
1147
- });
1148
- debug("acp", `[${agent}] ACP connection initialized, creating session...`);
1149
-
1150
- // Create a new session
1151
- return conn.newSession({
1152
- cwd: effectiveCwd,
1153
- mcpServers: [],
1154
- });
1155
- };
1156
-
1157
- let sessionResponse: Awaited<ReturnType<typeof conn.newSession>>;
1158
1442
  try {
1159
- sessionResponse = await Promise.race([
1160
- initAndSession(),
1443
+ await Promise.race([
1444
+ conn.initialize({
1445
+ protocolVersion: 1,
1446
+ clientInfo: { name: "ClawMatrix", version: "0.1.0" },
1447
+ clientCapabilities: {},
1448
+ }),
1161
1449
  initTimeout,
1162
1450
  earlyExitPromise as Promise<never>,
1163
1451
  ]);
1164
1452
  } catch (err) {
1165
- const msg = err instanceof Error ? err.message : (typeof err === "string" ? err : JSON.stringify(err));
1166
- debug("acp", `[${agent}] init failed: ${msg}`);
1167
- // Kill the process if init failed
1168
1453
  if (!earlyExit) proc.kill();
1169
- throw new Error(`ACP agent "${agent}" init failed: ${msg}`);
1454
+ throw err;
1455
+ } finally {
1456
+ clearTimeout(initTimer!);
1457
+ }
1458
+
1459
+ return { conn, proc };
1460
+ }
1461
+
1462
+ /**
1463
+ * Spawn an ACP agent, initialize, then call `sessionFactory` to create/resume the session.
1464
+ * Used for per-session (non-daemon) spawns.
1465
+ */
1466
+ private async spawnAndConnect(
1467
+ agent: string,
1468
+ cwd: string,
1469
+ from: string,
1470
+ label: string,
1471
+ sessionFactory: (conn: ClientSideConnection, cwd: string) => Promise<{ sessionId: string; modes?: { availableModes?: Array<{ id: string; name: string; description?: string }>; currentModeId?: string } }>,
1472
+ ): Promise<AcpSession> {
1473
+ let streamCallback: ((delta: string, event?: string) => void) | null = null;
1474
+
1475
+ const { conn, proc } = await this.spawnAndInit(agent, cwd, label, () => streamCallback);
1476
+
1477
+ let response: Awaited<ReturnType<typeof sessionFactory>>;
1478
+ try {
1479
+ response = await sessionFactory(conn, cwd);
1480
+ } catch (err) {
1481
+ proc.kill();
1482
+ throw err;
1170
1483
  }
1171
- debug("acp", `[${agent}] session created: ${sessionResponse.sessionId}`);
1484
+ debug("acp", `[${agent}] session ready: ${response.sessionId}`);
1172
1485
 
1173
- // Extract available modes from session response
1174
- const availableModes: AcpModeInfo[] | undefined = sessionResponse.modes?.availableModes?.map(
1486
+ const availableModes: AcpModeInfo[] | undefined = response.modes?.availableModes?.map(
1175
1487
  (m) => ({ id: m.id, name: m.name, description: m.description ?? undefined }),
1176
1488
  );
1177
- const currentModeId = sessionResponse.modes?.currentModeId;
1178
-
1179
- const sessionId = crypto.randomUUID();
1180
1489
 
1181
1490
  const session: AcpSession = {
1182
1491
  kind: "acp",
1183
- sessionId,
1492
+ sessionId: crypto.randomUUID(),
1184
1493
  agent,
1185
- acpSessionId: sessionResponse.sessionId,
1494
+ acpSessionId: response.sessionId,
1186
1495
  conn,
1187
1496
  proc,
1188
1497
  lastActiveAt: Date.now(),
1189
1498
  from,
1190
1499
  setStreamCallback: (cb) => { streamCallback = cb; },
1191
1500
  availableModes,
1192
- currentModeId,
1501
+ currentModeId: response.modes?.currentModeId,
1193
1502
  };
1194
1503
 
1195
- // Monitor process exit — clean up dead sessions proactively
1196
1504
  this.monitorProcess(session);
1197
-
1198
1505
  return session;
1199
1506
  }
1200
1507
 
1508
+ /** Spawn a new ACP agent process, initialize ACP connection, and create a session. */
1509
+ private spawnAndInitSession(agent: string, cwd: string, from: string): Promise<AcpSession> {
1510
+ return this.spawnAndConnect(agent, cwd, from, "spawnAndInitSession", (conn, effectiveCwd) =>
1511
+ conn.newSession({ cwd: effectiveCwd, mcpServers: [] }),
1512
+ );
1513
+ }
1514
+
1201
1515
  /** Watch for unexpected process exit and clean up the session. */
1202
1516
  private monitorProcess(session: AcpSession) {
1203
1517
  session.proc.exited.then(() => {
@@ -1386,7 +1700,7 @@ export class AcpProxy {
1386
1700
 
1387
1701
  const reader = body.getReader();
1388
1702
  const decoder = new TextDecoder();
1389
- let full = "";
1703
+ const chunks: string[] = [];
1390
1704
  let buffer = "";
1391
1705
 
1392
1706
  try {
@@ -1407,7 +1721,7 @@ export class AcpProxy {
1407
1721
  const parsed = JSON.parse(data);
1408
1722
  const delta = parsed.choices?.[0]?.delta?.content;
1409
1723
  if (delta) {
1410
- full += delta;
1724
+ chunks.push(delta);
1411
1725
  const streamFrame: AcpStreamChunk = {
1412
1726
  type: "acp_stream",
1413
1727
  id: requestId,
@@ -1428,141 +1742,19 @@ export class AcpProxy {
1428
1742
  reader.releaseLock();
1429
1743
  }
1430
1744
 
1431
- return full;
1745
+ return chunks.join("");
1432
1746
  }
1433
1747
 
1434
1748
  /** Create a session by resuming an existing ACP session ID. */
1435
- private async createSessionWithResume(
1749
+ private createSessionWithResume(
1436
1750
  agent: string,
1437
1751
  acpSessionId: string,
1438
1752
  cwd: string,
1439
1753
  from: string,
1440
1754
  ): Promise<AcpSession> {
1441
- const cmd = this.resolveCommand(agent);
1442
- debug("acp", `createSessionWithResume: spawning ${cmd.join(" ")} in ${cwd} (resume ${acpSessionId.slice(0, 8)}...)`);
1443
-
1444
- const proc = spawnProcess(cmd, {
1445
- cwd,
1446
- stdout: "pipe",
1447
- stderr: "pipe",
1448
- stdin: "pipe",
1449
- });
1450
-
1451
- if (!proc.stdin || !proc.stdout) {
1452
- proc.kill();
1453
- throw new Error(`Failed to spawn ACP agent "${agent}": no stdio streams`);
1454
- }
1455
-
1456
- // Capture stderr for diagnostics
1457
- if (proc.stderr) {
1458
- const reader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
1459
- const decoder = new TextDecoder();
1460
- (async () => {
1461
- try {
1462
- while (true) {
1463
- const { done, value } = await reader.read();
1464
- if (done) break;
1465
- const text = decoder.decode(value, { stream: true }).trim();
1466
- if (text) debug("acp", `[${agent}:stderr] ${text}`);
1467
- }
1468
- } catch { /* stream closed */ }
1469
- })();
1470
- }
1471
-
1472
- let earlyExit = false;
1473
- const earlyExitPromise = proc.exited.then((code) => {
1474
- earlyExit = true;
1475
- throw new Error(`ACP agent "${agent}" exited during resume init with code ${code}`);
1476
- });
1477
-
1478
- const stream = ndJsonStream(proc.stdin, proc.stdout);
1479
- let streamCallback: ((delta: string, event?: string) => void) | null = null;
1480
-
1481
- const conn = new ClientSideConnection(
1482
- (_agentRef) => ({
1483
- requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
1484
- return this.resolvePermission(params);
1485
- },
1486
- sessionUpdate: async (params: SessionNotification): Promise<void> => {
1487
- const update = params.update as Record<string, unknown>;
1488
- const updateType = update.sessionUpdate as string;
1489
- if (updateType === "agent_message_chunk" || updateType === "agent_thought_chunk") {
1490
- const content = (update as { content?: { type?: string; text?: string } }).content;
1491
- if (content?.type === "text" && content.text) {
1492
- streamCallback?.(content.text, updateType);
1493
- }
1494
- } else if (updateType === "tool_call") {
1495
- const toolName = (update as { title?: string }).title
1496
- || (update as { toolCallId?: string }).toolCallId
1497
- || "unknown";
1498
- debug("acp", `tool_call update: title=${JSON.stringify((update as any).title)} toolCallId=${JSON.stringify((update as any).toolCallId)} keys=${Object.keys(update).join(",")}`);
1499
- streamCallback?.(`\n[tool_call: ${toolName}]\n`, updateType);
1500
- } else if (updateType === "tool_call_update") {
1501
- const status = (update as { status?: string }).status;
1502
- if (status === "completed" || status === "error") {
1503
- streamCallback?.("", "tool_result");
1504
- }
1505
- }
1506
- },
1507
- }),
1508
- stream,
1755
+ return this.spawnAndConnect(agent, cwd, from, "createSessionWithResume", (conn) =>
1756
+ conn.unstable_resumeSession({ sessionId: acpSessionId, cwd }),
1509
1757
  );
1510
-
1511
- const initTimeout = new Promise<never>((_, reject) => {
1512
- setTimeout(() => reject(new Error(
1513
- `ACP agent "${agent}" resume initialization timed out after ${SESSION_INIT_TIMEOUT / 1000}s`,
1514
- )), SESSION_INIT_TIMEOUT);
1515
- });
1516
-
1517
- const initAndResume = async () => {
1518
- await conn.initialize({
1519
- protocolVersion: 1,
1520
- clientInfo: { name: "ClawMatrix", version: "0.1.0" },
1521
- clientCapabilities: {},
1522
- });
1523
-
1524
- // Resume the existing ACP session instead of creating a new one
1525
- return conn.unstable_resumeSession({
1526
- sessionId: acpSessionId,
1527
- cwd,
1528
- });
1529
- };
1530
-
1531
- let resumeResponse: Awaited<ReturnType<typeof conn.unstable_resumeSession>>;
1532
- try {
1533
- resumeResponse = await Promise.race([
1534
- initAndResume(),
1535
- initTimeout,
1536
- earlyExitPromise as Promise<never>,
1537
- ]);
1538
- } catch (err) {
1539
- if (!earlyExit) proc.kill();
1540
- throw err;
1541
- }
1542
-
1543
- const availableModes: AcpModeInfo[] | undefined = resumeResponse.modes?.availableModes?.map(
1544
- (m) => ({ id: m.id, name: m.name, description: m.description ?? undefined }),
1545
- );
1546
- const currentModeId = resumeResponse.modes?.currentModeId;
1547
-
1548
- const sessionId = crypto.randomUUID();
1549
-
1550
- const session: AcpSession = {
1551
- kind: "acp",
1552
- sessionId,
1553
- agent,
1554
- acpSessionId,
1555
- conn,
1556
- proc,
1557
- lastActiveAt: Date.now(),
1558
- from,
1559
- setStreamCallback: (cb) => { streamCallback = cb; },
1560
- availableModes,
1561
- currentModeId,
1562
- };
1563
-
1564
- this.monitorProcess(session);
1565
- return session;
1566
1758
  }
1567
1759
 
1568
1760
  /** Read all session stores from disk (OpenClaw + Claude Code). */
@@ -1571,7 +1763,16 @@ export class AcpProxy {
1571
1763
  }
1572
1764
 
1573
1765
  private destroySession(session: AnySession) {
1766
+ invalidateSessionListCache();
1574
1767
  if (session.kind === "acp") {
1768
+ // Clean up daemon stream callback
1769
+ const daemon = this.findDaemonByConn(session);
1770
+ if (daemon) {
1771
+ daemon.streamCallbacks.delete(session.acpSessionId);
1772
+ // Don't kill — process is shared by the daemon
1773
+ return;
1774
+ }
1775
+ // Not a daemon-backed session — kill the process
1575
1776
  try {
1576
1777
  session.proc.kill();
1577
1778
  } catch {
@@ -1614,7 +1815,7 @@ export class AcpProxy {
1614
1815
  }
1615
1816
 
1616
1817
  /** Built-in ACP agent commands (same as OpenClaw's acpx backend). */
1617
- private static readonly BUILTIN_ACP_COMMANDS: Record<string, string[]> = {
1818
+ static readonly BUILTIN_ACP_COMMANDS: Record<string, string[]> = {
1618
1819
  claude: ["npx", "-y", "@zed-industries/claude-agent-acp"],
1619
1820
  codex: ["npx", "-y", "@zed-industries/codex-acp"],
1620
1821
  gemini: ["gemini"],
@@ -1622,6 +1823,69 @@ export class AcpProxy {
1622
1823
  pi: ["npx", "-y", "pi-acp"],
1623
1824
  };
1624
1825
 
1826
+ /** Human-readable descriptions for built-in ACP agents. */
1827
+ private static readonly BUILTIN_ACP_DESCRIPTIONS: Record<string, string> = {
1828
+ claude: "Claude Code",
1829
+ codex: "OpenAI Codex CLI",
1830
+ gemini: "Gemini CLI",
1831
+ opencode: "OpenCode",
1832
+ pi: "Pi",
1833
+ };
1834
+
1835
+ /** Binary names to check for each agent (may differ from agent id). */
1836
+ private static readonly AGENT_BINARY_NAMES: Record<string, string> = {
1837
+ claude: "claude",
1838
+ codex: "codex",
1839
+ gemini: "gemini",
1840
+ opencode: "opencode",
1841
+ pi: "pi",
1842
+ };
1843
+
1844
+ /**
1845
+ * Auto-detect which ACP agents are installed by checking if their binaries
1846
+ * exist in PATH. Also checks config-defined custom commands.
1847
+ */
1848
+ static async detectAvailableAgents(
1849
+ configCommands?: Record<string, string[]>,
1850
+ ): Promise<import("./types.ts").AcpAgentInfo[]> {
1851
+ const detected: import("./types.ts").AcpAgentInfo[] = [];
1852
+ const whichCmd = process.platform === "win32" ? "where.exe" : "which";
1853
+
1854
+ // Check built-in agents
1855
+ const checks = Object.keys(AcpProxy.BUILTIN_ACP_COMMANDS).map(async (agent) => {
1856
+ const binary = AcpProxy.AGENT_BINARY_NAMES[agent] ?? agent;
1857
+ try {
1858
+ const proc = spawnProcess([whichCmd, binary], { stdout: "ignore", stderr: "ignore" });
1859
+ const code = await proc.exited;
1860
+ if (code === 0) {
1861
+ detected.push({
1862
+ id: agent,
1863
+ description: AcpProxy.BUILTIN_ACP_DESCRIPTIONS[agent] ?? agent,
1864
+ });
1865
+ }
1866
+ } catch { /* binary not found */ }
1867
+ });
1868
+
1869
+ // Check custom config commands
1870
+ if (configCommands) {
1871
+ for (const [agent, cmd] of Object.entries(configCommands)) {
1872
+ if (agent in AcpProxy.BUILTIN_ACP_COMMANDS) continue; // already checked above
1873
+ checks.push((async () => {
1874
+ try {
1875
+ const proc = spawnProcess([whichCmd, cmd[0]!], { stdout: "ignore", stderr: "ignore" });
1876
+ const code = await proc.exited;
1877
+ if (code === 0) {
1878
+ detected.push({ id: agent, description: agent });
1879
+ }
1880
+ } catch { /* binary not found */ }
1881
+ })());
1882
+ }
1883
+ }
1884
+
1885
+ await Promise.all(checks);
1886
+ return detected;
1887
+ }
1888
+
1625
1889
  private resolveCommand(agent: string): string[] {
1626
1890
  // Check config overrides first
1627
1891
  const commands = this.config.acp?.commands;
@@ -1781,6 +2045,36 @@ export class AcpProxy {
1781
2045
  }
1782
2046
  this.sessions.clear();
1783
2047
  this.sessionWatchers.clear();
2048
+
2049
+ this.disposed = true;
2050
+
2051
+ // Cancel pending prewarm timers
2052
+ for (const timer of this.prewarmTimers) clearTimeout(timer);
2053
+ this.prewarmTimers.clear();
2054
+
2055
+ // Clean up reuse pool
2056
+ for (const [, entry] of this.reusePool) {
2057
+ clearTimeout(entry.timer);
2058
+ this.destroySession(entry.session);
2059
+ }
2060
+ this.reusePool.clear();
2061
+
2062
+ // Clean up warm pool
2063
+ for (const [, pool] of this.warmPool) {
2064
+ for (const session of pool) {
2065
+ this.destroySession(session);
2066
+ }
2067
+ }
2068
+ this.warmPool.clear();
2069
+
2070
+ // Clean up daemons
2071
+ for (const [, daemon] of this.daemons) {
2072
+ clearTimeout(daemon.idleTimer);
2073
+ daemon.dead = true;
2074
+ try { daemon.proc.kill(); } catch { /* best effort */ }
2075
+ }
2076
+ this.daemons.clear();
2077
+ this.daemonStarting.clear();
1784
2078
  }
1785
2079
  }
1786
2080
 
@@ -1792,9 +2086,7 @@ const TRANSCRIPT_MAX_LINES = 20;
1792
2086
  /** Read the first user message from a session transcript JSONL file. */
1793
2087
  async function readFirstUserMessageFromTranscript(transcriptPath: string): Promise<string | null> {
1794
2088
  try {
1795
- const content = await readFileText(transcriptPath);
1796
- // Only scan the head to avoid reading large transcripts
1797
- const head = content.slice(0, TRANSCRIPT_HEAD_BYTES);
2089
+ const head = await readFileHead(transcriptPath, TRANSCRIPT_HEAD_BYTES);
1798
2090
  const lines = head.split("\n").slice(0, TRANSCRIPT_MAX_LINES);
1799
2091
  for (const line of lines) {
1800
2092
  if (!line.trim()) continue;
@@ -1820,8 +2112,56 @@ async function readFirstUserMessageFromTranscript(transcriptPath: string): Promi
1820
2112
  return null;
1821
2113
  }
1822
2114
 
2115
+ // ── Session list cache (stale-while-revalidate) ─────────────────────
2116
+ const SESSION_LIST_CACHE_TTL = 30_000; // 30 seconds fresh
2117
+ const SESSION_LIST_STALE_TTL = 120_000; // serve stale up to 2 min while refreshing
2118
+ let sessionListCache: { result: AcpSessionInfo[]; expiresAt: number; staleAt: number } | null = null;
2119
+ let sessionListRefreshing = false;
2120
+
2121
+ /** Invalidate the session list cache so the next call re-reads from disk. */
2122
+ export function invalidateSessionListCache() {
2123
+ sessionListCache = null;
2124
+ }
2125
+
2126
+ /** Refresh session list in background (stale-while-revalidate). */
2127
+ async function refreshSessionListInBackground() {
2128
+ if (sessionListRefreshing) return;
2129
+ sessionListRefreshing = true;
2130
+ try {
2131
+ const result = await fetchSessionListFromDisk();
2132
+ const now = Date.now();
2133
+ sessionListCache = { result, expiresAt: now + SESSION_LIST_CACHE_TTL, staleAt: now + SESSION_LIST_STALE_TTL };
2134
+ } catch {
2135
+ // Background refresh failed — keep stale data
2136
+ } finally {
2137
+ sessionListRefreshing = false;
2138
+ }
2139
+ }
2140
+
1823
2141
  /** Read all ACP session stores from disk (OpenClaw + Claude Code). Can be used without an AcpProxy instance. */
1824
2142
  export async function readAllSessionStoresFromDisk(): Promise<AcpSessionInfo[]> {
2143
+ const now = Date.now();
2144
+ if (sessionListCache) {
2145
+ if (now < sessionListCache.expiresAt) {
2146
+ // Fresh cache — return immediately
2147
+ return sessionListCache.result;
2148
+ }
2149
+ if (now < sessionListCache.staleAt) {
2150
+ // Stale but within grace period — return stale data, refresh in background
2151
+ refreshSessionListInBackground();
2152
+ return sessionListCache.result;
2153
+ }
2154
+ }
2155
+
2156
+ // Cache miss or expired — fetch from disk and cache
2157
+ const results = await fetchSessionListFromDisk();
2158
+ const ts = Date.now();
2159
+ sessionListCache = { result: results, expiresAt: ts + SESSION_LIST_CACHE_TTL, staleAt: ts + SESSION_LIST_STALE_TTL };
2160
+ return results;
2161
+ }
2162
+
2163
+ /** Fetch session list from disk (no caching). */
2164
+ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
1825
2165
  const results: AcpSessionInfo[] = [];
1826
2166
 
1827
2167
  // 1. OpenClaw agent session stores (ACP-backed + native OpenClaw sessions)
@@ -1835,20 +2175,22 @@ export async function readAllSessionStoresFromDisk(): Promise<AcpSessionInfo[]>
1835
2175
  try {
1836
2176
  const content = await readFileText(storePath);
1837
2177
  const entries: Record<string, { sessionId?: string; updatedAt?: number; displayName?: string; subject?: string; label?: string; acp?: { agent?: string } }> = JSON.parse(content);
1838
- for (const [key, entry] of Object.entries(entries)) {
1839
- if (!entry.sessionId) continue;
1840
- // Use the OpenClaw agent directory name, not the ACP backend agent.
1841
- // A cron/handoff session using Claude Code as backend is still an OpenClaw session.
1842
- // Pure Claude Code sessions are read separately from ~/.claude/history.jsonl.
1843
- const agent = agentId;
1844
- // Try to read the first user message from the transcript for description
1845
- const transcriptPath = join(sessionsDir, `${entry.sessionId}.jsonl`);
1846
- const firstMessage = await readFirstUserMessageFromTranscript(transcriptPath);
2178
+ const agent = agentId;
2179
+ // Read all transcripts in parallel instead of sequentially
2180
+ const entryList = Object.entries(entries).filter(([, e]) => e.sessionId);
2181
+ const transcriptResults = await Promise.all(
2182
+ entryList.map(([, entry]) => {
2183
+ const transcriptPath = join(sessionsDir, `${entry.sessionId!}.jsonl`);
2184
+ return readFirstUserMessageFromTranscript(transcriptPath);
2185
+ }),
2186
+ );
2187
+ for (let i = 0; i < entryList.length; i++) {
2188
+ const [key, entry] = entryList[i]!;
1847
2189
  results.push({
1848
- sessionId: entry.sessionId,
2190
+ sessionId: entry.sessionId!,
1849
2191
  cwd: key,
1850
2192
  title: entry.displayName ?? entry.subject ?? entry.label ?? undefined,
1851
- description: firstMessage ?? undefined,
2193
+ description: transcriptResults[i] ?? undefined,
1852
2194
  updatedAt: entry.updatedAt ? new Date(entry.updatedAt).toISOString() : undefined,
1853
2195
  agent,
1854
2196
  });
@@ -1862,9 +2204,11 @@ export async function readAllSessionStoresFromDisk(): Promise<AcpSessionInfo[]>
1862
2204
  }
1863
2205
 
1864
2206
  // 2. Claude Code session history (~/.claude/history.jsonl)
2207
+ // Only read the last 512KB — newer entries are appended at the end,
2208
+ // so the tail contains the most recent sessions.
1865
2209
  try {
1866
2210
  const historyPath = join(homedir(), ".claude", "history.jsonl");
1867
- const content = await readFileText(historyPath);
2211
+ const content = await readFileTail(historyPath, 512 * 1024);
1868
2212
  const sessions = new Map<string, { title: string; updatedAt: number; cwd: string }>();
1869
2213
 
1870
2214
  for (const line of content.split("\n")) {