dextunnel 0.1.0

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 (76) hide show
  1. package/LICENSE +211 -0
  2. package/README.md +112 -0
  3. package/SECURITY.md +27 -0
  4. package/SUPPORT.md +43 -0
  5. package/package.json +44 -0
  6. package/public/client-shared.js +1831 -0
  7. package/public/favicon.svg +11 -0
  8. package/public/host.html +29 -0
  9. package/public/host.js +2079 -0
  10. package/public/index.html +28 -0
  11. package/public/index.js +98 -0
  12. package/public/live-bridge-lifecycle.js +258 -0
  13. package/public/live-bridge-retry-state.js +61 -0
  14. package/public/live-selection-intent.js +79 -0
  15. package/public/remote-operator-state.js +316 -0
  16. package/public/remote.html +167 -0
  17. package/public/remote.js +3967 -0
  18. package/public/styles.css +2793 -0
  19. package/public/surface-view-state.js +89 -0
  20. package/public/voice-dictation.js +45 -0
  21. package/src/bin/desktop-rehydration-smoke.mjs +111 -0
  22. package/src/bin/dextunnel.mjs +41 -0
  23. package/src/bin/doctor.mjs +48 -0
  24. package/src/bin/launch-attest.mjs +39 -0
  25. package/src/bin/launch-status.mjs +49 -0
  26. package/src/bin/mobile-link-proxy.mjs +221 -0
  27. package/src/bin/mobile-proof.mjs +164 -0
  28. package/src/bin/mobile-transport-smoke.mjs +200 -0
  29. package/src/bin/probe-codex-app-server-write.mjs +36 -0
  30. package/src/bin/probe-codex-app-server.mjs +30 -0
  31. package/src/lib/agent-room-context.mjs +54 -0
  32. package/src/lib/agent-room-runtime.mjs +355 -0
  33. package/src/lib/agent-room-service.mjs +335 -0
  34. package/src/lib/agent-room-state.mjs +406 -0
  35. package/src/lib/agent-room-store.mjs +71 -0
  36. package/src/lib/agent-room-text.mjs +48 -0
  37. package/src/lib/app-server-contract.mjs +66 -0
  38. package/src/lib/app-server-runtime.mjs +60 -0
  39. package/src/lib/attachment-service.mjs +119 -0
  40. package/src/lib/bridge-api-handler.mjs +719 -0
  41. package/src/lib/bridge-runtime-lifecycle.mjs +51 -0
  42. package/src/lib/bridge-status-builder.mjs +60 -0
  43. package/src/lib/codex-app-server-client.mjs +1511 -0
  44. package/src/lib/companion-state.mjs +453 -0
  45. package/src/lib/control-lease-service.mjs +180 -0
  46. package/src/lib/debug-harness-service.mjs +173 -0
  47. package/src/lib/desktop-integration.mjs +146 -0
  48. package/src/lib/desktop-rehydration-smoke.mjs +269 -0
  49. package/src/lib/dextunnel-cli.mjs +122 -0
  50. package/src/lib/discovery-docs.mjs +1321 -0
  51. package/src/lib/fake-codex-app-server-bridge.mjs +340 -0
  52. package/src/lib/install-preflight.mjs +373 -0
  53. package/src/lib/interaction-resolution-service.mjs +185 -0
  54. package/src/lib/interaction-state.mjs +360 -0
  55. package/src/lib/launch-release-bar.mjs +158 -0
  56. package/src/lib/live-control-state.mjs +107 -0
  57. package/src/lib/live-payload-builder.mjs +298 -0
  58. package/src/lib/live-selection-transition-state.mjs +49 -0
  59. package/src/lib/live-transcript-state.mjs +549 -0
  60. package/src/lib/mobile-network-profile.mjs +39 -0
  61. package/src/lib/mock-codex-adapter.mjs +62 -0
  62. package/src/lib/operator-diagnostics.mjs +82 -0
  63. package/src/lib/repo-changes-service.mjs +527 -0
  64. package/src/lib/runtime-config.mjs +106 -0
  65. package/src/lib/selection-state-service.mjs +214 -0
  66. package/src/lib/session-store.mjs +355 -0
  67. package/src/lib/shared-room-state.mjs +473 -0
  68. package/src/lib/shared-selection-state.mjs +40 -0
  69. package/src/lib/sse-hub.mjs +35 -0
  70. package/src/lib/static-surface-service.mjs +71 -0
  71. package/src/lib/surface-access.mjs +189 -0
  72. package/src/lib/surface-presence-service.mjs +118 -0
  73. package/src/lib/surface-request-guard.mjs +52 -0
  74. package/src/lib/thread-sync-state.mjs +536 -0
  75. package/src/lib/watcher-lifecycle.mjs +287 -0
  76. package/src/server.mjs +1446 -0
