@betrue/openclaw-claude-code-plugin 1.0.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,481 @@
1
+ import { execFile } from "child_process";
2
+ import { Session } from "./session";
3
+ import { generateSessionName } from "./shared";
4
+ import type { NotificationRouter } from "./notifications";
5
+ import type { SessionConfig, SessionStatus } from "./types";
6
+
7
+ const CLEANUP_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
8
+
9
+ /**
10
+ * Aggregated metrics for all sessions (Task 18: Metrics and observability).
11
+ */
12
+ export interface SessionMetrics {
13
+ /** Total cost across all sessions (all time) */
14
+ totalCostUsd: number;
15
+ /** Cost per day: map of ISO date string (YYYY-MM-DD) to cost */
16
+ costPerDay: Map<string, number>;
17
+ /** Count of sessions by terminal state */
18
+ sessionsByStatus: { completed: number; failed: number; killed: number };
19
+ /** Total number of sessions ever launched */
20
+ totalLaunched: number;
21
+ /** Sum of all session durations in ms (for computing average) */
22
+ totalDurationMs: number;
23
+ /** Number of sessions that have a known duration (completed/failed/killed) */
24
+ sessionsWithDuration: number;
25
+ /** The most expensive session ever */
26
+ mostExpensive: {
27
+ id: string;
28
+ name: string;
29
+ costUsd: number;
30
+ prompt: string;
31
+ } | null;
32
+ }
33
+
34
+ /**
35
+ * Persisted session info for resume support (Task 16).
36
+ * Keeps a map of our internal session IDs to their Claude SDK session IDs,
37
+ * so users can resume sessions even after the Session object is garbage-collected.
38
+ */
39
+ interface PersistedSessionInfo {
40
+ claudeSessionId: string;
41
+ name: string;
42
+ prompt: string;
43
+ workdir: string;
44
+ model?: string;
45
+ completedAt?: number;
46
+ status: SessionStatus;
47
+ costUsd: number;
48
+ }
49
+
50
+ export class SessionManager {
51
+ private sessions: Map<string, Session> = new Map();
52
+ maxSessions: number;
53
+ maxPersistedSessions: number;
54
+ notificationRouter: NotificationRouter | null = null;
55
+
56
+ /**
57
+ * Persisted Claude session IDs — survives session cleanup/GC.
58
+ * Key: our internal session ID (nanoid) or session name.
59
+ * Allows resume even after the Session object has been garbage-collected.
60
+ */
61
+ private persistedSessions: Map<string, PersistedSessionInfo> = new Map();
62
+
63
+ /** Aggregated metrics (Task 18) */
64
+ private _metrics: SessionMetrics = {
65
+ totalCostUsd: 0,
66
+ costPerDay: new Map(),
67
+ sessionsByStatus: { completed: 0, failed: 0, killed: 0 },
68
+ totalLaunched: 0,
69
+ totalDurationMs: 0,
70
+ sessionsWithDuration: 0,
71
+ mostExpensive: null,
72
+ };
73
+
74
+ constructor(maxSessions: number = 5, maxPersistedSessions: number = 50) {
75
+ this.maxSessions = maxSessions;
76
+ this.maxPersistedSessions = maxPersistedSessions;
77
+ }
78
+
79
+ /**
80
+ * Ensure name is unique among existing sessions.
81
+ * If collision, append -2, -3, etc.
82
+ */
83
+ private uniqueName(baseName: string): string {
84
+ const existing = new Set(
85
+ [...this.sessions.values()].map((s) => s.name),
86
+ );
87
+ if (!existing.has(baseName)) return baseName;
88
+ let i = 2;
89
+ while (existing.has(`${baseName}-${i}`)) i++;
90
+ return `${baseName}-${i}`;
91
+ }
92
+
93
+ spawn(config: SessionConfig): Session {
94
+ const activeCount = [...this.sessions.values()].filter(
95
+ (s) => s.status === "starting" || s.status === "running",
96
+ ).length;
97
+ if (activeCount >= this.maxSessions) {
98
+ throw new Error(
99
+ `Max sessions reached (${this.maxSessions}). Kill a session first.`,
100
+ );
101
+ }
102
+
103
+ const baseName = config.name || generateSessionName(config.prompt);
104
+ const name = this.uniqueName(baseName);
105
+
106
+ const session = new Session(config, name);
107
+ this.sessions.set(session.id, session);
108
+ this._metrics.totalLaunched++;
109
+
110
+ // Wire up notification callbacks if NotificationRouter is available
111
+ if (this.notificationRouter) {
112
+ const nr = this.notificationRouter;
113
+ console.log(`[SessionManager] Wiring notification callbacks for session=${session.id} (${session.name}), originChannel=${session.originChannel}`);
114
+
115
+ session.onOutput = (text: string) => {
116
+ console.log(`[SessionManager] session.onOutput fired for session=${session.id}, textLen=${text.length}, fgChannels=${JSON.stringify([...session.foregroundChannels])}`);
117
+ nr.onAssistantText(session, text);
118
+ // Advance the output offset for all foreground channels so they don't
119
+ // see this output again as "catchup" when re-foregrounding later.
120
+ for (const ch of session.foregroundChannels) {
121
+ session.markFgOutputSeen(ch);
122
+ }
123
+ };
124
+
125
+ session.onToolUse = (toolName: string, toolInput: any) => {
126
+ console.log(`[SessionManager] session.onToolUse fired for session=${session.id}, tool=${toolName}`);
127
+ nr.onToolUse(session, toolName, toolInput);
128
+ };
129
+
130
+ session.onBudgetExhausted = () => {
131
+ console.log(`[SessionManager] session.onBudgetExhausted fired for session=${session.id}`);
132
+ nr.onBudgetExhausted(session, session.originChannel);
133
+ };
134
+
135
+ session.onWaitingForInput = () => {
136
+ console.log(`[SessionManager] session.onWaitingForInput fired for session=${session.id}`);
137
+ nr.onWaitingForInput(session, session.originChannel);
138
+
139
+ // Wake the orchestrator agent so it can forward the question to the user
140
+ this.triggerWaitingForInputEvent(session);
141
+ };
142
+
143
+ session.onComplete = () => {
144
+ console.log(`[SessionManager] session.onComplete fired for session=${session.id}, budgetExhausted=${session.budgetExhausted}`);
145
+
146
+ // Persist the Claude session ID for future resume
147
+ this.persistSession(session);
148
+
149
+ // Don't double-notify if budget exhaustion already handled
150
+ if (!session.budgetExhausted) {
151
+ nr.onSessionComplete(session, session.originChannel);
152
+ }
153
+
154
+ // Auto-trigger OpenClaw agent to process the completed session
155
+ this.triggerAgentEvent(session);
156
+ };
157
+ } else {
158
+ console.warn(`[SessionManager] No NotificationRouter available when spawning session=${session.id} (${session.name})`);
159
+ }
160
+
161
+ session.start();
162
+ return session;
163
+ }
164
+
165
+ /**
166
+ * Persist a session's Claude session ID for future resume.
167
+ * Called when a session completes so its ID is available after GC.
168
+ */
169
+ private persistSession(session: Session): void {
170
+ // Record metrics (only once per session — guard via persistedSessions check)
171
+ const alreadyPersisted = this.persistedSessions.has(session.id);
172
+ if (!alreadyPersisted) {
173
+ this.recordSessionMetrics(session);
174
+ }
175
+
176
+ if (!session.claudeSessionId) return;
177
+
178
+ const info: PersistedSessionInfo = {
179
+ claudeSessionId: session.claudeSessionId,
180
+ name: session.name,
181
+ prompt: session.prompt,
182
+ workdir: session.workdir,
183
+ model: session.model,
184
+ completedAt: session.completedAt,
185
+ status: session.status,
186
+ costUsd: session.costUsd,
187
+ };
188
+
189
+ // Store by internal ID
190
+ this.persistedSessions.set(session.id, info);
191
+ // Also store by name for easy lookup
192
+ this.persistedSessions.set(session.name, info);
193
+ // Also store by Claude session ID itself
194
+ this.persistedSessions.set(session.claudeSessionId, info);
195
+
196
+ console.log(`[SessionManager] Persisted session ${session.name} [${session.id}] -> claudeSessionId=${session.claudeSessionId}`);
197
+ }
198
+
199
+ /**
200
+ * Record metrics for a completed session (Task 18).
201
+ * Called once per session when it finishes (completed/failed/killed).
202
+ */
203
+ private recordSessionMetrics(session: Session): void {
204
+ const cost = session.costUsd ?? 0;
205
+ const status = session.status;
206
+
207
+ // Total cost
208
+ this._metrics.totalCostUsd += cost;
209
+
210
+ // Cost per day — use the completion date (or start date as fallback)
211
+ const dateKey = new Date(session.completedAt ?? session.startedAt)
212
+ .toISOString()
213
+ .slice(0, 10); // YYYY-MM-DD
214
+ this._metrics.costPerDay.set(
215
+ dateKey,
216
+ (this._metrics.costPerDay.get(dateKey) ?? 0) + cost,
217
+ );
218
+
219
+ // Sessions by status
220
+ if (status === "completed" || status === "failed" || status === "killed") {
221
+ this._metrics.sessionsByStatus[status]++;
222
+ }
223
+
224
+ // Duration
225
+ if (session.completedAt) {
226
+ const durationMs = session.completedAt - session.startedAt;
227
+ this._metrics.totalDurationMs += durationMs;
228
+ this._metrics.sessionsWithDuration++;
229
+ }
230
+
231
+ // Most expensive
232
+ if (
233
+ !this._metrics.mostExpensive ||
234
+ cost > this._metrics.mostExpensive.costUsd
235
+ ) {
236
+ this._metrics.mostExpensive = {
237
+ id: session.id,
238
+ name: session.name,
239
+ costUsd: cost,
240
+ prompt:
241
+ session.prompt.length > 80
242
+ ? session.prompt.slice(0, 80) + "..."
243
+ : session.prompt,
244
+ };
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Public accessor for aggregated metrics (Task 18).
250
+ * Returns a snapshot of the current metrics.
251
+ */
252
+ getMetrics(): SessionMetrics {
253
+ return this._metrics;
254
+ }
255
+
256
+ /**
257
+ * Trigger an OpenClaw agent event when a Claude Code session completes.
258
+ * Fires `openclaw system event` with session details so the agent can
259
+ * immediately process the result.
260
+ */
261
+ private triggerAgentEvent(session: Session): void {
262
+ const status = session.status;
263
+
264
+ // Build an output preview: last 5 lines, capped at 500 chars
265
+ const lastLines = session.getOutput(5);
266
+ let preview = lastLines.join("\n");
267
+ if (preview.length > 500) {
268
+ preview = preview.slice(-500);
269
+ }
270
+
271
+ const eventText = [
272
+ `Claude Code session completed.`,
273
+ `Name: ${session.name} | ID: ${session.id}`,
274
+ `Status: ${status}`,
275
+ ``,
276
+ `Output preview:`,
277
+ preview,
278
+ ``,
279
+ `Use claude_output(session='${session.id}', full=true) to get the full result and transmit the analysis to the user.`,
280
+ ].join("\n");
281
+
282
+ console.log(`[SessionManager] Triggering agent event for session=${session.id}`);
283
+
284
+ execFile(
285
+ "openclaw",
286
+ ["system", "event", "--text", eventText, "--mode", "now"],
287
+ (err, _stdout, stderr) => {
288
+ if (err) {
289
+ console.error(
290
+ `[SessionManager] Failed to trigger agent event for session=${session.id}: ${err.message}`,
291
+ );
292
+ if (stderr) console.error(`[SessionManager] stderr: ${stderr}`);
293
+ } else {
294
+ console.log(
295
+ `[SessionManager] Agent event triggered for session=${session.id}`,
296
+ );
297
+ }
298
+ },
299
+ );
300
+ }
301
+
302
+ /**
303
+ * Trigger an OpenClaw agent event when a session is waiting for user input.
304
+ * Works for ALL session types (single-turn and multi-turn).
305
+ * Fires `openclaw system event --mode now` so the orchestrator agent
306
+ * wakes up immediately and can forward the question to the user.
307
+ */
308
+ private triggerWaitingForInputEvent(session: Session): void {
309
+ // Build an output preview: last 5 lines, capped at 500 chars
310
+ const lastLines = session.getOutput(5);
311
+ let preview = lastLines.join("\n");
312
+ if (preview.length > 500) {
313
+ preview = preview.slice(-500);
314
+ }
315
+
316
+ const sessionType = session.multiTurn ? "Multi-turn session" : "Session";
317
+
318
+ const eventText = [
319
+ `${sessionType} is waiting for input.`,
320
+ `Name: ${session.name} | ID: ${session.id}`,
321
+ ``,
322
+ `Last output:`,
323
+ preview,
324
+ ``,
325
+ `Use claude_respond(session='${session.id}', message='...') to send a reply, or claude_output(session='${session.id}') to see full context.`,
326
+ ].join("\n");
327
+
328
+ console.log(`[SessionManager] Triggering waiting-for-input event for session=${session.id} (multiTurn=${session.multiTurn})`);
329
+
330
+ // Fire system event to wake the orchestrator agent
331
+ execFile(
332
+ "openclaw",
333
+ ["system", "event", "--text", eventText, "--mode", "now"],
334
+ (err, _stdout, stderr) => {
335
+ if (err) {
336
+ console.error(
337
+ `[SessionManager] Failed to trigger waiting-for-input event for session=${session.id}: ${err.message}`,
338
+ );
339
+ if (stderr) console.error(`[SessionManager] stderr: ${stderr}`);
340
+ } else {
341
+ console.log(
342
+ `[SessionManager] Waiting-for-input event triggered for session=${session.id}`,
343
+ );
344
+ }
345
+ },
346
+ );
347
+
348
+ // Note: `openclaw system event --mode now` above already triggers an
349
+ // immediate gateway wake, so no separate wake call is needed.
350
+ }
351
+
352
+ /**
353
+ * Resolve a Claude session ID from our internal ID, name, or Claude session ID.
354
+ * Looks in both active sessions and persisted (completed/GC'd) sessions.
355
+ */
356
+ resolveClaudeSessionId(ref: string): string | undefined {
357
+ // 1. Check active sessions
358
+ const active = this.resolve(ref);
359
+ if (active?.claudeSessionId) return active.claudeSessionId;
360
+
361
+ // 2. Check persisted sessions
362
+ const persisted = this.persistedSessions.get(ref);
363
+ if (persisted?.claudeSessionId) return persisted.claudeSessionId;
364
+
365
+ // 3. If the ref itself is a valid UUID, return it directly
366
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref)) return ref;
367
+
368
+ return undefined;
369
+ }
370
+
371
+ /**
372
+ * Get persisted session info by any identifier.
373
+ */
374
+ getPersistedSession(ref: string): PersistedSessionInfo | undefined {
375
+ return this.persistedSessions.get(ref);
376
+ }
377
+
378
+ /**
379
+ * List all persisted sessions (for /claude_resume listing).
380
+ */
381
+ listPersistedSessions(): PersistedSessionInfo[] {
382
+ // Deduplicate (same session stored under id, name, and claudeSessionId)
383
+ const seen = new Set<string>();
384
+ const result: PersistedSessionInfo[] = [];
385
+ for (const info of this.persistedSessions.values()) {
386
+ if (!seen.has(info.claudeSessionId)) {
387
+ seen.add(info.claudeSessionId);
388
+ result.push(info);
389
+ }
390
+ }
391
+ return result.sort((a, b) => (b.completedAt ?? 0) - (a.completedAt ?? 0));
392
+ }
393
+
394
+ /**
395
+ * Resolve a session by ID or name.
396
+ */
397
+ resolve(idOrName: string): Session | undefined {
398
+ // Try ID first (exact match)
399
+ const byId = this.sessions.get(idOrName);
400
+ if (byId) return byId;
401
+
402
+ // Try name match
403
+ for (const session of this.sessions.values()) {
404
+ if (session.name === idOrName) return session;
405
+ }
406
+ return undefined;
407
+ }
408
+
409
+ get(id: string): Session | undefined {
410
+ return this.sessions.get(id);
411
+ }
412
+
413
+ list(filter?: SessionStatus | "all"): Session[] {
414
+ let result = [...this.sessions.values()];
415
+ if (filter && filter !== "all") {
416
+ result = result.filter((s) => s.status === filter);
417
+ }
418
+ return result.sort((a, b) => b.startedAt - a.startedAt);
419
+ }
420
+
421
+ kill(id: string): boolean {
422
+ const session = this.sessions.get(id);
423
+ if (!session) {
424
+ return false;
425
+ }
426
+ session.kill();
427
+ // Record metrics immediately for killed sessions (they don't get onComplete)
428
+ if (!this.persistedSessions.has(session.id)) {
429
+ this.recordSessionMetrics(session);
430
+ }
431
+ // Persist and notify — killed sessions don't trigger onComplete
432
+ this.persistSession(session);
433
+ if (this.notificationRouter) {
434
+ this.notificationRouter.onSessionComplete(session, session.originChannel);
435
+ }
436
+ this.triggerAgentEvent(session);
437
+ return true;
438
+ }
439
+
440
+ killAll(): void {
441
+ for (const session of this.sessions.values()) {
442
+ if (session.status === "starting" || session.status === "running") {
443
+ session.kill();
444
+ }
445
+ }
446
+ }
447
+
448
+ cleanup(): void {
449
+ const now = Date.now();
450
+ for (const [id, session] of this.sessions) {
451
+ if (
452
+ session.completedAt &&
453
+ (session.status === "completed" ||
454
+ session.status === "failed" ||
455
+ session.status === "killed") &&
456
+ now - session.completedAt > CLEANUP_MAX_AGE_MS
457
+ ) {
458
+ // Persist before deleting (in case onComplete wasn't called)
459
+ this.persistSession(session);
460
+ this.sessions.delete(id);
461
+ }
462
+ }
463
+
464
+ // Evict oldest persisted sessions when over the cap.
465
+ // Each session is stored under up to 3 keys (id, name, claudeSessionId),
466
+ // so we deduplicate first, then remove the oldest entries.
467
+ const unique = this.listPersistedSessions(); // already sorted newest-first
468
+ if (unique.length > this.maxPersistedSessions) {
469
+ const toEvict = unique.slice(this.maxPersistedSessions);
470
+ for (const info of toEvict) {
471
+ // Remove all keys that point to this session
472
+ for (const [key, val] of this.persistedSessions) {
473
+ if (val.claudeSessionId === info.claudeSessionId) {
474
+ this.persistedSessions.delete(key);
475
+ }
476
+ }
477
+ }
478
+ console.log(`[SessionManager] Evicted ${toEvict.length} oldest persisted sessions (cap=${this.maxPersistedSessions})`);
479
+ }
480
+ }
481
+ }