@agentuity/coder-tui 2.0.8

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