@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.
- package/README.md +599 -0
- package/index.ts +158 -0
- package/openclaw.plugin.json +20 -0
- package/package.json +38 -0
- package/src/commands/claude-bg.ts +52 -0
- package/src/commands/claude-fg.ts +71 -0
- package/src/commands/claude-kill.ts +42 -0
- package/src/commands/claude-respond.ts +92 -0
- package/src/commands/claude-resume.ts +114 -0
- package/src/commands/claude-sessions.ts +27 -0
- package/src/commands/claude-stats.ts +20 -0
- package/src/commands/claude.ts +61 -0
- package/src/gateway.ts +185 -0
- package/src/notifications.ts +405 -0
- package/src/session-manager.ts +481 -0
- package/src/session.ts +455 -0
- package/src/shared.ts +194 -0
- package/src/tools/claude-bg.ts +100 -0
- package/src/tools/claude-fg.ts +106 -0
- package/src/tools/claude-kill.ts +66 -0
- package/src/tools/claude-launch.ts +173 -0
- package/src/tools/claude-output.ts +80 -0
- package/src/tools/claude-respond.ts +113 -0
- package/src/tools/claude-sessions.ts +63 -0
- package/src/tools/claude-stats.ts +33 -0
- package/src/types.ts +77 -0
|
@@ -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
|
+
}
|