@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/src/session.ts ADDED
@@ -0,0 +1,455 @@
1
+ import { query } from "@anthropic-ai/claude-agent-sdk";
2
+ import type { SessionConfig, SessionStatus, PermissionMode } from "./types";
3
+ import { pluginConfig } from "./shared";
4
+ import { nanoid } from "nanoid";
5
+
6
+ const OUTPUT_BUFFER_MAX = 200;
7
+
8
+ /**
9
+ * AsyncIterable controller for multi-turn conversations.
10
+ * Allows pushing SDKUserMessage objects into the query() prompt stream.
11
+ */
12
+ class MessageStream {
13
+ private queue: Array<{ type: "user"; message: { role: "user"; content: string }; parent_tool_use_id: null; session_id: string }> = [];
14
+ private resolve: (() => void) | null = null;
15
+ private done: boolean = false;
16
+
17
+ push(text: string, sessionId: string): void {
18
+ const msg = {
19
+ type: "user" as const,
20
+ message: { role: "user" as const, content: text },
21
+ parent_tool_use_id: null,
22
+ session_id: sessionId,
23
+ };
24
+ this.queue.push(msg);
25
+ if (this.resolve) {
26
+ this.resolve();
27
+ this.resolve = null;
28
+ }
29
+ }
30
+
31
+ end(): void {
32
+ this.done = true;
33
+ if (this.resolve) {
34
+ this.resolve();
35
+ this.resolve = null;
36
+ }
37
+ }
38
+
39
+ async *[Symbol.asyncIterator](): AsyncGenerator<any, void, undefined> {
40
+ while (true) {
41
+ while (this.queue.length > 0) {
42
+ yield this.queue.shift()!;
43
+ }
44
+ if (this.done) return;
45
+ await new Promise<void>((r) => { this.resolve = r; });
46
+ }
47
+ }
48
+ }
49
+
50
+ export class Session {
51
+ readonly id: string;
52
+ name: string;
53
+ claudeSessionId?: string;
54
+
55
+ // Config
56
+ readonly prompt: string;
57
+ readonly workdir: string;
58
+ readonly model?: string;
59
+ readonly maxBudgetUsd: number;
60
+ private readonly systemPrompt?: string;
61
+ private readonly allowedTools?: string[];
62
+ private readonly permissionMode: PermissionMode;
63
+
64
+ // Resume/fork config (Task 16)
65
+ readonly resumeSessionId?: string;
66
+ readonly forkSession?: boolean;
67
+
68
+ // Multi-turn config (Task 15)
69
+ readonly multiTurn: boolean;
70
+ private messageStream?: MessageStream;
71
+ private queryHandle?: ReturnType<typeof query>;
72
+ private idleTimer?: ReturnType<typeof setTimeout>;
73
+
74
+ // Safety-net idle timer: fires only if NO messages (text, tool_use, result) arrive
75
+ // for 15 seconds. The primary "waiting for input" signal is the multi-turn
76
+ // end-of-turn result handler — this timer is a rare fallback for edge cases
77
+ // (e.g. Claude stuck waiting for permission/clarification without a result event).
78
+ private safetyNetTimer?: ReturnType<typeof setTimeout>;
79
+ private static readonly SAFETY_NET_IDLE_MS = 15_000;
80
+
81
+ // State
82
+ status: SessionStatus = "starting";
83
+ error?: string;
84
+ startedAt: number;
85
+ completedAt?: number;
86
+
87
+ // SDK handles
88
+ private abortController: AbortController;
89
+
90
+ // Output
91
+ outputBuffer: string[] = [];
92
+
93
+ // Result
94
+ result?: {
95
+ subtype: string;
96
+ duration_ms: number;
97
+ total_cost_usd: number;
98
+ num_turns: number;
99
+ result?: string;
100
+ is_error: boolean;
101
+ session_id: string;
102
+ };
103
+
104
+ // Cost
105
+ costUsd: number = 0;
106
+
107
+ // Foreground
108
+ foregroundChannels: Set<string> = new Set();
109
+
110
+ // Per-channel output offset: tracks the outputBuffer index last seen while foregrounded.
111
+ // Used by claude_fg to send "catchup" of missed output when re-foregrounding.
112
+ private fgOutputOffsets: Map<string, number> = new Map();
113
+
114
+ // Origin channel -- the channel that launched this session (for background notifications)
115
+ originChannel?: string;
116
+
117
+ // Flags
118
+ budgetExhausted: boolean = false;
119
+ private waitingForInputFired: boolean = false;
120
+
121
+ // Event callbacks
122
+ onOutput?: (text: string) => void;
123
+ onToolUse?: (toolName: string, toolInput: any) => void;
124
+ onBudgetExhausted?: (session: Session) => void;
125
+ onComplete?: (session: Session) => void;
126
+ onWaitingForInput?: (session: Session) => void;
127
+
128
+ constructor(config: SessionConfig, name: string) {
129
+ this.id = nanoid(8);
130
+ this.name = name;
131
+ this.prompt = config.prompt;
132
+ this.workdir = config.workdir;
133
+ this.model = config.model;
134
+ this.maxBudgetUsd = config.maxBudgetUsd;
135
+ this.systemPrompt = config.systemPrompt;
136
+ this.allowedTools = config.allowedTools;
137
+ this.permissionMode = config.permissionMode ?? pluginConfig.permissionMode ?? "bypassPermissions";
138
+ this.originChannel = config.originChannel;
139
+ this.resumeSessionId = config.resumeSessionId;
140
+ this.forkSession = config.forkSession;
141
+ this.multiTurn = config.multiTurn ?? false;
142
+ this.startedAt = Date.now();
143
+ this.abortController = new AbortController();
144
+ }
145
+
146
+ async start(): Promise<void> {
147
+ let q;
148
+ try {
149
+ // Build SDK options
150
+ const options: any = {
151
+ cwd: this.workdir,
152
+ model: this.model,
153
+ maxBudgetUsd: this.maxBudgetUsd,
154
+ permissionMode: this.permissionMode,
155
+ allowDangerouslySkipPermissions: this.permissionMode === "bypassPermissions",
156
+ allowedTools: this.allowedTools,
157
+ includePartialMessages: true,
158
+ abortController: this.abortController,
159
+ ...(this.systemPrompt ? { systemPrompt: this.systemPrompt } : {}),
160
+ };
161
+
162
+ // Resume support (Task 16): pass resume + forkSession to SDK
163
+ if (this.resumeSessionId) {
164
+ options.resume = this.resumeSessionId;
165
+ if (this.forkSession) {
166
+ options.forkSession = true;
167
+ }
168
+ }
169
+
170
+ // Determine prompt: multi-turn uses AsyncIterable, otherwise string
171
+ let prompt: string | AsyncIterable<any>;
172
+ if (this.multiTurn) {
173
+ // Create a message stream for multi-turn conversations
174
+ this.messageStream = new MessageStream();
175
+ // Push the initial prompt as the first message
176
+ // The session_id will be set once we receive the init message
177
+ // For now use a placeholder — the SDK handles this
178
+ this.messageStream.push(this.prompt, "");
179
+ prompt = this.messageStream;
180
+ } else {
181
+ prompt = this.prompt;
182
+ }
183
+
184
+ q = query({
185
+ prompt,
186
+ options,
187
+ });
188
+
189
+ // Store the query handle for multi-turn control (interrupt, streamInput)
190
+ this.queryHandle = q;
191
+ } catch (err: any) {
192
+ this.status = "failed";
193
+ this.error = err?.message ?? String(err);
194
+ this.completedAt = Date.now();
195
+ return;
196
+ }
197
+
198
+ // Run the async iteration in background (non-blocking)
199
+ this.consumeMessages(q).catch((err) => {
200
+ if (this.status === "starting" || this.status === "running") {
201
+ this.status = "failed";
202
+ this.error = err?.message ?? String(err);
203
+ this.completedAt = Date.now();
204
+ }
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Reset the safety-net idle timer. Called on EVERY incoming message
210
+ * (text, tool_use, result). If no message of any kind arrives for
211
+ * SAFETY_NET_IDLE_MS (15s), we assume the session is stuck waiting
212
+ * for user input (e.g. a permission prompt without a result event).
213
+ *
214
+ * The primary "waiting for input" signal is the multi-turn end-of-turn
215
+ * result handler — this timer is a rare fallback for edge cases only.
216
+ */
217
+ private resetSafetyNetTimer(): void {
218
+ this.clearSafetyNetTimer();
219
+ this.safetyNetTimer = setTimeout(() => {
220
+ this.safetyNetTimer = undefined;
221
+ if (this.status === "running" && this.onWaitingForInput && !this.waitingForInputFired) {
222
+ console.log(`[Session] ${this.id} no messages for ${Session.SAFETY_NET_IDLE_MS / 1000}s — firing onWaitingForInput (safety-net)`);
223
+ this.waitingForInputFired = true;
224
+ this.onWaitingForInput(this);
225
+ }
226
+ }, Session.SAFETY_NET_IDLE_MS);
227
+ }
228
+
229
+ /**
230
+ * Cancel the safety-net idle timer.
231
+ */
232
+ private clearSafetyNetTimer(): void {
233
+ if (this.safetyNetTimer) {
234
+ clearTimeout(this.safetyNetTimer);
235
+ this.safetyNetTimer = undefined;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Reset (or start) the idle timer for multi-turn sessions.
241
+ * If no sendMessage() call arrives within the configured idle timeout, the
242
+ * session is automatically killed to avoid zombie sessions stuck in "running"
243
+ * forever. Timeout is read from pluginConfig.idleTimeoutMinutes (default 30).
244
+ */
245
+ private resetIdleTimer(): void {
246
+ if (this.idleTimer) clearTimeout(this.idleTimer);
247
+ if (!this.multiTurn) return;
248
+ const idleTimeoutMs = (pluginConfig.idleTimeoutMinutes ?? 30) * 60 * 1000;
249
+ this.idleTimer = setTimeout(() => {
250
+ if (this.status === "running") {
251
+ console.log(`[Session] ${this.id} idle timeout reached (${pluginConfig.idleTimeoutMinutes ?? 30}min), auto-killing`);
252
+ this.kill();
253
+ }
254
+ }, idleTimeoutMs);
255
+ }
256
+
257
+ /**
258
+ * Send a follow-up message to a running multi-turn session.
259
+ * Uses the SDK's streamInput() method to push a new user message.
260
+ */
261
+ async sendMessage(text: string): Promise<void> {
262
+ if (this.status !== "running") {
263
+ throw new Error(`Session is not running (status: ${this.status})`);
264
+ }
265
+
266
+ this.resetIdleTimer();
267
+ this.waitingForInputFired = false;
268
+
269
+ if (this.multiTurn && this.messageStream) {
270
+ // Push into the AsyncIterable prompt stream
271
+ this.messageStream.push(text, this.claudeSessionId ?? "");
272
+ } else if (this.queryHandle && typeof (this.queryHandle as any).streamInput === "function") {
273
+ // For non-multi-turn sessions, use streamInput() to inject messages
274
+ const userMsg = {
275
+ type: "user" as const,
276
+ message: { role: "user" as const, content: text },
277
+ parent_tool_use_id: null,
278
+ session_id: this.claudeSessionId ?? "",
279
+ };
280
+ // Create a one-shot async iterable
281
+ async function* oneMessage() { yield userMsg; }
282
+ await (this.queryHandle as any).streamInput(oneMessage());
283
+ } else {
284
+ throw new Error("Session does not support multi-turn messaging. Launch with multiTurn: true or use the SDK streamInput.");
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Interrupt the current turn (e.g. to send a new message mid-response).
290
+ */
291
+ async interrupt(): Promise<void> {
292
+ if (this.queryHandle && typeof (this.queryHandle as any).interrupt === "function") {
293
+ await (this.queryHandle as any).interrupt();
294
+ }
295
+ }
296
+
297
+ private async consumeMessages(q: AsyncIterable<any>): Promise<void> {
298
+ for await (const msg of q) {
299
+ // Reset the safety-net timer on every incoming message.
300
+ // This ensures it only fires when there is truly no activity for 15s.
301
+ this.resetSafetyNetTimer();
302
+
303
+ if (
304
+ msg.type === "system" &&
305
+ msg.subtype === "init"
306
+ ) {
307
+ this.claudeSessionId = msg.session_id;
308
+ this.status = "running";
309
+ this.resetIdleTimer();
310
+ } else if (msg.type === "assistant") {
311
+ this.waitingForInputFired = false;
312
+ const contentBlocks = msg.message?.content ?? [];
313
+ console.log(`[Session] ${this.id} assistant message received, blocks=${contentBlocks.length}, fgChannels=${JSON.stringify([...this.foregroundChannels])}`);
314
+ for (const block of contentBlocks) {
315
+ if (block.type === "text") {
316
+ const text: string = block.text;
317
+ this.outputBuffer.push(text);
318
+ if (this.outputBuffer.length > OUTPUT_BUFFER_MAX) {
319
+ this.outputBuffer.splice(
320
+ 0,
321
+ this.outputBuffer.length - OUTPUT_BUFFER_MAX
322
+ );
323
+ }
324
+ if (this.onOutput) {
325
+ console.log(`[Session] ${this.id} calling onOutput, textLen=${text.length}`);
326
+ this.onOutput(text);
327
+ } else {
328
+ console.log(`[Session] ${this.id} onOutput callback NOT set`);
329
+ }
330
+ } else if (block.type === "tool_use") {
331
+ // Emit tool_use event for compact foreground display
332
+ if (this.onToolUse) {
333
+ console.log(`[Session] ${this.id} calling onToolUse, tool=${block.name}`);
334
+ this.onToolUse(block.name, block.input);
335
+ } else {
336
+ console.log(`[Session] ${this.id} onToolUse callback NOT set`);
337
+ }
338
+ }
339
+ }
340
+ } else if (msg.type === "result") {
341
+ this.result = {
342
+ subtype: msg.subtype,
343
+ duration_ms: msg.duration_ms,
344
+ total_cost_usd: msg.total_cost_usd,
345
+ num_turns: msg.num_turns,
346
+ result: msg.result,
347
+ is_error: msg.is_error,
348
+ session_id: msg.session_id,
349
+ };
350
+ this.costUsd = msg.total_cost_usd;
351
+
352
+ // In multi-turn mode, a "success" result means end-of-turn, not end-of-session.
353
+ // The session stays running so the user can send follow-up messages.
354
+ // Only close on errors (budget exhaustion, actual failures, etc.).
355
+ const isMultiTurnEndOfTurn = this.multiTurn && this.messageStream && msg.subtype === "success";
356
+
357
+ if (isMultiTurnEndOfTurn) {
358
+ // Keep session alive — just update cost and result, stay in "running" status
359
+ console.log(`[Session] ${this.id} multi-turn end-of-turn (turn ${msg.num_turns}), staying open`);
360
+ this.clearSafetyNetTimer();
361
+ this.resetIdleTimer();
362
+
363
+ // Notify that the session is now waiting for user input
364
+ if (this.onWaitingForInput && !this.waitingForInputFired) {
365
+ console.log(`[Session] ${this.id} calling onWaitingForInput`);
366
+ this.waitingForInputFired = true;
367
+ this.onWaitingForInput(this);
368
+ }
369
+ } else {
370
+ // Session is truly done — either single-turn, or multi-turn with error/budget
371
+ this.clearSafetyNetTimer();
372
+ if (this.idleTimer) clearTimeout(this.idleTimer);
373
+ this.status = msg.subtype === "success" ? "completed" : "failed";
374
+ this.completedAt = Date.now();
375
+
376
+ // End the message stream if multi-turn
377
+ if (this.messageStream) {
378
+ this.messageStream.end();
379
+ }
380
+
381
+ // Detect budget exhaustion
382
+ if (msg.subtype === "error_max_budget_usd") {
383
+ this.budgetExhausted = true;
384
+ if (this.onBudgetExhausted) {
385
+ this.onBudgetExhausted(this);
386
+ }
387
+ }
388
+
389
+ if (this.onComplete) {
390
+ console.log(`[Session] ${this.id} calling onComplete, status=${this.status}`);
391
+ this.onComplete(this);
392
+ } else {
393
+ console.log(`[Session] ${this.id} onComplete callback NOT set`);
394
+ }
395
+ }
396
+ }
397
+ }
398
+ }
399
+
400
+ kill(): void {
401
+ if (this.status !== "starting" && this.status !== "running") return;
402
+ if (this.idleTimer) clearTimeout(this.idleTimer);
403
+ this.clearSafetyNetTimer();
404
+ this.status = "killed";
405
+ this.completedAt = Date.now();
406
+ // End the message stream
407
+ if (this.messageStream) {
408
+ this.messageStream.end();
409
+ }
410
+ this.abortController.abort();
411
+ }
412
+
413
+ getOutput(lines?: number): string[] {
414
+ if (lines === undefined) {
415
+ return this.outputBuffer.slice();
416
+ }
417
+ return this.outputBuffer.slice(-lines);
418
+ }
419
+
420
+ /**
421
+ * Get all output produced since this channel was last foregrounded (or since launch).
422
+ * Returns the missed output lines. If this is the first time foregrounding,
423
+ * returns the full buffer (same as getOutput()).
424
+ */
425
+ getCatchupOutput(channelId: string): string[] {
426
+ const lastOffset = this.fgOutputOffsets.get(channelId) ?? 0;
427
+ // The buffer is capped at OUTPUT_BUFFER_MAX. If output has been trimmed,
428
+ // we can only return what's still in the buffer.
429
+ const available = this.outputBuffer.length;
430
+ if (lastOffset >= available) {
431
+ return []; // Already caught up
432
+ }
433
+ return this.outputBuffer.slice(lastOffset);
434
+ }
435
+
436
+ /**
437
+ * Record that this channel has seen all current output (call when foregrounding).
438
+ * Sets the offset to the current end of the buffer.
439
+ */
440
+ markFgOutputSeen(channelId: string): void {
441
+ this.fgOutputOffsets.set(channelId, this.outputBuffer.length);
442
+ }
443
+
444
+ /**
445
+ * Save the current output position for a channel (call when backgrounding).
446
+ * This records where they left off so catchup can resume from here.
447
+ */
448
+ saveFgOutputOffset(channelId: string): void {
449
+ this.fgOutputOffsets.set(channelId, this.outputBuffer.length);
450
+ }
451
+
452
+ get duration(): number {
453
+ return (this.completedAt ?? Date.now()) - this.startedAt;
454
+ }
455
+ }
package/src/shared.ts ADDED
@@ -0,0 +1,194 @@
1
+ import type { Session } from "./session";
2
+ import type { SessionManager, SessionMetrics } from "./session-manager";
3
+ import type { NotificationRouter } from "./notifications";
4
+ import type { PluginConfig } from "./types";
5
+
6
+ export let sessionManager: SessionManager | null = null;
7
+ export let notificationRouter: NotificationRouter | null = null;
8
+
9
+ /**
10
+ * Plugin config — populated at service start from api.getConfig().
11
+ * All modules should read from this instead of using hardcoded constants.
12
+ */
13
+ export let pluginConfig: PluginConfig = {
14
+ maxSessions: 5,
15
+ defaultBudgetUsd: 5,
16
+ idleTimeoutMinutes: 30,
17
+ maxPersistedSessions: 50,
18
+ };
19
+
20
+ export function setPluginConfig(config: Partial<PluginConfig>): void {
21
+ pluginConfig = {
22
+ maxSessions: config.maxSessions ?? 5,
23
+ defaultBudgetUsd: config.defaultBudgetUsd ?? 5,
24
+ defaultModel: config.defaultModel,
25
+ defaultWorkdir: config.defaultWorkdir,
26
+ idleTimeoutMinutes: config.idleTimeoutMinutes ?? 30,
27
+ maxPersistedSessions: config.maxPersistedSessions ?? 50,
28
+ fallbackChannel: config.fallbackChannel,
29
+ };
30
+ }
31
+
32
+ export function setSessionManager(sm: SessionManager | null): void {
33
+ sessionManager = sm;
34
+ }
35
+
36
+ export function setNotificationRouter(nr: NotificationRouter | null): void {
37
+ notificationRouter = nr;
38
+ }
39
+
40
+ /**
41
+ * Resolve origin channel from an OpenClaw command/tool context.
42
+ *
43
+ * Attempts to build a "channel:target" string from context properties.
44
+ * Command context has: ctx.channel, ctx.senderId, ctx.chatId, ctx.id
45
+ * Tool execute receives just an _id (tool call ID like "toolu_xxx").
46
+ *
47
+ * Falls back to config.fallbackChannel when the real channel info
48
+ * is not available. If no fallbackChannel is configured, returns
49
+ * "unknown" as a safe default.
50
+ */
51
+
52
+ export function resolveOriginChannel(ctx: any, explicitChannel?: string): string {
53
+ // Highest priority: explicit channel passed by caller (e.g. from tool params)
54
+ if (explicitChannel && String(explicitChannel).includes(":")) {
55
+ return String(explicitChannel);
56
+ }
57
+ // Try structured channel info from command context
58
+ if (ctx?.channel && ctx?.chatId) {
59
+ return `${ctx.channel}:${ctx.chatId}`;
60
+ }
61
+ if (ctx?.channel && ctx?.senderId) {
62
+ return `${ctx.channel}:${ctx.senderId}`;
63
+ }
64
+ // If the context id looks like a numeric telegram chat id
65
+ if (ctx?.id && /^-?\d+$/.test(String(ctx.id))) {
66
+ return `telegram:${ctx.id}`;
67
+ }
68
+ // If channelId is already in "channel:target" format, pass through
69
+ if (ctx?.channelId && String(ctx.channelId).includes(":")) {
70
+ return String(ctx.channelId);
71
+ }
72
+ // Log what we got for debugging
73
+ const fallback = pluginConfig.fallbackChannel ?? "unknown";
74
+ console.log(`[resolveOriginChannel] Could not resolve channel from ctx keys: ${ctx ? Object.keys(ctx).join(", ") : "null"}, using fallback=${fallback}`);
75
+ return fallback;
76
+ }
77
+
78
+ export function formatDuration(ms: number): string {
79
+ const seconds = Math.floor(ms / 1000);
80
+ const minutes = Math.floor(seconds / 60);
81
+ const secs = seconds % 60;
82
+ if (minutes > 0) return `${minutes}m${secs}s`;
83
+ return `${secs}s`;
84
+ }
85
+
86
+ // Stop words filtered out when generating session names from prompts
87
+ const STOP_WORDS = new Set([
88
+ "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
89
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
90
+ "should", "may", "might", "shall", "can", "need", "must",
91
+ "i", "me", "my", "we", "our", "you", "your", "it", "its", "he", "she",
92
+ "to", "of", "in", "for", "on", "with", "at", "by", "from", "as",
93
+ "into", "through", "about", "that", "this", "these", "those",
94
+ "and", "or", "but", "if", "then", "so", "not", "no",
95
+ "please", "just", "also", "very", "all", "some", "any", "each",
96
+ "make", "write", "create", "build", "implement", "add", "update",
97
+ ]);
98
+
99
+ /**
100
+ * Generate a short kebab-case name from a prompt.
101
+ * Extracts 2-3 meaningful keywords.
102
+ */
103
+ export function generateSessionName(prompt: string): string {
104
+ const words = prompt
105
+ .toLowerCase()
106
+ .replace(/[^a-z0-9\s-]/g, " ")
107
+ .split(/\s+/)
108
+ .filter((w) => w.length > 1 && !STOP_WORDS.has(w));
109
+
110
+ const keywords = words.slice(0, 3);
111
+ if (keywords.length === 0) return "session";
112
+ return keywords.join("-");
113
+ }
114
+
115
+ const STATUS_ICONS: Record<string, string> = {
116
+ starting: "🟡",
117
+ running: "đŸŸĸ",
118
+ completed: "✅",
119
+ failed: "❌",
120
+ killed: "⛔",
121
+ };
122
+
123
+ export function formatSessionListing(session: Session): string {
124
+ const icon = STATUS_ICONS[session.status] ?? "❓";
125
+ const duration = formatDuration(session.duration);
126
+ const fg = session.foregroundChannels.size > 0 ? "foreground" : "background";
127
+ const mode = session.multiTurn ? "multi-turn" : "single";
128
+ const promptSummary =
129
+ session.prompt.length > 80
130
+ ? session.prompt.slice(0, 80) + "..."
131
+ : session.prompt;
132
+
133
+ const lines = [
134
+ `${icon} ${session.name} [${session.id}] (${duration}) — ${fg}, ${mode}`,
135
+ ` 📁 ${session.workdir}`,
136
+ ` 📝 "${promptSummary}"`,
137
+ ];
138
+
139
+ // Show Claude session ID for resume support
140
+ if (session.claudeSessionId) {
141
+ lines.push(` 🔗 Claude ID: ${session.claudeSessionId}`);
142
+ }
143
+
144
+ // Show resume info if this session was resumed
145
+ if (session.resumeSessionId) {
146
+ lines.push(` â†Šī¸ Resumed from: ${session.resumeSessionId}${session.forkSession ? " (forked)" : ""}`);
147
+ }
148
+
149
+ return lines.join("\n");
150
+ }
151
+
152
+ /**
153
+ * Format aggregated metrics into a human-readable stats report (Task 18).
154
+ */
155
+ export function formatStats(metrics: SessionMetrics): string {
156
+ // Average duration
157
+ const avgDurationMs =
158
+ metrics.sessionsWithDuration > 0
159
+ ? metrics.totalDurationMs / metrics.sessionsWithDuration
160
+ : 0;
161
+
162
+ // Currently running sessions (live count from sessionManager)
163
+ const running = sessionManager
164
+ ? sessionManager.list("running").length
165
+ : 0;
166
+
167
+ const { completed, failed, killed } = metrics.sessionsByStatus;
168
+ const totalFinished = completed + failed + killed;
169
+
170
+ const lines = [
171
+ `📊 Claude Code Plugin Stats`,
172
+ ``,
173
+ `📋 Sessions`,
174
+ ` Launched: ${metrics.totalLaunched}`,
175
+ ` Running: ${running}`,
176
+ ` Completed: ${completed}`,
177
+ ` Failed: ${failed}`,
178
+ ` Killed: ${killed}`,
179
+ ``,
180
+ `âąī¸ Average duration: ${avgDurationMs > 0 ? formatDuration(avgDurationMs) : "n/a"}`,
181
+ ];
182
+
183
+ if (metrics.mostExpensive) {
184
+ const me = metrics.mostExpensive;
185
+ lines.push(
186
+ ``,
187
+ `🏆 Notable session`,
188
+ ` ${me.name} [${me.id}]`,
189
+ ` 📝 "${me.prompt}"`,
190
+ );
191
+ }
192
+
193
+ return lines.join("\n");
194
+ }