@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
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
|
+
}
|