@inceptionstack/roundhouse 0.2.2 → 0.3.1

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.
Files changed (49) hide show
  1. package/README.md +321 -9
  2. package/architecture.md +77 -8
  3. package/package.json +9 -6
  4. package/src/agents/pi.ts +433 -26
  5. package/src/agents/registry.ts +8 -0
  6. package/src/cli/cli.ts +384 -189
  7. package/src/cli/cron.ts +296 -0
  8. package/src/cli/doctor/checks/agent.ts +68 -0
  9. package/src/cli/doctor/checks/config.ts +88 -0
  10. package/src/cli/doctor/checks/credentials.ts +62 -0
  11. package/src/cli/doctor/checks/disk.ts +69 -0
  12. package/src/cli/doctor/checks/stt.ts +76 -0
  13. package/src/cli/doctor/checks/system.ts +86 -0
  14. package/src/cli/doctor/checks/systemd.ts +76 -0
  15. package/src/cli/doctor/output.ts +58 -0
  16. package/src/cli/doctor/runner.ts +142 -0
  17. package/src/cli/doctor/shell.ts +33 -0
  18. package/src/cli/doctor/types.ts +44 -0
  19. package/src/cli/doctor.ts +48 -0
  20. package/src/cli/setup-telegram.ts +148 -0
  21. package/src/cli/setup.ts +936 -0
  22. package/src/commands.ts +23 -0
  23. package/src/config.ts +188 -0
  24. package/src/cron/constants.ts +54 -0
  25. package/src/cron/durations.ts +33 -0
  26. package/src/cron/format.ts +139 -0
  27. package/src/cron/helpers.ts +30 -0
  28. package/src/cron/runner.ts +148 -0
  29. package/src/cron/schedule.ts +101 -0
  30. package/src/cron/scheduler.ts +295 -0
  31. package/src/cron/store.ts +125 -0
  32. package/src/cron/template.ts +89 -0
  33. package/src/cron/types.ts +76 -0
  34. package/src/gateway.ts +927 -18
  35. package/src/index.ts +1 -58
  36. package/src/memory/bootstrap.ts +98 -0
  37. package/src/memory/files.ts +100 -0
  38. package/src/memory/inject.ts +41 -0
  39. package/src/memory/lifecycle.ts +245 -0
  40. package/src/memory/policy.ts +122 -0
  41. package/src/memory/prompts.ts +42 -0
  42. package/src/memory/state.ts +43 -0
  43. package/src/memory/types.ts +90 -0
  44. package/src/notify/telegram.ts +48 -0
  45. package/src/types.ts +68 -1
  46. package/src/util.ts +28 -2
  47. package/src/voice/providers/whisper.ts +339 -0
  48. package/src/voice/stt-service.ts +284 -0
  49. package/src/voice/types.ts +63 -0
package/src/agents/pi.ts CHANGED
@@ -7,8 +7,12 @@
7
7
  */
8
8
 
9
9
  import { mkdir } from "node:fs/promises";
10
- import { join } from "node:path";
10
+ import { readFileSync } from "node:fs";
11
+ import { join, dirname } from "node:path";
11
12
  import { homedir } from "node:os";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ const __piAdapterDir = dirname(fileURLToPath(import.meta.url));
12
16
 
