@agentuity/coder 1.0.40 → 1.0.41
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.
- package/dist/client.d.ts +2 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -1
- package/dist/hub-overlay-state.d.ts +30 -0
- package/dist/hub-overlay-state.d.ts.map +1 -0
- package/dist/hub-overlay-state.js +68 -0
- package/dist/hub-overlay-state.js.map +1 -0
- package/dist/hub-overlay.d.ts +41 -2
- package/dist/hub-overlay.d.ts.map +1 -1
- package/dist/hub-overlay.js +667 -115
- package/dist/hub-overlay.js.map +1 -1
- package/dist/protocol.d.ts +209 -37
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +2 -1
- package/dist/protocol.js.map +1 -1
- package/dist/remote-lifecycle.d.ts +61 -0
- package/dist/remote-lifecycle.d.ts.map +1 -0
- package/dist/remote-lifecycle.js +190 -0
- package/dist/remote-lifecycle.js.map +1 -0
- package/dist/remote-session.d.ts +15 -0
- package/dist/remote-session.d.ts.map +1 -1
- package/dist/remote-session.js +240 -35
- package/dist/remote-session.js.map +1 -1
- package/dist/remote-tui.d.ts.map +1 -1
- package/dist/remote-tui.js +78 -6
- package/dist/remote-tui.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +5 -3
- package/src/hub-overlay-state.ts +117 -0
- package/src/hub-overlay.ts +974 -138
- package/src/protocol.ts +269 -55
- package/src/remote-lifecycle.ts +270 -0
- package/src/remote-session.ts +293 -38
- package/src/remote-tui.ts +103 -8
package/src/remote-session.ts
CHANGED
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
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';
|
|
16
25
|
|
|
17
26
|
const DEBUG = !!process.env['AGENTUITY_DEBUG'];
|
|
18
27
|
|
|
@@ -54,6 +63,7 @@ export type RemoteUiHandler = (request: RpcUiRequest) => Promise<unknown>;
|
|
|
54
63
|
export type RemoteConnectionHandler = (
|
|
55
64
|
state: 'connected' | 'reconnecting' | 'disconnected'
|
|
56
65
|
) => void;
|
|
66
|
+
export type RemoteLifecycleHandler = (state: RemoteLifecycleState) => void;
|
|
57
67
|
|
|
58
68
|
// ── Remote Session Client ──
|
|
59
69
|
|
|
@@ -72,6 +82,9 @@ export class RemoteSession {
|
|
|
72
82
|
private uiHandler: RemoteUiHandler | null = null;
|
|
73
83
|
private responseHandlers: RemoteResponseHandler[] = [];
|
|
74
84
|
private connectionHandlers: RemoteConnectionHandler[] = [];
|
|
85
|
+
private lifecycleHandlers: RemoteLifecycleHandler[] = [];
|
|
86
|
+
private lifecycleState: RemoteLifecycleState;
|
|
87
|
+
private replaySettledTimer: ReturnType<typeof setTimeout> | null = null;
|
|
75
88
|
|
|
76
89
|
/** Session ID this client is connected to */
|
|
77
90
|
public sessionId: string;
|
|
@@ -84,6 +97,7 @@ export class RemoteSession {
|
|
|
84
97
|
|
|
85
98
|
constructor(sessionId: string) {
|
|
86
99
|
this.sessionId = sessionId;
|
|
100
|
+
this.lifecycleState = createRemoteLifecycleState(sessionId);
|
|
87
101
|
}
|
|
88
102
|
|
|
89
103
|
private dispatchEvent(event: RpcEvent): void {
|
|
@@ -106,6 +120,88 @@ export class RemoteSession {
|
|
|
106
120
|
}
|
|
107
121
|
}
|
|
108
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
|
+
|
|
109
205
|
/** Register a handler for RPC events from the sandbox */
|
|
110
206
|
onEvent(handler: RemoteEventHandler): void {
|
|
111
207
|
this.eventHandlers.push(handler);
|
|
@@ -126,6 +222,16 @@ export class RemoteSession {
|
|
|
126
222
|
this.connectionHandlers.push(handler);
|
|
127
223
|
}
|
|
128
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
|
+
|
|
129
235
|
/** Connect to the Hub WebSocket as a controller for the remote session */
|
|
130
236
|
async connect(hubWsUrl: string): Promise<void> {
|
|
131
237
|
this.hubWsUrl = hubWsUrl;
|
|
@@ -137,6 +243,7 @@ export class RemoteSession {
|
|
|
137
243
|
private doConnect(): Promise<void> {
|
|
138
244
|
return new Promise((resolve, reject) => {
|
|
139
245
|
const isReconnect = this.reconnectAttempts > 0;
|
|
246
|
+
this.applyLifecycle({ type: 'connect_start', reconnect: isReconnect });
|
|
140
247
|
|
|
141
248
|
// Build URL with controller params
|
|
142
249
|
const url = new URL(this.hubWsUrl);
|
|
@@ -180,6 +287,11 @@ export class RemoteSession {
|
|
|
180
287
|
this.connected = true;
|
|
181
288
|
this.reconnectAttempts = 0;
|
|
182
289
|
if (data.sessionId) this.sessionId = data.sessionId as string;
|
|
290
|
+
this.applyLifecycle({
|
|
291
|
+
type: 'init',
|
|
292
|
+
sessionId: typeof data.sessionId === 'string' ? data.sessionId : undefined,
|
|
293
|
+
label: typeof data.label === 'string' ? data.label : undefined,
|
|
294
|
+
});
|
|
183
295
|
log(`Connected to session ${this.sessionId}`);
|
|
184
296
|
this.notifyConnectionChange('connected');
|
|
185
297
|
resolve();
|
|
@@ -190,10 +302,58 @@ export class RemoteSession {
|
|
|
190
302
|
if (type === 'connection_rejected') {
|
|
191
303
|
clearTimeout(connectTimeout);
|
|
192
304
|
const msg = (data.message as string) || 'Connection rejected';
|
|
305
|
+
this.applyLifecycle({
|
|
306
|
+
type: 'rpc_command_error',
|
|
307
|
+
error: msg,
|
|
308
|
+
paused: false,
|
|
309
|
+
});
|
|
193
310
|
reject(new Error(msg));
|
|
194
311
|
return;
|
|
195
312
|
}
|
|
196
313
|
|
|
314
|
+
if (type === 'session_resume') {
|
|
315
|
+
this.applyLifecycle({
|
|
316
|
+
type: 'session_resume',
|
|
317
|
+
streamId: typeof data.streamId === 'string' ? data.streamId : null,
|
|
318
|
+
streamUrl: typeof data.streamUrl === 'string' ? data.streamUrl : null,
|
|
319
|
+
});
|
|
320
|
+
this.dispatchEvent({
|
|
321
|
+
type: 'session_resume',
|
|
322
|
+
...data,
|
|
323
|
+
_source: 'hub',
|
|
324
|
+
} as RpcEvent);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (type === 'session_stream_ready') {
|
|
329
|
+
this.applyLifecycle({
|
|
330
|
+
type: 'stream_ready',
|
|
331
|
+
streamId: typeof data.streamId === 'string' ? data.streamId : null,
|
|
332
|
+
streamUrl: typeof data.streamUrl === 'string' ? data.streamUrl : null,
|
|
333
|
+
});
|
|
334
|
+
this.dispatchEvent({
|
|
335
|
+
type: 'session_stream_ready',
|
|
336
|
+
...data,
|
|
337
|
+
_source: 'hub',
|
|
338
|
+
} as RpcEvent);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (type === 'rpc_command_error') {
|
|
343
|
+
const error = typeof data.error === 'string' ? data.error : 'Remote command failed';
|
|
344
|
+
this.applyLifecycle({
|
|
345
|
+
type: 'rpc_command_error',
|
|
346
|
+
error,
|
|
347
|
+
paused: /sandbox .*not connected|resume/i.test(error),
|
|
348
|
+
});
|
|
349
|
+
this.dispatchEvent({
|
|
350
|
+
type: 'rpc_command_error',
|
|
351
|
+
...data,
|
|
352
|
+
_source: 'hub',
|
|
353
|
+
} as RpcEvent);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
197
357
|
// Broadcast-wrapped messages from Hub (LIVE events)
|
|
198
358
|
// Format: { type: 'broadcast', event: '<name>', data: { ...payload } }
|
|
199
359
|
if (type === 'broadcast') {
|
|
@@ -201,11 +361,23 @@ export class RemoteSession {
|
|
|
201
361
|
const broadcastData = (data.data as Record<string, unknown>) ?? {};
|
|
202
362
|
if (broadcastEvent === 'rpc_event') {
|
|
203
363
|
const rpcEvent = broadcastData.event as RpcEvent;
|
|
204
|
-
if (rpcEvent)
|
|
364
|
+
if (rpcEvent) {
|
|
365
|
+
this.observeLiveSignal(
|
|
366
|
+
rpcEvent.type,
|
|
367
|
+
this.getLiveSignalStreamingState(rpcEvent.type)
|
|
368
|
+
);
|
|
369
|
+
this.dispatchEvent({ ...rpcEvent, _source: 'live' } as RpcEvent);
|
|
370
|
+
}
|
|
205
371
|
} else if (broadcastEvent === 'rpc_response') {
|
|
206
372
|
const response = broadcastData.response as RpcResponse;
|
|
207
|
-
if (response)
|
|
373
|
+
if (response) {
|
|
374
|
+
if (this.shouldObserveRpcResponseAsLive()) {
|
|
375
|
+
this.observeLiveSignal('rpc_response');
|
|
376
|
+
}
|
|
377
|
+
this.dispatchResponse(response);
|
|
378
|
+
}
|
|
208
379
|
} else if (broadcastEvent === 'rpc_ui_request') {
|
|
380
|
+
this.observeLiveSignal('rpc_ui_request');
|
|
209
381
|
this.handleUiRequest({
|
|
210
382
|
id: broadcastData.id as string,
|
|
211
383
|
method: broadcastData.method as string,
|
|
@@ -215,6 +387,10 @@ export class RemoteSession {
|
|
|
215
387
|
// Lifecycle event broadcasts (agent_start, message_end, turn_start, etc.)
|
|
216
388
|
// The broadcastData IS the event payload with a `type` field matching broadcastEvent.
|
|
217
389
|
// Dispatch as a regular event so the TUI can render agent activity.
|
|
390
|
+
this.observeLiveSignal(
|
|
391
|
+
broadcastEvent,
|
|
392
|
+
this.getLiveSignalStreamingState(broadcastEvent)
|
|
393
|
+
);
|
|
218
394
|
this.dispatchEvent({
|
|
219
395
|
type: broadcastEvent,
|
|
220
396
|
...broadcastData,
|
|
@@ -227,17 +403,27 @@ export class RemoteSession {
|
|
|
227
403
|
// Raw RPC messages (from Durable Stream replay — historical, not live)
|
|
228
404
|
if (type === 'rpc_event') {
|
|
229
405
|
const rpcEvent = data.event as RpcEvent;
|
|
230
|
-
if (rpcEvent)
|
|
406
|
+
if (rpcEvent) {
|
|
407
|
+
this.applyLifecycle({ type: 'replay_event' });
|
|
408
|
+
this.scheduleReplaySettled();
|
|
409
|
+
this.dispatchEvent({ ...rpcEvent, _source: 'replay' } as RpcEvent);
|
|
410
|
+
}
|
|
231
411
|
return;
|
|
232
412
|
}
|
|
233
413
|
|
|
234
414
|
if (type === 'rpc_response') {
|
|
235
415
|
const response = data.response as RpcResponse;
|
|
236
|
-
if (response)
|
|
416
|
+
if (response) {
|
|
417
|
+
if (this.shouldObserveRpcResponseAsLive()) {
|
|
418
|
+
this.observeLiveSignal('rpc_response');
|
|
419
|
+
}
|
|
420
|
+
this.dispatchResponse(response);
|
|
421
|
+
}
|
|
237
422
|
return;
|
|
238
423
|
}
|
|
239
424
|
|
|
240
425
|
if (type === 'rpc_ui_request') {
|
|
426
|
+
this.observeLiveSignal('rpc_ui_request');
|
|
241
427
|
this.handleUiRequest({
|
|
242
428
|
id: data.id as string,
|
|
243
429
|
method: data.method as string,
|
|
@@ -248,6 +434,16 @@ export class RemoteSession {
|
|
|
248
434
|
|
|
249
435
|
// Session hydration (conversation entries + task states from observer hydration)
|
|
250
436
|
if (type === 'session_hydration') {
|
|
437
|
+
this.applyLifecycle({
|
|
438
|
+
type: 'hydration',
|
|
439
|
+
leadConnected:
|
|
440
|
+
typeof data.leadConnected === 'boolean' ? data.leadConnected : undefined,
|
|
441
|
+
isStreaming:
|
|
442
|
+
typeof (data.streamingState as { isStreaming?: unknown } | undefined)
|
|
443
|
+
?.isStreaming === 'boolean'
|
|
444
|
+
? Boolean((data.streamingState as { isStreaming?: boolean }).isStreaming)
|
|
445
|
+
: undefined,
|
|
446
|
+
});
|
|
251
447
|
// Pass through as an event so the extension can render it
|
|
252
448
|
for (const handler of this.eventHandlers) {
|
|
253
449
|
try {
|
|
@@ -276,10 +472,12 @@ export class RemoteSession {
|
|
|
276
472
|
clearTimeout(connectTimeout);
|
|
277
473
|
const wasConnected = this.connected;
|
|
278
474
|
this.connected = false;
|
|
475
|
+
this.clearReplaySettledTimer();
|
|
279
476
|
if (!this.intentionallyClosed) {
|
|
280
477
|
if (wasConnected) {
|
|
281
478
|
log('WebSocket closed unexpectedly — scheduling reconnect');
|
|
282
479
|
this.notifyConnectionChange('reconnecting');
|
|
480
|
+
this.applyLifecycle({ type: 'connection_change', state: 'reconnecting' });
|
|
283
481
|
this.scheduleReconnect();
|
|
284
482
|
} else if (!isReconnect) {
|
|
285
483
|
// Failed initial connect and not already in reconnect loop
|
|
@@ -295,6 +493,7 @@ export class RemoteSession {
|
|
|
295
493
|
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
296
494
|
log(`Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached — giving up`);
|
|
297
495
|
this.notifyConnectionChange('disconnected');
|
|
496
|
+
this.applyLifecycle({ type: 'connection_change', state: 'disconnected' });
|
|
298
497
|
return;
|
|
299
498
|
}
|
|
300
499
|
|
|
@@ -334,6 +533,9 @@ export class RemoteSession {
|
|
|
334
533
|
log('Cannot send command — not connected');
|
|
335
534
|
return;
|
|
336
535
|
}
|
|
536
|
+
if (this.shouldMarkResuming(command.type) && this.lifecycleState.phase === 'paused') {
|
|
537
|
+
this.applyLifecycle({ type: 'local_resume_requested' });
|
|
538
|
+
}
|
|
337
539
|
this.ws.send(
|
|
338
540
|
JSON.stringify({
|
|
339
541
|
type: 'rpc_command',
|
|
@@ -379,12 +581,14 @@ export class RemoteSession {
|
|
|
379
581
|
/** Close the connection */
|
|
380
582
|
close(): void {
|
|
381
583
|
this.intentionallyClosed = true;
|
|
584
|
+
this.clearReplaySettledTimer();
|
|
382
585
|
if (this.reconnectTimer) {
|
|
383
586
|
clearTimeout(this.reconnectTimer);
|
|
384
587
|
this.reconnectTimer = null;
|
|
385
588
|
}
|
|
386
589
|
this.ws?.close();
|
|
387
590
|
this.ws = null;
|
|
591
|
+
this.applyLifecycle({ type: 'connection_change', state: 'disconnected' });
|
|
388
592
|
}
|
|
389
593
|
|
|
390
594
|
get isConnected(): boolean {
|
|
@@ -442,20 +646,18 @@ export async function setupRemoteMode(
|
|
|
442
646
|
): Promise<RemoteSession> {
|
|
443
647
|
const remote = new RemoteSession(sessionId);
|
|
444
648
|
|
|
445
|
-
// Connect to Hub
|
|
446
|
-
await remote.connect(hubWsUrl);
|
|
447
|
-
log(`Remote mode active — session ${sessionId}`);
|
|
448
|
-
|
|
449
649
|
// ── Track streaming state for widget rendering ──
|
|
450
650
|
let messageBuffer = '';
|
|
451
651
|
let thinkingBuffer = '';
|
|
452
652
|
let isStreaming = false;
|
|
453
653
|
let currentTool: string | null = null;
|
|
454
654
|
let extensionCtxRef: ExtensionContext | null = null;
|
|
655
|
+
let lifecycleOwnsWorkingMessage = false;
|
|
455
656
|
|
|
456
657
|
// Called by the extension setup to provide the rendering context
|
|
457
658
|
(remote as RemoteSessionInternal)._setExtensionCtx = (ctx: ExtensionContext) => {
|
|
458
659
|
extensionCtxRef = ctx;
|
|
660
|
+
applyLifecycleUi(remote.getLifecycleState());
|
|
459
661
|
};
|
|
460
662
|
|
|
461
663
|
// ── Render streaming output as a widget ──
|
|
@@ -477,6 +679,48 @@ export async function setupRemoteMode(
|
|
|
477
679
|
extensionCtxRef.ui.setWidget('remote_stream', undefined);
|
|
478
680
|
}
|
|
479
681
|
|
|
682
|
+
function applyLifecycleUi(state: RemoteLifecycleState): void {
|
|
683
|
+
if (!extensionCtxRef?.hasUI) return;
|
|
684
|
+
const shortSession = state.sessionId.slice(0, 16);
|
|
685
|
+
extensionCtxRef.ui.setStatus(
|
|
686
|
+
'remote_connection',
|
|
687
|
+
`Remote: ${shortSession}${shortSession.length < state.sessionId.length ? '...' : ''} ${getRemoteLifecycleLabel(state)}`
|
|
688
|
+
);
|
|
689
|
+
const activity = getRemoteLifecycleActivityLabel(state);
|
|
690
|
+
if (activity) {
|
|
691
|
+
extensionCtxRef.ui.setStatus('remote_activity', activity);
|
|
692
|
+
} else {
|
|
693
|
+
extensionCtxRef.ui.setStatus(
|
|
694
|
+
'remote_activity',
|
|
695
|
+
state.isStreaming ? 'agent working...' : 'idle'
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
lifecycleOwnsWorkingMessage = syncRemoteLifecycleWorkingMessage(
|
|
699
|
+
state,
|
|
700
|
+
extensionCtxRef.ui,
|
|
701
|
+
lifecycleOwnsWorkingMessage
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function setNonLifecycleWorkingMessage(message?: string): void {
|
|
706
|
+
if (!extensionCtxRef?.hasUI) return;
|
|
707
|
+
extensionCtxRef.ui.setWorkingMessage(message);
|
|
708
|
+
lifecycleOwnsWorkingMessage = false;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function clearWorkingMessage(): void {
|
|
712
|
+
if (!extensionCtxRef?.hasUI) return;
|
|
713
|
+
if (lifecycleOwnsWorkingMessage) {
|
|
714
|
+
lifecycleOwnsWorkingMessage = clearRemoteLifecycleWorkingMessage(
|
|
715
|
+
extensionCtxRef.ui,
|
|
716
|
+
lifecycleOwnsWorkingMessage
|
|
717
|
+
);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
extensionCtxRef.ui.setWorkingMessage();
|
|
721
|
+
lifecycleOwnsWorkingMessage = false;
|
|
722
|
+
}
|
|
723
|
+
|
|
480
724
|
// ── Set up UI handler (wired to Pi's UI later in setupRemoteModeExtension) ──
|
|
481
725
|
// Default handler — overridden by setupRemoteModeExtension once ctx is available
|
|
482
726
|
remote.setUiHandler(async (request) => {
|
|
@@ -582,12 +826,39 @@ export async function setupRemoteMode(
|
|
|
582
826
|
const eventType = event.type as string;
|
|
583
827
|
|
|
584
828
|
switch (eventType) {
|
|
829
|
+
case 'session_resume':
|
|
830
|
+
log(
|
|
831
|
+
`Session resume signaled (${typeof (event as { streamId?: string }).streamId === 'string' ? (event as { streamId?: string }).streamId : 'no stream id'})`
|
|
832
|
+
);
|
|
833
|
+
break;
|
|
834
|
+
|
|
835
|
+
case 'session_stream_ready':
|
|
836
|
+
log(
|
|
837
|
+
`Durable stream ready (${typeof (event as { streamId?: string }).streamId === 'string' ? (event as { streamId?: string }).streamId : 'no stream id'})`
|
|
838
|
+
);
|
|
839
|
+
break;
|
|
840
|
+
|
|
841
|
+
case 'rpc_command_error': {
|
|
842
|
+
const error =
|
|
843
|
+
typeof (event as { error?: string }).error === 'string'
|
|
844
|
+
? (event as { error?: string }).error!
|
|
845
|
+
: 'Remote command failed';
|
|
846
|
+
if (extensionCtxRef?.hasUI) {
|
|
847
|
+
extensionCtxRef.ui.notify(error, 'warning');
|
|
848
|
+
clearWorkingMessage();
|
|
849
|
+
}
|
|
850
|
+
isStreaming = false;
|
|
851
|
+
clearStreamWidget();
|
|
852
|
+
log(`Remote command error: ${error}`);
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
|
|
585
856
|
case 'message_start':
|
|
586
857
|
messageBuffer = '';
|
|
587
858
|
thinkingBuffer = '';
|
|
588
859
|
isStreaming = true;
|
|
589
860
|
if (extensionCtxRef?.hasUI) {
|
|
590
|
-
|
|
861
|
+
setNonLifecycleWorkingMessage('Responding...');
|
|
591
862
|
}
|
|
592
863
|
break;
|
|
593
864
|
|
|
@@ -601,9 +872,7 @@ export async function setupRemoteMode(
|
|
|
601
872
|
case 'message_end': {
|
|
602
873
|
isStreaming = false;
|
|
603
874
|
clearStreamWidget();
|
|
604
|
-
|
|
605
|
-
extensionCtxRef.ui.setWorkingMessage();
|
|
606
|
-
}
|
|
875
|
+
clearWorkingMessage();
|
|
607
876
|
|
|
608
877
|
// Extract content — prefer streamed buffer, fall back to message_end payload
|
|
609
878
|
let finalContent = messageBuffer.trim();
|
|
@@ -688,7 +957,7 @@ export async function setupRemoteMode(
|
|
|
688
957
|
const tool = (event as { toolName?: string }).toolName ?? 'tool';
|
|
689
958
|
currentTool = tool;
|
|
690
959
|
if (extensionCtxRef?.hasUI) {
|
|
691
|
-
|
|
960
|
+
setNonLifecycleWorkingMessage(`Running ${tool}...`);
|
|
692
961
|
extensionCtxRef.ui.setStatus('remote_activity', `Running ${tool}...`);
|
|
693
962
|
}
|
|
694
963
|
log(`Tool: ${tool}`);
|
|
@@ -699,7 +968,7 @@ export async function setupRemoteMode(
|
|
|
699
968
|
const tool = (event as { toolName?: string }).toolName ?? currentTool ?? 'tool';
|
|
700
969
|
currentTool = null;
|
|
701
970
|
if (extensionCtxRef?.hasUI) {
|
|
702
|
-
|
|
971
|
+
clearWorkingMessage();
|
|
703
972
|
extensionCtxRef.ui.setStatus('remote_activity', 'agent working...');
|
|
704
973
|
}
|
|
705
974
|
log(`Tool done: ${tool}`);
|
|
@@ -716,8 +985,8 @@ export async function setupRemoteMode(
|
|
|
716
985
|
case 'turn_end':
|
|
717
986
|
if (extensionCtxRef?.hasUI) {
|
|
718
987
|
extensionCtxRef.ui.setStatus('remote_activity', 'idle');
|
|
719
|
-
extensionCtxRef.ui.setWorkingMessage();
|
|
720
988
|
}
|
|
989
|
+
clearWorkingMessage();
|
|
721
990
|
clearStreamWidget();
|
|
722
991
|
log('Turn ended');
|
|
723
992
|
break;
|
|
@@ -754,39 +1023,25 @@ export async function setupRemoteMode(
|
|
|
754
1023
|
|
|
755
1024
|
case 'auto_compaction_start':
|
|
756
1025
|
if (extensionCtxRef?.hasUI) {
|
|
757
|
-
|
|
1026
|
+
setNonLifecycleWorkingMessage('Compacting context...');
|
|
758
1027
|
}
|
|
759
1028
|
break;
|
|
760
1029
|
|
|
761
1030
|
case 'auto_compaction_end':
|
|
762
|
-
|
|
763
|
-
extensionCtxRef.ui.setWorkingMessage();
|
|
764
|
-
}
|
|
1031
|
+
clearWorkingMessage();
|
|
765
1032
|
break;
|
|
766
1033
|
}
|
|
767
1034
|
});
|
|
768
1035
|
|
|
769
|
-
// ── Connection state handling ──
|
|
770
|
-
remote.
|
|
771
|
-
|
|
772
|
-
switch (state) {
|
|
773
|
-
case 'connected':
|
|
774
|
-
extensionCtxRef.ui.setStatus(
|
|
775
|
-
'remote_connection',
|
|
776
|
-
`Remote: ${sessionId.slice(0, 16)}...`
|
|
777
|
-
);
|
|
778
|
-
break;
|
|
779
|
-
case 'reconnecting':
|
|
780
|
-
extensionCtxRef.ui.setStatus('remote_connection', 'Reconnecting...');
|
|
781
|
-
extensionCtxRef.ui.setWorkingMessage('Connection lost — reconnecting...');
|
|
782
|
-
break;
|
|
783
|
-
case 'disconnected':
|
|
784
|
-
extensionCtxRef.ui.setStatus('remote_connection', 'Disconnected');
|
|
785
|
-
extensionCtxRef.ui.setWorkingMessage('Connection lost');
|
|
786
|
-
break;
|
|
787
|
-
}
|
|
1036
|
+
// ── Connection/lifecycle state handling ──
|
|
1037
|
+
remote.onLifecycleChange((state) => {
|
|
1038
|
+
applyLifecycleUi(state);
|
|
788
1039
|
});
|
|
789
1040
|
|
|
1041
|
+
// Connect to Hub after all listeners are attached so hydration/replay frames are not dropped.
|
|
1042
|
+
await remote.connect(hubWsUrl);
|
|
1043
|
+
log(`Remote mode active — session ${sessionId}`);
|
|
1044
|
+
|
|
790
1045
|
// Request initial state from the sandbox
|
|
791
1046
|
remote.getState();
|
|
792
1047
|
remote.getMessages();
|
package/src/remote-tui.ts
CHANGED
|
@@ -38,6 +38,13 @@ import {
|
|
|
38
38
|
setNativeRemoteExtensionContext,
|
|
39
39
|
waitForNativeRemoteExtensionContext,
|
|
40
40
|
} from './native-remote-ui-context.ts';
|
|
41
|
+
import {
|
|
42
|
+
clearRemoteLifecycleWorkingMessage,
|
|
43
|
+
getRemoteLifecycleActivityLabel,
|
|
44
|
+
getRemoteLifecycleLabel,
|
|
45
|
+
syncRemoteLifecycleWorkingMessage,
|
|
46
|
+
type RemoteLifecycleState,
|
|
47
|
+
} from './remote-lifecycle.ts';
|
|
41
48
|
import { RemoteSession } from './remote-session.ts';
|
|
42
49
|
import type { RpcEvent } from './remote-session.ts';
|
|
43
50
|
import { agentuityCoderHub } from './index.ts';
|
|
@@ -78,6 +85,7 @@ export async function runRemoteTui(options: {
|
|
|
78
85
|
// TODO: Remove/Change when we get Agentuity service level auth enabled, this is just temporary
|
|
79
86
|
remote.apiKey = process.env.AGENTUITY_CODER_API_KEY || null;
|
|
80
87
|
let hydrationStreamingDetected = false;
|
|
88
|
+
let sessionResumeSeen = false;
|
|
81
89
|
|
|
82
90
|
// ── 2. Create AgentSession with coder extension loaded ──
|
|
83
91
|
// The extension provides Hub UI (footer, /hub overlay, commands, titlebar).
|
|
@@ -103,6 +111,41 @@ export async function runRemoteTui(options: {
|
|
|
103
111
|
|
|
104
112
|
// Access the Agent instance (typed as `any` for monkey-patching)
|
|
105
113
|
const agent: any = session.agent;
|
|
114
|
+
let lifecycleState = remote.getLifecycleState();
|
|
115
|
+
let lifecycleOwnsWorkingMessage = false;
|
|
116
|
+
|
|
117
|
+
function applyLifecycleUi(state: RemoteLifecycleState): void {
|
|
118
|
+
const ctx = getNativeRemoteExtensionContext();
|
|
119
|
+
if (!ctx?.hasUI) return;
|
|
120
|
+
|
|
121
|
+
const shortSession = state.sessionId.slice(0, 16);
|
|
122
|
+
ctx.ui.setStatus(
|
|
123
|
+
'remote_connection',
|
|
124
|
+
`Remote: ${shortSession}${shortSession.length < state.sessionId.length ? '...' : ''} ${getRemoteLifecycleLabel(state)}`
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const activity = getRemoteLifecycleActivityLabel(state);
|
|
128
|
+
if (activity) {
|
|
129
|
+
ctx.ui.setStatus('remote_activity', activity);
|
|
130
|
+
} else {
|
|
131
|
+
ctx.ui.setStatus('remote_activity', state.isStreaming ? 'agent working...' : 'idle');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
lifecycleOwnsWorkingMessage = syncRemoteLifecycleWorkingMessage(
|
|
135
|
+
state,
|
|
136
|
+
ctx.ui,
|
|
137
|
+
lifecycleOwnsWorkingMessage
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
remote.onLifecycleChange((state) => {
|
|
142
|
+
lifecycleState = state;
|
|
143
|
+
applyLifecycleUi(state);
|
|
144
|
+
});
|
|
145
|
+
void waitForNativeRemoteExtensionContext(10_000).then((ctx) => {
|
|
146
|
+
if (!ctx) return;
|
|
147
|
+
applyLifecycleUi(lifecycleState);
|
|
148
|
+
});
|
|
106
149
|
|
|
107
150
|
// ── 3. Patch Agent to be remote-backed ──
|
|
108
151
|
// Track the running prompt promise so InteractiveMode waits correctly
|
|
@@ -281,6 +324,43 @@ export async function runRemoteTui(options: {
|
|
|
281
324
|
(rpcEvent as any).isReplay === true;
|
|
282
325
|
log(`Event received: ${rpcEvent.type} (source=${source})`);
|
|
283
326
|
|
|
327
|
+
if (rpcEvent.type === 'session_resume') {
|
|
328
|
+
sessionResumeSeen = true;
|
|
329
|
+
log(
|
|
330
|
+
`Session resume signaled (${typeof (rpcEvent as any).streamId === 'string' ? (rpcEvent as any).streamId : 'no stream id'})`
|
|
331
|
+
);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (rpcEvent.type === 'session_stream_ready') {
|
|
336
|
+
log(
|
|
337
|
+
`Durable stream ready (${typeof (rpcEvent as any).streamId === 'string' ? (rpcEvent as any).streamId : 'no stream id'})`
|
|
338
|
+
);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (rpcEvent.type === 'rpc_command_error') {
|
|
343
|
+
const error =
|
|
344
|
+
typeof (rpcEvent as any).error === 'string'
|
|
345
|
+
? (rpcEvent as any).error
|
|
346
|
+
: 'Remote command failed';
|
|
347
|
+
const ctx = getNativeRemoteExtensionContext();
|
|
348
|
+
if (ctx?.hasUI) {
|
|
349
|
+
ctx.ui.notify(error, 'warning');
|
|
350
|
+
lifecycleOwnsWorkingMessage = clearRemoteLifecycleWorkingMessage(
|
|
351
|
+
ctx.ui,
|
|
352
|
+
lifecycleOwnsWorkingMessage
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
agent._state.error = error;
|
|
356
|
+
seenAgentStart = false;
|
|
357
|
+
seenMessageStart = false;
|
|
358
|
+
resolveRunningPrompt();
|
|
359
|
+
assistantStreamActive = false;
|
|
360
|
+
log(`Remote command error: ${error}`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
284
364
|
// session_hydration is handled separately below — skip it here
|
|
285
365
|
if (rpcEvent.type === 'session_hydration') return;
|
|
286
366
|
|
|
@@ -493,8 +573,13 @@ export async function runRemoteTui(options: {
|
|
|
493
573
|
// InteractiveMode (which calls renderInitialMessages from SessionManager).
|
|
494
574
|
const sm = session.sessionManager;
|
|
495
575
|
let resolveHydration: () => void;
|
|
576
|
+
let hydrationComplete = false;
|
|
496
577
|
const hydrationReady = new Promise<void>((resolve) => {
|
|
497
|
-
resolveHydration =
|
|
578
|
+
resolveHydration = () => {
|
|
579
|
+
if (hydrationComplete) return;
|
|
580
|
+
hydrationComplete = true;
|
|
581
|
+
resolve();
|
|
582
|
+
};
|
|
498
583
|
});
|
|
499
584
|
|
|
500
585
|
let hydrationCount = 0;
|
|
@@ -673,15 +758,25 @@ export async function runRemoteTui(options: {
|
|
|
673
758
|
|
|
674
759
|
// Wait for hydration message (arrives right after init), with a timeout
|
|
675
760
|
// in case this is the first connection and there's nothing to hydrate.
|
|
676
|
-
const HYDRATION_TIMEOUT_MS = 2000;
|
|
677
761
|
await Promise.race([
|
|
678
762
|
hydrationReady,
|
|
679
|
-
new Promise<void>((resolve) =>
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
763
|
+
new Promise<void>((resolve) => {
|
|
764
|
+
const waitStartedAt = Date.now();
|
|
765
|
+
const poll = (): void => {
|
|
766
|
+
if (hydrationComplete) {
|
|
767
|
+
resolve();
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const timeoutMs = sessionResumeSeen ? 5000 : 2000;
|
|
771
|
+
if (Date.now() - waitStartedAt >= timeoutMs) {
|
|
772
|
+
log('Hydration timeout — no session_hydration received');
|
|
773
|
+
resolve();
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
setTimeout(poll, 50);
|
|
777
|
+
};
|
|
778
|
+
poll();
|
|
779
|
+
}),
|
|
685
780
|
]);
|
|
686
781
|
const smEntries = sm.getEntries?.() ?? [];
|
|
687
782
|
log(`SessionManager has ${smEntries.length} entries after hydration`);
|