@vibearound/plugin-channel-sdk 0.1.2 → 0.4.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/renderer.ts CHANGED
@@ -25,36 +25,68 @@
25
25
  *
26
26
  * ## Usage
27
27
  *
28
+ * Subclass and implement `sendText` + `sendBlock` (+ optionally `editBlock`):
29
+ *
28
30
  * ```ts
29
31
  * class MyRenderer extends BlockRenderer<string> {
30
- * protected async sendBlock(channelId, kind, content) {
31
- * const msg = await myApi.sendMessage(channelId, content);
32
+ * protected async sendText(chatId, text) {
33
+ * await myApi.sendMessage(chatId, text);
34
+ * }
35
+ * protected async sendBlock(chatId, kind, content) {
36
+ * const msg = await myApi.sendMessage(chatId, content);
32
37
  * return msg.id;
33
38
  * }
34
- * protected async editBlock(channelId, ref, kind, content, sealed) {
39
+ * protected async editBlock(chatId, ref, kind, content, sealed) {
35
40
  * await myApi.editMessage(ref, content);
36
41
  * }
37
42
  * }
38
- *
39
- * // In main.ts:
40
- * const renderer = new MyRenderer({ verbose: { showThinking: false } });
41
- *
42
- * // When user sends a message:
43
- * renderer.onPromptSent(channelId);
44
- * try {
45
- * await agent.prompt({ sessionId, content });
46
- * await renderer.onTurnEnd(channelId);
47
- * } catch (e) {
48
- * await renderer.onTurnError(channelId, String(e));
49
- * }
50
- *
51
- * // In the ACP client's sessionUpdate handler:
52
- * renderer.onSessionUpdate(notification);
53
43
  * ```
44
+ *
45
+ * The SDK's `runChannelPlugin` wires all ACP events to this renderer
46
+ * automatically — plugins don't call onSessionUpdate/onPromptSent/etc
47
+ * directly.
54
48
  */
55
49
 
56
50
  import type { SessionNotification } from "@agentclientprotocol/sdk";
57
- import type { BlockKind, BlockRendererOptions, VerboseConfig } from "./types.js";
51
+ import type { BlockKind, BlockRendererOptions, CommandEntry, VerboseConfig } from "./types.js";
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Local ACP session-update narrowing
55
+ // ---------------------------------------------------------------------------
56
+ //
57
+ // The ACP SDK's `SessionNotification.update` is a discriminated union keyed on
58
+ // the `sessionUpdate` field, but the shape we get back at runtime varies by
59
+ // version. We only consume four variants here, so we define a narrow local
60
+ // view that documents exactly the fields this renderer depends on. A mismatch
61
+ // against the upstream type will show up as a compile error when the SDK is
62
+ // bumped, instead of silently producing `undefined` at runtime.
63
+
64
+ interface AgentMessageChunk {
65
+ sessionUpdate: "agent_message_chunk";
66
+ content?: { text?: string };
67
+ }
68
+
69
+ interface AgentThoughtChunk {
70
+ sessionUpdate: "agent_thought_chunk";
71
+ content?: { text?: string };
72
+ }
73
+
74
+ interface ToolCall {
75
+ sessionUpdate: "tool_call";
76
+ title?: string;
77
+ }
78
+
79
+ interface ToolCallUpdate {
80
+ sessionUpdate: "tool_call_update";
81
+ title?: string;
82
+ status?: string;
83
+ }
84
+
85
+ type ConsumedSessionUpdate =
86
+ | AgentMessageChunk
87
+ | AgentThoughtChunk
88
+ | ToolCall
89
+ | ToolCallUpdate;
58
90
 
59
91
  // ---------------------------------------------------------------------------
60
92
  // Internal state types
@@ -62,7 +94,7 @@ import type { BlockKind, BlockRendererOptions, VerboseConfig } from "./types.js"
62
94
 