package/public/host.js ADDED
@@ -0,0 +1,2079 @@
1
+ import {
2
+ clearHtmlRenderState,
3
+ currentSurfaceTranscript,
4
+ createRequestError,
5
+ describeOperatorDiagnostics,
6
+ describeDesktopSyncNote,
7
+ describeThreadState,
8
+ entryKey,
9
+ escapeHtml,
10
+ formatRecoveryDuration,
11
+ getSurfaceBootstrap,
12
+ groupThreadsByProject,
13
+ humanize,
14
+ isConversationEntry,
15
+ isSystemNoticeEntry,
16
+ mergeSurfaceAttachments,
17
+ populateSelect,
18
+ projectLabel,
19
+ reconcileRenderedList,
20
+ renderChangeCard,
21
+ isAdvisoryEntry,
22
+ renderParticipantRoster,
23
+ setHtmlIfChanged,
24
+ setPanelHidden,
25
+ renderTranscriptCard,
26
+ sessionLabel,
27
+ shortThreadId,
28
+ stableSurfaceClientId,
29
+ shouldHideTranscriptEntry,
30
+ startTicker,
31
+ withSurfaceHeaders,
32
+ withSurfaceTokenUrl
33
+ } from "./client-shared.js";
34
+ import {
35
+ createSelectionIntent,
36
+ reconcileSelectionIntent,
37
+ selectionIntentMessage,
38
+ selectionIntentTitle
39
+ } from "./live-selection-intent.js";
40
+ import {
41
+ createLiveBridgeLifecycle,
42
+ createLiveBridgeLifecycleState
43
+ } from "./live-bridge-lifecycle.js";
44
+ import { createSurfaceViewState } from "./surface-view-state.js";
45
+
46
+ const stateUrl = "/api/state";
47
+ const liveStateUrl = "/api/codex-app-server/live-state";
48
+ const refreshUrl = "/api/codex-app-server/refresh";
49
+ const selectionUrl = "/api/codex-app-server/selection";
50
+ const interactionUrl = "/api/codex-app-server/interaction";
51
+ const controlUrl = "/api/codex-app-server/control";
52
+ const companionUrl = "/api/codex-app-server/companion";
53
+ const debugInteractionUrl = "/api/debug/live-interaction";
54
+ const changesUrl = "/api/codex-app-server/changes";
55
+ const openInCodexUrl = "/api/codex-app-server/open-in-codex";
56
+ const presenceUrl = "/api/codex-app-server/presence";
57
+ const FALLBACK_REFRESH_INTERVAL_MS = 6000;
58
+ const FALLBACK_REFRESH_STALE_MS = 14000;
59
+ const HOST_INTENT_ACTIVE_WINDOW_MS = 45000;
60
+ const PRESENCE_HEARTBEAT_INTERVAL_MS = 12000;
61
+ const STREAM_RECOVERY_BASE_MS = 700;
62
+ const STREAM_RECOVERY_MAX_MS = 5000;
63
+ const BOOTSTRAP_RETRY_BASE_MS = 900;
64
+ const BOOTSTRAP_RETRY_MAX_MS = 6000;
65
+ const surfaceBootstrap = getSurfaceBootstrap("host");
66
+ const surfaceAuthClientId = surfaceBootstrap.clientId;
67
+ let currentSnapshot = null;
68
+ let currentLiveState = null;
69
+ let renderedThreadId = null;
70
+ let hasRenderedOnce = false;
71
+ let lastLiveActivityAt = 0;
72
+ let presenceSyncPromise = null;
73
+ let presenceSyncTimer = null;
74
+ let lastPresenceSignature = "";
75
+ let lastPresenceSyncAt = 0;
76
+ let currentChanges = null;
77
+ let changesRefreshPromise = null;
78
+ let changesRefreshTimer = null;
79
+ let actionHandoffState = null;
80
+ let actionHandoffTimer = null;
81
+ let streamIssueStartedAt = 0;
82
+ let transientUiNotice = null;
83
+ let transientUiNoticeTimer = null;
84
+ let companionActionState = null;
85
+ let lastUserIntentAt = Date.now();
86
+ let selectionIntent = null;
87
+ let selectionRequestVersion = 0;
88
+ const surfaceClientId = stableSurfaceClientId("host");
89
+ const surfaceViewState = createSurfaceViewState({
90
+ defaults: {
91
+ filters: {
92
+ council: false,
93
+ changes: true,
94
+ thread: true,
95
+ advisories: false,
96
+ updates: false,
97
+ tools: false
98
+ }
99
+ },
100
+ scopeId: surfaceClientId,
101
+ surface: "host"
102
+ });
103
+ const expandedFeedSections = new Set();
104
+ const expandedEntryKeys = new Set();
105
+ const seenCardKeys = new Set();
106
+ const seenCommandKeys = new Set();
107
+ const bridgeState = createLiveBridgeLifecycleState({
108
+ bootstrapRetryBaseMs: BOOTSTRAP_RETRY_BASE_MS,
109
+ streamRecoveryBaseMs: STREAM_RECOVERY_BASE_MS
110
+ });
111
+ const feedFilters = surfaceViewState.loadFilters();
112
+ const uiState = {
113
+ booting: true,
114
+ controlling: false,
115
+ loadingChanges: false,
116
+ openingDesktop: false,
117
+ refreshing: false,
118
+ selecting: false,
119
+ submittingAction: false
120
+ };
121
+
122
+ const nodes = {
123
+ actionButtons: document.querySelector("#host-action-buttons"),
124
+ actionCancel: document.querySelector("#host-action-cancel"),
125
+ actionCard: document.querySelector("#approval-card"),
126
+ actionForm: document.querySelector("#host-action-form"),
127
+ actionKind: document.querySelector("#host-action-kind"),
128
+ actionPanel: document.querySelector("#host-approval-panel"),
129
+ actionQuestions: document.querySelector("#host-action-questions"),
130
+ approveSessionButton: document.querySelector("#host-approve-session-button"),
131
+ actionSubmit: document.querySelector("#host-action-submit"),
132
+ actionTitle: document.querySelector("#host-action-title"),
133
+ approveButton: document.querySelector("#host-approve-button"),
134
+ assistantForm: document.querySelector("#assistant-form"),
135
+ assistantText: document.querySelector("#assistant-text"),
136
+ commandLog: document.querySelector("#command-log"),
137
+ declineButton: document.querySelector("#host-decline-button"),
138
+ feed: document.querySelector("#transcript"),
139
+ filterButtons: Array.from(document.querySelectorAll("[data-filter]")),
140
+ hostControlIndicator: document.querySelector("#host-control-indicator"),
141
+ hostDesktopSyncNote: document.querySelector("#host-desktop-sync-note"),
142
+ hostDebugPanel: document.querySelector("#host-debug-panel"),
143
+ hostOpenInCodexButton: document.querySelector("#host-open-in-codex-button"),
144
+ hostOperatorDiagnostics: document.querySelector("#host-operator-diagnostics"),
145
+ hostPath: document.querySelector("#host-path"),
146
+ hostProjectSelect: document.querySelector("#host-project-select"),
147
+ hostRefreshButton: document.querySelector("#host-refresh-button"),
148
+ hostReleaseControlButton: document.querySelector("#host-release-control-button"),
149
+ hostSessionSelect: document.querySelector("#host-session-select"),
150
+ marquee: document.querySelector("#host-marquee"),
151
+ sessionRoster: document.querySelector("#session-roster"),
152
+ sessionSummary: document.querySelector("#session-summary"),
153
+ sessionTitle: document.querySelector("#session-title"),
154
+ sessionTopic: document.querySelector("#session-topic"),
155
+ uiStatus: document.querySelector("#host-ui-status")
156
+ };
157
+
158
+ const marqueeTicker = startTicker(nodes.marquee, [
159
+ "subscribing to selected thread...",
160
+ "watching approvals and turn events...",
161
+ "mirroring remote companion state..."
162
+ ]);
163
+
164
+ function shortPathLabel(value) {
165
+ const parts = String(value || "")
166
+ .split("/")
167
+ .filter(Boolean);
168
+
169
+ return parts.at(-1) || value || "";
170
+ }
171
+
172
+ function markLiveActivity() {
173
+ lastLiveActivityAt = Date.now();
174
+ }
175
+
176
+ function settleSelectionIntent() {
177
+ if (!selectionIntent) {
178
+ return false;
179
+ }
180
+
181
+ const result = reconcileSelectionIntent(selectionIntent, currentLiveState);
182
+ selectionIntent = result.intent;
183
+ if (result.settled) {
184
+ uiState.selecting = false;
185
+ }
186
+ return result.settled;
187
+ }
188
+
189
+ function markUserIntent() {
190
+ lastUserIntentAt = Date.now();
191
+ }
192
+
193
+ function currentThreadId() {
194
+ return currentLiveState?.selectedThreadSnapshot?.thread?.id || currentLiveState?.selectedThreadId || "";
195
+ }
196
+
197
+ function hostEngaged() {
198
+ return Boolean(
199
+ currentLiveState?.pendingInteraction ||
200
+ Date.now() - lastUserIntentAt <= HOST_INTENT_ACTIVE_WINDOW_MS
201
+ );
202
+ }
203
+
204
+ function buildPresencePayload() {
205
+ const threadId = currentThreadId();
206
+ if (!threadId) {
207
+ return null;
208
+ }
209
+
210
+ return {
211
+ clientId: surfaceAuthClientId,
212
+ engaged: hostEngaged(),
213
+ focused: document.hasFocus(),
214
+ surface: "host",
215
+ threadId,
216
+ visible: document.visibilityState === "visible"
217
+ };
218
+ }
219
+
220
+ function localSurfaceAttachment() {
221
+ const payload = buildPresencePayload();
222
+ if (!payload) {
223
+ return null;
224
+ }
225
+
226
+ return {
227
+ count: 1,
228
+ label: "host",
229
+ state: payload.visible && payload.focused && payload.engaged ? "active" : payload.visible ? "open" : "background",
230
+ surface: "host"
231
+ };
232
+ }
233
+
234
+ function sendDetachPresence() {
235
+ const threadId = currentThreadId() || currentLiveState?.selectedThreadId || "";
236
+ if (!threadId) {
237
+ return;
238
+ }
239
+
240
+ const payload = JSON.stringify({
241
+ clientId: surfaceAuthClientId,
242
+ detach: true,
243
+ surface: "host",
244
+ threadId
245
+ });
246
+
247
+ if (navigator.sendBeacon) {
248
+ navigator.sendBeacon(
249
+ withSurfaceTokenUrl(presenceUrl, surfaceBootstrap.accessToken),
250
+ new Blob([payload], { type: "application/json" })
251
+ );
252
+ return;
253
+ }
254
+
255
+ void fetch(presenceUrl, {
256
+ method: "POST",
257
+ headers: { "Content-Type": "application/json" },
258
+ body: payload,
259
+ keepalive: true
260
+ }).catch(() => {});
261
+ }
262
+
263
+ async function syncPresence({ force = false } = {}) {
264
+ if (!force && bridgeState.streamState !== "live") {
265
+ return null;
266
+ }
267
+
268
+ const payload = buildPresencePayload();
269
+ if (!payload) {
270
+ return null;
271
+ }
272
+
273
+ const signature = JSON.stringify(payload);
274
+ if (!force && signature === lastPresenceSignature && Date.now() - lastPresenceSyncAt < PRESENCE_HEARTBEAT_INTERVAL_MS - 500) {
275
+ return null;
276
+ }
277
+
278
+ if (presenceSyncPromise) {
279
+ return presenceSyncPromise;
280
+ }
281
+
282
+ presenceSyncPromise = requestJson(presenceUrl, {
283
+ method: "POST",
284
+ headers: { "Content-Type": "application/json" },
285
+ body: JSON.stringify(payload)
286
+ })
287
+ .then((response) => {
288
+ lastPresenceSignature = signature;
289
+ lastPresenceSyncAt = Date.now();
290
+ return response;
291
+ })
292
+ .catch(() => null)
293
+ .finally(() => {
294
+ presenceSyncPromise = null;
295
+ });
296
+
297
+ return presenceSyncPromise;
298
+ }
299
+
300
+ function schedulePresenceSync(delayMs = 140, { force = false } = {}) {
301
+ if (presenceSyncTimer) {
302
+ window.clearTimeout(presenceSyncTimer);
303
+ }
304
+
305
+ presenceSyncTimer = window.setTimeout(() => {
306
+ presenceSyncTimer = null;
307
+ void syncPresence({ force });
308
+ }, delayMs);
309
+ }
310
+
311
+ function setUiStatus(message = "", tone = "neutral") {
312
+ nodes.uiStatus.textContent = message;
313
+ setPanelHidden(nodes.uiStatus, !message);
314
+ nodes.uiStatus.classList.toggle("is-busy", tone === "busy");
315
+ nodes.uiStatus.classList.toggle("is-error", tone === "error");
316
+ nodes.uiStatus.classList.toggle("is-success", tone === "success");
317
+ }
318
+
319
+ function setTransientUiNotice(message, tone = "neutral", delayMs = 3200) {
320
+ transientUiNotice = { message, tone };
321
+ if (transientUiNoticeTimer) {
322
+ window.clearTimeout(transientUiNoticeTimer);
323
+ }
324
+ transientUiNoticeTimer = window.setTimeout(() => {
325
+ transientUiNoticeTimer = null;
326
+ transientUiNotice = null;
327
+ render();
328
+ }, delayMs);
329
+ }
330
+
331
+ function uiBusyNotice() {
332
+ const pending = currentLiveState?.pendingInteraction || actionHandoffState || null;
333
+
334
+ if (uiState.booting) {
335
+ return { message: "Connecting to selected thread...", tone: "busy" };
336
+ }
337
+
338
+ if (uiState.selecting) {
339
+ return { message: selectionIntentMessage(selectionIntent, "Switching shared room..."), tone: "busy" };
340
+ }
341
+
342
+ if (uiState.refreshing) {
343
+ return { message: "", tone: "neutral" };
344
+ }
345
+
346
+ if (uiState.openingDesktop) {
347
+ return { message: "Opening thread in Codex...", tone: "busy" };
348
+ }
349
+
350
+ if (uiState.submittingAction) {
351
+ return { message: interactionBusyNotice(pending, uiState.submittingAction), tone: "busy" };
352
+ }
353
+
354
+ if (uiState.controlling) {
355
+ return { message: "Releasing remote control...", tone: "busy" };
356
+ }
357
+
358
+ if (companionActionState) {
359
+ return { message: "Updating advisory reminder...", tone: "busy" };
360
+ }
361
+
362
+ if (uiState.loadingChanges && currentChanges == null) {
363
+ return { message: "Loading files and diffs...", tone: "busy" };
364
+ }
365
+
366
+ return { message: "", tone: "neutral" };
367
+ }
368
+
369
+ function interactionBusyNotice(pending, action) {
370
+ const subject = interactionSubject(pending);
371
+
372
+ switch (action) {
373
+ case "approve":
374
+ return pending?.kind === "permissions" ? `Allowing ${subject}...` : `Approving ${subject}...`;
375
+ case "session":
376
+ return pending?.kind === "permissions" ? `Allowing ${subject} for session...` : `Approving ${subject} for session...`;
377
+ case "decline":
378
+ return `Declining ${subject}...`;
379
+ case "cancel":
380
+ return `Cancelling ${subject}...`;
381
+ case "submit":
382
+ default:
383
+ return `Submitting ${subject}...`;
384
+ }
385
+ }
386
+
387
+ function interactionSubject(pending) {
388
+ const raw = String(pending?.summary || pending?.kindLabel || pending?.kind || "request").trim();
389
+ const normalized = raw.replace(/\s+approval$/i, "").trim();
390
+ return normalized || "request";
391
+ }
392
+
393
+ function interactionActionSummary(pending, action = "approve") {
394
+ const subject = interactionSubject(pending);
395
+
396
+ switch (action) {
397
+ case "decline":
398
+ return `Declined ${subject}.`;
399
+ case "cancel":
400
+ return pending?.actionKind === "user_input" ? `Cancelled ${subject}.` : `Declined ${subject}.`;
401
+ case "session":
402
+ return pending?.kind === "permissions" ? `Allowed ${subject} for session.` : `Approved ${subject} for session.`;
403
+ case "submit":
404
+ return `Submitted ${subject}.`;
405
+ case "approve":
406
+ default:
407
+ return pending?.kind === "permissions" ? `Allowed ${subject}.` : `Approved ${subject}.`;
408
+ }
409
+ }
410
+
411
+ function clearActionHandoff({ renderNow = true } = {}) {
412
+ if (actionHandoffTimer) {
413
+ window.clearTimeout(actionHandoffTimer);
414
+ actionHandoffTimer = null;
415
+ }
416
+
417
+ if (!actionHandoffState) {
418
+ return;
419
+ }
420
+
421
+ actionHandoffState = null;
422
+ if (renderNow) {
423
+ render();
424
+ }
425
+ }
426
+
427
+ function beginActionHandoff(previousPending, nextState = currentLiveState, action = "approve") {
428
+ clearActionHandoff({ renderNow: false });
429
+
430
+ if (!previousPending) {
431
+ return;
432
+ }
433
+
434
+ const liveThread = nextState?.selectedThreadSnapshot?.thread || null;
435
+ const busy = nextState?.status?.writeLock?.status || liveThread?.activeTurnId;
436
+ if (!busy) {
437
+ return;
438
+ }
439
+
440
+ actionHandoffState = {
441
+ actionKind: "handoff",
442
+ detail: `${interactionActionSummary(previousPending, action)} ${
443
+ previousPending.flowStep > 1 ? "Waiting for the next request in this turn..." : "Waiting for Codex to continue..."
444
+ }`.trim(),
445
+ flowContinuation: previousPending.summary ? `Last request: ${previousPending.summary}.` : previousPending.flowContinuation || "",
446
+ flowLabel: previousPending.flowLabel || "",
447
+ handoff: true,
448
+ kindLabel: "Waiting",
449
+ title: previousPending.actionKind === "user_input" ? "Input received" : "Decision received"
450
+ };
451
+
452
+ actionHandoffTimer = window.setTimeout(() => {
453
+ actionHandoffTimer = null;
454
+ actionHandoffState = null;
455
+ render();
456
+ }, 1800);
457
+ }
458
+
459
+ function resetCardHistoryIfNeeded() {
460
+ const threadId = currentLiveState?.selectedThreadSnapshot?.thread?.id || null;
461
+ if (threadId === renderedThreadId) {
462
+ return;
463
+ }
464
+
465
+ renderedThreadId = threadId;
466
+ seenCardKeys.clear();
467
+ seenCommandKeys.clear();
468
+ expandedEntryKeys.clear();
469
+ expandedFeedSections.clear();
470
+ for (const key of surfaceViewState.loadExpandedSections(threadId || "none")) {
471
+ expandedFeedSections.add(key);
472
+ }
473
+ hasRenderedOnce = false;
474
+ }
475
+
476
+ function isNewFeedCard(entry) {
477
+ const key = entryKey(entry);
478
+ if (!hasRenderedOnce) {
479
+ seenCardKeys.add(key);
480
+ return false;
481
+ }
482
+
483
+ if (seenCardKeys.has(key)) {
484
+ return false;
485
+ }
486
+
487
+ seenCardKeys.add(key);
488
+ return true;
489
+ }
490
+
491
+ function commandEntryKey(entry) {
492
+ return [entry.id || "", entry.type || "", entry.timestamp || "", entry.summary || ""].join("|");
493
+ }
494
+
495
+ function isNewCommandCard(entry) {
496
+ const key = commandEntryKey(entry);
497
+ if (!hasRenderedOnce) {
498
+ seenCommandKeys.add(key);
499
+ return false;
500
+ }
501
+
502
+ if (seenCommandKeys.has(key)) {
503
+ return false;
504
+ }
505
+
506
+ seenCommandKeys.add(key);
507
+ return true;
508
+ }
509
+
510
+ function buildEntries() {
511
+ const transcript = currentSurfaceTranscript({
512
+ bootstrapSnapshot: currentSnapshot,
513
+ liveState: currentLiveState
514
+ });
515
+ const companionWakeups = currentLiveState?.selectedCompanion?.wakeups || [];
516
+ return [...companionWakeups, ...transcript]
517
+ .filter((entry) => !shouldHideTranscriptEntry(entry))
518
+ .slice()
519
+ .sort((a, b) => new Date(b.timestamp || 0).getTime() - new Date(a.timestamp || 0).getTime());
520
+ }
521
+
522
+ function currentAgentRoom() {
523
+ return currentLiveState?.selectedAgentRoom || {
524
+ enabled: false,
525
+ memberIds: [],
526
+ messages: [],
527
+ participants: [],
528
+ round: null,
529
+ threadId: currentThreadId() || null,
530
+ updatedAt: null
531
+ };
532
+ }
533
+
534
+ function councilEntries() {
535
+ return currentAgentRoom().messages || [];
536
+ }
537
+
538
+ function councilSummaryText() {
539
+ const roomState = currentAgentRoom();
540
+ if (!roomState.enabled) {
541
+ return "off";
542
+ }
543
+
544
+ if (roomState.round?.status === "running") {
545
+ const pendingCount = Number(roomState.round.pendingCount || roomState.round.pendingParticipantIds?.length || 0);
546
+ return pendingCount > 0 ? `${pendingCount} thinking` : "running";
547
+ }
548
+
549
+ if (roomState.round?.status === "partial") {
550
+ const completedCount = Number(roomState.round.completedCount || roomState.round.completedParticipantIds?.length || 0);
551
+ const failedCount = Number(roomState.round.failedCount || roomState.round.failedParticipantIds?.length || 0);
552
+ return `${completedCount} replied / ${failedCount} failed`;
553
+ }
554
+
555
+ if (roomState.round?.status === "complete") {
556
+ const completedCount = Number(roomState.round.completedCount || roomState.round.completedParticipantIds?.length || 0);
557
+ return completedCount > 0 ? `${completedCount} replied` : "complete";
558
+ }
559
+
560
+ const messageCount = Array.isArray(roomState.messages) ? roomState.messages.length : 0;
561
+ return messageCount === 1 ? "1 message" : `${messageCount} messages`;
562
+ }
563
+
564
+ function renderFilterButtons(entries) {
565
+ for (const button of nodes.filterButtons) {
566
+ const filter = button.dataset.filter;
567
+ button.classList.toggle("is-active", feedFilters[filter]);
568
+ button.textContent = humanize(filter);
569
+ }
570
+ }
571
+
572
+ function describeControlEvent(event) {
573
+ if (!event?.action) {
574
+ return "";
575
+ }
576
+
577
+ const actorLabel = describeSurfaceActor(event.actor, event.actorClientId, { localSurface: "host" });
578
+
579
+ if (event.action === "claim") {
580
+ return event.actor === "remote" ? `${actorLabel} control active.` : `${actorLabel} claimed control.`;
581
+ }
582
+
583
+ if (event.action === "release") {
584
+ if (event.cause === "expired") {
585
+ return "Remote control expired.";
586
+ }
587
+
588
+ return `${actorLabel} released control.`;
589
+ }
590
+
591
+ return "";
592
+ }
593
+
594
+ function shortSurfaceClientLabel(clientId = "") {
595
+ const normalized = String(clientId || "")
596
+ .toLowerCase()
597
+ .replace(/[^a-z0-9]+/g, "");
598
+ if (!normalized) {
599
+ return "";
600
+ }
601
+
602
+ return normalized.length > 4 ? normalized.slice(-4) : normalized;
603
+ }
604
+
605
+ function describeSurfaceActor(surface, clientId, { localSurface = "" } = {}) {
606
+ const base = surface === "host" ? "Host" : "Remote";
607
+ if (surface === localSurface && clientId === surfaceAuthClientId) {
608
+ return surface === "host" ? "This host" : "This remote";
609
+ }
610
+
611
+ const suffix = shortSurfaceClientLabel(clientId);
612
+ return suffix ? `${base} ${suffix}` : base;
613
+ }
614
+
615
+ function controlOwnerLabel(lease) {
616
+ if (!lease) {
617
+ return "";
618
+ }
619
+
620
+ return describeSurfaceActor(lease.source || lease.owner || "remote", lease.ownerClientId || null, {
621
+ localSurface: "host"
622
+ });
623
+ }
624
+
625
+ function renderSelectors() {
626
+ const threads = currentLiveState?.threads || [];
627
+ const groups = groupThreadsByProject(threads);
628
+ const selectedProject = currentLiveState?.selectedProjectCwd || "";
629
+ const selectedThread = currentLiveState?.selectedThreadId || "";
630
+
631
+ if (groups.length === 0) {
632
+ populateSelect(nodes.hostProjectSelect, [{ value: "", label: "No projects" }], "");
633
+ populateSelect(nodes.hostSessionSelect, [{ value: "", label: "No sessions" }], "");
634
+ nodes.hostProjectSelect.disabled = true;
635
+ nodes.hostSessionSelect.disabled = true;
636
+ return;
637
+ }
638
+
639
+ populateSelect(
640
+ nodes.hostProjectSelect,
641
+ groups.map((group) => ({ value: group.cwd, label: group.label })),
642
+ selectedProject
643
+ );
644
+ nodes.hostProjectSelect.disabled = uiState.selecting || uiState.refreshing || uiState.booting;
645
+
646
+ const currentGroup = groups.find((group) => group.cwd === selectedProject) || groups[0];
647
+ const selectedInGroup = currentGroup.threads.some((thread) => thread.id === selectedThread)
648
+ ? selectedThread
649
+ : currentGroup.threads[0]?.id || "";
650
+
651
+ populateSelect(
652
+ nodes.hostSessionSelect,
653
+ currentGroup.threads.map((thread) => ({ value: thread.id, label: sessionLabel(thread) })),
654
+ selectedInGroup
655
+ );
656
+ nodes.hostSessionSelect.disabled = uiState.selecting || uiState.refreshing || uiState.booting;
657
+ }
658
+
659
+ function renderStatuses() {
660
+ const status = currentLiveState?.status || {};
661
+ const liveThread = currentLiveState?.selectedThreadSnapshot?.thread || null;
662
+ const selectedChannel = currentLiveState?.selectedChannel || currentLiveState?.selectedThreadSnapshot?.channel || null;
663
+ const selectedAttachments = mergeSurfaceAttachments(currentLiveState?.selectedAttachments || [], localSurfaceAttachment());
664
+ const participants = currentLiveState?.participants || currentLiveState?.selectedThreadSnapshot?.participants || [];
665
+ const transcriptHydrating = Boolean(currentLiveState?.selectedThreadSnapshot?.transcriptHydrating);
666
+ const remoteControlActive = Boolean(
667
+ status.controlLeaseForSelection &&
668
+ (status.controlLeaseForSelection.owner === "remote" || status.controlLeaseForSelection.source === "remote")
669
+ );
670
+ const operatorDiagnostics = describeOperatorDiagnostics({
671
+ diagnostics: status.diagnostics || [],
672
+ ownsControl: false,
673
+ status,
674
+ surface: "host"
675
+ });
676
+ const threadState = describeThreadState({
677
+ pendingInteraction: currentLiveState?.pendingInteraction,
678
+ status,
679
+ thread: liveThread
680
+ });
681
+ const controllerLabel = controlOwnerLabel(status.controlLeaseForSelection || null);
682
+
683
+ const pendingTitle = uiState.selecting ? selectionIntentTitle(selectionIntent) : "";
684
+
685
+ nodes.hostPath.textContent = selectedChannel?.serverLabel
686
+ ? `dextunnel // ${selectedChannel.serverLabel}`
687
+ : liveThread?.cwd
688
+ ? `dextunnel // ${projectLabel(liveThread.cwd)}`
689
+ : "dextunnel // host";
690
+ nodes.sessionTitle.textContent = pendingTitle || selectedChannel?.channelSlug || liveThread?.name || currentSnapshot?.session?.title || "#loading";
691
+ nodes.hostDesktopSyncNote.textContent = describeDesktopSyncNote({
692
+ hasSelectedThread: Boolean(liveThread?.id),
693
+ status
694
+ });
695
+ if (operatorDiagnostics.length > 0) {
696
+ const diagnosticsHtml = operatorDiagnostics
697
+ .slice(0, 2)
698
+ .map((entry) => {
699
+ const toneClass = entry.severity === "warn" ? "is-warn" : "is-info";
700
+ const title = entry.detail ? `${entry.title} ${entry.detail}` : entry.title;
701
+ return `<span class="diagnostic-chip ${toneClass}" title="${escapeHtml(title)}">${escapeHtml(entry.label)}</span>`;
702
+ })
703
+ .join("");
704
+ setHtmlIfChanged(
705
+ nodes.hostOperatorDiagnostics,
706
+ diagnosticsHtml,
707
+ `host-diagnostics:${operatorDiagnostics.map((entry) => `${entry.code}:${entry.label}`).join("|")}`
708
+ );
709
+ setPanelHidden(nodes.hostOperatorDiagnostics, false);
710
+ } else {
711
+ setHtmlIfChanged(nodes.hostOperatorDiagnostics, "", "host-diagnostics:empty");
712
+ setPanelHidden(nodes.hostOperatorDiagnostics, true);
713
+ }
714
+ nodes.sessionSummary.textContent = liveThread?.id
715
+ ? [
716
+ "local admin surface",
717
+ remoteControlActive && controllerLabel ? `${controllerLabel.toLowerCase()} controlling` : "remote idle",
718
+ threadState !== "ready" ? threadState : "mirror ready"
719
+ ]
720
+ .filter(Boolean)
721
+ .join(" // ")
722
+ : "Local status and approvals for the selected room.";
723
+ nodes.sessionTopic.textContent = "";
724
+ setPanelHidden(nodes.sessionTopic, true);
725
+ setHtmlIfChanged(nodes.sessionRoster, "", "participant-roster:host-hidden");
726
+ setPanelHidden(nodes.sessionRoster, true);
727
+
728
+ let bridgeStatusLine = "Starting session bridge...";
729
+ const busyNotice = uiBusyNotice();
730
+
731
+ if (bridgeState.streamState !== "live") {
732
+ bridgeStatusLine = currentLiveState ? "Reconnecting session bridge..." : "Connecting to session bridge...";
733
+ } else if (transcriptHydrating) {
734
+ bridgeStatusLine = "Loading more from the selected room...";
735
+ } else if (status.watcherConnected) {
736
+ const liveBits = [];
737
+ if (remoteControlActive) {
738
+ liveBits.push(controllerLabel ? `${controllerLabel} control active` : "Remote control active");
739
+ }
740
+ if (threadState !== "ready") {
741
+ liveBits.push(threadState);
742
+ }
743
+ bridgeStatusLine = liveBits.join(" // ") || "Session bridge online";
744
+ } else if (status.lastError) {
745
+ bridgeStatusLine = "Session bridge offline";
746
+ }
747
+
748
+ if (busyNotice.message) {
749
+ bridgeStatusLine = busyNotice.message;
750
+ } else if (transientUiNotice?.message) {
751
+ bridgeStatusLine = transientUiNotice.message;
752
+ }
753
+
754
+ marqueeTicker.setText(bridgeStatusLine);
755
+ if (transientUiNotice?.message) {
756
+ setUiStatus(transientUiNotice.message, transientUiNotice.tone);
757
+ } else {
758
+ setUiStatus("", "neutral");
759
+ }
760
+ setPanelHidden(nodes.hostControlIndicator, !remoteControlActive);
761
+ nodes.hostControlIndicator.textContent = remoteControlActive
762
+ ? controllerLabel
763
+ ? `${controllerLabel} control active`
764
+ : "Remote control active"
765
+ : "";
766
+ nodes.hostRefreshButton.disabled =
767
+ uiState.refreshing ||
768
+ uiState.selecting ||
769
+ uiState.booting;
770
+ nodes.hostRefreshButton.textContent = uiState.refreshing ? "Refreshing..." : "Refresh";
771
+ nodes.hostRefreshButton.classList.toggle("is-busy", uiState.refreshing);
772
+ nodes.hostOpenInCodexButton.disabled =
773
+ !liveThread?.id ||
774
+ uiState.openingDesktop ||
775
+ uiState.selecting ||
776
+ uiState.booting;
777
+ nodes.hostOpenInCodexButton.textContent = uiState.openingDesktop ? "Revealing..." : "Reveal in Codex";
778
+ nodes.hostOpenInCodexButton.title = "Reveal this thread in the Codex app. Quit and reopen the app manually to see new messages generated here.";
779
+ nodes.hostOpenInCodexButton.classList.toggle("is-busy", uiState.openingDesktop);
780
+ setPanelHidden(nodes.hostReleaseControlButton, !remoteControlActive);
781
+ nodes.hostReleaseControlButton.disabled = !remoteControlActive || uiState.refreshing || uiState.selecting || uiState.booting || uiState.controlling;
782
+ nodes.hostReleaseControlButton.textContent = uiState.controlling ? "Releasing..." : "Release remote";
783
+ nodes.hostReleaseControlButton.classList.toggle("is-busy", uiState.controlling);
784
+ setPanelHidden(nodes.hostDebugPanel, !status.devToolsEnabled);
785
+ }
786
+
787
+ function formatActionDetail(pending) {
788
+ const parts = [];
789
+
790
+ if (pending.flowLabel || pending.flowContinuation) {
791
+ parts.push(`
792
+ <div class="interaction-flow">
793
+ ${pending.flowLabel ? `<p class="interaction-flow-label">${escapeHtml(pending.flowLabel)}</p>` : ""}
794
+ ${pending.flowContinuation ? `<p class="interaction-flow-copy">${escapeHtml(pending.flowContinuation)}</p>` : ""}
795
+ </div>
796
+ `);
797
+ }
798
+
799
+ if (pending.summary) {
800
+ parts.push(`<p class="question-help">Now: ${escapeHtml(pending.summary)}</p>`);
801
+ }
802
+
803
+ if (pending.detail) {
804
+ parts.push(`<p>${escapeHtml(pending.detail)}</p>`);
805
+ }
806
+
807
+ if (pending.command) {
808
+ parts.push(`<pre class="command-preview">${escapeHtml(pending.command)}</pre>`);
809
+ }
810
+
811
+ if (pending.cwd) {
812
+ parts.push(`<p class="question-help">${escapeHtml(pending.cwd)}</p>`);
813
+ }
814
+
815
+ if (pending.permissions) {
816
+ parts.push(`<pre class="command-preview">${escapeHtml(JSON.stringify(pending.permissions, null, 2))}</pre>`);
817
+ }
818
+
819
+ return parts.join("");
820
+ }
821
+
822
+ function renderQuestions(questions, requestId = "") {
823
+ const html = questions
824
+ .map((question) => {
825
+ const options = question.options || [];
826
+ const baseControl = options.length
827
+ ? `
828
+ <select data-answer-id="${escapeHtml(question.id)}">
829
+ <option value="">Select</option>
830
+ ${options
831
+ .map((option) => `<option value="${escapeHtml(option.label)}">${escapeHtml(option.label)}</option>`)
832
+ .join("")}
833
+ ${question.isOther ? '<option value="__other__">Other</option>' : ""}
834
+ </select>
835
+ `
836
+ : `
837
+ <input
838
+ type="${question.isSecret ? "password" : "text"}"
839
+ data-answer-id="${escapeHtml(question.id)}"
840
+ placeholder="${escapeHtml(question.header || question.question)}"
841
+ >
842
+ `;
843
+
844
+ const otherControl = question.isOther
845
+ ? `
846
+ <input
847
+ type="${question.isSecret ? "password" : "text"}"
848
+ data-answer-other="${escapeHtml(question.id)}"
849
+ placeholder="Other"
850
+ >
851
+ `
852
+ : "";
853
+
854
+ return `
855
+ <label class="question-field">
856
+ <span>${escapeHtml(question.header || question.id)}</span>
857
+ <span class="question-help">${escapeHtml(question.question)}</span>
858
+ ${baseControl}
859
+ ${otherControl}
860
+ </label>
861
+ `;
862
+ })
863
+ .join("");
864
+ const signature = JSON.stringify(
865
+ (questions || []).map((question) => ({
866
+ header: question.header,
867
+ id: question.id,
868
+ isOther: Boolean(question.isOther),
869
+ isSecret: Boolean(question.isSecret),
870
+ options: (question.options || []).map((option) => option.label),
871
+ question: question.question
872
+ }))
873
+ );
874
+ setHtmlIfChanged(nodes.actionQuestions, html, `questions:${requestId}:${signature}`);
875
+ }
876
+
877
+ function renderActionPanel() {
878
+ const pending = currentLiveState?.pendingInteraction || actionHandoffState || null;
879
+
880
+ if (!pending) {
881
+ setPanelHidden(nodes.actionPanel, true);
882
+ nodes.actionPanel.classList.remove("panel-pop");
883
+ setPanelHidden(nodes.actionForm, true);
884
+ setPanelHidden(nodes.actionButtons, false);
885
+ setPanelHidden(nodes.approveSessionButton, true);
886
+ clearHtmlRenderState(nodes.actionQuestions);
887
+ return;
888
+ }
889
+
890
+ setPanelHidden(nodes.actionPanel, false);
891
+ nodes.actionPanel.classList.add("panel-pop");
892
+ nodes.actionTitle.textContent = pending.title || "Action required";
893
+ nodes.actionKind.textContent = pending.kindLabel || (pending.actionKind === "user_input" ? "Input" : pending.handoff ? "Waiting" : "Approval");
894
+ nodes.actionCard.innerHTML = formatActionDetail(pending);
895
+
896
+ if (pending.handoff) {
897
+ setPanelHidden(nodes.actionForm, true);
898
+ setPanelHidden(nodes.actionButtons, true);
899
+ setPanelHidden(nodes.approveSessionButton, true);
900
+ clearHtmlRenderState(nodes.actionQuestions);
901
+ return;
902
+ }
903
+
904
+ const isUserInput = pending.actionKind === "user_input";
905
+ setPanelHidden(nodes.actionForm, !isUserInput);
906
+ setPanelHidden(nodes.actionButtons, isUserInput);
907
+
908
+ if (isUserInput) {
909
+ renderQuestions(pending.questions || [], pending.requestId || "pending");
910
+ nodes.actionSubmit.textContent = uiState.submittingAction === "submit" ? "Submitting..." : pending.submitLabel || "Submit";
911
+ nodes.actionCancel.textContent = uiState.submittingAction === "cancel" ? "Cancelling..." : "Cancel";
912
+ } else {
913
+ clearHtmlRenderState(nodes.actionQuestions);
914
+ nodes.approveButton.textContent =
915
+ uiState.submittingAction === "approve"
916
+ ? pending.kind === "permissions"
917
+ ? "Allowing..."
918
+ : "Approving..."
919
+ : pending.approveLabel || (pending.kind === "permissions" ? "Allow turn" : "Approve");
920
+ nodes.declineButton.textContent = uiState.submittingAction === "decline" ? "Declining..." : pending.declineLabel || "Decline";
921
+ nodes.approveSessionButton.textContent =
922
+ uiState.submittingAction === "session"
923
+ ? pending.kind === "permissions"
924
+ ? "Allowing..."
925
+ : "Approving..."
926
+ : pending.sessionActionLabel || "Approve for session";
927
+ setPanelHidden(nodes.approveSessionButton, !pending.canApproveForSession);
928
+ }
929
+
930
+ nodes.actionSubmit.disabled = uiState.submittingAction;
931
+ nodes.actionCancel.disabled = uiState.submittingAction;
932
+ nodes.approveButton.disabled = uiState.submittingAction;
933
+ nodes.approveSessionButton.disabled = uiState.submittingAction;
934
+ nodes.declineButton.disabled = uiState.submittingAction;
935
+ nodes.actionSubmit.classList.toggle("is-busy", uiState.submittingAction && isUserInput);
936
+ nodes.approveButton.classList.toggle("is-busy", uiState.submittingAction && !isUserInput);
937
+ nodes.approveSessionButton.classList.toggle("is-busy", uiState.submittingAction && !isUserInput);
938
+ nodes.declineButton.classList.toggle("is-busy", uiState.submittingAction && !isUserInput);
939
+ }
940
+
941
+ function renderFeed() {
942
+ resetCardHistoryIfNeeded();
943
+ const entries = buildEntries();
944
+ renderFilterButtons(entries);
945
+ const changesSection = feedFilters.changes ? renderChangesSection() : null;
946
+ const items = [];
947
+ const roomEntries = councilEntries();
948
+ const threadEntries = entries.filter((entry) => isConversationEntry(entry) && !isAdvisoryEntry(entry));
949
+ const advisoryEntries = entries.filter((entry) => isAdvisoryEntry(entry));
950
+ const toolEntries = entries.filter((entry) => entry.role === "tool");
951
+ const updateEntries = entries.filter(
952
+ (entry) =>
953
+ !isSystemNoticeEntry(entry) &&
954
+ !isConversationEntry(entry) &&
955
+ !isAdvisoryEntry(entry) &&
956
+ entry.role !== "tool"
957
+ );
958
+
959
+ if (changesSection) {
960
+ items.push({
961
+ html: changesSection.html,
962
+ key: "__changes__",
963
+ signature: changesSection.signature
964
+ });
965
+ }
966
+
967
+ if (feedFilters.thread) {
968
+ items.push(...renderFeedItems(threadEntries));
969
+ }
970
+
971
+ if (feedFilters.council) {
972
+ const section = renderSupplementalSection("council", {
973
+ entries: roomEntries,
974
+ kicker: "Council",
975
+ summary: councilSummaryText(),
976
+ title: "Optional advisory room"
977
+ });
978
+ if (section) {
979
+ items.push(section);
980
+ }
981
+ }
982
+
983
+ if (feedFilters.advisories) {
984
+ const section = renderSupplementalSection("advisories", {
985
+ entries: advisoryEntries,
986
+ kicker: "Advisories",
987
+ summary: advisoryEntries.length === 1 ? "1 item" : `${advisoryEntries.length} items`,
988
+ title: "Recaps and reviews"
989
+ });
990
+ if (section) {
991
+ items.push(section);
992
+ }
993
+ }
994
+
995
+ if (feedFilters.updates) {
996
+ const section = renderSupplementalSection("updates", {
997
+ entries: updateEntries,
998
+ kicker: "Updates",
999
+ summary: updateEntries.length === 1 ? "1 item" : `${updateEntries.length} items`,
1000
+ title: "Operational notes"
1001
+ });
1002
+ if (section) {
1003
+ items.push(section);
1004
+ }
1005
+ }
1006
+
1007
+ if (feedFilters.tools) {
1008
+ const section = renderSupplementalSection("tools", {
1009
+ entries: toolEntries,
1010
+ kicker: "Tools",
1011
+ summary: toolEntries.length === 1 ? "1 item" : `${toolEntries.length} items`,
1012
+ title: "Tool output"
1013
+ });
1014
+ if (section) {
1015
+ items.push(section);
1016
+ }
1017
+ }
1018
+
1019
+ if (!items.length) {
1020
+ items.push({
1021
+ html: '<div class="empty-card">No matching items.</div>',
1022
+ key: "__empty__",
1023
+ signature: "empty"
1024
+ });
1025
+ }
1026
+
1027
+ reconcileRenderedList(nodes.feed, items);
1028
+
1029
+ hasRenderedOnce = true;
1030
+ }
1031
+
1032
+ function summarizeInteraction(entry) {
1033
+ if (!entry) {
1034
+ return "";
1035
+ }
1036
+
1037
+ const requestLabel = String(entry.summary || humanize(entry.kind || "interaction") || "interaction").trim();
1038
+ const progressionSuffix = entry.flowStep > 1 ? ` (step ${entry.flowStep})` : "";
1039
+ if (entry.status === "pending") {
1040
+ return `Waiting on ${requestLabel}${progressionSuffix}.`;
1041
+ }
1042
+
1043
+ if (entry.status === "responded") {
1044
+ if (entry.action === "decline" || entry.action === "cancel") {
1045
+ return `Sent ${entry.action} for ${requestLabel}${progressionSuffix}.`;
1046
+ }
1047
+
1048
+ if (entry.action === "session") {
1049
+ return `Allowed ${requestLabel}${progressionSuffix} for the session.`;
1050
+ }
1051
+
1052
+ if (entry.action === "submit") {
1053
+ return `Submitted ${requestLabel}${progressionSuffix}.`;
1054
+ }
1055
+
1056
+ return `Approved ${requestLabel}${progressionSuffix}.`;
1057
+ }
1058
+
1059
+ if (entry.status === "cleared") {
1060
+ return `${requestLabel} cleared${progressionSuffix}.`;
1061
+ }
1062
+
1063
+ if (entry.status === "resolved") {
1064
+ return `${requestLabel} settled${progressionSuffix}.`;
1065
+ }
1066
+
1067
+ if (entry.action === "decline") {
1068
+ return `Declined ${requestLabel}${progressionSuffix}.`;
1069
+ }
1070
+
1071
+ if (entry.action === "session") {
1072
+ return `Allowed ${requestLabel}${progressionSuffix} for the current session.`;
1073
+ }
1074
+
1075
+ if (entry.action === "submit") {
1076
+ return `Submitted ${requestLabel}${progressionSuffix}.`;
1077
+ }
1078
+
1079
+ return `Approved ${requestLabel}${progressionSuffix}.`;
1080
+ }
1081
+
1082
+ function buildLiveInteractionLogEntry() {
1083
+ const interaction = currentLiveState?.status?.lastInteractionForSelection || null;
1084
+ if (!interaction) {
1085
+ return null;
1086
+ }
1087
+
1088
+ return {
1089
+ id: `live-interaction-${interaction.kind || "interaction"}-${interaction.at || "unknown"}-${interaction.action || interaction.status || "event"}`,
1090
+ source: interaction.source || "app-server",
1091
+ summary: summarizeInteraction(interaction),
1092
+ timestamp: interaction.at || null,
1093
+ type: interaction.kind || "interaction"
1094
+ };
1095
+ }
1096
+
1097
+ function renderCommandLog() {
1098
+ const commandLog = currentSnapshot?.commandLog?.slice().reverse() || [];
1099
+ const liveInteraction = buildLiveInteractionLogEntry();
1100
+ const entries = liveInteraction ? [liveInteraction, ...commandLog] : commandLog;
1101
+
1102
+ const html = entries.length
1103
+ ? entries
1104
+ .map((entry) => {
1105
+ const classes = ["command-card"];
1106
+ if (isNewCommandCard(entry)) {
1107
+ classes.push("card-new");
1108
+ }
1109
+
1110
+ return `
1111
+ <article class="${classes.join(" ")}">
1112
+ <div class="card-head">
1113
+ <span class="card-label">${escapeHtml(humanize(entry.type))}</span>
1114
+ ${entry.timestamp ? `<time class="card-time">${escapeHtml(new Date(entry.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }))}</time>` : ""}
1115
+ </div>
1116
+ <div class="card-note">${escapeHtml(entry.source || "mock-adapter")}</div>
1117
+ <p>${escapeHtml(entry.summary || "")}</p>
1118
+ </article>
1119
+ `;
1120
+ })
1121
+ .join("")
1122
+ : '<div class="empty-card">No log events.</div>';
1123
+ const signature = JSON.stringify(entries.map((entry) => commandEntryKey(entry)));
1124
+ setHtmlIfChanged(nodes.commandLog, html, `command-log:${signature}`);
1125
+ }
1126
+
1127
+ function changesSummaryText() {
1128
+ if (currentChanges == null) {
1129
+ return uiState.loadingChanges ? "Loading changes..." : "No repo diff";
1130
+ }
1131
+
1132
+ if (!currentChanges.supported) {
1133
+ return "No repo diff";
1134
+ }
1135
+
1136
+ const count = currentChanges.shownCount || currentChanges.items?.length || currentChanges.totalCount || 0;
1137
+ const hiddenLabel = currentChanges.hiddenCount ? ` // +${currentChanges.hiddenCount} more` : "";
1138
+ const sourceLabel =
1139
+ currentChanges.source === "live_turn"
1140
+ ? " // live turn"
1141
+ : currentChanges.source === "git_session"
1142
+ ? " // session focus"
1143
+ : "";
1144
+ const focusLabel = currentChanges.focusPaths?.length
1145
+ ? ` // focus ${shortPathLabel(currentChanges.focusPaths[0])}${currentChanges.focusPaths.length > 1 ? ` +${currentChanges.focusPaths.length - 1}` : ""}`
1146
+ : "";
1147
+
1148
+ return `${count === 1 ? "1 file" : `${count} files`}${hiddenLabel}${sourceLabel}${focusLabel}${uiState.loadingChanges ? " // syncing" : ""}`;
1149
+ }
1150
+
1151
+ function currentThreadIdLabel() {
1152
+ return currentLiveState?.selectedThreadId || "";
1153
+ }
1154
+
1155
+ function feedSectionKey(name) {
1156
+ return `${name}:${currentThreadIdLabel() || "none"}`;
1157
+ }
1158
+
1159
+ function changesSectionKey() {
1160
+ return feedSectionKey("changes");
1161
+ }
1162
+
1163
+ function renderChangesSection() {
1164
+ const summary = changesSummaryText();
1165
+ const open = expandedFeedSections.has(changesSectionKey());
1166
+
1167
+ if (currentChanges == null) {
1168
+ return {
1169
+ html: `
1170
+ <details class="feed-section feed-section-changes feed-section-details" data-feed-section="changes"${open ? " open" : ""}>
1171
+ <summary class="feed-section-summary">
1172
+ <div>
1173
+ <p class="feed-section-kicker">Changes</p>
1174
+ <h3>Files and diffs</h3>
1175
+ </div>
1176
+ <span class="inline-status ${uiState.loadingChanges ? "is-busy" : ""}">${escapeHtml(summary)}</span>
1177
+ </summary>
1178
+ <div class="changes-list">
1179
+ <div class="empty-card">Loading changes for the selected session...</div>
1180
+ </div>
1181
+ </details>
1182
+ `,
1183
+ signature: `changes:loading:${open ? "open" : "closed"}`
1184
+ };
1185
+ }
1186
+
1187
+ if (!currentChanges.supported) {
1188
+ return {
1189
+ html: `
1190
+ <details class="feed-section feed-section-changes feed-section-details" data-feed-section="changes"${open ? " open" : ""}>
1191
+ <summary class="feed-section-summary">
1192
+ <div>
1193
+ <p class="feed-section-kicker">Changes</p>
1194
+ <h3>Files and diffs</h3>
1195
+ </div>
1196
+ <span class="inline-status">${escapeHtml(summary)}</span>
1197
+ </summary>
1198
+ <div class="changes-list">
1199
+ <div class="empty-card">No Git diff available for this project.</div>
1200
+ </div>
1201
+ </details>
1202
+ `,
1203
+ signature: `changes:unsupported:${open ? "open" : "closed"}`
1204
+ };
1205
+ }
1206
+
1207
+ const count = currentChanges.shownCount || currentChanges.items?.length || currentChanges.totalCount || 0;
1208
+ const html = count
1209
+ ? currentChanges.items
1210
+ .map((change, index) => renderChangeCard(change, { open: index === 0 && count <= 3 }))
1211
+ .join("")
1212
+ : `<div class="empty-card">${currentChanges.source === "live_turn" ? "No live diff yet." : "Working tree is clean."}</div>`;
1213
+ const signature = JSON.stringify({
1214
+ count,
1215
+ cwd: currentChanges.cwd || "",
1216
+ source: currentChanges.source || "",
1217
+ focusPaths: currentChanges.focusPaths || [],
1218
+ hiddenCount: currentChanges.hiddenCount || 0,
1219
+ items: (currentChanges.items || []).map((change) => ({
1220
+ additions: change.additions,
1221
+ deletions: change.deletions,
1222
+ diffPreview: change.diffPreview,
1223
+ fromPath: change.fromPath,
1224
+ kind: change.kind,
1225
+ path: change.path,
1226
+ relevance: change.relevance,
1227
+ statusCode: change.statusCode
1228
+ }))
1229
+ });
1230
+
1231
+ return {
1232
+ html: `
1233
+ <details class="feed-section feed-section-changes feed-section-details" data-feed-section="changes"${open ? " open" : ""}>
1234
+ <summary class="feed-section-summary">
1235
+ <div>
1236
+ <p class="feed-section-kicker">Changes</p>
1237
+ <h3>Files and diffs</h3>
1238
+ </div>
1239
+ <span class="inline-status ${uiState.loadingChanges ? "is-busy" : ""}">${escapeHtml(summary)}</span>
1240
+ </summary>
1241
+ <div class="changes-list">${html}</div>
1242
+ </details>
1243
+ `,
1244
+ signature: `changes:${open ? "open" : "closed"}:${signature}`
1245
+ };
1246
+ }
1247
+
1248
+ function renderFeedItems(entries) {
1249
+ return entries.map((entry) => {
1250
+ const isNew = isNewFeedCard(entry);
1251
+ const isExpanded = expandedEntryKeys.has(entryKey(entry));
1252
+ const nextEntry =
1253
+ companionActionState?.key && entry.key === companionActionState.key
1254
+ ? {
1255
+ ...entry,
1256
+ actionState: {
1257
+ action: companionActionState.action,
1258
+ busy: true
1259
+ }
1260
+ }
1261
+ : entry;
1262
+ return {
1263
+ html: renderTranscriptCard(nextEntry, { expanded: isExpanded, isNew }),
1264
+ key: entryKey(entry),
1265
+ signature: JSON.stringify({
1266
+ actionState: nextEntry.actionState || null,
1267
+ expanded: isExpanded,
1268
+ entry: nextEntry
1269
+ })
1270
+ };
1271
+ });
1272
+ }
1273
+
1274
+ function renderSupplementalSection(name, { kicker, title, entries, summary }) {
1275
+ if (!entries.length) {
1276
+ return null;
1277
+ }
1278
+
1279
+ const open = expandedFeedSections.has(feedSectionKey(name));
1280
+ const items = renderFeedItems(entries);
1281
+ return {
1282
+ html: `
1283
+ <details class="feed-section feed-section-details" data-feed-section="${escapeHtml(name)}"${open ? " open" : ""}>
1284
+ <summary class="feed-section-summary">
1285
+ <div>
1286
+ <p class="feed-section-kicker">${escapeHtml(kicker)}</p>
1287
+ <h3>${escapeHtml(title)}</h3>
1288
+ </div>
1289
+ <span class="inline-status">${escapeHtml(summary)}</span>
1290
+ </summary>
1291
+ <div class="feed-list">${items.map((item) => item.html).join("")}</div>
1292
+ </details>
1293
+ `,
1294
+ key: `__section__:${name}`,
1295
+ signature: JSON.stringify({
1296
+ items: items.map((item) => item.signature),
1297
+ name,
1298
+ open
1299
+ })
1300
+ };
1301
+ }
1302
+
1303
+ function shouldImmediatelyRefreshChanges(previousState, nextState) {
1304
+ if (!previousState) {
1305
+ return true;
1306
+ }
1307
+
1308
+ if ((previousState.selectedThreadId || "") !== (nextState?.selectedThreadId || "")) {
1309
+ return true;
1310
+ }
1311
+
1312
+ if ((previousState.turnDiff?.updatedAt || "") !== (nextState?.turnDiff?.updatedAt || "")) {
1313
+ return true;
1314
+ }
1315
+
1316
+ if (
1317
+ (previousState.status?.lastWriteForSelection?.at || "") !==
1318
+ (nextState?.status?.lastWriteForSelection?.at || "")
1319
+ ) {
1320
+ return true;
1321
+ }
1322
+
1323
+ if ((previousState.pendingInteraction?.requestId || "") !== (nextState?.pendingInteraction?.requestId || "")) {
1324
+ return true;
1325
+ }
1326
+
1327
+ if (
1328
+ (previousState.selectedThreadSnapshot?.thread?.activeTurnId || "") !==
1329
+ (nextState?.selectedThreadSnapshot?.thread?.activeTurnId || "")
1330
+ ) {
1331
+ return true;
1332
+ }
1333
+
1334
+ return false;
1335
+ }
1336
+
1337
+ function render() {
1338
+ if (!currentSnapshot && !currentLiveState) {
1339
+ return;
1340
+ }
1341
+
1342
+ renderSelectors();
1343
+ renderStatuses();
1344
+ renderActionPanel();
1345
+ renderFeed();
1346
+ renderCommandLog();
1347
+ }
1348
+
1349
+ async function requestJson(url, options = {}) {
1350
+ const response = await fetch(url, withSurfaceHeaders(options, surfaceBootstrap.accessToken));
1351
+ const payload = await response.json();
1352
+
1353
+ if (!response.ok) {
1354
+ throw createRequestError(payload, response);
1355
+ }
1356
+
1357
+ return payload;
1358
+ }
1359
+
1360
+ function adoptErrorState(error) {
1361
+ const state = error?.state || error?.payload?.state || null;
1362
+ if (!state) {
1363
+ return false;
1364
+ }
1365
+
1366
+ currentLiveState = state;
1367
+ settleSelectionIntent();
1368
+ markLiveActivity();
1369
+ return true;
1370
+ }
1371
+
1372
+ const bridgeLifecycle = createLiveBridgeLifecycle({
1373
+ bootstrapRetry: { baseMs: BOOTSTRAP_RETRY_BASE_MS, maxMs: BOOTSTRAP_RETRY_MAX_MS },
1374
+ createEventSource: (url) => new EventSource(withSurfaceTokenUrl(url, surfaceBootstrap.accessToken)),
1375
+ getHasLiveState: () => Boolean(currentLiveState),
1376
+ getVisible: () => document.visibilityState === "visible",
1377
+ onBootstrapError: ({ error, retrying }) => {
1378
+ if (!currentLiveState) {
1379
+ uiState.booting = true;
1380
+ nodes.sessionSummary.textContent = retrying ? "Waiting for session bridge..." : error.message;
1381
+ setUiStatus(retrying ? "Waiting for session bridge..." : error.message, retrying ? "busy" : "error");
1382
+ }
1383
+ },
1384
+ onBootstrapStart: () => {
1385
+ if (!currentLiveState) {
1386
+ uiState.booting = true;
1387
+ }
1388
+ },
1389
+ onBootstrapSuccess: ({ snapshot, live }) => {
1390
+ currentSnapshot = snapshot;
1391
+ currentLiveState = live;
1392
+ settleSelectionIntent();
1393
+ clearActionHandoff({ renderNow: false });
1394
+ uiState.booting = false;
1395
+ markLiveActivity();
1396
+ scheduleChangesRefresh({ immediate: true, showLoading: true });
1397
+ schedulePresenceSync(20, { force: true });
1398
+ },
1399
+ onLive: (live) => {
1400
+ const previousState = currentLiveState;
1401
+ currentLiveState = live;
1402
+ settleSelectionIntent();
1403
+ if (currentLiveState?.pendingInteraction || !currentLiveState?.selectedThreadSnapshot?.thread?.activeTurnId) {
1404
+ clearActionHandoff({ renderNow: false });
1405
+ }
1406
+ markLiveActivity();
1407
+ scheduleChangesRefresh({ immediate: shouldImmediatelyRefreshChanges(previousState, currentLiveState) });
1408
+ schedulePresenceSync(90);
1409
+ },
1410
+ onRender: render,
1411
+ onSnapshot: (snapshot) => {
1412
+ currentSnapshot = snapshot;
1413
+ settleSelectionIntent();
1414
+ markLiveActivity();
1415
+ },
1416
+ onStreamOpen: () => {
1417
+ if (streamIssueStartedAt) {
1418
+ setTransientUiNotice(`Bridge recovered in ${formatRecoveryDuration(Date.now() - streamIssueStartedAt)}.`, "success", 2200);
1419
+ streamIssueStartedAt = 0;
1420
+ }
1421
+ markLiveActivity();
1422
+ },
1423
+ onStreamError: () => {
1424
+ if (!streamIssueStartedAt) {
1425
+ streamIssueStartedAt = Date.now();
1426
+ }
1427
+ },
1428
+ requestBootstrap: async () => {
1429
+ const [snapshot, live] = await Promise.all([
1430
+ requestJson(stateUrl),
1431
+ requestJson(liveStateUrl)
1432
+ ]);
1433
+
1434
+ return { live, snapshot };
1435
+ },
1436
+ requestRefresh: async ({ background = false }) => {
1437
+ if (!background) {
1438
+ uiState.refreshing = true;
1439
+ render();
1440
+ }
1441
+
1442
+ try {
1443
+ const url = background ? `${refreshUrl}?threads=0` : refreshUrl;
1444
+ const payload = await requestJson(url, { method: "POST" });
1445
+ currentLiveState = payload.state;
1446
+ settleSelectionIntent();
1447
+ markLiveActivity();
1448
+ scheduleChangesRefresh({ immediate: true, showLoading: !background });
1449
+ schedulePresenceSync(90, { force: true });
1450
+ return payload;
1451
+ } finally {
1452
+ if (!background) {
1453
+ uiState.refreshing = false;
1454
+ render();
1455
+ }
1456
+ }
1457
+ },
1458
+ state: bridgeState,
1459
+ streamRecovery: { baseMs: STREAM_RECOVERY_BASE_MS, maxMs: STREAM_RECOVERY_MAX_MS },
1460
+ streamUrl: "/api/stream"
1461
+ });
1462
+
1463
+ function closeStream() {
1464
+ bridgeLifecycle.closeStream();
1465
+ }
1466
+
1467
+ function ensureStream({ force = false } = {}) {
1468
+ bridgeLifecycle.ensureStream({ force });
1469
+ }
1470
+
1471
+ async function bootstrapLiveState({ retrying = false } = {}) {
1472
+ return bridgeLifecycle.bootstrap({ retrying });
1473
+ }
1474
+
1475
+ async function refreshLiveState({ background = false } = {}) {
1476
+ return bridgeLifecycle.refresh({ background });
1477
+ }
1478
+
1479
+ async function submitHostControlRelease() {
1480
+ if (uiState.controlling) {
1481
+ return null;
1482
+ }
1483
+
1484
+ const threadId = currentLiveState?.selectedThreadSnapshot?.thread?.id || currentLiveState?.selectedThreadId || "";
1485
+ if (!threadId) {
1486
+ throw new Error("No live session selected.");
1487
+ }
1488
+
1489
+ uiState.controlling = true;
1490
+ render();
1491
+
1492
+ try {
1493
+ const payload = await requestJson(controlUrl, {
1494
+ body: JSON.stringify({
1495
+ action: "release",
1496
+ clientId: surfaceAuthClientId,
1497
+ source: "host",
1498
+ threadId
1499
+ }),
1500
+ headers: {
1501
+ "Content-Type": "application/json"
1502
+ },
1503
+ method: "POST"
1504
+ });
1505
+ currentLiveState = payload.state;
1506
+ markLiveActivity();
1507
+ schedulePresenceSync(30, { force: true });
1508
+ render();
1509
+ return payload;
1510
+ } catch (error) {
1511
+ adoptErrorState(error);
1512
+ throw error;
1513
+ } finally {
1514
+ uiState.controlling = false;
1515
+ render();
1516
+ }
1517
+ }
1518
+
1519
+ async function refreshChanges({ background = false, cwd = currentLiveState?.selectedProjectCwd || "", showLoading = false } = {}) {
1520
+ if (!cwd) {
1521
+ currentChanges = null;
1522
+ uiState.loadingChanges = false;
1523
+ render();
1524
+ return null;
1525
+ }
1526
+
1527
+ if (changesRefreshPromise) {
1528
+ return changesRefreshPromise;
1529
+ }
1530
+
1531
+ if (showLoading) {
1532
+ uiState.loadingChanges = true;
1533
+ render();
1534
+ }
1535
+
1536
+ const threadId = currentLiveState?.selectedThreadId || "";
1537
+ const query = new URLSearchParams({ cwd });
1538
+ if (threadId) {
1539
+ query.set("threadId", threadId);
1540
+ }
1541
+
1542
+ changesRefreshPromise = requestJson(`${changesUrl}?${query.toString()}`)
1543
+ .then((payload) => {
1544
+ currentChanges = payload;
1545
+ render();
1546
+ return payload;
1547
+ })
1548
+ .catch((error) => {
1549
+ if (background) {
1550
+ return null;
1551
+ }
1552
+ throw error;
1553
+ })
1554
+ .finally(() => {
1555
+ if (showLoading) {
1556
+ uiState.loadingChanges = false;
1557
+ render();
1558
+ }
1559
+ changesRefreshPromise = null;
1560
+ });
1561
+
1562
+ return changesRefreshPromise;
1563
+ }
1564
+
1565
+ function scheduleChangesRefresh({ immediate = false, showLoading = false } = {}) {
1566
+ if (changesRefreshTimer) {
1567
+ window.clearTimeout(changesRefreshTimer);
1568
+ }
1569
+
1570
+ changesRefreshTimer = window.setTimeout(() => {
1571
+ changesRefreshTimer = null;
1572
+ void refreshChanges({ background: true, showLoading });
1573
+ }, immediate ? 0 : 350);
1574
+ }
1575
+
1576
+ async function submitSelection(body) {
1577
+ const requestVersion = ++selectionRequestVersion;
1578
+ selectionIntent = createSelectionIntent({
1579
+ cwd: body.cwd || currentLiveState?.selectedProjectCwd || "",
1580
+ projectLabel: projectLabel(body.cwd || currentLiveState?.selectedProjectCwd || ""),
1581
+ source: body.source || "host",
1582
+ threadId: body.threadId || "",
1583
+ threadLabel: currentLiveState?.threads?.find((thread) => thread.id === body.threadId)?.name || ""
1584
+ });
1585
+ uiState.selecting = true;
1586
+ render();
1587
+
1588
+ try {
1589
+ const payload = await requestJson(selectionUrl, {
1590
+ method: "POST",
1591
+ headers: { "Content-Type": "application/json" },
1592
+ body: JSON.stringify({
1593
+ ...body,
1594
+ clientId: surfaceAuthClientId
1595
+ })
1596
+ });
1597
+ if (requestVersion !== selectionRequestVersion) {
1598
+ return;
1599
+ }
1600
+ currentLiveState = payload.state;
1601
+ settleSelectionIntent();
1602
+ markLiveActivity();
1603
+ scheduleChangesRefresh({ immediate: true, showLoading: true });
1604
+ schedulePresenceSync(40, { force: true });
1605
+ render();
1606
+ } catch (error) {
1607
+ if (requestVersion === selectionRequestVersion) {
1608
+ adoptErrorState(error);
1609
+ selectionIntent = null;
1610
+ uiState.selecting = false;
1611
+ render();
1612
+ }
1613
+ throw error;
1614
+ } finally {
1615
+ if (requestVersion === selectionRequestVersion && !selectionIntent) {
1616
+ uiState.selecting = false;
1617
+ render();
1618
+ }
1619
+ }
1620
+ }
1621
+
1622
+ async function openInCodex() {
1623
+ const threadId = currentThreadId();
1624
+ if (!threadId) {
1625
+ throw new Error("No thread selected.");
1626
+ }
1627
+
1628
+ uiState.openingDesktop = true;
1629
+ render();
1630
+
1631
+ try {
1632
+ const payload = await requestJson(openInCodexUrl, {
1633
+ method: "POST",
1634
+ headers: { "Content-Type": "application/json" },
1635
+ body: JSON.stringify({ threadId })
1636
+ });
1637
+ setTransientUiNotice(payload.message || "Revealed in Codex.", "success", 5200);
1638
+ render();
1639
+ return payload;
1640
+ } catch (error) {
1641
+ adoptErrorState(error);
1642
+ throw error;
1643
+ } finally {
1644
+ uiState.openingDesktop = false;
1645
+ render();
1646
+ }
1647
+ }
1648
+
1649
+ async function submitInteraction(body) {
1650
+ const previousPending = currentLiveState?.pendingInteraction || null;
1651
+ uiState.submittingAction = body?.action || "submit";
1652
+ render();
1653
+
1654
+ try {
1655
+ const payload = await requestJson(interactionUrl, {
1656
+ method: "POST",
1657
+ headers: { "Content-Type": "application/json" },
1658
+ body: JSON.stringify(body)
1659
+ });
1660
+ currentLiveState = payload.state;
1661
+ if (!currentLiveState?.pendingInteraction) {
1662
+ beginActionHandoff(previousPending, currentLiveState, body?.action || "submit");
1663
+ }
1664
+ markLiveActivity();
1665
+ scheduleChangesRefresh({ immediate: true });
1666
+ schedulePresenceSync(30, { force: true });
1667
+ render();
1668
+ } catch (error) {
1669
+ adoptErrorState(error);
1670
+ throw error;
1671
+ } finally {
1672
+ uiState.submittingAction = false;
1673
+ render();
1674
+ }
1675
+ }
1676
+
1677
+ async function submitCompanionAction({ action, advisorId, wakeKey }) {
1678
+ if (!action || !wakeKey || companionActionState) {
1679
+ return;
1680
+ }
1681
+
1682
+ companionActionState = {
1683
+ action,
1684
+ key: wakeKey
1685
+ };
1686
+ render();
1687
+
1688
+ try {
1689
+ const payload = await requestJson(companionUrl, {
1690
+ method: "POST",
1691
+ headers: { "Content-Type": "application/json" },
1692
+ body: JSON.stringify({
1693
+ action,
1694
+ advisorId,
1695
+ threadId: currentThreadId(),
1696
+ wakeKey
1697
+ })
1698
+ });
1699
+ currentLiveState = payload.state;
1700
+ markLiveActivity();
1701
+ schedulePresenceSync(30, { force: true });
1702
+ setTransientUiNotice(payload.message || "Advisory reminder updated.", "success");
1703
+ render();
1704
+ } catch (error) {
1705
+ adoptErrorState(error);
1706
+ throw error;
1707
+ } finally {
1708
+ companionActionState = null;
1709
+ render();
1710
+ }
1711
+ }
1712
+
1713
+ function collectAnswers(questions) {
1714
+ const answers = {};
1715
+
1716
+ for (const question of questions || []) {
1717
+ const primary = nodes.actionQuestions.querySelector(`[data-answer-id="${question.id}"]`);
1718
+ const other = nodes.actionQuestions.querySelector(`[data-answer-other="${question.id}"]`);
1719
+ const otherValue = other?.value.trim();
1720
+
1721
+ if (otherValue) {
1722
+ answers[question.id] = otherValue;
1723
+ continue;
1724
+ }
1725
+
1726
+ const primaryValue = primary?.value?.trim();
1727
+ if (primaryValue && primaryValue !== "__other__") {
1728
+ answers[question.id] = primaryValue;
1729
+ }
1730
+ }
1731
+
1732
+ return answers;
1733
+ }
1734
+
1735
+ async function sendMockCommand(command) {
1736
+ await requestJson("/api/commands", {
1737
+ method: "POST",
1738
+ headers: { "Content-Type": "application/json" },
1739
+ body: JSON.stringify(command)
1740
+ });
1741
+ }
1742
+
1743
+ async function sendLiveDebugInteraction(body) {
1744
+ const payload = await requestJson(debugInteractionUrl, {
1745
+ method: "POST",
1746
+ headers: { "Content-Type": "application/json" },
1747
+ body: JSON.stringify(body)
1748
+ });
1749
+ currentLiveState = payload.state;
1750
+ markLiveActivity();
1751
+ render();
1752
+ }
1753
+
1754
+ nodes.hostProjectSelect.addEventListener("change", async () => {
1755
+ try {
1756
+ await submitSelection({
1757
+ clientId: surfaceAuthClientId,
1758
+ cwd: nodes.hostProjectSelect.value,
1759
+ source: "host"
1760
+ });
1761
+ } catch (error) {
1762
+ window.alert(error.message);
1763
+ }
1764
+ });
1765
+
1766
+ nodes.hostSessionSelect.addEventListener("change", async () => {
1767
+ try {
1768
+ const selected = currentLiveState?.threads?.find((thread) => thread.id === nodes.hostSessionSelect.value) || null;
1769
+ await submitSelection({
1770
+ clientId: surfaceAuthClientId,
1771
+ cwd: selected?.cwd || currentLiveState?.selectedProjectCwd || "",
1772
+ source: "host",
1773
+ threadId: nodes.hostSessionSelect.value
1774
+ });
1775
+ } catch (error) {
1776
+ window.alert(error.message);
1777
+ }
1778
+ });
1779
+
1780
+ for (const button of nodes.filterButtons) {
1781
+ button.addEventListener("click", () => {
1782
+ markUserIntent();
1783
+ schedulePresenceSync(120);
1784
+ const filter = button.dataset.filter;
1785
+ const activeCount = Object.values(feedFilters).filter(Boolean).length;
1786
+
1787
+ if (feedFilters[filter] && activeCount === 1) {
1788
+ return;
1789
+ }
1790
+
1791
+ feedFilters[filter] = !feedFilters[filter];
1792
+ surfaceViewState.saveFilters(feedFilters);
1793
+ render();
1794
+ });
1795
+ }
1796
+
1797
+ nodes.feed.addEventListener(
1798
+ "toggle",
1799
+ (event) => {
1800
+ const details = event.target.closest?.("details[data-feed-section]");
1801
+ if (!details) {
1802
+ return;
1803
+ }
1804
+
1805
+ const key = feedSectionKey(details.dataset.feedSection || "");
1806
+ if (details.open) {
1807
+ expandedFeedSections.add(key);
1808
+ } else {
1809
+ expandedFeedSections.delete(key);
1810
+ }
1811
+ surfaceViewState.saveExpandedSections(currentThreadId() || "none", [...expandedFeedSections]);
1812
+ },
1813
+ true
1814
+ );
1815
+
1816
+ nodes.feed.addEventListener("click", async (event) => {
1817
+ markUserIntent();
1818
+ const actionButton = event.target.closest("[data-companion-action]");
1819
+ if (actionButton) {
1820
+ event.preventDefault();
1821
+ event.stopPropagation();
1822
+ try {
1823
+ await submitCompanionAction({
1824
+ action: actionButton.dataset.companionAction,
1825
+ advisorId: actionButton.dataset.advisorId,
1826
+ wakeKey: actionButton.dataset.wakeKey
1827
+ });
1828
+ } catch (error) {
1829
+ window.alert(error.message);
1830
+ }
1831
+ return;
1832
+ }
1833
+
1834
+ if (event.target.closest("a")) {
1835
+ return;
1836
+ }
1837
+
1838
+ const card = event.target.closest("[data-entry-key]");
1839
+ if (!card || card.dataset.expandable !== "true") {
1840
+ return;
1841
+ }
1842
+
1843
+ const key = card.dataset.entryKey;
1844
+ if (expandedEntryKeys.has(key)) {
1845
+ expandedEntryKeys.delete(key);
1846
+ } else {
1847
+ expandedEntryKeys.add(key);
1848
+ }
1849
+ render();
1850
+ });
1851
+
1852
+ nodes.feed.addEventListener("keydown", (event) => {
1853
+ markUserIntent();
1854
+ if (event.target.closest("[data-companion-action]")) {
1855
+ return;
1856
+ }
1857
+
1858
+ if (event.key !== "Enter" && event.key !== " ") {
1859
+ return;
1860
+ }
1861
+
1862
+ const card = event.target.closest("[data-entry-key]");
1863
+ if (!card || card.dataset.expandable !== "true") {
1864
+ return;
1865
+ }
1866
+
1867
+ event.preventDefault();
1868
+ card.click();
1869
+ });
1870
+
1871
+ nodes.hostRefreshButton.addEventListener("click", async () => {
1872
+ markUserIntent();
1873
+ try {
1874
+ await refreshLiveState();
1875
+ } catch (error) {
1876
+ window.alert(error.message);
1877
+ }
1878
+ });
1879
+
1880
+ nodes.hostOpenInCodexButton.addEventListener("click", async () => {
1881
+ markUserIntent();
1882
+ try {
1883
+ await openInCodex();
1884
+ } catch (error) {
1885
+ window.alert(error.message);
1886
+ }
1887
+ });
1888
+
1889
+ nodes.hostReleaseControlButton.addEventListener("click", async () => {
1890
+ markUserIntent();
1891
+ try {
1892
+ await submitHostControlRelease();
1893
+ } catch (error) {
1894
+ window.alert(error.message);
1895
+ }
1896
+ });
1897
+
1898
+ nodes.approveButton.addEventListener("click", async () => {
1899
+ markUserIntent();
1900
+ try {
1901
+ await submitInteraction({ action: "approve" });
1902
+ } catch (error) {
1903
+ window.alert(error.message);
1904
+ }
1905
+ });
1906
+
1907
+ nodes.approveSessionButton.addEventListener("click", async () => {
1908
+ markUserIntent();
1909
+ try {
1910
+ await submitInteraction({ action: "session" });
1911
+ } catch (error) {
1912
+ window.alert(error.message);
1913
+ }
1914
+ });
1915
+
1916
+ nodes.declineButton.addEventListener("click", async () => {
1917
+ markUserIntent();
1918
+ try {
1919
+ await submitInteraction({ action: "decline" });
1920
+ } catch (error) {
1921
+ window.alert(error.message);
1922
+ }
1923
+ });
1924
+
1925
+ nodes.actionForm.addEventListener("submit", async (event) => {
1926
+ event.preventDefault();
1927
+ markUserIntent();
1928
+ const pending = currentLiveState?.pendingInteraction;
1929
+ if (!pending) {
1930
+ return;
1931
+ }
1932
+
1933
+ try {
1934
+ await submitInteraction({
1935
+ action: "submit",
1936
+ answers: collectAnswers(pending.questions || [])
1937
+ });
1938
+ } catch (error) {
1939
+ window.alert(error.message);
1940
+ }
1941
+ });
1942
+
1943
+ nodes.actionCancel.addEventListener("click", async () => {
1944
+ markUserIntent();
1945
+ try {
1946
+ await submitInteraction({ action: "cancel" });
1947
+ } catch (error) {
1948
+ window.alert(error.message);
1949
+ }
1950
+ });
1951
+
1952
+ document.querySelectorAll("[data-type]").forEach((button) => {
1953
+ button.addEventListener("click", async () => {
1954
+ markUserIntent();
1955
+ try {
1956
+ await sendMockCommand({
1957
+ type: button.dataset.type,
1958
+ strategyId: button.dataset.strategyId,
1959
+ windowProfileId: button.dataset.windowProfileId,
1960
+ source: "host"
1961
+ });
1962
+ } catch (error) {
1963
+ window.alert(error.message);
1964
+ }
1965
+ });
1966
+ });
1967
+
1968
+ document.querySelectorAll("[data-live-interaction-kind]").forEach((button) => {
1969
+ button.addEventListener("click", async () => {
1970
+ markUserIntent();
1971
+ try {
1972
+ await sendLiveDebugInteraction({
1973
+ kind: button.dataset.liveInteractionKind
1974
+ });
1975
+ } catch (error) {
1976
+ window.alert(error.message);
1977
+ }
1978
+ });
1979
+ });
1980
+
1981
+ document.querySelectorAll("[data-live-interaction-action]").forEach((button) => {
1982
+ button.addEventListener("click", async () => {
1983
+ markUserIntent();
1984
+ try {
1985
+ await sendLiveDebugInteraction({
1986
+ action: button.dataset.liveInteractionAction
1987
+ });
1988
+ } catch (error) {
1989
+ window.alert(error.message);
1990
+ }
1991
+ });
1992
+ });
1993
+
1994
+ nodes.assistantForm.addEventListener("submit", async (event) => {
1995
+ event.preventDefault();
1996
+ markUserIntent();
1997
+
1998
+ try {
1999
+ await sendMockCommand({
2000
+ type: "simulate_assistant_turn",
2001
+ text: nodes.assistantText.value,
2002
+ source: "host"
2003
+ });
2004
+ nodes.assistantText.value = "";
2005
+ } catch (error) {
2006
+ window.alert(error.message);
2007
+ }
2008
+ });
2009
+
2010
+ window.setInterval(() => {
2011
+ if (document.visibilityState !== "visible") {
2012
+ return;
2013
+ }
2014
+
2015
+ if (!currentLiveState) {
2016
+ return;
2017
+ }
2018
+
2019
+ if (bridgeState.streamState !== "live") {
2020
+ return;
2021
+ }
2022
+
2023
+ if (Date.now() - lastLiveActivityAt <= FALLBACK_REFRESH_STALE_MS) {
2024
+ return;
2025
+ }
2026
+
2027
+ void refreshLiveState({ background: true });
2028
+ }, FALLBACK_REFRESH_INTERVAL_MS);
2029
+
2030
+ window.setInterval(() => {
2031
+ if (!currentLiveState || bridgeState.streamState !== "live") {
2032
+ return;
2033
+ }
2034
+
2035
+ void syncPresence();
2036
+ }, PRESENCE_HEARTBEAT_INTERVAL_MS);
2037
+
2038
+ document.addEventListener(
2039
+ "pointerdown",
2040
+ () => {
2041
+ markUserIntent();
2042
+ schedulePresenceSync(120);
2043
+ },
2044
+ { passive: true }
2045
+ );
2046
+
2047
+ document.addEventListener("keydown", () => {
2048
+ markUserIntent();
2049
+ schedulePresenceSync(120);
2050
+ });
2051
+
2052
+ document.addEventListener("input", () => {
2053
+ markUserIntent();
2054
+ schedulePresenceSync(120);
2055
+ });
2056
+
2057
+ document.addEventListener("visibilitychange", () => {
2058
+ schedulePresenceSync(40, { force: true });
2059
+ if (document.visibilityState === "visible") {
2060
+ bridgeLifecycle.resumeVisible();
2061
+ }
2062
+ });
2063
+
2064
+ window.addEventListener("focus", () => {
2065
+ schedulePresenceSync(40, { force: true });
2066
+ bridgeLifecycle.resumeVisible();
2067
+ });
2068
+
2069
+ window.addEventListener("blur", () => {
2070
+ schedulePresenceSync(40, { force: true });
2071
+ });
2072
+
2073
+ window.addEventListener("pagehide", () => {
2074
+ sendDetachPresence();
2075
+ closeStream();
2076
+ });
2077
+
2078
+ ensureStream();
2079
+ void bootstrapLiveState();