@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,61 @@
1
+ import { sessionManager, pluginConfig, resolveOriginChannel } from "../shared";
2
+
3
+ export function registerClaudeCommand(api: any): void {
4
+ api.registerCommand({
5
+ name: "claude",
6
+ description: "Launch a Claude Code session. Usage: /claude [--name <name>] <prompt>",
7
+ acceptsArgs: true,
8
+ requireAuth: true,
9
+ handler: (ctx: any) => {
10
+ if (!sessionManager) {
11
+ return {
12
+ text: "Error: SessionManager not initialized. The claude-code service must be running.",
13
+ };
14
+ }
15
+
16
+ let args = (ctx.args ?? "").trim();
17
+ if (!args) {
18
+ return { text: "Usage: /claude [--name <name>] <prompt>" };
19
+ }
20
+
21
+ // Parse optional --name flag
22
+ let name: string | undefined;
23
+ const nameMatch = args.match(/^--name\s+(\S+)\s+/);
24
+ if (nameMatch) {
25
+ name = nameMatch[1];
26
+ args = args.slice(nameMatch[0].length).trim();
27
+ }
28
+
29
+ const prompt = args;
30
+ if (!prompt) {
31
+ return { text: "Usage: /claude [--name <name>] <prompt>" };
32
+ }
33
+
34
+ try {
35
+ const session = sessionManager.spawn({
36
+ prompt,
37
+ name,
38
+ workdir: pluginConfig.defaultWorkdir || process.cwd(),
39
+ model: pluginConfig.defaultModel,
40
+ maxBudgetUsd: pluginConfig.defaultBudgetUsd ?? 5,
41
+ originChannel: resolveOriginChannel(ctx),
42
+ });
43
+
44
+ const promptSummary =
45
+ prompt.length > 80 ? prompt.slice(0, 80) + "..." : prompt;
46
+
47
+ return {
48
+ text: [
49
+ `Session launched.`,
50
+ ` Name: ${session.name}`,
51
+ ` ID: ${session.id}`,
52
+ ` Prompt: "${promptSummary}"`,
53
+ ` Status: ${session.status}`,
54
+ ].join("\n"),
55
+ };
56
+ } catch (err: any) {
57
+ return { text: `Error: ${err.message}` };
58
+ }
59
+ },
60
+ });
61
+ }
package/src/gateway.ts ADDED
@@ -0,0 +1,185 @@
1
+ import { sessionManager, pluginConfig, formatSessionListing, formatDuration, formatStats, resolveOriginChannel } from "./shared";
2
+
3
+ /**
4
+ * Task 17 — Gateway RPC methods
5
+ *
6
+ * Registers RPC methods so external clients (dashboard, API, other plugins)
7
+ * can control Claude Code Plugin sessions programmatically.
8
+ *
9
+ * Methods:
10
+ * claude-code.sessions — list sessions (optionally filtered by status)
11
+ * claude-code.launch — launch a new session
12
+ * claude-code.kill — kill a running session
13
+ * claude-code.output — get output from a session
14
+ * claude-code.stats — return aggregated metrics
15
+ */
16
+ export function registerGatewayMethods(api: any): void {
17
+
18
+ // ── claude-code.sessions ────────────────────────────────────────
19
+ api.registerGatewayMethod("claude-code.sessions", ({ respond, params }: any) => {
20
+ if (!sessionManager) {
21
+ return respond(false, { error: "SessionManager not initialized" });
22
+ }
23
+
24
+ const filter = params?.status ?? "all";
25
+ const sessions = sessionManager.list(filter);
26
+
27
+ const result = sessions.map((s) => ({
28
+ id: s.id,
29
+ name: s.name,
30
+ status: s.status,
31
+ prompt: s.prompt,
32
+ workdir: s.workdir,
33
+ model: s.model,
34
+ costUsd: s.costUsd,
35
+ startedAt: s.startedAt,
36
+ completedAt: s.completedAt,
37
+ durationMs: s.duration,
38
+ claudeSessionId: s.claudeSessionId,
39
+ foreground: s.foregroundChannels.size > 0,
40
+ multiTurn: s.multiTurn,
41
+ // Also include human-readable listing
42
+ display: formatSessionListing(s),
43
+ }));
44
+
45
+ respond(true, { sessions: result, count: result.length });
46
+ });
47
+
48
+ // ── claude-code.launch ──────────────────────────────────────────
49
+ api.registerGatewayMethod("claude-code.launch", ({ respond, params }: any) => {
50
+ if (!sessionManager) {
51
+ return respond(false, { error: "SessionManager not initialized" });
52
+ }
53
+
54
+ if (!params?.prompt) {
55
+ return respond(false, { error: "Missing required parameter: prompt" });
56
+ }
57
+
58
+ try {
59
+ const session = sessionManager.spawn({
60
+ prompt: params.prompt,
61
+ name: params.name,
62
+ workdir: params.workdir || pluginConfig.defaultWorkdir || process.cwd(),
63
+ model: params.model || pluginConfig.defaultModel,
64
+ maxBudgetUsd: params.maxBudgetUsd ?? params.max_budget_usd ?? pluginConfig.defaultBudgetUsd ?? 5,
65
+ systemPrompt: params.systemPrompt ?? params.system_prompt,
66
+ allowedTools: params.allowedTools ?? params.allowed_tools,
67
+ resumeSessionId: params.resumeSessionId ?? params.resume_session_id,
68
+ forkSession: params.forkSession ?? params.fork_session,
69
+ multiTurn: params.multiTurn ?? params.multi_turn,
70
+ originChannel: params.originChannel ?? "gateway",
71
+ });
72
+
73
+ respond(true, {
74
+ id: session.id,
75
+ name: session.name,
76
+ status: session.status,
77
+ workdir: session.workdir,
78
+ model: session.model,
79
+ });
80
+ } catch (err: any) {
81
+ respond(false, { error: err.message });
82
+ }
83
+ });
84
+
85
+ // ── claude-code.kill ────────────────────────────────────────────
86
+ api.registerGatewayMethod("claude-code.kill", ({ respond, params }: any) => {
87
+ if (!sessionManager) {
88
+ return respond(false, { error: "SessionManager not initialized" });
89
+ }
90
+
91
+ const ref = params?.session ?? params?.id;
92
+ if (!ref) {
93
+ return respond(false, { error: "Missing required parameter: session (name or ID)" });
94
+ }
95
+
96
+ const session = sessionManager.resolve(ref);
97
+ if (!session) {
98
+ return respond(false, { error: `Session "${ref}" not found` });
99
+ }
100
+
101
+ if (session.status === "completed" || session.status === "failed" || session.status === "killed") {
102
+ return respond(true, {
103
+ id: session.id,
104
+ name: session.name,
105
+ status: session.status,
106
+ message: `Session already ${session.status}`,
107
+ });
108
+ }
109
+
110
+ sessionManager.kill(session.id);
111
+
112
+ respond(true, {
113
+ id: session.id,
114
+ name: session.name,
115
+ status: "killed",
116
+ message: `Session ${session.name} [${session.id}] terminated`,
117
+ });
118
+ });
119
+
120
+ // ── claude-code.output ──────────────────────────────────────────
121
+ api.registerGatewayMethod("claude-code.output", ({ respond, params }: any) => {
122
+ if (!sessionManager) {
123
+ return respond(false, { error: "SessionManager not initialized" });
124
+ }
125
+
126
+ const ref = params?.session ?? params?.id;
127
+ if (!ref) {
128
+ return respond(false, { error: "Missing required parameter: session (name or ID)" });
129
+ }
130
+
131
+ const session = sessionManager.resolve(ref);
132
+ if (!session) {
133
+ return respond(false, { error: `Session "${ref}" not found` });
134
+ }
135
+
136
+ const lines = params?.full
137
+ ? session.getOutput()
138
+ : session.getOutput(params?.lines ?? 50);
139
+
140
+ respond(true, {
141
+ id: session.id,
142
+ name: session.name,
143
+ status: session.status,
144
+ costUsd: session.costUsd,
145
+ durationMs: session.duration,
146
+ duration: formatDuration(session.duration),
147
+ lines,
148
+ lineCount: lines.length,
149
+ result: session.result ?? null,
150
+ });
151
+ });
152
+
153
+ // ── claude-code.stats ───────────────────────────────────────────
154
+ api.registerGatewayMethod("claude-code.stats", ({ respond, params }: any) => {
155
+ if (!sessionManager) {
156
+ return respond(false, { error: "SessionManager not initialized" });
157
+ }
158
+
159
+ const metrics = sessionManager.getMetrics();
160
+
161
+ // Build a serializable version (Map → Object)
162
+ const costPerDay: Record<string, number> = {};
163
+ for (const [key, val] of metrics.costPerDay) {
164
+ costPerDay[key] = val;
165
+ }
166
+
167
+ const running = sessionManager.list("running").length;
168
+
169
+ respond(true, {
170
+ totalCostUsd: metrics.totalCostUsd,
171
+ costPerDay,
172
+ sessionsByStatus: {
173
+ ...metrics.sessionsByStatus,
174
+ running,
175
+ },
176
+ totalLaunched: metrics.totalLaunched,
177
+ averageDurationMs: metrics.sessionsWithDuration > 0
178
+ ? metrics.totalDurationMs / metrics.sessionsWithDuration
179
+ : 0,
180
+ mostExpensive: metrics.mostExpensive,
181
+ // Human-readable version too
182
+ display: formatStats(metrics),
183
+ });
184
+ });
185
+ }
@@ -0,0 +1,405 @@
1
+ import type { Session } from "./session";
2
+ import { formatDuration } from "./shared";
3
+
4
+ /**
5
+ * NotificationRouter — Phase 2
6
+ *
7
+ * Decides when and what to notify on active channels.
8
+ * Implements the notification matrix from the plan (section 7.2):
9
+ *
10
+ * | Event | Background | Foreground |
11
+ * |----------------------------|------------------|-------------------|
12
+ * | Session started | silent | silent (stream) |
13
+ * | Assistant output (text) | silent | stream to chat |
14
+ * | Tool call (name + params) | silent | compact indicator |
15
+ * | Tool result | silent | silent (verbose) |
16
+ * | Session completed (success)| notify | notify |
17
+ * | Session completed (error) | notify | notify |
18
+ * | Budget exhausted | notify | notify |
19
+ * | Session > 10min | reminder (once) | silent (user sees)|
20
+ */
21
+
22
+ // Callback type: the plugin must provide a way to send messages to a channel
23
+ export type SendMessageFn = (channelId: string, text: string) => void;
24
+
25
+ // Debounce state per channel per session
26
+ interface DebounceEntry {
27
+ buffer: string;
28
+ timer: ReturnType<typeof setTimeout>;
29
+ }
30
+
31
+ const DEBOUNCE_MS = 500;
32
+ const LONG_RUNNING_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
33
+
34
+ export class NotificationRouter {
35
+ private sendMessage: SendMessageFn;
36
+
37
+ // Track debounced foreground streaming: key = `${sessionId}|${channelId}`
38
+ private debounceMap: Map<string, DebounceEntry> = new Map();
39
+
40
+ // Track which sessions have already sent the 10min reminder
41
+ private longRunningReminded: Set<string> = new Set();
42
+
43
+ // Interval for checking long-running sessions
44
+ private reminderInterval: ReturnType<typeof setInterval> | null = null;
45
+
46
+ // Reference to get all sessions for reminder checks
47
+ private getActiveSessions: (() => Session[]) | null = null;
48
+
49
+ constructor(sendMessage: SendMessageFn) {
50
+ this.sendMessage = (channelId: string, text: string) => {
51
+ console.log(`[NotificationRouter] sendMessage -> channel=${channelId}, textLen=${text.length}, preview=${text.slice(0, 120)}`);
52
+ sendMessage(channelId, text);
53
+ };
54
+ console.log("[NotificationRouter] Initialized");
55
+ }
56
+
57
+ /**
58
+ * Start the reminder check interval.
59
+ * Pass a function that returns currently active sessions.
60
+ */
61
+ startReminderCheck(getActiveSessions: () => Session[]): void {
62
+ this.getActiveSessions = getActiveSessions;
63
+ // Check every 60 seconds for long-running sessions
64
+ this.reminderInterval = setInterval(() => this.checkLongRunning(), 60_000);
65
+ }
66
+
67
+ /**
68
+ * Stop the reminder check interval and flush all debounce timers.
69
+ */
70
+ stop(): void {
71
+ if (this.reminderInterval) {
72
+ clearInterval(this.reminderInterval);
73
+ this.reminderInterval = null;
74
+ }
75
+ // Flush all pending debounce buffers
76
+ for (const [key, entry] of this.debounceMap) {
77
+ clearTimeout(entry.timer);
78
+ if (entry.buffer) {
79
+ const [_sessionId, channelId] = key.split("|", 2);
80
+ this.sendMessage(channelId, entry.buffer);
81
+ }
82
+ }
83
+ this.debounceMap.clear();
84
+ this.longRunningReminded.clear();
85
+ }
86
+
87
+ // ─── Foreground streaming ──────────────────────────────────────────
88
+
89
+ /**
90
+ * Called when an assistant text block arrives on a session.
91
+ * If the session has foreground channels, debounce and stream to them.
92
+ */
93
+ onAssistantText(session: Session, text: string): void {
94
+ console.log(`[NotificationRouter] onAssistantText session=${session.id} (${session.name}), fgChannels=${JSON.stringify([...session.foregroundChannels])}, textLen=${text.length}`);
95
+ if (session.foregroundChannels.size === 0) {
96
+ console.log(`[NotificationRouter] onAssistantText SKIPPED — no foreground channels`);
97
+ return;
98
+ }
99
+
100
+ for (const channelId of session.foregroundChannels) {
101
+ console.log(`[NotificationRouter] appendDebounced -> session=${session.id}, channel=${channelId}`);
102
+ this.appendDebounced(session.id, channelId, text);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Called when a tool_use block arrives on an assistant message.
108
+ * Shows a compact one-line indicator on foreground channels.
109
+ */
110
+ onToolUse(session: Session, toolName: string, toolInput: any): void {
111
+ console.log(`[NotificationRouter] onToolUse session=${session.id}, tool=${toolName}, fgChannels=${JSON.stringify([...session.foregroundChannels])}`);
112
+ if (session.foregroundChannels.size === 0) return;
113
+
114
+ const inputSummary = summarizeToolInput(toolInput);
115
+ const line = `🔧 ${toolName}${inputSummary ? ` — ${inputSummary}` : ""}`;
116
+
117
+ for (const channelId of session.foregroundChannels) {
118
+ // Flush any pending text first, then send tool indicator immediately
119
+ this.flushDebounced(session.id, channelId);
120
+ this.sendMessage(channelId, line);
121
+ }
122
+ }
123
+
124
+ // ─── Completion notifications ──────────────────────────────────────
125
+
126
+ /**
127
+ * Called when a session completes (success or failure).
128
+ * Notifies ALL channels that have ever been associated with this session:
129
+ * - Foreground channels get a notification
130
+ * - If no foreground channels, notify via a "last known" channel if available
131
+ *
132
+ * For background sessions, we store the originating channel in session metadata.
133
+ */
134
+ onSessionComplete(session: Session, originChannel?: string): void {
135
+ console.log(`[NotificationRouter] onSessionComplete session=${session.id} (${session.name}), status=${session.status}, originChannel=${originChannel}, fgChannels=${JSON.stringify([...session.foregroundChannels])}`);
136
+ // Flush any pending foreground output first
137
+ for (const channelId of session.foregroundChannels) {
138
+ this.flushDebounced(session.id, channelId);
139
+ }
140
+
141
+ const msg = formatCompletionNotification(session);
142
+
143
+ // Collect all channels to notify (foreground + origin)
144
+ const channels = new Set(session.foregroundChannels);
145
+ if (originChannel) channels.add(originChannel);
146
+
147
+ for (const channelId of channels) {
148
+ this.sendMessage(channelId, msg);
149
+ }
150
+
151
+ // Clean up debounce state for this session
152
+ this.cleanupSession(session.id);
153
+ }
154
+
155
+ /**
156
+ * Called when budget is exhausted (subtype: "error_max_budget_usd").
157
+ * This is effectively handled by onSessionComplete since it's a result event,
158
+ * but we expose it separately for clarity and custom formatting.
159
+ */
160
+ onBudgetExhausted(session: Session, originChannel?: string): void {
161
+ for (const channelId of session.foregroundChannels) {
162
+ this.flushDebounced(session.id, channelId);
163
+ }
164
+
165
+ const duration = formatDuration(session.duration);
166
+ const msg = [
167
+ `⛔ Session limit reached — ${session.name} [${session.id}] (${duration})`,
168
+ ` 📁 ${session.workdir}`,
169
+ ].join("\n");
170
+
171
+ const channels = new Set(session.foregroundChannels);
172
+ if (originChannel) channels.add(originChannel);
173
+
174
+ for (const channelId of channels) {
175
+ this.sendMessage(channelId, msg);
176
+ }
177
+
178
+ this.cleanupSession(session.id);
179
+ }
180
+
181
+ // ─── Waiting for input (all session types) ─────────────────────────
182
+
183
+ /**
184
+ * Called when any session is waiting for user input (e.g. Claude asked a question,
185
+ * needs a permission decision, or finished a turn in multi-turn mode).
186
+ * Notifies foreground and origin channels so the user knows Claude needs a response.
187
+ */
188
+ onWaitingForInput(session: Session, originChannel?: string): void {
189
+ console.log(`[NotificationRouter] onWaitingForInput session=${session.id} (${session.name}), originChannel=${originChannel}, fgChannels=${JSON.stringify([...session.foregroundChannels])}`);
190
+
191
+ // Flush any pending foreground output first
192
+ for (const channelId of session.foregroundChannels) {
193
+ this.flushDebounced(session.id, channelId);
194
+ }
195
+
196
+ // Build notification message with last output as context
197
+ const lastOutput = session.getOutput(5);
198
+ let preview = lastOutput.join("\n");
199
+ if (preview.length > 500) {
200
+ preview = preview.slice(-500);
201
+ }
202
+
203
+ // Determine which channels are background-only (origin but not foreground)
204
+ const fgChannels = new Set(session.foregroundChannels);
205
+ const allChannels = new Set(session.foregroundChannels);
206
+ if (originChannel) allChannels.add(originChannel);
207
+
208
+ for (const channelId of allChannels) {
209
+ const isBg = !fgChannels.has(channelId);
210
+ if (isBg) {
211
+ // Background channel: show the full question so user can follow along
212
+ const msg = [
213
+ `🔔 [${session.name}] Claude asks:`,
214
+ preview || "(no output captured)",
215
+ ].join("\n");
216
+ this.sendMessage(channelId, msg);
217
+ } else {
218
+ // Foreground channel: compact notification (output already streamed)
219
+ const duration = formatDuration(session.duration);
220
+ const msg = [
221
+ `💬 Session ${session.name} [${session.id}] is waiting for input (${duration})`,
222
+ ` Use claude_respond to reply.`,
223
+ ].join("\n");
224
+ this.sendMessage(channelId, msg);
225
+ }
226
+ }
227
+ }
228
+
229
+ // ─── Public message passthrough ─────────────────────────────────────
230
+
231
+ /**
232
+ * Emit a message to a specific channel. Used by tools (e.g. claude_respond)
233
+ * to display messages in the conversation thread without going through
234
+ * the foreground streaming / debounce logic.
235
+ */
236
+ emitToChannel(channelId: string, text: string): void {
237
+ this.sendMessage(channelId, text);
238
+ }
239
+
240
+ // ─── Long-running reminder ─────────────────────────────────────────
241
+
242
+ /**
243
+ * Periodic check: notify if a background session (no foreground channels)
244
+ * has been running for more than 10 minutes. Only once per session.
245
+ */
246
+ private checkLongRunning(): void {
247
+ if (!this.getActiveSessions) return;
248
+
249
+ const sessions = this.getActiveSessions();
250
+ const now = Date.now();
251
+
252
+ for (const session of sessions) {
253
+ if (
254
+ (session.status === "running" || session.status === "starting") &&
255
+ session.foregroundChannels.size === 0 &&
256
+ !this.longRunningReminded.has(session.id) &&
257
+ now - session.startedAt > LONG_RUNNING_THRESHOLD_MS
258
+ ) {
259
+ this.longRunningReminded.add(session.id);
260
+
261
+ const duration = formatDuration(now - session.startedAt);
262
+ const msg = [
263
+ `⏱️ Session ${session.name} [${session.id}] running for ${duration}`,
264
+ ` 📁 ${session.workdir}`,
265
+ ` Use claude_fg to check on it, or claude_kill to stop it.`,
266
+ ].join("\n");
267
+
268
+ // Try to notify the origin channel if available
269
+ if (session.originChannel) {
270
+ this.sendMessage(session.originChannel, msg);
271
+ }
272
+ }
273
+ }
274
+ }
275
+
276
+ // ─── Debounce internals ────────────────────────────────────────────
277
+
278
+ private debounceKey(sessionId: string, channelId: string): string {
279
+ return `${sessionId}|${channelId}`;
280
+ }
281
+
282
+ private appendDebounced(
283
+ sessionId: string,
284
+ channelId: string,
285
+ text: string,
286
+ ): void {
287
+ const key = this.debounceKey(sessionId, channelId);
288
+ const existing = this.debounceMap.get(key);
289
+
290
+ if (existing) {
291
+ clearTimeout(existing.timer);
292
+ existing.buffer += text;
293
+ existing.timer = setTimeout(() => {
294
+ this.flushDebounced(sessionId, channelId);
295
+ }, DEBOUNCE_MS);
296
+ } else {
297
+ const timer = setTimeout(() => {
298
+ this.flushDebounced(sessionId, channelId);
299
+ }, DEBOUNCE_MS);
300
+ this.debounceMap.set(key, { buffer: text, timer });
301
+ }
302
+ }
303
+
304
+ private flushDebounced(sessionId: string, channelId: string): void {
305
+ const key = this.debounceKey(sessionId, channelId);
306
+ const entry = this.debounceMap.get(key);
307
+ if (!entry) return;
308
+
309
+ clearTimeout(entry.timer);
310
+ if (entry.buffer) {
311
+ console.log(`[NotificationRouter] flushDebounced -> session=${sessionId}, channel=${channelId}, bufferLen=${entry.buffer.length}`);
312
+ this.sendMessage(channelId, entry.buffer);
313
+ }
314
+ this.debounceMap.delete(key);
315
+ }
316
+
317
+ private cleanupSession(sessionId: string): void {
318
+ // Remove all debounce entries for this session
319
+ for (const key of this.debounceMap.keys()) {
320
+ if (key.startsWith(`${sessionId}|`)) {
321
+ const entry = this.debounceMap.get(key)!;
322
+ clearTimeout(entry.timer);
323
+ this.debounceMap.delete(key);
324
+ }
325
+ }
326
+ this.longRunningReminded.delete(sessionId);
327
+ }
328
+ }
329
+
330
+ // ─── Formatting helpers ──────────────────────────────────────────────
331
+
332
+ function formatCompletionNotification(session: Session): string {
333
+ const duration = formatDuration(session.duration);
334
+ const promptSummary =
335
+ session.prompt.length > 60
336
+ ? session.prompt.slice(0, 60) + "..."
337
+ : session.prompt;
338
+
339
+ if (session.status === "completed") {
340
+ return [
341
+ `✅ Claude Code [${session.id}] completed (${duration})`,
342
+ ` 📁 ${session.workdir}`,
343
+ ` 📝 "${promptSummary}"`,
344
+ ].join("\n");
345
+ }
346
+
347
+ if (session.status === "failed") {
348
+ const errorDetail = session.error
349
+ ? ` ⚠️ ${session.error}`
350
+ : session.result?.subtype
351
+ ? ` ⚠️ ${session.result.subtype}`
352
+ : "";
353
+ return [
354
+ `❌ Claude Code [${session.id}] failed (${duration})`,
355
+ ` 📁 ${session.workdir}`,
356
+ ` 📝 "${promptSummary}"`,
357
+ ...(errorDetail ? [errorDetail] : []),
358
+ ].join("\n");
359
+ }
360
+
361
+ if (session.status === "killed") {
362
+ return [
363
+ `⛔ Claude Code [${session.id}] killed (${duration})`,
364
+ ` 📁 ${session.workdir}`,
365
+ ` 📝 "${promptSummary}"`,
366
+ ].join("\n");
367
+ }
368
+
369
+ // Fallback
370
+ return `Session [${session.id}] finished with status: ${session.status}`;
371
+ }
372
+
373
+ /**
374
+ * Summarize tool input into a short string for compact display.
375
+ * Handles common Claude Code tools.
376
+ */
377
+ function summarizeToolInput(input: any): string {
378
+ if (!input || typeof input !== "object") return "";
379
+
380
+ // File operations: show the path
381
+ if (input.file_path) return truncate(input.file_path, 60);
382
+ if (input.path) return truncate(input.path, 60);
383
+
384
+ // Bash: show the command
385
+ if (input.command) return truncate(input.command, 80);
386
+
387
+ // Search: show the pattern
388
+ if (input.pattern) return truncate(input.pattern, 60);
389
+
390
+ // Glob
391
+ if (input.glob) return truncate(input.glob, 60);
392
+
393
+ // Generic: show first string value
394
+ const firstValue = Object.values(input).find(
395
+ (v) => typeof v === "string" && v.length > 0,
396
+ );
397
+ if (firstValue) return truncate(String(firstValue), 60);
398
+
399
+ return "";
400
+ }
401
+
402
+ function truncate(s: string, maxLen: number): string {
403
+ if (s.length <= maxLen) return s;
404
+ return s.slice(0, maxLen - 3) + "...";
405
+ }