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/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 ?? process.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.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
@@ -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, cwd } = payload;
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 readFileText(candidate); return candidate; } catch { /* try default */ }
1069
+ try { await access(candidate); return candidate; } catch { /* try default */ }
1026
1070
  }
1027
1071
  const candidate = join(sessionsDir, `${sessionId}.jsonl`);
1028
- try { await readFileText(candidate); return candidate; } catch { /* continue */ }
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 readFileText(candidate); return candidate; } catch { /* next agent */ }
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 cmd = this.resolveCommand(agent);
1056
- const effectiveCwd = cwd ?? process.cwd();
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
- debug("acp", `createSession: spawning ${cmd.join(" ")} in ${effectiveCwd} (for ${from})`);
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: effectiveCwd,
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 (agent errors, missing API keys, etc.)
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
- streamCallback?.(content.text, updateType);
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
- 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);
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
- streamCallback?.("", "tool_result");
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
- // Initialize with timeout — prevents hanging if agent process is stuck
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
- sessionResponse = await Promise.race([
1160
- initAndSession(),
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 new Error(`ACP agent "${agent}" init failed: ${msg}`);
1479
+ throw err;
1480
+ } finally {
1481
+ clearTimeout(initTimer!);
1170
1482
  }
1171
- debug("acp", `[${agent}] session created: ${sessionResponse.sessionId}`);
1172
1483
 
1173
- // Extract available modes from session response
1174
- const availableModes: AcpModeInfo[] | undefined = sessionResponse.modes?.availableModes?.map(
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: sessionResponse.sessionId,
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
- let full = "";
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
- full += delta;
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 full;
1770
+ return chunks.join("");
1432
1771
  }
1433
1772
 
1434
1773
  /** Create a session by resuming an existing ACP session ID. */
1435
- private async createSessionWithResume(
1774
+ private createSessionWithResume(
1436
1775
  agent: string,
1437
1776
  acpSessionId: string,
1438
1777
  cwd: string,
1439
1778
  from: string,
1440
1779
  ): 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,
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 content = await readFileText(transcriptPath);
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
- for (const [key, entry] of Object.entries(entries)) {
1902
- if (!entry.sessionId) continue;
1903
- // Use the OpenClaw agent directory name, not the ACP backend agent.
1904
- // A cron/handoff session using Claude Code as backend is still an OpenClaw session.
1905
- // Pure Claude Code sessions are read separately from ~/.claude/history.jsonl.
1906
- const agent = agentId;
1907
- // Try to read the first user message from the transcript for description
1908
- const transcriptPath = join(sessionsDir, `${entry.sessionId}.jsonl`);
1909
- const firstMessage = await readFirstUserMessageFromTranscript(transcriptPath);
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: firstMessage ?? undefined,
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 readFileText(historyPath);
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
- const msg = parsed?.message;
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 : "";