@agentuity/coder 2.0.7 → 2.0.9-v3.48d5810

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