@agentuity/coder-tui 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +57 -0
  2. package/dist/chain-preview.d.ts +55 -0
  3. package/dist/chain-preview.d.ts.map +1 -0
  4. package/dist/chain-preview.js +472 -0
  5. package/dist/chain-preview.js.map +1 -0
  6. package/dist/client.d.ts +44 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +411 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/commands.d.ts +22 -0
  11. package/dist/commands.d.ts.map +1 -0
  12. package/dist/commands.js +99 -0
  13. package/dist/commands.js.map +1 -0
  14. package/dist/footer.d.ts +34 -0
  15. package/dist/footer.d.ts.map +1 -0
  16. package/dist/footer.js +249 -0
  17. package/dist/footer.js.map +1 -0
  18. package/dist/handlers.d.ts +24 -0
  19. package/dist/handlers.d.ts.map +1 -0
  20. package/dist/handlers.js +83 -0
  21. package/dist/handlers.js.map +1 -0
  22. package/dist/hub-overlay-state.d.ts +31 -0
  23. package/dist/hub-overlay-state.d.ts.map +1 -0
  24. package/dist/hub-overlay-state.js +78 -0
  25. package/dist/hub-overlay-state.js.map +1 -0
  26. package/dist/hub-overlay.d.ts +146 -0
  27. package/dist/hub-overlay.d.ts.map +1 -0
  28. package/dist/hub-overlay.js +2354 -0
  29. package/dist/hub-overlay.js.map +1 -0
  30. package/dist/inbound-rpc.d.ts +3 -0
  31. package/dist/inbound-rpc.d.ts.map +1 -0
  32. package/dist/inbound-rpc.js +29 -0
  33. package/dist/inbound-rpc.js.map +1 -0
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +1641 -0
  37. package/dist/index.js.map +1 -0
  38. package/dist/native-remote-ui-context.d.ts +5 -0
  39. package/dist/native-remote-ui-context.d.ts.map +1 -0
  40. package/dist/native-remote-ui-context.js +30 -0
  41. package/dist/native-remote-ui-context.js.map +1 -0
  42. package/dist/output-viewer.d.ts +49 -0
  43. package/dist/output-viewer.d.ts.map +1 -0
  44. package/dist/output-viewer.js +389 -0
  45. package/dist/output-viewer.js.map +1 -0
  46. package/dist/overlay.d.ts +40 -0
  47. package/dist/overlay.d.ts.map +1 -0
  48. package/dist/overlay.js +225 -0
  49. package/dist/overlay.js.map +1 -0
  50. package/dist/protocol.d.ts +605 -0
  51. package/dist/protocol.d.ts.map +1 -0
  52. package/dist/protocol.js +4 -0
  53. package/dist/protocol.js.map +1 -0
  54. package/dist/remote-lifecycle.d.ts +61 -0
  55. package/dist/remote-lifecycle.d.ts.map +1 -0
  56. package/dist/remote-lifecycle.js +190 -0
  57. package/dist/remote-lifecycle.js.map +1 -0
  58. package/dist/remote-session.d.ts +130 -0
  59. package/dist/remote-session.d.ts.map +1 -0
  60. package/dist/remote-session.js +896 -0
  61. package/dist/remote-session.js.map +1 -0
  62. package/dist/remote-tui.d.ts +42 -0
  63. package/dist/remote-tui.d.ts.map +1 -0
  64. package/dist/remote-tui.js +868 -0
  65. package/dist/remote-tui.js.map +1 -0
  66. package/dist/remote-ui-handler.d.ts +5 -0
  67. package/dist/remote-ui-handler.d.ts.map +1 -0
  68. package/dist/remote-ui-handler.js +53 -0
  69. package/dist/remote-ui-handler.js.map +1 -0
  70. package/dist/renderers.d.ts +34 -0
  71. package/dist/renderers.d.ts.map +1 -0
  72. package/dist/renderers.js +669 -0
  73. package/dist/renderers.js.map +1 -0
  74. package/dist/review.d.ts +15 -0
  75. package/dist/review.d.ts.map +1 -0
  76. package/dist/review.js +154 -0
  77. package/dist/review.js.map +1 -0
  78. package/dist/titlebar.d.ts +3 -0
  79. package/dist/titlebar.d.ts.map +1 -0
  80. package/dist/titlebar.js +59 -0
  81. package/dist/titlebar.js.map +1 -0
  82. package/dist/todo/index.d.ts +3 -0
  83. package/dist/todo/index.d.ts.map +1 -0
  84. package/dist/todo/index.js +3 -0
  85. package/dist/todo/index.js.map +1 -0
  86. package/dist/todo/store.d.ts +6 -0
  87. package/dist/todo/store.d.ts.map +1 -0
  88. package/dist/todo/store.js +43 -0
  89. package/dist/todo/store.js.map +1 -0
  90. package/dist/todo/types.d.ts +13 -0
  91. package/dist/todo/types.d.ts.map +1 -0
  92. package/dist/todo/types.js +2 -0
  93. package/dist/todo/types.js.map +1 -0
  94. package/package.json +42 -0
  95. package/src/chain-preview.ts +621 -0
  96. package/src/client.ts +527 -0
  97. package/src/commands.ts +132 -0
  98. package/src/footer.ts +305 -0
  99. package/src/handlers.ts +113 -0
  100. package/src/hub-overlay-state.ts +127 -0
  101. package/src/hub-overlay.ts +3037 -0
  102. package/src/inbound-rpc.ts +35 -0
  103. package/src/index.ts +1963 -0
  104. package/src/native-remote-ui-context.ts +41 -0
  105. package/src/output-viewer.ts +480 -0
  106. package/src/overlay.ts +294 -0
  107. package/src/protocol.ts +758 -0
  108. package/src/remote-lifecycle.ts +270 -0
  109. package/src/remote-session.ts +1100 -0
  110. package/src/remote-tui.ts +1023 -0
  111. package/src/remote-ui-handler.ts +86 -0
  112. package/src/renderers.ts +740 -0
  113. package/src/review.ts +201 -0
  114. package/src/titlebar.ts +63 -0
  115. package/src/todo/index.ts +2 -0
  116. package/src/todo/store.ts +49 -0
  117. package/src/todo/types.ts +14 -0
