@agentuity/coder 1.0.37

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 (92) hide show
  1. package/README.md +57 -0
  2. package/dist/chain-preview.d.ts +55 -0
  3. package/dist/chain-preview.d.ts.map +1 -0
  4. package/dist/chain-preview.js +472 -0
  5. package/dist/chain-preview.js.map +1 -0
  6. package/dist/client.d.ts +43 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +402 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/commands.d.ts +22 -0
  11. package/dist/commands.d.ts.map +1 -0
  12. package/dist/commands.js +99 -0
  13. package/dist/commands.js.map +1 -0
  14. package/dist/footer.d.ts +34 -0
  15. package/dist/footer.d.ts.map +1 -0
  16. package/dist/footer.js +249 -0
  17. package/dist/footer.js.map +1 -0
  18. package/dist/handlers.d.ts +24 -0
  19. package/dist/handlers.d.ts.map +1 -0
  20. package/dist/handlers.js +83 -0
  21. package/dist/handlers.js.map +1 -0
  22. package/dist/hub-overlay.d.ts +107 -0
  23. package/dist/hub-overlay.d.ts.map +1 -0
  24. package/dist/hub-overlay.js +1794 -0
  25. package/dist/hub-overlay.js.map +1 -0
  26. package/dist/index.d.ts +4 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +1585 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/output-viewer.d.ts +49 -0
  31. package/dist/output-viewer.d.ts.map +1 -0
  32. package/dist/output-viewer.js +389 -0
  33. package/dist/output-viewer.js.map +1 -0
  34. package/dist/overlay.d.ts +40 -0
  35. package/dist/overlay.d.ts.map +1 -0
  36. package/dist/overlay.js +225 -0
  37. package/dist/overlay.js.map +1 -0
  38. package/dist/protocol.d.ts +118 -0
  39. package/dist/protocol.d.ts.map +1 -0
  40. package/dist/protocol.js +3 -0
  41. package/dist/protocol.js.map +1 -0
  42. package/dist/remote-session.d.ts +113 -0
  43. package/dist/remote-session.d.ts.map +1 -0
  44. package/dist/remote-session.js +645 -0
  45. package/dist/remote-session.js.map +1 -0
  46. package/dist/remote-tui.d.ts +40 -0
  47. package/dist/remote-tui.d.ts.map +1 -0
  48. package/dist/remote-tui.js +606 -0
  49. package/dist/remote-tui.js.map +1 -0
  50. package/dist/renderers.d.ts +34 -0
  51. package/dist/renderers.d.ts.map +1 -0
  52. package/dist/renderers.js +669 -0
  53. package/dist/renderers.js.map +1 -0
  54. package/dist/review.d.ts +15 -0
  55. package/dist/review.d.ts.map +1 -0
  56. package/dist/review.js +154 -0
  57. package/dist/review.js.map +1 -0
  58. package/dist/titlebar.d.ts +3 -0
  59. package/dist/titlebar.d.ts.map +1 -0
  60. package/dist/titlebar.js +59 -0
  61. package/dist/titlebar.js.map +1 -0
  62. package/dist/todo/index.d.ts +3 -0
  63. package/dist/todo/index.d.ts.map +1 -0
  64. package/dist/todo/index.js +3 -0
  65. package/dist/todo/index.js.map +1 -0
  66. package/dist/todo/store.d.ts +6 -0
  67. package/dist/todo/store.d.ts.map +1 -0
  68. package/dist/todo/store.js +43 -0
  69. package/dist/todo/store.js.map +1 -0
  70. package/dist/todo/types.d.ts +13 -0
  71. package/dist/todo/types.d.ts.map +1 -0
  72. package/dist/todo/types.js +2 -0
  73. package/dist/todo/types.js.map +1 -0
  74. package/package.json +44 -0
  75. package/src/chain-preview.ts +621 -0
  76. package/src/client.ts +515 -0
  77. package/src/commands.ts +132 -0
  78. package/src/footer.ts +305 -0
  79. package/src/handlers.ts +113 -0
  80. package/src/hub-overlay.ts +2324 -0
  81. package/src/index.ts +1907 -0
  82. package/src/output-viewer.ts +480 -0
  83. package/src/overlay.ts +294 -0
  84. package/src/protocol.ts +157 -0
  85. package/src/remote-session.ts +800 -0
  86. package/src/remote-tui.ts +707 -0
  87. package/src/renderers.ts +740 -0
  88. package/src/review.ts +201 -0
  89. package/src/titlebar.ts +63 -0
  90. package/src/todo/index.ts +2 -0
  91. package/src/todo/store.ts +49 -0
  92. package/src/todo/types.ts +14 -0
