clawmatrix 0.2.11 → 0.4.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.
- package/LICENSE +27 -0
- package/README.md +123 -12
- package/cli/bin/clawmatrix.mjs +1006 -0
- package/cli/package.json +27 -0
- package/cli/skills/clawmatrix/SKILL.md +104 -0
- package/openclaw.plugin.json +1 -0
- package/package.json +3 -1
- package/src/acp-proxy.ts +820 -96
- package/src/cluster-service.ts +186 -16
- package/src/compat.ts +0 -6
- package/src/config.ts +8 -5
- package/src/connection.ts +61 -55
- package/src/e2e/helpers.ts +1 -5
- package/src/file-transfer.ts +64 -14
- package/src/handoff.ts +21 -8
- package/src/health-tracker.ts +40 -11
- package/src/index.ts +686 -14
- package/src/knowledge-sync.ts +62 -10
- package/src/model-proxy.ts +40 -10
- package/src/peer-manager.ts +114 -17
- package/src/rate-limiter.ts +16 -10
- package/src/router.ts +115 -33
- package/src/sentinel-manager.ts +51 -0
- package/src/sentinel.ts +13 -3
- package/src/tool-proxy.ts +52 -6
- package/src/tools/cluster-diagnostic.ts +3 -2
- package/src/tools/cluster-edit.ts +2 -1
- package/src/tools/cluster-events.ts +3 -1
- package/src/tools/cluster-exec.ts +2 -0
- package/src/tools/cluster-handoff.ts +3 -1
- package/src/tools/cluster-notify.ts +132 -0
- package/src/tools/cluster-peers.ts +3 -1
- package/src/tools/cluster-read.ts +4 -1
- package/src/tools/cluster-send.ts +2 -1
- package/src/tools/cluster-terminal.ts +4 -7
- package/src/tools/cluster-tool.ts +2 -2
- package/src/tools/cluster-write.ts +3 -1
- package/src/types.ts +103 -1
- package/src/web.ts +2 -10
- package/src/cli.ts +0 -243
- package/src/web-ui.ts +0 -1622
package/src/acp-proxy.ts
CHANGED
|
@@ -12,7 +12,8 @@ import { spawnProcess, readFileText, readFileHead, readFileTail } from "./compat
|
|
|
12
12
|
import { debug } from "./debug.ts";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { readdir, access } from "node:fs/promises";
|
|
15
|
+
import { readdir, access, open as fsOpen, stat as fsStat, unlink, writeFile as fsWriteFile } from "node:fs/promises";
|
|
16
|
+
import { watch as fsWatch, type FSWatcher } from "node:fs";
|
|
16
17
|
import type {
|
|
17
18
|
AcpTaskRequest,
|
|
18
19
|
AcpTaskResponse,
|
|
@@ -35,6 +36,13 @@ import type {
|
|
|
35
36
|
ChatHistoryRequest,
|
|
36
37
|
ChatHistoryResponse,
|
|
37
38
|
ChatHistoryMessage,
|
|
39
|
+
AcpSetConfigRequest,
|
|
40
|
+
AcpSetConfigResponse,
|
|
41
|
+
AcpConfigOption,
|
|
42
|
+
AcpSubscribeRequest,
|
|
43
|
+
AcpSubscribeResponse,
|
|
44
|
+
AcpUnsubscribeRequest,
|
|
45
|
+
AcpSessionNotify,
|
|
38
46
|
} from "./types.ts";
|
|
39
47
|
import { TaskActivityBroadcaster } from "./task-activity.ts";
|
|
40
48
|
|
|
@@ -74,7 +82,7 @@ interface AgentDaemon {
|
|
|
74
82
|
conn: ClientSideConnection;
|
|
75
83
|
proc: { kill: () => void; exited: Promise<number> };
|
|
76
84
|
/** Per-session stream callbacks keyed by ACP session ID */
|
|
77
|
-
streamCallbacks: Map<string, ((delta: string, event?: string) => void) | null>;
|
|
85
|
+
streamCallbacks: Map<string, ((delta: string, event?: string, data?: unknown) => void) | null>;
|
|
78
86
|
lastActiveAt: number;
|
|
79
87
|
idleTimer: ReturnType<typeof setTimeout>;
|
|
80
88
|
/** Set to true when process has exited */
|
|
@@ -92,9 +100,12 @@ interface AcpSession {
|
|
|
92
100
|
proc: { kill: () => void; exited: Promise<number> };
|
|
93
101
|
lastActiveAt: number;
|
|
94
102
|
from: string; // owning nodeId
|
|
95
|
-
|
|
103
|
+
prompting: boolean; // true while executing a prompt (busy state)
|
|
104
|
+
setStreamCallback: (cb: ((delta: string, event?: string, data?: unknown) => void) | null) => void;
|
|
96
105
|
availableModes?: AcpModeInfo[];
|
|
97
106
|
currentModeId?: string;
|
|
107
|
+
configOptions?: AcpConfigOption[];
|
|
108
|
+
slashCommands?: import("./types.ts").AcpSlashCommand[];
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
/** Session backed by local OpenClaw gateway (not a spawned ACP agent). */
|
|
@@ -105,6 +116,7 @@ interface NativeSession {
|
|
|
105
116
|
sessionKey: string; // OpenClaw session key (e.g. "agent:main:cron:xxx")
|
|
106
117
|
lastActiveAt: number;
|
|
107
118
|
from: string;
|
|
119
|
+
prompting: boolean; // true while executing a prompt (busy state)
|
|
108
120
|
abortController: AbortController | null;
|
|
109
121
|
}
|
|
110
122
|
|
|
@@ -156,10 +168,16 @@ export class AcpProxy {
|
|
|
156
168
|
// Track pending retry timers for sendResponse so they can be cancelled on destroy
|
|
157
169
|
private retryTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
158
170
|
private disposed = false;
|
|
171
|
+
// Transcript file watchers: watch transcript files for external changes (terminal Claude Code)
|
|
172
|
+
private transcriptWatchers = new Map<string, { watcher: FSWatcher; path: string; offset: number; lineBuffer: string; reading: boolean; idleTimer: ReturnType<typeof setTimeout> | null; hadActivity: boolean }>();
|
|
159
173
|
// Agent daemon pool: long-lived process per agent type, reused across sessions
|
|
160
174
|
private daemons = new Map<string, AgentDaemon>();
|
|
161
175
|
// In-flight daemon acquisition: prevents multiple concurrent spawns for same agent
|
|
162
176
|
private daemonStarting = new Map<string, Promise<AgentDaemon>>();
|
|
177
|
+
// In-flight resume operations: prevents concurrent resumes for same acpSessionId
|
|
178
|
+
private resumeInFlight = new Map<string, Promise<AcpSession>>();
|
|
179
|
+
// Sessions explicitly closed by users — excluded from disk session list
|
|
180
|
+
private closedSessionIds = new Set<string>();
|
|
163
181
|
|
|
164
182
|
constructor(config: ClawMatrixConfig, peerManager: PeerManager, openclawConfig?: Record<string, unknown>, gatewayInfo?: GatewayInfo) {
|
|
165
183
|
this.config = config;
|
|
@@ -183,12 +201,27 @@ export class AcpProxy {
|
|
|
183
201
|
// ── Multi-device sync helpers ──────────────────────────────────
|
|
184
202
|
|
|
185
203
|
private addSessionWatcher(sessionId: string, nodeId: string) {
|
|
204
|
+
// A mobile device typically watches one session at a time.
|
|
205
|
+
// Remove from any other session this node was watching to prevent cross-talk.
|
|
206
|
+
for (const [otherId, otherWatchers] of this.sessionWatchers) {
|
|
207
|
+
if (otherId !== sessionId && otherWatchers.has(nodeId)) {
|
|
208
|
+
otherWatchers.delete(nodeId);
|
|
209
|
+
debug("acp", `addSessionWatcher: removed ${nodeId} from old session ${otherId.slice(0, 8)}`);
|
|
210
|
+
if (otherWatchers.size === 0) {
|
|
211
|
+
this.sessionWatchers.delete(otherId);
|
|
212
|
+
this.stopTranscriptWatcher(otherId);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
186
217
|
let watchers = this.sessionWatchers.get(sessionId);
|
|
187
218
|
if (!watchers) {
|
|
188
219
|
watchers = new Set();
|
|
189
220
|
this.sessionWatchers.set(sessionId, watchers);
|
|
190
221
|
}
|
|
222
|
+
const isNew = !watchers.has(nodeId);
|
|
191
223
|
watchers.add(nodeId);
|
|
224
|
+
debug("acp", `addSessionWatcher: sessionId=${sessionId.slice(0, 8)} nodeId=${nodeId} isNew=${isNew} total=${watchers.size} all=[${[...watchers].join(",")}]`);
|
|
192
225
|
}
|
|
193
226
|
|
|
194
227
|
/** Send a frame to all session watchers except the specified node (usually the original `from`). */
|
|
@@ -197,6 +230,7 @@ export class AcpProxy {
|
|
|
197
230
|
if (!watchers) return;
|
|
198
231
|
for (const nodeId of watchers) {
|
|
199
232
|
if (nodeId === exclude) continue;
|
|
233
|
+
debug("acp", `sendToOtherWatchers: sessionId=${sessionId.slice(0, 8)} → ${nodeId} (type=${frame.type})`);
|
|
200
234
|
this.peerManager.sendTo(nodeId, { ...frame, to: nodeId });
|
|
201
235
|
}
|
|
202
236
|
}
|
|
@@ -218,6 +252,265 @@ export class AcpProxy {
|
|
|
218
252
|
}
|
|
219
253
|
}
|
|
220
254
|
|
|
255
|
+
// ── Transcript file watcher (for external Claude Code sessions) ──
|
|
256
|
+
|
|
257
|
+
/** Start watching a transcript file for new content and forward to session watchers. */
|
|
258
|
+
private async startTranscriptWatcher(sessionId: string, acpSessionId: string) {
|
|
259
|
+
// Don't start duplicate watchers
|
|
260
|
+
if (this.transcriptWatchers.has(sessionId)) return;
|
|
261
|
+
|
|
262
|
+
const transcriptPath = await this.findTranscriptPath(acpSessionId);
|
|
263
|
+
if (!transcriptPath) {
|
|
264
|
+
debug("acp", `transcript watcher: no transcript found for ${acpSessionId.slice(0, 8)}`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Start from end of file (only forward NEW content)
|
|
269
|
+
let offset: number;
|
|
270
|
+
try {
|
|
271
|
+
const s = await fsStat(transcriptPath);
|
|
272
|
+
offset = s.size;
|
|
273
|
+
} catch {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
debug("acp", `transcript watcher: started for sessionId=${sessionId.slice(0, 8)} path=${transcriptPath} offset=${offset}`);
|
|
278
|
+
|
|
279
|
+
const state = { watcher: null as unknown as FSWatcher, path: transcriptPath, offset, lineBuffer: "", reading: false, idleTimer: null as ReturnType<typeof setTimeout> | null, hadActivity: false };
|
|
280
|
+
|
|
281
|
+
// Use fs.watch (kqueue on macOS) for near-instant file change detection
|
|
282
|
+
try {
|
|
283
|
+
state.watcher = fsWatch(transcriptPath, () => {
|
|
284
|
+
this.readTranscriptChanges(sessionId, state);
|
|
285
|
+
});
|
|
286
|
+
// Handle watcher errors gracefully (e.g., file deleted)
|
|
287
|
+
state.watcher.on("error", () => {
|
|
288
|
+
this.stopTranscriptWatcher(sessionId);
|
|
289
|
+
});
|
|
290
|
+
} catch {
|
|
291
|
+
debug("acp", `transcript watcher: fs.watch failed for ${transcriptPath}, skipping`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this.transcriptWatchers.set(sessionId, state);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Stop watching a transcript file. */
|
|
299
|
+
private stopTranscriptWatcher(sessionId: string) {
|
|
300
|
+
const entry = this.transcriptWatchers.get(sessionId);
|
|
301
|
+
if (!entry) return;
|
|
302
|
+
try { entry.watcher.close(); } catch { /* best effort */ }
|
|
303
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
304
|
+
this.transcriptWatchers.delete(sessionId);
|
|
305
|
+
debug("acp", `transcript watcher: stopped for sessionId=${sessionId.slice(0, 8)}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Read new bytes from transcript file and forward as stream events. */
|
|
309
|
+
private async readTranscriptChanges(sessionId: string, state: { path: string; offset: number; lineBuffer: string; reading: boolean; idleTimer: ReturnType<typeof setTimeout> | null; hadActivity: boolean }) {
|
|
310
|
+
// Prevent concurrent reads (fs.watch can fire multiple times rapidly)
|
|
311
|
+
if (state.reading) return;
|
|
312
|
+
state.reading = true;
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
// Skip if session is prompting through ACP (stream callback handles it)
|
|
316
|
+
const session = this.sessions.get(sessionId);
|
|
317
|
+
if (session?.prompting) return;
|
|
318
|
+
|
|
319
|
+
// Skip if no watchers
|
|
320
|
+
const watchers = this.sessionWatchers.get(sessionId);
|
|
321
|
+
if (!watchers || watchers.size === 0) {
|
|
322
|
+
this.stopTranscriptWatcher(sessionId);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const s = await fsStat(state.path);
|
|
327
|
+
if (s.size <= state.offset) return; // No new data
|
|
328
|
+
|
|
329
|
+
// Read new bytes from the file
|
|
330
|
+
const bytesToRead = s.size - state.offset;
|
|
331
|
+
const fh = await fsOpen(state.path, "r");
|
|
332
|
+
try {
|
|
333
|
+
const buf = Buffer.alloc(Math.min(bytesToRead, 256 * 1024)); // Cap at 256KB per read
|
|
334
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, state.offset);
|
|
335
|
+
if (bytesRead === 0) return;
|
|
336
|
+
state.offset += bytesRead;
|
|
337
|
+
|
|
338
|
+
// Append to line buffer and extract complete lines
|
|
339
|
+
state.lineBuffer += buf.toString("utf8", 0, bytesRead);
|
|
340
|
+
const lines = state.lineBuffer.split("\n");
|
|
341
|
+
state.lineBuffer = lines.pop()!; // Keep incomplete last line in buffer
|
|
342
|
+
|
|
343
|
+
let forwarded = false;
|
|
344
|
+
for (const line of lines) {
|
|
345
|
+
const trimmed = line.trim();
|
|
346
|
+
if (!trimmed) continue;
|
|
347
|
+
this.forwardTranscriptLine(sessionId, trimmed);
|
|
348
|
+
forwarded = true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// After forwarding content, reset the idle timer.
|
|
352
|
+
// When transcript goes idle for 3s after activity, send transcript_idle
|
|
353
|
+
// so iOS can re-fetch full history for clean rendering.
|
|
354
|
+
if (forwarded) {
|
|
355
|
+
state.hadActivity = true;
|
|
356
|
+
if (state.idleTimer) clearTimeout(state.idleTimer);
|
|
357
|
+
state.idleTimer = setTimeout(() => {
|
|
358
|
+
if (!state.hadActivity) return;
|
|
359
|
+
state.hadActivity = false;
|
|
360
|
+
debug("acp", `transcript watcher: idle after activity, sending transcript_idle for ${sessionId.slice(0, 8)}`);
|
|
361
|
+
this.broadcastTranscriptEvent(sessionId, "", "transcript_idle");
|
|
362
|
+
}, 3000);
|
|
363
|
+
}
|
|
364
|
+
} finally {
|
|
365
|
+
await fh.close();
|
|
366
|
+
}
|
|
367
|
+
} catch (err) {
|
|
368
|
+
debug("acp", `transcript watcher: read error for ${sessionId.slice(0, 8)}: ${errorMessage(err)}`);
|
|
369
|
+
} finally {
|
|
370
|
+
state.reading = false;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Parse a single JSONL line and forward as acp_stream frames to watchers.
|
|
375
|
+
* Uses the same normalizeTranscriptMessages logic for consistent rendering. */
|
|
376
|
+
private forwardTranscriptLine(sessionId: string, line: string) {
|
|
377
|
+
// Reuse the shared normalizer for consistent output with chat history
|
|
378
|
+
const messages = normalizeTranscriptMessages([line], 10);
|
|
379
|
+
for (const msg of messages) {
|
|
380
|
+
if (msg.role === "user") {
|
|
381
|
+
this.broadcastTranscriptEvent(sessionId, msg.text, "user_message");
|
|
382
|
+
} else if (msg.role === "assistant") {
|
|
383
|
+
if (msg.thinking) {
|
|
384
|
+
this.broadcastTranscriptEvent(sessionId, msg.thinking, "agent_thought_chunk");
|
|
385
|
+
}
|
|
386
|
+
if (msg.text) {
|
|
387
|
+
this.broadcastTranscriptEvent(sessionId, msg.text, "agent_message_chunk");
|
|
388
|
+
}
|
|
389
|
+
} else if (msg.role === "tool") {
|
|
390
|
+
if (msg.toolName) {
|
|
391
|
+
this.broadcastTranscriptEvent(sessionId, `\n[tool_call: ${msg.toolName}]\n`, "tool_call");
|
|
392
|
+
}
|
|
393
|
+
if (msg.toolResult) {
|
|
394
|
+
this.broadcastTranscriptEvent(sessionId, msg.toolResult, "tool_result");
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Send a transcript event to all watchers of a session. */
|
|
401
|
+
private broadcastTranscriptEvent(sessionId: string, delta: string, event: string) {
|
|
402
|
+
const watchers = this.sessionWatchers.get(sessionId);
|
|
403
|
+
if (!watchers) return;
|
|
404
|
+
|
|
405
|
+
const frame: AcpStreamChunk = {
|
|
406
|
+
type: "acp_stream",
|
|
407
|
+
id: `transcript-${Date.now()}`,
|
|
408
|
+
from: this.config.nodeId,
|
|
409
|
+
to: "",
|
|
410
|
+
timestamp: Date.now(),
|
|
411
|
+
payload: { delta, event, done: false, sessionId },
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
for (const nodeId of watchers) {
|
|
415
|
+
this.peerManager.sendTo(nodeId, { ...frame, to: nodeId });
|
|
416
|
+
}
|
|
417
|
+
debug("acp", `transcript watcher: broadcast event=${event} delta=${delta.slice(0, 40)} to ${watchers.size} watchers`);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── Subscribe / observe (receiver side) ─────────────────────────
|
|
421
|
+
|
|
422
|
+
/** Handle acp_subscribe: register caller as observer and return history snapshot.
|
|
423
|
+
*
|
|
424
|
+
* `payload.sessionId` may be an ACP/OpenClaw session ID (from the list) or a
|
|
425
|
+
* ClawMatrix session ID. We resolve to the ClawMatrix session ID for watcher
|
|
426
|
+
* registration, while using the original ID for transcript lookup (disk files
|
|
427
|
+
* are named by ACP session ID).
|
|
428
|
+
*/
|
|
429
|
+
async handleSubscribeRequest(frame: AcpSubscribeRequest): Promise<void> {
|
|
430
|
+
const { id, from, payload } = frame;
|
|
431
|
+
const { sessionId } = payload;
|
|
432
|
+
|
|
433
|
+
// Resolve to ClawMatrix session ID for watcher registration (reverse lookup by acpSessionId)
|
|
434
|
+
let watcherKey = sessionId;
|
|
435
|
+
if (!this.sessions.has(sessionId)) {
|
|
436
|
+
for (const [sid, s] of this.sessions) {
|
|
437
|
+
if (s.kind === "acp" && s.acpSessionId === sessionId) {
|
|
438
|
+
watcherKey = sid;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
this.addSessionWatcher(watcherKey, from);
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
const transcriptPath = await this.findTranscriptPath(sessionId);
|
|
447
|
+
let history: import("./types.ts").ChatHistoryMessage[] = [];
|
|
448
|
+
if (transcriptPath) {
|
|
449
|
+
const content = await readFileText(transcriptPath);
|
|
450
|
+
const lines = content.split(/\r?\n/).filter((l: string) => l.trim());
|
|
451
|
+
history = normalizeTranscriptMessages(lines, 200);
|
|
452
|
+
}
|
|
453
|
+
this.peerManager.sendTo(from, {
|
|
454
|
+
type: "acp_subscribe_res",
|
|
455
|
+
id,
|
|
456
|
+
from: this.config.nodeId,
|
|
457
|
+
to: from,
|
|
458
|
+
timestamp: Date.now(),
|
|
459
|
+
payload: { success: true, history },
|
|
460
|
+
} satisfies AcpSubscribeResponse);
|
|
461
|
+
} catch (err) {
|
|
462
|
+
this.peerManager.sendTo(from, {
|
|
463
|
+
type: "acp_subscribe_res",
|
|
464
|
+
id,
|
|
465
|
+
from: this.config.nodeId,
|
|
466
|
+
to: from,
|
|
467
|
+
timestamp: Date.now(),
|
|
468
|
+
payload: { success: false, error: errorMessage(err) },
|
|
469
|
+
} satisfies AcpSubscribeResponse);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Handle acp_unsubscribe: remove caller from observer list. */
|
|
474
|
+
handleUnsubscribeRequest(frame: AcpUnsubscribeRequest): void {
|
|
475
|
+
const { from, payload } = frame;
|
|
476
|
+
// Resolve watcher key (same logic as subscribe)
|
|
477
|
+
let watcherKey = payload.sessionId;
|
|
478
|
+
if (!this.sessionWatchers.has(watcherKey)) {
|
|
479
|
+
for (const [sid, s] of this.sessions) {
|
|
480
|
+
if (s.kind === "acp" && s.acpSessionId === payload.sessionId) {
|
|
481
|
+
watcherKey = sid;
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const watchers = this.sessionWatchers.get(watcherKey);
|
|
487
|
+
if (watchers) {
|
|
488
|
+
watchers.delete(from);
|
|
489
|
+
if (watchers.size === 0) this.sessionWatchers.delete(watcherKey);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Broadcast acp_session_notify to all connected peers. */
|
|
494
|
+
private broadcastSessionNotify(
|
|
495
|
+
sessionId: string,
|
|
496
|
+
event: AcpSessionNotify["payload"]["event"],
|
|
497
|
+
title?: string,
|
|
498
|
+
updatedAt?: string,
|
|
499
|
+
agent?: string,
|
|
500
|
+
): void {
|
|
501
|
+
const peers = this.peerManager.router.getAllPeers();
|
|
502
|
+
if (peers.length === 0) return;
|
|
503
|
+
const frame: AcpSessionNotify = {
|
|
504
|
+
type: "acp_session_notify",
|
|
505
|
+
from: this.config.nodeId,
|
|
506
|
+
timestamp: Date.now(),
|
|
507
|
+
payload: { sessionId, nodeId: this.config.nodeId, event, title, updatedAt, agent },
|
|
508
|
+
};
|
|
509
|
+
for (const peer of peers) {
|
|
510
|
+
this.peerManager.sendTo(peer.nodeId, { ...frame, to: peer.nodeId });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
221
514
|
// ── Requester side: send prompt ────────────────────────────────
|
|
222
515
|
|
|
223
516
|
async prompt(
|
|
@@ -479,8 +772,14 @@ export class AcpProxy {
|
|
|
479
772
|
const pending = this.pending.get(frame.id);
|
|
480
773
|
if (!pending) return;
|
|
481
774
|
|
|
482
|
-
// Only accumulate
|
|
483
|
-
|
|
775
|
+
// Only accumulate agent text content for the final result.
|
|
776
|
+
// Skip events that produce markers/metadata (not actual response text).
|
|
777
|
+
const skipAccumulate = new Set([
|
|
778
|
+
"agent_thought_chunk", "tool_call", "tool_result", "plan",
|
|
779
|
+
"usage", "available_commands", "config_options",
|
|
780
|
+
"current_mode_update", "session_info_update",
|
|
781
|
+
]);
|
|
782
|
+
if (!skipAccumulate.has(frame.payload.event ?? "")) {
|
|
484
783
|
pending.accumulated += frame.payload.delta;
|
|
485
784
|
}
|
|
486
785
|
if (pending.onStream) {
|
|
@@ -577,15 +876,27 @@ export class AcpProxy {
|
|
|
577
876
|
debug("acp", `handleRequest: id=${id} from=${from} agent=${agent} mode=${mode} sessionId=${sessionId ?? "(new)"} acpSessionId=${resumeAcpSessionId ?? "(none)"}`);
|
|
578
877
|
|
|
579
878
|
try {
|
|
580
|
-
|
|
879
|
+
// Resolve sessionId: if absent but resumeAcpSessionId is provided, look up by acpSessionId
|
|
880
|
+
let resolvedSessionId = sessionId;
|
|
881
|
+
if (!resolvedSessionId && resumeAcpSessionId) {
|
|
882
|
+
for (const [sid, s] of this.sessions) {
|
|
883
|
+
if (s.kind === "acp" && s.acpSessionId === resumeAcpSessionId) {
|
|
884
|
+
resolvedSessionId = sid;
|
|
885
|
+
debug("acp", `Resolved sessionId from acpSessionId: ${resumeAcpSessionId.slice(0, 8)} → ${sid.slice(0, 8)}`);
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
if (resolvedSessionId) {
|
|
581
892
|
// Follow-up on existing session
|
|
582
|
-
const session = this.sessions.get(
|
|
893
|
+
const session = this.sessions.get(resolvedSessionId);
|
|
583
894
|
if (!session) {
|
|
584
895
|
// Session expired or process restarted – try to resume
|
|
585
896
|
if (this.isAcpAgent(agent)) {
|
|
586
897
|
let newSession: AcpSession;
|
|
587
898
|
if (resumeAcpSessionId) {
|
|
588
|
-
debug("acp", `Session "${
|
|
899
|
+
debug("acp", `Session "${resolvedSessionId}" not found, resuming via acpSessionId "${resumeAcpSessionId.slice(0, 8)}..."`);
|
|
589
900
|
const effectiveCwd = cwd || process.cwd();
|
|
590
901
|
try {
|
|
591
902
|
newSession = await this.createSessionWithResume(agent, resumeAcpSessionId, effectiveCwd, from);
|
|
@@ -594,7 +905,7 @@ export class AcpProxy {
|
|
|
594
905
|
newSession = await this.createSession(agent, cwd, from);
|
|
595
906
|
}
|
|
596
907
|
} else {
|
|
597
|
-
debug("acp", `Session "${
|
|
908
|
+
debug("acp", `Session "${resolvedSessionId}" not found, creating new session as fallback`);
|
|
598
909
|
newSession = await this.createSession(agent, cwd, from);
|
|
599
910
|
}
|
|
600
911
|
if (mode === "persistent") {
|
|
@@ -610,19 +921,22 @@ export class AcpProxy {
|
|
|
610
921
|
agent: newSession.agent,
|
|
611
922
|
sessionId: newSession.sessionId,
|
|
612
923
|
acpSessionId: newSession.acpSessionId,
|
|
924
|
+
configOptions: newSession.configOptions,
|
|
925
|
+
availableModes: newSession.availableModes,
|
|
926
|
+
currentModeId: newSession.currentModeId,
|
|
613
927
|
});
|
|
614
928
|
}
|
|
615
|
-
if (mode === "oneshot"
|
|
929
|
+
if (mode === "oneshot") {
|
|
616
930
|
this.returnToReusePool(newSession, cwd || process.cwd());
|
|
617
931
|
}
|
|
618
932
|
} else {
|
|
619
933
|
// Re-create native session
|
|
620
|
-
debug("acp", `Native session "${
|
|
621
|
-
const sessionKey = cwd ??
|
|
934
|
+
debug("acp", `Native session "${resolvedSessionId}" not found, re-creating with key from cwd`);
|
|
935
|
+
const sessionKey = cwd ?? resolvedSessionId;
|
|
622
936
|
const newId = `native-${crypto.randomUUID()}`;
|
|
623
937
|
const nativeSession: NativeSession = {
|
|
624
938
|
kind: "native", sessionId: newId, agent, sessionKey,
|
|
625
|
-
lastActiveAt: Date.now(), from, abortController: null,
|
|
939
|
+
lastActiveAt: Date.now(), from, prompting: false, abortController: null,
|
|
626
940
|
};
|
|
627
941
|
if (mode === "persistent") {
|
|
628
942
|
this.sessions.set(newId, nativeSession);
|
|
@@ -637,7 +951,7 @@ export class AcpProxy {
|
|
|
637
951
|
return;
|
|
638
952
|
}
|
|
639
953
|
// Track this node as a session watcher for multi-device sync
|
|
640
|
-
this.addSessionWatcher(
|
|
954
|
+
this.addSessionWatcher(resolvedSessionId, from);
|
|
641
955
|
session.lastActiveAt = Date.now();
|
|
642
956
|
if (session.kind === "native") {
|
|
643
957
|
await this.runNativePrompt(id, from, session, task);
|
|
@@ -655,8 +969,20 @@ export class AcpProxy {
|
|
|
655
969
|
}
|
|
656
970
|
|
|
657
971
|
if (this.isAcpAgent(agent)) {
|
|
658
|
-
//
|
|
659
|
-
|
|
972
|
+
// Resume existing ACP session if acpSessionId is provided, otherwise create new
|
|
973
|
+
let session: AcpSession;
|
|
974
|
+
if (resumeAcpSessionId) {
|
|
975
|
+
debug("acp", `No sessionId but acpSessionId=${resumeAcpSessionId.slice(0, 8)} provided, attempting resume`);
|
|
976
|
+
const effectiveCwd = cwd || process.cwd();
|
|
977
|
+
try {
|
|
978
|
+
session = await this.createSessionWithResume(agent, resumeAcpSessionId, effectiveCwd, from);
|
|
979
|
+
} catch (resumeErr) {
|
|
980
|
+
debug("acp", `Resume failed (${errorMessage(resumeErr)}), falling back to new session`);
|
|
981
|
+
session = await this.createSession(agent, cwd, from);
|
|
982
|
+
}
|
|
983
|
+
} else {
|
|
984
|
+
session = await this.createSession(agent, cwd, from);
|
|
985
|
+
}
|
|
660
986
|
if (mode === "persistent") {
|
|
661
987
|
this.sessions.set(session.sessionId, session);
|
|
662
988
|
this.addSessionWatcher(session.sessionId, from);
|
|
@@ -670,9 +996,12 @@ export class AcpProxy {
|
|
|
670
996
|
agent: session.agent,
|
|
671
997
|
sessionId: session.sessionId,
|
|
672
998
|
acpSessionId: session.acpSessionId,
|
|
999
|
+
configOptions: session.configOptions,
|
|
1000
|
+
availableModes: session.availableModes,
|
|
1001
|
+
currentModeId: session.currentModeId,
|
|
673
1002
|
});
|
|
674
1003
|
}
|
|
675
|
-
if (mode === "oneshot"
|
|
1004
|
+
if (mode === "oneshot") {
|
|
676
1005
|
this.returnToReusePool(session, cwd || process.cwd());
|
|
677
1006
|
}
|
|
678
1007
|
} else {
|
|
@@ -685,7 +1014,7 @@ export class AcpProxy {
|
|
|
685
1014
|
const newId = `native-${crypto.randomUUID()}`;
|
|
686
1015
|
const nativeSession: NativeSession = {
|
|
687
1016
|
kind: "native", sessionId: newId, agent, sessionKey,
|
|
688
|
-
lastActiveAt: Date.now(), from, abortController: null,
|
|
1017
|
+
lastActiveAt: Date.now(), from, prompting: false, abortController: null,
|
|
689
1018
|
};
|
|
690
1019
|
if (mode === "persistent") {
|
|
691
1020
|
this.sessions.set(newId, nativeSession);
|
|
@@ -708,7 +1037,19 @@ export class AcpProxy {
|
|
|
708
1037
|
|
|
709
1038
|
async handleClose(frame: AcpCloseRequest): Promise<void> {
|
|
710
1039
|
const { id, from, payload } = frame;
|
|
711
|
-
|
|
1040
|
+
|
|
1041
|
+
// Resolve session: direct lookup, then reverse lookup by acpSessionId
|
|
1042
|
+
let resolvedId = payload.sessionId;
|
|
1043
|
+
let session = this.sessions.get(resolvedId);
|
|
1044
|
+
if (!session) {
|
|
1045
|
+
for (const [sid, s] of this.sessions) {
|
|
1046
|
+
if (s.kind === "acp" && s.acpSessionId === payload.sessionId) {
|
|
1047
|
+
resolvedId = sid;
|
|
1048
|
+
session = s;
|
|
1049
|
+
break;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
712
1053
|
|
|
713
1054
|
if (!session) {
|
|
714
1055
|
this.peerManager.sendTo(from, {
|
|
@@ -719,18 +1060,37 @@ export class AcpProxy {
|
|
|
719
1060
|
return;
|
|
720
1061
|
}
|
|
721
1062
|
|
|
722
|
-
|
|
1063
|
+
// Allow close from session owner or any watcher (multi-device sync)
|
|
1064
|
+
const watchers = this.sessionWatchers.get(resolvedId);
|
|
1065
|
+
if (session.from !== from && !watchers?.has(from)) {
|
|
723
1066
|
this.peerManager.sendTo(from, {
|
|
724
1067
|
type: "acp_close_res", id, from: this.config.nodeId, to: from,
|
|
725
1068
|
timestamp: Date.now(),
|
|
726
|
-
payload: { success: false, error: "Not the session owner" },
|
|
1069
|
+
payload: { success: false, error: "Not the session owner or watcher" },
|
|
727
1070
|
} satisfies AcpCloseResponse);
|
|
728
1071
|
return;
|
|
729
1072
|
}
|
|
730
1073
|
|
|
731
|
-
this.sessions.delete(
|
|
1074
|
+
this.sessions.delete(resolvedId);
|
|
732
1075
|
this.destroySession(session);
|
|
733
1076
|
|
|
1077
|
+
// Mark session as closed so it won't reappear from disk scan
|
|
1078
|
+
const acpSid = session.kind === "acp" ? session.acpSessionId : undefined;
|
|
1079
|
+
if (acpSid) this.closedSessionIds.add(acpSid);
|
|
1080
|
+
this.closedSessionIds.add(resolvedId);
|
|
1081
|
+
|
|
1082
|
+
// Physical deletion: only OpenClaw sessions can be safely deleted from disk.
|
|
1083
|
+
// Claude Code and Codex don't expose a deleteSession API, and their index files
|
|
1084
|
+
// (history.jsonl, SQLite DB, session_index.jsonl) would still reference deleted
|
|
1085
|
+
// transcripts, breaking their own UI/CLI. Both have open GitHub issues requesting
|
|
1086
|
+
// delete support (CC: #25304/#26904/#13514, Codex: #13018/#8784).
|
|
1087
|
+
// For now, rely on the in-memory closedSessionIds set to hide them from list.
|
|
1088
|
+
// TODO: enable physical deletion when these agents add delete APIs
|
|
1089
|
+
// const deleteId = acpSid ?? resolvedId;
|
|
1090
|
+
// deleteSessionFromDisk(deleteId).catch((err) => {
|
|
1091
|
+
// debug("acp", `deleteSessionFromDisk failed for ${deleteId.slice(0, 8)}: ${errorMessage(err)}`);
|
|
1092
|
+
// });
|
|
1093
|
+
|
|
734
1094
|
this.peerManager.sendTo(from, {
|
|
735
1095
|
type: "acp_close_res", id, from: this.config.nodeId, to: from,
|
|
736
1096
|
timestamp: Date.now(),
|
|
@@ -743,25 +1103,44 @@ export class AcpProxy {
|
|
|
743
1103
|
const { id, from, payload } = frame;
|
|
744
1104
|
|
|
745
1105
|
try {
|
|
746
|
-
// 1.
|
|
1106
|
+
// 1. Read persisted sessions from disk first (they have full metadata)
|
|
1107
|
+
const diskSessions = await this.readAllSessionStoresFromDisk();
|
|
1108
|
+
// Index disk sessions by sessionId for quick lookup
|
|
1109
|
+
const diskSessionMap = new Map<string, AcpSessionInfo>();
|
|
1110
|
+
for (const ds of diskSessions) {
|
|
1111
|
+
if (ds.sessionId) diskSessionMap.set(ds.sessionId, ds);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// 2. ClawMatrix-managed active sessions — merge with disk metadata
|
|
747
1115
|
const clawSessions: AcpSessionInfo[] = [];
|
|
1116
|
+
const activeAcpSessionIds = new Set<string>();
|
|
748
1117
|
for (const [, session] of this.sessions) {
|
|
749
1118
|
if (payload.agent && session.agent !== payload.agent) continue;
|
|
1119
|
+
const acpSid = session.kind === "acp" ? session.acpSessionId : undefined;
|
|
1120
|
+
if (acpSid) activeAcpSessionIds.add(acpSid);
|
|
1121
|
+
// Look up disk metadata for this active session (by ACP session ID)
|
|
1122
|
+
const diskInfo = acpSid ? diskSessionMap.get(acpSid) : undefined;
|
|
750
1123
|
clawSessions.push({
|
|
751
|
-
sessionId: session.sessionId,
|
|
752
|
-
cwd: "",
|
|
1124
|
+
sessionId: acpSid ?? session.sessionId,
|
|
1125
|
+
cwd: diskInfo?.cwd ?? "",
|
|
1126
|
+
title: diskInfo?.title,
|
|
1127
|
+
description: diskInfo?.description,
|
|
753
1128
|
agent: session.agent,
|
|
754
1129
|
updatedAt: new Date(session.lastActiveAt).toISOString(),
|
|
1130
|
+
status: session.prompting ? "busy" : "active",
|
|
755
1131
|
});
|
|
756
1132
|
}
|
|
757
1133
|
|
|
758
|
-
//
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1134
|
+
// 3. Filter disk sessions: exclude already-tracked active sessions and explicitly closed ones
|
|
1135
|
+
const filteredDiskSessions = diskSessions
|
|
1136
|
+
.filter((s) => {
|
|
1137
|
+
if (payload.agent && s.agent !== payload.agent) return false;
|
|
1138
|
+
if (clawSessions.some((cs) => cs.sessionId === s.sessionId)) return false;
|
|
1139
|
+
if (activeAcpSessionIds.has(s.sessionId)) return false;
|
|
1140
|
+
if (this.closedSessionIds.has(s.sessionId)) return false;
|
|
1141
|
+
return true;
|
|
1142
|
+
})
|
|
1143
|
+
.map((s) => ({ ...s, status: "idle" as const }));
|
|
765
1144
|
|
|
766
1145
|
this.peerManager.sendTo(from, {
|
|
767
1146
|
type: "acp_list_res", id, from: this.config.nodeId, to: from,
|
|
@@ -784,6 +1163,28 @@ export class AcpProxy {
|
|
|
784
1163
|
const cwd = payload.cwd || process.cwd();
|
|
785
1164
|
|
|
786
1165
|
try {
|
|
1166
|
+
// If the session is already active in memory (by ClawMatrix ID or ACP session ID),
|
|
1167
|
+
// just add the caller as a watcher and return the existing session ID.
|
|
1168
|
+
// This avoids spawning a duplicate process and ensures stream forwarding works.
|
|
1169
|
+
debug("acp", `resume: looking for acpSessionId=${acpSessionId.slice(0, 8)} in ${this.sessions.size} active sessions: [${[...this.sessions].map(([sid, s]) => `${sid.slice(0, 8)}(${s.kind === "acp" ? "acp:" + s.acpSessionId.slice(0, 8) : "native"} prompting=${s.prompting})`).join(", ")}]`);
|
|
1170
|
+
for (const [sid, s] of this.sessions) {
|
|
1171
|
+
const isMatch = sid === acpSessionId || (s.kind === "acp" && s.acpSessionId === acpSessionId);
|
|
1172
|
+
if (isMatch) {
|
|
1173
|
+
debug("acp", `resume: session already active (${sid}), adding watcher ${from}`);
|
|
1174
|
+
this.addSessionWatcher(sid, from);
|
|
1175
|
+
this.peerManager.sendTo(from, {
|
|
1176
|
+
type: "acp_resume_res", id, from: this.config.nodeId, to: from,
|
|
1177
|
+
timestamp: Date.now(),
|
|
1178
|
+
payload: { success: true, sessionId: sid },
|
|
1179
|
+
} satisfies AcpResumeResponse);
|
|
1180
|
+
s.lastActiveAt = Date.now();
|
|
1181
|
+
// Start transcript watcher for external activity (terminal Claude Code)
|
|
1182
|
+
const acpSidForWatch = s.kind === "acp" ? s.acpSessionId : acpSessionId;
|
|
1183
|
+
this.startTranscriptWatcher(sid, acpSidForWatch).catch(() => {});
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
787
1188
|
// Enforce concurrent session limit
|
|
788
1189
|
if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) {
|
|
789
1190
|
this.peerManager.sendTo(from, {
|
|
@@ -795,13 +1196,28 @@ export class AcpProxy {
|
|
|
795
1196
|
}
|
|
796
1197
|
|
|
797
1198
|
if (this.isAcpAgent(agent)) {
|
|
798
|
-
//
|
|
1199
|
+
// Deduplicate concurrent resumes for the same acpSessionId:
|
|
1200
|
+
// if another request is already resuming this session, reuse its result.
|
|
1201
|
+
const existingResume = this.resumeInFlight.get(acpSessionId);
|
|
799
1202
|
let session: AcpSession;
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
1203
|
+
if (existingResume) {
|
|
1204
|
+
debug("acp", `resume already in-flight for acpSessionId=${acpSessionId.slice(0, 8)}..., reusing`);
|
|
1205
|
+
session = await existingResume;
|
|
1206
|
+
} else {
|
|
1207
|
+
const resumePromise = (async (): Promise<AcpSession> => {
|
|
1208
|
+
try {
|
|
1209
|
+
return await this.createSessionWithResume(agent, acpSessionId, cwd, from);
|
|
1210
|
+
} catch (resumeErr) {
|
|
1211
|
+
debug("acp", `Resume failed (${errorMessage(resumeErr)}), falling back to new session`);
|
|
1212
|
+
return await this.createSession(agent, cwd, from);
|
|
1213
|
+
}
|
|
1214
|
+
})();
|
|
1215
|
+
this.resumeInFlight.set(acpSessionId, resumePromise);
|
|
1216
|
+
try {
|
|
1217
|
+
session = await resumePromise;
|
|
1218
|
+
} finally {
|
|
1219
|
+
this.resumeInFlight.delete(acpSessionId);
|
|
1220
|
+
}
|
|
805
1221
|
}
|
|
806
1222
|
this.sessions.set(session.sessionId, session);
|
|
807
1223
|
this.addSessionWatcher(session.sessionId, from);
|
|
@@ -811,6 +1227,8 @@ export class AcpProxy {
|
|
|
811
1227
|
timestamp: Date.now(),
|
|
812
1228
|
payload: { success: true, sessionId: session.sessionId },
|
|
813
1229
|
} satisfies AcpResumeResponse);
|
|
1230
|
+
// Start transcript watcher for external activity (terminal Claude Code)
|
|
1231
|
+
this.startTranscriptWatcher(session.sessionId, session.acpSessionId).catch(() => {});
|
|
814
1232
|
} else {
|
|
815
1233
|
// Native OpenClaw session — no process to spawn, just record the session key
|
|
816
1234
|
// cwd contains the OpenClaw session key (e.g. "agent:main:clawmatrix-handoff:xxx")
|
|
@@ -822,6 +1240,7 @@ export class AcpProxy {
|
|
|
822
1240
|
sessionKey: cwd,
|
|
823
1241
|
lastActiveAt: Date.now(),
|
|
824
1242
|
from,
|
|
1243
|
+
prompting: false,
|
|
825
1244
|
abortController: null,
|
|
826
1245
|
};
|
|
827
1246
|
this.sessions.set(sessionId, nativeSession);
|
|
@@ -856,11 +1275,13 @@ export class AcpProxy {
|
|
|
856
1275
|
return;
|
|
857
1276
|
}
|
|
858
1277
|
|
|
859
|
-
|
|
1278
|
+
// Allow cancel from session owner OR any session watcher (multi-device sync)
|
|
1279
|
+
const watchers = this.sessionWatchers.get(payload.sessionId);
|
|
1280
|
+
if (session.from !== from && !watchers?.has(from)) {
|
|
860
1281
|
this.peerManager.sendTo(from, {
|
|
861
1282
|
type: "acp_cancel_res", id, from: this.config.nodeId, to: from,
|
|
862
1283
|
timestamp: Date.now(),
|
|
863
|
-
payload: { success: false, error: "Not the session owner" },
|
|
1284
|
+
payload: { success: false, error: "Not the session owner or watcher" },
|
|
864
1285
|
} satisfies AcpCancelResponse);
|
|
865
1286
|
return;
|
|
866
1287
|
}
|
|
@@ -962,6 +1383,100 @@ export class AcpProxy {
|
|
|
962
1383
|
} satisfies AcpGetModesResponse);
|
|
963
1384
|
}
|
|
964
1385
|
|
|
1386
|
+
// ── Config options (receiver side) ──────────────────────────────
|
|
1387
|
+
|
|
1388
|
+
/** Handle set config option request (receiver side): change model, thinking level, etc. */
|
|
1389
|
+
async handleSetConfigRequest(frame: AcpSetConfigRequest): Promise<void> {
|
|
1390
|
+
const { id, from, payload } = frame;
|
|
1391
|
+
const session = this.sessions.get(payload.sessionId);
|
|
1392
|
+
|
|
1393
|
+
if (!session) {
|
|
1394
|
+
this.peerManager.sendTo(from, {
|
|
1395
|
+
type: "acp_set_config_res", id, from: this.config.nodeId, to: from,
|
|
1396
|
+
timestamp: Date.now(),
|
|
1397
|
+
payload: { success: false, error: "Session not found" },
|
|
1398
|
+
} satisfies AcpSetConfigResponse);
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
if (session.kind !== "acp") {
|
|
1403
|
+
this.peerManager.sendTo(from, {
|
|
1404
|
+
type: "acp_set_config_res", id, from: this.config.nodeId, to: from,
|
|
1405
|
+
timestamp: Date.now(),
|
|
1406
|
+
payload: { success: false, error: "Config options not supported for native sessions" },
|
|
1407
|
+
} satisfies AcpSetConfigResponse);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
try {
|
|
1412
|
+
const response = await session.conn.setSessionConfigOption({
|
|
1413
|
+
sessionId: session.acpSessionId,
|
|
1414
|
+
configId: payload.configId,
|
|
1415
|
+
value: payload.value as string,
|
|
1416
|
+
});
|
|
1417
|
+
const configOptions = (response as Record<string, unknown>)?.configOptions as AcpConfigOption[] | undefined;
|
|
1418
|
+
this.peerManager.sendTo(from, {
|
|
1419
|
+
type: "acp_set_config_res", id, from: this.config.nodeId, to: from,
|
|
1420
|
+
timestamp: Date.now(),
|
|
1421
|
+
payload: { success: true, configOptions },
|
|
1422
|
+
} satisfies AcpSetConfigResponse);
|
|
1423
|
+
} catch (err) {
|
|
1424
|
+
this.peerManager.sendTo(from, {
|
|
1425
|
+
type: "acp_set_config_res", id, from: this.config.nodeId, to: from,
|
|
1426
|
+
timestamp: Date.now(),
|
|
1427
|
+
payload: { success: false, error: errorMessage(err) },
|
|
1428
|
+
} satisfies AcpSetConfigResponse);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/** Handle set config option response (requester side). */
|
|
1433
|
+
handleSetConfigResponse(frame: AcpSetConfigResponse): void {
|
|
1434
|
+
const pending = this.pending.get(frame.id);
|
|
1435
|
+
if (!pending) return;
|
|
1436
|
+
clearTimeout(pending.timer);
|
|
1437
|
+
this.pending.delete(frame.id);
|
|
1438
|
+
pending.resolve(frame.payload as unknown as AcpTaskResponse["payload"]);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/** Set a config option on a remote session (requester side). */
|
|
1442
|
+
setSessionConfig(
|
|
1443
|
+
targetNodeId: string,
|
|
1444
|
+
sessionId: string,
|
|
1445
|
+
configId: string,
|
|
1446
|
+
value: string | boolean,
|
|
1447
|
+
): Promise<AcpSetConfigResponse["payload"]> {
|
|
1448
|
+
const id = crypto.randomUUID();
|
|
1449
|
+
return new Promise((resolve, reject) => {
|
|
1450
|
+
const timer = setTimeout(() => {
|
|
1451
|
+
this.pending.delete(id);
|
|
1452
|
+
reject(new Error("ACP set config request timed out"));
|
|
1453
|
+
}, 15_000);
|
|
1454
|
+
|
|
1455
|
+
this.pending.set(id, {
|
|
1456
|
+
resolve: resolve as (r: AcpTaskResponse["payload"]) => void,
|
|
1457
|
+
reject,
|
|
1458
|
+
timer,
|
|
1459
|
+
targetNodeId,
|
|
1460
|
+
accumulated: "",
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
const frame: AcpSetConfigRequest = {
|
|
1464
|
+
type: "acp_set_config",
|
|
1465
|
+
id,
|
|
1466
|
+
from: this.config.nodeId,
|
|
1467
|
+
to: targetNodeId,
|
|
1468
|
+
timestamp: Date.now(),
|
|
1469
|
+
payload: { sessionId, configId, value },
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
if (!this.peerManager.sendTo(targetNodeId, frame)) {
|
|
1473
|
+
this.pending.delete(id);
|
|
1474
|
+
clearTimeout(timer);
|
|
1475
|
+
reject(new Error(`Cannot reach node "${targetNodeId}"`));
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1479
|
+
|
|
965
1480
|
// ── Chat history (receiver side) ─────────────────────────────────
|
|
966
1481
|
|
|
967
1482
|
/** Handle chat history request: read session transcript from disk and return normalized messages. */
|
|
@@ -1151,7 +1666,7 @@ export class AcpProxy {
|
|
|
1151
1666
|
}
|
|
1152
1667
|
|
|
1153
1668
|
private async spawnDaemon(agent: string, cwd: string): Promise<AgentDaemon> {
|
|
1154
|
-
const streamCallbacks = new Map<string, ((delta: string, event?: string) => void) | null>();
|
|
1669
|
+
const streamCallbacks = new Map<string, ((delta: string, event?: string, data?: unknown) => void) | null>();
|
|
1155
1670
|
|
|
1156
1671
|
const { conn, proc } = await this.spawnAndInit(agent, cwd, "spawnDaemon", (params) => {
|
|
1157
1672
|
const acpSessionId = (params as unknown as { sessionId?: string }).sessionId;
|
|
@@ -1233,6 +1748,7 @@ export class AcpProxy {
|
|
|
1233
1748
|
const availableModes: AcpModeInfo[] | undefined = response.modes?.availableModes?.map(
|
|
1234
1749
|
(m) => ({ id: m.id, name: m.name, description: m.description ?? undefined }),
|
|
1235
1750
|
);
|
|
1751
|
+
const configOptions = (response as Record<string, unknown>).configOptions as AcpConfigOption[] | undefined;
|
|
1236
1752
|
|
|
1237
1753
|
const session: AcpSession = {
|
|
1238
1754
|
kind: "acp",
|
|
@@ -1243,12 +1759,13 @@ export class AcpProxy {
|
|
|
1243
1759
|
proc: daemon.proc,
|
|
1244
1760
|
lastActiveAt: Date.now(),
|
|
1245
1761
|
from,
|
|
1762
|
+
prompting: false,
|
|
1246
1763
|
setStreamCallback: (cb) => {
|
|
1247
1764
|
daemon.streamCallbacks.set(response.sessionId, cb);
|
|
1248
1765
|
},
|
|
1249
1766
|
availableModes,
|
|
1250
1767
|
currentModeId: response.modes?.currentModeId,
|
|
1251
|
-
|
|
1768
|
+
configOptions,
|
|
1252
1769
|
};
|
|
1253
1770
|
|
|
1254
1771
|
return session;
|
|
@@ -1278,11 +1795,17 @@ export class AcpProxy {
|
|
|
1278
1795
|
|
|
1279
1796
|
/** Return a completed oneshot session to the reuse pool instead of destroying it. */
|
|
1280
1797
|
private returnToReusePool(session: AcpSession, cwd: string) {
|
|
1281
|
-
// Daemon-backed sessions don't need reuse pool — the daemon stays alive
|
|
1798
|
+
// Daemon-backed sessions don't need reuse pool — the daemon stays alive.
|
|
1799
|
+
// But we must close the ACP session on the daemon to prevent accumulation.
|
|
1282
1800
|
const daemon = this.findDaemonByConn(session);
|
|
1283
1801
|
if (daemon) {
|
|
1284
1802
|
daemon.streamCallbacks.delete(session.acpSessionId);
|
|
1285
|
-
|
|
1803
|
+
// Close the ACP session on the daemon connection (best-effort, don't block).
|
|
1804
|
+
// Uses unstable_closeSession per ACP SDK — session/close is still experimental.
|
|
1805
|
+
daemon.conn.unstable_closeSession({ sessionId: session.acpSessionId }).catch((err) => {
|
|
1806
|
+
debug("acp", `daemon closeSession failed for ${session.acpSessionId}: ${errorMessage(err)}`);
|
|
1807
|
+
});
|
|
1808
|
+
debug("acp", `daemon-backed oneshot done: agent=${session.agent} (session closed, daemon stays alive)`);
|
|
1286
1809
|
return;
|
|
1287
1810
|
}
|
|
1288
1811
|
|
|
@@ -1389,7 +1912,7 @@ export class AcpProxy {
|
|
|
1389
1912
|
agent: string,
|
|
1390
1913
|
cwd: string,
|
|
1391
1914
|
label: string,
|
|
1392
|
-
resolveStreamCb: (params: SessionNotification) => ((delta: string, event?: string) => void) | null | undefined,
|
|
1915
|
+
resolveStreamCb: (params: SessionNotification) => ((delta: string, event?: string, data?: unknown) => void) | null | undefined,
|
|
1393
1916
|
): Promise<{ conn: ClientSideConnection; proc: { kill: () => void; exited: Promise<number> } }> {
|
|
1394
1917
|
const cmd = this.resolveCommand(agent);
|
|
1395
1918
|
debug("acp", `${label}: spawning ${cmd.join(" ")} in ${cwd}`);
|
|
@@ -1445,15 +1968,68 @@ export class AcpProxy {
|
|
|
1445
1968
|
cb?.(content.text, updateType);
|
|
1446
1969
|
}
|
|
1447
1970
|
} else if (updateType === "tool_call") {
|
|
1448
|
-
const
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1971
|
+
const title = (update as { title?: string }).title;
|
|
1972
|
+
const toolCallId = (update as { toolCallId?: string }).toolCallId;
|
|
1973
|
+
const kind = (update as { kind?: string }).kind;
|
|
1974
|
+
const toolName = title || toolCallId || "unknown";
|
|
1975
|
+
cb?.(`\n[tool_call: ${toolName}]\n`, updateType, { title, toolCallId, kind });
|
|
1452
1976
|
} else if (updateType === "tool_call_update") {
|
|
1453
1977
|
const status = (update as { status?: string }).status;
|
|
1978
|
+
// Extract tool output content from completed tool calls
|
|
1979
|
+
const contentBlocks = (update as { content?: Array<{ type?: string; text?: string; content?: Array<{ type?: string; text?: string }> }> }).content;
|
|
1980
|
+
let toolOutput = "";
|
|
1981
|
+
if (contentBlocks) {
|
|
1982
|
+
for (const block of contentBlocks) {
|
|
1983
|
+
if (block.type === "content" && Array.isArray(block.content)) {
|
|
1984
|
+
// Nested content block (e.g. from tool call content wrapper)
|
|
1985
|
+
for (const inner of block.content) {
|
|
1986
|
+
if (inner.type === "text" && inner.text) toolOutput += inner.text;
|
|
1987
|
+
}
|
|
1988
|
+
} else if (block.type === "text" && block.text) {
|
|
1989
|
+
toolOutput += block.text;
|
|
1990
|
+
} else if (typeof block.text === "string") {
|
|
1991
|
+
toolOutput += block.text;
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
// Strip Codex terminal output metadata prefix
|
|
1996
|
+
toolOutput = stripCodexTerminalMeta(toolOutput);
|
|
1454
1997
|
if (status === "completed" || status === "error") {
|
|
1455
|
-
cb?.(
|
|
1998
|
+
cb?.(toolOutput, "tool_result");
|
|
1456
1999
|
}
|
|
2000
|
+
} else if (updateType === "plan") {
|
|
2001
|
+
// Forward execution plan with structured data
|
|
2002
|
+
const entries = (update as { entries?: Array<{ content?: string; status?: string; priority?: string }> }).entries;
|
|
2003
|
+
if (entries && entries.length > 0) {
|
|
2004
|
+
const planText = entries.map((e) => `[${e.status ?? "pending"}] ${e.content ?? ""}`).join("\n");
|
|
2005
|
+
cb?.(`\n[plan]\n${planText}\n`, "plan", entries);
|
|
2006
|
+
}
|
|
2007
|
+
} else if (updateType === "current_mode_update") {
|
|
2008
|
+
const modeId = (update as { modeId?: string }).modeId;
|
|
2009
|
+
if (modeId) cb?.("", "current_mode_update", { modeId });
|
|
2010
|
+
} else if (updateType === "session_info_update") {
|
|
2011
|
+
const title = (update as { title?: string }).title;
|
|
2012
|
+
if (title) {
|
|
2013
|
+
cb?.("", "session_info_update", { title });
|
|
2014
|
+
// Broadcast title update so observers can refresh session list
|
|
2015
|
+
const acpSid = params.sessionId;
|
|
2016
|
+
for (const s of this.sessions.values()) {
|
|
2017
|
+
if (s.kind === "acp" && s.acpSessionId === acpSid) {
|
|
2018
|
+
this.broadcastSessionNotify(s.sessionId, "updated", title, new Date().toISOString(), s.agent);
|
|
2019
|
+
break;
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
} else if (updateType === "available_commands_update") {
|
|
2024
|
+
const commands = (update as { commands?: Array<{ name?: string; description?: string; input?: { hint?: string } }> }).commands;
|
|
2025
|
+
if (commands) cb?.("", "available_commands", commands);
|
|
2026
|
+
} else if (updateType === "config_option_update") {
|
|
2027
|
+
const options = (update as { configOptions?: unknown[] }).configOptions;
|
|
2028
|
+
if (options) cb?.("", "config_options", options);
|
|
2029
|
+
} else if (updateType === "usage_update") {
|
|
2030
|
+
// Forward token usage stats
|
|
2031
|
+
const { inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens } = update as Record<string, unknown>;
|
|
2032
|
+
cb?.("", "usage", { inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens });
|
|
1457
2033
|
}
|
|
1458
2034
|
},
|
|
1459
2035
|
}),
|
|
@@ -1468,15 +2044,29 @@ export class AcpProxy {
|
|
|
1468
2044
|
});
|
|
1469
2045
|
|
|
1470
2046
|
try {
|
|
1471
|
-
await Promise.race([
|
|
2047
|
+
const initResponse = await Promise.race([
|
|
1472
2048
|
conn.initialize({
|
|
1473
2049
|
protocolVersion: 1,
|
|
1474
2050
|
clientInfo: { name: "ClawMatrix", version: "0.1.0" },
|
|
1475
|
-
clientCapabilities: {
|
|
2051
|
+
clientCapabilities: {
|
|
2052
|
+
prompt: { image: true },
|
|
2053
|
+
},
|
|
1476
2054
|
}),
|
|
1477
2055
|
initTimeout,
|
|
1478
2056
|
earlyExitPromise as Promise<never>,
|
|
1479
2057
|
]);
|
|
2058
|
+
|
|
2059
|
+
// Handle agents that require authentication (e.g. Codex with ChatGPT login)
|
|
2060
|
+
const authMethods = (initResponse as Record<string, unknown>)?.authMethods as Array<{ id: string }> | undefined;
|
|
2061
|
+
if (authMethods && authMethods.length > 0) {
|
|
2062
|
+
// Auto-authenticate with the first available method (typically OAuth/API key already in env)
|
|
2063
|
+
try {
|
|
2064
|
+
await conn.authenticate({ method: authMethods[0]!.id });
|
|
2065
|
+
debug("acp", `[${agent}] authenticated with method "${authMethods[0]!.id}"`);
|
|
2066
|
+
} catch (authErr) {
|
|
2067
|
+
debug("acp", `[${agent}] authentication failed: ${errorMessage(authErr)} (continuing without auth)`);
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
1480
2070
|
} catch (err) {
|
|
1481
2071
|
if (!earlyExit) proc.kill();
|
|
1482
2072
|
throw err;
|
|
@@ -1496,9 +2086,9 @@ export class AcpProxy {
|
|
|
1496
2086
|
cwd: string,
|
|
1497
2087
|
from: string,
|
|
1498
2088
|
label: string,
|
|
1499
|
-
sessionFactory: (conn: ClientSideConnection, cwd: string) => Promise<{ sessionId: string; modes?: { availableModes?: Array<{ id: string; name: string; description?: string }>; currentModeId?: string } }>,
|
|
2089
|
+
sessionFactory: (conn: ClientSideConnection, cwd: string) => Promise<{ sessionId: string; modes?: { availableModes?: Array<{ id: string; name: string; description?: string }>; currentModeId?: string }; configOptions?: AcpConfigOption[] }>,
|
|
1500
2090
|
): Promise<AcpSession> {
|
|
1501
|
-
let streamCallback: ((delta: string, event?: string) => void) | null = null;
|
|
2091
|
+
let streamCallback: ((delta: string, event?: string, data?: unknown) => void) | null = null;
|
|
1502
2092
|
|
|
1503
2093
|
const { conn, proc } = await this.spawnAndInit(agent, cwd, label, () => streamCallback);
|
|
1504
2094
|
|
|
@@ -1524,10 +2114,11 @@ export class AcpProxy {
|
|
|
1524
2114
|
proc,
|
|
1525
2115
|
lastActiveAt: Date.now(),
|
|
1526
2116
|
from,
|
|
2117
|
+
prompting: false,
|
|
1527
2118
|
setStreamCallback: (cb) => { streamCallback = cb; },
|
|
1528
2119
|
availableModes,
|
|
1529
2120
|
currentModeId: response.modes?.currentModeId,
|
|
1530
|
-
|
|
2121
|
+
configOptions: response.configOptions,
|
|
1531
2122
|
};
|
|
1532
2123
|
|
|
1533
2124
|
this.monitorProcess(session);
|
|
@@ -1558,20 +2149,30 @@ export class AcpProxy {
|
|
|
1558
2149
|
|
|
1559
2150
|
private async runPrompt(requestId: string, from: string, session: AcpSession, task: string, images?: import("./types.ts").ImageContent[]): Promise<void> {
|
|
1560
2151
|
debug("acp", `runPrompt: reqId=${requestId} session=${session.sessionId} agent=${session.agent} task=${task.slice(0, 80)}...`);
|
|
2152
|
+
session.prompting = true;
|
|
1561
2153
|
const promptStartedAt = Date.now();
|
|
1562
2154
|
|
|
1563
|
-
// Broadcast task started to mobile nodes
|
|
2155
|
+
// Broadcast task started to mobile nodes + session notify to all peers
|
|
1564
2156
|
this.taskActivity.broadcast(requestId, "acp", "started", session.agent, promptStartedAt, task.slice(0, 100));
|
|
2157
|
+
this.broadcastSessionNotify(session.sessionId, "created", undefined, new Date().toISOString(), session.agent);
|
|
1565
2158
|
|
|
1566
2159
|
// Wire streaming to send acp_stream frames (to requester + all session watchers)
|
|
1567
|
-
session.
|
|
2160
|
+
const watchers = this.sessionWatchers.get(session.sessionId);
|
|
2161
|
+
debug("acp", `runPrompt: wiring stream callback. sessionId=${session.sessionId.slice(0, 8)} from=${from} watchers=[${watchers ? [...watchers].join(",") : "none"}]`);
|
|
2162
|
+
let streamChunkCount = 0;
|
|
2163
|
+
session.setStreamCallback((delta, event, data) => {
|
|
2164
|
+
streamChunkCount++;
|
|
2165
|
+
if (streamChunkCount <= 3 || streamChunkCount % 50 === 0) {
|
|
2166
|
+
debug("acp", `stream chunk #${streamChunkCount}: event=${event ?? "text"} delta=${delta.slice(0, 40)} sessionId=${session.sessionId.slice(0, 8)}`);
|
|
2167
|
+
}
|
|
2168
|
+
session.lastActiveAt = Date.now();
|
|
1568
2169
|
const streamFrame: AcpStreamChunk = {
|
|
1569
2170
|
type: "acp_stream",
|
|
1570
2171
|
id: requestId,
|
|
1571
2172
|
from: this.config.nodeId,
|
|
1572
2173
|
to: from,
|
|
1573
2174
|
timestamp: Date.now(),
|
|
1574
|
-
payload: { delta, event, done: false, sessionId: session.sessionId },
|
|
2175
|
+
payload: { delta, event, done: false, sessionId: session.sessionId, data },
|
|
1575
2176
|
};
|
|
1576
2177
|
this.peerManager.sendTo(from, streamFrame);
|
|
1577
2178
|
this.sendToOtherWatchers(session.sessionId, from, streamFrame);
|
|
@@ -1603,25 +2204,33 @@ export class AcpProxy {
|
|
|
1603
2204
|
promptParts.push({ type: "image", data: img.data, mimeType: img.mediaType });
|
|
1604
2205
|
}
|
|
1605
2206
|
}
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
2207
|
+
// Race prompt against connection close to detect unexpected agent crashes.
|
|
2208
|
+
// Suppress the rejection on the losing branch to avoid unhandled promise rejection.
|
|
2209
|
+
let cancelConnWatch: (() => void) | undefined;
|
|
2210
|
+
const connClosed = new Promise<never>((_, reject) => {
|
|
2211
|
+
const onClose = () => reject(new Error("ACP agent connection closed unexpectedly during prompt"));
|
|
2212
|
+
session.conn.closed.then(onClose, onClose);
|
|
2213
|
+
cancelConnWatch = () => {
|
|
2214
|
+
// Swallow the eventual rejection so it doesn't become unhandled
|
|
2215
|
+
session.conn.closed.catch(() => {});
|
|
2216
|
+
};
|
|
1609
2217
|
});
|
|
2218
|
+
connClosed.catch(() => {}); // prevent unhandled rejection on the race loser
|
|
2219
|
+
const promptResponse = await Promise.race([
|
|
2220
|
+
session.conn.prompt({
|
|
2221
|
+
sessionId: session.acpSessionId,
|
|
2222
|
+
prompt: promptParts as any,
|
|
2223
|
+
}),
|
|
2224
|
+
connClosed,
|
|
2225
|
+
]);
|
|
2226
|
+
cancelConnWatch?.();
|
|
1610
2227
|
|
|
1611
|
-
// Send done marker to
|
|
1612
|
-
|
|
1613
|
-
type: "acp_stream",
|
|
1614
|
-
id: requestId,
|
|
1615
|
-
from: this.config.nodeId,
|
|
1616
|
-
to: from,
|
|
1617
|
-
timestamp: Date.now(),
|
|
1618
|
-
payload: { delta: "", done: true, sessionId: session.sessionId },
|
|
1619
|
-
};
|
|
1620
|
-
this.peerManager.sendTo(from, doneFrame);
|
|
1621
|
-
this.sendToOtherWatchers(session.sessionId, from, doneFrame);
|
|
2228
|
+
// Send done marker BEFORE response to guarantee ordering for clients
|
|
2229
|
+
this.sendDoneMarker(requestId, from, session.sessionId);
|
|
1622
2230
|
|
|
2231
|
+
const isCancelled = promptResponse.stopReason === "cancelled";
|
|
1623
2232
|
const responsePayload: AcpTaskResponse["payload"] = {
|
|
1624
|
-
success:
|
|
2233
|
+
success: !isCancelled,
|
|
1625
2234
|
nodeId: this.config.nodeId,
|
|
1626
2235
|
agent: session.agent,
|
|
1627
2236
|
sessionId: this.sessions.has(session.sessionId) ? session.sessionId : undefined,
|
|
@@ -1631,9 +2240,17 @@ export class AcpProxy {
|
|
|
1631
2240
|
this.sendResponse(requestId, from, responsePayload);
|
|
1632
2241
|
this.sendResponseToOtherWatchers(requestId, session.sessionId, from, responsePayload);
|
|
1633
2242
|
|
|
1634
|
-
// Broadcast task completed
|
|
1635
|
-
this.taskActivity.broadcast(
|
|
2243
|
+
// Broadcast task completed (or cancelled)
|
|
2244
|
+
this.taskActivity.broadcast(
|
|
2245
|
+
requestId, "acp", isCancelled ? "failed" : "completed", session.agent, promptStartedAt,
|
|
2246
|
+
isCancelled ? "已取消" : undefined,
|
|
2247
|
+
);
|
|
2248
|
+
if (!isCancelled) {
|
|
2249
|
+
this.broadcastSessionNotify(session.sessionId, "completed", undefined, new Date().toISOString(), session.agent);
|
|
2250
|
+
}
|
|
1636
2251
|
} catch (err) {
|
|
2252
|
+
// Always send done marker on error so clients don't hang
|
|
2253
|
+
this.sendDoneMarker(requestId, from, session.sessionId);
|
|
1637
2254
|
// Broadcast task failed
|
|
1638
2255
|
this.taskActivity.broadcast(
|
|
1639
2256
|
requestId, "acp", "failed", session.agent, promptStartedAt,
|
|
@@ -1641,6 +2258,7 @@ export class AcpProxy {
|
|
|
1641
2258
|
);
|
|
1642
2259
|
throw err;
|
|
1643
2260
|
} finally {
|
|
2261
|
+
session.prompting = false;
|
|
1644
2262
|
session.setStreamCallback(null);
|
|
1645
2263
|
}
|
|
1646
2264
|
}
|
|
@@ -1650,8 +2268,10 @@ export class AcpProxy {
|
|
|
1650
2268
|
if (!this.gatewayInfo) throw new Error("Gateway info not available for native session");
|
|
1651
2269
|
|
|
1652
2270
|
debug("acp", `runNativePrompt: reqId=${requestId} session=${session.sessionId} key=${session.sessionKey} task=${task.slice(0, 80)}...`);
|
|
2271
|
+
session.prompting = true;
|
|
1653
2272
|
const promptStartedAt = Date.now();
|
|
1654
2273
|
this.taskActivity.broadcast(requestId, "acp", "started", session.agent, promptStartedAt, task.slice(0, 100));
|
|
2274
|
+
this.broadcastSessionNotify(session.sessionId, "created", undefined, new Date().toISOString(), session.agent);
|
|
1655
2275
|
|
|
1656
2276
|
const { port, authHeader } = this.gatewayInfo;
|
|
1657
2277
|
const abortController = new AbortController();
|
|
@@ -1687,19 +2307,10 @@ export class AcpProxy {
|
|
|
1687
2307
|
}
|
|
1688
2308
|
|
|
1689
2309
|
// Stream SSE response as acp_stream frames (to requester + watchers)
|
|
2310
|
+
session.lastActiveAt = Date.now();
|
|
1690
2311
|
const result = await this.streamGatewaySSE(res, requestId, from, session.sessionId);
|
|
1691
2312
|
|
|
1692
|
-
|
|
1693
|
-
const doneFrame: AcpStreamChunk = {
|
|
1694
|
-
type: "acp_stream",
|
|
1695
|
-
id: requestId,
|
|
1696
|
-
from: this.config.nodeId,
|
|
1697
|
-
to: from,
|
|
1698
|
-
timestamp: Date.now(),
|
|
1699
|
-
payload: { delta: "", done: true, sessionId: session.sessionId },
|
|
1700
|
-
};
|
|
1701
|
-
this.peerManager.sendTo(from, doneFrame);
|
|
1702
|
-
this.sendToOtherWatchers(session.sessionId, from, doneFrame);
|
|
2313
|
+
this.sendDoneMarker(requestId, from, session.sessionId);
|
|
1703
2314
|
|
|
1704
2315
|
const responsePayload: AcpTaskResponse["payload"] = {
|
|
1705
2316
|
success: true,
|
|
@@ -1711,13 +2322,17 @@ export class AcpProxy {
|
|
|
1711
2322
|
this.sendResponse(requestId, from, responsePayload);
|
|
1712
2323
|
this.sendResponseToOtherWatchers(requestId, session.sessionId, from, responsePayload);
|
|
1713
2324
|
this.taskActivity.broadcast(requestId, "acp", "completed", session.agent, promptStartedAt);
|
|
2325
|
+
this.broadcastSessionNotify(session.sessionId, "completed", undefined, new Date().toISOString(), session.agent);
|
|
1714
2326
|
} catch (err) {
|
|
2327
|
+
// Always send done marker on error so clients don't hang
|
|
2328
|
+
this.sendDoneMarker(requestId, from, session.sessionId);
|
|
1715
2329
|
this.taskActivity.broadcast(
|
|
1716
2330
|
requestId, "acp", "failed", session.agent, promptStartedAt,
|
|
1717
2331
|
errorMessage(err),
|
|
1718
2332
|
);
|
|
1719
2333
|
throw err;
|
|
1720
2334
|
} finally {
|
|
2335
|
+
session.prompting = false;
|
|
1721
2336
|
session.abortController = null;
|
|
1722
2337
|
}
|
|
1723
2338
|
}
|
|
@@ -1787,7 +2402,7 @@ export class AcpProxy {
|
|
|
1787
2402
|
try {
|
|
1788
2403
|
const loadResp = await conn.loadSession({ sessionId: acpSessionId, cwd: effectiveCwd, mcpServers: [] });
|
|
1789
2404
|
debug("acp", `loadSession succeeded for ${agent} (acpSessionId=${acpSessionId.slice(0, 8)}...)`);
|
|
1790
|
-
return { sessionId: acpSessionId, modes: loadResp.modes };
|
|
2405
|
+
return { sessionId: acpSessionId, modes: loadResp.modes, configOptions: (loadResp as Record<string, unknown>).configOptions as AcpConfigOption[] | undefined };
|
|
1791
2406
|
} catch (loadErr) {
|
|
1792
2407
|
debug("acp", `loadSession failed for ${agent}: ${errorMessage(loadErr)}, trying session/resume`);
|
|
1793
2408
|
}
|
|
@@ -1805,6 +2420,9 @@ export class AcpProxy {
|
|
|
1805
2420
|
|
|
1806
2421
|
private destroySession(session: AnySession) {
|
|
1807
2422
|
invalidateSessionListCache();
|
|
2423
|
+
// Clean up transcript watcher and session watchers
|
|
2424
|
+
this.stopTranscriptWatcher(session.sessionId);
|
|
2425
|
+
this.sessionWatchers.delete(session.sessionId);
|
|
1808
2426
|
if (session.kind === "acp") {
|
|
1809
2427
|
// Clean up daemon stream callback
|
|
1810
2428
|
const daemon = this.findDaemonByConn(session);
|
|
@@ -2017,6 +2635,20 @@ export class AcpProxy {
|
|
|
2017
2635
|
});
|
|
2018
2636
|
}
|
|
2019
2637
|
|
|
2638
|
+
/** Send a done marker to requester + all session watchers. */
|
|
2639
|
+
private sendDoneMarker(requestId: string, to: string, sessionId: string) {
|
|
2640
|
+
const doneFrame: AcpStreamChunk = {
|
|
2641
|
+
type: "acp_stream",
|
|
2642
|
+
id: requestId,
|
|
2643
|
+
from: this.config.nodeId,
|
|
2644
|
+
to,
|
|
2645
|
+
timestamp: Date.now(),
|
|
2646
|
+
payload: { delta: "", done: true, sessionId },
|
|
2647
|
+
};
|
|
2648
|
+
this.peerManager.sendTo(to, doneFrame);
|
|
2649
|
+
this.sendToOtherWatchers(sessionId, to, doneFrame);
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2020
2652
|
private sendResponse(id: string, to: string, payload: AcpTaskResponse["payload"]) {
|
|
2021
2653
|
debug("acp", `sendResponse: id=${id} to=${to} success=${payload.success} error=${payload.error ?? "(none)"}`);
|
|
2022
2654
|
const frame = {
|
|
@@ -2070,6 +2702,9 @@ export class AcpProxy {
|
|
|
2070
2702
|
}
|
|
2071
2703
|
|
|
2072
2704
|
destroy() {
|
|
2705
|
+
// Set disposed first to prevent new timers/prewarms from being scheduled during teardown
|
|
2706
|
+
this.disposed = true;
|
|
2707
|
+
|
|
2073
2708
|
if (this.cleanupTimer) {
|
|
2074
2709
|
clearInterval(this.cleanupTimer);
|
|
2075
2710
|
this.cleanupTimer = null;
|
|
@@ -2090,8 +2725,9 @@ export class AcpProxy {
|
|
|
2090
2725
|
}
|
|
2091
2726
|
this.sessions.clear();
|
|
2092
2727
|
this.sessionWatchers.clear();
|
|
2093
|
-
|
|
2094
|
-
this.
|
|
2728
|
+
// Stop all transcript watchers
|
|
2729
|
+
for (const [sid] of this.transcriptWatchers) this.stopTranscriptWatcher(sid);
|
|
2730
|
+
this.transcriptWatchers.clear();
|
|
2095
2731
|
|
|
2096
2732
|
// Cancel pending retry timers
|
|
2097
2733
|
for (const timer of this.retryTimers) clearTimeout(timer);
|
|
@@ -2124,6 +2760,7 @@ export class AcpProxy {
|
|
|
2124
2760
|
}
|
|
2125
2761
|
this.daemons.clear();
|
|
2126
2762
|
this.daemonStarting.clear();
|
|
2763
|
+
this.resumeInFlight.clear();
|
|
2127
2764
|
}
|
|
2128
2765
|
}
|
|
2129
2766
|
|
|
@@ -2225,22 +2862,29 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
|
|
|
2225
2862
|
const content = await readFileText(storePath);
|
|
2226
2863
|
const entries: Record<string, { sessionId?: string; updatedAt?: number; displayName?: string; subject?: string; label?: string; acp?: { agent?: string } }> = JSON.parse(content);
|
|
2227
2864
|
const agent = agentId;
|
|
2228
|
-
// Read all transcripts in parallel
|
|
2865
|
+
// Read all transcripts in parallel (first message + mtime)
|
|
2229
2866
|
const entryList = Object.entries(entries).filter(([, e]) => e.sessionId);
|
|
2230
2867
|
const transcriptResults = await Promise.all(
|
|
2231
|
-
entryList.map(([, entry]) => {
|
|
2868
|
+
entryList.map(async ([, entry]) => {
|
|
2232
2869
|
const transcriptPath = join(sessionsDir, `${entry.sessionId!}.jsonl`);
|
|
2233
|
-
|
|
2870
|
+
const [firstMsg, fileStat] = await Promise.all([
|
|
2871
|
+
readFirstUserMessageFromTranscript(transcriptPath),
|
|
2872
|
+
fsStat(transcriptPath).catch(() => null),
|
|
2873
|
+
]);
|
|
2874
|
+
return { firstMsg, mtimeMs: fileStat?.mtimeMs ?? null };
|
|
2234
2875
|
}),
|
|
2235
2876
|
);
|
|
2236
2877
|
for (let i = 0; i < entryList.length; i++) {
|
|
2237
2878
|
const [key, entry] = entryList[i]!;
|
|
2879
|
+
const { firstMsg, mtimeMs } = transcriptResults[i]!;
|
|
2880
|
+
const storeTs = entry.updatedAt ?? 0;
|
|
2881
|
+
const effectiveTs = mtimeMs && mtimeMs > storeTs ? mtimeMs : storeTs;
|
|
2238
2882
|
results.push({
|
|
2239
2883
|
sessionId: entry.sessionId!,
|
|
2240
2884
|
cwd: key,
|
|
2241
2885
|
title: entry.displayName ?? entry.subject ?? entry.label ?? undefined,
|
|
2242
|
-
description:
|
|
2243
|
-
updatedAt:
|
|
2886
|
+
description: firstMsg ?? undefined,
|
|
2887
|
+
updatedAt: effectiveTs ? new Date(effectiveTs).toISOString() : undefined,
|
|
2244
2888
|
agent,
|
|
2245
2889
|
});
|
|
2246
2890
|
}
|
|
@@ -2288,14 +2932,41 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
|
|
|
2288
2932
|
|
|
2289
2933
|
const ccResults: AcpSessionInfo[] = [];
|
|
2290
2934
|
const existingIds = new Set(results.map((r) => r.sessionId));
|
|
2935
|
+
// Stat transcript files in parallel to get more accurate mtime
|
|
2936
|
+
const claudeProjectsDir = join(homedir(), ".claude", "projects");
|
|
2937
|
+
let projectDirs: string[] = [];
|
|
2938
|
+
try { projectDirs = await readdir(claudeProjectsDir); } catch { /* no projects dir */ }
|
|
2939
|
+
|
|
2940
|
+
const pendingEntries: { sessionId: string; info: { title: string; updatedAt: number; cwd: string } }[] = [];
|
|
2291
2941
|
for (const [sessionId, info] of sessions) {
|
|
2292
2942
|
if (!info.title || info.title.startsWith("/")) continue;
|
|
2293
2943
|
if (existingIds.has(sessionId)) continue;
|
|
2944
|
+
pendingEntries.push({ sessionId, info });
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
// For each session, try to find its transcript file and stat it
|
|
2948
|
+
const mtimeResults = await Promise.all(
|
|
2949
|
+
pendingEntries.map(async ({ sessionId }) => {
|
|
2950
|
+
for (const dir of projectDirs) {
|
|
2951
|
+
try {
|
|
2952
|
+
const s = await fsStat(join(claudeProjectsDir, dir, `${sessionId}.jsonl`));
|
|
2953
|
+
return s.mtimeMs;
|
|
2954
|
+
} catch { /* not in this project dir */ }
|
|
2955
|
+
}
|
|
2956
|
+
return null;
|
|
2957
|
+
}),
|
|
2958
|
+
);
|
|
2959
|
+
|
|
2960
|
+
for (let i = 0; i < pendingEntries.length; i++) {
|
|
2961
|
+
const { sessionId, info } = pendingEntries[i]!;
|
|
2962
|
+
const mtime = mtimeResults[i];
|
|
2963
|
+
// Use the more recent of history timestamp and transcript file mtime
|
|
2964
|
+
const effectiveUpdatedAt = mtime && mtime > info.updatedAt ? mtime : info.updatedAt;
|
|
2294
2965
|
ccResults.push({
|
|
2295
2966
|
sessionId,
|
|
2296
2967
|
cwd: info.cwd,
|
|
2297
2968
|
title: info.title,
|
|
2298
|
-
updatedAt:
|
|
2969
|
+
updatedAt: effectiveUpdatedAt ? new Date(effectiveUpdatedAt).toISOString() : undefined,
|
|
2299
2970
|
agent: "claude",
|
|
2300
2971
|
});
|
|
2301
2972
|
}
|
|
@@ -2364,6 +3035,52 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
|
|
|
2364
3035
|
return results;
|
|
2365
3036
|
}
|
|
2366
3037
|
|
|
3038
|
+
// ── Physical session deletion ──────────────────────────────────────
|
|
3039
|
+
|
|
3040
|
+
/**
|
|
3041
|
+
* Delete a session's files from disk (only for agents we fully control).
|
|
3042
|
+
*
|
|
3043
|
+
* Claude Code and Codex have their own index files (history.jsonl, SQLite DB,
|
|
3044
|
+
* session_index.jsonl) that still reference sessions — deleting transcript
|
|
3045
|
+
* files without cleaning those indexes would break their own UI/CLI.
|
|
3046
|
+
* Since they don't expose a deleteSession API, we only physically delete
|
|
3047
|
+
* OpenClaw-managed session files where we control both the store and transcripts.
|
|
3048
|
+
*
|
|
3049
|
+
* For Claude Code and Codex, sessions are hidden via the in-memory
|
|
3050
|
+
* closedSessionIds set (which filters them from list responses).
|
|
3051
|
+
*/
|
|
3052
|
+
async function deleteSessionFromDisk(sessionId: string): Promise<void> {
|
|
3053
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR ?? join(homedir(), ".openclaw");
|
|
3054
|
+
|
|
3055
|
+
// OpenClaw agent session stores: remove entry from sessions.json + delete transcript
|
|
3056
|
+
const agentsDir = join(stateDir, "agents");
|
|
3057
|
+
try {
|
|
3058
|
+
const agentIds = await readdir(agentsDir);
|
|
3059
|
+
for (const agentId of agentIds) {
|
|
3060
|
+
const sessionsDir = join(agentsDir, agentId, "sessions");
|
|
3061
|
+
const storePath = join(sessionsDir, "sessions.json");
|
|
3062
|
+
try {
|
|
3063
|
+
const content = await readFileText(storePath);
|
|
3064
|
+
const entries: Record<string, Record<string, unknown>> = JSON.parse(content);
|
|
3065
|
+
let modified = false;
|
|
3066
|
+
for (const [key, entry] of Object.entries(entries)) {
|
|
3067
|
+
if ((entry as { sessionId?: string }).sessionId === sessionId) {
|
|
3068
|
+
delete entries[key];
|
|
3069
|
+
modified = true;
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
if (modified) {
|
|
3073
|
+
await fsWriteFile(storePath, JSON.stringify(entries, null, 2), "utf-8");
|
|
3074
|
+
}
|
|
3075
|
+
await unlink(join(sessionsDir, `${sessionId}.jsonl`)).catch(() => {});
|
|
3076
|
+
} catch { /* store unreadable */ }
|
|
3077
|
+
}
|
|
3078
|
+
} catch { /* no agents directory */ }
|
|
3079
|
+
|
|
3080
|
+
invalidateSessionListCache();
|
|
3081
|
+
debug("acp", `deleteSessionFromDisk: cleaned up OpenClaw files for ${sessionId.slice(0, 8)}`);
|
|
3082
|
+
}
|
|
3083
|
+
|
|
2367
3084
|
// ── Transcript normalization ───────────────────────────────────────
|
|
2368
3085
|
// Mirrors the OpenClaw web dashboard pipeline:
|
|
2369
3086
|
// Server: readSessionMessages → stripEnvelopeFromMessages → sanitizeChatHistoryMessages
|
|
@@ -2536,6 +3253,12 @@ function stripThinkingTags(text: string): string {
|
|
|
2536
3253
|
.trim();
|
|
2537
3254
|
}
|
|
2538
3255
|
|
|
3256
|
+
/** Strip Codex terminal output metadata (Chunk ID, Wall time, etc.) from tool results. */
|
|
3257
|
+
const CODEX_TERMINAL_META_RE = /^Chunk ID: [^\n]*\nWall time: [^\n]*\nProcess exited with code \d+\n(?:Original token count: [^\n]*\n)?Output:\n/;
|
|
3258
|
+
function stripCodexTerminalMeta(text: string): string {
|
|
3259
|
+
return text.replace(CODEX_TERMINAL_META_RE, "");
|
|
3260
|
+
}
|
|
3261
|
+
|
|
2539
3262
|
/** Check if text is a silent reply (NO_REPLY). */
|
|
2540
3263
|
function isSilentReply(text: string): boolean {
|
|
2541
3264
|
return /^\s*NO_REPLY\s*$/.test(text);
|
|
@@ -2614,7 +3337,8 @@ function normalizeTranscriptMessages(lines: string[], limit: number): ChatHistor
|
|
|
2614
3337
|
: typeof msg.toolName === "string" ? msg.toolName
|
|
2615
3338
|
: typeof msg.tool_name === "string" ? msg.tool_name
|
|
2616
3339
|
: inlineResults[0]?.name;
|
|
2617
|
-
const
|
|
3340
|
+
const rawResultText = inlineResults.length > 0 ? inlineResults.map((r) => r.text).join("\n") : text;
|
|
3341
|
+
const resultText = stripCodexTerminalMeta(rawResultText);
|
|
2618
3342
|
|
|
2619
3343
|
if (raw.length > 0) {
|
|
2620
3344
|
const prev = raw[raw.length - 1]!;
|