@@ -0,0 +1,1023 @@
1
+ /**
2
+ * Remote TUI — Native Pi Coding Agent Renderer for Remote Sessions
3
+ *
4
+ * Creates a real AgentSession + InteractiveMode backed by a remote sandbox
5
+ * via Hub WebSocket, with the coder extension loaded for Hub UI (footer,
6
+ * /hub overlay, commands, titlebar).
7
+ *
8
+ * Architecture:
9
+ * Remote TUI → Hub WebSocket (controller) → Sandbox (Pi RPC mode)
10
+ * - User input → agent.prompt() (monkey-patched) → RPC `prompt` → Hub → sandbox
11
+ * - Sandbox Pi → AgentEvent stream → Hub broadcast → Agent.emit() → InteractiveMode renders natively
12
+ * - Hub UI → coder extension (loaded via DefaultResourceLoader) provides footer, /hub, commands
13
+ *
14
+ * The local Agent never calls an LLM. Its prompt/steer/abort are monkey-patched
15
+ * to send RPC commands. Its internal state is kept in sync with the remote agent
16
+ * by mirroring state updates from each received event.
17
+ *
18
+ * The coder extension sees AGENTUITY_CODER_REMOTE_SESSION (Hub UI mode) +
19
+ * AGENTUITY_CODER_NATIVE_REMOTE (skip legacy event rendering). It provides
20
+ * Hub connection, footer, /hub overlay, commands, titlebar — but does NOT
21
+ * intercept input or render events (this module handles both).
22
+ *
23
+ * IMPORTANT: Initialization order matters!
24
+ * 1. Create RemoteSession (no connection yet)
25
+ * 2. Create AgentSession, patch Agent/Session methods
26
+ * 3. Register ALL event handlers on RemoteSession
27
+ * 4. THEN connect — so hydration + replay events are captured
28
+ */
29
+
30
+ import {
31
+ createAgentSession,
32
+ DefaultResourceLoader,
33
+ InteractiveMode,
34
+ SessionManager,
35
+ } from '@mariozechner/pi-coding-agent';
36
+ import {
37
+ getNativeRemoteExtensionContext,
38
+ setNativeRemoteExtensionContext,
39
+ waitForNativeRemoteExtensionContext,
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';
48
+ import { RemoteSession } from './remote-session.ts';
49
+ import type { RpcEvent } from './remote-session.ts';
50
+ import { agentuityCoderHub } from './index.ts';
51
+ import { handleRemoteUiRequest, REMOTE_FIRE_AND_FORGET_UI_METHODS } from './remote-ui-handler.ts';
52
+
53
+ const DEBUG = !!process.env['AGENTUITY_DEBUG'];
54
+
55
+ function log(msg: string): void {
56
+ if (DEBUG) console.error(`[remote-tui] ${msg}`);
57
+ }
58
+
59
+ /**
60
+ * Run the native Pi TUI connected to a remote sandbox session.
61
+ *
62
+ * This is the entry point for `agentuity coder start --remote <sessionId>`.
63
+ * Creates an AgentSession with the coder extension loaded (Hub UI), then
64
+ * monkey-patches the Agent for remote-backed execution.
65
+ */
66
+ export async function runRemoteTui(options: {
67
+ hubWsUrl: string;
68
+ sessionId: string;
69
+ apiKey?: string;
70
+ orgId?: string;
71
+ }): Promise<void> {
72
+ const { hubWsUrl, sessionId, apiKey, orgId } = options;
73
+
74
+ log(`Starting remote TUI for session ${sessionId}`);
75
+ log(`Hub URL: ${hubWsUrl}`);
76
+
77
+ // Set env vars BEFORE loading the extension so it enters native remote mode
78
+ process.env.AGENTUITY_CODER_HUB_URL = hubWsUrl;
79
+ process.env.AGENTUITY_CODER_REMOTE_SESSION = sessionId;
80
+ process.env.AGENTUITY_CODER_NATIVE_REMOTE = '1';
81
+ setNativeRemoteExtensionContext(null);
82
+
83
+ // ── 1. Create RemoteSession (NOT connected yet) ──
84
+ // We register all handlers BEFORE connecting so that the hydration
85
+ // message from the Hub (sent immediately after init) is captured.
86
+ const remote = new RemoteSession(sessionId);
87
+ // Resolve API key: explicit option → env var → null
88
+ remote.apiKey = apiKey || process.env.AGENTUITY_CODER_API_KEY || null;
89
+ remote.orgId = orgId || process.env.AGENTUITY_ORGID || null;
90
+ let hydrationStreamingDetected = false;
91
+ let sessionResumeSeen = false;
92
+
93
+ // ── 2. Create AgentSession with coder extension loaded ──
94
+ // The extension provides Hub UI (footer, /hub overlay, commands, titlebar).
95
+ // AGENTUITY_CODER_NATIVE_REMOTE=1 tells it to skip legacy event rendering.
96
+ const cwd = process.cwd();
97
+ const resourceLoader = new DefaultResourceLoader({
98
+ cwd,
99
+ noExtensions: true, // Skip file-system extension discovery
100
+ extensionFactories: [agentuityCoderHub], // Load coder extension directly
101
+ });
102
+ await resourceLoader.reload();
103
+
104
+ const { session } = await createAgentSession({
105
+ sessionManager: SessionManager.inMemory(),
106
+ tools: [], // No local tools — sandbox has all the tools
107
+ resourceLoader,
108
+ });
109
+ log('AgentSession created');
110
+
111
+ // NOTE: Do NOT call session.bindExtensions() here.
112
+ // InteractiveMode.initExtensions() calls it with the proper uiContext.
113
+ // Calling it early fires session_start twice, duplicating extension init.
114
+
115
+ // Access the Agent instance (typed as `any` for monkey-patching)
116
+ const agent: any = session.agent;
117
+ let lifecycleState = remote.getLifecycleState();
118
+ let lifecycleOwnsWorkingMessage = false;
119
+
120
+ function applyLifecycleUi(state: RemoteLifecycleState): void {
121
+ const ctx = getNativeRemoteExtensionContext();
122
+ if (!ctx?.hasUI) return;
123
+
124
+ const shortSession = state.sessionId.slice(0, 16);
125
+ ctx.ui.setStatus(
126
+ 'remote_connection',
127
+ `Remote: ${shortSession}${shortSession.length < state.sessionId.length ? '...' : ''} ${getRemoteLifecycleLabel(state)}`
128
+ );
129
+
130
+ const activity = getRemoteLifecycleActivityLabel(state);
131
+ if (activity) {
132
+ ctx.ui.setStatus('remote_activity', activity);
133
+ } else {
134
+ ctx.ui.setStatus('remote_activity', state.isStreaming ? 'agent working...' : 'idle');
135
+ }
136
+
137
+ lifecycleOwnsWorkingMessage = syncRemoteLifecycleWorkingMessage(
138
+ state,
139
+ ctx.ui,
140
+ lifecycleOwnsWorkingMessage
141
+ );
142
+ }
143
+
144
+ remote.onLifecycleChange((state) => {
145
+ lifecycleState = state;
146
+ applyLifecycleUi(state);
147
+ });
148
+ void waitForNativeRemoteExtensionContext(10_000).then((ctx) => {
149
+ if (!ctx) return;
150
+ applyLifecycleUi(lifecycleState);
151
+ });
152
+
153
+ // ── 3. Patch Agent to be remote-backed ──
154
+ // Track the running prompt promise so InteractiveMode waits correctly
155
+ let runningPromptResolve: (() => void) | null = null;
156
+ let syntheticAgentStartEmitted = false;
157
+
158
+ // Override Agent.prompt — send RPC prompt command, block until agent_end
159
+ agent.prompt = async (input: any): Promise<void> => {
160
+ const text = extractPromptText(input);
161
+ log(`agent.prompt called, extracted text: ${text ? text.slice(0, 80) : '(empty)'}`);
162
+ if (!text) return;
163
+
164
+ // Set streaming state — InteractiveMode checks this
165
+ agent._state.isStreaming = true;
166
+ agent._state.streamMessage = null;
167
+ agent._state.error = undefined;
168
+
169
+ // Create runningPrompt so waitForIdle() works
170
+ const runPromise = new Promise<void>((resolve) => {
171
+ runningPromptResolve = resolve;
172
+ });
173
+ agent.runningPrompt = runPromise;
174
+
175
+ // Emit synthetic agent_start so InteractiveMode shows "working" immediately
176
+ syntheticAgentStartEmitted = true;
177
+ agent.emit({ type: 'agent_start', agentName: 'lead', timestamp: Date.now() });
178
+
179
+ // Send RPC command to sandbox
180
+ remote.prompt(text);
181
+ log(`Sent prompt: ${text.slice(0, 100)}`);
182
+
183
+ // Block until agent_end received from remote
184
+ await runPromise;
185
+ };
186
+
187
+ // Override Agent.steer — send RPC steer command
188
+ agent.steer = (m: any) => {
189
+ const text = extractMessageText(m);
190
+ if (text) {
191
+ remote.steer(text);
192
+ log(`Sent steer: ${text.slice(0, 100)}`);
193
+ }
194
+ };
195
+
196
+ // Override Agent.abort — send RPC abort command
197
+ agent.abort = () => {
198
+ remote.abort();
199
+ log('Sent abort');
200
+ resolveRunningPrompt();
201
+ };
202
+
203
+ // Override Agent.waitForIdle
204
+ agent.waitForIdle = () => {
205
+ return agent.runningPrompt ?? Promise.resolve();
206
+ };
207
+
208
+ // ── 4. Patch AgentSession methods ──
209
+ // session.prompt() does model/API key validation before calling agent.prompt().
210
+ // In remote mode, skip validation — the sandbox has the model/key.
211
+ // InteractiveMode calls session.prompt(text, { streamingBehavior: "steer" })
212
+ // when user types during streaming, and session.prompt(text, { streamingBehavior: "followUp" })
213
+ // for Alt+Enter follow-ups. Handle these by routing to steer/followUp commands.
214
+ (session as any).prompt = async (text: string, options?: any) => {
215
+ const behavior = options?.streamingBehavior;
216
+ log(`session.prompt called (behavior=${behavior ?? 'normal'}): ${text.slice(0, 80)}`);
217
+
218
+ // Extension commands (start with /) — let AgentSession handle them
219
+ // so extension-registered slash commands still work in remote mode
220
+ if (text.startsWith('/') && (session as any)._tryExecuteExtensionCommand) {
221
+ try {
222
+ const handled = await (session as any)._tryExecuteExtensionCommand(text);
223
+ if (handled) {
224
+ log(`Extension command handled: ${text}`);
225
+ return;
226
+ }
227
+ } catch (err) {
228
+ log(`Extension command error: ${err}`);
229
+ }
230
+ }
231
+
232
+ if (behavior === 'steer') {
233
+ remote.steer(text);
234
+ log(`Sent steer: ${text.slice(0, 80)}`);
235
+ return;
236
+ }
237
+ if (behavior === 'followUp') {
238
+ remote.sendCommand({ type: 'follow_up', message: text });
239
+ log(`Sent follow-up: ${text.slice(0, 80)}`);
240
+ return;
241
+ }
242
+
243
+ // Normal prompt — send to sandbox
244
+ await agent.prompt(text);
245
+ };
246
+
247
+ (session as any).steer = async (text: string) => {
248
+ remote.steer(text);
249
+ };
250
+
251
+ (session as any).followUp = async (text: string) => {
252
+ remote.sendCommand({ type: 'follow_up', message: text });
253
+ };
254
+
255
+ (session as any).abort = async () => {
256
+ remote.abort();
257
+ resolveRunningPrompt();
258
+ };
259
+
260
+ // Disable auto-compaction (sandbox handles it)
261
+ session.setAutoCompactionEnabled(false);
262
+ session.setAutoRetryEnabled(false);
263
+
264
+ // ── 5. Wire up remote events → Agent event pipeline ──
265
+ // Only emit LIVE events (from broadcast) to Agent → InteractiveMode.
266
+ // Replay events (from Durable Stream) are historical — skip them.
267
+ // Hydration is handled separately via session_hydration → agent.replaceMessages().
268
+ //
269
+ // Events that arrive before InteractiveMode is initialized are buffered
270
+ // and flushed after init (InteractiveMode registers listeners during init,
271
+ // so agent.emit() before that fires into the void).
272
+ let interactiveModeReady = false;
273
+ let eventBuffer: RpcEvent[] = [];
274
+ let seenMessageStart = false;
275
+ let seenAgentStart = false;
276
+
277
+ // Dedup guard: some events may arrive twice via different broadcast paths
278
+ // (rpc_event envelope + direct lifecycle broadcast). Track recent live events
279
+ // by type+timestamp to skip duplicates.
280
+ const recentEventKeys = new Set<string>();
281
+ const DEDUP_WINDOW_MS = 100;
282
+
283
+ // InteractiveMode adds a new assistant component on every assistant message_start.
284
+ // Track active/completed remote messages so normal terminal events and late duplicates
285
+ // do not spawn extra components.
286
+ let assistantStreamActive = false;
287
+ const recentCompletedAssistantMessageKeys: string[] = [];
288
+ const completedAssistantMessageKeySet = new Set<string>();
289
+
290
+ function rememberCompletedAssistantMessage(key: string): void {
291
+ if (completedAssistantMessageKeySet.has(key)) return;
292
+ recentCompletedAssistantMessageKeys.push(key);
293
+ completedAssistantMessageKeySet.add(key);
294
+ if (recentCompletedAssistantMessageKeys.length > 32) {
295
+ const expired = recentCompletedAssistantMessageKeys.shift();
296
+ if (expired) completedAssistantMessageKeySet.delete(expired);
297
+ }
298
+ }
299
+
300
+ function emitRemoteUserPrompt(text: string, timestamp: number): void {
301
+ const userMessage = {
302
+ role: 'user' as const,
303
+ content: [{ type: 'text' as const, text }],
304
+ timestamp,
305
+ };
306
+ const syntheticEvents = [
307
+ { type: 'message_start', message: userMessage },
308
+ { type: 'message_end', message: userMessage },
309
+ ] as RpcEvent[];
310
+
311
+ if (!interactiveModeReady) {
312
+ eventBuffer.push(...syntheticEvents);
313
+ log('Buffered synthetic user_prompt events (InteractiveMode not ready)');
314
+ return;
315
+ }
316
+
317
+ for (const event of syntheticEvents) {
318
+ agent.emit(event);
319
+ }
320
+ }
321
+
322
+ remote.onEvent((rpcEvent: RpcEvent) => {
323
+ const source = (rpcEvent as any)._source ?? 'unknown';
324
+ const isReplay =
325
+ source === 'replay' ||
326
+ (rpcEvent as any).replay === true ||
327
+ (rpcEvent as any).isReplay === true;
328
+ log(`Event received: ${rpcEvent.type} (source=${source})`);
329
+
330
+ if (rpcEvent.type === 'session_resume') {
331
+ sessionResumeSeen = true;
332
+ log(
333
+ `Session resume signaled (${typeof (rpcEvent as any).streamId === 'string' ? (rpcEvent as any).streamId : 'no stream id'})`
334
+ );
335
+ return;
336
+ }
337
+
338
+ if (rpcEvent.type === 'session_stream_ready') {
339
+ log(
340
+ `Durable stream ready (${typeof (rpcEvent as any).streamId === 'string' ? (rpcEvent as any).streamId : 'no stream id'})`
341
+ );
342
+ return;
343
+ }
344
+
345
+ if (rpcEvent.type === 'rpc_command_error') {
346
+ const error =
347
+ typeof (rpcEvent as any).error === 'string'
348
+ ? (rpcEvent as any).error
349
+ : 'Remote command failed';
350
+ const ctx = getNativeRemoteExtensionContext();
351
+ if (ctx?.hasUI) {
352
+ ctx.ui.notify(error, 'warning');
353
+ lifecycleOwnsWorkingMessage = clearRemoteLifecycleWorkingMessage(
354
+ ctx.ui,
355
+ lifecycleOwnsWorkingMessage
356
+ );
357
+ }
358
+ agent._state.error = error;
359
+ seenAgentStart = false;
360
+ seenMessageStart = false;
361
+ resolveRunningPrompt();
362
+ assistantStreamActive = false;
363
+ log(`Remote command error: ${error}`);
364
+ return;
365
+ }
366
+
367
+ // session_hydration is handled separately below — skip it here
368
+ if (rpcEvent.type === 'session_hydration') return;
369
+
370
+ // Remote prompts from other controllers are broadcast as user_prompt.
371
+ // Convert them to synthetic user message lifecycle events so InteractiveMode
372
+ // renders them like locally-entered prompts. Replays are covered by hydration.
373
+ if (rpcEvent.type === 'user_prompt') {
374
+ if (isReplay) {
375
+ log('Skipping replay user_prompt');
376
+ return;
377
+ }
378
+
379
+ const promptText =
380
+ typeof (rpcEvent as any).content === 'string'
381
+ ? (rpcEvent as any).content
382
+ : typeof (rpcEvent as any).text === 'string'
383
+ ? (rpcEvent as any).text
384
+ : '';
385
+ if (!promptText.trim()) {
386
+ log('Skipping empty user_prompt');
387
+ return;
388
+ }
389
+
390
+ const promptTimestamp =
391
+ typeof (rpcEvent as any).timestamp === 'number'
392
+ ? (rpcEvent as any).timestamp
393
+ : Date.now();
394
+ log('Rendering live user_prompt as synthetic user message');
395
+ emitRemoteUserPrompt(promptText, promptTimestamp);
396
+ return;
397
+ }
398
+
399
+ // Skip user-role message events — the TUI already shows user messages
400
+ // via InteractiveMode input or the synthetic user_prompt path above.
401
+ // Pi emits message_start/end for both user and assistant messages; without
402
+ // this guard the same prompt can appear twice.
403
+ if (rpcEvent.type === 'message_start' || rpcEvent.type === 'message_end') {
404
+ const msg = (rpcEvent as any).message;
405
+ if (msg?.role === 'user') {
406
+ log(`Skipping ${rpcEvent.type} (role=user) — handled locally`);
407
+ return;
408
+ }
409
+ }
410
+
411
+ // Dedup: skip if we already processed the same event type + timestamp recently
412
+ // Replays still check the cache, but they never populate it.
413
+ const ts = (rpcEvent as any).timestamp ?? 0;
414
+ const dedupKey = `${rpcEvent.type}:${ts}`;
415
+ if (recentEventKeys.has(dedupKey)) {
416
+ log(`Dedup: skipping duplicate ${rpcEvent.type} (ts=${ts})`);
417
+ return;
418
+ }
419
+ if (!isReplay && ts > 0) {
420
+ recentEventKeys.add(dedupKey);
421
+ setTimeout(() => recentEventKeys.delete(dedupKey), DEDUP_WINDOW_MS);
422
+ }
423
+
424
+ // Skip duplicate agent_start if we already emitted a synthetic one
425
+ if (rpcEvent.type === 'agent_start' && syntheticAgentStartEmitted) {
426
+ syntheticAgentStartEmitted = false;
427
+ // Still update state from real event
428
+ updateAgentState(agent, rpcEvent);
429
+ return;
430
+ }
431
+
432
+ // Skip replay events — these are historical from Durable Stream
433
+ if (isReplay) {
434
+ log(`Skipping replay event: ${rpcEvent.type}`);
435
+ return;
436
+ }
437
+
438
+ const hadSeenAgentStart = seenAgentStart;
439
+ const hadSeenMessageStart = seenMessageStart;
440
+ const assistantMessageKey = getRemoteAssistantMessageKey(rpcEvent);
441
+ if (
442
+ assistantMessageKey &&
443
+ (rpcEvent.type === 'message_start' || rpcEvent.type === 'message_end') &&
444
+ completedAssistantMessageKeySet.has(assistantMessageKey)
445
+ ) {
446
+ log(`Dedup: skipping repeated assistant ${rpcEvent.type}`);
447
+ return;
448
+ }
449
+
450
+ // State-based dedup for assistant message streaming.
451
+ // Prevents duplicate AssistantMessageComponent from being added to the DOM.
452
+ if (rpcEvent.type === 'message_start') {
453
+ const msg = (rpcEvent as any).message;
454
+ if (msg?.role === 'assistant') {
455
+ if (assistantStreamActive) {
456
+ log(`Dedup: skipping duplicate assistant message_start (stream already active)`);
457
+ return;
458
+ }
459
+ assistantStreamActive = true;
460
+ }
461
+ }
462
+ if (rpcEvent.type === 'message_end') {
463
+ const msg = (rpcEvent as any).message;
464
+ if (msg?.role === 'assistant') {
465
+ assistantStreamActive = false;
466
+ if (assistantMessageKey) {
467
+ rememberCompletedAssistantMessage(assistantMessageKey);
468
+ }
469
+ }
470
+ }
471
+ if (rpcEvent.type === 'agent_end') {
472
+ assistantStreamActive = false;
473
+ }
474
+
475
+ // Track streaming lifecycle events so we can inject synthetics when
476
+ // we attach mid-stream (controller connected after agent already started).
477
+ if (rpcEvent.type === 'agent_start') seenAgentStart = true;
478
+ if (rpcEvent.type === 'agent_end') {
479
+ seenAgentStart = false;
480
+ seenMessageStart = false;
481
+ }
482
+ if (rpcEvent.type === 'message_start') seenMessageStart = true;
483
+ if (rpcEvent.type === 'message_end') seenMessageStart = false;
484
+
485
+ // Update agent internal state (mirrors Agent._runLoop behavior)
486
+ updateAgentState(agent, rpcEvent);
487
+
488
+ // Buffer events until InteractiveMode is ready to receive them
489
+ if (!interactiveModeReady) {
490
+ eventBuffer.push(rpcEvent);
491
+ log(`Buffered event: ${rpcEvent.type} (InteractiveMode not ready)`);
492
+ return;
493
+ }
494
+
495
+ // Mid-stream attach guard: if we receive message_update or message_end
496
+ // without having seen message_start, inject synthetic agent_start +
497
+ // message_start so InteractiveMode sets up its streaming component.
498
+ // Without this, the events are silently dropped because
499
+ // InteractiveMode.streamingComponent is null.
500
+ // This happens when the controller connects mid-stream or after the
501
+ // agent finishes — the broadcast of agent_start/message_start occurred
502
+ // before the controller WebSocket was registered.
503
+ let injectedSyntheticMessageStart = false;
504
+ if (
505
+ (rpcEvent.type === 'message_update' || rpcEvent.type === 'message_end') &&
506
+ !hadSeenMessageStart
507
+ ) {
508
+ log(`Live ${rpcEvent.type} without prior message_start — injecting synthetics`);
509
+ if (!hadSeenAgentStart) {
510
+ agent.emit({ type: 'agent_start', agentName: 'lead', timestamp: Date.now() } as any);
511
+ seenAgentStart = true;
512
+ }
513
+ agent.emit({
514
+ type: 'message_start',
515
+ message: {
516
+ role: 'assistant',
517
+ content: [],
518
+ api: 'anthropic-messages',
519
+ provider: 'anthropic',
520
+ model: 'remote',
521
+ usage: {
522
+ input: 0,
523
+ output: 0,
524
+ cacheRead: 0,
525
+ cacheWrite: 0,
526
+ totalTokens: 0,
527
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
528
+ },
529
+ timestamp: Date.now(),
530
+ },
531
+ } as any);
532
+ seenMessageStart = true;
533
+ assistantStreamActive = true;
534
+ injectedSyntheticMessageStart = true;
535
+ }
536
+
537
+ // Emit to subscribers — InteractiveMode.handleEvent processes this
538
+ agent.emit(rpcEvent);
539
+ if (injectedSyntheticMessageStart && rpcEvent.type === 'message_end') {
540
+ seenMessageStart = false;
541
+ seenAgentStart = hadSeenAgentStart;
542
+ assistantStreamActive = false;
543
+ }
544
+
545
+ // Resolve running prompt when agent finishes
546
+ if (rpcEvent.type === 'agent_end') {
547
+ resolveRunningPrompt();
548
+ }
549
+ });
550
+
551
+ // ── 6. Wire up UI handlers for extension dialogs from sandbox ──
552
+ remote.setUiHandler(async (request) => {
553
+ const ctx =
554
+ getNativeRemoteExtensionContext() ?? (await waitForNativeRemoteExtensionContext(10_000));
555
+ if (!ctx) {
556
+ log(
557
+ `UI request: ${request.method} (${request.id}) timed out waiting for extension UI context`
558
+ );
559
+ return REMOTE_FIRE_AND_FORGET_UI_METHODS.has(request.method) ? undefined : null;
560
+ }
561
+
562
+ try {
563
+ return await handleRemoteUiRequest(ctx, request);
564
+ } catch (err) {
565
+ log(
566
+ `UI request handler error for ${request.method}: ${err instanceof Error ? err.message : String(err)}`
567
+ );
568
+ return REMOTE_FIRE_AND_FORGET_UI_METHODS.has(request.method) ? undefined : null;
569
+ }
570
+ });
571
+
572
+ // ── 7. Handle hydration (initial state from Hub) ──
573
+ // Hydration arrives as the message AFTER 'init' on the WebSocket.
574
+ // remote.connect() resolves on 'init', so hydration arrives in the next
575
+ // event loop tick. We use a promise to wait for it before creating
576
+ // InteractiveMode (which calls renderInitialMessages from SessionManager).
577
+ const sm = session.sessionManager;
578
+ let resolveHydration: () => void;
579
+ let hydrationComplete = false;
580
+ const hydrationReady = new Promise<void>((resolve) => {
581
+ resolveHydration = () => {
582
+ if (hydrationComplete) return;
583
+ hydrationComplete = true;
584
+ resolve();
585
+ };
586
+ });
587
+
588
+ let hydrationCount = 0;
589
+ remote.onEvent((event: RpcEvent) => {
590
+ if (event.type !== 'session_hydration') return;
591
+ hydrationCount++;
592
+
593
+ const entries = (event as any).entries as
594
+ | Array<{
595
+ type: string;
596
+ content?: string | Array<{ type: string; text?: string }>;
597
+ role?: string;
598
+ timestamp?: number;
599
+ }>
600
+ | undefined;
601
+
602
+ // Extract task text from hydration (Hub includes session.sandbox?.task)
603
+ const hydrationTask = (event as any).task as string | undefined;
604
+
605
+ // On reconnect (2nd+ hydration), clear SM to prevent duplicate accumulation.
606
+ // agent.replaceMessages() already replaces state.messages, but SM only appends.
607
+ if (hydrationCount > 1) {
608
+ log(`Re-hydration #${hydrationCount} — clearing SessionManager to prevent duplicates`);
609
+ try {
610
+ if (typeof (sm as any).clear === 'function') {
611
+ (sm as any).clear();
612
+ }
613
+ } catch (err) {
614
+ log(`SM clear error (non-fatal): ${err}`);
615
+ }
616
+ }
617
+
618
+ if (!entries?.length) {
619
+ log('Received session_hydration with no entries');
620
+ // Even with no entries, inject task as user message if available
621
+ if (hydrationTask) {
622
+ const taskMsg = {
623
+ role: 'user' as const,
624
+ content: [{ type: 'text' as const, text: hydrationTask }],
625
+ timestamp: Date.now(),
626
+ };
627
+ agent.replaceMessages([taskMsg]);
628
+ try {
629
+ sm.appendMessage(taskMsg as any);
630
+ } catch (err) {
631
+ log(`SM append task error: ${err}`);
632
+ }
633
+ log('Injected task as user message (no entries)');
634
+ }
635
+ resolveHydration!();
636
+ return;
637
+ }
638
+
639
+ log(`Hydrating ${entries.length} entries (hydration #${hydrationCount})`);
640
+ const agentMessages: any[] = [];
641
+
642
+ // If we have a task and no user_prompt entry, inject the task as the first user message
643
+ const hasUserEntry = entries.some((e) => e.type === 'user_prompt' || e.role === 'user');
644
+ if (hydrationTask && !hasUserEntry) {
645
+ const taskMsg = {
646
+ role: 'user' as const,
647
+ content: [{ type: 'text' as const, text: hydrationTask }],
648
+ timestamp: Date.now(),
649
+ };
650
+ agentMessages.push(taskMsg);
651
+ try {
652
+ sm.appendMessage(taskMsg as any);
653
+ } catch (err) {
654
+ log(`SM append task error: ${err}`);
655
+ }
656
+ log('Injected task as user message');
657
+ }
658
+
659
+ for (const entry of entries) {
660
+ const text =
661
+ typeof entry.content === 'string'
662
+ ? entry.content
663
+ : Array.isArray(entry.content)
664
+ ? entry.content
665
+ .filter(
666
+ (c): c is { type: string; text: string } =>
667
+ c.type === 'text' && typeof c.text === 'string'
668
+ )
669
+ .map((c) => c.text)
670
+ .join('\n')
671
+ : '';
672
+
673
+ if (!text) continue;
674
+
675
+ // Hub conversation entries use type: 'message' for assistant, 'thinking' for thinking,
676
+ // 'task_result' for delegation results, 'turn' for turn markers, 'user_prompt' for user input.
677
+ // Only 'user_prompt' entries are user messages; everything else is assistant-side.
678
+ const isAssistant =
679
+ entry.role === 'assistant' ||
680
+ entry.type === 'message' ||
681
+ entry.type === 'thinking' ||
682
+ entry.type === 'task_result' ||
683
+ entry.type === 'assistant';
684
+ if (isAssistant) {
685
+ const msg = {
686
+ role: 'assistant' as const,
687
+ content: [{ type: 'text' as const, text }],
688
+ api: 'anthropic-messages',
689
+ provider: 'anthropic',
690
+ model: 'remote',
691
+ usage: {
692
+ input: 0,
693
+ output: 0,
694
+ cacheRead: 0,
695
+ cacheWrite: 0,
696
+ totalTokens: 0,
697
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
698
+ },
699
+ stopReason: 'stop',
700
+ timestamp: entry.timestamp ?? Date.now(),
701
+ };
702
+ agentMessages.push(msg);
703
+ try {
704
+ sm.appendMessage(msg as any);
705
+ } catch (err) {
706
+ log(`SM append error: ${err}`);
707
+ }
708
+ } else {
709
+ const msg = {
710
+ role: 'user' as const,
711
+ content: [{ type: 'text' as const, text }],
712
+ timestamp: entry.timestamp ?? Date.now(),
713
+ };
714
+ agentMessages.push(msg);
715
+ try {
716
+ sm.appendMessage(msg as any);
717
+ } catch (err) {
718
+ log(`SM append error: ${err}`);
719
+ }
720
+ }
721
+ }
722
+
723
+ if (agentMessages.length > 0) {
724
+ agent.replaceMessages(agentMessages);
725
+ log(`Hydrated ${agentMessages.length} agent messages (+ session manager)`);
726
+ } else {
727
+ log('Hydration: 0 messages after filtering (all entries had empty text?)');
728
+ }
729
+
730
+ // Restore streaming state from hydration — fixes first-connect miss
731
+ const streamingState = (event as any).streamingState as
732
+ | {
733
+ isStreaming?: boolean;
734
+ activeTasks?: Array<{ taskId: string; agent: string }>;
735
+ }
736
+ | undefined;
737
+
738
+ if (streamingState?.isStreaming) {
739
+ agent._state.isStreaming = true;
740
+ hydrationStreamingDetected = true;
741
+ // Create runningPrompt so InteractiveMode knows we're busy
742
+ if (!agent.runningPrompt) {
743
+ const runPromise = new Promise<void>((resolve) => {
744
+ runningPromptResolve = resolve;
745
+ });
746
+ agent.runningPrompt = runPromise;
747
+ }
748
+ log(
749
+ `Hydration: session is streaming with ${streamingState.activeTasks?.length ?? 0} active tasks`
750
+ );
751
+ }
752
+
753
+ resolveHydration!();
754
+ });
755
+
756
+ // ── 8. NOW connect to Hub ──
757
+ // All handlers are registered, so hydration + replay events will be captured.
758
+ log('Connecting to Hub (handlers registered)...');
759
+ await remote.connect(hubWsUrl);
760
+ log('Connected to Hub as controller');
761
+
762
+ // Wait for hydration message (arrives right after init), with a timeout
763
+ // in case this is the first connection and there's nothing to hydrate.
764
+ await Promise.race([
765
+ hydrationReady,
766
+ new Promise<void>((resolve) => {
767
+ const waitStartedAt = Date.now();
768
+ const poll = (): void => {
769
+ if (hydrationComplete) {
770
+ resolve();
771
+ return;
772
+ }
773
+ const timeoutMs = sessionResumeSeen ? 5000 : 2000;
774
+ if (Date.now() - waitStartedAt >= timeoutMs) {
775
+ log('Hydration timeout — no session_hydration received');
776
+ resolve();
777
+ return;
778
+ }
779
+ setTimeout(poll, 50);
780
+ };
781
+ poll();
782
+ }),
783
+ ]);
784
+ const smEntries = sm.getEntries?.() ?? [];
785
+ log(`SessionManager has ${smEntries.length} entries after hydration`);
786
+ log(`Post-hydration: SM has ${smEntries.length} entries, leafId=${sm.getLeafId?.() ?? 'N/A'}`);
787
+
788
+ // ── 9. Start InteractiveMode — full native Pi TUI ──
789
+ log('Creating InteractiveMode');
790
+ const interactive = new InteractiveMode(session);
791
+ log('InteractiveMode created, calling init...');
792
+ await interactive.init();
793
+
794
+ // Flush buffered events now that InteractiveMode is listening.
795
+ // If the session was already streaming when we connected (mid-stream attach),
796
+ // InteractiveMode needs agent_start + message_start to set up its streaming
797
+ // components. Without these, message_update events are silently dropped
798
+ // because InteractiveMode.streamingComponent is null.
799
+ interactiveModeReady = true;
800
+
801
+ if (hydrationStreamingDetected) {
802
+ // Immediately emit agent_start + message_start so InteractiveMode shows
803
+ // the streaming indicator right away, before any buffered events flush.
804
+ // This prevents the blank screen gap between connect and first event.
805
+ log('Hydration detected streaming — emitting immediate synthetics');
806
+ agent.emit({ type: 'agent_start', agentName: 'lead', timestamp: Date.now() } as any);
807
+ agent.emit({
808
+ type: 'message_start',
809
+ message: {
810
+ role: 'assistant',
811
+ content: [],
812
+ api: 'anthropic-messages',
813
+ provider: 'anthropic',
814
+ model: 'remote',
815
+ usage: {
816
+ input: 0,
817
+ output: 0,
818
+ cacheRead: 0,
819
+ cacheWrite: 0,
820
+ totalTokens: 0,
821
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
822
+ },
823
+ timestamp: Date.now(),
824
+ },
825
+ } as any);
826
+ seenAgentStart = true;
827
+ seenMessageStart = true;
828
+ assistantStreamActive = true;
829
+
830
+ // Remove any agent_start/message_start from buffer since we already emitted them
831
+ eventBuffer = eventBuffer.filter(
832
+ (e) => e.type !== 'agent_start' && e.type !== 'message_start'
833
+ );
834
+ }
835
+
836
+ if (eventBuffer.length > 0) {
837
+ log(`Flushing ${eventBuffer.length} events: ${eventBuffer.map((e) => e.type).join(', ')}`);
838
+ for (const buffered of eventBuffer) {
839
+ agent.emit(buffered);
840
+ if (buffered.type === 'agent_end') {
841
+ resolveRunningPrompt();
842
+ }
843
+ }
844
+ }
845
+ eventBuffer = [];
846
+
847
+ log('InteractiveMode initialized, calling run...');
848
+
849
+ // Handle clean shutdown
850
+ const cleanup = () => {
851
+ remote.close();
852
+ setNativeRemoteExtensionContext(null);
853
+ interactive.stop();
854
+ };
855
+ process.on('SIGINT', cleanup);
856
+ process.on('SIGTERM', cleanup);
857
+
858
+ try {
859
+ await interactive.run();
860
+ } catch (err) {
861
+ log(`InteractiveMode.run() threw: ${err instanceof Error ? err.stack : String(err)}`);
862
+ throw err;
863
+ } finally {
864
+ remote.close();
865
+ setNativeRemoteExtensionContext(null);
866
+ log('Remote TUI exited');
867
+ }
868
+
869
+ // ── Helper: resolve the running prompt promise ──
870
+ function resolveRunningPrompt(): void {
871
+ syntheticAgentStartEmitted = false;
872
+ agent._state.isStreaming = false;
873
+ agent._state.streamMessage = null;
874
+ agent._state.pendingToolCalls = new Set();
875
+ if (runningPromptResolve) {
876
+ runningPromptResolve();
877
+ runningPromptResolve = null;
878
+ agent.runningPrompt = undefined;
879
+ }
880
+ }
881
+ }
882
+
883
+ // ══════════════════════════════════════════════
884
+ // Agent state synchronization
885
+ // Mirrors Agent._runLoop state updates (agent.js lines 317-352)
886
+ // ══════════════════════════════════════════════
887
+
888
+ function updateAgentState(agent: any, event: RpcEvent): void {
889
+ const state = agent._state;
890
+
891
+ switch (event.type) {
892
+ case 'agent_start':
893
+ state.isStreaming = true;
894
+ break;
895
+
896
+ case 'agent_end':
897
+ state.isStreaming = false;
898
+ state.streamMessage = null;
899
+ state.pendingToolCalls = new Set();
900
+ break;
901
+
902
+ case 'message_start':
903
+ state.streamMessage = (event as any).message;
904
+ state.isStreaming = true;
905
+ break;
906
+
907
+ case 'message_update':
908
+ state.streamMessage = (event as any).message;
909
+ break;
910
+
911
+ case 'message_end':
912
+ state.streamMessage = null;
913
+ // NOTE: Do NOT push to state.messages here.
914
+ // AgentSession._handleAgentEvent already persists via sessionManager.appendMessage().
915
+ // Pushing here causes state.messages to accumulate duplicates with SM,
916
+ // leading to visual duplicates if rebuildChatFromMessages() is ever triggered.
917
+ break;
918
+
919
+ case 'tool_execution_start': {
920
+ const s = new Set(state.pendingToolCalls);
921
+ s.add((event as any).toolCallId);
922
+ state.pendingToolCalls = s;
923
+ break;
924
+ }
925
+
926
+ case 'tool_execution_end': {
927
+ const s = new Set(state.pendingToolCalls);
928
+ s.delete((event as any).toolCallId);
929
+ state.pendingToolCalls = s;
930
+ break;
931
+ }
932
+
933
+ case 'thinking_start':
934
+ state.isStreaming = true;
935
+ break;
936
+
937
+ case 'thinking_end':
938
+ break;
939
+
940
+ case 'tool_call':
941
+ state.isStreaming = true;
942
+ break;
943
+
944
+ case 'tool_result':
945
+ break;
946
+
947
+ case 'task_start':
948
+ state.isStreaming = true;
949
+ break;
950
+
951
+ case 'task_complete':
952
+ case 'task_error':
953
+ break;
954
+
955
+ case 'turn_end': {
956
+ const msg = (event as any).message;
957
+ if (msg?.role === 'assistant' && msg?.errorMessage) {
958
+ state.error = msg.errorMessage;
959
+ }
960
+ break;
961
+ }
962
+ }
963
+ }
964
+
965
+ // ══════════════════════════════════════════════
966
+ // Text extraction helpers
967
+ // ══════════════════════════════════════════════
968
+
969
+ function extractPromptText(input: any): string {
970
+ if (typeof input === 'string') return input;
971
+
972
+ if (Array.isArray(input)) {
973
+ return input.map(extractMessageText).filter(Boolean).join('\n');
974
+ }
975
+
976
+ return extractMessageText(input);
977
+ }
978
+
979
+ function extractMessageText(msg: any): string {
980
+ if (typeof msg === 'string') return msg;
981
+ if (!msg || typeof msg !== 'object') return '';
982
+
983
+ if (typeof msg.content === 'string') return msg.content;
984
+
985
+ if (Array.isArray(msg.content)) {
986
+ return msg.content
987
+ .filter((c: any) => c?.type === 'text' && typeof c.text === 'string')
988
+ .map((c: any) => c.text)
989
+ .join('\n');
990
+ }
991
+
992
+ return '';
993
+ }
994
+
995
+ function getRemoteAssistantMessageKey(event: RpcEvent): string | undefined {
996
+ if (event.type !== 'message_start' && event.type !== 'message_end') return undefined;
997
+
998
+ const message = (event as any).message;
999
+ if (!message || typeof message !== 'object' || message.role !== 'assistant') {
1000
+ return undefined;
1001
+ }
1002
+
1003
+ const messageId = typeof message.id === 'string' ? message.id : '';
1004
+ if (messageId) return `id:${messageId}`;
1005
+
1006
+ const timestamp =
1007
+ typeof message.timestamp === 'number'
1008
+ ? String(message.timestamp)
1009
+ : typeof message.timestamp === 'string'
1010
+ ? message.timestamp
1011
+ : typeof (event as any).timestamp === 'number'
1012
+ ? String((event as any).timestamp)
1013
+ : typeof (event as any).timestamp === 'string'
1014
+ ? (event as any).timestamp
1015
+ : '';
1016
+ if (timestamp) return `ts:${timestamp}`;
1017
+
1018
+ const text = extractMessageText(message);
1019
+ if (!text) return undefined;
1020
+
1021
+ const stopReason = typeof message.stopReason === 'string' ? message.stopReason : '';
1022
+ return `text:${stopReason}|${text}`;
1023
+ }