package/src/index.ts ADDED
@@ -0,0 +1,1907 @@
1
+ import type {
2
+ AgentToolResult,
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ ExtensionCommandContext,
6
+ ToolDefinition,
7
+ } from '@mariozechner/pi-coding-agent';
8
+ import { Type, type TSchema } from '@sinclair/typebox';
9
+ import { createRequire } from 'node:module';
10
+ import { HubClient } from './client.ts';
11
+ import type { ConnectionState } from './client.ts';
12
+ import { processActions } from './handlers.ts';
13
+ import { getToolRenderers } from './renderers.ts';
14
+ import { setupCoderFooter, type ObserverState } from './footer.ts';
15
+ import { setupTitlebar } from './titlebar.ts';
16
+ import { registerAgentCommands } from './commands.ts';
17
+ import { AgentManagerOverlay } from './overlay.ts';
18
+ import { ChainEditorOverlay, type ChainResult } from './chain-preview.ts';
19
+ import { HubOverlay } from './hub-overlay.ts';
20
+ import { OutputViewerOverlay, type StoredResult } from './output-viewer.ts';
21
+ import type {
22
+ HubAction,
23
+ HubResponse,
24
+ InitMessage,
25
+ HubConfig,
26
+ HubToolDefinition,
27
+ AgentDefinition,
28
+ AgentProgressUpdate,
29
+ } from './protocol.ts';
30
+ import {
31
+ setupRemoteMode,
32
+ type RemoteSession,
33
+ type RemoteSessionInternal,
34
+ } from './remote-session.ts';
35
+
36
+ // ESM doesn't have require() — create one for synchronous child_process access
37
+ const _require = createRequire(import.meta.url);
38
+
39
+ const HUB_URL_ENV = 'AGENTUITY_CODER_HUB_URL';
40
+ const AGENT_ENV = 'AGENTUITY_CODER_AGENT';
41
+ const REMOTE_SESSION_ENV = 'AGENTUITY_CODER_REMOTE_SESSION';
42
+ const NATIVE_REMOTE_ENV = 'AGENTUITY_CODER_NATIVE_REMOTE';
43
+ // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
44
+ const API_KEY_ENV = 'AGENTUITY_CODER_API_KEY';
45
+ const API_KEY_HEADER = 'x-agentuity-auth-api-key';
46
+ const RECONNECT_WAIT_TIMEOUT_MS = 120_000;
47
+
48
+ type HubUiStatus = 'connected' | 'reconnecting' | 'offline';
49
+
50
+ // Recent agent results for full-screen viewer (Ctrl+Shift+V / Alt+Shift+V)
51
+ const recentResults: StoredResult[] = [];
52
+ const MAX_STORED_RESULTS = 20;
53
+
54
+ function startStreamingResult(
55
+ agentName: string,
56
+ description?: string,
57
+ prompt?: string
58
+ ): StoredResult {
59
+ const result: StoredResult = {
60
+ agentName,
61
+ text: '',
62
+ thinking: '',
63
+ timestamp: Date.now(),
64
+ isStreaming: true,
65
+ description,
66
+ prompt,
67
+ };
68
+ recentResults.unshift(result);
69
+ if (recentResults.length > MAX_STORED_RESULTS) recentResults.pop();
70
+ return result;
71
+ }
72
+
73
+ // ══════════════════════════════════════════════
74
+ // Sub-Agent Output Limits (prevents context bloat in parent)
75
+ // Inspired by pi-subagents (200KB/5K lines) and oh-my-pi (500KB/5K lines)
76
+ // ══════════════════════════════════════════════
77
+ const MAX_OUTPUT_BYTES = 200_000;
78
+ const MAX_OUTPUT_LINES = 5_000;
79
+
80
+ // All Pi events we subscribe to
81
+ const PROXY_EVENTS = [
82
+ 'session_shutdown',
83
+ 'session_before_switch',
84
+ 'session_switch',
85
+ 'session_before_fork',
86
+ 'session_fork',
87
+ 'session_before_compact',
88
+ 'session_compact',
89
+ 'before_agent_start',
90
+ 'agent_start',
91
+ 'agent_end',
92
+ 'turn_start',
93
+ 'turn_end',
94
+ 'tool_call',
95
+ 'tool_result',
96
+ 'tool_execution_start',
97
+ 'tool_execution_update',
98
+ 'tool_execution_end',
99
+ 'message_start',
100
+ 'message_update',
101
+ 'message_end',
102
+ 'input',
103
+ 'model_select',
104
+ 'context',
105
+ ] as const;
106
+
107
+ type GenericEventHandler = (
108
+ event: string,
109
+ handler: (event: unknown, ctx: ExtensionContext) => Promise<unknown>
110
+ ) => void;
111
+
112
+ const DEBUG = !!process.env['AGENTUITY_DEBUG'];
113
+
114
+ function log(msg: string): void {
115
+ if (DEBUG) console.error(`[agentuity-coder] ${msg}`);
116
+ }
117
+
118
+ /** Build headers object with API key if available. Merges with any existing headers. */
119
+ // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
120
+ function authHeaders(extra?: Record<string, string>): Record<string, string> {
121
+ const apiKey = process.env[API_KEY_ENV];
122
+ const headers: Record<string, string> = { ...extra };
123
+ if (apiKey) headers[API_KEY_HEADER] = apiKey;
124
+ return headers;
125
+ }
126
+
127
+ // ══════════════════════════════════════════════
128
+ // Synchronous Bootstrap — fetch InitMessage from Hub REST endpoint
129
+ // This runs BEFORE tool registration so we know what tools/agents
130
+ // the server actually provides. No hardcoded schemas.
131
+ // ══════════════════════════════════════════════
132
+
133
+ function buildInitUrl(hubUrl: string, agentRole?: string): string {
134
+ let httpUrl = hubUrl.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://');
135
+
136
+ if (httpUrl.includes('/api/ws')) {
137
+ httpUrl = httpUrl.replace('/api/ws', '/api/hub/tui/init');
138
+ } else {
139
+ httpUrl = httpUrl.replace(/\/?$/, '/api/hub/tui/init');
140
+ }
141
+
142
+ if (agentRole && agentRole !== 'lead') {
143
+ httpUrl += `?agent=${encodeURIComponent(agentRole)}`;
144
+ }
145
+
146
+ return httpUrl;
147
+ }
148
+
149
+ function getHubHttpBaseUrl(hubUrl: string): string {
150
+ let httpUrl = hubUrl.replace(/^ws:\/\//, 'http://').replace(/^wss:\/\//, 'https://');
151
+ httpUrl = httpUrl.replace(/\/api\/ws\b.*$/, '');
152
+ return httpUrl.replace(/\/+$/, '');
153
+ }
154
+
155
+ /**
156
+ * Synchronously fetch the InitMessage from Hub's REST endpoint.
157
+ *
158
+ * Uses `curl` via `execFileSync` because Pi's extension registration is synchronous —
159
+ * we need tools/agents BEFORE the extension returns. Node's `fetch()` is async-only,
160
+ * and `Bun.spawnSync` isn't available in Pi's Node.js runtime.
161
+ *
162
+ * Requires `curl` binary (available on macOS, Linux, Windows 10+).
163
+ */
164
+ function fetchInitMessageSync(hubUrl: string, agentRole?: string): InitMessage | null {
165
+ const httpUrl = buildInitUrl(hubUrl, agentRole);
166
+
167
+ try {
168
+ const { execFileSync } = _require(
169
+ 'node:child_process'
170
+ ) as typeof import('node:child_process');
171
+ const apiKey = process.env[API_KEY_ENV];
172
+ const curlArgs = ['-s', '--connect-timeout', '3', '--max-time', '5'];
173
+ // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
174
+ if (apiKey) curlArgs.push('-H', `${API_KEY_HEADER}: ${apiKey}`);
175
+ curlArgs.push(httpUrl);
176
+ const result = execFileSync('curl', curlArgs, { encoding: 'utf-8' });
177
+
178
+ const parsed = JSON.parse(result);
179
+ if (parsed && parsed.type === 'init') {
180
+ return parsed as InitMessage;
181
+ }
182
+ return null;
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Fetch session snapshot from Hub REST endpoint.
190
+ * Extracts observer count and session label for the footer display.
191
+ * Best-effort, non-blocking — failures are silently ignored.
192
+ */
193
+ async function fetchSessionSnapshot(
194
+ hubUrl: string,
195
+ sessionId?: string | null,
196
+ observerState?: ObserverState
197
+ ): Promise<void> {
198
+ const baseUrl = getHubHttpBaseUrl(hubUrl);
199
+ const httpUrl = sessionId
200
+ ? `${baseUrl}/api/hub/session/${encodeURIComponent(sessionId)}`
201
+ : `${baseUrl}/api/hub/sessions`;
202
+
203
+ const controller = new AbortController();
204
+ const timeout = setTimeout(() => controller.abort(), 5_000);
205
+
206
+ try {
207
+ const response = await fetch(httpUrl, {
208
+ signal: controller.signal,
209
+ headers: authHeaders({ accept: 'application/json' }),
210
+ });
211
+ if (!response.ok) return;
212
+
213
+ if (sessionId) {
214
+ const snapshot = (await response.json()) as {
215
+ label?: string;
216
+ participants?: Array<{ role?: string }>;
217
+ };
218
+ if (observerState) {
219
+ if (snapshot.label) observerState.label = snapshot.label;
220
+ if (Array.isArray(snapshot.participants)) {
221
+ observerState.count = snapshot.participants.filter(
222
+ (p) => p.role === 'observer'
223
+ ).length;
224
+ }
225
+ }
226
+ return;
227
+ }
228
+
229
+ const data = (await response.json()) as {
230
+ sessions?: {
231
+ websocket?: Array<{
232
+ label?: string;
233
+ observerCount?: number;
234
+ }>;
235
+ };
236
+ };
237
+ const first = data.sessions?.websocket?.[0];
238
+ if (first && observerState) {
239
+ if (first.label) observerState.label = first.label;
240
+ if (typeof first.observerCount === 'number') observerState.count = first.observerCount;
241
+ }
242
+ } catch {
243
+ // Ignore — best effort
244
+ } finally {
245
+ clearTimeout(timeout);
246
+ }
247
+ }
248
+
249
+ async function fetchInitMessage(hubUrl: string, agentRole?: string): Promise<InitMessage | null> {
250
+ const httpUrl = buildInitUrl(hubUrl, agentRole);
251
+ const controller = new AbortController();
252
+ const timeout = setTimeout(() => controller.abort(), 5_000);
253
+
254
+ try {
255
+ const response = await fetch(httpUrl, {
256
+ signal: controller.signal,
257
+ headers: authHeaders({ accept: 'application/json' }),
258
+ });
259
+
260
+ if (!response.ok) return null;
261
+
262
+ const parsed = (await response.json()) as Record<string, unknown>;
263
+ if (parsed.type === 'init') {
264
+ return parsed as unknown as InitMessage;
265
+ }
266
+ return null;
267
+ } catch {
268
+ return null;
269
+ } finally {
270
+ clearTimeout(timeout);
271
+ }
272
+ }
273
+
274
+ export function agentuityCoderHub(pi: ExtensionAPI) {
275
+ const hubUrl = process.env[HUB_URL_ENV];
276
+ if (!hubUrl) return;
277
+
278
+ // ── Remote mode detection ──
279
+ // If AGENTUITY_CODER_REMOTE_SESSION is set, the TUI connects as a controller
280
+ // to an existing sandbox session. The full UI is set up (tools, commands, /hub)
281
+ // but user input is relayed to the remote sandbox instead of the local Pi agent.
282
+ const remoteSessionId = process.env[REMOTE_SESSION_ENV] || null;
283
+ if (remoteSessionId) {
284
+ log(`Remote mode: will connect as controller to session ${remoteSessionId}`);
285
+ }
286
+
287
+ const isSubAgent = !!process.env[AGENT_ENV];
288
+ const agentRole = process.env[AGENT_ENV] || 'lead';
289
+
290
+ log(`Hub URL: ${hubUrl} (role: ${agentRole})`);
291
+
292
+ // ══════════════════════════════════════════════
293
+ // Fetch InitMessage from Hub REST endpoint (synchronous)
294
+ // This is how we discover what tools/agents the server provides.
295
+ // ══════════════════════════════════════════════
296
+
297
+ const initMsg = fetchInitMessageSync(hubUrl, agentRole);
298
+
299
+ if (!initMsg) {
300
+ log('Hub not reachable — no tools or agents registered');
301
+ log('Make sure the Hub server is running');
302
+ return;
303
+ }
304
+
305
+ const serverTools = initMsg.tools || [];
306
+ const serverAgents = initMsg.agents || [];
307
+ let hubConfig: HubConfig | undefined = initMsg.config;
308
+ const openChainEditor = async (
309
+ ctx: ExtensionContext | ExtensionCommandContext,
310
+ initialAgents: string[] = []
311
+ ): Promise<void> => {
312
+ if (!ctx.hasUI) return;
313
+
314
+ const result = await ctx.ui.custom<ChainResult | undefined>(
315
+ (_tui, theme, _keybindings, done) =>
316
+ new ChainEditorOverlay(theme, serverAgents, done, initialAgents),
317
+ {
318
+ overlay: true,
319
+ overlayOptions: { width: '95%', maxHeight: '95%', anchor: 'center', margin: 1 },
320
+ }
321
+ );
322
+
323
+ if (!result || result.steps.length === 0) return;
324
+
325
+ const instructions = result.steps
326
+ .map((step, index) => `${index + 1}) @${step.agent}: ${step.task || '(no task provided)'}`)
327
+ .join(', ');
328
+
329
+ const message =
330
+ result.mode === 'parallel'
331
+ ? `@lead Execute these tasks in parallel: ${instructions}`
332
+ : `@lead Execute this plan in order: ${instructions}`;
333
+
334
+ pi.sendUserMessage(message, { deliverAs: 'followUp' });
335
+ };
336
+
337
+ type AgentManagerOverlayResult =
338
+ | { action: 'run'; agent: string }
339
+ | { action: 'chain'; agents: string[] };
340
+
341
+ const openAgentManager = async (
342
+ ctx: ExtensionContext | ExtensionCommandContext
343
+ ): Promise<void> => {
344
+ if (!ctx.hasUI) return;
345
+
346
+ const result = await ctx.ui.custom<AgentManagerOverlayResult | undefined>(
347
+ (_tui, theme, _keybindings, done) => new AgentManagerOverlay(theme, serverAgents, done),
348
+ {
349
+ overlay: true,
350
+ overlayOptions: { width: '95%', maxHeight: '95%', anchor: 'center', margin: 1 },
351
+ }
352
+ );
353
+
354
+ // TODO: chain action from Agent Manager overlay (multi-select + Ctrl+R) not yet implemented
355
+ if (result?.action === 'chain' && Array.isArray(result.agents)) {
356
+ await openChainEditor(ctx, result.agents);
357
+ return;
358
+ }
359
+
360
+ if (result?.action === 'run' && result.agent) {
361
+ const task = await ctx.ui.input(`Task for ${result.agent}`, 'What should this agent do?');
362
+ const trimmed = task?.trim();
363
+ if (trimmed) {
364
+ pi.sendUserMessage(`@${result.agent} ${trimmed}`, { deliverAs: 'followUp' });
365
+ }
366
+ }
367
+ };
368
+
369
+ const openHubOverlay = async (
370
+ ctx: ExtensionContext | ExtensionCommandContext,
371
+ activeSessionId: string | null,
372
+ detailSessionId?: string | null
373
+ ): Promise<void> => {
374
+ if (!ctx.hasUI) return;
375
+ if (hubOverlayOpen) return;
376
+ hubOverlayOpen = true;
377
+
378
+ try {
379
+ await ctx.ui.custom<undefined>(
380
+ (tui, theme, _keybindings, done) =>
381
+ new HubOverlay(tui, theme, {
382
+ baseUrl: getHubHttpBaseUrl(hubUrl!),
383
+ currentSessionId: activeSessionId ?? undefined,
384
+ initialSessionId: detailSessionId ?? undefined,
385
+ startInDetail: !!detailSessionId,
386
+ done,
387
+ }),
388
+ {
389
+ overlay: true,
390
+ overlayOptions: { width: '95%', maxHeight: '95%', anchor: 'center', margin: 1 },
391
+ }
392
+ );
393
+ } finally {
394
+ hubOverlayOpen = false;
395
+ }
396
+ };
397
+
398
+ const buildActionContext = (ctx: ExtensionContext | ExtensionCommandContext) => ({
399
+ ui: ctx.hasUI ? ctx.ui : undefined,
400
+ sendUserMessage: (message: string, options?: { deliverAs?: 'followUp' }) => {
401
+ pi.sendUserMessage(message, { deliverAs: options?.deliverAs ?? 'followUp' });
402
+ },
403
+ });
404
+
405
+ log(`Hub connected. Tools: ${serverTools.length}, Agents: ${serverAgents.length}`);
406
+
407
+ // Titlebar: branding + spinner (registers its own event handlers)
408
+ setupTitlebar(pi);
409
+
410
+ // ══════════════════════════════════════════════
411
+ // WebSocket client for runtime communication (tool execution + events)
412
+ // ══════════════════════════════════════════════
413
+
414
+ const client = new HubClient();
415
+ // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
416
+ client.apiKey = process.env[API_KEY_ENV] || null;
417
+ let cachedInitMessage: InitMessage | null = initMsg;
418
+ let currentSessionId: string | null = initMsg.sessionId ?? null;
419
+ let systemPromptApplied = false;
420
+ let connectPromise: Promise<InitMessage | null> | null = null;
421
+ // In native remote mode, remote-tui.ts owns the Hub connection — show as connected
422
+ let hubUiStatus: HubUiStatus = process.env[NATIVE_REMOTE_ENV] ? 'connected' : 'offline';
423
+ let footerCtx: ExtensionContext | null = null;
424
+ let hubOverlayOpen = false;
425
+
426
+ // Observer awareness state — tracks who's watching this session.
427
+ // Updated via broadcast events from the Hub (session_join, session_leave).
428
+ const observerState: ObserverState = { count: 0, label: '' };
429
+ const observerParticipantIds = new Set<string>();
430
+
431
+ function getHubUiStatus(): HubUiStatus {
432
+ return hubUiStatus;
433
+ }
434
+
435
+ function getObserverState(): ObserverState {
436
+ return observerState;
437
+ }
438
+
439
+ function mapConnectionStateToUiStatus(state: ConnectionState): HubUiStatus {
440
+ if (state === 'connected') return 'connected';
441
+ if (state === 'reconnecting') return 'reconnecting';
442
+ return 'offline';
443
+ }
444
+
445
+ function updateHubUiStatus(state: ConnectionState): void {
446
+ hubUiStatus = mapConnectionStateToUiStatus(state);
447
+ if (footerCtx?.hasUI) {
448
+ footerCtx.ui.setStatus('hub_connection', hubUiStatus);
449
+ }
450
+ }
451
+
452
+ function applyInitMessage(nextInit: InitMessage): void {
453
+ cachedInitMessage = nextInit;
454
+ if (nextInit.sessionId) currentSessionId = nextInit.sessionId;
455
+ if (nextInit.config) hubConfig = nextInit.config;
456
+ }
457
+
458
+ client.onInitMessage = (nextInit) => {
459
+ applyInitMessage(nextInit);
460
+ };
461
+
462
+ client.onConnectionStateChange = (state) => {
463
+ updateHubUiStatus(state);
464
+ log(`Hub connection state: ${state}`);
465
+ };
466
+
467
+ client.onBeforeReconnect = async () => {
468
+ const refreshedInit = await fetchInitMessage(hubUrl!, agentRole);
469
+ if (refreshedInit) {
470
+ applyInitMessage(refreshedInit);
471
+ log('Refreshed Hub init payload before reconnect');
472
+ }
473
+ };
474
+
475
+ // Handle unsolicited server messages (broadcast, presence)
476
+ // Updates observer state for footer display
477
+ client.onServerMessage = (message) => {
478
+ const msgType = message.type as string;
479
+ if (msgType === 'broadcast') {
480
+ const event = message.event as string;
481
+ if (event === 'session_join') {
482
+ const participant = (message.data as Record<string, unknown>)?.participant as
483
+ | Record<string, unknown>
484
+ | undefined;
485
+ if (participant?.role === 'observer' && typeof participant.id === 'string') {
486
+ observerParticipantIds.add(participant.id);
487
+ observerState.count = observerParticipantIds.size;
488
+ log(`Observer joined: ${observerState.count} observers`);
489
+ }
490
+ } else if (event === 'session_label_updated') {
491
+ const label = (message.data as Record<string, unknown>)?.label as string | undefined;
492
+ if (label) {
493
+ observerState.label = label;
494
+ log(`Session label updated: ${label}`);
495
+ }
496
+ } else if (event === 'session_leave') {
497
+ const participant = (message.data as Record<string, unknown>)?.participant as
498
+ | Record<string, unknown>
499
+ | undefined;
500
+ if (participant?.role === 'observer' && typeof participant.id === 'string') {
501
+ observerParticipantIds.delete(participant.id);
502
+ observerState.count = observerParticipantIds.size;
503
+ log(`Observer left: ${observerState.count} observers`);
504
+ }
505
+ }
506
+ } else if (msgType === 'presence') {
507
+ // Full presence update — may include participant list
508
+ const participants = message.participants as Array<Record<string, unknown>> | undefined;
509
+ if (participants) {
510
+ observerParticipantIds.clear();
511
+ for (const participant of participants) {
512
+ if (participant.role === 'observer' && typeof participant.id === 'string') {
513
+ observerParticipantIds.add(participant.id);
514
+ }
515
+ }
516
+ observerState.count = observerParticipantIds.size;
517
+ log(`Presence update: ${observerState.count} observers`);
518
+ }
519
+ }
520
+ };
521
+
522
+ // Lazy WebSocket connect — returns cached InitMessage
523
+ // In native remote mode, remote-tui.ts owns the controller WebSocket via RemoteSession.
524
+ // Skip the extension's own HubClient connection to avoid duplicate controllers.
525
+ function ensureConnected(): Promise<InitMessage | null> {
526
+ if (isNativeRemote) {
527
+ log('Native remote mode — skipping HubClient WebSocket (remote-tui owns controller)');
528
+ return Promise.resolve(cachedInitMessage);
529
+ }
530
+ if (client.connected && cachedInitMessage) return Promise.resolve(cachedInitMessage);
531
+ if (client.connectionState === 'reconnecting' || client.connectionState === 'disconnected') {
532
+ return client
533
+ .waitUntilConnected(RECONNECT_WAIT_TIMEOUT_MS)
534
+ .then(() => cachedInitMessage)
535
+ .catch(() => null);
536
+ }
537
+ if (connectPromise) return connectPromise;
538
+
539
+ connectPromise = (async () => {
540
+ log('Connecting WebSocket to Hub...');
541
+ try {
542
+ // In remote mode, connect as a controller to the existing session
543
+ const connectOpts = remoteSessionId
544
+ ? { sessionId: remoteSessionId, role: 'controller' }
545
+ : undefined;
546
+ const wsInitMsg = await client.connect(hubUrl!, connectOpts);
547
+ log('WebSocket connected');
548
+ applyInitMessage(wsInitMsg);
549
+ connectPromise = null; // Clear so future disconnects can reconnect
550
+ return wsInitMsg;
551
+ } catch (err) {
552
+ log(`WebSocket failed: ${err instanceof Error ? err.message : String(err)}`);
553
+ connectPromise = null;
554
+ return null;
555
+ }
556
+ })();
557
+
558
+ return connectPromise;
559
+ }
560
+
561
+ // ══════════════════════════════════════════════
562
+ // Register Hub tools from server's InitMessage
563
+ // Tools come from the server — NOT hardcoded in the extension.
564
+ // ══════════════════════════════════════════════
565
+
566
+ for (const toolDef of serverTools) {
567
+ log(`Registering tool: ${toolDef.name}`);
568
+ const renderers = getToolRenderers(toolDef.name);
569
+ pi.registerTool({
570
+ name: toolDef.name,
571
+ label: toolDef.label || toolDef.name,
572
+ description: toolDef.description,
573
+ // Server sends JSON Schema; TypeBox schemas are JSON Schema at runtime
574
+ parameters: toolDef.parameters as TSchema,
575
+ ...(toolDef.promptSnippet ? { promptSnippet: toolDef.promptSnippet } : {}),
576
+ ...(toolDef.promptGuidelines ? { promptGuidelines: toolDef.promptGuidelines } : {}),
577
+ async execute(
578
+ toolCallId: string,
579
+ params: unknown,
580
+ _signal: AbortSignal | undefined,
581
+ _onUpdate: unknown,
582
+ ctx: ExtensionContext
583
+ ): Promise<AgentToolResult<unknown>> {
584
+ // Ensure WebSocket is connected before executing
585
+ await ensureConnected();
586
+
587
+ if (!client.connected) {
588
+ return {
589
+ content: [{ type: 'text' as const, text: 'Error: Hub WebSocket not connected' }],
590
+ details: undefined as unknown,
591
+ };
592
+ }
593
+
594
+ const id = client.nextId();
595
+ let response: HubResponse;
596
+
597
+ try {
598
+ response = await client.send({
599
+ id,
600
+ type: 'tool',
601
+ name: toolDef.name,
602
+ toolCallId,
603
+ params: (params ?? {}) as Record<string, unknown>,
604
+ });
605
+ } catch {
606
+ return {
607
+ content: [{ type: 'text' as const, text: 'Error: Hub connection lost' }],
608
+ details: undefined as unknown,
609
+ };
610
+ }
611
+
612
+ // Process ALL Hub actions (NOTIFY, STATUS, RETURN, etc.)
613
+ const result = await processActions(response.actions, buildActionContext(ctx));
614
+
615
+ // If there's a return value from processActions, use it
616
+ if (result.returnValue !== undefined) {
617
+ const text =
618
+ typeof result.returnValue === 'string'
619
+ ? result.returnValue
620
+ : JSON.stringify(result.returnValue, null, 2);
621
+ return {
622
+ content: [{ type: 'text' as const, text }],
623
+ details: undefined as unknown,
624
+ };
625
+ }
626
+
627
+ // Fallback — check for RETURN action directly (backward compat)
628
+ const returnAction = response.actions.find((a: HubAction) => a.action === 'RETURN');
629
+ if (returnAction && 'result' in returnAction) {
630
+ const text =
631
+ typeof returnAction.result === 'string'
632
+ ? returnAction.result
633
+ : JSON.stringify(returnAction.result, null, 2);
634
+ return {
635
+ content: [{ type: 'text' as const, text }],
636
+ details: undefined as unknown,
637
+ };
638
+ }
639
+
640
+ return {
641
+ content: [{ type: 'text' as const, text: 'Done' }],
642
+ details: undefined as unknown,
643
+ };
644
+ },
645
+ // TUI renderers — optional, only for known Hub tools.
646
+ // Cast needed: SimpleText satisfies Component, but TS can't verify cross-package structural match.
647
+ ...(renderers?.renderCall && {
648
+ renderCall: renderers.renderCall as ToolDefinition['renderCall'],
649
+ }),
650
+ ...(renderers?.renderResult && {
651
+ renderResult: renderers.renderResult as ToolDefinition['renderResult'],
652
+ }),
653
+ });
654
+ }
655
+
656
+ // ══════════════════════════════════════════════
657
+ // Register task tools (LEAD only) from server's agent list
658
+ // Agent names and configs come from the Hub, not hardcoded.
659
+ // ══════════════════════════════════════════════
660
+
661
+ if (!isSubAgent && serverAgents.length > 0) {
662
+ pi.registerShortcut('ctrl+shift+a', {
663
+ description: 'Open Agent Manager',
664
+ handler: async (ctx) => {
665
+ await openAgentManager(ctx);
666
+ },
667
+ });
668
+
669
+ const openOutputViewer = async (ctx: ExtensionContext): Promise<void> => {
670
+ if (!ctx.hasUI || recentResults.length === 0) return;
671
+ await ctx.ui.custom<undefined>(
672
+ (tui, theme, _keybindings, done) =>
673
+ new OutputViewerOverlay(tui, theme, recentResults, done),
674
+ {
675
+ overlay: true,
676
+ overlayOptions: { width: '95%', maxHeight: '95%', anchor: 'center', margin: 1 },
677
+ }
678
+ );
679
+ };
680
+
681
+ pi.registerShortcut('ctrl+shift+v', {
682
+ description: 'View full agent output',
683
+ handler: openOutputViewer,
684
+ });
685
+
686
+ // Tmux/terminal environments often cannot emit Ctrl+Shift+V consistently.
687
+ pi.registerShortcut('alt+shift+v', {
688
+ description: 'View full agent output',
689
+ handler: openOutputViewer,
690
+ });
691
+
692
+ pi.registerShortcut('ctrl+shift+c', {
693
+ description: 'Open Chain Editor',
694
+ handler: async (ctx) => {
695
+ await openChainEditor(ctx);
696
+ },
697
+ });
698
+
699
+ pi.registerShortcut('ctrl+h', {
700
+ description: 'Open Hub overlay',
701
+ handler: async (ctx) => {
702
+ if (!ctx.hasUI) return;
703
+ await openHubOverlay(ctx, currentSessionId);
704
+ },
705
+ });
706
+
707
+ const agentRegistry = new Map(serverAgents.map((a) => [a.name, a]));
708
+ const agentNames = serverAgents.map((a) => a.name);
709
+
710
+ log(`Registering task tools. Agents: ${agentNames.join(', ')}`);
711
+
712
+ const taskRenderers = getToolRenderers('task');
713
+ pi.registerTool({
714
+ name: 'task',
715
+ label: 'Delegate Task to Agent',
716
+ description:
717
+ `Delegate a task to a specialized agent on your team. ` +
718
+ `Available agents: ${agentNames.join(', ')}. ` +
719
+ `Each agent runs independently with its own context window.`,
720
+ parameters: Type.Object({
721
+ description: Type.String({ description: 'Short 3-5 word task description' }),
722
+ prompt: Type.String({ description: 'Detailed task instructions for the agent' }),
723
+ subagent_type: Type.String({
724
+ description: `Agent: ${agentNames.join(', ')}`,
725
+ }),
726
+ }),
727
+ async execute(
728
+ toolCallId: string,
729
+ params: unknown,
730
+ signal: AbortSignal | undefined,
731
+ _onUpdate: unknown,
732
+ ctx: ExtensionContext
733
+ ): Promise<AgentToolResult<unknown>> {
734
+ const { description, prompt, subagent_type } = params as {
735
+ description: string;
736
+ prompt: string;
737
+ subagent_type: string;
738
+ };
739
+
740
+ if (signal?.aborted) {
741
+ return {
742
+ content: [{ type: 'text' as const, text: 'Cancelled' }],
743
+ details: undefined as unknown,
744
+ };
745
+ }
746
+
747
+ const agent = agentRegistry.get(subagent_type);
748
+ if (!agent) {
749
+ return {
750
+ content: [
751
+ {
752
+ type: 'text' as const,
753
+ text: `Unknown agent: ${subagent_type}. Available: ${agentNames.join(', ')}`,
754
+ },
755
+ ],
756
+ details: undefined as unknown,
757
+ };
758
+ }
759
+
760
+ log(`Task: ${description} → ${subagent_type}`);
761
+
762
+ const startTime = Date.now();
763
+ const formatElapsed = (): string => {
764
+ const s = Math.floor((Date.now() - startTime) / 1000);
765
+ if (s < 60) return `${s}s`;
766
+ return `${Math.floor(s / 60)}m ${s % 60}s`;
767
+ };
768
+ let elapsedTimer: ReturnType<typeof setInterval> | null = null;
769
+
770
+ // ── Single-agent status via working message ──
771
+ let lastWidgetTool: string | undefined;
772
+ let lastWidgetToolArgs: string | undefined;
773
+
774
+ function updateWidget(status: string, tool?: string, toolArgs?: string): void {
775
+ if (!ctx.hasUI) return;
776
+ let msg = '';
777
+ if (status === 'running') {
778
+ msg = '\u25CF ' + subagent_type; // ● name
779
+ if (tool) {
780
+ const toolInfo = toolArgs ? `${tool} ${toolArgs}` : tool;
781
+ msg += ' ' + toolInfo.slice(0, 40);
782
+ }
783
+ msg += ' ' + formatElapsed();
784
+ } else if (status === 'completed') {
785
+ msg = '\u2713 ' + subagent_type + ' ' + formatElapsed(); // ✓ name Xs
786
+ } else if (status === 'failed') {
787
+ msg = '\u2717 ' + subagent_type + ' failed'; // ✗ name failed
788
+ }
789
+ ctx.ui.setWorkingMessage(msg);
790
+ }
791
+
792
+ if (ctx.hasUI) {
793
+ ctx.ui.setStatus('active_agent', subagent_type);
794
+ updateWidget('running');
795
+ elapsedTimer = setInterval(() => {
796
+ updateWidget('running', lastWidgetTool, lastWidgetToolArgs);
797
+ }, 1000);
798
+ }
799
+
800
+ // Create live streaming result before starting sub-agent
801
+ const liveResult = startStreamingResult(subagent_type, description, prompt);
802
+ sendEventNoWait('task_start', {
803
+ taskId: toolCallId,
804
+ agent: subagent_type,
805
+ prompt,
806
+ description,
807
+ });
808
+
809
+ try {
810
+ const result = await runSubAgent(
811
+ agent,
812
+ prompt,
813
+ client,
814
+ ctx.hasUI
815
+ ? (progress) => {
816
+ // Update TUI working message with live tool activity
817
+ try {
818
+ if (progress.status === 'thinking_delta' && progress.delta) {
819
+ liveResult.thinking += progress.delta;
820
+ updateWidget('running', 'thinking...');
821
+ } else if (progress.status === 'text_delta' && progress.delta) {
822
+ liveResult.text += progress.delta;
823
+ updateWidget('running', 'writing...');
824
+ } else if (progress.status === 'tool_start' && progress.currentTool) {
825
+ lastWidgetTool = progress.currentTool;
826
+ lastWidgetToolArgs = progress.currentToolArgs;
827
+ updateWidget(
828
+ 'running',
829
+ progress.currentTool,
830
+ progress.currentToolArgs
831
+ );
832
+ }
833
+ } catch {
834
+ // Best-effort live widget updates.
835
+ }
836
+
837
+ // Forward progress to Hub (fire-and-forget, queued while disconnected)
838
+ sendEventNoWait('agent_progress', {
839
+ agentName: progress.agentName,
840
+ status: progress.status,
841
+ taskId: toolCallId,
842
+ delta: progress.delta,
843
+ currentTool: progress.currentTool,
844
+ currentToolArgs: progress.currentToolArgs,
845
+ elapsed: progress.elapsed,
846
+ });
847
+ }
848
+ : undefined,
849
+ signal
850
+ );
851
+
852
+ // Flash completed state briefly before clearing
853
+ updateWidget('completed');
854
+
855
+ // Finalize the live result instead of creating a new one
856
+ liveResult.isStreaming = false;
857
+ liveResult.text = result.output || liveResult.text || '(no output)';
858
+ await sendEvent(
859
+ 'task_complete',
860
+ {
861
+ taskId: toolCallId,
862
+ agent: subagent_type,
863
+ duration: result.duration,
864
+ result: result.output.slice(0, 10000),
865
+ },
866
+ ctx
867
+ );
868
+
869
+ let output = result.output;
870
+ let tokenInfoStr: string | undefined;
871
+ if (result.tokens && (result.tokens.input > 0 || result.tokens.output > 0)) {
872
+ tokenInfoStr = `${subagent_type}: ${result.duration}ms | ${result.tokens.input} in ${result.tokens.output} out | $${result.tokens.cost.toFixed(4)}`;
873
+ output += `\n\n---\n_${subagent_type}: ${result.duration}ms | ${result.tokens.input} in ${result.tokens.output} out tokens | $${result.tokens.cost.toFixed(4)}_`;
874
+ }
875
+ if (tokenInfoStr) liveResult.tokenInfo = tokenInfoStr;
876
+ return {
877
+ content: [{ type: 'text' as const, text: output }],
878
+ details: undefined as unknown,
879
+ };
880
+ } catch (err) {
881
+ const errorMsg = err instanceof Error ? err.message : String(err);
882
+ liveResult.isStreaming = false;
883
+ liveResult.text = liveResult.text || `Agent ${subagent_type} failed: ${errorMsg}`;
884
+ await sendEvent(
885
+ 'task_error',
886
+ {
887
+ taskId: toolCallId,
888
+ agent: subagent_type,
889
+ error: errorMsg,
890
+ },
891
+ ctx
892
+ );
893
+ updateWidget('failed');
894
+ return {
895
+ content: [
896
+ { type: 'text' as const, text: `Agent ${subagent_type} failed: ${errorMsg}` },
897
+ ],
898
+ details: undefined as unknown,
899
+ };
900
+ } finally {
901
+ if (elapsedTimer) clearInterval(elapsedTimer);
902
+ if (ctx.hasUI) {
903
+ ctx.ui.setStatus('active_agent', undefined);
904
+ ctx.ui.setWorkingMessage(); // Restore Pi's default working message
905
+ }
906
+ }
907
+ },
908
+ ...(taskRenderers?.renderCall && {
909
+ renderCall: taskRenderers.renderCall as ToolDefinition['renderCall'],
910
+ }),
911
+ ...(taskRenderers?.renderResult && {
912
+ renderResult: taskRenderers.renderResult as ToolDefinition['renderResult'],
913
+ }),
914
+ });
915
+
916
+ const parallelRenderers = getToolRenderers('parallel_tasks');
917
+ pi.registerTool({
918
+ name: 'parallel_tasks',
919
+ label: 'Delegate Parallel Tasks',
920
+ description:
921
+ `Run multiple agent tasks concurrently (max 4). ` +
922
+ `Available agents: ${agentNames.join(', ')}.`,
923
+ parameters: Type.Object({
924
+ tasks: Type.Array(
925
+ Type.Object({
926
+ description: Type.String({ description: 'Short task description' }),
927
+ prompt: Type.String({ description: 'Detailed instructions' }),
928
+ subagent_type: Type.String({ description: 'Agent to delegate to' }),
929
+ }),
930
+ { maxItems: 4 }
931
+ ),
932
+ }),
933
+ async execute(
934
+ toolCallId: string,
935
+ params: unknown,
936
+ signal: AbortSignal | undefined,
937
+ _onUpdate: unknown,
938
+ ctx: ExtensionContext
939
+ ): Promise<AgentToolResult<unknown>> {
940
+ const { tasks } = params as {
941
+ tasks: Array<{ description: string; prompt: string; subagent_type: string }>;
942
+ };
943
+
944
+ if (signal?.aborted) {
945
+ return {
946
+ content: [{ type: 'text' as const, text: 'Cancelled' }],
947
+ details: undefined as unknown,
948
+ };
949
+ }
950
+
951
+ if (tasks.length > 4) {
952
+ return {
953
+ content: [{ type: 'text' as const, text: 'Maximum 4 concurrent tasks allowed.' }],
954
+ details: undefined as unknown,
955
+ };
956
+ }
957
+
958
+ log(
959
+ `Parallel tasks: ${tasks.map((t) => `${t.subagent_type}:${t.description}`).join(', ')}`
960
+ );
961
+
962
+ let elapsedTimer: ReturnType<typeof setInterval> | null = null;
963
+
964
+ // ── Per-agent status tracking for live widget ──
965
+ interface ParallelAgentStatus {
966
+ name: string;
967
+ status: 'pending' | 'running' | 'completed' | 'failed';
968
+ currentTool?: string;
969
+ currentToolArgs?: string;
970
+ startTime?: number;
971
+ duration?: number;
972
+ }
973
+
974
+ const agentStatuses: ParallelAgentStatus[] = tasks.map((t) => ({
975
+ name: t.subagent_type,
976
+ status: 'pending',
977
+ }));
978
+
979
+ function updateWidget(): void {
980
+ if (!ctx.hasUI) return;
981
+ const parts = agentStatuses
982
+ .filter((s) => s.status !== 'pending')
983
+ .map((s) => {
984
+ const elapsed = s.startTime
985
+ ? Math.floor((Date.now() - s.startTime) / 1000)
986
+ : 0;
987
+ const timeStr =
988
+ elapsed < 60
989
+ ? `${elapsed}s`
990
+ : `${Math.floor(elapsed / 60)}m${elapsed % 60}s`;
991
+ if (s.status === 'running') {
992
+ let info = `\u25CF ${s.name}`;
993
+ if (s.currentTool) info += ` ${s.currentTool.slice(0, 15)}`;
994
+ return info + ` ${timeStr}`;
995
+ }
996
+ if (s.status === 'completed') {
997
+ return `\u2713 ${s.name} ${timeStr}`;
998
+ }
999
+ if (s.status === 'failed') {
1000
+ return `\u2717 ${s.name}`;
1001
+ }
1002
+ return `\u25CB ${s.name}`;
1003
+ });
1004
+ ctx.ui.setWorkingMessage(parts.join(' '));
1005
+ }
1006
+
1007
+ if (ctx.hasUI) {
1008
+ ctx.ui.setStatus('active_agent', 'agents');
1009
+ updateWidget();
1010
+ elapsedTimer = setInterval(() => {
1011
+ updateWidget(); // Refresh elapsed times in widget
1012
+ }, 1000);
1013
+ }
1014
+
1015
+ // Create live streaming results for each parallel task
1016
+ const liveResults = tasks.map((task) =>
1017
+ startStreamingResult(task.subagent_type, task.description, task.prompt)
1018
+ );
1019
+
1020
+ const promises = tasks.map(async (task, index) => {
1021
+ const taskId = `${toolCallId}-${index}-${task.subagent_type}`;
1022
+ const agent = agentRegistry.get(task.subagent_type);
1023
+ if (!agent) {
1024
+ agentStatuses[index]!.status = 'failed';
1025
+ liveResults[index]!.isStreaming = false;
1026
+ liveResults[index]!.text = `Unknown agent: ${task.subagent_type}`;
1027
+ await sendEvent(
1028
+ 'task_error',
1029
+ {
1030
+ taskId,
1031
+ agent: task.subagent_type,
1032
+ error: `Unknown agent: ${task.subagent_type}`,
1033
+ },
1034
+ ctx
1035
+ );
1036
+ updateWidget();
1037
+ return {
1038
+ agent: task.subagent_type,
1039
+ error: `Unknown agent: ${task.subagent_type}`,
1040
+ };
1041
+ }
1042
+
1043
+ agentStatuses[index]!.status = 'running';
1044
+ agentStatuses[index]!.startTime = Date.now();
1045
+ sendEventNoWait('task_start', {
1046
+ taskId,
1047
+ agent: task.subagent_type,
1048
+ prompt: task.prompt,
1049
+ description: task.description,
1050
+ });
1051
+ updateWidget();
1052
+
1053
+ try {
1054
+ const result = await runSubAgent(
1055
+ agent,
1056
+ task.prompt,
1057
+ client,
1058
+ ctx.hasUI
1059
+ ? (progress) => {
1060
+ // Handle streaming deltas
1061
+ if (progress.status === 'thinking_delta' && progress.delta) {
1062
+ liveResults[index]!.thinking += progress.delta;
1063
+ } else if (progress.status === 'text_delta' && progress.delta) {
1064
+ liveResults[index]!.text += progress.delta;
1065
+ }
1066
+
1067
+ // Update per-agent widget with tool activity
1068
+ agentStatuses[index]!.currentTool = progress.currentTool;
1069
+ agentStatuses[index]!.currentToolArgs = progress.currentToolArgs;
1070
+ updateWidget();
1071
+
1072
+ // Forward progress to Hub (fire-and-forget, queued while disconnected)
1073
+ sendEventNoWait('agent_progress', {
1074
+ agentName: progress.agentName,
1075
+ status: progress.status,
1076
+ taskId,
1077
+ delta: progress.delta,
1078
+ currentTool: progress.currentTool,
1079
+ currentToolArgs: progress.currentToolArgs,
1080
+ elapsed: progress.elapsed,
1081
+ });
1082
+ }
1083
+ : undefined,
1084
+ signal
1085
+ );
1086
+
1087
+ agentStatuses[index]!.status = 'completed';
1088
+ agentStatuses[index]!.duration = result.duration;
1089
+ agentStatuses[index]!.currentTool = undefined;
1090
+ agentStatuses[index]!.currentToolArgs = undefined;
1091
+
1092
+ // Finalize the live result
1093
+ liveResults[index]!.isStreaming = false;
1094
+ liveResults[index]!.text =
1095
+ result.output || liveResults[index]!.text || '(no output)';
1096
+ await sendEvent(
1097
+ 'task_complete',
1098
+ {
1099
+ taskId,
1100
+ agent: task.subagent_type,
1101
+ duration: result.duration,
1102
+ result: result.output.slice(0, 10000),
1103
+ },
1104
+ ctx
1105
+ );
1106
+ updateWidget();
1107
+
1108
+ return {
1109
+ agent: task.subagent_type,
1110
+ output: result.output,
1111
+ duration: result.duration,
1112
+ tokens: result.tokens,
1113
+ };
1114
+ } catch (err) {
1115
+ const errorMsg = err instanceof Error ? err.message : String(err);
1116
+ agentStatuses[index]!.status = 'failed';
1117
+ agentStatuses[index]!.currentTool = undefined;
1118
+ agentStatuses[index]!.currentToolArgs = undefined;
1119
+ liveResults[index]!.isStreaming = false;
1120
+ liveResults[index]!.text = liveResults[index]!.text || `Failed: ${errorMsg}`;
1121
+ await sendEvent(
1122
+ 'task_error',
1123
+ {
1124
+ taskId,
1125
+ agent: task.subagent_type,
1126
+ error: errorMsg,
1127
+ },
1128
+ ctx
1129
+ );
1130
+ updateWidget();
1131
+ return { agent: task.subagent_type, error: errorMsg };
1132
+ }
1133
+ });
1134
+
1135
+ try {
1136
+ const results = await Promise.all(promises);
1137
+
1138
+ // Finalize live results with token info
1139
+ results.forEach((r, idx) => {
1140
+ if ('output' in r && r.output && !('error' in r && r.error)) {
1141
+ if ('tokens' in r && r.tokens && (r.tokens.input > 0 || r.tokens.output > 0)) {
1142
+ liveResults[idx]!.tokenInfo =
1143
+ `${r.agent}: ${'duration' in r ? r.duration : 0}ms | ${r.tokens.input} in ${r.tokens.output} out | $${r.tokens.cost.toFixed(4)}`;
1144
+ }
1145
+ }
1146
+ });
1147
+
1148
+ const output = results
1149
+ .map((r) => {
1150
+ if ('error' in r && r.error) return `### ${r.agent} (FAILED)\n${r.error}`;
1151
+ let text = `### ${r.agent} (${'duration' in r ? r.duration : 0}ms)\n${'output' in r ? r.output : ''}`;
1152
+ if ('tokens' in r && r.tokens && (r.tokens.input > 0 || r.tokens.output > 0)) {
1153
+ text += `\n\n---\n_${r.agent}: ${'duration' in r ? r.duration : 0}ms | ${r.tokens.input} in ${r.tokens.output} out tokens | $${r.tokens.cost.toFixed(4)}_`;
1154
+ }
1155
+ return text;
1156
+ })
1157
+ .join('\n\n---\n\n');
1158
+
1159
+ return {
1160
+ content: [{ type: 'text' as const, text: output }],
1161
+ details: undefined as unknown,
1162
+ };
1163
+ } finally {
1164
+ if (elapsedTimer) clearInterval(elapsedTimer);
1165
+ if (ctx.hasUI) {
1166
+ ctx.ui.setStatus('active_agent', undefined);
1167
+ ctx.ui.setWorkingMessage(); // Restore Pi's default working message
1168
+ }
1169
+ }
1170
+ },
1171
+ ...(parallelRenderers?.renderCall && {
1172
+ renderCall: parallelRenderers.renderCall as ToolDefinition['renderCall'],
1173
+ }),
1174
+ ...(parallelRenderers?.renderResult && {
1175
+ renderResult: parallelRenderers.renderResult as ToolDefinition['renderResult'],
1176
+ }),
1177
+ });
1178
+ }
1179
+
1180
+ log('Tool registration complete');
1181
+
1182
+ // ══════════════════════════════════════════════
1183
+ // Register slash commands for agent routing (LEAD only)
1184
+ // When user types /memory, /scout, etc., the message is routed
1185
+ // to that specific agent via a routing prefix.
1186
+ // ══════════════════════════════════════════════
1187
+
1188
+ if (!isSubAgent && serverAgents.length > 0) {
1189
+ registerAgentCommands(pi, serverAgents, getHubUiStatus, openAgentManager, openChainEditor);
1190
+ }
1191
+
1192
+ // ══════════════════════════════════════════════
1193
+ // /hub command — Hub session overview (LEAD only)
1194
+ // ══════════════════════════════════════════════
1195
+
1196
+ if (!isSubAgent) {
1197
+ pi.registerCommand('hub', {
1198
+ description: 'Open Coder Hub overlay (sessions, detail, feed)',
1199
+ handler: async (_args, ctx) => {
1200
+ if (!ctx.hasUI) return;
1201
+ await openHubOverlay(ctx, currentSessionId);
1202
+ },
1203
+ });
1204
+
1205
+ pi.registerCommand('todos', {
1206
+ description: 'Open session todo board for current Hub session',
1207
+ handler: async (args, ctx) => {
1208
+ if (!ctx.hasUI) return;
1209
+ const targetSessionId = args.trim().length > 0 ? args.trim() : currentSessionId;
1210
+ if (!targetSessionId) {
1211
+ ctx.ui.notify('No active Hub session id available yet.', 'warning');
1212
+ return;
1213
+ }
1214
+ await openHubOverlay(ctx, currentSessionId, targetSessionId);
1215
+ },
1216
+ });
1217
+
1218
+ pi.registerCommand('rename-session', {
1219
+ description: 'Rename the current Hub session (max 30 chars)',
1220
+ handler: async (args, ctx) => {
1221
+ const label = args.trim().slice(0, 30);
1222
+ if (!label) {
1223
+ if (ctx.hasUI) ctx.ui.notify('Usage: /rename-session <label>', 'warning');
1224
+ return;
1225
+ }
1226
+ if (!client.connected) {
1227
+ if (ctx.hasUI) ctx.ui.notify('Not connected to Hub', 'warning');
1228
+ return;
1229
+ }
1230
+ try {
1231
+ client.send({ type: 'rename_session', label } as any);
1232
+ observerState.label = label;
1233
+ if (ctx.hasUI) ctx.ui.notify(`Session renamed to "${label}"`, 'info');
1234
+ log(`Session renamed to "${label}"`);
1235
+ } catch (err: any) {
1236
+ if (ctx.hasUI) ctx.ui.notify(`Failed to rename: ${err?.message || err}`, 'error');
1237
+ }
1238
+ },
1239
+ });
1240
+
1241
+ pi.registerCommand('sync-hub-skills', {
1242
+ description: 'Sync skills from Coder Hub to local .agents/skills/ directory',
1243
+ handler: async (_args, ctx) => {
1244
+ const baseUrl = getHubHttpBaseUrl(hubUrl);
1245
+ const url = `${baseUrl}/api/hub/skills`;
1246
+ try {
1247
+ const resp = await fetch(url, { headers: authHeaders() });
1248
+ if (!resp.ok) {
1249
+ const msg = `Hub skills fetch failed: ${resp.status} ${resp.statusText}`;
1250
+ if (ctx.hasUI) ctx.ui.notify(msg, 'error');
1251
+ return;
1252
+ }
1253
+ const data = (await resp.json()) as {
1254
+ ok: boolean;
1255
+ count: number;
1256
+ skills: Array<{ path: string; content: string }>;
1257
+ };
1258
+ if (!data.ok || !data.skills?.length) {
1259
+ if (ctx.hasUI) ctx.ui.notify('No skills available from Hub.', 'info');
1260
+ return;
1261
+ }
1262
+ const { mkdirSync, writeFileSync } = _require('node:fs') as typeof import('node:fs');
1263
+ const { dirname, resolve, relative } = _require(
1264
+ 'node:path'
1265
+ ) as typeof import('node:path');
1266
+ const cwd = process.cwd();
1267
+ const synced: string[] = [];
1268
+ for (const skill of data.skills) {
1269
+ if (skill.path.includes('\0')) {
1270
+ log(`Skipping skill with null byte in path: ${skill.path}`);
1271
+ continue;
1272
+ }
1273
+ const fullPath = resolve(cwd, skill.path);
1274
+ const rel = relative(cwd, fullPath);
1275
+ if (rel.startsWith('..') || resolve(cwd, rel) !== fullPath) {
1276
+ log(`Skipping skill with path traversal: ${skill.path}`);
1277
+ continue;
1278
+ }
1279
+ mkdirSync(dirname(fullPath), { recursive: true });
1280
+ writeFileSync(fullPath, skill.content, 'utf-8');
1281
+ synced.push(skill.path);
1282
+ }
1283
+ const msg = `Synced ${synced.length} skill files:\n${synced.map((p) => ` ${p}`).join('\n')}`;
1284
+ if (ctx.hasUI) ctx.ui.notify(msg, 'info');
1285
+ log(msg);
1286
+ } catch (err: any) {
1287
+ const msg = `Failed to sync skills: ${err?.message || err}`;
1288
+ if (ctx.hasUI) ctx.ui.notify(msg, 'error');
1289
+ }
1290
+ },
1291
+ });
1292
+ }
1293
+
1294
+ // ══════════════════════════════════════════════
1295
+ // Event Handlers
1296
+ // ══════════════════════════════════════════════
1297
+
1298
+ function serializeEvent(event: unknown): Record<string, unknown> {
1299
+ const data: Record<string, unknown> = {};
1300
+ if (event && typeof event === 'object') {
1301
+ for (const [key, value] of Object.entries(event)) {
1302
+ if (typeof value !== 'function' && key !== 'signal') {
1303
+ try {
1304
+ JSON.stringify(value);
1305
+ data[key] = value;
1306
+ } catch {
1307
+ /* skip */
1308
+ }
1309
+ }
1310
+ }
1311
+ }
1312
+ return data;
1313
+ }
1314
+
1315
+ async function sendEvent(
1316
+ eventName: string,
1317
+ data: Record<string, unknown>,
1318
+ ctx: ExtensionContext
1319
+ ): Promise<unknown> {
1320
+ const id = client.nextId();
1321
+ try {
1322
+ const response = await client.send({
1323
+ id,
1324
+ type: 'event',
1325
+ event: eventName,
1326
+ data: { ...data, agentRole },
1327
+ });
1328
+ const result = await processActions(response.actions, buildActionContext(ctx));
1329
+ if (result.block) return result.block;
1330
+ if (result.returnValue !== undefined) return result.returnValue;
1331
+ } catch {
1332
+ /* ignore */
1333
+ }
1334
+ return undefined;
1335
+ }
1336
+
1337
+ function sendEventNoWait(eventName: string, data: Record<string, unknown>): void {
1338
+ client.sendNoWait({
1339
+ id: client.nextId(),
1340
+ type: 'event',
1341
+ event: eventName,
1342
+ data: { ...data, agentRole },
1343
+ });
1344
+ }
1345
+
1346
+ const onEvent = pi.on.bind(pi) as GenericEventHandler;
1347
+
1348
+ // session_start: establish WebSocket connection to Hub + set up footer
1349
+ onEvent('session_start', async (event: unknown, ctx: ExtensionContext) => {
1350
+ await ensureConnected();
1351
+ footerCtx = ctx;
1352
+ if (ctx.hasUI) {
1353
+ ctx.ui.setStatus('hub_connection', getHubUiStatus());
1354
+ }
1355
+
1356
+ // Set up Coder footer (powerline: model or active agent > branch > status + observer count)
1357
+ setupCoderFooter(ctx, getHubUiStatus, getObserverState);
1358
+
1359
+ // Fire-and-forget: fetch session snapshot for label + initial observer count.
1360
+ // Uses the Hub REST endpoint — non-blocking, best-effort.
1361
+ if (!isSubAgent) {
1362
+ fetchSessionSnapshot(hubUrl!, currentSessionId, observerState).catch(() => {});
1363
+ }
1364
+
1365
+ return sendEvent('session_start', serializeEvent(event), ctx);
1366
+ });
1367
+
1368
+ // before_agent_start: inject system prompt from Hub
1369
+ onEvent('before_agent_start', async (event: unknown, ctx: ExtensionContext) => {
1370
+ const eventData = event as { systemPrompt?: string };
1371
+ let systemPrompt = eventData.systemPrompt || '';
1372
+
1373
+ const id = client.nextId();
1374
+ try {
1375
+ const response = await client.send({
1376
+ id,
1377
+ type: 'event',
1378
+ event: 'before_agent_start',
1379
+ data: { ...serializeEvent(event), agentRole },
1380
+ });
1381
+
1382
+ const result = await processActions(response.actions, buildActionContext(ctx));
1383
+ if (result.block) return result.block;
1384
+
1385
+ if (result.systemPrompt) {
1386
+ const mode = result.systemPromptMode || 'suffix';
1387
+ if (mode === 'prefix') {
1388
+ systemPrompt = result.systemPrompt + '\n\n' + systemPrompt;
1389
+ } else if (mode === 'suffix') {
1390
+ systemPrompt = systemPrompt + '\n\n' + result.systemPrompt;
1391
+ } else {
1392
+ systemPrompt = result.systemPrompt;
1393
+ }
1394
+ }
1395
+ } catch {
1396
+ /* ignore */
1397
+ }
1398
+
1399
+ // Apply config prefix/suffix — LEAD ONLY
1400
+ if (!isSubAgent) {
1401
+ if (hubConfig?.systemPromptPrefix && !systemPromptApplied) {
1402
+ systemPrompt = hubConfig.systemPromptPrefix + '\n\n' + systemPrompt;
1403
+ systemPromptApplied = true;
1404
+ }
1405
+ if (hubConfig?.systemPromptSuffix) {
1406
+ systemPrompt = systemPrompt + '\n\n' + hubConfig.systemPromptSuffix;
1407
+ }
1408
+ }
1409
+
1410
+ return { systemPrompt };
1411
+ });
1412
+
1413
+ // Proxy all other events
1414
+ for (const eventName of PROXY_EVENTS) {
1415
+ if (eventName === 'before_agent_start') continue;
1416
+ onEvent(eventName, async (event: unknown, ctx: ExtensionContext) => {
1417
+ return sendEvent(eventName, serializeEvent(event), ctx);
1418
+ });
1419
+ }
1420
+
1421
+ // ── Remote mode: input interception + remote event rendering ──
1422
+ // Two sub-modes:
1423
+ // 1. Native remote (AGENTUITY_CODER_NATIVE_REMOTE=1): remote-tui.ts drives rendering
1424
+ // via Agent.emit(). Extension only provides Hub UI (footer, /hub, commands).
1425
+ // No pi.sendMessage() rendering, no setupRemoteMode() event handlers.
1426
+ // 2. Legacy remote: Extension handles all rendering via pi.sendMessage({ customType }).
1427
+ const isNativeRemote = !!process.env[NATIVE_REMOTE_ENV];
1428
+
1429
+ if (remoteSessionId) {
1430
+ let remoteSession: RemoteSession | null = null;
1431
+
1432
+ // Register custom message renderers (used only in legacy mode, harmless in native)
1433
+ try {
1434
+ pi.registerMessageRenderer('remote_message', () => undefined);
1435
+ pi.registerMessageRenderer('remote_history', () => undefined);
1436
+ } catch {
1437
+ /* not available in this Pi version */
1438
+ }
1439
+
1440
+ if (!isNativeRemote) {
1441
+ // Legacy remote: intercept input and render events via pi.sendMessage()
1442
+ (pi.on as GenericEventHandler)('input', async (event: unknown, ctx: ExtensionContext) => {
1443
+ const inputEvent = event as { text?: string; message?: string; images?: string[] };
1444
+ const userMessage = inputEvent.text || inputEvent.message;
1445
+ if (!userMessage) return;
1446
+
1447
+ if (!remoteSession?.isConnected) {
1448
+ if (ctx.hasUI) ctx.ui.notify('Not connected to remote session yet');
1449
+ return { action: 'handled' };
1450
+ }
1451
+
1452
+ pi.sendMessage({
1453
+ customType: 'remote_message',
1454
+ content: `**You:** ${userMessage}`,
1455
+ display: true,
1456
+ });
1457
+
1458
+ remoteSession.prompt(userMessage, inputEvent.images);
1459
+ log(`Sent prompt to remote: ${userMessage.slice(0, 100)}`);
1460
+
1461
+ if (ctx.hasUI) {
1462
+ ctx.ui.setWorkingMessage('Sending to remote agent…');
1463
+ }
1464
+ return { action: 'handled' };
1465
+ });
1466
+
1467
+ // Connect the remote session with legacy event rendering
1468
+ (async () => {
1469
+ try {
1470
+ remoteSession = await setupRemoteMode(pi, hubUrl, remoteSessionId);
1471
+ log(`Remote session connected: ${remoteSessionId}`);
1472
+
1473
+ if (footerCtx) {
1474
+ (remoteSession as RemoteSessionInternal)._setExtensionCtx?.(footerCtx);
1475
+ }
1476
+
1477
+ pi.sendMessage({
1478
+ customType: 'remote_message',
1479
+ content: `Connected to remote session **${remoteSessionId}**`,
1480
+ display: true,
1481
+ });
1482
+
1483
+ remoteSession.setUiHandler(async (request) => {
1484
+ if (!footerCtx?.hasUI) return null;
1485
+ const ui = footerCtx.ui;
1486
+ switch (request.method) {
1487
+ case 'select': {
1488
+ const options =
1489
+ (request.params.options as Array<{ label: string; value: string }>) ??
1490
+ [];
1491
+ const title = (request.params.title as string) ?? 'Select';
1492
+ const result = await ui.select(
1493
+ title,
1494
+ options.map((o) => o.label)
1495
+ );
1496
+ if (result === null || result === undefined) return null;
1497
+ const idx = typeof result === 'number' ? result : Number(result);
1498
+ return options[idx]?.value ?? null;
1499
+ }
1500
+ case 'confirm':
1501
+ return await ui.confirm(
1502
+ (request.params.message as string) ?? 'Confirm?',
1503
+ (request.params.message as string) ?? 'Confirm?'
1504
+ );
1505
+ case 'input':
1506
+ return await ui.input(
1507
+ (request.params.prompt as string) ?? 'Input:',
1508
+ (request.params.placeholder as string) ?? ''
1509
+ );
1510
+ case 'editor':
1511
+ return await ui.editor(
1512
+ (request.params.content as string) ?? '',
1513
+ (request.params.language as string) ?? 'text'
1514
+ );
1515
+ case 'notify':
1516
+ ui.notify((request.params.message as string) ?? '');
1517
+ return undefined;
1518
+ case 'setStatus':
1519
+ ui.setStatus(
1520
+ (request.params.key as string) ?? 'remote',
1521
+ (request.params.text as string) ?? ''
1522
+ );
1523
+ return undefined;
1524
+ case 'setTitle':
1525
+ ui.setTitle((request.params.title as string) ?? '');
1526
+ return undefined;
1527
+ default:
1528
+ return null;
1529
+ }
1530
+ });
1531
+ } catch (err) {
1532
+ const msg = err instanceof Error ? err.message : String(err);
1533
+ log(`Remote connection failed: ${msg}`);
1534
+ pi.sendMessage({
1535
+ customType: 'remote_message',
1536
+ content: `Failed to connect to remote session: ${msg}`,
1537
+ display: true,
1538
+ });
1539
+ }
1540
+ })();
1541
+ }
1542
+ // In native remote mode: no input interception, no event rendering.
1543
+ // remote-tui.ts handles Agent.emit() for native rendering and
1544
+ // monkey-patches Agent.prompt/steer/abort for input relay.
1545
+ }
1546
+
1547
+ // Clean up on shutdown
1548
+ (pi.on as GenericEventHandler)(
1549
+ 'session_shutdown',
1550
+ async (_event: unknown, _ctx: ExtensionContext) => {
1551
+ log('Shutting down — closing Hub connection');
1552
+ try {
1553
+ client.close();
1554
+ } catch {
1555
+ /* pending promises rejected on close — safe to ignore */
1556
+ }
1557
+ }
1558
+ );
1559
+ }
1560
+
1561
+ // ══════════════════════════════════════════════
1562
+ // In-Process Sub-Agent Execution
1563
+ // Uses Pi's createAgentSession() for fast, context-isolated sub-agents.
1564
+ // NO subprocess spawning — returns only getLastAssistantText(), not JSONL events.
1565
+ // Pattern based on oh-my-pi's in-process executor.
1566
+ // ══════════════════════════════════════════════
1567
+
1568
+ /** Token usage extracted from sub-agent sessions (best-effort) */
1569
+ interface SubAgentTokens {
1570
+ input: number;
1571
+ output: number;
1572
+ cost: number;
1573
+ }
1574
+
1575
+ /** Callback fired during sub-agent execution with live progress updates */
1576
+ type ProgressCallback = (progress: AgentProgressUpdate) => void;
1577
+
1578
+ function truncateOutput(text: string): string {
1579
+ let result = text;
1580
+ const lines = result.split('\n');
1581
+ if (lines.length > MAX_OUTPUT_LINES) {
1582
+ result =
1583
+ lines.slice(0, MAX_OUTPUT_LINES).join('\n') +
1584
+ `\n\n[Output truncated — ${lines.length - MAX_OUTPUT_LINES} lines omitted]`;
1585
+ }
1586
+ if (result.length > MAX_OUTPUT_BYTES) {
1587
+ result =
1588
+ result.slice(0, MAX_OUTPUT_BYTES) +
1589
+ `\n\n[Output truncated — exceeded ${MAX_OUTPUT_BYTES} bytes]`;
1590
+ }
1591
+ return result;
1592
+ }
1593
+
1594
+ /** Cache resolved Pi SDK modules to avoid repeated dynamic import resolution */
1595
+ let _piSdkCache: { piSdk: unknown; piAi: unknown } | null = null;
1596
+
1597
+ /**
1598
+ * Load Pi SDK packages at runtime.
1599
+ * The extension runs inside Pi's process, but @mariozechner/pi-ai isn't in
1600
+ * our node_modules — resolve it from Pi's install directory via process.argv[1].
1601
+ */
1602
+ async function loadPiSdk(): Promise<{ piSdk: unknown; piAi: unknown }> {
1603
+ if (_piSdkCache) return _piSdkCache;
1604
+
1605
+ // Try direct import first (works if packages are in module resolution path)
1606
+ try {
1607
+ const piSdk = await import('@mariozechner/pi-coding-agent');
1608
+ // @ts-expect-error pi-ai is a runtime dependency available inside Pi's process
1609
+ const piAi = await import('@mariozechner/pi-ai');
1610
+ _piSdkCache = { piSdk, piAi };
1611
+ return _piSdkCache;
1612
+ } catch {
1613
+ /* fall through to argv[1] resolution */
1614
+ }
1615
+
1616
+ // Resolve from Pi CLI binary (process.argv[1] → pi-coding-agent package root)
1617
+ const { realpathSync } = _require('node:fs') as typeof import('node:fs');
1618
+ const { pathToFileURL } = _require('node:url') as typeof import('node:url');
1619
+ const { dirname, join } = _require('node:path') as typeof import('node:path');
1620
+
1621
+ const piRealPath = realpathSync(process.argv[1] || '');
1622
+ const piPkgDir = dirname(dirname(piRealPath));
1623
+ const piSdkEntry = pathToFileURL(join(piPkgDir, 'dist', 'index.js')).href;
1624
+ const piAiEntry = pathToFileURL(
1625
+ join(piPkgDir, 'node_modules', '@mariozechner', 'pi-ai', 'dist', 'index.js')
1626
+ ).href;
1627
+
1628
+ const piSdk = await import(piSdkEntry);
1629
+ const piAi = await import(piAiEntry);
1630
+ _piSdkCache = { piSdk, piAi };
1631
+ return _piSdkCache;
1632
+ }
1633
+
1634
+ /**
1635
+ * Create a Pi-compatible tool that proxies execution to the Hub via WebSocket.
1636
+ * Used to give sub-agents access to Hub tools (memory, context7, etc.).
1637
+ */
1638
+ function createHubToolProxy(
1639
+ toolDef: HubToolDefinition,
1640
+ hubClient: HubClient
1641
+ ): Record<string, unknown> {
1642
+ return {
1643
+ name: toolDef.name,
1644
+ label: toolDef.label || toolDef.name,
1645
+ description: toolDef.description,
1646
+ parameters: toolDef.parameters,
1647
+ async execute(
1648
+ toolCallId: string,
1649
+ params: unknown
1650
+ ): Promise<{ content: Array<{ type: string; text: string }>; details: unknown }> {
1651
+ if (!hubClient.connected) {
1652
+ return {
1653
+ content: [
1654
+ { type: 'text', text: `Hub not connected — cannot execute ${toolDef.name}` },
1655
+ ],
1656
+ details: undefined,
1657
+ };
1658
+ }
1659
+ const id = hubClient.nextId();
1660
+ try {
1661
+ const response = await hubClient.send({
1662
+ id,
1663
+ type: 'tool',
1664
+ name: toolDef.name,
1665
+ toolCallId,
1666
+ params: (params ?? {}) as Record<string, unknown>,
1667
+ });
1668
+ // Extract RETURN action result
1669
+ const returnAction = response.actions.find((a: HubAction) => a.action === 'RETURN');
1670
+ if (returnAction && 'result' in returnAction) {
1671
+ const text =
1672
+ typeof returnAction.result === 'string'
1673
+ ? returnAction.result
1674
+ : JSON.stringify(returnAction.result, null, 2);
1675
+ return { content: [{ type: 'text', text }], details: undefined };
1676
+ }
1677
+ return { content: [{ type: 'text', text: 'Done' }], details: undefined };
1678
+ } catch (err) {
1679
+ const msg = err instanceof Error ? err.message : String(err);
1680
+ return {
1681
+ content: [{ type: 'text', text: `Hub tool error: ${msg}` }],
1682
+ details: undefined,
1683
+ };
1684
+ }
1685
+ },
1686
+ };
1687
+ }
1688
+
1689
+ /**
1690
+ * Run a sub-agent in-process using Pi's createAgentSession().
1691
+ * Sub-agents are created with noExtensions=true so they can't recursively
1692
+ * spawn further sub-agents (no task tool registered).
1693
+ * Sub-agents DO get Hub tools (memory, context7, etc.) via extensionFactories.
1694
+ * Only returns the final assistant text, not intermediate events.
1695
+ */
1696
+ async function runSubAgent(
1697
+ agentConfig: AgentDefinition,
1698
+ task: string,
1699
+ hubClient: HubClient,
1700
+ onProgress?: ProgressCallback,
1701
+ signal?: AbortSignal
1702
+ ): Promise<{ output: string; duration: number; tokens: SubAgentTokens }> {
1703
+ const startTime = Date.now();
1704
+
1705
+ const { piSdk, piAi } = await loadPiSdk();
1706
+ // Runtime-resolved dynamic imports — exact types unavailable statically
1707
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1708
+ const {
1709
+ createAgentSession,
1710
+ DefaultResourceLoader,
1711
+ SessionManager,
1712
+ createCodingTools,
1713
+ createReadOnlyTools,
1714
+ } = piSdk as any;
1715
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1716
+ const { getModel } = piAi as any;
1717
+
1718
+ // Model — use agent's configured model (sub-agents typically use haiku for speed)
1719
+ const modelId = agentConfig.model || 'claude-haiku-4-5';
1720
+ const [provider, id] = modelId.includes('/')
1721
+ ? (modelId.split('/', 2) as [string, string])
1722
+ : ['anthropic', modelId];
1723
+ const subModel = getModel(provider, id);
1724
+ if (!subModel) {
1725
+ throw new Error(
1726
+ `Model "${modelId}" not available. ` +
1727
+ `Check that the ${provider} API key is configured ` +
1728
+ `(e.g. ${provider.toUpperCase().replace(/[^A-Z]/g, '_')}_API_KEY).`
1729
+ );
1730
+ }
1731
+
1732
+ // Hub tools for this sub-agent (shared WebSocket connection)
1733
+ // Sub-agents get Hub tools (memory, context7, etc.) via extensionFactories
1734
+ // so they work in both driver and TUI mode.
1735
+ const hubTools = agentConfig.hubTools ?? [];
1736
+
1737
+ // Resource loader — no extensions (prevents recursive task tool registration),
1738
+ // no skills, agent's system prompt injected directly.
1739
+ // Hub tools are injected via extensionFactories so sub-agents can use
1740
+ // memory_recall, context7_search, etc.
1741
+ const subLoader = new DefaultResourceLoader({
1742
+ cwd: process.cwd(),
1743
+ noExtensions: true,
1744
+ extensionFactories:
1745
+ hubTools.length > 0
1746
+ ? [
1747
+ (pi: ExtensionAPI) => {
1748
+ for (const toolDef of hubTools) {
1749
+ // Proxy object has the correct shape; cast needed because return type is Record<string, unknown>
1750
+ pi.registerTool(
1751
+ createHubToolProxy(toolDef, hubClient) as unknown as ToolDefinition
1752
+ );
1753
+ }
1754
+ },
1755
+ ]
1756
+ : [],
1757
+ systemPromptOverride: () => agentConfig.systemPrompt,
1758
+ });
1759
+ await subLoader.reload();
1760
+
1761
+ // Select tools based on readOnly flag
1762
+ const cwd = process.cwd();
1763
+ const tools = agentConfig.readOnly ? createReadOnlyTools(cwd) : createCodingTools(cwd);
1764
+
1765
+ const { session } = await createAgentSession({
1766
+ // subModel is already untyped (from dynamic import) — createAgentSession is also dynamically imported
1767
+ model: subModel,
1768
+ thinkingLevel: (agentConfig.thinkingLevel || 'xhigh') as
1769
+ | 'off'
1770
+ | 'minimal'
1771
+ | 'low'
1772
+ | 'medium'
1773
+ | 'high'
1774
+ | 'xhigh',
1775
+ tools,
1776
+ resourceLoader: subLoader,
1777
+ sessionManager: SessionManager.inMemory('/tmp'),
1778
+ });
1779
+ await session.bindExtensions({});
1780
+
1781
+ // Subscribe to sub-agent events for live progress tracking
1782
+ if (onProgress) {
1783
+ try {
1784
+ session.subscribe?.((event: unknown) => {
1785
+ try {
1786
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1787
+ const evt = event as any;
1788
+ const elapsed = Date.now() - startTime;
1789
+
1790
+ // Handle streaming message updates (thinking + text tokens)
1791
+ if (evt.type === 'message_update' && evt.assistantMessageEvent) {
1792
+ const ame = evt.assistantMessageEvent as { type?: string; delta?: string };
1793
+ if (ame.type === 'thinking_delta' && ame.delta) {
1794
+ onProgress({
1795
+ agentName: agentConfig.name,
1796
+ status: 'thinking_delta',
1797
+ delta: ame.delta,
1798
+ elapsed,
1799
+ });
1800
+ } else if (ame.type === 'text_delta' && ame.delta) {
1801
+ onProgress({
1802
+ agentName: agentConfig.name,
1803
+ status: 'text_delta',
1804
+ delta: ame.delta,
1805
+ elapsed,
1806
+ });
1807
+ }
1808
+ return;
1809
+ }
1810
+
1811
+ if (evt.type === 'tool_execution_start' || evt.type === 'tool_call') {
1812
+ const toolName = evt.toolName || evt.name || evt.tool || 'unknown';
1813
+ let toolArgs = '';
1814
+ if (evt.args && typeof evt.args === 'object') {
1815
+ const args = evt.args as Record<string, unknown>;
1816
+ if (args.command) toolArgs = String(args.command).slice(0, 60);
1817
+ else if (args.filePath || args.path)
1818
+ toolArgs = String(args.filePath || args.path);
1819
+ else if (args.pattern) toolArgs = String(args.pattern).slice(0, 40);
1820
+ else {
1821
+ const first = Object.values(args)[0];
1822
+ if (first) toolArgs = String(first).slice(0, 40);
1823
+ }
1824
+ }
1825
+
1826
+ onProgress({
1827
+ agentName: agentConfig.name,
1828
+ status: 'tool_start',
1829
+ currentTool: toolName,
1830
+ currentToolArgs: toolArgs,
1831
+ elapsed,
1832
+ });
1833
+ } else if (evt.type === 'tool_execution_end' || evt.type === 'tool_result') {
1834
+ onProgress({
1835
+ agentName: agentConfig.name,
1836
+ status: 'tool_end',
1837
+ elapsed,
1838
+ });
1839
+ }
1840
+ } catch {
1841
+ /* ignore — progress tracking is best-effort */
1842
+ }
1843
+ });
1844
+ } catch {
1845
+ /* ignore — subscribe may not be available */
1846
+ }
1847
+ }
1848
+
1849
+ // Abort signal support — cancel sub-agent when user presses Esc
1850
+ if (signal) {
1851
+ if (signal.aborted) {
1852
+ throw new Error('Aborted');
1853
+ }
1854
+ const onAbort = () => {
1855
+ log(`Sub-agent ${agentConfig.name} aborted by signal`);
1856
+ try {
1857
+ session.abort?.();
1858
+ } catch {
1859
+ /* ignore */
1860
+ }
1861
+ };
1862
+ signal.addEventListener('abort', onAbort, { once: true });
1863
+ }
1864
+
1865
+ log(`Sub-agent started: ${agentConfig.name} (model: ${modelId})`);
1866
+
1867
+ try {
1868
+ await session.prompt(task);
1869
+
1870
+ // Only return the final assistant text — NOT intermediate JSONL events
1871
+ const output = session.getLastAssistantText?.() || '(no output)';
1872
+ const duration = Date.now() - startTime;
1873
+ log(`Sub-agent ${agentConfig.name} completed in ${duration}ms`);
1874
+
1875
+ // Best-effort token extraction from sub-agent session messages
1876
+ const subTokens: SubAgentTokens = { input: 0, output: 0, cost: 0 };
1877
+ try {
1878
+ const branch = session.sessionManager?.getBranch?.() || [];
1879
+ for (const entry of branch) {
1880
+ if (entry.type === 'message') {
1881
+ const msg = entry.message as {
1882
+ role?: string;
1883
+ usage?: { input: number; output: number; cost: { total: number } };
1884
+ };
1885
+ if (msg.role === 'assistant' && msg.usage) {
1886
+ subTokens.input += msg.usage.input;
1887
+ subTokens.output += msg.usage.output;
1888
+ subTokens.cost += msg.usage.cost.total;
1889
+ }
1890
+ }
1891
+ }
1892
+ } catch {
1893
+ /* ignore — token extraction is best-effort */
1894
+ }
1895
+
1896
+ return { output: truncateOutput(output.trim()), duration, tokens: subTokens };
1897
+ } catch (err) {
1898
+ try {
1899
+ session.abort?.();
1900
+ } catch {
1901
+ /* ignore */
1902
+ }
1903
+ throw err;
1904
+ }
1905
+ }
1906
+
1907
+ export default agentuityCoderHub;