@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.
@@ -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) this.dispatchEvent({ ...rpcEvent, _source: 'live' } as 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) this.dispatchResponse(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) this.dispatchEvent({ ...rpcEvent, _source: 'replay' } as 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) this.dispatchResponse(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
- extensionCtxRef.ui.setWorkingMessage('Responding...');
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
- if (extensionCtxRef?.hasUI) {
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
- extensionCtxRef.ui.setWorkingMessage(`Running ${tool}...`);
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
- extensionCtxRef.ui.setWorkingMessage();
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
- extensionCtxRef.ui.setWorkingMessage('Compacting context...');
1026
+ setNonLifecycleWorkingMessage('Compacting context...');
758
1027
  }
759
1028
  break;
760
1029
 
761
1030
  case 'auto_compaction_end':
762
- if (extensionCtxRef?.hasUI) {
763
- extensionCtxRef.ui.setWorkingMessage();
764
- }
1031
+ clearWorkingMessage();
765
1032
  break;
766
1033
  }
767
1034
  });
768
1035
 
769
- // ── Connection state handling ──
770
- remote.onConnectionChange((state) => {
771
- if (!extensionCtxRef?.hasUI) return;
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 = resolve;
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
- setTimeout(() => {
681
- log('Hydration timeout no session_hydration received');
682
- resolve();
683
- }, HYDRATION_TIMEOUT_MS)
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`);