@agentuity/coder 2.0.6 → 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 (113) hide show
  1. package/AGENTS.md +43 -0
  2. package/README.md +75 -37
  3. package/dist/index.d.ts +1 -3
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -1577
  6. package/dist/index.js.map +1 -1
  7. package/package.json +9 -12
  8. package/src/index.ts +51 -1885
  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 -43
  14. package/dist/client.d.ts.map +0 -1
  15. package/dist/client.js +0 -405
  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 -518
  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
@@ -1,1080 +0,0 @@
1
- /**
2
- * Remote Session Bridge — TUI ↔ Hub ↔ Sandbox
3
- *
4
- * Handles the WebSocket connection and RPC protocol bridge for remote mode.
5
- * In remote mode, the local Pi TUI connects to an existing sandbox session
6
- * through the Hub, acting as a thin client:
7
- * - User input → rpc_command → Hub → sandbox
8
- * - Sandbox events → rpc_event → Hub → TUI rendering
9
- * - Extension UI dialogs → rpc_ui_request/response → Hub ↔ TUI
10
- *
11
- * This module manages the connection lifecycle and provides an API
12
- * for the extension to send commands and receive events.
13
- */
14
-
15
- import type { ExtensionAPI, ExtensionContext } from '@mariozechner/pi-coding-agent';
16
- import {
17
- applyRemoteLifecycleEvent,
18
- clearRemoteLifecycleWorkingMessage,
19
- createRemoteLifecycleState,
20
- getRemoteLifecycleActivityLabel,
21
- getRemoteLifecycleLabel,
22
- syncRemoteLifecycleWorkingMessage,
23
- type RemoteLifecycleState,
24
- } from './remote-lifecycle.ts';
25
-
26
- const DEBUG = !!process.env['AGENTUITY_DEBUG'];
27
-
28
- function log(msg: string): void {
29
- if (DEBUG) console.error(`[remote-session] ${msg}`);
30
- }
31
-
32
- // ── RPC Message Types (mirrors hub-protocol.ts) ──
33
-
34
- export interface RpcCommand {
35
- type: string;
36
- [key: string]: unknown;
37
- }
38
-
39
- export interface RpcEvent {
40
- type: string;
41
- [key: string]: unknown;
42
- }
43
-
44
- export interface RpcUiRequest {
45
- id: string;
46
- method: string;
47
- params: Record<string, unknown>;
48
- }
49
-
50
- /** RPC response from sandbox (correlated by id) */
51
- export interface RpcResponse {
52
- type: 'response';
53
- id: string;
54
- command: string;
55
- success: boolean;
56
- data?: unknown;
57
- error?: string;
58
- }
59
-
60
- export type RemoteEventHandler = (event: RpcEvent) => void;
61
- export type RemoteResponseHandler = (response: RpcResponse) => void;
62
- export type RemoteUiHandler = (request: RpcUiRequest) => Promise<unknown>;
63
- export type RemoteConnectionHandler = (
64
- state: 'connected' | 'reconnecting' | 'disconnected'
65
- ) => void;
66
- export type RemoteLifecycleHandler = (state: RemoteLifecycleState) => void;
67
-
68
- // ── Remote Session Client ──
69
-
70
- const RECONNECT_BASE_MS = 1_000;
71
- const RECONNECT_MAX_MS = 30_000;
72
- const MAX_RECONNECT_ATTEMPTS = 20;
73
-
74
- export class RemoteSession {
75
- private ws: WebSocket | null = null;
76
- private connected = false;
77
- private intentionallyClosed = false;
78
- private hubWsUrl: string = '';
79
- private reconnectAttempts = 0;
80
- private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
81
- private eventHandlers: RemoteEventHandler[] = [];
82
- private uiHandler: RemoteUiHandler | null = null;
83
- private responseHandlers: RemoteResponseHandler[] = [];
84
- private connectionHandlers: RemoteConnectionHandler[] = [];
85
- private lifecycleHandlers: RemoteLifecycleHandler[] = [];
86
- private lifecycleState: RemoteLifecycleState;
87
- private replaySettledTimer: ReturnType<typeof setTimeout> | null = null;
88
-
89
- /** Session ID this client is connected to */
90
- public sessionId: string;
91
- /** Session label (populated after connection) */
92
- public label: string = '';
93
-
94
- /** API key for Hub authentication */
95
- // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
96
- public apiKey: string | null = null;
97
-
98
- constructor(sessionId: string) {
99
- this.sessionId = sessionId;
100
- this.lifecycleState = createRemoteLifecycleState(sessionId);
101
- }
102
-
103
- private dispatchEvent(event: RpcEvent): void {
104
- for (const handler of this.eventHandlers) {
105
- try {
106
- handler(event);
107
- } catch (err) {
108
- log(`Event handler error: ${err instanceof Error ? err.message : String(err)}`);
109
- }
110
- }
111
- }
112
-
113
- private dispatchResponse(response: RpcResponse): void {
114
- for (const handler of this.responseHandlers) {
115
- try {
116
- handler(response);
117
- } catch (err) {
118
- log(`Response handler error: ${err instanceof Error ? err.message : String(err)}`);
119
- }
120
- }
121
- }
122
-
123
- private applyLifecycle(event: Parameters<typeof applyRemoteLifecycleEvent>[1]): void {
124
- const next = applyRemoteLifecycleEvent(this.lifecycleState, event);
125
- if (next === this.lifecycleState) return;
126
- this.lifecycleState = next;
127
- for (const handler of this.lifecycleHandlers) {
128
- try {
129
- handler(this.lifecycleState);
130
- } catch (err) {
131
- log(`Lifecycle handler error: ${err instanceof Error ? err.message : String(err)}`);
132
- }
133
- }
134
- }
135
-
136
- private clearReplaySettledTimer(): void {
137
- if (!this.replaySettledTimer) return;
138
- clearTimeout(this.replaySettledTimer);
139
- this.replaySettledTimer = null;
140
- }
141
-
142
- private scheduleReplaySettled(): void {
143
- this.clearReplaySettledTimer();
144
- this.replaySettledTimer = setTimeout(() => {
145
- this.replaySettledTimer = null;
146
- this.applyLifecycle({ type: 'replay_idle' });
147
- }, 400);
148
- }
149
-
150
- private observeLiveSignal(eventType: string, isStreaming?: boolean): void {
151
- const liveEvents = new Set([
152
- 'agent_start',
153
- 'agent_end',
154
- 'message_start',
155
- 'message_update',
156
- 'message_end',
157
- 'thinking_start',
158
- 'thinking_update',
159
- 'thinking_end',
160
- 'tool_call',
161
- 'tool_result',
162
- 'tool_execution_start',
163
- 'tool_execution_end',
164
- 'task_start',
165
- 'task_complete',
166
- 'task_error',
167
- 'turn_start',
168
- 'turn_end',
169
- 'rpc_response',
170
- 'rpc_ui_request',
171
- ]);
172
- if (!liveEvents.has(eventType)) return;
173
-
174
- this.clearReplaySettledTimer();
175
- this.applyLifecycle({ type: 'live_signal', isStreaming });
176
- }
177
-
178
- private getLiveSignalStreamingState(eventType: string): boolean | undefined {
179
- if (
180
- eventType === 'agent_start' ||
181
- eventType === 'message_start' ||
182
- eventType === 'message_update' ||
183
- eventType === 'thinking_start' ||
184
- eventType === 'thinking_update' ||
185
- eventType === 'tool_execution_start' ||
186
- eventType === 'turn_start' ||
187
- eventType === 'task_start'
188
- ) {
189
- return true;
190
- }
191
- if (eventType === 'agent_end' || eventType === 'turn_end') {
192
- return false;
193
- }
194
- return undefined;
195
- }
196
-
197
- private shouldMarkResuming(commandType: string): boolean {
198
- return commandType === 'prompt' || commandType === 'follow_up' || commandType === 'steer';
199
- }
200
-
201
- private shouldObserveRpcResponseAsLive(): boolean {
202
- return this.lifecycleState.phase !== 'paused' && this.lifecycleState.phase !== 'replaying';
203
- }
204
-
205
- /** Register a handler for RPC events from the sandbox */
206
- onEvent(handler: RemoteEventHandler): void {
207
- this.eventHandlers.push(handler);
208
- }
209
-
210
- /** Register a handler for RPC responses from the sandbox */
211
- onResponse(handler: RemoteResponseHandler): void {
212
- this.responseHandlers.push(handler);
213
- }
214
-
215
- /** Register the UI dialog handler (select, confirm, input, editor) */
216
- setUiHandler(handler: RemoteUiHandler): void {
217
- this.uiHandler = handler;
218
- }
219
-
220
- /** Register a connection state change handler */
221
- onConnectionChange(handler: RemoteConnectionHandler): void {
222
- this.connectionHandlers.push(handler);
223
- }
224
-
225
- /** Register a lifecycle state handler for remote attach/replay/live transitions. */
226
- onLifecycleChange(handler: RemoteLifecycleHandler): void {
227
- this.lifecycleHandlers.push(handler);
228
- handler(this.lifecycleState);
229
- }
230
-
231
- getLifecycleState(): RemoteLifecycleState {
232
- return this.lifecycleState;
233
- }
234
-
235
- /** Connect to the Hub WebSocket as a controller for the remote session */
236
- async connect(hubWsUrl: string): Promise<void> {
237
- this.hubWsUrl = hubWsUrl;
238
- this.intentionallyClosed = false;
239
- this.reconnectAttempts = 0;
240
- return this.doConnect();
241
- }
242
-
243
- private doConnect(): Promise<void> {
244
- return new Promise((resolve, reject) => {
245
- const isReconnect = this.reconnectAttempts > 0;
246
- this.applyLifecycle({ type: 'connect_start', reconnect: isReconnect });
247
-
248
- // Build URL with controller params
249
- const url = new URL(this.hubWsUrl);
250
- url.searchParams.set('sessionId', this.sessionId);
251
- url.searchParams.set('role', 'controller');
252
-
253
- log(`${isReconnect ? 'Reconnecting' : 'Connecting'} to ${url.toString()}`);
254
- // TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
255
- this.ws = this.apiKey
256
- ? new WebSocket(url.toString(), {
257
- headers: { 'x-agentuity-auth-api-key': this.apiKey },
258
- })
259
- : new WebSocket(url.toString());
260
-
261
- const connectTimeout = setTimeout(() => {
262
- reject(new Error('Remote session connection timed out'));
263
- this.ws?.close();
264
- }, 30_000);
265
-
266
- this.ws.onopen = () => {
267
- log('WebSocket connected');
268
- };
269
-
270
- this.ws.onmessage = (event: MessageEvent) => {
271
- let data: Record<string, unknown>;
272
- try {
273
- const raw =
274
- typeof event.data === 'string'
275
- ? event.data
276
- : new TextDecoder().decode(event.data as ArrayBuffer);
277
- data = JSON.parse(raw) as Record<string, unknown>;
278
- } catch {
279
- return;
280
- }
281
-
282
- const type = data.type as string;
283
-
284
- // Init message — connection established
285
- if (type === 'init') {
286
- clearTimeout(connectTimeout);
287
- this.connected = true;
288
- this.reconnectAttempts = 0;
289
- if (data.sessionId) this.sessionId = data.sessionId as string;
290
- if (typeof data.label === 'string') this.label = data.label;
291
- this.applyLifecycle({
292
- type: 'init',
293
- sessionId: typeof data.sessionId === 'string' ? data.sessionId : undefined,
294
- label: typeof data.label === 'string' ? data.label : undefined,
295
- });
296
- try {
297
- this.ws?.send(JSON.stringify({ type: 'bootstrap_ready' }));
298
- } catch {
299
- // Let the close/error path surface bootstrap failure.
300
- }
301
- log(`Connected to session ${this.sessionId}`);
302
- this.notifyConnectionChange('connected');
303
- resolve();
304
- return;
305
- }
306
-
307
- // Connection rejected
308
- if (type === 'connection_rejected') {
309
- clearTimeout(connectTimeout);
310
- const msg = (data.message as string) || 'Connection rejected';
311
- this.applyLifecycle({
312
- type: 'rpc_command_error',
313
- error: msg,
314
- paused: false,
315
- });
316
- reject(new Error(msg));
317
- return;
318
- }
319
-
320
- if (type === 'protocol_error') {
321
- clearTimeout(connectTimeout);
322
- const msg = (data.message as string) || 'Hub protocol error';
323
- this.applyLifecycle({
324
- type: 'rpc_command_error',
325
- error: msg,
326
- paused: false,
327
- });
328
- this.dispatchEvent({
329
- type: 'protocol_error',
330
- ...data,
331
- _source: 'hub',
332
- } as RpcEvent);
333
- if (!this.connected) {
334
- reject(new Error(msg));
335
- }
336
- return;
337
- }
338
-
339
- if (type === 'session_resume') {
340
- this.applyLifecycle({
341
- type: 'session_resume',
342
- streamId: typeof data.streamId === 'string' ? data.streamId : null,
343
- streamUrl: typeof data.streamUrl === 'string' ? data.streamUrl : null,
344
- });
345
- this.dispatchEvent({
346
- type: 'session_resume',
347
- ...data,
348
- _source: 'hub',
349
- } as RpcEvent);
350
- return;
351
- }
352
-
353
- if (type === 'session_stream_ready') {
354
- this.applyLifecycle({
355
- type: 'stream_ready',
356
- streamId: typeof data.streamId === 'string' ? data.streamId : null,
357
- streamUrl: typeof data.streamUrl === 'string' ? data.streamUrl : null,
358
- });
359
- this.dispatchEvent({
360
- type: 'session_stream_ready',
361
- ...data,
362
- _source: 'hub',
363
- } as RpcEvent);
364
- return;
365
- }
366
-
367
- if (type === 'rpc_command_error') {
368
- const error = typeof data.error === 'string' ? data.error : 'Remote command failed';
369
- this.applyLifecycle({
370
- type: 'rpc_command_error',
371
- error,
372
- paused: /sandbox .*not connected|resume/i.test(error),
373
- });
374
- this.dispatchEvent({
375
- type: 'rpc_command_error',
376
- ...data,
377
- _source: 'hub',
378
- } as RpcEvent);
379
- return;
380
- }
381
-
382
- // Broadcast-wrapped messages from Hub (LIVE events)
383
- // Format: { type: 'broadcast', event: '<name>', data: { ...payload } }
384
- if (type === 'broadcast') {
385
- const broadcastEvent = data.event as string;
386
- const broadcastData = (data.data as Record<string, unknown>) ?? {};
387
- if (broadcastEvent === 'rpc_event') {
388
- const rpcEvent = broadcastData.event as RpcEvent;
389
- if (rpcEvent) {
390
- this.observeLiveSignal(
391
- rpcEvent.type,
392
- this.getLiveSignalStreamingState(rpcEvent.type)
393
- );
394
- this.dispatchEvent({ ...rpcEvent, _source: 'live' } as RpcEvent);
395
- }
396
- } else if (broadcastEvent === 'rpc_response') {
397
- const response = broadcastData.response as RpcResponse;
398
- if (response) {
399
- if (this.shouldObserveRpcResponseAsLive()) {
400
- this.observeLiveSignal('rpc_response');
401
- }
402
- this.dispatchResponse(response);
403
- }
404
- } else if (broadcastEvent === 'rpc_ui_request') {
405
- this.observeLiveSignal('rpc_ui_request');
406
- this.handleUiRequest({
407
- id: broadcastData.id as string,
408
- method: broadcastData.method as string,
409
- params: (broadcastData.params as Record<string, unknown>) ?? {},
410
- });
411
- } else {
412
- // Lifecycle event broadcasts (agent_start, message_end, turn_start, etc.)
413
- // The broadcastData IS the event payload with a `type` field matching broadcastEvent.
414
- // Dispatch as a regular event so the TUI can render agent activity.
415
- this.observeLiveSignal(
416
- broadcastEvent,
417
- this.getLiveSignalStreamingState(broadcastEvent)
418
- );
419
- this.dispatchEvent({
420
- type: broadcastEvent,
421
- ...broadcastData,
422
- _source: 'live',
423
- } as RpcEvent);
424
- }
425
- return;
426
- }
427
-
428
- // Legacy/raw RPC messages — tolerated but not expected on the controller path.
429
- if (type === 'rpc_event') {
430
- const rpcEvent = data.event as RpcEvent;
431
- if (rpcEvent) {
432
- this.applyLifecycle({ type: 'replay_event' });
433
- this.scheduleReplaySettled();
434
- this.dispatchEvent({ ...rpcEvent, _source: 'replay' } as RpcEvent);
435
- }
436
- return;
437
- }
438
-
439
- if (type === 'rpc_response') {
440
- const response = data.response as RpcResponse;
441
- if (response) {
442
- if (this.shouldObserveRpcResponseAsLive()) {
443
- this.observeLiveSignal('rpc_response');
444
- }
445
- this.dispatchResponse(response);
446
- }
447
- return;
448
- }
449
-
450
- if (type === 'rpc_ui_request') {
451
- this.observeLiveSignal('rpc_ui_request');
452
- this.handleUiRequest({
453
- id: data.id as string,
454
- method: data.method as string,
455
- params: (data.params as Record<string, unknown>) ?? {},
456
- });
457
- return;
458
- }
459
-
460
- // Session hydration (conversation entries + task states from observer hydration)
461
- if (type === 'session_hydration') {
462
- this.applyLifecycle({
463
- type: 'hydration',
464
- leadConnected:
465
- typeof data.leadConnected === 'boolean' ? data.leadConnected : undefined,
466
- isStreaming:
467
- typeof (data.streamingState as { isStreaming?: unknown } | undefined)
468
- ?.isStreaming === 'boolean'
469
- ? Boolean((data.streamingState as { isStreaming?: boolean }).isStreaming)
470
- : undefined,
471
- });
472
- // Pass through as an event so the extension can render it
473
- for (const handler of this.eventHandlers) {
474
- try {
475
- handler({ type: 'session_hydration', ...data });
476
- } catch (err) {
477
- log(
478
- `Hydration handler error: ${err instanceof Error ? err.message : String(err)}`
479
- );
480
- }
481
- }
482
- return;
483
- }
484
-
485
- log(`Unhandled message type: ${type}`);
486
- };
487
-
488
- this.ws.onerror = (err: Event) => {
489
- clearTimeout(connectTimeout);
490
- if (!this.connected) {
491
- const message = 'message' in err ? (err as ErrorEvent).message : 'WebSocket error';
492
- reject(new Error(message));
493
- }
494
- };
495
-
496
- this.ws.onclose = () => {
497
- clearTimeout(connectTimeout);
498
- const wasConnected = this.connected;
499
- this.connected = false;
500
- this.clearReplaySettledTimer();
501
- if (!this.intentionallyClosed) {
502
- if (wasConnected) {
503
- log('WebSocket closed unexpectedly — scheduling reconnect');
504
- this.notifyConnectionChange('reconnecting');
505
- this.applyLifecycle({ type: 'connection_change', state: 'reconnecting' });
506
- this.scheduleReconnect();
507
- } else if (!isReconnect) {
508
- // Failed initial connect and not already in reconnect loop
509
- log('WebSocket closed during initial connect');
510
- }
511
- }
512
- };
513
- });
514
- }
515
-
516
- private scheduleReconnect(): void {
517
- if (this.intentionallyClosed) return;
518
- if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
519
- log(`Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached — giving up`);
520
- this.notifyConnectionChange('disconnected');
521
- this.applyLifecycle({ type: 'connection_change', state: 'disconnected' });
522
- return;
523
- }
524
-
525
- const delay = Math.min(
526
- RECONNECT_BASE_MS * Math.pow(2, this.reconnectAttempts),
527
- RECONNECT_MAX_MS
528
- );
529
- this.reconnectAttempts++;
530
- log(`Reconnect attempt ${this.reconnectAttempts} in ${delay}ms`);
531
-
532
- this.reconnectTimer = setTimeout(async () => {
533
- this.reconnectTimer = null;
534
- try {
535
- await this.doConnect();
536
- // On successful reconnect, request fresh state
537
- this.getState();
538
- } catch (err) {
539
- log(`Reconnect failed: ${err instanceof Error ? err.message : String(err)}`);
540
- this.scheduleReconnect();
541
- }
542
- }, delay);
543
- }
544
-
545
- private notifyConnectionChange(state: 'connected' | 'reconnecting' | 'disconnected'): void {
546
- for (const handler of this.connectionHandlers) {
547
- try {
548
- handler(state);
549
- } catch {
550
- /* ignore */
551
- }
552
- }
553
- }
554
-
555
- /** Send an RPC command to the sandbox (prompt, steer, abort, etc.) */
556
- sendCommand(command: RpcCommand): void {
557
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
558
- log('Cannot send command — not connected');
559
- return;
560
- }
561
- if (this.shouldMarkResuming(command.type) && this.lifecycleState.phase === 'paused') {
562
- this.applyLifecycle({ type: 'local_resume_requested' });
563
- }
564
- this.ws.send(
565
- JSON.stringify({
566
- type: 'rpc_command',
567
- command,
568
- })
569
- );
570
- }
571
-
572
- /** Send a user prompt to the remote sandbox */
573
- prompt(message: string, images?: string[]): void {
574
- this.sendCommand({
575
- type: 'prompt',
576
- message,
577
- ...(images?.length ? { images } : {}),
578
- });
579
- }
580
-
581
- /** Steer the agent mid-turn */
582
- steer(message: string): void {
583
- this.sendCommand({ type: 'steer', message });
584
- }
585
-
586
- /** Abort current operation */
587
- abort(): void {
588
- this.sendCommand({ type: 'abort' });
589
- }
590
-
591
- /** Get current session state */
592
- getState(): void {
593
- this.sendCommand({ type: 'get_state', id: crypto.randomUUID() });
594
- }
595
-
596
- /** Get all messages in current session */
597
- getMessages(): void {
598
- this.sendCommand({ type: 'get_messages', id: crypto.randomUUID() });
599
- }
600
-
601
- /** Compact the session context */
602
- compact(): void {
603
- this.sendCommand({ type: 'compact' });
604
- }
605
-
606
- /** Close the connection */
607
- close(): void {
608
- this.intentionallyClosed = true;
609
- this.clearReplaySettledTimer();
610
- if (this.reconnectTimer) {
611
- clearTimeout(this.reconnectTimer);
612
- this.reconnectTimer = null;
613
- }
614
- this.ws?.close();
615
- this.ws = null;
616
- this.applyLifecycle({ type: 'connection_change', state: 'disconnected' });
617
- }
618
-
619
- get isConnected(): boolean {
620
- return this.connected;
621
- }
622
-
623
- /** Handle UI request from sandbox — delegate to registered handler */
624
- private async handleUiRequest(request: RpcUiRequest): Promise<void> {
625
- if (!this.uiHandler) {
626
- log(`No UI handler for ${request.method} — sending null response`);
627
- this.sendUiResponse(request.id, null);
628
- return;
629
- }
630
-
631
- try {
632
- const result = await this.uiHandler(request);
633
- this.sendUiResponse(request.id, result);
634
- } catch (err) {
635
- log(
636
- `UI handler error for ${request.method}: ${err instanceof Error ? err.message : String(err)}`
637
- );
638
- this.sendUiResponse(request.id, null);
639
- }
640
- }
641
-
642
- /** Send UI response back to sandbox */
643
- private sendUiResponse(id: string, result: unknown): void {
644
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
645
- this.ws.send(
646
- JSON.stringify({
647
- type: 'rpc_ui_response',
648
- id,
649
- result,
650
- })
651
- );
652
- }
653
- }
654
-
655
- /**
656
- * Set up remote mode for the Pi extension.
657
- *
658
- * Connects to an existing sandbox session through the Hub and bridges
659
- * user input → RPC commands and sandbox events → TUI rendering.
660
- *
661
- * Uses Pi's extension APIs for rich rendering:
662
- * - pi.sendMessage() for completed assistant messages
663
- * - ctx.ui.setWidget() for streaming output
664
- * - ctx.ui.setWorkingMessage() for tool execution status
665
- * - ctx.ui.setStatus() for connection and agent state
666
- */
667
- export async function setupRemoteMode(
668
- pi: ExtensionAPI,
669
- hubWsUrl: string,
670
- sessionId: string
671
- ): Promise<RemoteSession> {
672
- const remote = new RemoteSession(sessionId);
673
-
674
- // ── Track streaming state for widget rendering ──
675
- let messageBuffer = '';
676
- let thinkingBuffer = '';
677
- let isStreaming = false;
678
- let currentTool: string | null = null;
679
- let extensionCtxRef: ExtensionContext | null = null;
680
- let lifecycleOwnsWorkingMessage = false;
681
-
682
- // Called by the extension setup to provide the rendering context
683
- (remote as RemoteSessionInternal)._setExtensionCtx = (ctx: ExtensionContext) => {
684
- extensionCtxRef = ctx;
685
- applyLifecycleUi(remote.getLifecycleState());
686
- };
687
-
688
- // ── Render streaming output as a widget ──
689
- function updateStreamWidget(): void {
690
- if (!extensionCtxRef?.hasUI) return;
691
- if (!isStreaming && !messageBuffer) return;
692
-
693
- // Show the most recent streaming text in a widget
694
- const display =
695
- messageBuffer.length > 2000 ? `...${messageBuffer.slice(-2000)}` : messageBuffer;
696
-
697
- if (display) {
698
- extensionCtxRef.ui.setWidget('remote_stream', display.split('\n'));
699
- }
700
- }
701
-
702
- function clearStreamWidget(): void {
703
- if (!extensionCtxRef?.hasUI) return;
704
- extensionCtxRef.ui.setWidget('remote_stream', undefined);
705
- }
706
-
707
- function applyLifecycleUi(state: RemoteLifecycleState): void {
708
- if (!extensionCtxRef?.hasUI) return;
709
- const shortSession = state.sessionId.slice(0, 16);
710
- extensionCtxRef.ui.setStatus(
711
- 'remote_connection',
712
- `Remote: ${shortSession}${shortSession.length < state.sessionId.length ? '...' : ''} ${getRemoteLifecycleLabel(state)}`
713
- );
714
- const activity = getRemoteLifecycleActivityLabel(state);
715
- if (activity) {
716
- extensionCtxRef.ui.setStatus('remote_activity', activity);
717
- } else {
718
- extensionCtxRef.ui.setStatus(
719
- 'remote_activity',
720
- state.isStreaming ? 'agent working...' : 'idle'
721
- );
722
- }
723
- lifecycleOwnsWorkingMessage = syncRemoteLifecycleWorkingMessage(
724
- state,
725
- extensionCtxRef.ui,
726
- lifecycleOwnsWorkingMessage
727
- );
728
- }
729
-
730
- function setNonLifecycleWorkingMessage(message?: string): void {
731
- if (!extensionCtxRef?.hasUI) return;
732
- extensionCtxRef.ui.setWorkingMessage(message);
733
- lifecycleOwnsWorkingMessage = false;
734
- }
735
-
736
- function clearWorkingMessage(): void {
737
- if (!extensionCtxRef?.hasUI) return;
738
- if (lifecycleOwnsWorkingMessage) {
739
- lifecycleOwnsWorkingMessage = clearRemoteLifecycleWorkingMessage(
740
- extensionCtxRef.ui,
741
- lifecycleOwnsWorkingMessage
742
- );
743
- return;
744
- }
745
- extensionCtxRef.ui.setWorkingMessage();
746
- lifecycleOwnsWorkingMessage = false;
747
- }
748
-
749
- // ── Set up UI handler (wired to Pi's UI later in setupRemoteModeExtension) ──
750
- // Default handler — overridden by setupRemoteModeExtension once ctx is available
751
- remote.setUiHandler(async (request) => {
752
- log(`UI request: ${request.method} (${request.id}) — no ctx yet`);
753
- const fireAndForget = ['notify', 'setStatus', 'setWidget', 'setTitle', 'set_editor_text'];
754
- if (fireAndForget.includes(request.method)) return undefined;
755
- return null;
756
- });
757
-
758
- // ── Handle RPC responses (get_state, get_messages results) ──
759
- remote.onResponse((response) => {
760
- if (!response.success) {
761
- log(`RPC response error for ${response.command}: ${response.error ?? 'unknown'}`);
762
- return;
763
- }
764
-
765
- switch (response.command) {
766
- case 'get_state': {
767
- const state = response.data as
768
- | {
769
- isStreaming?: boolean;
770
- isWaitingForInput?: boolean;
771
- sessionName?: string;
772
- }
773
- | undefined;
774
- if (state) {
775
- isStreaming = !!state.isStreaming;
776
- if (extensionCtxRef?.hasUI) {
777
- if (state.isStreaming) {
778
- extensionCtxRef.ui.setStatus('remote_activity', 'agent working...');
779
- } else if (state.isWaitingForInput) {
780
- extensionCtxRef.ui.setStatus('remote_activity', 'waiting for input');
781
- } else {
782
- extensionCtxRef.ui.setStatus('remote_activity', 'idle');
783
- }
784
- }
785
- log(
786
- `State hydrated: streaming=${state.isStreaming}, waiting=${state.isWaitingForInput}`
787
- );
788
- }
789
- break;
790
- }
791
-
792
- case 'get_messages': {
793
- const messages = response.data as
794
- | Array<{
795
- role: string;
796
- content?: string | Array<{ type: string; text?: string }>;
797
- timestamp?: number;
798
- }>
799
- | undefined;
800
- if (messages?.length) {
801
- hydrateMessages(messages);
802
- }
803
- break;
804
- }
805
- }
806
- });
807
-
808
- // ── Hydrate message history into the TUI ──
809
- function hydrateMessages(
810
- messages: Array<{
811
- role: string;
812
- content?: string | Array<{ type: string; text?: string }>;
813
- timestamp?: number;
814
- }>
815
- ): void {
816
- // Show the last few messages as custom messages in the TUI
817
- const recent = messages.slice(-20);
818
- let hydrated = 0;
819
-
820
- for (const msg of recent) {
821
- const text =
822
- typeof msg.content === 'string'
823
- ? msg.content
824
- : Array.isArray(msg.content)
825
- ? msg.content
826
- .filter(
827
- (c): c is { type: string; text: string } =>
828
- c.type === 'text' && typeof c.text === 'string'
829
- )
830
- .map((c) => c.text)
831
- .join('\n')
832
- : '';
833
-
834
- if (!text) continue;
835
-
836
- const role = msg.role === 'assistant' ? 'assistant' : 'user';
837
- pi.sendMessage({
838
- customType: 'remote_history',
839
- content: text,
840
- display: true,
841
- details: { role, timestamp: msg.timestamp, hydrated: true },
842
- });
843
- hydrated++;
844
- }
845
-
846
- log(`Hydrated ${hydrated} messages from history`);
847
- }
848
-
849
- // ── Handle RPC events for rendering ──
850
- remote.onEvent((event) => {
851
- const eventType = event.type as string;
852
-
853
- switch (eventType) {
854
- case 'session_resume':
855
- log(
856
- `Session resume signaled (${typeof (event as { streamId?: string }).streamId === 'string' ? (event as { streamId?: string }).streamId : 'no stream id'})`
857
- );
858
- break;
859
-
860
- case 'session_stream_ready':
861
- log(
862
- `Durable stream ready (${typeof (event as { streamId?: string }).streamId === 'string' ? (event as { streamId?: string }).streamId : 'no stream id'})`
863
- );
864
- break;
865
-
866
- case 'rpc_command_error': {
867
- const error =
868
- typeof (event as { error?: string }).error === 'string'
869
- ? (event as { error?: string }).error!
870
- : 'Remote command failed';
871
- if (extensionCtxRef?.hasUI) {
872
- extensionCtxRef.ui.notify(error, 'warning');
873
- clearWorkingMessage();
874
- }
875
- isStreaming = false;
876
- clearStreamWidget();
877
- log(`Remote command error: ${error}`);
878
- break;
879
- }
880
-
881
- case 'message_start':
882
- messageBuffer = '';
883
- thinkingBuffer = '';
884
- isStreaming = true;
885
- if (extensionCtxRef?.hasUI) {
886
- setNonLifecycleWorkingMessage('Responding...');
887
- }
888
- break;
889
-
890
- case 'message_update': {
891
- const delta = (event as { text?: string }).text ?? '';
892
- messageBuffer += delta;
893
- updateStreamWidget();
894
- break;
895
- }
896
-
897
- case 'message_end': {
898
- isStreaming = false;
899
- clearStreamWidget();
900
- clearWorkingMessage();
901
-
902
- // Extract content — prefer streamed buffer, fall back to message_end payload
903
- let finalContent = messageBuffer.trim();
904
- if (!finalContent) {
905
- // Lifecycle broadcasts include full message in the event payload
906
- const msg = (event as Record<string, unknown>).message as
907
- | Record<string, unknown>
908
- | undefined;
909
- if (msg) {
910
- const content = msg.content;
911
- if (typeof content === 'string') {
912
- finalContent = content.trim();
913
- } else if (Array.isArray(content)) {
914
- finalContent = content
915
- .filter(
916
- (c: unknown): c is { type: string; text: string } =>
917
- !!c &&
918
- typeof c === 'object' &&
919
- (c as Record<string, unknown>).type === 'text' &&
920
- typeof (c as Record<string, unknown>).text === 'string'
921
- )
922
- .map((c) => c.text)
923
- .join('\n')
924
- .trim();
925
- }
926
- }
927
- }
928
-
929
- // Only display assistant messages (skip user message echoes)
930
- const msgRole = (
931
- (event as Record<string, unknown>).message as Record<string, unknown> | undefined
932
- )?.role as string | undefined;
933
- if (finalContent && msgRole !== 'user') {
934
- pi.sendMessage({
935
- customType: 'remote_message',
936
- content: finalContent,
937
- display: true,
938
- details: { role: 'assistant' },
939
- });
940
- }
941
- messageBuffer = '';
942
- log(`Message complete`);
943
- break;
944
- }
945
-
946
- case 'thinking_start':
947
- thinkingBuffer = '';
948
- break;
949
-
950
- case 'thinking_update': {
951
- const delta = (event as { text?: string }).text ?? '';
952
- thinkingBuffer += delta;
953
- break;
954
- }
955
-
956
- case 'thinking_end':
957
- // Thinking is internal — just log it
958
- if (thinkingBuffer) {
959
- log(`Thinking complete (${thinkingBuffer.length} chars)`);
960
- }
961
- thinkingBuffer = '';
962
- break;
963
-
964
- case 'agent_start': {
965
- const agent = (event as { agentName?: string }).agentName ?? 'agent';
966
- if (extensionCtxRef?.hasUI) {
967
- extensionCtxRef.ui.setStatus('remote_activity', `${agent} working...`);
968
- }
969
- log(`Agent started: ${agent}`);
970
- break;
971
- }
972
-
973
- case 'agent_end':
974
- if (extensionCtxRef?.hasUI) {
975
- extensionCtxRef.ui.setStatus('remote_activity', 'idle');
976
- }
977
- clearStreamWidget();
978
- log(`Agent ended`);
979
- break;
980
-
981
- case 'tool_execution_start': {
982
- const tool = (event as { toolName?: string }).toolName ?? 'tool';
983
- currentTool = tool;
984
- if (extensionCtxRef?.hasUI) {
985
- setNonLifecycleWorkingMessage(`Running ${tool}...`);
986
- extensionCtxRef.ui.setStatus('remote_activity', `Running ${tool}...`);
987
- }
988
- log(`Tool: ${tool}`);
989
- break;
990
- }
991
-
992
- case 'tool_execution_end': {
993
- const tool = (event as { toolName?: string }).toolName ?? currentTool ?? 'tool';
994
- currentTool = null;
995
- if (extensionCtxRef?.hasUI) {
996
- clearWorkingMessage();
997
- extensionCtxRef.ui.setStatus('remote_activity', 'agent working...');
998
- }
999
- log(`Tool done: ${tool}`);
1000
- break;
1001
- }
1002
-
1003
- case 'turn_start':
1004
- if (extensionCtxRef?.hasUI) {
1005
- extensionCtxRef.ui.setStatus('remote_activity', 'agent working...');
1006
- }
1007
- log('Turn started');
1008
- break;
1009
-
1010
- case 'turn_end':
1011
- if (extensionCtxRef?.hasUI) {
1012
- extensionCtxRef.ui.setStatus('remote_activity', 'idle');
1013
- }
1014
- clearWorkingMessage();
1015
- clearStreamWidget();
1016
- log('Turn ended');
1017
- break;
1018
-
1019
- case 'session_hydration': {
1020
- // Hydrate conversation history from Hub
1021
- const entries = (event as Record<string, unknown>).entries as
1022
- | Array<{
1023
- type: string;
1024
- content?: string;
1025
- agent?: string;
1026
- timestamp?: number;
1027
- }>
1028
- | undefined;
1029
- if (entries?.length) {
1030
- let hydrated = 0;
1031
- for (const entry of entries.slice(-30)) {
1032
- if (!entry.content) continue;
1033
- const role = entry.type === 'message' ? 'assistant' : 'user';
1034
- pi.sendMessage({
1035
- customType: 'remote_history',
1036
- content: entry.content,
1037
- display: true,
1038
- details: { role, timestamp: entry.timestamp, hydrated: true },
1039
- });
1040
- hydrated++;
1041
- }
1042
- log(`Hydrated ${hydrated} entries from session_hydration`);
1043
- } else {
1044
- log('Received session_hydration with no entries');
1045
- }
1046
- break;
1047
- }
1048
-
1049
- case 'auto_compaction_start':
1050
- if (extensionCtxRef?.hasUI) {
1051
- setNonLifecycleWorkingMessage('Compacting context...');
1052
- }
1053
- break;
1054
-
1055
- case 'auto_compaction_end':
1056
- clearWorkingMessage();
1057
- break;
1058
- }
1059
- });
1060
-
1061
- // ── Connection/lifecycle state handling ──
1062
- remote.onLifecycleChange((state) => {
1063
- applyLifecycleUi(state);
1064
- });
1065
-
1066
- // Connect to Hub after all listeners are attached so hydration/replay frames are not dropped.
1067
- await remote.connect(hubWsUrl);
1068
- log(`Remote mode active — session ${sessionId}`);
1069
-
1070
- // Request initial state from the sandbox
1071
- remote.getState();
1072
- remote.getMessages();
1073
-
1074
- return remote;
1075
- }
1076
-
1077
- /** Internal interface for passing extension context to RemoteSession */
1078
- export interface RemoteSessionInternal extends RemoteSession {
1079
- _setExtensionCtx?: (ctx: ExtensionContext) => void;
1080
- }