63
95
  interface ManagedBlock<TRef> {
64
96
  /** Channel this block belongs to. Captured at creation time. */
65
- channelId: string;
97
+ chatId: string;
66
98
  kind: BlockKind;
67
99
  content: string;
68
100
  /** Platform message reference set after the first successful send. */
@@ -101,13 +133,21 @@ const DEFAULT_MIN_EDIT_INTERVAL_MS = 1000;
101
133
  * return type of `sendBlock` and the first argument of `editBlock`.
102
134
  */
103
135
  export abstract class BlockRenderer<TRef = string> {
136
+ /** When true, blocks are sent and edited in real-time. When false, each
137
+ * block is held until complete, then sent once (send-only mode). */
138
+ protected readonly streaming: boolean;
104
139
  protected readonly flushIntervalMs: number;
105
140
  protected readonly minEditIntervalMs: number;
106
141
  protected readonly verbose: VerboseConfig;
107
142
 
108
143
  private states = new Map<string, ChannelState<TRef>>();
109
144
 
145
+ /** The chatId of the most recent prompt. Used as fallback target for
146
+ * notifications that arrive without an explicit chatId. */
147
+ private lastActiveChatId: string | null = null;
148
+
110
149
  constructor(options: BlockRendererOptions = {}) {
150
+ this.streaming = options.streaming ?? true;
111
151
  this.flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
112
152
  this.minEditIntervalMs = options.minEditIntervalMs ?? DEFAULT_MIN_EDIT_INTERVAL_MS;
113
153
  this.verbose = {
@@ -117,21 +157,31 @@ export abstract class BlockRenderer<TRef = string> {
117
157
  }
118
158
 
119
159
  // ---------------------------------------------------------------------------
120
- // Abstract / overridable subclass implements these
160
+ // Abstract plugin MUST implement these
121
161
  // ---------------------------------------------------------------------------
122
162
 
123
163
  /**
124
- * Send a new block message to the platform.
164
+ * Send a plain text message to the IM. Used for system text, agent ready
165
+ * notifications, session ready, and error messages.
166
+ */
167
+ protected abstract sendText(chatId: string, text: string): Promise<void>;
168
+
169
+ /**
170
+ * Send a new streaming block message to the platform.
125
171
  *
126
172
  * Return the platform message reference that will be passed to future
127
173
  * `editBlock` calls. Return `null` if editing is not supported.
128
174
  */
129
175
  protected abstract sendBlock(
130
- channelId: string,
176
+ chatId: string,
131
177
  kind: BlockKind,
132
178
  content: string,
133
179
  ): Promise<TRef | null>;
134
180
 
181
+ // ---------------------------------------------------------------------------
182
+ // Optional overrides — plugin MAY implement these
183
+ // ---------------------------------------------------------------------------
184
+
135
185
  /**
136
186
  * Edit an existing block message in-place.
137
187
  *
@@ -142,7 +192,7 @@ export abstract class BlockRenderer<TRef = string> {
142
192
  * Use to switch from a "streaming" card format to a finalized one.
143
193
  */
144
194
  protected editBlock?(
145
- channelId: string,
195
+ chatId: string,
146
196
  ref: TRef,
147
197
  kind: BlockKind,
148
198
  content: string,
@@ -169,31 +219,18 @@ export abstract class BlockRenderer<TRef = string> {
169
219
 
170
220
  /**
171
221
  * Called after the last block has been flushed and the turn is complete.
172
- * Override to perform cleanup (e.g. remove a "typing" indicator, a
173
- * processing reaction, etc.).
174
- */
175
- protected onAfterTurnEnd(_channelId: string): Promise<void> {
176
- return Promise.resolve();
177
- }
178
-
179
- /**
180
- * Called after a turn error, once state has been cleaned up.
181
- * Override to send an error message to the user.
222
+ * Override to perform cleanup (e.g. remove a "typing" indicator).
182
223
  */
183
- protected onAfterTurnError(_channelId: string, _error: string): Promise<void> {
224
+ protected onAfterTurnEnd(_chatId: string): Promise<void> {
184
225
  return Promise.resolve();
185
226
  }
186
227
 
187
228
  /**
188
- * Map an ACP `sessionId` to the channel ID used internally.
189
- *
190
- * Default: identity (sessionId === channelId).
191
- *
192
- * Override if your plugin namespaces channel IDs (e.g. Feishu uses
193
- * `"feishu:<sessionId>"`, WeChat uses `"weixin-openclaw-bridge:<sessionId>"`).
229
+ * Called after a turn error. Default sends an error message via sendText.
230
+ * Override for platform-specific error rendering (e.g. error card).
194
231
  */
195
- protected sessionIdToChannelId(sessionId: string): string {
196
- return sessionId;
232
+ protected async onAfterTurnError(chatId: string, error: string): Promise<void> {
233
+ await this.sendText(chatId, `❌ Error: ${error}`);
197
234
  }
198
235
 
199
236
  // ---------------------------------------------------------------------------
@@ -206,39 +243,46 @@ export abstract class BlockRenderer<TRef = string> {
206
243
  * Routes the event to the correct block based on its variant, appending
207
244
  * deltas to the current block or starting a new one when the kind changes.
208
245
  *
209
- * Call this from the ACP `Client.sessionUpdate` handler.
246
+ * Called automatically by `runChannelPlugin` — plugins don't call this directly.
210
247
  */
211
248
  onSessionUpdate(notification: SessionNotification): void {
212
- const sessionId = notification.sessionId;
213
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
214
- const update = notification.update as any;
215
- const variant = update.sessionUpdate as string;
216
- const channelId = this.sessionIdToChannelId(sessionId);
249
+ // sessionId from ACP = chatId (the host replaces the real agent session
250
+ // ID with the chat ID before forwarding to the plugin).
251
+ const chatId = notification.sessionId;
252
+ const rawUpdate = notification.update as unknown as { sessionUpdate: string };
253
+ const variant = rawUpdate.sessionUpdate;
254
+ if (
255
+ variant !== "agent_message_chunk" &&
256
+ variant !== "agent_thought_chunk" &&
257
+ variant !== "tool_call" &&
258
+ variant !== "tool_call_update"
259
+ ) {
260
+ return;
261
+ }
262
+ const update = rawUpdate as ConsumedSessionUpdate;
217
263
 
218
- switch (variant) {
264
+ switch (update.sessionUpdate) {
219
265
  case "agent_message_chunk": {
220
- const delta = (update.content?.text ?? "") as string;
221
- if (delta) this.appendToBlock(channelId, "text", delta);
266
+ const delta = update.content?.text ?? "";
267
+ if (delta) this.appendToBlock(chatId, "text", delta);
222
268
  break;
223
269
  }
224
270
  case "agent_thought_chunk": {
225
- if (!this.verbose.showThinking) return; // skip — no block, no boundary
226
- const delta = (update.content?.text ?? "") as string;
227
- if (delta) this.appendToBlock(channelId, "thinking", delta);
271
+ if (!this.verbose.showThinking) return;
272
+ const delta = update.content?.text ?? "";
273
+ if (delta) this.appendToBlock(chatId, "thinking", delta);
228
274
  break;
229
275
  }
230
276
  case "tool_call": {
231
- if (!this.verbose.showToolUse) return; // skip
232
- const title = update.title as string | undefined;
233
- if (title) this.appendToBlock(channelId, "tool", `🔧 ${title}\n`);
277
+ if (!this.verbose.showToolUse) return;
278
+ if (update.title) this.appendToBlock(chatId, "tool", `🔧 ${update.title}\n`);
234
279
  break;
235
280
  }
236
281
  case "tool_call_update": {
237
- if (!this.verbose.showToolUse) return; // skip
238
- const title = (update.title ?? "tool") as string;
239
- const status = update.status as string | undefined;
240
- if (status === "completed" || status === "error") {
241
- this.appendToBlock(channelId, "tool", `✅ ${title}\n`);
282
+ if (!this.verbose.showToolUse) return;
283
+ const title = update.title ?? "tool";
284
+ if (update.status === "completed" || update.status === "error") {
285
+ this.appendToBlock(chatId, "tool", `✅ ${title}\n`);
242
286
  }
243
287
  break;
244
288
  }
@@ -246,15 +290,14 @@ export abstract class BlockRenderer<TRef = string> {
246
290
  }
247
291
 
248
292
  /**
249
- * Call this before sending a prompt to the agent.
250
- *
251
- * Clears any leftover state from a previous turn so the new turn starts
252
- * with a clean slate.
293
+ * Call before sending a prompt. Tracks the active chatId and clears
294
+ * leftover state from a previous turn.
253
295
  */
254
- onPromptSent(channelId: string): void {
255
- const old = this.states.get(channelId);
296
+ onPromptSent(chatId: string): void {
297
+ this.lastActiveChatId = chatId;
298
+ const old = this.states.get(chatId);
256
299
  if (old?.flushTimer) clearTimeout(old.flushTimer);
257
- this.states.set(channelId, {
300
+ this.states.set(chatId, {
258
301
  blocks: [],
259
302
  flushTimer: null,
260
303
  lastEditMs: 0,
@@ -262,14 +305,70 @@ export abstract class BlockRenderer<TRef = string> {
262
305
  });
263
306
  }
264
307
 
308
+ // ---------------------------------------------------------------------------
309
+ // Host notification handlers — built-in defaults, no per-plugin duplication
310
+ // ---------------------------------------------------------------------------
311
+
312
+ /** Handle `va/system_text` from host. */
313
+ onSystemText(chatId: string, text: string): void {
314
+ this.sendText(chatId, text).catch(() => {});
315
+ }
316
+
317
+ /** Handle `va/agent_ready` from host. */
318
+ onAgentReady(chatId: string, agent: string, version: string): void {
319
+ this.sendText(chatId, `🤖 Agent: ${agent} v${version}`).catch(() => {});
320
+ }
321
+
322
+ /** Handle `va/session_ready` from host. */
323
+ onSessionReady(chatId: string, sessionId: string): void {
324
+ this.sendText(chatId, `📋 Session: ${sessionId}`).catch(() => {});
325
+ }
326
+
327
+ /**
328
+ * Handle `va/command_menu` from host — display available commands.
329
+ *
330
+ * Default renders a plain-text list. Override for platform-specific
331
+ * rendering (e.g. Feishu interactive card, Slack Block Kit, Telegram
332
+ * inline keyboard).
333
+ */
334
+ onCommandMenu(
335
+ chatId: string,
336
+ systemCommands: CommandEntry[],
337
+ agentCommands: CommandEntry[],
338
+ ): void {
339
+ const lines: string[] = [];
340
+
341
+ lines.push("System commands:");
342
+ for (const cmd of systemCommands) {
343
+ const usage = cmd.args ? `/${cmd.name} ${cmd.args}` : `/${cmd.name}`;
344
+ lines.push(` ${usage} — ${cmd.description}`);
345
+ }
346
+
347
+ if (agentCommands.length > 0) {
348
+ lines.push("");
349
+ lines.push("Agent commands (use /agent <command>):");
350
+ for (const cmd of agentCommands) {
351
+ const desc = cmd.description.length > 80
352
+ ? `${cmd.description.slice(0, 77)}...`
353
+ : cmd.description;
354
+ lines.push(` /${cmd.name} — ${desc}`);
355
+ }
356
+ } else {
357
+ lines.push("");
358
+ lines.push("Agent commands will appear after sending your first message.");
359
+ }
360
+
361
+ this.sendText(chatId, lines.join("\n")).catch(() => {});
362
+ }
363
+
265
364
  /**
266
365
  * Call this after `agent.prompt()` resolves (turn complete).
267
366
  *
268
367
  * Seals and flushes the last block, then waits for all pending sends/edits
269
368
  * to complete before calling `onAfterTurnEnd`.
270
369
  */
271
- async onTurnEnd(channelId: string): Promise<void> {
272
- const state = this.states.get(channelId);
370
+ async onTurnEnd(chatId: string): Promise<void> {
371
+ const state = this.states.get(chatId);
273
372
  if (!state) return;
274
373
 
275
374
  if (state.flushTimer) {
@@ -283,35 +382,33 @@ export abstract class BlockRenderer<TRef = string> {
283
382
  this.enqueueFlush(state, last);
284
383
  }
285
384
 
286
- // Wait for the entire chain to drain before cleanup
287
385
  await state.sendChain;
288
- this.states.delete(channelId);
289
- await this.onAfterTurnEnd(channelId);
386
+ this.states.delete(chatId);
387
+ await this.onAfterTurnEnd(chatId);
290
388
  }
291
389
 
292
390
  /**
293
391
  * Call this when `agent.prompt()` throws (turn error).
294
392
  *
295
- * Discards pending state and calls `onAfterTurnError` so the subclass can
296
- * send an error message to the user.
393
+ * Discards pending state and calls `onAfterTurnError`.
297
394
  */
298
- async onTurnError(channelId: string, error: string): Promise<void> {
299
- const state = this.states.get(channelId);
395
+ async onTurnError(chatId: string, error: string): Promise<void> {
396
+ const state = this.states.get(chatId);
300
397
  if (state?.flushTimer) clearTimeout(state.flushTimer);
301
- this.states.delete(channelId);
302
- await this.onAfterTurnError(channelId, error);
398
+ this.states.delete(chatId);
399
+ await this.onAfterTurnError(chatId, error);
303
400
  }
304
401
 
305
402
  // ---------------------------------------------------------------------------
306
403
  // Internal — block management
307
404
  // ---------------------------------------------------------------------------
308
405
 
309
- private appendToBlock(channelId: string, kind: BlockKind, delta: string): void {
310
- let state = this.states.get(channelId);
406
+ private appendToBlock(chatId: string, kind: BlockKind, delta: string): void {
407
+ let state = this.states.get(chatId);
311
408
  if (!state) {
312
409
  // Auto-create state if onPromptSent wasn't called (e.g. host-initiated turns)
313
410
  state = { blocks: [], flushTimer: null, lastEditMs: 0, sendChain: Promise.resolve() };
314
- this.states.set(channelId, state);
411
+ this.states.set(chatId, state);
315
412
  }
316
413
 
317
414
  const last = state.blocks.at(-1);
@@ -330,25 +427,33 @@ export abstract class BlockRenderer<TRef = string> {
330
427
  }
331
428
  this.enqueueFlush(state, last);
332
429
  }
333
- state.blocks.push({ channelId, kind, content: delta, ref: null, creating: false, sealed: false });
430
+ state.blocks.push({ chatId, kind, content: delta, ref: null, creating: false, sealed: false });
334
431
  }
335
432
 
336
- this.scheduleFlush(channelId, state);
433
+ this.scheduleFlush(chatId, state);
337
434
  }
338
435
 
339
- private scheduleFlush(channelId: string, state: ChannelState<TRef>): void {
436
+ private scheduleFlush(chatId: string, state: ChannelState<TRef>): void {
340
437
  if (state.flushTimer) return; // already scheduled
341
438
 
342
439
  state.flushTimer = setTimeout(() => {
343
440
  state.flushTimer = null;
344
- this.flush(channelId, state);
441
+ this.flush(chatId, state);
345
442
  }, this.flushIntervalMs);
346
443
  }
347
444
 
348
- private flush(channelId: string, state: ChannelState<TRef>): void {
445
+ private flush(chatId: string, state: ChannelState<TRef>): void {
349
446
  const block = state.blocks.at(-1);
350
447
  if (!block || block.sealed || !block.content) return;
351
448
 
449
+ // Send-only mode (streaming=false): defer intermediate sends.
450
+ // Only sealed blocks (from onTurnEnd or block boundary transitions)
451
+ // will actually POST. This prevents the user seeing a partial chunk
452
+ // followed by the full message as two separate messages.
453
+ if (!this.streaming) {
454
+ return;
455
+ }
456
+
352
457
  const now = Date.now();
353
458
  if (now - state.lastEditMs < this.minEditIntervalMs) {
354
459
  // Throttled — reschedule for the remaining window
@@ -356,7 +461,7 @@ export abstract class BlockRenderer<TRef = string> {
356
461
  if (!state.flushTimer) {
357
462
  state.flushTimer = setTimeout(() => {
358
463
  state.flushTimer = null;
359
- this.flush(channelId, state);
464
+ this.flush(chatId, state);
360
465
  }, delay);
361
466
  }
362
467
  return;
@@ -379,12 +484,12 @@ export abstract class BlockRenderer<TRef = string> {
379
484
  if (block.ref === null && !block.creating) {
380
485
  // First send — use sentinel to prevent concurrent creates
381
486
  block.creating = true;
382
- block.ref = await this.sendBlock(block.channelId, block.kind, content);
487
+ block.ref = await this.sendBlock(block.chatId, block.kind, content);
383
488
  block.creating = false;
384
489
  state.lastEditMs = Date.now();
385
- } else if (block.ref !== null && !block.creating && this.editBlock) {
386
- // Subsequent update — edit in-place
387
- await this.editBlock(block.channelId, block.ref, block.kind, content, block.sealed);
490
+ } else if (block.ref !== null && !block.creating && this.streaming && this.editBlock) {
491
+ // Subsequent update — edit in-place (streaming mode only)
492
+ await this.editBlock(block.chatId, block.ref, block.kind, content, block.sealed);
388
493
  state.lastEditMs = Date.now();
389
494
  }
390
495
  // else: create is in-flight (creating === true) — skip
package/src/types.ts CHANGED
@@ -43,6 +43,18 @@ export interface PluginManifest {
43
43
  capabilities?: PluginCapabilities;
44
44
  }
45
45
 
46
+ // ---------------------------------------------------------------------------
47
+ // Command menu
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /** A command entry from the host's command list. */
51
+ export interface CommandEntry {
52
+ name: string;
53
+ description: string;
54
+ args?: string;
55
+ aliases?: string[];
56
+ }
57
+
46
58
  // ---------------------------------------------------------------------------
47
59
  // Block rendering
48
60
  // ---------------------------------------------------------------------------
@@ -58,6 +70,18 @@ export interface VerboseConfig {
58
70
  }
59
71
 
60
72
  export interface BlockRendererOptions {
73
+ /**
74
+ * Whether the IM platform supports message editing (streaming mode).
75
+ *
76
+ * - `true` (default): blocks stream in real-time — `sendBlock()` creates
77
+ * the message, `editBlock()` updates it as more content arrives.
78
+ * - `false`: each block is held until complete, then sent once via
79
+ * `sendBlock()`. `editBlock()` is never called.
80
+ *
81
+ * Set to `false` for platforms that don't support editing sent messages
82
+ * (e.g. QQ Bot, WhatsApp, WeChat, LINE).
83
+ */
84
+ streaming?: boolean;
61
85
  /**
62
86
  * Debounce interval before flushing an unsealed block (ms).
63
87
  * Controls how often in-progress blocks are sent to the platform.