13
17
  import {
14
18
  AuthStorage,
@@ -19,8 +23,8 @@ import {
19
23
  type AgentSessionEvent,
20
24
  } from "@mariozechner/pi-coding-agent";
21
25
 
22
- import type { AgentAdapter, AgentAdapterFactory, AgentResponse } from "../types";
23
- import { threadIdToDir } from "../util";
26
+ import type { AgentAdapter, AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent } from "../types";
27
+ import { DEBUG_STREAM, threadIdToDir } from "../util";
24
28
 
25
29
  interface SessionEntry {
26
30
  session: AgentSession;
@@ -45,10 +49,115 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
45
49
  const authStorage = AuthStorage.create();
46
50
  const modelRegistry = ModelRegistry.create(authStorage);
47
51
  const sessions = new Map<string, SessionEntry>();
48
- // Track in-flight session creation to prevent races
49
52
  const creating = new Map<string, Promise<SessionEntry>>();
53
+ let memoryCapabilities: { hasMemoryExtension: boolean; memoryTools: string[]; extensions: string[] } | undefined;
50
54
  let reapInterval: ReturnType<typeof setInterval> | undefined;
51
55
 
56
+ async function drainSessionEvents(session: AgentSession): Promise<void> {
57
+ // AgentSession._handleAgentEvent queues event processing on a private
58
+ // promise chain (_agentEventQueue). session.prompt() / agent.continue()
59
+ // resolve when the agent loop finishes, but NOT when the queue drains.
60
+ // We must await the queue so that:
61
+ // 1. agent_end extension handlers (e.g. pi-lgtm review) complete
62
+ // 2. followUp messages they queue are visible via hasQueuedMessages()
63
+ // 3. message_end events for custom messages reach our subscribe() handler
64
+ // BEFORE we unsubscribe in the finally block.
65
+ //
66
+ // WARNING: _agentEventQueue is a private field of AgentSession (not part
67
+ // of the public pi-coding-agent API). Tested against
68
+ // @mariozechner/pi-coding-agent version bundled via `latest` in
69
+ // package.json at the time of this commit. If upstream renames or changes
70
+ // this field, extension custom messages (e.g. pi-lgtm review bubbles)
71
+ // will stop reaching Telegram. The `if (queue)` check fails silently
72
+ // on purpose because a missing field is not fatal — it just reverts to
73
+ // the pre-fix race condition. A public `session.flushEvents()` or
74
+ // `session.waitForIdle()` upstream would obsolete this.
75
+ const queue = (session as unknown as { _agentEventQueue?: Promise<void> })._agentEventQueue;
76
+ if (queue) {
77
+ await queue;
78
+ }
79
+ }
80
+
81
+ function customContentToText(content: unknown): string {
82
+ if (typeof content === "string") return content;
83
+ if (Array.isArray(content)) {
84
+ return content
85
+ .filter((part): part is { type: "text"; text: string } =>
86
+ !!part && typeof part === "object" && (part as any).type === "text"
87
+ )
88
+ .map((part) => part.text)
89
+ .join("");
90
+ }
91
+ return "";
92
+ }
93
+
94
+ /**
95
+ * Extract displayable text from a session event if it is an extension custom
96
+ * message (e.g. pi-lgtm review) with display=true. Returns null otherwise.
97
+ * Shared helper so promptStream() and doPrompt() use identical filter logic.
98
+ */
99
+ function extractCustomMessage(event: AgentSessionEvent): { customType: string; content: string } | null {
100
+ if (event.type !== "message_end") return null;
101
+ const message = (event as any).message;
102
+ if (!message || message.role !== "custom" || !message.display) return null;
103
+ const content = customContentToText(message.content);
104
+ if (!content.trim()) return null;
105
+ const customType = message.customType ?? "";
106
+ return { customType, content };
107
+ }
108
+
109
+ async function runPromptAndFollowUps(entry: SessionEntry, text: string, onDraining?: () => void, onDrainComplete?: () => void): Promise<void> {
110
+ await entry.session.prompt(text);
111
+ await drainSessionEvents(entry.session);
112
+
113
+ // Check for pending follow-up work AFTER drainSessionEvents — that's
114
+ // where agent_end extension handlers run and queue follow-up messages
115
+ // (e.g. pi-lgtm calls sendMessage with deliverAs: "followUp").
116
+ // The actual long wait is in the while loop's waitForIdle() below.
117
+ let notifiedDraining = false;
118
+ if (onDraining && (entry.session.isStreaming || entry.session.agent.hasQueuedMessages())) {
119
+ onDraining();
120
+ notifiedDraining = true;
121
+ }
122
+
123
+ // Loop until the session is fully idle. Two separate conditions can keep
124
+ // work in flight after the initial prompt resolves:
125
+ //
126
+ // (a) `hasQueuedMessages()` — an extension called `pi.sendMessage(...,
127
+ // { triggerTurn: true, deliverAs: "followUp" })` *while isStreaming
128
+ // was true* — pi queued onto `agent.followUp()`, so we manually
129
+ // drain it with `continue()`.
130
+ //
131
+ // (b) `isStreaming === true` — an extension called the same sendMessage
132
+ // *after isStreaming became false*. In that path pi's
133
+ // `sendCustomMessage` skips the queue entirely and calls
134
+ // `agent.prompt(appMessage)` directly as fire-and-forget, kicking
135
+ // off a brand-new agent run. `hasQueuedMessages()` returns false
136
+ // for this run, but `isStreaming` is true — we have to
137
+ // `waitForIdle()` so subscribers see the new run's events (e.g. the
138
+ // agent's reply to a pi-lgtm code review) before we unsubscribe.
139
+ //
140
+ // Without (b), pi CLI works (its subscriber stays attached across runs)
141
+ // but roundhouse delivers the review bubble then goes silent.
142
+ while (true) {
143
+ if (entry.session.isStreaming) {
144
+ await entry.session.agent.waitForIdle();
145
+ await drainSessionEvents(entry.session);
146
+ continue;
147
+ }
148
+ if (entry.session.agent.hasQueuedMessages()) {
149
+ await entry.session.agent.continue();
150
+ await drainSessionEvents(entry.session);
151
+ continue;
152
+ }
153
+ break;
154
+ }
155
+
156
+ if (notifiedDraining && onDrainComplete) {
157
+ onDrainComplete();
158
+ }
159
+ }
160
+
52
161
  async function createSession(threadId: string): Promise<SessionEntry> {
53
162
  const threadDir = join(sessionsDir, threadIdToDir(threadId));
54
163
  await mkdir(threadDir, { recursive: true });
@@ -74,21 +183,56 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
74
183
  modelRegistry,
75
184
  });
76
185
 
186
+ if (result.extensionsResult.extensions.length > 0) {
187
+ console.log(
188
+ `[pi-agent] extensions loaded: ${result.extensionsResult.extensions.map((e: any) => e.name || e.path).join(", ")}`
189
+ );
190
+ } else {
191
+ console.log(`[pi-agent] no extensions loaded`);
192
+ }
193
+ if (result.extensionsResult.errors.length > 0) {
194
+ for (const err of result.extensionsResult.errors) {
195
+ console.warn(`[pi-agent] extension error: ${err.path}: ${err.error}`);
196
+ }
197
+ }
198
+
77
199
  if (result.modelFallbackMessage) {
78
200
  console.log(`[pi-agent] model fallback: ${result.modelFallbackMessage}`);
79
201
  }
80
202
 
81
203
  const entry: SessionEntry = { session: result.session, lastUsed: Date.now() };
82
204
  sessions.set(threadId, entry);
205
+
206
+ // Detect memory capabilities from loaded extensions (first session only)
207
+ if (!memoryCapabilities) {
208
+ const allTools = new Set<string>();
209
+ const extNames: string[] = [];
210
+ for (const ext of result.extensionsResult.extensions) {
211
+ extNames.push(ext.sourceInfo?.source || ext.path);
212
+ for (const toolName of ext.tools.keys()) {
213
+ allTools.add(toolName);
214
+ }
215
+ }
216
+ memoryCapabilities = {
217
+ hasMemoryExtension: allTools.has("memory_search") || allTools.has("memory_remember"),
218
+ memoryTools: ["memory_search", "memory_remember", "memory_forget", "memory_lessons", "memory_stats"]
219
+ .filter(t => allTools.has(t)),
220
+ extensions: extNames,
221
+ };
222
+ if (memoryCapabilities.hasMemoryExtension) {
223
+ console.log(`[pi-agent] memory extension detected (tools: ${memoryCapabilities.memoryTools.join(", ")})`);
224
+ } else {
225
+ console.log(`[pi-agent] no memory extension detected — roundhouse memory will manage`);
226
+ }
227
+ }
228
+
83
229
  return entry;
84
230
  }
