clawmatrix 0.2.7 → 0.2.9
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/package.json +1 -1
- package/src/acp-proxy.ts +574 -207
- package/src/cluster-service.ts +24 -2
- package/src/compat.ts +36 -1
- package/src/handoff.ts +10 -3
- package/src/health-tracker.ts +581 -0
- package/src/model-proxy.ts +16 -1
- package/src/peer-manager.ts +61 -38
- package/src/router.ts +41 -0
- package/src/sentinel.ts +29 -7
- package/src/types.ts +10 -1
- package/src/web.ts +33 -0
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;
|
|
@@ -550,7 +584,7 @@ export class AcpProxy {
|
|
|
550
584
|
let newSession: AcpSession;
|
|
551
585
|
if (resumeAcpSessionId) {
|
|
552
586
|
debug("acp", `Session "${sessionId}" not found, resuming via acpSessionId "${resumeAcpSessionId.slice(0, 8)}..."`);
|
|
553
|
-
const effectiveCwd = cwd
|
|
587
|
+
const effectiveCwd = cwd || process.cwd();
|
|
554
588
|
try {
|
|
555
589
|
newSession = await this.createSessionWithResume(agent, resumeAcpSessionId, effectiveCwd, from);
|
|
556
590
|
} catch (resumeErr) {
|
|
@@ -577,7 +611,7 @@ export class AcpProxy {
|
|
|
577
611
|
});
|
|
578
612
|
}
|
|
579
613
|
if (mode === "oneshot" && task) {
|
|
580
|
-
this.
|
|
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.
|
|
674
|
+
this.returnToReusePool(session, cwd || process.cwd());
|
|
641
675
|
}
|
|
642
676
|
} else {
|
|
643
677
|
// Create new native OpenClaw session
|
|
@@ -744,7 +778,8 @@ export class AcpProxy {
|
|
|
744
778
|
/** Handle resume session request (receiver side): resume an ACP session and track it. */
|
|
745
779
|
async handleResumeRequest(frame: AcpResumeRequest): Promise<void> {
|
|
746
780
|
const { id, from, payload } = frame;
|
|
747
|
-
const { agent, acpSessionId
|
|
781
|
+
const { agent, acpSessionId } = payload;
|
|
782
|
+
const cwd = payload.cwd || process.cwd();
|
|
748
783
|
|
|
749
784
|
try {
|
|
750
785
|
// Enforce concurrent session limit
|
|
@@ -871,6 +906,15 @@ export class AcpProxy {
|
|
|
871
906
|
return;
|
|
872
907
|
}
|
|
873
908
|
|
|
909
|
+
if (session.kind !== "acp") {
|
|
910
|
+
this.peerManager.sendTo(from, {
|
|
911
|
+
type: "acp_set_mode_res", id, from: this.config.nodeId, to: from,
|
|
912
|
+
timestamp: Date.now(),
|
|
913
|
+
payload: { success: false, error: "Mode switching not supported for native sessions" },
|
|
914
|
+
} satisfies AcpSetModeResponse);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
874
918
|
try {
|
|
875
919
|
await session.conn.setSessionMode({
|
|
876
920
|
sessionId: session.acpSessionId,
|
|
@@ -910,8 +954,8 @@ export class AcpProxy {
|
|
|
910
954
|
timestamp: Date.now(),
|
|
911
955
|
payload: {
|
|
912
956
|
success: true,
|
|
913
|
-
modes: session.availableModes,
|
|
914
|
-
currentModeId: session.currentModeId,
|
|
957
|
+
modes: session.kind === "acp" ? session.availableModes : undefined,
|
|
958
|
+
currentModeId: session.kind === "acp" ? session.currentModeId : undefined,
|
|
915
959
|
},
|
|
916
960
|
} satisfies AcpGetModesResponse);
|
|
917
961
|
}
|
|
@@ -1022,17 +1066,17 @@ export class AcpProxy {
|
|
|
1022
1066
|
const sessionsDir = join(agentsDir, agentId, "sessions");
|
|
1023
1067
|
if (entry.sessionFile) {
|
|
1024
1068
|
const candidate = join(sessionsDir, entry.sessionFile);
|
|
1025
|
-
try { await
|
|
1069
|
+
try { await access(candidate); return candidate; } catch { /* try default */ }
|
|
1026
1070
|
}
|
|
1027
1071
|
const candidate = join(sessionsDir, `${sessionId}.jsonl`);
|
|
1028
|
-
try { await
|
|
1072
|
+
try { await access(candidate); return candidate; } catch { /* continue */ }
|
|
1029
1073
|
}
|
|
1030
1074
|
}
|
|
1031
1075
|
} catch { /* store unreadable */ }
|
|
1032
1076
|
|
|
1033
1077
|
// Fallback: try direct file path
|
|
1034
1078
|
const candidate = join(agentsDir, agentId, "sessions", `${sessionId}.jsonl`);
|
|
1035
|
-
try { await
|
|
1079
|
+
try { await access(candidate); return candidate; } catch { /* next agent */ }
|
|
1036
1080
|
}
|
|
1037
1081
|
} catch { /* no agents directory */ }
|
|
1038
1082
|
|
|
@@ -1046,19 +1090,309 @@ export class AcpProxy {
|
|
|
1046
1090
|
}
|
|
1047
1091
|
} catch { /* no claude projects directory */ }
|
|
1048
1092
|
|
|
1093
|
+
// 3. Search Codex sessions (~/.codex/sessions/YYYY/MM/DD/rollout-*-{sessionId}.jsonl)
|
|
1094
|
+
// Reverse sort so we search recent dates first (most likely to match)
|
|
1095
|
+
const codexSessionsDir = join(homedir(), ".codex", "sessions");
|
|
1096
|
+
try {
|
|
1097
|
+
const years = (await readdir(codexSessionsDir)).sort().reverse();
|
|
1098
|
+
for (const year of years) {
|
|
1099
|
+
const monthsDir = join(codexSessionsDir, year);
|
|
1100
|
+
let months: string[];
|
|
1101
|
+
try { months = (await readdir(monthsDir)).sort().reverse(); } catch { continue; }
|
|
1102
|
+
for (const month of months) {
|
|
1103
|
+
const daysDir = join(monthsDir, month);
|
|
1104
|
+
let days: string[];
|
|
1105
|
+
try { days = (await readdir(daysDir)).sort().reverse(); } catch { continue; }
|
|
1106
|
+
for (const day of days) {
|
|
1107
|
+
const dayDir = join(daysDir, day);
|
|
1108
|
+
let files: string[];
|
|
1109
|
+
try { files = await readdir(dayDir); } catch { continue; }
|
|
1110
|
+
const match = files.find((f) => f.endsWith(`-${sessionId}.jsonl`));
|
|
1111
|
+
if (match) return join(dayDir, match);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
} catch { /* no codex sessions directory */ }
|
|
1116
|
+
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// ── Internal: agent daemon pool ─────────────────────────────────
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Get or create a long-lived daemon for the given agent.
|
|
1124
|
+
* The daemon process stays alive and its initialized connection is reused
|
|
1125
|
+
* for all subsequent newSession() calls — eliminating spawn+init overhead.
|
|
1126
|
+
*/
|
|
1127
|
+
private async getOrCreateDaemon(agent: string, cwd: string): Promise<AgentDaemon> {
|
|
1128
|
+
const key = `${agent}:${cwd}`;
|
|
1129
|
+
// Return existing healthy daemon for this agent+cwd
|
|
1130
|
+
const existing = this.daemons.get(key);
|
|
1131
|
+
if (existing && !existing.dead) {
|
|
1132
|
+
existing.lastActiveAt = Date.now();
|
|
1133
|
+
this.resetDaemonIdleTimer(existing);
|
|
1134
|
+
return existing;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Prevent concurrent spawns for the same agent+cwd
|
|
1138
|
+
const inflight = this.daemonStarting.get(key);
|
|
1139
|
+
if (inflight) return inflight;
|
|
1140
|
+
|
|
1141
|
+
const promise = this.spawnDaemon(agent, cwd);
|
|
1142
|
+
this.daemonStarting.set(key, promise);
|
|
1143
|
+
try {
|
|
1144
|
+
const daemon = await promise;
|
|
1145
|
+
return daemon;
|
|
1146
|
+
} finally {
|
|
1147
|
+
this.daemonStarting.delete(key);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
private async spawnDaemon(agent: string, cwd: string): Promise<AgentDaemon> {
|
|
1152
|
+
const streamCallbacks = new Map<string, ((delta: string, event?: string) => void) | null>();
|
|
1153
|
+
|
|
1154
|
+
const { conn, proc } = await this.spawnAndInit(agent, cwd, "spawnDaemon", (params) => {
|
|
1155
|
+
const acpSessionId = (params as unknown as { sessionId?: string }).sessionId;
|
|
1156
|
+
if (!acpSessionId) {
|
|
1157
|
+
debug("acp", `[${agent}:daemon] sessionUpdate missing sessionId, cannot route`);
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
return streamCallbacks.get(acpSessionId) ?? null;
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
debug("acp", `[${agent}] daemon initialized`);
|
|
1164
|
+
|
|
1165
|
+
const key = `${agent}:${cwd}`;
|
|
1166
|
+
const daemon: AgentDaemon = {
|
|
1167
|
+
agent,
|
|
1168
|
+
cwd,
|
|
1169
|
+
conn,
|
|
1170
|
+
proc,
|
|
1171
|
+
streamCallbacks,
|
|
1172
|
+
lastActiveAt: Date.now(),
|
|
1173
|
+
idleTimer: setTimeout(() => {}, 0), // placeholder, reset below
|
|
1174
|
+
dead: false,
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
// Monitor process exit — clean up daemon and any sessions using its connection
|
|
1178
|
+
const onDaemonExit = () => {
|
|
1179
|
+
daemon.dead = true;
|
|
1180
|
+
// Only remove from map if this daemon hasn't been replaced by a new one
|
|
1181
|
+
if (this.daemons.get(key) === daemon) this.daemons.delete(key);
|
|
1182
|
+
// Clean up sessions that were using this daemon's connection
|
|
1183
|
+
for (const [sid, s] of this.sessions) {
|
|
1184
|
+
if (s.kind === "acp" && s.conn === daemon.conn) {
|
|
1185
|
+
this.sessions.delete(sid);
|
|
1186
|
+
debug("acp", `daemon exited: cleaned up session ${sid} (agent=${daemon.agent})`);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
};
|
|
1190
|
+
proc.exited.then(onDaemonExit).catch(onDaemonExit);
|
|
1191
|
+
|
|
1192
|
+
this.resetDaemonIdleTimer(daemon);
|
|
1193
|
+
this.daemons.set(key, daemon);
|
|
1194
|
+
return daemon;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
private resetDaemonIdleTimer(daemon: AgentDaemon) {
|
|
1198
|
+
clearTimeout(daemon.idleTimer);
|
|
1199
|
+
daemon.idleTimer = setTimeout(() => {
|
|
1200
|
+
if (daemon.dead) return;
|
|
1201
|
+
// Only kill if no active sessions are using this daemon
|
|
1202
|
+
const hasActiveSessions = [...this.sessions.values()].some(
|
|
1203
|
+
(s) => s.kind === "acp" && s.agent === daemon.agent && s.conn === daemon.conn,
|
|
1204
|
+
);
|
|
1205
|
+
if (!hasActiveSessions) {
|
|
1206
|
+
debug("acp", `daemon idle timeout: killing ${daemon.agent} (cwd=${daemon.cwd})`);
|
|
1207
|
+
daemon.dead = true;
|
|
1208
|
+
this.daemons.delete(`${daemon.agent}:${daemon.cwd}`);
|
|
1209
|
+
try { daemon.proc.kill(); } catch { /* best effort */ }
|
|
1210
|
+
} else {
|
|
1211
|
+
// Sessions still active, reschedule
|
|
1212
|
+
this.resetDaemonIdleTimer(daemon);
|
|
1213
|
+
}
|
|
1214
|
+
}, DAEMON_IDLE_TTL);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Create a new ACP session using the daemon's long-lived connection.
|
|
1219
|
+
* Falls back to spawnAndConnect if daemon is unavailable.
|
|
1220
|
+
*/
|
|
1221
|
+
private async createSessionViaDaemon(
|
|
1222
|
+
agent: string,
|
|
1223
|
+
cwd: string,
|
|
1224
|
+
from: string,
|
|
1225
|
+
): Promise<AcpSession> {
|
|
1226
|
+
const daemon = await this.getOrCreateDaemon(agent, cwd);
|
|
1227
|
+
|
|
1228
|
+
const response = await daemon.conn.newSession({ cwd, mcpServers: [] });
|
|
1229
|
+
debug("acp", `[${agent}] daemon session created: ${response.sessionId}`);
|
|
1230
|
+
|
|
1231
|
+
const availableModes: AcpModeInfo[] | undefined = response.modes?.availableModes?.map(
|
|
1232
|
+
(m) => ({ id: m.id, name: m.name, description: m.description ?? undefined }),
|
|
1233
|
+
);
|
|
1234
|
+
|
|
1235
|
+
const session: AcpSession = {
|
|
1236
|
+
kind: "acp",
|
|
1237
|
+
sessionId: crypto.randomUUID(),
|
|
1238
|
+
agent,
|
|
1239
|
+
acpSessionId: response.sessionId,
|
|
1240
|
+
conn: daemon.conn,
|
|
1241
|
+
proc: daemon.proc,
|
|
1242
|
+
lastActiveAt: Date.now(),
|
|
1243
|
+
from,
|
|
1244
|
+
setStreamCallback: (cb) => {
|
|
1245
|
+
daemon.streamCallbacks.set(response.sessionId, cb);
|
|
1246
|
+
},
|
|
1247
|
+
availableModes,
|
|
1248
|
+
currentModeId: response.modes?.currentModeId,
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
return session;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// ── Internal: session reuse & prewarming ─────────────────────────
|
|
1255
|
+
|
|
1256
|
+
/** Try to grab a reusable session from the reuse pool (same agent, same cwd). */
|
|
1257
|
+
private takeFromReusePool(agent: string, cwd: string): AcpSession | null {
|
|
1258
|
+
const key = `${agent}:${cwd}`;
|
|
1259
|
+
const entry = this.reusePool.get(key);
|
|
1260
|
+
if (!entry) return null;
|
|
1261
|
+
this.reusePool.delete(key);
|
|
1262
|
+
clearTimeout(entry.timer);
|
|
1263
|
+
debug("acp", `reuse pool hit: agent=${agent} cwd=${cwd} sessionId=${entry.session.sessionId}`);
|
|
1264
|
+
return entry.session;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/** Find the daemon that owns this session's connection (if any). */
|
|
1268
|
+
private findDaemonByConn(session: AcpSession): AgentDaemon | null {
|
|
1269
|
+
// Fast path: check the exact agent+cwd key
|
|
1270
|
+
for (const daemon of this.daemons.values()) {
|
|
1271
|
+
if (daemon.conn === session.conn) return daemon;
|
|
1272
|
+
}
|
|
1049
1273
|
return null;
|
|
1050
1274
|
}
|
|
1051
1275
|
|
|
1276
|
+
/** Return a completed oneshot session to the reuse pool instead of destroying it. */
|
|
1277
|
+
private returnToReusePool(session: AcpSession, cwd: string) {
|
|
1278
|
+
// Daemon-backed sessions don't need reuse pool — the daemon stays alive
|
|
1279
|
+
const daemon = this.findDaemonByConn(session);
|
|
1280
|
+
if (daemon) {
|
|
1281
|
+
daemon.streamCallbacks.delete(session.acpSessionId);
|
|
1282
|
+
debug("acp", `daemon-backed oneshot done: agent=${session.agent} (daemon stays alive)`);
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
const key = `${session.agent}:${cwd}`;
|
|
1287
|
+
// Evict any existing entry for the same key
|
|
1288
|
+
const existing = this.reusePool.get(key);
|
|
1289
|
+
if (existing) {
|
|
1290
|
+
clearTimeout(existing.timer);
|
|
1291
|
+
this.destroySession(existing.session);
|
|
1292
|
+
}
|
|
1293
|
+
const timer = setTimeout(() => {
|
|
1294
|
+
const entry = this.reusePool.get(key);
|
|
1295
|
+
if (entry?.session.sessionId === session.sessionId) {
|
|
1296
|
+
this.reusePool.delete(key);
|
|
1297
|
+
this.destroySession(session);
|
|
1298
|
+
debug("acp", `reuse pool expired: agent=${session.agent} cwd=${cwd}`);
|
|
1299
|
+
}
|
|
1300
|
+
}, REUSE_POOL_TTL);
|
|
1301
|
+
this.reusePool.set(key, { session, timer });
|
|
1302
|
+
debug("acp", `reuse pool stored: agent=${session.agent} cwd=${cwd} ttl=${REUSE_POOL_TTL / 1000}s`);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/** Try to grab a pre-warmed session from the warm pool (same agent + cwd). */
|
|
1306
|
+
private takeFromWarmPool(agent: string, cwd: string): AcpSession | null {
|
|
1307
|
+
const key = `${agent}:${cwd}`;
|
|
1308
|
+
const pool = this.warmPool.get(key);
|
|
1309
|
+
if (!pool || pool.length === 0) return null;
|
|
1310
|
+
const session = pool.shift()!;
|
|
1311
|
+
debug("acp", `warm pool hit: agent=${agent} cwd=${cwd} sessionId=${session.sessionId}`);
|
|
1312
|
+
return session;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/** Schedule a prewarm for the given agent after a short delay. */
|
|
1316
|
+
private schedulePrewarm(agent: string, cwd: string) {
|
|
1317
|
+
const timer = setTimeout(async () => {
|
|
1318
|
+
this.prewarmTimers.delete(timer);
|
|
1319
|
+
if (this.disposed) return;
|
|
1320
|
+
// Only prewarm if the warm pool is empty for this agent+cwd
|
|
1321
|
+
const key = `${agent}:${cwd}`;
|
|
1322
|
+
const pool = this.warmPool.get(key);
|
|
1323
|
+
if (pool && pool.length > 0) return;
|
|
1324
|
+
try {
|
|
1325
|
+
debug("acp", `prewarming agent=${agent} cwd=${cwd}`);
|
|
1326
|
+
const session = await this.spawnAndInitSession(agent, cwd, "prewarm");
|
|
1327
|
+
if (this.disposed) { this.destroySession(session); return; }
|
|
1328
|
+
if (!this.warmPool.has(key)) this.warmPool.set(key, []);
|
|
1329
|
+
this.warmPool.get(key)!.push(session);
|
|
1330
|
+
debug("acp", `warm pool ready: agent=${agent} sessionId=${session.sessionId}`);
|
|
1331
|
+
} catch (err) {
|
|
1332
|
+
debug("acp", `prewarm failed: agent=${agent} error=${errorMessage(err)}`);
|
|
1333
|
+
}
|
|
1334
|
+
}, PREWARM_DELAY);
|
|
1335
|
+
this.prewarmTimers.add(timer);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1052
1338
|
// ── Internal: ACP session management (receiver) ────────────────
|
|
1053
1339
|
|
|
1054
1340
|
private async createSession(agent: string, cwd: string | undefined, from: string): Promise<AcpSession> {
|
|
1055
|
-
const
|
|
1056
|
-
|
|
1341
|
+
const effectiveCwd = cwd || process.cwd();
|
|
1342
|
+
|
|
1343
|
+
// 1. Try daemon pool first (long-lived process, instant newSession)
|
|
1344
|
+
try {
|
|
1345
|
+
return await this.createSessionViaDaemon(agent, effectiveCwd, from);
|
|
1346
|
+
} catch (err) {
|
|
1347
|
+
debug("acp", `daemon session failed for ${agent}: ${errorMessage(err)}, falling back`);
|
|
1348
|
+
}
|
|
1057
1349
|
|
|
1058
|
-
|
|
1350
|
+
// 2. Check reuse pool (same agent + cwd, still alive)
|
|
1351
|
+
const reused = this.takeFromReusePool(agent, effectiveCwd);
|
|
1352
|
+
if (reused) {
|
|
1353
|
+
reused.from = from;
|
|
1354
|
+
reused.lastActiveAt = Date.now();
|
|
1355
|
+
reused.sessionId = crypto.randomUUID();
|
|
1356
|
+
this.monitorProcess(reused);
|
|
1357
|
+
return reused;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// 3. Check warm pool (pre-initialized, same agent + cwd)
|
|
1361
|
+
const warm = this.takeFromWarmPool(agent, effectiveCwd);
|
|
1362
|
+
if (warm) {
|
|
1363
|
+
warm.from = from;
|
|
1364
|
+
warm.lastActiveAt = Date.now();
|
|
1365
|
+
warm.sessionId = crypto.randomUUID();
|
|
1366
|
+
this.monitorProcess(warm);
|
|
1367
|
+
this.schedulePrewarm(agent, effectiveCwd);
|
|
1368
|
+
return warm;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// 4. Cold start: spawn + initialize (last resort)
|
|
1372
|
+
const session = await this.spawnAndInitSession(agent, effectiveCwd, from);
|
|
1373
|
+
this.schedulePrewarm(agent, effectiveCwd);
|
|
1374
|
+
return session;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Spawn an ACP agent process, set up NDJSON/stdio, and initialize the ACP connection.
|
|
1379
|
+
* Returns the initialized connection and process handle.
|
|
1380
|
+
* Shared by spawnDaemon (daemon pool) and spawnAndConnect (per-session).
|
|
1381
|
+
*
|
|
1382
|
+
* @param resolveStreamCb Called on each sessionUpdate to resolve the stream callback.
|
|
1383
|
+
* For daemons this routes by ACP session ID; for per-session it returns a closure variable.
|
|
1384
|
+
*/
|
|
1385
|
+
private async spawnAndInit(
|
|
1386
|
+
agent: string,
|
|
1387
|
+
cwd: string,
|
|
1388
|
+
label: string,
|
|
1389
|
+
resolveStreamCb: (params: SessionNotification) => ((delta: string, event?: string) => void) | null | undefined,
|
|
1390
|
+
): Promise<{ conn: ClientSideConnection; proc: { kill: () => void; exited: Promise<number> } }> {
|
|
1391
|
+
const cmd = this.resolveCommand(agent);
|
|
1392
|
+
debug("acp", `${label}: spawning ${cmd.join(" ")} in ${cwd}`);
|
|
1059
1393
|
|
|
1060
1394
|
const proc = spawnProcess(cmd, {
|
|
1061
|
-
cwd
|
|
1395
|
+
cwd,
|
|
1062
1396
|
stdout: "pipe",
|
|
1063
1397
|
stderr: "pipe",
|
|
1064
1398
|
stdin: "pipe",
|
|
@@ -1069,7 +1403,7 @@ export class AcpProxy {
|
|
|
1069
1403
|
throw new Error(`Failed to spawn ACP agent "${agent}": no stdio streams`);
|
|
1070
1404
|
}
|
|
1071
1405
|
|
|
1072
|
-
// Capture stderr for diagnostics
|
|
1406
|
+
// Capture stderr for diagnostics
|
|
1073
1407
|
if (proc.stderr) {
|
|
1074
1408
|
const reader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
|
|
1075
1409
|
const decoder = new TextDecoder();
|
|
@@ -1085,19 +1419,14 @@ export class AcpProxy {
|
|
|
1085
1419
|
})();
|
|
1086
1420
|
}
|
|
1087
1421
|
|
|
1088
|
-
// Detect early process exit (e.g., npx not found, package install failure)
|
|
1089
1422
|
let earlyExit = false;
|
|
1090
1423
|
const earlyExitPromise = proc.exited.then((code) => {
|
|
1091
1424
|
earlyExit = true;
|
|
1092
1425
|
throw new Error(`ACP agent "${agent}" exited during init with code ${code}`);
|
|
1093
1426
|
});
|
|
1094
1427
|
|
|
1095
|
-
// Build ACP connection over NDJSON/stdio
|
|
1096
1428
|
const stream = ndJsonStream(proc.stdin, proc.stdout);
|
|
1097
1429
|
|
|
1098
|
-
// Accumulated text for streaming back — set per-prompt via closure
|
|
1099
|
-
let streamCallback: ((delta: string, event?: string) => void) | null = null;
|
|
1100
|
-
|
|
1101
1430
|
const conn = new ClientSideConnection(
|
|
1102
1431
|
(_agentRef) => ({
|
|
1103
1432
|
requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
|
|
@@ -1106,23 +1435,21 @@ export class AcpProxy {
|
|
|
1106
1435
|
sessionUpdate: async (params: SessionNotification): Promise<void> => {
|
|
1107
1436
|
const update = params.update as Record<string, unknown>;
|
|
1108
1437
|
const updateType = update.sessionUpdate as string;
|
|
1109
|
-
|
|
1110
|
-
// Extract text delta from content chunks
|
|
1438
|
+
const cb = resolveStreamCb(params);
|
|
1111
1439
|
if (updateType === "agent_message_chunk" || updateType === "agent_thought_chunk") {
|
|
1112
1440
|
const content = (update as { content?: { type?: string; text?: string } }).content;
|
|
1113
1441
|
if (content?.type === "text" && content.text) {
|
|
1114
|
-
|
|
1442
|
+
cb?.(content.text, updateType);
|
|
1115
1443
|
}
|
|
1116
1444
|
} else if (updateType === "tool_call") {
|
|
1117
1445
|
const toolName = (update as { title?: string }).title
|
|
1118
1446
|
|| (update as { toolCallId?: string }).toolCallId
|
|
1119
1447
|
|| "unknown";
|
|
1120
|
-
|
|
1121
|
-
streamCallback?.(`\n[tool_call: ${toolName}]\n`, updateType);
|
|
1448
|
+
cb?.(`\n[tool_call: ${toolName}]\n`, updateType);
|
|
1122
1449
|
} else if (updateType === "tool_call_update") {
|
|
1123
1450
|
const status = (update as { status?: string }).status;
|
|
1124
1451
|
if (status === "completed" || status === "error") {
|
|
1125
|
-
|
|
1452
|
+
cb?.("", "tool_result");
|
|
1126
1453
|
}
|
|
1127
1454
|
}
|
|
1128
1455
|
},
|
|
@@ -1130,74 +1457,86 @@ export class AcpProxy {
|
|
|
1130
1457
|
stream,
|
|
1131
1458
|
);
|
|
1132
1459
|
|
|
1133
|
-
|
|
1460
|
+
let initTimer: ReturnType<typeof setTimeout>;
|
|
1134
1461
|
const initTimeout = new Promise<never>((_, reject) => {
|
|
1135
|
-
setTimeout(() => reject(new Error(
|
|
1462
|
+
initTimer = setTimeout(() => reject(new Error(
|
|
1136
1463
|
`ACP agent "${agent}" initialization timed out after ${SESSION_INIT_TIMEOUT / 1000}s`,
|
|
1137
1464
|
)), SESSION_INIT_TIMEOUT);
|
|
1138
1465
|
});
|
|
1139
1466
|
|
|
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
1467
|
try {
|
|
1159
|
-
|
|
1160
|
-
|
|
1468
|
+
await Promise.race([
|
|
1469
|
+
conn.initialize({
|
|
1470
|
+
protocolVersion: 1,
|
|
1471
|
+
clientInfo: { name: "ClawMatrix", version: "0.1.0" },
|
|
1472
|
+
clientCapabilities: {},
|
|
1473
|
+
}),
|
|
1161
1474
|
initTimeout,
|
|
1162
1475
|
earlyExitPromise as Promise<never>,
|
|
1163
1476
|
]);
|
|
1164
1477
|
} 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
1478
|
if (!earlyExit) proc.kill();
|
|
1169
|
-
throw
|
|
1479
|
+
throw err;
|
|
1480
|
+
} finally {
|
|
1481
|
+
clearTimeout(initTimer!);
|
|
1170
1482
|
}
|
|
1171
|
-
debug("acp", `[${agent}] session created: ${sessionResponse.sessionId}`);
|
|
1172
1483
|
|
|
1173
|
-
|
|
1174
|
-
|
|
1484
|
+
return { conn, proc };
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* Spawn an ACP agent, initialize, then call `sessionFactory` to create/resume the session.
|
|
1489
|
+
* Used for per-session (non-daemon) spawns.
|
|
1490
|
+
*/
|
|
1491
|
+
private async spawnAndConnect(
|
|
1492
|
+
agent: string,
|
|
1493
|
+
cwd: string,
|
|
1494
|
+
from: string,
|
|
1495
|
+
label: string,
|
|
1496
|
+
sessionFactory: (conn: ClientSideConnection, cwd: string) => Promise<{ sessionId: string; modes?: { availableModes?: Array<{ id: string; name: string; description?: string }>; currentModeId?: string } }>,
|
|
1497
|
+
): Promise<AcpSession> {
|
|
1498
|
+
let streamCallback: ((delta: string, event?: string) => void) | null = null;
|
|
1499
|
+
|
|
1500
|
+
const { conn, proc } = await this.spawnAndInit(agent, cwd, label, () => streamCallback);
|
|
1501
|
+
|
|
1502
|
+
let response: Awaited<ReturnType<typeof sessionFactory>>;
|
|
1503
|
+
try {
|
|
1504
|
+
response = await sessionFactory(conn, cwd);
|
|
1505
|
+
} catch (err) {
|
|
1506
|
+
proc.kill();
|
|
1507
|
+
throw err;
|
|
1508
|
+
}
|
|
1509
|
+
debug("acp", `[${agent}] session ready: ${response.sessionId}`);
|
|
1510
|
+
|
|
1511
|
+
const availableModes: AcpModeInfo[] | undefined = response.modes?.availableModes?.map(
|
|
1175
1512
|
(m) => ({ id: m.id, name: m.name, description: m.description ?? undefined }),
|
|
1176
1513
|
);
|
|
1177
|
-
const currentModeId = sessionResponse.modes?.currentModeId;
|
|
1178
|
-
|
|
1179
|
-
const sessionId = crypto.randomUUID();
|
|
1180
1514
|
|
|
1181
1515
|
const session: AcpSession = {
|
|
1182
1516
|
kind: "acp",
|
|
1183
|
-
sessionId,
|
|
1517
|
+
sessionId: crypto.randomUUID(),
|
|
1184
1518
|
agent,
|
|
1185
|
-
acpSessionId:
|
|
1519
|
+
acpSessionId: response.sessionId,
|
|
1186
1520
|
conn,
|
|
1187
1521
|
proc,
|
|
1188
1522
|
lastActiveAt: Date.now(),
|
|
1189
1523
|
from,
|
|
1190
1524
|
setStreamCallback: (cb) => { streamCallback = cb; },
|
|
1191
1525
|
availableModes,
|
|
1192
|
-
currentModeId,
|
|
1526
|
+
currentModeId: response.modes?.currentModeId,
|
|
1193
1527
|
};
|
|
1194
1528
|
|
|
1195
|
-
// Monitor process exit — clean up dead sessions proactively
|
|
1196
1529
|
this.monitorProcess(session);
|
|
1197
|
-
|
|
1198
1530
|
return session;
|
|
1199
1531
|
}
|
|
1200
1532
|
|
|
1533
|
+
/** Spawn a new ACP agent process, initialize ACP connection, and create a session. */
|
|
1534
|
+
private spawnAndInitSession(agent: string, cwd: string, from: string): Promise<AcpSession> {
|
|
1535
|
+
return this.spawnAndConnect(agent, cwd, from, "spawnAndInitSession", (conn, effectiveCwd) =>
|
|
1536
|
+
conn.newSession({ cwd: effectiveCwd, mcpServers: [] }),
|
|
1537
|
+
);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1201
1540
|
/** Watch for unexpected process exit and clean up the session. */
|
|
1202
1541
|
private monitorProcess(session: AcpSession) {
|
|
1203
1542
|
session.proc.exited.then(() => {
|
|
@@ -1386,7 +1725,7 @@ export class AcpProxy {
|
|
|
1386
1725
|
|
|
1387
1726
|
const reader = body.getReader();
|
|
1388
1727
|
const decoder = new TextDecoder();
|
|
1389
|
-
|
|
1728
|
+
const chunks: string[] = [];
|
|
1390
1729
|
let buffer = "";
|
|
1391
1730
|
|
|
1392
1731
|
try {
|
|
@@ -1407,7 +1746,7 @@ export class AcpProxy {
|
|
|
1407
1746
|
const parsed = JSON.parse(data);
|
|
1408
1747
|
const delta = parsed.choices?.[0]?.delta?.content;
|
|
1409
1748
|
if (delta) {
|
|
1410
|
-
|
|
1749
|
+
chunks.push(delta);
|
|
1411
1750
|
const streamFrame: AcpStreamChunk = {
|
|
1412
1751
|
type: "acp_stream",
|
|
1413
1752
|
id: requestId,
|
|
@@ -1428,141 +1767,19 @@ export class AcpProxy {
|
|
|
1428
1767
|
reader.releaseLock();
|
|
1429
1768
|
}
|
|
1430
1769
|
|
|
1431
|
-
return
|
|
1770
|
+
return chunks.join("");
|
|
1432
1771
|
}
|
|
1433
1772
|
|
|
1434
1773
|
/** Create a session by resuming an existing ACP session ID. */
|
|
1435
|
-
private
|
|
1774
|
+
private createSessionWithResume(
|
|
1436
1775
|
agent: string,
|
|
1437
1776
|
acpSessionId: string,
|
|
1438
1777
|
cwd: string,
|
|
1439
1778
|
from: string,
|
|
1440
1779
|
): Promise<AcpSession> {
|
|
1441
|
-
|
|
1442
|
-
|
|
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,
|
|
1780
|
+
return this.spawnAndConnect(agent, cwd, from, "createSessionWithResume", (conn) =>
|
|
1781
|
+
conn.unstable_resumeSession({ sessionId: acpSessionId, cwd }),
|
|
1509
1782
|
);
|
|
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
1783
|
}
|
|
1567
1784
|
|
|
1568
1785
|
/** Read all session stores from disk (OpenClaw + Claude Code). */
|
|
@@ -1571,7 +1788,16 @@ export class AcpProxy {
|
|
|
1571
1788
|
}
|
|
1572
1789
|
|
|
1573
1790
|
private destroySession(session: AnySession) {
|
|
1791
|
+
invalidateSessionListCache();
|
|
1574
1792
|
if (session.kind === "acp") {
|
|
1793
|
+
// Clean up daemon stream callback
|
|
1794
|
+
const daemon = this.findDaemonByConn(session);
|
|
1795
|
+
if (daemon) {
|
|
1796
|
+
daemon.streamCallbacks.delete(session.acpSessionId);
|
|
1797
|
+
// Don't kill — process is shared by the daemon
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1800
|
+
// Not a daemon-backed session — kill the process
|
|
1575
1801
|
try {
|
|
1576
1802
|
session.proc.kill();
|
|
1577
1803
|
} catch {
|
|
@@ -1844,6 +2070,36 @@ export class AcpProxy {
|
|
|
1844
2070
|
}
|
|
1845
2071
|
this.sessions.clear();
|
|
1846
2072
|
this.sessionWatchers.clear();
|
|
2073
|
+
|
|
2074
|
+
this.disposed = true;
|
|
2075
|
+
|
|
2076
|
+
// Cancel pending prewarm timers
|
|
2077
|
+
for (const timer of this.prewarmTimers) clearTimeout(timer);
|
|
2078
|
+
this.prewarmTimers.clear();
|
|
2079
|
+
|
|
2080
|
+
// Clean up reuse pool
|
|
2081
|
+
for (const [, entry] of this.reusePool) {
|
|
2082
|
+
clearTimeout(entry.timer);
|
|
2083
|
+
this.destroySession(entry.session);
|
|
2084
|
+
}
|
|
2085
|
+
this.reusePool.clear();
|
|
2086
|
+
|
|
2087
|
+
// Clean up warm pool
|
|
2088
|
+
for (const [, pool] of this.warmPool) {
|
|
2089
|
+
for (const session of pool) {
|
|
2090
|
+
this.destroySession(session);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
this.warmPool.clear();
|
|
2094
|
+
|
|
2095
|
+
// Clean up daemons
|
|
2096
|
+
for (const [, daemon] of this.daemons) {
|
|
2097
|
+
clearTimeout(daemon.idleTimer);
|
|
2098
|
+
daemon.dead = true;
|
|
2099
|
+
try { daemon.proc.kill(); } catch { /* best effort */ }
|
|
2100
|
+
}
|
|
2101
|
+
this.daemons.clear();
|
|
2102
|
+
this.daemonStarting.clear();
|
|
1847
2103
|
}
|
|
1848
2104
|
}
|
|
1849
2105
|
|
|
@@ -1855,9 +2111,7 @@ const TRANSCRIPT_MAX_LINES = 20;
|
|
|
1855
2111
|
/** Read the first user message from a session transcript JSONL file. */
|
|
1856
2112
|
async function readFirstUserMessageFromTranscript(transcriptPath: string): Promise<string | null> {
|
|
1857
2113
|
try {
|
|
1858
|
-
const
|
|
1859
|
-
// Only scan the head to avoid reading large transcripts
|
|
1860
|
-
const head = content.slice(0, TRANSCRIPT_HEAD_BYTES);
|
|
2114
|
+
const head = await readFileHead(transcriptPath, TRANSCRIPT_HEAD_BYTES);
|
|
1861
2115
|
const lines = head.split("\n").slice(0, TRANSCRIPT_MAX_LINES);
|
|
1862
2116
|
for (const line of lines) {
|
|
1863
2117
|
if (!line.trim()) continue;
|
|
@@ -1883,8 +2137,56 @@ async function readFirstUserMessageFromTranscript(transcriptPath: string): Promi
|
|
|
1883
2137
|
return null;
|
|
1884
2138
|
}
|
|
1885
2139
|
|
|
2140
|
+
// ── Session list cache (stale-while-revalidate) ─────────────────────
|
|
2141
|
+
const SESSION_LIST_CACHE_TTL = 30_000; // 30 seconds fresh
|
|
2142
|
+
const SESSION_LIST_STALE_TTL = 120_000; // serve stale up to 2 min while refreshing
|
|
2143
|
+
let sessionListCache: { result: AcpSessionInfo[]; expiresAt: number; staleAt: number } | null = null;
|
|
2144
|
+
let sessionListRefreshing = false;
|
|
2145
|
+
|
|
2146
|
+
/** Invalidate the session list cache so the next call re-reads from disk. */
|
|
2147
|
+
export function invalidateSessionListCache() {
|
|
2148
|
+
sessionListCache = null;
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
/** Refresh session list in background (stale-while-revalidate). */
|
|
2152
|
+
async function refreshSessionListInBackground() {
|
|
2153
|
+
if (sessionListRefreshing) return;
|
|
2154
|
+
sessionListRefreshing = true;
|
|
2155
|
+
try {
|
|
2156
|
+
const result = await fetchSessionListFromDisk();
|
|
2157
|
+
const now = Date.now();
|
|
2158
|
+
sessionListCache = { result, expiresAt: now + SESSION_LIST_CACHE_TTL, staleAt: now + SESSION_LIST_STALE_TTL };
|
|
2159
|
+
} catch {
|
|
2160
|
+
// Background refresh failed — keep stale data
|
|
2161
|
+
} finally {
|
|
2162
|
+
sessionListRefreshing = false;
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
1886
2166
|
/** Read all ACP session stores from disk (OpenClaw + Claude Code). Can be used without an AcpProxy instance. */
|
|
1887
2167
|
export async function readAllSessionStoresFromDisk(): Promise<AcpSessionInfo[]> {
|
|
2168
|
+
const now = Date.now();
|
|
2169
|
+
if (sessionListCache) {
|
|
2170
|
+
if (now < sessionListCache.expiresAt) {
|
|
2171
|
+
// Fresh cache — return immediately
|
|
2172
|
+
return sessionListCache.result;
|
|
2173
|
+
}
|
|
2174
|
+
if (now < sessionListCache.staleAt) {
|
|
2175
|
+
// Stale but within grace period — return stale data, refresh in background
|
|
2176
|
+
refreshSessionListInBackground();
|
|
2177
|
+
return sessionListCache.result;
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// Cache miss or expired — fetch from disk and cache
|
|
2182
|
+
const results = await fetchSessionListFromDisk();
|
|
2183
|
+
const ts = Date.now();
|
|
2184
|
+
sessionListCache = { result: results, expiresAt: ts + SESSION_LIST_CACHE_TTL, staleAt: ts + SESSION_LIST_STALE_TTL };
|
|
2185
|
+
return results;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
/** Fetch session list from disk (no caching). */
|
|
2189
|
+
async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
|
|
1888
2190
|
const results: AcpSessionInfo[] = [];
|
|
1889
2191
|
|
|
1890
2192
|
// 1. OpenClaw agent session stores (ACP-backed + native OpenClaw sessions)
|
|
@@ -1898,20 +2200,22 @@ export async function readAllSessionStoresFromDisk(): Promise<AcpSessionInfo[]>
|
|
|
1898
2200
|
try {
|
|
1899
2201
|
const content = await readFileText(storePath);
|
|
1900
2202
|
const entries: Record<string, { sessionId?: string; updatedAt?: number; displayName?: string; subject?: string; label?: string; acp?: { agent?: string } }> = JSON.parse(content);
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
2203
|
+
const agent = agentId;
|
|
2204
|
+
// Read all transcripts in parallel instead of sequentially
|
|
2205
|
+
const entryList = Object.entries(entries).filter(([, e]) => e.sessionId);
|
|
2206
|
+
const transcriptResults = await Promise.all(
|
|
2207
|
+
entryList.map(([, entry]) => {
|
|
2208
|
+
const transcriptPath = join(sessionsDir, `${entry.sessionId!}.jsonl`);
|
|
2209
|
+
return readFirstUserMessageFromTranscript(transcriptPath);
|
|
2210
|
+
}),
|
|
2211
|
+
);
|
|
2212
|
+
for (let i = 0; i < entryList.length; i++) {
|
|
2213
|
+
const [key, entry] = entryList[i]!;
|
|
1910
2214
|
results.push({
|
|
1911
|
-
sessionId: entry.sessionId
|
|
2215
|
+
sessionId: entry.sessionId!,
|
|
1912
2216
|
cwd: key,
|
|
1913
2217
|
title: entry.displayName ?? entry.subject ?? entry.label ?? undefined,
|
|
1914
|
-
description:
|
|
2218
|
+
description: transcriptResults[i] ?? undefined,
|
|
1915
2219
|
updatedAt: entry.updatedAt ? new Date(entry.updatedAt).toISOString() : undefined,
|
|
1916
2220
|
agent,
|
|
1917
2221
|
});
|
|
@@ -1925,9 +2229,11 @@ export async function readAllSessionStoresFromDisk(): Promise<AcpSessionInfo[]>
|
|
|
1925
2229
|
}
|
|
1926
2230
|
|
|
1927
2231
|
// 2. Claude Code session history (~/.claude/history.jsonl)
|
|
2232
|
+
// Only read the last 512KB — newer entries are appended at the end,
|
|
2233
|
+
// so the tail contains the most recent sessions.
|
|
1928
2234
|
try {
|
|
1929
2235
|
const historyPath = join(homedir(), ".claude", "history.jsonl");
|
|
1930
|
-
const content = await
|
|
2236
|
+
const content = await readFileTail(historyPath, 512 * 1024);
|
|
1931
2237
|
const sessions = new Map<string, { title: string; updatedAt: number; cwd: string }>();
|
|
1932
2238
|
|
|
1933
2239
|
for (const line of content.split("\n")) {
|
|
@@ -1975,6 +2281,62 @@ export async function readAllSessionStoresFromDisk(): Promise<AcpSessionInfo[]>
|
|
|
1975
2281
|
// history.jsonl missing or unreadable — skip
|
|
1976
2282
|
}
|
|
1977
2283
|
|
|
2284
|
+
// 3. Codex session history (~/.codex/history.jsonl + session_index.jsonl)
|
|
2285
|
+
try {
|
|
2286
|
+
const codexDir = join(homedir(), ".codex");
|
|
2287
|
+
// Read session index for titles
|
|
2288
|
+
const titleMap = new Map<string, string>();
|
|
2289
|
+
try {
|
|
2290
|
+
const indexContent = await readFileText(join(codexDir, "session_index.jsonl"));
|
|
2291
|
+
for (const line of indexContent.split("\n")) {
|
|
2292
|
+
if (!line.trim()) continue;
|
|
2293
|
+
try {
|
|
2294
|
+
const entry = JSON.parse(line) as { id?: string; thread_name?: string };
|
|
2295
|
+
if (entry.id && entry.thread_name) titleMap.set(entry.id, entry.thread_name);
|
|
2296
|
+
} catch { /* skip */ }
|
|
2297
|
+
}
|
|
2298
|
+
} catch { /* index missing */ }
|
|
2299
|
+
|
|
2300
|
+
const codexHistoryPath = join(codexDir, "history.jsonl");
|
|
2301
|
+
const codexContent = await readFileTail(codexHistoryPath, 512 * 1024);
|
|
2302
|
+
const codexSessions = new Map<string, { title: string; updatedAt: number }>();
|
|
2303
|
+
|
|
2304
|
+
for (const line of codexContent.split("\n")) {
|
|
2305
|
+
if (!line.trim()) continue;
|
|
2306
|
+
try {
|
|
2307
|
+
const entry = JSON.parse(line) as { session_id?: string; ts?: number; text?: string };
|
|
2308
|
+
if (!entry.session_id) continue;
|
|
2309
|
+
const existing = codexSessions.get(entry.session_id);
|
|
2310
|
+
if (!existing) {
|
|
2311
|
+
const title = (entry.text ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
|
|
2312
|
+
codexSessions.set(entry.session_id, {
|
|
2313
|
+
title,
|
|
2314
|
+
updatedAt: entry.ts ? entry.ts * 1000 : 0, // ts is in seconds
|
|
2315
|
+
});
|
|
2316
|
+
} else if (entry.ts && entry.ts * 1000 > existing.updatedAt) {
|
|
2317
|
+
existing.updatedAt = entry.ts * 1000;
|
|
2318
|
+
}
|
|
2319
|
+
} catch { /* skip */ }
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
const codexResults: AcpSessionInfo[] = [];
|
|
2323
|
+
const existingIds2 = new Set(results.map((r) => r.sessionId));
|
|
2324
|
+
for (const [sessionId, info] of codexSessions) {
|
|
2325
|
+
if (existingIds2.has(sessionId)) continue;
|
|
2326
|
+
codexResults.push({
|
|
2327
|
+
sessionId,
|
|
2328
|
+
cwd: "",
|
|
2329
|
+
title: titleMap.get(sessionId) ?? info.title,
|
|
2330
|
+
updatedAt: info.updatedAt ? new Date(info.updatedAt).toISOString() : undefined,
|
|
2331
|
+
agent: "codex",
|
|
2332
|
+
});
|
|
2333
|
+
}
|
|
2334
|
+
codexResults.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
2335
|
+
results.push(...codexResults);
|
|
2336
|
+
} catch {
|
|
2337
|
+
// Codex history missing or unreadable — skip
|
|
2338
|
+
}
|
|
2339
|
+
|
|
1978
2340
|
return results;
|
|
1979
2341
|
}
|
|
1980
2342
|
|
|
@@ -2163,7 +2525,12 @@ function normalizeTranscriptMessages(lines: string[], limit: number): ChatHistor
|
|
|
2163
2525
|
for (const line of lines) {
|
|
2164
2526
|
try {
|
|
2165
2527
|
const parsed = JSON.parse(line);
|
|
2166
|
-
|
|
2528
|
+
|
|
2529
|
+
// Support both OpenClaw/Claude format (parsed.message) and Codex format (parsed.payload with type=response_item)
|
|
2530
|
+
let msg: Record<string, unknown> | undefined = parsed?.message;
|
|
2531
|
+
if (!msg && parsed?.type === "response_item" && parsed?.payload?.role) {
|
|
2532
|
+
msg = parsed.payload;
|
|
2533
|
+
}
|
|
2167
2534
|
if (!msg) continue;
|
|
2168
2535
|
|
|
2169
2536
|
const role = typeof msg.role === "string" ? msg.role : "";
|