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
@@ -0,0 +1,214 @@
1
+ export function createSelectionStateService({
2
+ appServerState,
3
+ applyLiveSelectionTransition,
4
+ bestThreadLabel,
5
+ broadcast = () => {},
6
+ buildLivePayload = () => ({}),
7
+ createThreadSelectionState,
8
+ getPendingInteractionForSelectedThread = () => null,
9
+ liveState,
10
+ nowIso = () => new Date().toISOString(),
11
+ nowMs = () => Date.now(),
12
+ projectLabel,
13
+ randomId = () => `${Date.now()}`,
14
+ refreshSelectedThreadSnapshot = async () => {},
15
+ refreshThreads = async () => {},
16
+ restartWatcher = async () => {},
17
+ scheduleControlLeaseExpiry = () => {},
18
+ shortThreadId = (value) => String(value || "").trim(),
19
+ slugifyChannelName = (value) => String(value || "").trim().toLowerCase(),
20
+ surfaceActorLabel = ({ surface = "", clientId = null } = {}) =>
21
+ clientId ? `${surface}:${clientId}` : surface
22
+ } = {}) {
23
+ function restartWatcherInBackground() {
24
+ void restartWatcher().catch((error) => {
25
+ liveState.lastError = error?.message || "Watcher restart failed.";
26
+ broadcast("live", buildLivePayload());
27
+ });
28
+ }
29
+
30
+ function hydrateSelectionInBackground(expectedThreadId) {
31
+ const normalizedThreadId = String(expectedThreadId || "").trim();
32
+ if (!normalizedThreadId) {
33
+ return;
34
+ }
35
+
36
+ void refreshSelectedThreadSnapshot({ broadcastUpdate: true }).catch((error) => {
37
+ if (liveState.selectedThreadId !== normalizedThreadId) {
38
+ return;
39
+ }
40
+
41
+ liveState.lastError = error?.message || "Thread refresh failed.";
42
+ broadcast("live", buildLivePayload());
43
+ });
44
+ }
45
+
46
+ function threadDescriptor(threadId, snapshot = null) {
47
+ const id = String(threadId || "").trim();
48
+ if (!id) {
49
+ return {
50
+ channelLabel: "",
51
+ channelSlug: "",
52
+ cwd: null,
53
+ serverLabel: "",
54
+ threadId: null
55
+ };
56
+ }
57
+
58
+ const snapshotThread = snapshot?.thread?.id === id ? snapshot.thread : null;
59
+ const thread = snapshotThread || liveState.threads.find((candidate) => candidate.id === id) || null;
60
+ const channelLabel = thread
61
+ ? bestThreadLabel(thread, snapshotThread ? snapshot : null, { selected: true })
62
+ : `session ${shortThreadId(id)}`;
63
+ const cwd = thread?.cwd || null;
64
+
65
+ return {
66
+ channelLabel,
67
+ channelSlug: `#${slugifyChannelName(channelLabel)}`,
68
+ cwd,
69
+ serverLabel: projectLabel(cwd || ""),
70
+ threadId: id
71
+ };
72
+ }
73
+
74
+ function recordSelectionEvent({
75
+ actor = "remote",
76
+ clientId = null,
77
+ cause = "switched",
78
+ fromDescriptor = null,
79
+ toDescriptor = null
80
+ } = {}) {
81
+ const nextThreadId = String(toDescriptor?.threadId || "").trim();
82
+ if (!nextThreadId) {
83
+ return null;
84
+ }
85
+
86
+ appServerState.lastSelectionEvent = {
87
+ action: "switch",
88
+ actor,
89
+ actorClientId: clientId ? String(clientId).trim() : null,
90
+ actorLabel: surfaceActorLabel({ surface: actor, clientId }),
91
+ at: nowIso(),
92
+ cause,
93
+ fromChannelLabel: fromDescriptor?.channelLabel || "",
94
+ fromChannelSlug: fromDescriptor?.channelSlug || "",
95
+ fromServerLabel: fromDescriptor?.serverLabel || "",
96
+ fromThreadId: fromDescriptor?.threadId || null,
97
+ id: randomId(),
98
+ threadId: nextThreadId,
99
+ toChannelLabel: toDescriptor?.channelLabel || "",
100
+ toChannelSlug: toDescriptor?.channelSlug || "",
101
+ toServerLabel: toDescriptor?.serverLabel || "",
102
+ toThreadId: nextThreadId
103
+ };
104
+ return appServerState.lastSelectionEvent;
105
+ }
106
+
107
+ async function setSelection({ clientId = null, cwd = null, source = "remote", threadId = null } = {}) {
108
+ const previousThreadId = liveState.selectedThreadId;
109
+ const previousDescriptor = threadDescriptor(previousThreadId, liveState.selectedThreadSnapshot);
110
+ const selection = applyLiveSelectionTransition(
111
+ {
112
+ controlLease: liveState.controlLease,
113
+ interactionFlow: liveState.interactionFlow,
114
+ selectedProjectCwd: liveState.selectedProjectCwd,
115
+ selectedThreadId: liveState.selectedThreadId,
116
+ selectedThreadSnapshot: liveState.selectedThreadSnapshot,
117
+ selectionSource: liveState.selectionSource,
118
+ turnDiff: liveState.turnDiff,
119
+ writeLock: liveState.writeLock
120
+ },
121
+ {
122
+ cwd,
123
+ source,
124
+ threadId,
125
+ threads: liveState.threads
126
+ },
127
+ {
128
+ now: nowMs()
129
+ }
130
+ );
131
+
132
+ liveState.selectedProjectCwd = selection.nextState.selectedProjectCwd;
133
+ liveState.selectionSource = selection.nextState.selectionSource;
134
+ liveState.selectedThreadId = selection.nextState.selectedThreadId;
135
+ liveState.selectedThreadSnapshot = selection.nextState.selectedThreadSnapshot;
136
+ liveState.writeLock = selection.nextState.writeLock;
137
+ liveState.turnDiff = selection.nextState.turnDiff;
138
+ liveState.controlLease = selection.nextState.controlLease;
139
+ liveState.interactionFlow = selection.nextState.interactionFlow;
140
+
141
+ if (selection.threadChanged) {
142
+ scheduleControlLeaseExpiry();
143
+ }
144
+
145
+ liveState.lastError = null;
146
+ if (selection.threadChanged) {
147
+ liveState.watcherConnected = false;
148
+ } else {
149
+ await refreshSelectedThreadSnapshot({ broadcastUpdate: false });
150
+ }
151
+ if (liveState.selectedThreadId && liveState.selectedThreadId !== previousThreadId) {
152
+ recordSelectionEvent({
153
+ actor: source,
154
+ cause: "switched",
155
+ clientId,
156
+ fromDescriptor: previousDescriptor,
157
+ toDescriptor: threadDescriptor(liveState.selectedThreadId, liveState.selectedThreadSnapshot)
158
+ });
159
+ }
160
+ broadcast("live", buildLivePayload());
161
+ if (selection.threadChanged) {
162
+ hydrateSelectionInBackground(liveState.selectedThreadId);
163
+ }
164
+ restartWatcherInBackground();
165
+ void refreshThreads({ broadcastUpdate: true });
166
+
167
+ return {
168
+ ok: true,
169
+ source,
170
+ state: buildLivePayload()
171
+ };
172
+ }
173
+
174
+ async function createThreadSelection({ clientId = null, cwd = null, source = "remote" } = {}) {
175
+ const previousDescriptor = threadDescriptor(liveState.selectedThreadId, liveState.selectedThreadSnapshot);
176
+ if (getPendingInteractionForSelectedThread()) {
177
+ throw new Error("Resolve the pending interaction before creating a new session.");
178
+ }
179
+
180
+ if (liveState.writeLock?.status) {
181
+ throw new Error("Wait for the current live write to finish before creating a new session.");
182
+ }
183
+
184
+ const { snapshot, thread: hydratedThread } = await createThreadSelectionState({
185
+ cwd,
186
+ source
187
+ });
188
+ recordSelectionEvent({
189
+ actor: source,
190
+ cause: "created",
191
+ clientId,
192
+ fromDescriptor: previousDescriptor,
193
+ toDescriptor: threadDescriptor(hydratedThread.id, snapshot)
194
+ });
195
+
196
+ broadcast("live", buildLivePayload());
197
+ restartWatcherInBackground();
198
+ void refreshThreads({ broadcastUpdate: true });
199
+
200
+ return {
201
+ ok: true,
202
+ source,
203
+ state: buildLivePayload(),
204
+ thread: snapshot.thread
205
+ };
206
+ }
207
+
208
+ return {
209
+ createThreadSelection,
210
+ recordSelectionEvent,
211
+ setSelection,
212
+ threadDescriptor
213
+ };
214
+ }
@@ -0,0 +1,355 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export const STRATEGIES = {
4
+ "semantic-dom": {
5
+ id: "semantic-dom",
6
+ label: "Semantic DOM adapter",
7
+ summary: "Reads the transcript semantically and can send structured input back.",
8
+ capabilities: [
9
+ "semantic_read",
10
+ "semantic_write",
11
+ "approval_actions",
12
+ "focus_input",
13
+ "window_stream"
14
+ ]
15
+ },
16
+ "paste-submit": {
17
+ id: "paste-submit",
18
+ label: "Focus + paste + submit",
19
+ summary: "Keeps semantic read when possible, but writes back via focused paste and submit.",
20
+ capabilities: [
21
+ "semantic_read",
22
+ "approval_actions",
23
+ "focus_input",
24
+ "paste_submit",
25
+ "window_stream"
26
+ ]
27
+ },
28
+ "video-only": {
29
+ id: "video-only",
30
+ label: "Window stream fallback",
31
+ summary: "No semantic adapter is available, so the companion relies on preview plus generic input.",
32
+ capabilities: [
33
+ "window_stream",
34
+ "generic_input_only"
35
+ ]
36
+ }
37
+ };
38
+
39
+ export const WINDOW_PROFILES = {
40
+ "phone-portrait": {
41
+ id: "phone-portrait",
42
+ label: "Phone portrait",
43
+ size: "430 x 932",
44
+ summary: "Tight text density and large touch targets."
45
+ },
46
+ "ipad-portrait": {
47
+ id: "ipad-portrait",
48
+ label: "iPad portrait",
49
+ size: "820 x 1180",
50
+ summary: "Single-column transcript with large approval affordances."
51
+ },
52
+ "ipad-landscape": {
53
+ id: "ipad-landscape",
54
+ label: "iPad landscape",
55
+ size: "1180 x 820",
56
+ summary: "Two-panel view with transcript and live preview."
57
+ },
58
+ desktop: {
59
+ id: "desktop",
60
+ label: "Desktop",
61
+ size: "1440 x 960",
62
+ summary: "Reference host layout."
63
+ }
64
+ };
65
+
66
+ function now() {
67
+ return new Date().toISOString();
68
+ }
69
+
70
+ function createMessage(role, kind, text) {
71
+ return {
72
+ id: randomUUID(),
73
+ role,
74
+ kind,
75
+ text,
76
+ timestamp: now()
77
+ };
78
+ }
79
+
80
+ function clampList(list, limit = 18) {
81
+ return list.slice(Math.max(0, list.length - limit));
82
+ }
83
+
84
+ function createBaseState() {
85
+ return {
86
+ session: {
87
+ id: "codex-session-alpha",
88
+ title: "Codex session / couch control spike",
89
+ appName: "Codex",
90
+ status: "live",
91
+ strategyId: "semantic-dom",
92
+ windowProfileId: "ipad-landscape",
93
+ transportLabel: "Local SSE bridge",
94
+ transportNext: "WebRTC data channel and preview stream",
95
+ previewMode: "mock-preview",
96
+ lastUpdatedAt: now()
97
+ },
98
+ transcript: [
99
+ createMessage(
100
+ "system",
101
+ "status",
102
+ "Host bridge online. This MVP is local-only and uses a mock Codex adapter."
103
+ ),
104
+ createMessage(
105
+ "assistant",
106
+ "message",
107
+ "Remote companion layout is active. Use the host console to switch between semantic mode and fallback modes."
108
+ ),
109
+ createMessage(
110
+ "tool",
111
+ "status",
112
+ "Pending real integrations: ScreenCaptureKit, WebRTC, Accessibility, and Codex-specific adapters."
113
+ )
114
+ ],
115
+ pendingApproval: null,
116
+ commandLog: [],
117
+ draftInput: ""
118
+ };
119
+ }
120
+
121
+ function decorateState(state) {
122
+ const strategy = STRATEGIES[state.session.strategyId];
123
+ const windowProfile = WINDOW_PROFILES[state.session.windowProfileId];
124
+
125
+ return {
126
+ ...structuredClone(state),
127
+ session: {
128
+ ...structuredClone(state.session),
129
+ strategy,
130
+ windowProfile
131
+ }
132
+ };
133
+ }
134
+
135
+ export function createSessionStore() {
136
+ const listeners = new Set();
137
+ const state = createBaseState();
138
+
139
+ function emit() {
140
+ state.session.lastUpdatedAt = now();
141
+ const snapshot = decorateState(state);
142
+ for (const listener of listeners) {
143
+ listener(snapshot);
144
+ }
145
+ }
146
+
147
+ function addTranscript(role, kind, text) {
148
+ state.transcript = clampList([...state.transcript, createMessage(role, kind, text)], 36);
149
+ }
150
+
151
+ function logCommand(source, type, summary) {
152
+ state.commandLog = clampList(
153
+ [
154
+ ...state.commandLog,
155
+ {
156
+ id: randomUUID(),
157
+ source,
158
+ type,
159
+ summary,
160
+ timestamp: now()
161
+ }
162
+ ],
163
+ 14
164
+ );
165
+ }
166
+
167
+ function setStrategy(strategyId, source = "host") {
168
+ const strategy = STRATEGIES[strategyId];
169
+ if (!strategy) {
170
+ throw new Error(`Unknown strategy: ${strategyId}`);
171
+ }
172
+
173
+ state.session.strategyId = strategyId;
174
+ logCommand(source, "set_strategy", `Switched adapter to ${strategy.label}.`);
175
+ addTranscript("system", "status", `Adapter switched to ${strategy.label}. ${strategy.summary}`);
176
+ }
177
+
178
+ function setWindowProfile(windowProfileId, source = "host") {
179
+ const profile = WINDOW_PROFILES[windowProfileId];
180
+ if (!profile) {
181
+ throw new Error(`Unknown window profile: ${windowProfileId}`);
182
+ }
183
+
184
+ state.session.windowProfileId = windowProfileId;
185
+ logCommand(source, "set_window_profile", `Window preset changed to ${profile.label}.`);
186
+ addTranscript(
187
+ "system",
188
+ "status",
189
+ `Window preset is now ${profile.label} (${profile.size}). ${profile.summary}`
190
+ );
191
+ }
192
+
193
+ function queueApproval(source = "host") {
194
+ if (state.pendingApproval) {
195
+ logCommand(source, "queue_approval", "Approval was already pending.");
196
+ return;
197
+ }
198
+
199
+ state.pendingApproval = {
200
+ id: randomUUID(),
201
+ title: "Approve next remote action",
202
+ detail: "Companion-mode approval added by the host console."
203
+ };
204
+ logCommand(source, "queue_approval", "Queued approval request.");
205
+ addTranscript("tool", "status", "Host queued an approval card for the remote companion.");
206
+ }
207
+
208
+ function clearApproval(source = "host") {
209
+ state.pendingApproval = null;
210
+ logCommand(source, "clear_approval", "Cleared pending approval.");
211
+ addTranscript("tool", "status", "Pending approval cleared.");
212
+ }
213
+
214
+ function approve(source = "remote") {
215
+ if (!state.pendingApproval) {
216
+ logCommand(source, "approve", "No pending approval to accept.");
217
+ addTranscript("tool", "status", "Approve tapped, but nothing was waiting.");
218
+ return;
219
+ }
220
+
221
+ logCommand(source, "approve", `Approved "${state.pendingApproval.title}".`);
222
+ addTranscript(
223
+ "tool",
224
+ "status",
225
+ `Approval accepted from ${source}. In a real adapter this would release the gated action.`
226
+ );
227
+ state.pendingApproval = null;
228
+ }
229
+
230
+ function sendText(text, source = "remote") {
231
+ const trimmed = text.trim();
232
+ if (!trimmed) {
233
+ throw new Error("Reply text cannot be empty.");
234
+ }
235
+
236
+ state.draftInput = "";
237
+ logCommand(source, "send_text", `Sent reply: ${trimmed}`);
238
+ addTranscript("user", "message", trimmed);
239
+ state.session.status = "running";
240
+ }
241
+
242
+ function submit(source = "remote") {
243
+ logCommand(
244
+ source,
245
+ "submit",
246
+ `Submit requested via ${STRATEGIES[state.session.strategyId].label}.`
247
+ );
248
+ addTranscript(
249
+ "system",
250
+ "status",
251
+ `Submit routed through ${STRATEGIES[state.session.strategyId].label}.`
252
+ );
253
+ state.session.status = "running";
254
+ }
255
+
256
+ function interrupt(source = "remote") {
257
+ logCommand(source, "interrupt", "Interrupt requested.");
258
+ addTranscript("system", "status", "Interrupt intent received. Host would attempt to stop the active turn.");
259
+ state.session.status = "live";
260
+ }
261
+
262
+ function focusInput(source = "remote") {
263
+ logCommand(source, "focus_input", "Focus input requested.");
264
+ addTranscript(
265
+ "system",
266
+ "status",
267
+ `Focus input requested from ${source}. In fallback mode this would activate the target window first.`
268
+ );
269
+ }
270
+
271
+ function setDraft(text, source = "remote") {
272
+ state.draftInput = text;
273
+ logCommand(source, "set_draft", "Updated remote draft.");
274
+ }
275
+
276
+ function simulateAssistantTurn(source = "host", text) {
277
+ const message =
278
+ text?.trim() ||
279
+ "Mock adapter follow-up: the next real spike should test whether Codex can expose semantic transcript access without degrading the normal desktop experience.";
280
+
281
+ logCommand(source, "simulate_assistant_turn", "Added mock assistant turn.");
282
+ addTranscript("assistant", "message", message);
283
+ state.session.status = "live";
284
+ }
285
+
286
+ function applyCommand(command) {
287
+ const source = command.source || "host";
288
+
289
+ switch (command.type) {
290
+ case "set_strategy":
291
+ setStrategy(command.strategyId, source);
292
+ break;
293
+ case "set_window_profile":
294
+ setWindowProfile(command.windowProfileId, source);
295
+ break;
296
+ case "queue_approval":
297
+ queueApproval(source);
298
+ break;
299
+ case "clear_approval":
300
+ clearApproval(source);
301
+ break;
302
+ case "approve":
303
+ approve(source);
304
+ break;
305
+ case "send_text":
306
+ sendText(command.text || "", source);
307
+ break;
308
+ case "submit":
309
+ submit(source);
310
+ break;
311
+ case "interrupt":
312
+ interrupt(source);
313
+ break;
314
+ case "focus_input":
315
+ focusInput(source);
316
+ break;
317
+ case "set_draft":
318
+ setDraft(command.text || "", source);
319
+ break;
320
+ case "simulate_assistant_turn":
321
+ simulateAssistantTurn(source, command.text);
322
+ break;
323
+ default:
324
+ throw new Error(`Unknown command type: ${command.type}`);
325
+ }
326
+
327
+ emit();
328
+ return getState();
329
+ }
330
+
331
+ function subscribe(listener) {
332
+ listeners.add(listener);
333
+ listener(getState());
334
+ return () => {
335
+ listeners.delete(listener);
336
+ };
337
+ }
338
+
339
+ function getState() {
340
+ return decorateState(state);
341
+ }
342
+
343
+ function publishTranscript(role, kind, text) {
344
+ addTranscript(role, kind, text);
345
+ emit();
346
+ return getState();
347
+ }
348
+
349
+ return {
350
+ applyCommand,
351
+ publishTranscript,
352
+ getState,
353
+ subscribe
354
+ };
355
+ }