85
231
 
86
232
  async function getOrCreate(threadId: string): Promise<SessionEntry> {
87
- // Fast path: already created
88
233
  const existing = sessions.get(threadId);
89
234
  if (existing) return existing;
90
235
 
91
- // Prevent concurrent creation for the same threadId
92
236
  let pending = creating.get(threadId);
93
237
  if (pending) return pending;
94
238
 
@@ -110,34 +254,173 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
110
254
  }
111
255
  }
112
256
 
113
- // Start reaper (unref so it doesn't prevent Node from exiting)
114
257
  reapInterval = setInterval(reap, 60_000);
115
258
  reapInterval.unref();
116
259
 
260
+ // Per-thread serialization for both prompt() and promptStream()
261
+ const threadQueues = new Map<string, Promise<any>>();
262
+
263
+ function enqueue<T>(threadId: string, fn: () => Promise<T>): Promise<T> {
264
+ const previous = threadQueues.get(threadId) ?? Promise.resolve();
265
+ const current = previous.catch(() => {}).then(fn);
266
+ threadQueues.set(threadId, current);
267
+ return current.finally(() => {
268
+ if (threadQueues.get(threadId) === current) {
269
+ threadQueues.delete(threadId);
270
+ }
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Format an AgentMessage into the text string sent to the Pi session.
276
+ * Attachments are rendered as a fenced JSON manifest appended to the user text.
277
+ */
278
+ function formatMessage(message: AgentMessage): string {
279
+ let text = message.text;
280
+ if (message.attachments?.length) {
281
+ const manifest = JSON.stringify(
282
+ message.attachments.map((a) => {
283
+ const entry: Record<string, unknown> = {
284
+ id: a.id,
285
+ type: a.mediaType,
286
+ name: a.name,
287
+ localPath: a.localPath,
288
+ mime: a.mime,
289
+ sizeBytes: a.sizeBytes,
290
+ untrusted: true,
291
+ };
292
+ if (a.transcript?.status === "completed" && a.transcript.text) {
293
+ entry.transcript = {
294
+ text: a.transcript.text,
295
+ language: a.transcript.language,
296
+ provider: a.transcript.provider,
297
+ approximate: true,
298
+ };
299
+ } else if (a.transcript?.status === "failed") {
300
+ entry.transcript = {
301
+ status: "failed",
302
+ error: a.transcript.error,
303
+ approximate: true,
304
+ };
305
+ }
306
+ return entry;
307
+ }),
308
+ null,
309
+ 2,
310
+ );
311
+ const block = [
312
+ "Chat attachments saved locally. Inspect files with tools before making claims. Transcripts are approximate; use the raw file if exact wording matters.",
313
+ "```json",
314
+ manifest,
315
+ "```",
316
+ ].join("\n");
317
+ text = text ? `${text}\n\n${block}` : block;
318
+ }
319
+ return text;
320
+ }
321
+
117
322
  const adapter: AgentAdapter = {
118
323
  name: "pi",
119
324
 
120
- async prompt(threadId: string, text: string): Promise<AgentResponse> {
121
- const entry = await getOrCreate(threadId);
122
- entry.lastUsed = Date.now();
123
-
124
- let fullText = "";
125
- const unsub = entry.session.subscribe((event: AgentSessionEvent) => {
126
- if (
127
- event.type === "message_update" &&
128
- event.assistantMessageEvent.type === "text_delta"
129
- ) {
130
- fullText += event.assistantMessageEvent.delta;
131
- }
132
- });
325
+ async prompt(threadId: string, message: AgentMessage): Promise<AgentResponse> {
326
+ return enqueue(threadId, () => doPrompt(threadId, formatMessage(message)));
327
+ },
133
328
 
134
- try {
135
- await entry.session.prompt(text);
136
- } finally {
137
- unsub();
138
- }
329
+ promptStream(threadId: string, message: AgentMessage): AsyncIterable<AgentStreamEvent> {
330
+ const text = formatMessage(message);
331
+ // Return an async iterable that is single-use by design.
332
+ // State is scoped inside the iterator factory to prevent sharing.
333
+ let consumed = false;
334
+
335
+ return {
336
+ [Symbol.asyncIterator]() {
337
+ if (consumed) throw new Error("promptStream() iterable can only be consumed once");
338
+ consumed = true;
339
+
340
+ let eventQueue: AgentStreamEvent[] = [];
341
+ let resolve: (() => void) | null = null;
342
+ let done = false;
343
+ let error: Error | null = null;
344
+
345
+ // Start the prompt in the thread queue
346
+ const promptDone = enqueue(threadId, async () => {
347
+ const entry = await getOrCreate(threadId);
348
+ entry.lastUsed = Date.now();
349
+
350
+ const unsub = entry.session.subscribe((event: AgentSessionEvent) => {
351
+ if (DEBUG_STREAM) {
352
+ const extra =
353
+ event.type === "message_end" || event.type === "message_start"
354
+ ? ` role=${(event as any).message?.role}`
355
+ : event.type === "message_update"
356
+ ? ` subType=${(event as any).assistantMessageEvent?.type}`
357
+ : "";
358
+ console.log(`[pi-agent/sub] event=${event.type}${extra}`);
359
+ }
360
+ let streamEvent: AgentStreamEvent | null = null;
361
+
362
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
363
+ streamEvent = { type: "text_delta", text: event.assistantMessageEvent.delta };
364
+ } else {
365
+ const custom = extractCustomMessage(event);
366
+ if (custom) {
367
+ streamEvent = { type: "custom_message", customType: custom.customType, content: custom.content };
368
+ } else if (event.type === "tool_execution_start") {
369
+ streamEvent = { type: "tool_start", toolName: event.toolName, toolCallId: event.toolCallId };
370
+ } else if (event.type === "tool_execution_end") {
371
+ streamEvent = { type: "tool_end", toolName: event.toolName, toolCallId: event.toolCallId, isError: event.isError };
372
+ } else if (event.type === "turn_end") {
373
+ streamEvent = { type: "turn_end" };
374
+ }
375
+ }
139
376
 
140
- return { text: fullText };
377
+ if (streamEvent) {
378
+ eventQueue.push(streamEvent);
379
+ resolve?.();
380
+ }
381
+ });
382
+
383
+ try {
384
+ await runPromptAndFollowUps(entry, text, () => {
385
+ eventQueue.push({ type: "draining" });
386
+ resolve?.();
387
+ }, () => {
388
+ eventQueue.push({ type: "drain_complete" });
389
+ resolve?.();
390
+ });
391
+ // Final drain — guarantees all subscriber events have been delivered
392
+ // before we unsubscribe below.
393
+ await drainSessionEvents(entry.session);
394
+ } finally {
395
+ unsub();
396
+ eventQueue.push({ type: "agent_end" });
397
+ done = true;
398
+ resolve?.();
399
+ }
400
+ });
401
+
402
+ promptDone.catch((err) => {
403
+ error = err instanceof Error ? err : new Error(String(err));
404
+ done = true;
405
+ resolve?.();
406
+ });
407
+
408
+ return {
409
+ async next(): Promise<IteratorResult<AgentStreamEvent>> {
410
+ while (true) {
411
+ if (eventQueue.length > 0) {
412
+ return { value: eventQueue.shift()!, done: false };
413
+ }
414
+ if (error) throw error;
415
+ if (done) return { value: undefined as any, done: true };
416
+ // Wait for next event
417
+ await new Promise<void>((r) => { resolve = r; });
418
+ resolve = null;
419
+ }
420
+ },
421
+ };
422
+ },
423
+ };
141
424
  },
142
425
 
143
426
  async dispose(): Promise<void> {
@@ -147,8 +430,132 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
147
430
  }
148
431
  sessions.clear();
149
432
  creating.clear();
433
+ threadQueues.clear();
434
+ },
435
+
436
+ async restart(threadId: string): Promise<void> {
437
+ await enqueue(threadId, async () => {
438
+ const existing = sessions.get(threadId);
439
+ if (existing) {
440
+ existing.session.dispose();
441
+ sessions.delete(threadId);
442
+ console.log(`[pi-agent] disposed session for ${threadId}`);
443
+ }
444
+ memoryCapabilities = undefined; // re-detect on next session creation
445
+ // Next prompt() or promptStream() call will create a fresh session
446
+ });
447
+ },
448
+
449
+ async compact(threadId: string): Promise<{ tokensBefore: number; tokensAfter: number | null } | null> {
450
+ return enqueue(threadId, async () => {
451
+ const entry = sessions.get(threadId);
452
+ if (!entry) return null;
453
+
454
+ const result = await entry.session.compact();
455
+ const usage = entry.session.getContextUsage();
456
+ return {
457
+ tokensBefore: result.tokensBefore,
458
+ tokensAfter: usage?.tokens ?? null,
459
+ };
460
+ });
461
+ },
462
+
463
+ async abort(threadId: string): Promise<void> {
464
+ const entry = sessions.get(threadId);
465
+ if (entry) {
466
+ await entry.session.abort();
467
+ entry.session.abortCompaction();
468
+ console.log(`[pi-agent] aborted session for ${threadId}`);
469
+ }
470
+ },
471
+
472
+ getInfo(threadId?: string): Record<string, unknown> {
473
+ // Get model from the requested thread's session, or most recently used
474
+ let modelInfo: string | undefined;
475
+ let contextUsage: { tokens: number | null; contextWindow: number; percent: number | null } | undefined;
476
+ const threadEntry = threadId ? sessions.get(threadId) : undefined;
477
+
478
+ if (threadEntry) {
479
+ const model = threadEntry.session.model;
480
+ if (model) modelInfo = `${model.provider}/${model.id}`;
481
+ contextUsage = threadEntry.session.getContextUsage() ?? undefined;
482
+ }
483
+
484
+ if (!modelInfo) {
485
+ let latestUsed = 0;
486
+ for (const [, entry] of sessions) {
487
+ if (entry.lastUsed > latestUsed) {
488
+ latestUsed = entry.lastUsed;
489
+ const model = entry.session.model;
490
+ if (model) modelInfo = `${model.provider}/${model.id}`;
491
+ if (!contextUsage) contextUsage = entry.session.getContextUsage() ?? undefined;
492
+ }
493
+ }
494
+ }
495
+
496
+ // Fall back to configured default from settings.json
497
+ if (!modelInfo) {
498
+ try {
499
+ const settingsPath = join(homedir(), ".pi", "agent", "settings.json");
500
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
501
+ if (settings.defaultProvider && settings.defaultModel) {
502
+ modelInfo = `${settings.defaultProvider}/${settings.defaultModel}`;
503
+ }
504
+ } catch (err) {
505
+ console.warn(`[pi-agent] could not read settings.json for model info:`, (err as Error).message);
506
+ }
507
+ }
508
+
509
+ // Read agent version
510
+ let version = "unknown";
511
+ try {
512
+ const piPkgPath = join(__piAdapterDir, "..", "..", "node_modules", "@mariozechner", "pi-coding-agent", "package.json");
513
+ version = JSON.parse(readFileSync(piPkgPath, "utf8")).version;
514
+ } catch {}
515
+
516
+ return {
517
+ version,
518
+ model: modelInfo ?? "unknown",
519
+ activeSessions: sessions.size,
520
+ cwd,
521
+ contextTokens: contextUsage?.tokens ?? null,
522
+ contextWindow: contextUsage?.contextWindow ?? null,
523
+ contextPercent: contextUsage?.percent ?? null,
524
+ hasMemoryExtension: memoryCapabilities?.hasMemoryExtension ?? null,
525
+ memoryTools: memoryCapabilities?.memoryTools ?? [],
526
+ extensions: memoryCapabilities?.extensions ?? [],
527
+ };
150
528
  },
151
529
  };
152
530
 
531
+ async function doPrompt(threadId: string, text: string): Promise<AgentResponse> {
532
+ const entry = await getOrCreate(threadId);
533
+ entry.lastUsed = Date.now();
534
+
535
+ let fullText = "";
536
+ const unsub = entry.session.subscribe((event: AgentSessionEvent) => {
537
+ if (
538
+ event.type === "message_update" &&
539
+ event.assistantMessageEvent.type === "text_delta"
540
+ ) {
541
+ fullText += event.assistantMessageEvent.delta;
542
+ } else {
543
+ const custom = extractCustomMessage(event);
544
+ if (custom) {
545
+ fullText += "\n\n" + custom.content;
546
+ }
547
+ }
548
+ });
549
+
550
+ try {
551
+ await runPromptAndFollowUps(entry, text);
552
+ await drainSessionEvents(entry.session);
553
+ } finally {
554
+ unsub();
555
+ }
556
+
557
+ return { text: fullText };
558
+ }
559
+
153
560
  return adapter;
154
561
  };
@@ -9,9 +9,12 @@ import type { AgentAdapterFactory } from "../types";
9
9
  import { createPiAgentAdapter } from "./pi";
10
10
 
11
11
  const registry = new Map<string, AgentAdapterFactory>();
12
+ const sdkPackages = new Map<string, string>();
12
13
 
13
14
  registry.set("pi", createPiAgentAdapter);
15
+ sdkPackages.set("pi", "@mariozechner/pi-coding-agent");
14
16
  // registry.set("kiro", createKiroAgentAdapter);
17
+ // sdkPackages.set("kiro", "@kiro/...");
15
18
 
16
19
  export function getAgentFactory(type: string): AgentAdapterFactory {
17
20
  const factory = registry.get(type);
@@ -23,3 +26,8 @@ export function getAgentFactory(type: string): AgentAdapterFactory {
23
26
  }
24
27
  return factory;
25
28
  }
29
+
30
+ /** Get the npm package name for an agent type's SDK (for version display) */
31
+ export function getAgentSdkPackage(type: string): string | undefined {
32
+ return sdkPackages.get(type);
33
+ }