clawmatrix 0.4.1 → 0.5.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/src/acp-proxy.ts CHANGED
@@ -44,6 +44,7 @@ import type {
44
44
  AcpUnsubscribeRequest,
45
45
  AcpSessionNotify,
46
46
  } from "./types.ts";
47
+ import { nanoid } from "nanoid";
47
48
  import { TaskActivityBroadcaster } from "./task-activity.ts";
48
49
 
49
50
  /** Extract a human-readable message from any thrown value (Error, JSON-RPC error object, etc.) */
@@ -146,7 +147,8 @@ export class AcpProxy {
146
147
  private maxSessions: number;
147
148
  private permissionMode: AcpPermissionMode;
148
149
  private taskActivity: TaskActivityBroadcaster;
149
-
150
+ /** Callback for file write attribution (set by ClusterRuntime). */
151
+ onToolWrite: ((filePath: string, agentId: string) => void) | null = null;
150
152
  // Receiver: active sessions (ACP or native OpenClaw)
151
153
  private sessions = new Map<string, AnySession>();
152
154
  // Requester: pending requests
@@ -176,8 +178,11 @@ export class AcpProxy {
176
178
  private daemonStarting = new Map<string, Promise<AgentDaemon>>();
177
179
  // In-flight resume operations: prevents concurrent resumes for same acpSessionId
178
180
  private resumeInFlight = new Map<string, Promise<AcpSession>>();
179
- // Sessions explicitly closed by users — excluded from disk session list
181
+ // Sessions explicitly closed by users — excluded from disk session list.
182
+ // Falls back to in-memory Set when Store is not available.
180
183
  private closedSessionIds = new Set<string>();
184
+ /** Optional SQLite store for persistent closedSessionIds (survives restarts). */
185
+ store: import("./store.ts").Store | null = null;
181
186
 
182
187
  constructor(config: ClawMatrixConfig, peerManager: PeerManager, openclawConfig?: Record<string, unknown>, gatewayInfo?: GatewayInfo) {
183
188
  this.config = config;
@@ -537,7 +542,7 @@ export class AcpProxy {
537
542
  targetNodeId: string,
538
543
  sessionId: string,
539
544
  ): Promise<AcpCloseResponse["payload"]> {
540
- const id = crypto.randomUUID();
545
+ const id = nanoid();
541
546
  return new Promise((resolve, reject) => {
542
547
  const timer = setTimeout(() => {
543
548
  this.pending.delete(id);
@@ -577,7 +582,7 @@ export class AcpProxy {
577
582
  targetNodeId: string,
578
583
  options?: { agent?: string; cwd?: string },
579
584
  ): Promise<AcpListResponse["payload"]> {
580
- const id = crypto.randomUUID();
585
+ const id = nanoid();
581
586
  return new Promise((resolve, reject) => {
582
587
  const timer = setTimeout(() => {
583
588
  this.pending.delete(id);
@@ -619,7 +624,7 @@ export class AcpProxy {
619
624
  acpSessionId: string,
620
625
  cwd: string,
621
626
  ): Promise<AcpResumeResponse["payload"]> {
622
- const id = crypto.randomUUID();
627
+ const id = nanoid();
623
628
  return new Promise((resolve, reject) => {
624
629
  const timer = setTimeout(() => {
625
630
  this.pending.delete(id);
@@ -659,7 +664,7 @@ export class AcpProxy {
659
664
  targetNodeId: string,
660
665
  sessionId: string,
661
666
  ): Promise<AcpCancelResponse["payload"]> {
662
- const id = crypto.randomUUID();
667
+ const id = nanoid();
663
668
  return new Promise((resolve, reject) => {
664
669
  const timer = setTimeout(() => {
665
670
  this.pending.delete(id);
@@ -697,7 +702,7 @@ export class AcpProxy {
697
702
  sessionId: string,
698
703
  modeId: string,
699
704
  ): Promise<AcpSetModeResponse["payload"]> {
700
- const id = crypto.randomUUID();
705
+ const id = nanoid();
701
706
  return new Promise((resolve, reject) => {
702
707
  const timer = setTimeout(() => {
703
708
  this.pending.delete(id);
@@ -734,7 +739,7 @@ export class AcpProxy {
734
739
  targetNodeId: string,
735
740
  sessionId: string,
736
741
  ): Promise<AcpGetModesResponse["payload"]> {
737
- const id = crypto.randomUUID();
742
+ const id = nanoid();
738
743
  return new Promise((resolve, reject) => {
739
744
  const timer = setTimeout(() => {
740
745
  this.pending.delete(id);
@@ -933,7 +938,7 @@ export class AcpProxy {
933
938
  // Re-create native session
934
939
  debug("acp", `Native session "${resolvedSessionId}" not found, re-creating with key from cwd`);
935
940
  const sessionKey = cwd ?? resolvedSessionId;
936
- const newId = `native-${crypto.randomUUID()}`;
941
+ const newId = `native-${nanoid()}`;
937
942
  const nativeSession: NativeSession = {
938
943
  kind: "native", sessionId: newId, agent, sessionKey,
939
944
  lastActiveAt: Date.now(), from, prompting: false, abortController: null,
@@ -959,13 +964,16 @@ export class AcpProxy {
959
964
  await this.runPrompt(id, from, session, task, images);
960
965
  }
961
966
  } else {
962
- // Enforce concurrent session limit
967
+ // Enforce concurrent session limit — evict oldest idle session if at capacity
963
968
  if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions && mode === "persistent") {
964
- this.sendResponse(id, from, {
965
- success: false,
966
- error: `Max concurrent sessions reached (${this.maxSessions})`,
967
- });
968
- return;
969
+ const evicted = this.evictOldestIdleSession();
970
+ if (!evicted) {
971
+ this.sendResponse(id, from, {
972
+ success: false,
973
+ error: `Max concurrent sessions reached (${this.maxSessions})`,
974
+ });
975
+ return;
976
+ }
969
977
  }
970
978
 
971
979
  if (this.isAcpAgent(agent)) {
@@ -1010,8 +1018,8 @@ export class AcpProxy {
1010
1018
  this.sendResponse(id, from, { success: false, error: "Gateway info not available for native sessions" });
1011
1019
  return;
1012
1020
  }
1013
- const sessionKey = cwd ?? `agent:main:clawmatrix:${crypto.randomUUID()}`;
1014
- const newId = `native-${crypto.randomUUID()}`;
1021
+ const sessionKey = cwd ?? `agent:main:clawmatrix:${nanoid()}`;
1022
+ const newId = `native-${nanoid()}`;
1015
1023
  const nativeSession: NativeSession = {
1016
1024
  kind: "native", sessionId: newId, agent, sessionKey,
1017
1025
  lastActiveAt: Date.now(), from, prompting: false, abortController: null,
@@ -1076,8 +1084,10 @@ export class AcpProxy {
1076
1084
 
1077
1085
  // Mark session as closed so it won't reappear from disk scan
1078
1086
  const acpSid = session.kind === "acp" ? session.acpSessionId : undefined;
1079
- if (acpSid) this.closedSessionIds.add(acpSid);
1087
+ const now = Date.now();
1088
+ if (acpSid) { this.closedSessionIds.add(acpSid); this.store?.insertClosedSession(acpSid, now); }
1080
1089
  this.closedSessionIds.add(resolvedId);
1090
+ this.store?.insertClosedSession(resolvedId, now);
1081
1091
 
1082
1092
  // Physical deletion: only OpenClaw sessions can be safely deleted from disk.
1083
1093
  // Claude Code and Codex don't expose a deleteSession API, and their index files
@@ -1137,7 +1147,7 @@ export class AcpProxy {
1137
1147
  if (payload.agent && s.agent !== payload.agent) return false;
1138
1148
  if (clawSessions.some((cs) => cs.sessionId === s.sessionId)) return false;
1139
1149
  if (activeAcpSessionIds.has(s.sessionId)) return false;
1140
- if (this.closedSessionIds.has(s.sessionId)) return false;
1150
+ if (this.closedSessionIds.has(s.sessionId) || this.store?.isSessionClosed(s.sessionId)) return false;
1141
1151
  return true;
1142
1152
  })
1143
1153
  .map((s) => ({ ...s, status: "idle" as const }));
@@ -1185,14 +1195,17 @@ export class AcpProxy {
1185
1195
  }
1186
1196
  }
1187
1197
 
1188
- // Enforce concurrent session limit
1198
+ // Enforce concurrent session limit — evict oldest idle session if at capacity
1189
1199
  if (this.maxSessions > 0 && this.sessions.size >= this.maxSessions) {
1190
- this.peerManager.sendTo(from, {
1191
- type: "acp_resume_res", id, from: this.config.nodeId, to: from,
1192
- timestamp: Date.now(),
1193
- payload: { success: false, error: `Max concurrent sessions reached (${this.maxSessions})` },
1194
- } satisfies AcpResumeResponse);
1195
- return;
1200
+ const evicted = this.evictOldestIdleSession();
1201
+ if (!evicted) {
1202
+ this.peerManager.sendTo(from, {
1203
+ type: "acp_resume_res", id, from: this.config.nodeId, to: from,
1204
+ timestamp: Date.now(),
1205
+ payload: { success: false, error: `Max concurrent sessions reached (${this.maxSessions})` },
1206
+ } satisfies AcpResumeResponse);
1207
+ return;
1208
+ }
1196
1209
  }
1197
1210
 
1198
1211
  if (this.isAcpAgent(agent)) {
@@ -1445,7 +1458,7 @@ export class AcpProxy {
1445
1458
  configId: string,
1446
1459
  value: string | boolean,
1447
1460
  ): Promise<AcpSetConfigResponse["payload"]> {
1448
- const id = crypto.randomUUID();
1461
+ const id = nanoid();
1449
1462
  return new Promise((resolve, reject) => {
1450
1463
  const timer = setTimeout(() => {
1451
1464
  this.pending.delete(id);
@@ -1484,6 +1497,24 @@ export class AcpProxy {
1484
1497
  const { id, from, payload } = frame;
1485
1498
  const { sessionId, limit = 200 } = payload;
1486
1499
 
1500
+ // Register requester as session watcher so they receive real-time transcript updates.
1501
+ // Resolve to ClawMatrix session ID for watcher registration (same logic as subscribe).
1502
+ let watcherKey = sessionId;
1503
+ let acpSidForWatch = sessionId;
1504
+ if (this.sessions.has(sessionId)) {
1505
+ const s = this.sessions.get(sessionId)!;
1506
+ acpSidForWatch = s.kind === "acp" ? s.acpSessionId : sessionId;
1507
+ } else {
1508
+ for (const [sid, s] of this.sessions) {
1509
+ if (s.kind === "acp" && s.acpSessionId === sessionId) {
1510
+ watcherKey = sid;
1511
+ break;
1512
+ }
1513
+ }
1514
+ }
1515
+ this.addSessionWatcher(watcherKey, from);
1516
+ this.startTranscriptWatcher(watcherKey, acpSidForWatch).catch(() => {});
1517
+
1487
1518
  try {
1488
1519
  const transcriptPath = await this.findTranscriptPath(sessionId);
1489
1520
  if (!transcriptPath) {
@@ -1529,7 +1560,7 @@ export class AcpProxy {
1529
1560
  sessionId: string,
1530
1561
  limit?: number,
1531
1562
  ): Promise<ChatHistoryResponse["payload"]> {
1532
- const id = crypto.randomUUID();
1563
+ const id = nanoid();
1533
1564
  return new Promise((resolve, reject) => {
1534
1565
  const timer = setTimeout(() => {
1535
1566
  this.pending.delete(id);
@@ -1752,7 +1783,7 @@ export class AcpProxy {
1752
1783
 
1753
1784
  const session: AcpSession = {
1754
1785
  kind: "acp",
1755
- sessionId: crypto.randomUUID(),
1786
+ sessionId: nanoid(),
1756
1787
  agent,
1757
1788
  acpSessionId: response.sessionId,
1758
1789
  conn: daemon.conn,
@@ -1878,7 +1909,7 @@ export class AcpProxy {
1878
1909
  if (reused) {
1879
1910
  reused.from = from;
1880
1911
  reused.lastActiveAt = Date.now();
1881
- reused.sessionId = crypto.randomUUID();
1912
+ reused.sessionId = nanoid();
1882
1913
  this.monitorProcess(reused);
1883
1914
  return reused;
1884
1915
  }
@@ -1888,7 +1919,7 @@ export class AcpProxy {
1888
1919
  if (warm) {
1889
1920
  warm.from = from;
1890
1921
  warm.lastActiveAt = Date.now();
1891
- warm.sessionId = crypto.randomUUID();
1922
+ warm.sessionId = nanoid();
1892
1923
  this.monitorProcess(warm);
1893
1924
  this.schedulePrewarm(agent, effectiveCwd);
1894
1925
  return warm;
@@ -1973,6 +2004,13 @@ export class AcpProxy {
1973
2004
  const kind = (update as { kind?: string }).kind;
1974
2005
  const toolName = title || toolCallId || "unknown";
1975
2006
  cb?.(`\n[tool_call: ${toolName}]\n`, updateType, { title, toolCallId, kind });
2007
+ // Track file write attribution for knowledge-sync
2008
+ if (title && this.onToolWrite) {
2009
+ const writeMatch = title.match(/^(?:Write|Edit|Create)\s+(.+)/i);
2010
+ if (writeMatch) {
2011
+ this.onToolWrite(writeMatch[1].trim(), params.agent);
2012
+ }
2013
+ }
1976
2014
  } else if (updateType === "tool_call_update") {
1977
2015
  const status = (update as { status?: string }).status;
1978
2016
  // Extract tool output content from completed tool calls
@@ -2107,7 +2145,7 @@ export class AcpProxy {
2107
2145
 
2108
2146
  const session: AcpSession = {
2109
2147
  kind: "acp",
2110
- sessionId: crypto.randomUUID(),
2148
+ sessionId: nanoid(),
2111
2149
  agent,
2112
2150
  acpSessionId: response.sessionId,
2113
2151
  conn,
@@ -2162,6 +2200,7 @@ export class AcpProxy {
2162
2200
  let streamChunkCount = 0;
2163
2201
  session.setStreamCallback((delta, event, data) => {
2164
2202
  streamChunkCount++;
2203
+
2165
2204
  if (streamChunkCount <= 3 || streamChunkCount % 50 === 0) {
2166
2205
  debug("acp", `stream chunk #${streamChunkCount}: event=${event ?? "text"} delta=${delta.slice(0, 40)} sessionId=${session.sessionId.slice(0, 8)}`);
2167
2206
  }
@@ -2598,7 +2637,7 @@ export class AcpProxy {
2598
2637
  onStream?: (delta: string) => void;
2599
2638
  },
2600
2639
  ): Promise<AcpTaskResponse["payload"]> {
2601
- const id = crypto.randomUUID();
2640
+ const id = nanoid();
2602
2641
  return new Promise((resolve, reject) => {
2603
2642
  const timer = setTimeout(() => {
2604
2643
  this.pending.delete(id);
@@ -2690,6 +2729,26 @@ export class AcpProxy {
2690
2729
  }
2691
2730
  }
2692
2731
 
2732
+ /**
2733
+ * Evict the oldest non-prompting session to make room for a new one.
2734
+ * Returns true if a session was evicted, false if all sessions are actively prompting.
2735
+ */
2736
+ private evictOldestIdleSession(): boolean {
2737
+ let oldest: { id: string; session: AnySession } | null = null;
2738
+ for (const [id, session] of this.sessions) {
2739
+ if (session.prompting) continue; // never evict mid-prompt sessions
2740
+ if (!oldest || session.lastActiveAt < oldest.session.lastActiveAt) {
2741
+ oldest = { id, session };
2742
+ }
2743
+ }
2744
+ if (!oldest) return false;
2745
+ debug("acp", `evicting idle session ${oldest.id.slice(0, 8)} (lastActive=${new Date(oldest.session.lastActiveAt).toISOString()}) to make room`);
2746
+ this.sessions.delete(oldest.id);
2747
+ this.sessionWatchers.delete(oldest.id);
2748
+ this.destroySession(oldest.session);
2749
+ return true;
2750
+ }
2751
+
2693
2752
  private cleanupIdleSessions() {
2694
2753
  const now = Date.now();
2695
2754
  for (const [id, session] of this.sessions) {
@@ -2834,14 +2893,13 @@ export async function readAllSessionStoresFromDisk(): Promise<AcpSessionInfo[]>
2834
2893
  // Fresh cache — return immediately
2835
2894
  return sessionListCache.result;
2836
2895
  }
2837
- if (now < sessionListCache.staleAt) {
2838
- // Stale but within grace period return stale data, refresh in background
2839
- refreshSessionListInBackground();
2840
- return sessionListCache.result;
2841
- }
2896
+ // Stale cache return stale data immediately, refresh in background
2897
+ // Never block on disk I/O when we have any cached data
2898
+ refreshSessionListInBackground();
2899
+ return sessionListCache.result;
2842
2900
  }
2843
2901
 
2844
- // Cache miss or expired — fetch from disk and cache
2902
+ // No cache at allmust fetch from disk synchronously
2845
2903
  const results = await fetchSessionListFromDisk();
2846
2904
  const ts = Date.now();
2847
2905
  sessionListCache = { result: results, expiresAt: ts + SESSION_LIST_CACHE_TTL, staleAt: ts + SESSION_LIST_STALE_TTL };
@@ -2935,10 +2993,25 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
2935
2993
 
2936
2994
  const ccResults: AcpSessionInfo[] = [];
2937
2995
  const existingIds = new Set(results.map((r) => r.sessionId));
2938
- // Stat transcript files in parallel to get more accurate mtime
2996
+ // Build a sessionId file path index from project dirs (one readdir per dir)
2997
+ // instead of O(sessions × dirs) stat calls
2939
2998
  const claudeProjectsDir = join(homedir(), ".claude", "projects");
2940
- let projectDirs: string[] = [];
2941
- try { projectDirs = await readdir(claudeProjectsDir); } catch { /* no projects dir */ }
2999
+ const sessionFileIndex = new Map<string, string>(); // sessionId → full path
3000
+ try {
3001
+ const projectDirs = await readdir(claudeProjectsDir);
3002
+ await Promise.all(
3003
+ projectDirs.map(async (dir) => {
3004
+ try {
3005
+ const files = await readdir(join(claudeProjectsDir, dir));
3006
+ for (const f of files) {
3007
+ if (f.endsWith(".jsonl")) {
3008
+ sessionFileIndex.set(f.slice(0, -6), join(claudeProjectsDir, dir, f));
3009
+ }
3010
+ }
3011
+ } catch { /* unreadable dir */ }
3012
+ }),
3013
+ );
3014
+ } catch { /* no projects dir */ }
2942
3015
 
2943
3016
  const pendingEntries: { sessionId: string; info: { title: string; updatedAt: number; cwd: string } }[] = [];
2944
3017
  for (const [sessionId, info] of sessions) {
@@ -2947,16 +3020,15 @@ async function fetchSessionListFromDisk(): Promise<AcpSessionInfo[]> {
2947
3020
  pendingEntries.push({ sessionId, info });
2948
3021
  }
2949
3022
 
2950
- // For each session, try to find its transcript file and stat it
3023
+ // Stat only matching files (O(matched) instead of O(sessions × dirs))
2951
3024
  const mtimeResults = await Promise.all(
2952
3025
  pendingEntries.map(async ({ sessionId }) => {
2953
- for (const dir of projectDirs) {
2954
- try {
2955
- const s = await fsStat(join(claudeProjectsDir, dir, `${sessionId}.jsonl`));
2956
- return s.mtimeMs;
2957
- } catch { /* not in this project dir */ }
2958
- }
2959
- return null;
3026
+ const filePath = sessionFileIndex.get(sessionId);
3027
+ if (!filePath) return null;
3028
+ try {
3029
+ const s = await fsStat(filePath);
3030
+ return s.mtimeMs;
3031
+ } catch { return null; }
2960
3032
  }),
2961
3033
  );
2962
3034