clawmatrix 0.1.22 → 0.2.0

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.
@@ -0,0 +1,2073 @@
1
+ import {
2
+ ClientSideConnection,
3
+ ndJsonStream,
4
+ type SessionNotification,
5
+ type RequestPermissionRequest,
6
+ type RequestPermissionResponse,
7
+ } from "@agentclientprotocol/sdk";
8
+ import type { PeerManager } from "./peer-manager.ts";
9
+ import type { ClawMatrixConfig } from "./config.ts";
10
+ import type { GatewayInfo } from "./tool-proxy.ts";
11
+ import { spawnProcess, readFileText } from "./compat.ts";
12
+ import { debug } from "./debug.ts";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { readdir, access } from "node:fs/promises";
16
+ import type {
17
+ AcpTaskRequest,
18
+ AcpTaskResponse,
19
+ AcpStreamChunk,
20
+ AcpCloseRequest,
21
+ AcpCloseResponse,
22
+ AcpListRequest,
23
+ AcpListResponse,
24
+ AcpResumeRequest,
25
+ AcpResumeResponse,
26
+ AcpCancelRequest,
27
+ AcpCancelResponse,
28
+ AcpSetModeRequest,
29
+ AcpSetModeResponse,
30
+ AcpGetModesRequest,
31
+ AcpGetModesResponse,
32
+ AcpSessionMode,
33
+ AcpSessionInfo,
34
+ AcpModeInfo,
35
+ ChatHistoryRequest,
36
+ ChatHistoryResponse,
37
+ ChatHistoryMessage,
38
+ } from "./types.ts";
39
+ import { TaskActivityBroadcaster } from "./task-activity.ts";
40
+
41
+ const DEFAULT_TIMEOUT = 600_000;
42
+ const DEFAULT_SESSION_TTL = 1_800_000;
43
+ const DEFAULT_MAX_SESSIONS = 5;
44
+ const CLEANUP_INTERVAL = 120_000;
45
+ const SESSION_INIT_TIMEOUT = 60_000; // 60s timeout for ACP agent initialization
46
+
47
+ // ── Session state on receiver side ───────────────────────────────
48
+
49
+ interface AcpSession {
50
+ kind: "acp";
51
+ sessionId: string; // ClawMatrix-assigned session ID
52
+ agent: string;
53
+ acpSessionId: string; // ACP protocol session ID
54
+ conn: ClientSideConnection;
55
+ proc: { kill: () => void; exited: Promise<number> };
56
+ lastActiveAt: number;
57
+ from: string; // owning nodeId
58
+ setStreamCallback: (cb: ((delta: string, event?: string) => void) | null) => void;
59
+ availableModes?: AcpModeInfo[];
60
+ currentModeId?: string;
61
+ }
62
+
63
+ /** Session backed by local OpenClaw gateway (not a spawned ACP agent). */
64
+ interface NativeSession {
65
+ kind: "native";
66
+ sessionId: string;
67
+ agent: string;
68
+ sessionKey: string; // OpenClaw session key (e.g. "agent:main:cron:xxx")
69
+ lastActiveAt: number;
70
+ from: string;
71
+ abortController: AbortController | null;
72
+ }
73
+
74
+ type AnySession = AcpSession | NativeSession;
75
+
76
+ // ── Pending request state on requester side ──────────────────────
77
+
78
+ interface PendingAcpRequest {
79
+ resolve: (result: AcpTaskResponse["payload"]) => void;
80
+ reject: (error: Error) => void;
81
+ timer: ReturnType<typeof setTimeout>;
82
+ targetNodeId: string;
83
+ accumulated: string;
84
+ onStream?: (delta: string) => void;
85
+ }
86
+
87
+ // ── AcpProxy ─────────────────────────────────────────────────────
88
+
89
+ type AcpPermissionMode = "approve-all" | "approve-reads" | "deny-all";
90
+
91
+ export class AcpProxy {
92
+ private config: ClawMatrixConfig;
93
+ private peerManager: PeerManager;
94
+ private gatewayInfo: GatewayInfo | null;
95
+ private timeout: number;
96
+ private sessionTTL: number;
97
+ private maxSessions: number;
98
+ private permissionMode: AcpPermissionMode;
99
+ private taskActivity: TaskActivityBroadcaster;
100
+
101
+ // Receiver: active sessions (ACP or native OpenClaw)
102
+ private sessions = new Map<string, AnySession>();
103
+ // Requester: pending requests
104
+ private pending = new Map<string, PendingAcpRequest>();
105
+ // Close/list/resume requests reuse pending map via separate sets for type discrimination
106
+ private pendingCloses = new Set<string>();
107
+ private pendingLists = new Set<string>();
108
+ private pendingResumes = new Set<string>();
109
+ private pendingChatHistory = new Set<string>();
110
+ private cleanupTimer: ReturnType<typeof setInterval> | null = null;
111
+
112
+ constructor(config: ClawMatrixConfig, peerManager: PeerManager, openclawConfig?: Record<string, unknown>, gatewayInfo?: GatewayInfo) {
113
+ this.config = config;
114
+ this.peerManager = peerManager;
115
+ this.gatewayInfo = gatewayInfo ?? null;
116
+ this.timeout = config.acp?.timeout ?? DEFAULT_TIMEOUT;
117
+ this.sessionTTL = config.acp?.sessionTTL ?? DEFAULT_SESSION_TTL;
118
+ this.maxSessions = config.acp?.maxSessions ?? DEFAULT_MAX_SESSIONS;
119
+
120
+ // Read permission mode from OpenClaw's acpx plugin config
121
+ const acpxConfig = (openclawConfig?.plugins as Record<string, unknown>)?.entries as Record<string, unknown> | undefined;
122
+ const acpxEntry = (acpxConfig?.acpx as Record<string, unknown>)?.config as Record<string, unknown> | undefined;
123
+ const rawMode = acpxEntry?.permissionMode as string | undefined;
124
+ this.permissionMode = rawMode === "approve-all" || rawMode === "approve-reads" || rawMode === "deny-all"
125
+ ? rawMode : "approve-reads";
126
+ this.taskActivity = new TaskActivityBroadcaster(config, peerManager);
127
+
128
+ this.cleanupTimer = setInterval(() => this.cleanupIdleSessions(), CLEANUP_INTERVAL);
129
+ }
130
+
131
+ // ── Requester side: send prompt ────────────────────────────────
132
+
133
+ async prompt(
134
+ target: string,
135
+ agent: string,
136
+ task: string,
137
+ options?: {
138
+ sessionId?: string;
139
+ mode?: AcpSessionMode;
140
+ cwd?: string;
141
+ nodeId?: string;
142
+ onStream?: (delta: string) => void;
143
+ },
144
+ ): Promise<AcpTaskResponse["payload"]> {
145
+ const nodeId = options?.nodeId ?? this.resolveNode(target);
146
+ if (!nodeId) {
147
+ throw new Error(`No reachable node for target "${target}"`);
148
+ }
149
+ return this.sendRequest(nodeId, agent, task, options);
150
+ }
151
+
152
+ /** Close a remote persistent session (requester side). */
153
+ closeSession(
154
+ targetNodeId: string,
155
+ sessionId: string,
156
+ ): Promise<AcpCloseResponse["payload"]> {
157
+ const id = crypto.randomUUID();
158
+ return new Promise((resolve, reject) => {
159
+ const timer = setTimeout(() => {
160
+ this.pending.delete(id);
161
+ this.pendingCloses.delete(id);
162
+ reject(new Error("ACP close request timed out"));
163
+ }, 30_000);
164
+
165
+ this.pendingCloses.add(id);
166
+ this.pending.set(id, {
167
+ resolve: resolve as (r: AcpTaskResponse["payload"]) => void,
168
+ reject,
169
+ timer,
170
+ targetNodeId,
171
+ accumulated: "",
172
+ });
173
+
174
+ const frame: AcpCloseRequest = {
175
+ type: "acp_close",
176
+ id,
177
+ from: this.config.nodeId,
178
+ to: targetNodeId,
179
+ timestamp: Date.now(),
180
+ payload: { sessionId },
181
+ };
182
+
183
+ if (!this.peerManager.sendTo(targetNodeId, frame)) {
184
+ this.pending.delete(id);
185
+ this.pendingCloses.delete(id);
186
+ clearTimeout(timer);
187
+ reject(new Error(`Cannot reach node "${targetNodeId}"`));
188
+ }
189
+ });
190
+ }
191
+
192
+ /** List ACP sessions on a remote node (requester side). */
193
+ listSessions(
194
+ targetNodeId: string,
195
+ options?: { agent?: string; cwd?: string },
196
+ ): Promise<AcpListResponse["payload"]> {
197
+ const id = crypto.randomUUID();
198
+ return new Promise((resolve, reject) => {
199
+ const timer = setTimeout(() => {
200
+ this.pending.delete(id);
201
+ this.pendingLists.delete(id);
202
+ reject(new Error("ACP list request timed out"));
203
+ }, 30_000);
204
+
205
+ this.pendingLists.add(id);
206
+ this.pending.set(id, {
207
+ resolve: resolve as (r: AcpTaskResponse["payload"]) => void,
208
+ reject,
209
+ timer,
210
+ targetNodeId,
211
+ accumulated: "",
212
+ });
213
+
214
+ const frame: AcpListRequest = {
215
+ type: "acp_list_req",
216
+ id,
217
+ from: this.config.nodeId,
218
+ to: targetNodeId,
219
+ timestamp: Date.now(),
220
+ payload: { agent: options?.agent, cwd: options?.cwd },
221
+ };
222
+
223
+ if (!this.peerManager.sendTo(targetNodeId, frame)) {
224
+ this.pending.delete(id);
225
+ this.pendingLists.delete(id);
226
+ clearTimeout(timer);
227
+ reject(new Error(`Cannot reach node "${targetNodeId}"`));
228
+ }
229
+ });
230
+ }
231
+
232
+ /** Resume an ACP session on a remote node (requester side). */
233
+ resumeSession(
234
+ targetNodeId: string,
235
+ agent: string,
236
+ acpSessionId: string,
237
+ cwd: string,
238
+ ): Promise<AcpResumeResponse["payload"]> {
239
+ const id = crypto.randomUUID();
240
+ return new Promise((resolve, reject) => {
241
+ const timer = setTimeout(() => {
242
+ this.pending.delete(id);
243
+ this.pendingResumes.delete(id);
244
+ reject(new Error("ACP resume request timed out"));
245
+ }, 30_000);
246
+
247
+ this.pendingResumes.add(id);
248
+ this.pending.set(id, {
249
+ resolve: resolve as (r: AcpTaskResponse["payload"]) => void,
250
+ reject,
251
+ timer,
252
+ targetNodeId,
253
+ accumulated: "",
254
+ });
255
+
256
+ const frame: AcpResumeRequest = {
257
+ type: "acp_resume_req",
258
+ id,
259
+ from: this.config.nodeId,
260
+ to: targetNodeId,
261
+ timestamp: Date.now(),
262
+ payload: { agent, acpSessionId, cwd },
263
+ };
264
+
265
+ if (!this.peerManager.sendTo(targetNodeId, frame)) {
266
+ this.pending.delete(id);
267
+ this.pendingResumes.delete(id);
268
+ clearTimeout(timer);
269
+ reject(new Error(`Cannot reach node "${targetNodeId}"`));
270
+ }
271
+ });
272
+ }
273
+
274
+ /** Cancel an in-flight prompt on a remote session (requester side). */
275
+ cancelSession(
276
+ targetNodeId: string,
277
+ sessionId: string,
278
+ ): Promise<AcpCancelResponse["payload"]> {
279
+ const id = crypto.randomUUID();
280
+ return new Promise((resolve, reject) => {
281
+ const timer = setTimeout(() => {
282
+ this.pending.delete(id);
283
+ reject(new Error("ACP cancel request timed out"));
284
+ }, 15_000);
285
+
286
+ this.pending.set(id, {
287
+ resolve: resolve as (r: AcpTaskResponse["payload"]) => void,
288
+ reject,
289
+ timer,
290
+ targetNodeId,
291
+ accumulated: "",
292
+ });
293
+
294
+ const frame: AcpCancelRequest = {
295
+ type: "acp_cancel",
296
+ id,
297
+ from: this.config.nodeId,
298
+ to: targetNodeId,
299
+ timestamp: Date.now(),
300
+ payload: { sessionId },
301
+ };
302
+
303
+ if (!this.peerManager.sendTo(targetNodeId, frame)) {
304
+ this.pending.delete(id);
305
+ clearTimeout(timer);
306
+ reject(new Error(`Cannot reach node "${targetNodeId}"`));
307
+ }
308
+ });
309
+ }
310
+
311
+ /** Set session mode on a remote session (requester side). */
312
+ setSessionMode(
313
+ targetNodeId: string,
314
+ sessionId: string,
315
+ modeId: string,
316
+ ): Promise<AcpSetModeResponse["payload"]> {
317
+ const id = crypto.randomUUID();
318
+ return new Promise((resolve, reject) => {
319
+ const timer = setTimeout(() => {
320
+ this.pending.delete(id);
321
+ reject(new Error("ACP set mode request timed out"));
322
+ }, 15_000);
323
+
324
+ this.pending.set(id, {
325
+ resolve: resolve as (r: AcpTaskResponse["payload"]) => void,
326
+ reject,
327
+ timer,
328
+ targetNodeId,
329
+ accumulated: "",
330
+ });
331
+
332
+ const frame: AcpSetModeRequest = {
333
+ type: "acp_set_mode",
334
+ id,
335
+ from: this.config.nodeId,
336
+ to: targetNodeId,
337
+ timestamp: Date.now(),
338
+ payload: { sessionId, modeId },
339
+ };
340
+
341
+ if (!this.peerManager.sendTo(targetNodeId, frame)) {
342
+ this.pending.delete(id);
343
+ clearTimeout(timer);
344
+ reject(new Error(`Cannot reach node "${targetNodeId}"`));
345
+ }
346
+ });
347
+ }
348
+
349
+ /** Get available modes for a remote session (requester side). */
350
+ getSessionModes(
351
+ targetNodeId: string,
352
+ sessionId: string,
353
+ ): Promise<AcpGetModesResponse["payload"]> {
354
+ const id = crypto.randomUUID();
355
+ return new Promise((resolve, reject) => {
356
+ const timer = setTimeout(() => {
357
+ this.pending.delete(id);
358
+ reject(new Error("ACP get modes request timed out"));
359
+ }, 15_000);
360
+
361
+ this.pending.set(id, {
362
+ resolve: resolve as (r: AcpTaskResponse["payload"]) => void,
363
+ reject,
364
+ timer,
365
+ targetNodeId,
366
+ accumulated: "",
367
+ });
368
+
369
+ const frame: AcpGetModesRequest = {
370
+ type: "acp_get_modes",
371
+ id,
372
+ from: this.config.nodeId,
373
+ to: targetNodeId,
374
+ timestamp: Date.now(),
375
+ payload: { sessionId },
376
+ };
377
+
378
+ if (!this.peerManager.sendTo(targetNodeId, frame)) {
379
+ this.pending.delete(id);
380
+ clearTimeout(timer);
381
+ reject(new Error(`Cannot reach node "${targetNodeId}"`));
382
+ }
383
+ });
384
+ }
385
+
386
+ // ── Handle incoming responses (requester side) ─────────────────
387
+
388
+ handleStream(frame: AcpStreamChunk) {
389
+ const pending = this.pending.get(frame.id);
390
+ if (!pending) return;
391
+
392
+ // Only accumulate non-thinking content for the final result
393
+ if (frame.payload.event !== "agent_thought_chunk") {
394
+ pending.accumulated += frame.payload.delta;
395
+ }
396
+ if (pending.onStream && frame.payload.delta) {
397
+ pending.onStream(frame.payload.delta);
398
+ }
399
+
400
+ // Reset timeout on activity
401
+ clearTimeout(pending.timer);
402
+ pending.timer = setTimeout(() => {
403
+ this.pending.delete(frame.id);
404
+ pending.reject(new Error("ACP request timed out"));
405
+ }, this.timeout);
406
+ }
407
+
408
+ handleResponse(frame: AcpTaskResponse) {
409
+ const pending = this.pending.get(frame.id);
410
+ if (!pending) return;
411
+
412
+ clearTimeout(pending.timer);
413
+ this.pending.delete(frame.id);
414
+
415
+ if (frame.payload.success && !frame.payload.result && pending.accumulated) {
416
+ frame.payload.result = pending.accumulated;
417
+ }
418
+
419
+ pending.resolve(frame.payload);
420
+ }
421
+
422
+ handleCloseResponse(frame: AcpCloseResponse) {
423
+ const pending = this.pending.get(frame.id);
424
+ if (!pending) return;
425
+
426
+ clearTimeout(pending.timer);
427
+ this.pending.delete(frame.id);
428
+ this.pendingCloses.delete(frame.id);
429
+
430
+ pending.resolve(frame.payload as unknown as AcpTaskResponse["payload"]);
431
+ }
432
+
433
+ handleListResponse(frame: AcpListResponse) {
434
+ const pending = this.pending.get(frame.id);
435
+ if (!pending) return;
436
+
437
+ clearTimeout(pending.timer);
438
+ this.pending.delete(frame.id);
439
+ this.pendingLists.delete(frame.id);
440
+
441
+ pending.resolve(frame.payload as unknown as AcpTaskResponse["payload"]);
442
+ }
443
+
444
+ handleResumeResponse(frame: AcpResumeResponse) {
445
+ const pending = this.pending.get(frame.id);
446
+ if (!pending) return;
447
+
448
+ clearTimeout(pending.timer);
449
+ this.pending.delete(frame.id);
450
+ this.pendingResumes.delete(frame.id);
451
+
452
+ pending.resolve(frame.payload as unknown as AcpTaskResponse["payload"]);
453
+ }
454
+
455
+ handleCancelResponse(frame: AcpCancelResponse) {
456
+ const pending = this.pending.get(frame.id);
457
+ if (!pending) return;
458
+
459
+ clearTimeout(pending.timer);
460
+ this.pending.delete(frame.id);
461
+ pending.resolve(frame.payload as unknown as AcpTaskResponse["payload"]);
462
+ }
463
+
464
+ handleSetModeResponse(frame: AcpSetModeResponse) {
465
+ const pending = this.pending.get(frame.id);
466
+ if (!pending) return;
467
+
468
+ clearTimeout(pending.timer);
469
+ this.pending.delete(frame.id);
470
+ pending.resolve(frame.payload as unknown as AcpTaskResponse["payload"]);
471
+ }
472
+
473
+ handleGetModesResponse(frame: AcpGetModesResponse) {
474
+ const pending = this.pending.get(frame.id);
475
+ if (!pending) return;
476
+
477
+ clearTimeout(pending.timer);
478
+ this.pending.delete(frame.id);
479
+ pending.resolve(frame.payload as unknown as AcpTaskResponse["payload"]);
480
+ }
481
+
482
+ // ── Handle incoming requests (receiver side) ───────────────────
483
+
484
+ async handleRequest(frame: AcpTaskRequest): Promise<void> {
485
+ const { id, from, payload } = frame;
486
+ const { agent, task, sessionId, acpSessionId: resumeAcpSessionId, mode = "oneshot", cwd, images } = payload;
487
+ debug("acp", `handleRequest: id=${id} from=${from} agent=${agent} mode=${mode} sessionId=${sessionId ?? "(new)"} acpSessionId=${resumeAcpSessionId ?? "(none)"}`);
488
+
489
+ try {
490
+ if (sessionId) {
491
+ // Follow-up on existing session
492
+ const session = this.sessions.get(sessionId);
493
+ if (!session) {
494
+ // Session expired or process restarted – try to resume
495
+ if (this.isAcpAgent(agent)) {
496
+ let newSession: AcpSession;
497
+ if (resumeAcpSessionId) {
498
+ debug("acp", `Session "${sessionId}" not found, resuming via acpSessionId "${resumeAcpSessionId.slice(0, 8)}..."`);
499
+ const effectiveCwd = cwd ?? process.cwd();
500
+ try {
501
+ newSession = await this.createSessionWithResume(agent, resumeAcpSessionId, effectiveCwd, from);
502
+ } catch (resumeErr) {
503
+ debug("acp", `Resume failed (${resumeErr instanceof Error ? resumeErr.message : String(resumeErr)}), falling back to new session`);
504
+ newSession = await this.createSession(agent, cwd, from);
505
+ }
506
+ } else {
507
+ debug("acp", `Session "${sessionId}" not found, creating new session as fallback`);
508
+ newSession = await this.createSession(agent, cwd, from);
509
+ }
510
+ if (mode === "persistent") {
511
+ this.sessions.set(newSession.sessionId, newSession);
512
+ }
513
+ if (task) {
514
+ await this.runPrompt(id, from, newSession, task, images);
515
+ } else {
516
+ this.sendResponse(id, from, {
517
+ success: true,
518
+ nodeId: this.config.nodeId,
519
+ agent: newSession.agent,
520
+ sessionId: newSession.sessionId,
521
+ acpSessionId: newSession.acpSessionId,
522
+ });
523
+ }
524
+ if (mode === "oneshot" && task) {
525
+ this.destroySession(newSession);
526
+ }
527
+ } else {
528
+ // Re-create native session
529
+ debug("acp", `Native session "${sessionId}" not found, re-creating with key from cwd`);
530
+ const sessionKey = cwd ?? sessionId;
531
+ const newId = `native-${crypto.randomUUID()}`;
532
+ const nativeSession: NativeSession = {
533
+ kind: "native", sessionId: newId, agent, sessionKey,
534
+ lastActiveAt: Date.now(), from, abortController: null,
535
+ };
536
+ if (mode === "persistent") {
537
+ this.sessions.set(newId, nativeSession);
538
+ }
539
+ if (task) {
540
+ await this.runNativePrompt(id, from, nativeSession, task);
541
+ } else {
542
+ this.sendResponse(id, from, { success: true, nodeId: this.config.nodeId, agent, sessionId: newId });
543
+ }
544
+ }
545
+ return;
546
+ }
547
+ if (session.from !== from) {
548
+ this.sendResponse(id, from, { success: false, error: "Not the session owner" });
549
+ return;
550
+ }
551
+ session.lastActiveAt = Date.now();
552
+ if (session.kind === "native") {
553
+ await this.runNativePrompt(id, from, session, task);
554
+ } else {
555
+ await this.runPrompt(id, from, session, task, images);
556
+ }
557
+ } else {
558
+ // Enforce concurrent session limit
559
+ if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions && mode === "persistent") {
560
+ this.sendResponse(id, from, {
561
+ success: false,
562
+ error: `Max concurrent sessions reached (${this.maxSessions})`,
563
+ });
564
+ return;
565
+ }
566
+
567
+ if (this.isAcpAgent(agent)) {
568
+ // Create new ACP session
569
+ const session = await this.createSession(agent, cwd, from);
570
+ if (mode === "persistent") {
571
+ this.sessions.set(session.sessionId, session);
572
+ }
573
+ if (task) {
574
+ await this.runPrompt(id, from, session, task, images);
575
+ } else {
576
+ this.sendResponse(id, from, {
577
+ success: true,
578
+ nodeId: this.config.nodeId,
579
+ agent: session.agent,
580
+ sessionId: session.sessionId,
581
+ acpSessionId: session.acpSessionId,
582
+ });
583
+ }
584
+ if (mode === "oneshot" && task) {
585
+ this.destroySession(session);
586
+ }
587
+ } else {
588
+ // Create new native OpenClaw session
589
+ if (!this.gatewayInfo) {
590
+ this.sendResponse(id, from, { success: false, error: "Gateway info not available for native sessions" });
591
+ return;
592
+ }
593
+ const sessionKey = cwd ?? `agent:main:clawmatrix:${crypto.randomUUID()}`;
594
+ const newId = `native-${crypto.randomUUID()}`;
595
+ const nativeSession: NativeSession = {
596
+ kind: "native", sessionId: newId, agent, sessionKey,
597
+ lastActiveAt: Date.now(), from, abortController: null,
598
+ };
599
+ if (mode === "persistent") {
600
+ this.sessions.set(newId, nativeSession);
601
+ }
602
+ if (task) {
603
+ await this.runNativePrompt(id, from, nativeSession, task);
604
+ } else {
605
+ this.sendResponse(id, from, { success: true, nodeId: this.config.nodeId, agent, sessionId: newId });
606
+ }
607
+ }
608
+ }
609
+ } catch (err) {
610
+ this.sendResponse(id, from, {
611
+ success: false,
612
+ error: err instanceof Error ? err.message : String(err),
613
+ });
614
+ }
615
+ }
616
+
617
+ async handleClose(frame: AcpCloseRequest): Promise<void> {
618
+ const { id, from, payload } = frame;
619
+ const session = this.sessions.get(payload.sessionId);
620
+
621
+ if (!session) {
622
+ this.peerManager.sendTo(from, {
623
+ type: "acp_close_res", id, from: this.config.nodeId, to: from,
624
+ timestamp: Date.now(),
625
+ payload: { success: false, error: "Session not found" },
626
+ } satisfies AcpCloseResponse);
627
+ return;
628
+ }
629
+
630
+ if (session.from !== from) {
631
+ this.peerManager.sendTo(from, {
632
+ type: "acp_close_res", id, from: this.config.nodeId, to: from,
633
+ timestamp: Date.now(),
634
+ payload: { success: false, error: "Not the session owner" },
635
+ } satisfies AcpCloseResponse);
636
+ return;
637
+ }
638
+
639
+ this.sessions.delete(payload.sessionId);
640
+ this.destroySession(session);
641
+
642
+ this.peerManager.sendTo(from, {
643
+ type: "acp_close_res", id, from: this.config.nodeId, to: from,
644
+ timestamp: Date.now(),
645
+ payload: { success: true },
646
+ } satisfies AcpCloseResponse);
647
+ }
648
+
649
+ /** Handle list sessions request (receiver side). */
650
+ async handleListRequest(frame: AcpListRequest): Promise<void> {
651
+ const { id, from, payload } = frame;
652
+
653
+ try {
654
+ // 1. ClawMatrix-managed active sessions
655
+ const clawSessions: AcpSessionInfo[] = [];
656
+ for (const [, session] of this.sessions) {
657
+ if (payload.agent && session.agent !== payload.agent) continue;
658
+ clawSessions.push({
659
+ sessionId: session.sessionId,
660
+ cwd: "",
661
+ agent: session.agent,
662
+ updatedAt: new Date(session.lastActiveAt).toISOString(),
663
+ });
664
+ }
665
+
666
+ // 2. Read persisted sessions directly from disk (no daemon spawn needed)
667
+ const diskSessions = await this.readAllSessionStoresFromDisk();
668
+ // Filter by agent if requested, and exclude already-tracked active sessions
669
+ const filteredDiskSessions = diskSessions.filter((s) => {
670
+ if (payload.agent && s.agent !== payload.agent) return false;
671
+ return !clawSessions.some((cs) => cs.sessionId === s.sessionId);
672
+ });
673
+
674
+ this.peerManager.sendTo(from, {
675
+ type: "acp_list_res", id, from: this.config.nodeId, to: from,
676
+ timestamp: Date.now(),
677
+ payload: { success: true, sessions: [...filteredDiskSessions, ...clawSessions] },
678
+ } satisfies AcpListResponse);
679
+ } catch (err) {
680
+ this.peerManager.sendTo(from, {
681
+ type: "acp_list_res", id, from: this.config.nodeId, to: from,
682
+ timestamp: Date.now(),
683
+ payload: { success: false, error: err instanceof Error ? err.message : String(err) },
684
+ } satisfies AcpListResponse);
685
+ }
686
+ }
687
+
688
+ /** Handle resume session request (receiver side): resume an ACP session and track it. */
689
+ async handleResumeRequest(frame: AcpResumeRequest): Promise<void> {
690
+ const { id, from, payload } = frame;
691
+ const { agent, acpSessionId, cwd } = payload;
692
+
693
+ try {
694
+ // Enforce concurrent session limit
695
+ if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) {
696
+ this.peerManager.sendTo(from, {
697
+ type: "acp_resume_res", id, from: this.config.nodeId, to: from,
698
+ timestamp: Date.now(),
699
+ payload: { success: false, error: `Max concurrent sessions reached (${this.maxSessions})` },
700
+ } satisfies AcpResumeResponse);
701
+ return;
702
+ }
703
+
704
+ if (this.isAcpAgent(agent)) {
705
+ // Spawn a fresh ACP agent process and resume the session on it
706
+ let session: AcpSession;
707
+ try {
708
+ session = await this.createSessionWithResume(agent, acpSessionId, cwd, from);
709
+ } catch (resumeErr) {
710
+ debug("acp", `Resume failed (${resumeErr instanceof Error ? resumeErr.message : String(resumeErr)}), falling back to new session`);
711
+ session = await this.createSession(agent, cwd, from);
712
+ }
713
+ this.sessions.set(session.sessionId, session);
714
+
715
+ this.peerManager.sendTo(from, {
716
+ type: "acp_resume_res", id, from: this.config.nodeId, to: from,
717
+ timestamp: Date.now(),
718
+ payload: { success: true, sessionId: session.sessionId },
719
+ } satisfies AcpResumeResponse);
720
+ } else {
721
+ // Native OpenClaw session — no process to spawn, just record the session key
722
+ // cwd contains the OpenClaw session key (e.g. "agent:main:clawmatrix-handoff:xxx")
723
+ const sessionId = `native-${acpSessionId}`;
724
+ const nativeSession: NativeSession = {
725
+ kind: "native",
726
+ sessionId,
727
+ agent,
728
+ sessionKey: cwd,
729
+ lastActiveAt: Date.now(),
730
+ from,
731
+ abortController: null,
732
+ };
733
+ this.sessions.set(sessionId, nativeSession);
734
+
735
+ this.peerManager.sendTo(from, {
736
+ type: "acp_resume_res", id, from: this.config.nodeId, to: from,
737
+ timestamp: Date.now(),
738
+ payload: { success: true, sessionId },
739
+ } satisfies AcpResumeResponse);
740
+ }
741
+ } catch (err) {
742
+ this.peerManager.sendTo(from, {
743
+ type: "acp_resume_res", id, from: this.config.nodeId, to: from,
744
+ timestamp: Date.now(),
745
+ payload: { success: false, error: err instanceof Error ? err.message : String(err) },
746
+ } satisfies AcpResumeResponse);
747
+ }
748
+ }
749
+
750
+ /** Handle cancel request (receiver side): cancel in-flight prompt. */
751
+ async handleCancelRequest(frame: AcpCancelRequest): Promise<void> {
752
+ const { id, from, payload } = frame;
753
+ const session = this.sessions.get(payload.sessionId);
754
+
755
+ if (!session) {
756
+ this.peerManager.sendTo(from, {
757
+ type: "acp_cancel_res", id, from: this.config.nodeId, to: from,
758
+ timestamp: Date.now(),
759
+ payload: { success: false, error: "Session not found" },
760
+ } satisfies AcpCancelResponse);
761
+ return;
762
+ }
763
+
764
+ if (session.from !== from) {
765
+ this.peerManager.sendTo(from, {
766
+ type: "acp_cancel_res", id, from: this.config.nodeId, to: from,
767
+ timestamp: Date.now(),
768
+ payload: { success: false, error: "Not the session owner" },
769
+ } satisfies AcpCancelResponse);
770
+ return;
771
+ }
772
+
773
+ try {
774
+ if (session.kind === "acp") {
775
+ await session.conn.cancel({ sessionId: session.acpSessionId });
776
+ } else {
777
+ session.abortController?.abort();
778
+ }
779
+ this.peerManager.sendTo(from, {
780
+ type: "acp_cancel_res", id, from: this.config.nodeId, to: from,
781
+ timestamp: Date.now(),
782
+ payload: { success: true },
783
+ } satisfies AcpCancelResponse);
784
+ } catch (err) {
785
+ this.peerManager.sendTo(from, {
786
+ type: "acp_cancel_res", id, from: this.config.nodeId, to: from,
787
+ timestamp: Date.now(),
788
+ payload: { success: false, error: err instanceof Error ? err.message : String(err) },
789
+ } satisfies AcpCancelResponse);
790
+ }
791
+ }
792
+
793
+ /** Handle set mode request (receiver side): switch session mode. */
794
+ async handleSetModeRequest(frame: AcpSetModeRequest): Promise<void> {
795
+ const { id, from, payload } = frame;
796
+ const session = this.sessions.get(payload.sessionId);
797
+
798
+ if (!session) {
799
+ this.peerManager.sendTo(from, {
800
+ type: "acp_set_mode_res", id, from: this.config.nodeId, to: from,
801
+ timestamp: Date.now(),
802
+ payload: { success: false, error: "Session not found" },
803
+ } satisfies AcpSetModeResponse);
804
+ return;
805
+ }
806
+
807
+ if (session.from !== from) {
808
+ this.peerManager.sendTo(from, {
809
+ type: "acp_set_mode_res", id, from: this.config.nodeId, to: from,
810
+ timestamp: Date.now(),
811
+ payload: { success: false, error: "Not the session owner" },
812
+ } satisfies AcpSetModeResponse);
813
+ return;
814
+ }
815
+
816
+ try {
817
+ await session.conn.setSessionMode({
818
+ sessionId: session.acpSessionId,
819
+ modeId: payload.modeId,
820
+ });
821
+ session.currentModeId = payload.modeId;
822
+ this.peerManager.sendTo(from, {
823
+ type: "acp_set_mode_res", id, from: this.config.nodeId, to: from,
824
+ timestamp: Date.now(),
825
+ payload: { success: true },
826
+ } satisfies AcpSetModeResponse);
827
+ } catch (err) {
828
+ this.peerManager.sendTo(from, {
829
+ type: "acp_set_mode_res", id, from: this.config.nodeId, to: from,
830
+ timestamp: Date.now(),
831
+ payload: { success: false, error: err instanceof Error ? err.message : String(err) },
832
+ } satisfies AcpSetModeResponse);
833
+ }
834
+ }
835
+
836
+ /** Handle get modes request (receiver side): return available modes. */
837
+ async handleGetModesRequest(frame: AcpGetModesRequest): Promise<void> {
838
+ const { id, from, payload } = frame;
839
+ const session = this.sessions.get(payload.sessionId);
840
+
841
+ if (!session) {
842
+ this.peerManager.sendTo(from, {
843
+ type: "acp_get_modes_res", id, from: this.config.nodeId, to: from,
844
+ timestamp: Date.now(),
845
+ payload: { success: false, error: "Session not found" },
846
+ } satisfies AcpGetModesResponse);
847
+ return;
848
+ }
849
+
850
+ this.peerManager.sendTo(from, {
851
+ type: "acp_get_modes_res", id, from: this.config.nodeId, to: from,
852
+ timestamp: Date.now(),
853
+ payload: {
854
+ success: true,
855
+ modes: session.availableModes,
856
+ currentModeId: session.currentModeId,
857
+ },
858
+ } satisfies AcpGetModesResponse);
859
+ }
860
+
861
+ // ── Chat history (receiver side) ─────────────────────────────────
862
+
863
+ /** Handle chat history request: read session transcript from disk and return normalized messages. */
864
+ async handleChatHistoryRequest(frame: ChatHistoryRequest): Promise<void> {
865
+ const { id, from, payload } = frame;
866
+ const { sessionId, limit = 200 } = payload;
867
+
868
+ try {
869
+ const transcriptPath = await this.findTranscriptPath(sessionId);
870
+ if (!transcriptPath) {
871
+ this.peerManager.sendTo(from, {
872
+ type: "chat_history_res", id, from: this.config.nodeId, to: from,
873
+ timestamp: Date.now(),
874
+ payload: { success: true, messages: [] },
875
+ } satisfies ChatHistoryResponse);
876
+ return;
877
+ }
878
+
879
+ const content = await readFileText(transcriptPath);
880
+ const lines = content.split(/\r?\n/).filter((l: string) => l.trim());
881
+ const messages = normalizeTranscriptMessages(lines, limit);
882
+
883
+ this.peerManager.sendTo(from, {
884
+ type: "chat_history_res", id, from: this.config.nodeId, to: from,
885
+ timestamp: Date.now(),
886
+ payload: { success: true, messages },
887
+ } satisfies ChatHistoryResponse);
888
+ } catch (err) {
889
+ this.peerManager.sendTo(from, {
890
+ type: "chat_history_res", id, from: this.config.nodeId, to: from,
891
+ timestamp: Date.now(),
892
+ payload: { success: false, error: err instanceof Error ? err.message : String(err) },
893
+ } satisfies ChatHistoryResponse);
894
+ }
895
+ }
896
+
897
+ /** Handle chat history response (requester side). */
898
+ handleChatHistoryResponse(frame: ChatHistoryResponse): void {
899
+ const entry = this.pending.get(frame.id);
900
+ if (!entry) return;
901
+ this.pending.delete(frame.id);
902
+ this.pendingChatHistory.delete(frame.id);
903
+ clearTimeout(entry.timer);
904
+ entry.resolve(frame.payload as unknown as AcpTaskResponse["payload"]);
905
+ }
906
+
907
+ /** Request chat history from a remote node (requester side). */
908
+ requestChatHistory(
909
+ targetNodeId: string,
910
+ sessionId: string,
911
+ limit?: number,
912
+ ): Promise<ChatHistoryResponse["payload"]> {
913
+ const id = crypto.randomUUID();
914
+ return new Promise((resolve, reject) => {
915
+ const timer = setTimeout(() => {
916
+ this.pending.delete(id);
917
+ this.pendingChatHistory.delete(id);
918
+ reject(new Error("Chat history request timed out"));
919
+ }, 30_000);
920
+
921
+ this.pendingChatHistory.add(id);
922
+ this.pending.set(id, {
923
+ resolve: resolve as (r: AcpTaskResponse["payload"]) => void,
924
+ reject,
925
+ timer,
926
+ targetNodeId,
927
+ accumulated: "",
928
+ });
929
+
930
+ const frame: ChatHistoryRequest = {
931
+ type: "chat_history_req",
932
+ id,
933
+ from: this.config.nodeId,
934
+ to: targetNodeId,
935
+ timestamp: Date.now(),
936
+ payload: { sessionId, limit },
937
+ };
938
+
939
+ if (!this.peerManager.sendTo(targetNodeId, frame)) {
940
+ this.pending.delete(id);
941
+ this.pendingChatHistory.delete(id);
942
+ clearTimeout(timer);
943
+ reject(new Error(`Cannot reach node "${targetNodeId}"`));
944
+ }
945
+ });
946
+ }
947
+
948
+ /** Find the NDJSON transcript file for a given session ID. */
949
+ private async findTranscriptPath(sessionId: string): Promise<string | null> {
950
+ // 1. Search OpenClaw agent session stores
951
+ const stateDir = process.env.OPENCLAW_STATE_DIR ?? join(homedir(), ".openclaw");
952
+ const agentsDir = join(stateDir, "agents");
953
+
954
+ try {
955
+ const agentIds = await readdir(agentsDir);
956
+ for (const agentId of agentIds) {
957
+ // Check sessions.json for sessionFile override
958
+ const storePath = join(agentsDir, agentId, "sessions", "sessions.json");
959
+ try {
960
+ const storeContent = await readFileText(storePath);
961
+ const entries: Record<string, { sessionId?: string; sessionFile?: string }> = JSON.parse(storeContent);
962
+ for (const entry of Object.values(entries)) {
963
+ if (entry.sessionId === sessionId) {
964
+ const sessionsDir = join(agentsDir, agentId, "sessions");
965
+ if (entry.sessionFile) {
966
+ const candidate = join(sessionsDir, entry.sessionFile);
967
+ try { await readFileText(candidate); return candidate; } catch { /* try default */ }
968
+ }
969
+ const candidate = join(sessionsDir, `${sessionId}.jsonl`);
970
+ try { await readFileText(candidate); return candidate; } catch { /* continue */ }
971
+ }
972
+ }
973
+ } catch { /* store unreadable */ }
974
+
975
+ // Fallback: try direct file path
976
+ const candidate = join(agentsDir, agentId, "sessions", `${sessionId}.jsonl`);
977
+ try { await readFileText(candidate); return candidate; } catch { /* next agent */ }
978
+ }
979
+ } catch { /* no agents directory */ }
980
+
981
+ // 2. Search Claude Code project directories (~/.claude/projects/*/{sessionId}.jsonl)
982
+ const claudeProjectsDir = join(homedir(), ".claude", "projects");
983
+ try {
984
+ const projectDirs = await readdir(claudeProjectsDir);
985
+ for (const projectDir of projectDirs) {
986
+ const candidate = join(claudeProjectsDir, projectDir, `${sessionId}.jsonl`);
987
+ try { await access(candidate); return candidate; } catch { /* next project */ }
988
+ }
989
+ } catch { /* no claude projects directory */ }
990
+
991
+ return null;
992
+ }
993
+
994
+ // ── Internal: ACP session management (receiver) ────────────────
995
+
996
+ private async createSession(agent: string, cwd: string | undefined, from: string): Promise<AcpSession> {
997
+ const cmd = this.resolveCommand(agent);
998
+ const effectiveCwd = cwd ?? process.cwd();
999
+
1000
+ debug("acp", `createSession: spawning ${cmd.join(" ")} in ${effectiveCwd} (for ${from})`);
1001
+
1002
+ const proc = spawnProcess(cmd, {
1003
+ cwd: effectiveCwd,
1004
+ stdout: "pipe",
1005
+ stderr: "pipe",
1006
+ stdin: "pipe",
1007
+ });
1008
+
1009
+ if (!proc.stdin || !proc.stdout) {
1010
+ proc.kill();
1011
+ throw new Error(`Failed to spawn ACP agent "${agent}": no stdio streams`);
1012
+ }
1013
+
1014
+ // Capture stderr for diagnostics (agent errors, missing API keys, etc.)
1015
+ if (proc.stderr) {
1016
+ const reader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
1017
+ const decoder = new TextDecoder();
1018
+ (async () => {
1019
+ try {
1020
+ while (true) {
1021
+ const { done, value } = await reader.read();
1022
+ if (done) break;
1023
+ const text = decoder.decode(value, { stream: true }).trim();
1024
+ if (text) debug("acp", `[${agent}:stderr] ${text}`);
1025
+ }
1026
+ } catch { /* stream closed */ }
1027
+ })();
1028
+ }
1029
+
1030
+ // Detect early process exit (e.g., npx not found, package install failure)
1031
+ let earlyExit = false;
1032
+ const earlyExitPromise = proc.exited.then((code) => {
1033
+ earlyExit = true;
1034
+ throw new Error(`ACP agent "${agent}" exited during init with code ${code}`);
1035
+ });
1036
+
1037
+ // Build ACP connection over NDJSON/stdio
1038
+ const stream = ndJsonStream(proc.stdin, proc.stdout);
1039
+
1040
+ // Accumulated text for streaming back — set per-prompt via closure
1041
+ let streamCallback: ((delta: string, event?: string) => void) | null = null;
1042
+
1043
+ const conn = new ClientSideConnection(
1044
+ (_agentRef) => ({
1045
+ requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
1046
+ return this.resolvePermission(params);
1047
+ },
1048
+ sessionUpdate: async (params: SessionNotification): Promise<void> => {
1049
+ const update = params.update as Record<string, unknown>;
1050
+ const updateType = update.sessionUpdate as string;
1051
+
1052
+ // Extract text delta from content chunks
1053
+ if (updateType === "agent_message_chunk" || updateType === "agent_thought_chunk") {
1054
+ const content = (update as { content?: { type?: string; text?: string } }).content;
1055
+ if (content?.type === "text" && content.text) {
1056
+ streamCallback?.(content.text, updateType);
1057
+ }
1058
+ } else if (updateType === "tool_call") {
1059
+ const toolName = (update as { name?: string }).name ?? "unknown";
1060
+ streamCallback?.(`\n[tool_call: ${toolName}]\n`, updateType);
1061
+ }
1062
+ },
1063
+ }),
1064
+ stream,
1065
+ );
1066
+
1067
+ // Initialize with timeout — prevents hanging if agent process is stuck
1068
+ const initTimeout = new Promise<never>((_, reject) => {
1069
+ setTimeout(() => reject(new Error(
1070
+ `ACP agent "${agent}" initialization timed out after ${SESSION_INIT_TIMEOUT / 1000}s`,
1071
+ )), SESSION_INIT_TIMEOUT);
1072
+ });
1073
+
1074
+ const initAndSession = async () => {
1075
+ // Initialize the ACP connection
1076
+ debug("acp", `[${agent}] initializing ACP connection...`);
1077
+ await conn.initialize({
1078
+ protocolVersion: 1,
1079
+ clientInfo: { name: "ClawMatrix", version: "0.1.0" },
1080
+ clientCapabilities: {},
1081
+ });
1082
+ debug("acp", `[${agent}] ACP connection initialized, creating session...`);
1083
+
1084
+ // Create a new session
1085
+ return conn.newSession({
1086
+ cwd: effectiveCwd,
1087
+ mcpServers: [],
1088
+ });
1089
+ };
1090
+
1091
+ let sessionResponse: Awaited<ReturnType<typeof conn.newSession>>;
1092
+ try {
1093
+ sessionResponse = await Promise.race([
1094
+ initAndSession(),
1095
+ initTimeout,
1096
+ earlyExitPromise as Promise<never>,
1097
+ ]);
1098
+ } catch (err) {
1099
+ const msg = err instanceof Error ? err.message : (typeof err === "string" ? err : JSON.stringify(err));
1100
+ debug("acp", `[${agent}] init failed: ${msg}`);
1101
+ // Kill the process if init failed
1102
+ if (!earlyExit) proc.kill();
1103
+ throw new Error(`ACP agent "${agent}" init failed: ${msg}`);
1104
+ }
1105
+ debug("acp", `[${agent}] session created: ${sessionResponse.sessionId}`);
1106
+
1107
+ // Extract available modes from session response
1108
+ const availableModes: AcpModeInfo[] | undefined = sessionResponse.modes?.availableModes?.map(
1109
+ (m) => ({ id: m.id, name: m.name, description: m.description ?? undefined }),
1110
+ );
1111
+ const currentModeId = sessionResponse.modes?.currentModeId;
1112
+
1113
+ const sessionId = crypto.randomUUID();
1114
+
1115
+ const session: AcpSession = {
1116
+ kind: "acp",
1117
+ sessionId,
1118
+ agent,
1119
+ acpSessionId: sessionResponse.sessionId,
1120
+ conn,
1121
+ proc,
1122
+ lastActiveAt: Date.now(),
1123
+ from,
1124
+ setStreamCallback: (cb) => { streamCallback = cb; },
1125
+ availableModes,
1126
+ currentModeId,
1127
+ };
1128
+
1129
+ // Monitor process exit — clean up dead sessions proactively
1130
+ this.monitorProcess(session);
1131
+
1132
+ return session;
1133
+ }
1134
+
1135
+ /** Watch for unexpected process exit and clean up the session. */
1136
+ private monitorProcess(session: AcpSession) {
1137
+ session.proc.exited.then(() => {
1138
+ // Only clean up if the session is still tracked (persistent)
1139
+ if (this.sessions.has(session.sessionId)) {
1140
+ this.sessions.delete(session.sessionId);
1141
+ }
1142
+ }).catch(() => {
1143
+ // Process already dead or error — same cleanup
1144
+ if (this.sessions.has(session.sessionId)) {
1145
+ this.sessions.delete(session.sessionId);
1146
+ }
1147
+ });
1148
+ }
1149
+
1150
+ private async runPrompt(requestId: string, from: string, session: AcpSession, task: string, images?: import("./types.ts").ImageContent[]): Promise<void> {
1151
+ debug("acp", `runPrompt: reqId=${requestId} session=${session.sessionId} agent=${session.agent} task=${task.slice(0, 80)}...`);
1152
+ const promptStartedAt = Date.now();
1153
+
1154
+ // Broadcast task started to mobile nodes
1155
+ this.taskActivity.broadcast(requestId, "acp", "started", session.agent, promptStartedAt, task.slice(0, 100));
1156
+
1157
+ // Wire streaming to send acp_stream frames
1158
+ session.setStreamCallback((delta, event) => {
1159
+ this.peerManager.sendTo(from, {
1160
+ type: "acp_stream",
1161
+ id: requestId,
1162
+ from: this.config.nodeId,
1163
+ to: from,
1164
+ timestamp: Date.now(),
1165
+ payload: { delta, event, done: false },
1166
+ } satisfies AcpStreamChunk);
1167
+
1168
+ // Broadcast progress to mobile nodes (only on meaningful events, not every text chunk)
1169
+ if (event === "tool_call") {
1170
+ const toolMatch = delta.match(/\[tool_call:\s*([^\]]+)\]/);
1171
+ const toolName = toolMatch?.[1]?.trim();
1172
+ this.taskActivity.broadcast(
1173
+ requestId, "acp", "progress", session.agent, promptStartedAt,
1174
+ toolName ? `正在执行 ${toolName}` : undefined, toolName, false,
1175
+ );
1176
+ } else if (event === "tool_result") {
1177
+ this.taskActivity.broadcast(
1178
+ requestId, "acp", "progress", session.agent, promptStartedAt,
1179
+ undefined, undefined, true,
1180
+ );
1181
+ }
1182
+ // Skip broadcasting for plain text chunks — they're token-level fragments
1183
+ // and would show meaningless partial sentences in the Live Activity
1184
+ });
1185
+
1186
+ try {
1187
+ const promptParts: Array<{ type: string; text?: string; data?: string; media_type?: string }> = [
1188
+ { type: "text", text: task },
1189
+ ];
1190
+ if (images && images.length > 0) {
1191
+ for (const img of images) {
1192
+ promptParts.push({ type: "image", data: img.data, media_type: img.mediaType });
1193
+ }
1194
+ }
1195
+ const promptResponse = await session.conn.prompt({
1196
+ sessionId: session.acpSessionId,
1197
+ prompt: promptParts,
1198
+ });
1199
+
1200
+ // Send done marker
1201
+ this.peerManager.sendTo(from, {
1202
+ type: "acp_stream",
1203
+ id: requestId,
1204
+ from: this.config.nodeId,
1205
+ to: from,
1206
+ timestamp: Date.now(),
1207
+ payload: { delta: "", done: true },
1208
+ } satisfies AcpStreamChunk);
1209
+
1210
+ this.sendResponse(requestId, from, {
1211
+ success: true,
1212
+ nodeId: this.config.nodeId,
1213
+ agent: session.agent,
1214
+ sessionId: this.sessions.has(session.sessionId) ? session.sessionId : undefined,
1215
+ acpSessionId: session.acpSessionId,
1216
+ stopReason: promptResponse.stopReason,
1217
+ });
1218
+
1219
+ // Broadcast task completed
1220
+ this.taskActivity.broadcast(requestId, "acp", "completed", session.agent, promptStartedAt);
1221
+ } catch (err) {
1222
+ // Broadcast task failed
1223
+ this.taskActivity.broadcast(
1224
+ requestId, "acp", "failed", session.agent, promptStartedAt,
1225
+ err instanceof Error ? err.message : String(err),
1226
+ );
1227
+ throw err;
1228
+ } finally {
1229
+ session.setStreamCallback(null);
1230
+ }
1231
+ }
1232
+
1233
+ /** Run a prompt on a native OpenClaw session via gateway HTTP API. */
1234
+ private async runNativePrompt(requestId: string, from: string, session: NativeSession, task: string): Promise<void> {
1235
+ if (!this.gatewayInfo) throw new Error("Gateway info not available for native session");
1236
+
1237
+ debug("acp", `runNativePrompt: reqId=${requestId} session=${session.sessionId} key=${session.sessionKey} task=${task.slice(0, 80)}...`);
1238
+ const promptStartedAt = Date.now();
1239
+ this.taskActivity.broadcast(requestId, "acp", "started", session.agent, promptStartedAt, task.slice(0, 100));
1240
+
1241
+ const { port, authHeader } = this.gatewayInfo;
1242
+ const abortController = new AbortController();
1243
+ session.abortController = abortController;
1244
+
1245
+ // Extract agentId from session key (e.g. "agent:main:clawmatrix-handoff:xxx" → "main")
1246
+ const keyParts = session.sessionKey.split(":");
1247
+ const agentId = keyParts.length >= 2 ? keyParts[1] : "main";
1248
+
1249
+ try {
1250
+ const headers: Record<string, string> = {
1251
+ "Content-Type": "application/json",
1252
+ "X-OpenClaw-Agent-Id": agentId,
1253
+ "X-OpenClaw-Session-Key": session.sessionKey,
1254
+ "X-OpenClaw-Message-Channel": "clawmatrix",
1255
+ };
1256
+ if (authHeader) headers["Authorization"] = authHeader;
1257
+
1258
+ const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
1259
+ method: "POST",
1260
+ headers,
1261
+ body: JSON.stringify({
1262
+ model: "openclaw",
1263
+ stream: true,
1264
+ messages: [{ role: "user", content: task }],
1265
+ }),
1266
+ signal: abortController.signal,
1267
+ });
1268
+
1269
+ if (!res.ok) {
1270
+ const text = await res.text();
1271
+ throw new Error(`Gateway returned ${res.status}: ${text}`);
1272
+ }
1273
+
1274
+ // Stream SSE response as acp_stream frames
1275
+ const result = await this.streamGatewaySSE(res, requestId, from);
1276
+
1277
+ // Send done marker
1278
+ this.peerManager.sendTo(from, {
1279
+ type: "acp_stream",
1280
+ id: requestId,
1281
+ from: this.config.nodeId,
1282
+ to: from,
1283
+ timestamp: Date.now(),
1284
+ payload: { delta: "", done: true },
1285
+ } satisfies AcpStreamChunk);
1286
+
1287
+ this.sendResponse(requestId, from, {
1288
+ success: true,
1289
+ nodeId: this.config.nodeId,
1290
+ agent: session.agent,
1291
+ sessionId: session.sessionId,
1292
+ result,
1293
+ });
1294
+ this.taskActivity.broadcast(requestId, "acp", "completed", session.agent, promptStartedAt);
1295
+ } catch (err) {
1296
+ this.taskActivity.broadcast(
1297
+ requestId, "acp", "failed", session.agent, promptStartedAt,
1298
+ err instanceof Error ? err.message : String(err),
1299
+ );
1300
+ throw err;
1301
+ } finally {
1302
+ session.abortController = null;
1303
+ }
1304
+ }
1305
+
1306
+ /** Parse SSE streaming response from gateway and relay as acp_stream chunks. */
1307
+ private async streamGatewaySSE(res: Response, requestId: string, to: string): Promise<string> {
1308
+ const body = res.body;
1309
+ if (!body) return "";
1310
+
1311
+ const reader = body.getReader();
1312
+ const decoder = new TextDecoder();
1313
+ let full = "";
1314
+ let buffer = "";
1315
+
1316
+ try {
1317
+ while (true) {
1318
+ const { done, value } = await reader.read();
1319
+ if (done) break;
1320
+
1321
+ buffer += decoder.decode(value, { stream: true });
1322
+ const lines = buffer.split("\n");
1323
+ buffer = lines.pop()!;
1324
+
1325
+ for (const line of lines) {
1326
+ if (!line.startsWith("data: ")) continue;
1327
+ const data = line.slice(6);
1328
+ if (data === "[DONE]") continue;
1329
+
1330
+ try {
1331
+ const parsed = JSON.parse(data);
1332
+ const delta = parsed.choices?.[0]?.delta?.content;
1333
+ if (delta) {
1334
+ full += delta;
1335
+ this.peerManager.sendTo(to, {
1336
+ type: "acp_stream",
1337
+ id: requestId,
1338
+ from: this.config.nodeId,
1339
+ to,
1340
+ timestamp: Date.now(),
1341
+ payload: { delta, done: false },
1342
+ } satisfies AcpStreamChunk);
1343
+ }
1344
+ } catch {
1345
+ // skip malformed SSE lines
1346
+ }
1347
+ }
1348
+ }
1349
+ } finally {
1350
+ reader.releaseLock();
1351
+ }
1352
+
1353
+ return full;
1354
+ }
1355
+
1356
+ /** Create a session by resuming an existing ACP session ID. */
1357
+ private async createSessionWithResume(
1358
+ agent: string,
1359
+ acpSessionId: string,
1360
+ cwd: string,
1361
+ from: string,
1362
+ ): Promise<AcpSession> {
1363
+ const cmd = this.resolveCommand(agent);
1364
+ debug("acp", `createSessionWithResume: spawning ${cmd.join(" ")} in ${cwd} (resume ${acpSessionId.slice(0, 8)}...)`);
1365
+
1366
+ const proc = spawnProcess(cmd, {
1367
+ cwd,
1368
+ stdout: "pipe",
1369
+ stderr: "pipe",
1370
+ stdin: "pipe",
1371
+ });
1372
+
1373
+ if (!proc.stdin || !proc.stdout) {
1374
+ proc.kill();
1375
+ throw new Error(`Failed to spawn ACP agent "${agent}": no stdio streams`);
1376
+ }
1377
+
1378
+ // Capture stderr for diagnostics
1379
+ if (proc.stderr) {
1380
+ const reader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
1381
+ const decoder = new TextDecoder();
1382
+ (async () => {
1383
+ try {
1384
+ while (true) {
1385
+ const { done, value } = await reader.read();
1386
+ if (done) break;
1387
+ const text = decoder.decode(value, { stream: true }).trim();
1388
+ if (text) debug("acp", `[${agent}:stderr] ${text}`);
1389
+ }
1390
+ } catch { /* stream closed */ }
1391
+ })();
1392
+ }
1393
+
1394
+ let earlyExit = false;
1395
+ const earlyExitPromise = proc.exited.then((code) => {
1396
+ earlyExit = true;
1397
+ throw new Error(`ACP agent "${agent}" exited during resume init with code ${code}`);
1398
+ });
1399
+
1400
+ const stream = ndJsonStream(proc.stdin, proc.stdout);
1401
+ let streamCallback: ((delta: string, event?: string) => void) | null = null;
1402
+
1403
+ const conn = new ClientSideConnection(
1404
+ (_agentRef) => ({
1405
+ requestPermission: async (params: RequestPermissionRequest): Promise<RequestPermissionResponse> => {
1406
+ return this.resolvePermission(params);
1407
+ },
1408
+ sessionUpdate: async (params: SessionNotification): Promise<void> => {
1409
+ const update = params.update as Record<string, unknown>;
1410
+ const updateType = update.sessionUpdate as string;
1411
+ if (updateType === "agent_message_chunk" || updateType === "agent_thought_chunk") {
1412
+ const content = (update as { content?: { type?: string; text?: string } }).content;
1413
+ if (content?.type === "text" && content.text) {
1414
+ streamCallback?.(content.text, updateType);
1415
+ }
1416
+ } else if (updateType === "tool_call") {
1417
+ const toolName = (update as { name?: string }).name ?? "unknown";
1418
+ streamCallback?.(`\n[tool_call: ${toolName}]\n`, updateType);
1419
+ }
1420
+ },
1421
+ }),
1422
+ stream,
1423
+ );
1424
+
1425
+ const initTimeout = new Promise<never>((_, reject) => {
1426
+ setTimeout(() => reject(new Error(
1427
+ `ACP agent "${agent}" resume initialization timed out after ${SESSION_INIT_TIMEOUT / 1000}s`,
1428
+ )), SESSION_INIT_TIMEOUT);
1429
+ });
1430
+
1431
+ const initAndResume = async () => {
1432
+ await conn.initialize({
1433
+ protocolVersion: 1,
1434
+ clientInfo: { name: "ClawMatrix", version: "0.1.0" },
1435
+ clientCapabilities: {},
1436
+ });
1437
+
1438
+ // Resume the existing ACP session instead of creating a new one
1439
+ return conn.unstable_resumeSession({
1440
+ sessionId: acpSessionId,
1441
+ cwd,
1442
+ });
1443
+ };
1444
+
1445
+ let resumeResponse: Awaited<ReturnType<typeof conn.unstable_resumeSession>>;
1446
+ try {
1447
+ resumeResponse = await Promise.race([
1448
+ initAndResume(),
1449
+ initTimeout,
1450
+ earlyExitPromise as Promise<never>,
1451
+ ]);
1452
+ } catch (err) {
1453
+ if (!earlyExit) proc.kill();
1454
+ throw err;
1455
+ }
1456
+
1457
+ const availableModes: AcpModeInfo[] | undefined = resumeResponse.modes?.availableModes?.map(
1458
+ (m) => ({ id: m.id, name: m.name, description: m.description ?? undefined }),
1459
+ );
1460
+ const currentModeId = resumeResponse.modes?.currentModeId;
1461
+
1462
+ const sessionId = crypto.randomUUID();
1463
+
1464
+ const session: AcpSession = {
1465
+ kind: "acp",
1466
+ sessionId,
1467
+ agent,
1468
+ acpSessionId,
1469
+ conn,
1470
+ proc,
1471
+ lastActiveAt: Date.now(),
1472
+ from,
1473
+ setStreamCallback: (cb) => { streamCallback = cb; },
1474
+ availableModes,
1475
+ currentModeId,
1476
+ };
1477
+
1478
+ this.monitorProcess(session);
1479
+ return session;
1480
+ }
1481
+
1482
+ /** Read all session stores from disk (OpenClaw + Claude Code). */
1483
+ private readAllSessionStoresFromDisk(): Promise<AcpSessionInfo[]> {
1484
+ return readAllSessionStoresFromDisk();
1485
+ }
1486
+
1487
+ private destroySession(session: AnySession) {
1488
+ if (session.kind === "acp") {
1489
+ try {
1490
+ session.proc.kill();
1491
+ } catch {
1492
+ // best effort
1493
+ }
1494
+ } else {
1495
+ // Native session: abort any in-flight request
1496
+ session.abortController?.abort();
1497
+ }
1498
+ }
1499
+
1500
+ /** Resolve a permission request based on configured permissionMode. */
1501
+ private resolvePermission(params: RequestPermissionRequest): RequestPermissionResponse {
1502
+ if (this.permissionMode === "deny-all") {
1503
+ // Reject everything
1504
+ const rejectOption = params.options.find((o) => o.kind.startsWith("reject")) ?? params.options[0];
1505
+ return { outcome: { outcome: rejectOption ? "selected" : "cancelled", optionId: rejectOption?.optionId } } as RequestPermissionResponse;
1506
+ }
1507
+ if (this.permissionMode === "approve-all") {
1508
+ // Approve everything
1509
+ const allowOption = params.options.find((o) => !o.kind.startsWith("reject")) ?? params.options[0];
1510
+ if (allowOption) {
1511
+ return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
1512
+ }
1513
+ return { outcome: { outcome: "cancelled" } };
1514
+ }
1515
+ // approve-reads: approve read-like operations, reject writes
1516
+ const isReadLike = params.options.some((o) =>
1517
+ o.kind === "allow" && /read|search|list|view|get/i.test(o.title ?? o.description ?? ""),
1518
+ );
1519
+ if (isReadLike) {
1520
+ const allowOption = params.options.find((o) => !o.kind.startsWith("reject"));
1521
+ if (allowOption) {
1522
+ return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
1523
+ }
1524
+ }
1525
+ // Default: reject
1526
+ const rejectOption = params.options.find((o) => o.kind.startsWith("reject")) ?? params.options[0];
1527
+ return { outcome: { outcome: rejectOption ? "selected" : "cancelled", optionId: rejectOption?.optionId } } as RequestPermissionResponse;
1528
+ }
1529
+
1530
+ /** Built-in ACP agent commands (same as OpenClaw's acpx backend). */
1531
+ private static readonly BUILTIN_ACP_COMMANDS: Record<string, string[]> = {
1532
+ claude: ["npx", "-y", "@zed-industries/claude-agent-acp"],
1533
+ codex: ["npx", "-y", "@zed-industries/codex-acp"],
1534
+ gemini: ["gemini"],
1535
+ opencode: ["npx", "-y", "opencode-ai", "acp"],
1536
+ pi: ["npx", "-y", "pi-acp"],
1537
+ };
1538
+
1539
+ private resolveCommand(agent: string): string[] {
1540
+ // Check config overrides first
1541
+ const commands = this.config.acp?.commands;
1542
+ if (commands && commands[agent]) {
1543
+ return commands[agent]!;
1544
+ }
1545
+ // Use built-in ACP agent commands (same as acpx)
1546
+ const builtin = AcpProxy.BUILTIN_ACP_COMMANDS[agent.toLowerCase()];
1547
+ if (builtin) return builtin;
1548
+ // Fallback: use agent name as the binary
1549
+ return [agent];
1550
+ }
1551
+
1552
+ /** Check if an agent name corresponds to a known ACP agent (has a spawnable command). */
1553
+ private isAcpAgent(agent: string): boolean {
1554
+ const commands = this.config.acp?.commands;
1555
+ if (commands && commands[agent]) return true;
1556
+ return agent.toLowerCase() in AcpProxy.BUILTIN_ACP_COMMANDS;
1557
+ }
1558
+
1559
+ // ── Helpers ────────────────────────────────────────────────────
1560
+
1561
+ private resolveNode(target: string): string | null {
1562
+ // Try direct nodeId match
1563
+ const peers = this.peerManager.router.getAllPeers();
1564
+ const directMatch = peers.find((p) => p.nodeId === target);
1565
+ if (directMatch) return directMatch.nodeId;
1566
+
1567
+ // Try tag-based resolution
1568
+ if (target.startsWith("tags:")) {
1569
+ const tag = target.slice(5);
1570
+ const tagMatch = peers.find((p) => p.tags.includes(tag));
1571
+ if (tagMatch) return tagMatch.nodeId;
1572
+ }
1573
+
1574
+ // Try agent resolution as fallback
1575
+ const route = this.peerManager.router.resolveAgent(target);
1576
+ if (route) return route.nodeId;
1577
+
1578
+ return null;
1579
+ }
1580
+
1581
+ private sendRequest(
1582
+ targetNodeId: string,
1583
+ agent: string,
1584
+ task: string,
1585
+ options?: {
1586
+ sessionId?: string;
1587
+ mode?: AcpSessionMode;
1588
+ cwd?: string;
1589
+ onStream?: (delta: string) => void;
1590
+ },
1591
+ ): Promise<AcpTaskResponse["payload"]> {
1592
+ const id = crypto.randomUUID();
1593
+ return new Promise((resolve, reject) => {
1594
+ const timer = setTimeout(() => {
1595
+ this.pending.delete(id);
1596
+ reject(new Error(`ACP request to "${targetNodeId}" timed out`));
1597
+ }, this.timeout);
1598
+
1599
+ this.pending.set(id, {
1600
+ resolve, reject, timer,
1601
+ targetNodeId,
1602
+ accumulated: "",
1603
+ onStream: options?.onStream,
1604
+ });
1605
+
1606
+ const frame: AcpTaskRequest = {
1607
+ type: "acp_req",
1608
+ id,
1609
+ from: this.config.nodeId,
1610
+ to: targetNodeId,
1611
+ timestamp: Date.now(),
1612
+ payload: {
1613
+ agent,
1614
+ task,
1615
+ sessionId: options?.sessionId,
1616
+ mode: options?.mode ?? "oneshot",
1617
+ cwd: options?.cwd,
1618
+ },
1619
+ };
1620
+
1621
+ if (!this.peerManager.sendTo(targetNodeId, frame)) {
1622
+ this.pending.delete(id);
1623
+ clearTimeout(timer);
1624
+ reject(new Error(`Cannot reach node "${targetNodeId}"`));
1625
+ }
1626
+ });
1627
+ }
1628
+
1629
+ private sendResponse(id: string, to: string, payload: AcpTaskResponse["payload"]) {
1630
+ debug("acp", `sendResponse: id=${id} to=${to} success=${payload.success} error=${payload.error ?? "(none)"}`);
1631
+ const sent = this.peerManager.sendTo(to, {
1632
+ type: "acp_res",
1633
+ id,
1634
+ from: this.config.nodeId,
1635
+ to,
1636
+ timestamp: Date.now(),
1637
+ payload,
1638
+ } satisfies AcpTaskResponse);
1639
+ if (!sent) {
1640
+ console.error(`[clawmatrix:acp] Failed to send acp_res to ${to} (no route)`);
1641
+ }
1642
+ }
1643
+
1644
+ private cleanupIdleSessions() {
1645
+ const now = Date.now();
1646
+ for (const [id, session] of this.sessions) {
1647
+ if (now - session.lastActiveAt > this.sessionTTL) {
1648
+ this.sessions.delete(id);
1649
+ this.destroySession(session);
1650
+ }
1651
+ }
1652
+ }
1653
+
1654
+ destroy() {
1655
+ if (this.cleanupTimer) {
1656
+ clearInterval(this.cleanupTimer);
1657
+ this.cleanupTimer = null;
1658
+ }
1659
+
1660
+ for (const [, pending] of this.pending) {
1661
+ clearTimeout(pending.timer);
1662
+ pending.reject(new Error("Shutting down"));
1663
+ }
1664
+ this.pending.clear();
1665
+ this.pendingCloses.clear();
1666
+ this.pendingLists.clear();
1667
+ this.pendingResumes.clear();
1668
+ this.pendingChatHistory.clear();
1669
+
1670
+ for (const [, session] of this.sessions) {
1671
+ this.destroySession(session);
1672
+ }
1673
+ this.sessions.clear();
1674
+ }
1675
+ }
1676
+
1677
+ // ── Standalone session discovery (no AcpProxy needed) ─────────────
1678
+
1679
+ const TRANSCRIPT_HEAD_BYTES = 8192;
1680
+ const TRANSCRIPT_MAX_LINES = 20;
1681
+
1682
+ /** Read the first user message from a session transcript JSONL file. */
1683
+ async function readFirstUserMessageFromTranscript(transcriptPath: string): Promise<string | null> {
1684
+ try {
1685
+ const content = await readFileText(transcriptPath);
1686
+ // Only scan the head to avoid reading large transcripts
1687
+ const head = content.slice(0, TRANSCRIPT_HEAD_BYTES);
1688
+ const lines = head.split("\n").slice(0, TRANSCRIPT_MAX_LINES);
1689
+ for (const line of lines) {
1690
+ if (!line.trim()) continue;
1691
+ try {
1692
+ const parsed = JSON.parse(line);
1693
+ const msg = parsed?.message;
1694
+ if (msg?.role !== "user") continue;
1695
+ // Extract text from content (string or array of content blocks)
1696
+ if (typeof msg.content === "string") return msg.content.slice(0, 120);
1697
+ if (Array.isArray(msg.content)) {
1698
+ for (const block of msg.content) {
1699
+ if (typeof block === "string") return block.slice(0, 120);
1700
+ if (block?.type === "text" && typeof block.text === "string") return block.text.slice(0, 120);
1701
+ }
1702
+ }
1703
+ } catch {
1704
+ // skip malformed lines
1705
+ }
1706
+ }
1707
+ } catch {
1708
+ // transcript missing or unreadable
1709
+ }
1710
+ return null;
1711
+ }
1712
+
1713
+ /** Read all ACP session stores from disk (OpenClaw + Claude Code). Can be used without an AcpProxy instance. */
1714
+ export async function readAllSessionStoresFromDisk(): Promise<AcpSessionInfo[]> {
1715
+ const results: AcpSessionInfo[] = [];
1716
+
1717
+ // 1. OpenClaw agent session stores (ACP-backed + native OpenClaw sessions)
1718
+ const stateDir = process.env.OPENCLAW_STATE_DIR ?? join(homedir(), ".openclaw");
1719
+ const agentsDir = join(stateDir, "agents");
1720
+ try {
1721
+ const agentIds = await readdir(agentsDir);
1722
+ for (const agentId of agentIds) {
1723
+ const sessionsDir = join(agentsDir, agentId, "sessions");
1724
+ const storePath = join(sessionsDir, "sessions.json");
1725
+ try {
1726
+ const content = await readFileText(storePath);
1727
+ const entries: Record<string, { sessionId?: string; updatedAt?: number; displayName?: string; subject?: string; label?: string; acp?: { agent?: string } }> = JSON.parse(content);
1728
+ for (const [key, entry] of Object.entries(entries)) {
1729
+ if (!entry.sessionId) continue;
1730
+ // Use the OpenClaw agent directory name, not the ACP backend agent.
1731
+ // A cron/handoff session using Claude Code as backend is still an OpenClaw session.
1732
+ // Pure Claude Code sessions are read separately from ~/.claude/history.jsonl.
1733
+ const agent = agentId;
1734
+ // Try to read the first user message from the transcript for description
1735
+ const transcriptPath = join(sessionsDir, `${entry.sessionId}.jsonl`);
1736
+ const firstMessage = await readFirstUserMessageFromTranscript(transcriptPath);
1737
+ results.push({
1738
+ sessionId: entry.sessionId,
1739
+ cwd: key,
1740
+ title: entry.displayName ?? entry.subject ?? entry.label ?? undefined,
1741
+ description: firstMessage ?? undefined,
1742
+ updatedAt: entry.updatedAt ? new Date(entry.updatedAt).toISOString() : undefined,
1743
+ agent,
1744
+ });
1745
+ }
1746
+ } catch {
1747
+ // Store file missing or unreadable — skip
1748
+ }
1749
+ }
1750
+ } catch {
1751
+ // No agents directory — skip
1752
+ }
1753
+
1754
+ // 2. Claude Code session history (~/.claude/history.jsonl)
1755
+ try {
1756
+ const historyPath = join(homedir(), ".claude", "history.jsonl");
1757
+ const content = await readFileText(historyPath);
1758
+ const sessions = new Map<string, { title: string; updatedAt: number; cwd: string }>();
1759
+
1760
+ for (const line of content.split("\n")) {
1761
+ if (!line.trim()) continue;
1762
+ try {
1763
+ const entry = JSON.parse(line) as {
1764
+ sessionId?: string;
1765
+ display?: string;
1766
+ timestamp?: number;
1767
+ project?: string;
1768
+ };
1769
+ if (!entry.sessionId) continue;
1770
+ const existing = sessions.get(entry.sessionId);
1771
+ if (!existing) {
1772
+ const title = (entry.display ?? "").replace(/\s+/g, " ").trim().slice(0, 120);
1773
+ sessions.set(entry.sessionId, {
1774
+ title,
1775
+ updatedAt: entry.timestamp ?? 0,
1776
+ cwd: entry.project ?? "",
1777
+ });
1778
+ } else if (entry.timestamp && entry.timestamp > existing.updatedAt) {
1779
+ existing.updatedAt = entry.timestamp;
1780
+ }
1781
+ } catch {
1782
+ // Skip malformed lines
1783
+ }
1784
+ }
1785
+
1786
+ const ccResults: AcpSessionInfo[] = [];
1787
+ const existingIds = new Set(results.map((r) => r.sessionId));
1788
+ for (const [sessionId, info] of sessions) {
1789
+ if (!info.title || info.title.startsWith("/")) continue;
1790
+ if (existingIds.has(sessionId)) continue;
1791
+ ccResults.push({
1792
+ sessionId,
1793
+ cwd: info.cwd,
1794
+ title: info.title,
1795
+ updatedAt: info.updatedAt ? new Date(info.updatedAt).toISOString() : undefined,
1796
+ agent: "claude",
1797
+ });
1798
+ }
1799
+ ccResults.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
1800
+ results.push(...ccResults);
1801
+ } catch {
1802
+ // history.jsonl missing or unreadable — skip
1803
+ }
1804
+
1805
+ return results;
1806
+ }
1807
+
1808
+ // ── Transcript normalization ───────────────────────────────────────
1809
+ // Mirrors the OpenClaw web dashboard pipeline:
1810
+ // Server: readSessionMessages → stripEnvelopeFromMessages → sanitizeChatHistoryMessages
1811
+ // Client: normalizeMessage → extractText → extractThinking → extractToolCards
1812
+
1813
+ const MAX_TEXT_CHARS = 12_000;
1814
+
1815
+ function truncateText(text: string): string {
1816
+ return text.length <= MAX_TEXT_CHARS
1817
+ ? text
1818
+ : text.slice(0, MAX_TEXT_CHARS) + "\n...(truncated)...";
1819
+ }
1820
+
1821
+ // ── Text extraction ─────────────────────────────────────────────────
1822
+
1823
+ const TEXT_TYPES = new Set(["text", "output_text", "input_text"]);
1824
+
1825
+ function extractTextFromContent(content: unknown): string {
1826
+ if (typeof content === "string") return content;
1827
+ if (!Array.isArray(content)) return "";
1828
+ return content
1829
+ .filter((item: Record<string, unknown>) => TEXT_TYPES.has(item.type as string) && typeof item.text === "string")
1830
+ .map((item: Record<string, unknown>) => item.text as string)
1831
+ .join("\n");
1832
+ }
1833
+
1834
+ const IMAGE_TYPES = new Set(["image"]);
1835
+
1836
+ function extractImagesFromContent(content: unknown): Array<{ data: string; mediaType: string }> | undefined {
1837
+ if (!Array.isArray(content)) return undefined;
1838
+ const images: Array<{ data: string; mediaType: string }> = [];
1839
+ for (const item of content) {
1840
+ if (!IMAGE_TYPES.has(item?.type)) continue;
1841
+ // ACP format: { type: "image", data: "base64...", media_type: "image/png" }
1842
+ if (typeof item.data === "string" && typeof item.media_type === "string") {
1843
+ images.push({ data: item.data, mediaType: item.media_type });
1844
+ continue;
1845
+ }
1846
+ // Anthropic API format: { type: "image", source: { type: "base64", media_type: "...", data: "..." } }
1847
+ const src = item.source;
1848
+ if (src && typeof src.data === "string" && typeof src.media_type === "string") {
1849
+ images.push({ data: src.data, mediaType: src.media_type });
1850
+ }
1851
+ }
1852
+ return images.length > 0 ? images : undefined;
1853
+ }
1854
+
1855
+ function extractThinkingFromContent(content: unknown): string | undefined {
1856
+ if (!Array.isArray(content)) return undefined;
1857
+ const parts = content
1858
+ .filter((item: Record<string, unknown>) => item.type === "thinking" && typeof item.thinking === "string")
1859
+ .map((item: Record<string, unknown>) => item.thinking as string);
1860
+ return parts.length > 0 ? parts.join("\n") : undefined;
1861
+ }
1862
+
1863
+ // ── Tool extraction ─────────────────────────────────────────────────
1864
+
1865
+ const TOOL_CALL_TYPES = new Set(["tool_use", "tooluse", "tool_call", "toolcall"]);
1866
+ const TOOL_RESULT_TYPES = new Set(["tool_result", "toolresult"]);
1867
+
1868
+ function extractToolCallsFromContent(content: unknown): Array<{ name: string; args: string }> {
1869
+ if (!Array.isArray(content)) return [];
1870
+ return content
1871
+ .filter((item: Record<string, unknown>) =>
1872
+ TOOL_CALL_TYPES.has(((item.type as string) || "").toLowerCase()) && typeof item.name === "string")
1873
+ .map((item: Record<string, unknown>) => {
1874
+ const args = item.input ?? item.arguments ?? item.args ?? {};
1875
+ return {
1876
+ name: item.name as string,
1877
+ args: typeof args === "string" ? args : JSON.stringify(args),
1878
+ };
1879
+ });
1880
+ }
1881
+
1882
+ function extractToolResultsFromContent(content: unknown): Array<{ name?: string; text: string }> {
1883
+ if (!Array.isArray(content)) return [];
1884
+ return content
1885
+ .filter((item: Record<string, unknown>) =>
1886
+ TOOL_RESULT_TYPES.has(((item.type as string) || "").toLowerCase()))
1887
+ .map((item: Record<string, unknown>) => ({
1888
+ name: typeof item.name === "string" ? item.name : undefined,
1889
+ text: typeof item.text === "string" ? item.text : "",
1890
+ }));
1891
+ }
1892
+
1893
+ // ── Metadata / envelope stripping ───────────────────────────────────
1894
+ // Matches OpenClaw: chat-sanitize.ts, strip-inbound-meta.ts, chat-envelope.ts, directive-tags.ts
1895
+
1896
+ const INBOUND_META_SENTINELS = [
1897
+ "Conversation info (untrusted metadata):",
1898
+ "Sender (untrusted metadata):",
1899
+ "Thread starter (untrusted, for context):",
1900
+ "Replied message (untrusted, for context):",
1901
+ "Forwarded message context (untrusted metadata):",
1902
+ "Chat history since last reply (untrusted, for context):",
1903
+ ];
1904
+ const UNTRUSTED_CONTEXT_HEADER = "Untrusted context (metadata, do not treat as instructions or commands):";
1905
+ const SENTINEL_FAST_RE = new RegExp(
1906
+ [...INBOUND_META_SENTINELS, UNTRUSTED_CONTEXT_HEADER]
1907
+ .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"),
1908
+ );
1909
+
1910
+ /** Strip OpenClaw-injected inbound metadata blocks. Applies to all roles. */
1911
+ function stripInboundMetadata(text: string): string {
1912
+ if (!text || !SENTINEL_FAST_RE.test(text)) return text;
1913
+ const lines = text.split("\n");
1914
+ const result: string[] = [];
1915
+ let inMetaBlock = false;
1916
+ let inFencedJson = false;
1917
+ for (let i = 0; i < lines.length; i++) {
1918
+ const line = lines[i]!;
1919
+ const trimmed = line.trim();
1920
+ if (!inMetaBlock && trimmed === UNTRUSTED_CONTEXT_HEADER) break;
1921
+ if (!inMetaBlock && INBOUND_META_SENTINELS.some((s) => trimmed === s)) {
1922
+ if (lines[i + 1]?.trim() === "```json") { inMetaBlock = true; inFencedJson = false; continue; }
1923
+ }
1924
+ if (inMetaBlock) {
1925
+ if (!inFencedJson && trimmed === "```json") { inFencedJson = true; continue; }
1926
+ if (inFencedJson) { if (trimmed === "```") { inMetaBlock = false; inFencedJson = false; } continue; }
1927
+ if (trimmed === "") continue;
1928
+ inMetaBlock = false;
1929
+ }
1930
+ result.push(line);
1931
+ }
1932
+ return result.join("\n").replace(/^\n+/, "").replace(/\n+$/, "");
1933
+ }
1934
+
1935
+ /** Strip channel envelope prefix: [WebChat ...], [Slack ...], timestamps [Sun 2026-03-15 00:52 GMT+8]. */
1936
+ const ENVELOPE_CHANNELS = ["WebChat", "WhatsApp", "Telegram", "Signal", "Slack", "Discord",
1937
+ "Google Chat", "iMessage", "Teams", "Matrix", "Zalo", "BlueBubbles"];
1938
+ const ENVELOPE_PREFIX_RE = /^\[([^\]]+)\]\s*/;
1939
+ function stripEnvelope(text: string): string {
1940
+ const match = text.match(ENVELOPE_PREFIX_RE);
1941
+ if (!match) return text;
1942
+ const header = match[1] ?? "";
1943
+ if (/\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(header) ||
1944
+ ENVELOPE_CHANNELS.some((ch) => header.startsWith(ch + " "))) {
1945
+ return text.slice(match[0].length);
1946
+ }
1947
+ return text;
1948
+ }
1949
+
1950
+ /** Strip [message_id: ...] hint lines. */
1951
+ const MESSAGE_ID_RE = /^\s*\[message_id:\s*[^\]]+\]\s*$/i;
1952
+ function stripMessageIdHints(text: string): string {
1953
+ if (!text.includes("[message_id:")) return text;
1954
+ return text.split("\n").filter((line) => !MESSAGE_ID_RE.test(line)).join("\n");
1955
+ }
1956
+
1957
+ /** Strip inline directive tags: [[audio_as_voice]], [[reply_to:...]], [[reply_to_current]]. */
1958
+ function stripDirectiveTags(text: string): string {
1959
+ return text.replace(/\[\[(?:audio_as_voice|reply_to_current|reply_to:\s*[^\]]*)\]\]/g, "");
1960
+ }
1961
+
1962
+ /** Full user message sanitization (matches OpenClaw's stripEnvelopeFromMessage for user role). */
1963
+ function sanitizeUserText(text: string): string {
1964
+ return stripDirectiveTags(stripEnvelope(stripMessageIdHints(stripInboundMetadata(text)))).trim();
1965
+ }
1966
+
1967
+ /** Non-user message sanitization. */
1968
+ function sanitizeNonUserText(text: string): string {
1969
+ return stripDirectiveTags(stripInboundMetadata(text));
1970
+ }
1971
+
1972
+ /** Strip <think>/<thinking>/<reasoning> and <relevant_memories> tags from assistant text. */
1973
+ function stripThinkingTags(text: string): string {
1974
+ return text
1975
+ .replace(/<\s*(?:think(?:ing)?|reasoning)\s*>[\s\S]*?<\s*\/\s*(?:think(?:ing)?|reasoning)\s*>/gi, "")
1976
+ .replace(/<\s*relevant[_-]memories\s*>[\s\S]*?<\s*\/\s*relevant[_-]memories\s*>/gi, "")
1977
+ .trim();
1978
+ }
1979
+
1980
+ /** Check if text is a silent reply (NO_REPLY). */
1981
+ function isSilentReply(text: string): boolean {
1982
+ return /^\s*NO_REPLY\s*$/.test(text);
1983
+ }
1984
+
1985
+ // ── Main normalization ──────────────────────────────────────────────
1986
+
1987
+ function normalizeTranscriptMessages(lines: string[], limit: number): ChatHistoryMessage[] {
1988
+ const raw: ChatHistoryMessage[] = [];
1989
+
1990
+ for (const line of lines) {
1991
+ try {
1992
+ const parsed = JSON.parse(line);
1993
+ const msg = parsed?.message;
1994
+ if (!msg) continue;
1995
+
1996
+ const role = typeof msg.role === "string" ? msg.role : "";
1997
+ const content = msg.content;
1998
+ const timestamp = typeof msg.timestamp === "number"
1999
+ ? msg.timestamp
2000
+ : (typeof parsed.timestamp === "string" ? new Date(parsed.timestamp).getTime() : undefined);
2001
+
2002
+ // Skip compaction markers
2003
+ if (role === "system" && msg.__openclaw?.kind === "compaction") continue;
2004
+
2005
+ if (role === "user") {
2006
+ const text = sanitizeUserText(extractTextFromContent(content));
2007
+ const images = extractImagesFromContent(content);
2008
+ if (text || images) {
2009
+ raw.push({ role: "user", text: truncateText(text), images, timestamp });
2010
+ }
2011
+ } else if (role === "assistant") {
2012
+ const rawText = extractTextFromContent(content);
2013
+ // Filter silent replies (NO_REPLY)
2014
+ const visibleText = typeof msg.text === "string" ? msg.text : rawText;
2015
+ if (isSilentReply(visibleText)) continue;
2016
+
2017
+ const text = stripThinkingTags(sanitizeNonUserText(rawText));
2018
+ const thinking = extractThinkingFromContent(content);
2019
+ const toolCalls = extractToolCallsFromContent(content);
2020
+
2021
+ if (text || thinking) {
2022
+ raw.push({
2023
+ role: "assistant",
2024
+ text: truncateText(text),
2025
+ thinking: thinking ? truncateText(thinking) : undefined,
2026
+ timestamp,
2027
+ });
2028
+ }
2029
+
2030
+ for (const tc of toolCalls) {
2031
+ raw.push({ role: "tool", text: "", toolName: tc.name, toolArgs: truncateText(tc.args), timestamp });
2032
+ }
2033
+
2034
+ // OpenAI-format tool_calls array
2035
+ if (Array.isArray(msg.tool_calls)) {
2036
+ for (const tc of msg.tool_calls) {
2037
+ const fn = tc.function ?? tc;
2038
+ raw.push({
2039
+ role: "tool", text: "",
2040
+ toolName: typeof fn.name === "string" ? fn.name : "unknown",
2041
+ toolArgs: truncateText(typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments ?? {})),
2042
+ timestamp,
2043
+ });
2044
+ }
2045
+ }
2046
+ } else if (role === "tool" || role.toLowerCase() === "toolresult" || role === "function") {
2047
+ const text = extractTextFromContent(content);
2048
+ const inlineResults = extractToolResultsFromContent(content);
2049
+ const toolName = typeof msg.name === "string" ? msg.name
2050
+ : typeof msg.toolName === "string" ? msg.toolName
2051
+ : typeof msg.tool_name === "string" ? msg.tool_name
2052
+ : inlineResults[0]?.name;
2053
+ const resultText = inlineResults.length > 0 ? inlineResults.map((r) => r.text).join("\n") : text;
2054
+
2055
+ if (raw.length > 0) {
2056
+ const prev = raw[raw.length - 1]!;
2057
+ if (prev.role === "tool" && prev.toolName && !prev.toolResult) {
2058
+ prev.toolResult = truncateText(resultText);
2059
+ continue;
2060
+ }
2061
+ }
2062
+ raw.push({ role: "tool", text: "", toolName, toolResult: truncateText(resultText), timestamp });
2063
+ } else if (role === "system") {
2064
+ const text = sanitizeNonUserText(extractTextFromContent(content));
2065
+ if (text && text !== "Compaction") {
2066
+ raw.push({ role: "system", text: truncateText(text), timestamp });
2067
+ }
2068
+ }
2069
+ } catch { /* skip bad lines */ }
2070
+ }
2071
+
2072
+ return raw.length > limit ? raw.slice(-limit) : raw;
2073
+ }