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,185 @@
1
+ export function createInteractionResolutionService({
2
+ appServerState,
3
+ broadcast = () => {},
4
+ buildLivePayload = () => ({}),
5
+ controlLeaseTtlMs,
6
+ ensureRemoteControlLease = () => {},
7
+ getWatcherController = () => null,
8
+ hasWatcherController = () => false,
9
+ liveState,
10
+ maybeWakeCompanionForInteractionResolution = () => {},
11
+ nowIso = () => new Date().toISOString(),
12
+ setControlLease = () => {}
13
+ } = {}) {
14
+ function isWriterSurface(source) {
15
+ return source === "remote" || source === "agent";
16
+ }
17
+
18
+ function normalizeUserInputAnswers(questions, answers = {}) {
19
+ const payload = {};
20
+
21
+ for (const question of questions || []) {
22
+ const raw = answers[question.id];
23
+ const list = Array.isArray(raw)
24
+ ? raw.map((value) => String(value).trim()).filter(Boolean)
25
+ : raw == null
26
+ ? []
27
+ : [String(raw).trim()].filter(Boolean);
28
+
29
+ if (list.length > 0) {
30
+ payload[question.id] = { answers: list };
31
+ }
32
+ }
33
+
34
+ return payload;
35
+ }
36
+
37
+ function getCommandDecision(action, pending) {
38
+ const decisions = pending.availableDecisions || [];
39
+
40
+ if (action === "decline") {
41
+ return decisions.includes("decline") ? "decline" : "cancel";
42
+ }
43
+
44
+ if (action === "session" && decisions.includes("acceptForSession")) {
45
+ return "acceptForSession";
46
+ }
47
+
48
+ return "accept";
49
+ }
50
+
51
+ async function resolvePendingInteraction(body) {
52
+ const pending = liveState.pendingInteraction;
53
+ if (!pending) {
54
+ throw new Error("No pending interaction.");
55
+ }
56
+
57
+ const authorityClientId = body.authorityClientId || null;
58
+ if (isWriterSurface(body.source) && pending.threadId) {
59
+ ensureRemoteControlLease(pending.threadId, body.source, authorityClientId);
60
+ }
61
+
62
+ const activeWatcherController = getWatcherController();
63
+
64
+ if (!pending.debug && !hasWatcherController()) {
65
+ throw new Error("Live watcher is not connected.");
66
+ }
67
+
68
+ if (pending.debug) {
69
+ appServerState.lastInteraction = {
70
+ action: body.action || (pending.actionKind === "user_input" ? "submit" : "approve"),
71
+ answers:
72
+ pending.actionKind === "user_input"
73
+ ? normalizeUserInputAnswers(pending.questions, body.answers || {})
74
+ : null,
75
+ at: nowIso(),
76
+ flowContinuation: pending.flowContinuation || "",
77
+ flowLabel: pending.flowLabel || "",
78
+ flowStep: pending.flowStep || null,
79
+ kind: pending.kind,
80
+ kindLabel: pending.kindLabel || null,
81
+ retryAttempt: pending.retryAttempt || 1,
82
+ summary: pending.summary || null,
83
+ source: "debug-harness",
84
+ status: "resolved",
85
+ threadId: pending.threadId || null
86
+ };
87
+ liveState.pendingInteraction = null;
88
+ liveState.lastError = null;
89
+ maybeWakeCompanionForInteractionResolution({
90
+ interaction: appServerState.lastInteraction,
91
+ threadId: pending.threadId || null
92
+ });
93
+ broadcast("live", buildLivePayload());
94
+ return;
95
+ }
96
+
97
+ switch (pending.method) {
98
+ case "item/commandExecution/requestApproval":
99
+ activeWatcherController.respond(pending.requestId, {
100
+ decision: getCommandDecision(body.action || "approve", pending)
101
+ });
102
+ break;
103
+ case "item/fileChange/requestApproval":
104
+ activeWatcherController.respond(pending.requestId, {
105
+ decision: body.action === "decline" ? "decline" : "accept"
106
+ });
107
+ break;
108
+ case "item/permissions/requestApproval":
109
+ if ((body.action || "approve") !== "approve") {
110
+ if (body.action === "session") {
111
+ activeWatcherController.respond(pending.requestId, {
112
+ permissions: pending.permissions || {},
113
+ scope: "session"
114
+ });
115
+ break;
116
+ }
117
+
118
+ activeWatcherController.respondError(pending.requestId, "Permission request declined by the companion.");
119
+ break;
120
+ }
121
+
122
+ activeWatcherController.respond(pending.requestId, {
123
+ permissions: pending.permissions || {},
124
+ scope: body.scope === "session" ? "session" : "turn"
125
+ });
126
+ break;
127
+ case "item/tool/requestUserInput": {
128
+ if ((body.action || "submit") !== "submit") {
129
+ activeWatcherController.respondError(pending.requestId, "User input cancelled by the companion.");
130
+ break;
131
+ }
132
+
133
+ const answers = normalizeUserInputAnswers(pending.questions, body.answers || {});
134
+ activeWatcherController.respond(pending.requestId, {
135
+ answers
136
+ });
137
+ break;
138
+ }
139
+ default:
140
+ activeWatcherController.respondError(pending.requestId, "Unsupported interaction from Dextunnel.");
141
+ break;
142
+ }
143
+
144
+ appServerState.lastInteraction = {
145
+ action: body.action || (pending.actionKind === "user_input" ? "submit" : "approve"),
146
+ answers:
147
+ pending.actionKind === "user_input"
148
+ ? normalizeUserInputAnswers(pending.questions, body.answers || {})
149
+ : null,
150
+ at: nowIso(),
151
+ flowContinuation: pending.flowContinuation || "",
152
+ flowLabel: pending.flowLabel || "",
153
+ flowStep: pending.flowStep || null,
154
+ itemId: pending.itemId || null,
155
+ kind: pending.kind,
156
+ kindLabel: pending.kindLabel || null,
157
+ requestId: pending.requestId,
158
+ retryAttempt: pending.retryAttempt || 1,
159
+ summary: pending.summary || null,
160
+ source: "app-server",
161
+ status: "responded",
162
+ threadId: pending.threadId || null,
163
+ turnId: pending.turnId || null
164
+ };
165
+ if (isWriterSurface(body.source) && pending.threadId) {
166
+ setControlLease({
167
+ clientId: authorityClientId,
168
+ owner: body.source,
169
+ reason: "interaction",
170
+ source: body.source,
171
+ threadId: pending.threadId,
172
+ ttlMs: controlLeaseTtlMs
173
+ });
174
+ }
175
+ liveState.pendingInteraction = null;
176
+ liveState.lastError = null;
177
+ broadcast("live", buildLivePayload());
178
+ }
179
+
180
+ return {
181
+ getCommandDecision,
182
+ normalizeUserInputAnswers,
183
+ resolvePendingInteraction
184
+ };
185
+ }
@@ -0,0 +1,360 @@
1
+ export function createInteractionStateService({
2
+ appServerState,
3
+ liveState,
4
+ nowIso = () => new Date().toISOString(),
5
+ trimInteractionText = (value, maxLength = 72) => String(value || "").trim().slice(0, maxLength)
6
+ } = {}) {
7
+ function interactionKindLabel(request) {
8
+ switch (request.method) {
9
+ case "item/commandExecution/requestApproval":
10
+ return "Command";
11
+ case "item/fileChange/requestApproval":
12
+ return "File change";
13
+ case "item/permissions/requestApproval":
14
+ return "Permissions";
15
+ case "item/tool/requestUserInput":
16
+ return "Tool input";
17
+ default:
18
+ return "Action";
19
+ }
20
+ }
21
+
22
+ function interactionRequestSummary(request) {
23
+ switch (request.method) {
24
+ case "item/commandExecution/requestApproval":
25
+ return trimInteractionText(request.params?.command || "command approval", 52);
26
+ case "item/fileChange/requestApproval":
27
+ return trimInteractionText(request.params?.changes?.[0]?.path || "file change", 52);
28
+ case "item/permissions/requestApproval":
29
+ return "permissions";
30
+ case "item/tool/requestUserInput": {
31
+ const firstQuestion = request.params?.questions?.[0] || null;
32
+ const questionText = `${firstQuestion?.question || ""}`;
33
+ const matchedTool = questionText.match(/tool "([^"]+)"/i);
34
+ if (matchedTool?.[1]) {
35
+ return `${matchedTool[1]} approval`;
36
+ }
37
+ return trimInteractionText(firstQuestion?.header || firstQuestion?.question || "user input", 52);
38
+ }
39
+ default:
40
+ return trimInteractionText(request.method || "interaction", 52);
41
+ }
42
+ }
43
+
44
+ function interactionRetryContinuation(summary, action = null) {
45
+ switch (action) {
46
+ case "cancel":
47
+ return `Codex asked for ${summary} again after the last step was canceled.`;
48
+ case "decline":
49
+ return `Codex asked for ${summary} again after the last step was declined.`;
50
+ case "session":
51
+ return `Codex asked for ${summary} again after session access was allowed.`;
52
+ case "submit":
53
+ return `Codex asked for ${summary} again after the last input was submitted.`;
54
+ default:
55
+ return `Codex asked for ${summary} again in the same turn.`;
56
+ }
57
+ }
58
+
59
+ function describeInteractionFlow({
60
+ actionKind = "",
61
+ flowStep = 1,
62
+ previousAction = null,
63
+ previousSummary = "",
64
+ retryAttempt = 1,
65
+ summary = ""
66
+ } = {}) {
67
+ const flowLabel = flowStep > 1 ? `Step ${flowStep} of the live flow` : "Waiting on this turn";
68
+
69
+ if (retryAttempt > 1) {
70
+ return {
71
+ flowContinuation: interactionRetryContinuation(summary, previousAction),
72
+ flowLabel
73
+ };
74
+ }
75
+
76
+ if (flowStep > 1) {
77
+ if (previousSummary && previousSummary !== summary) {
78
+ return {
79
+ flowContinuation: `Last step settled: ${previousSummary}. Now waiting on ${summary}.`,
80
+ flowLabel
81
+ };
82
+ }
83
+
84
+ return {
85
+ flowContinuation: summary
86
+ ? `Continuing ${summary} in the same turn.`
87
+ : "Continuing the same live flow.",
88
+ flowLabel
89
+ };
90
+ }
91
+
92
+ return {
93
+ flowContinuation:
94
+ actionKind === "user_input"
95
+ ? "Codex needs this input before the turn can continue."
96
+ : "Codex needs this decision before the turn can continue.",
97
+ flowLabel
98
+ };
99
+ }
100
+
101
+ function beginInteractionFlow(request) {
102
+ const threadId = request.params?.threadId || null;
103
+ const turnId = request.params?.turnId || null;
104
+ const summary = interactionRequestSummary(request);
105
+ const previous = liveState.interactionFlow;
106
+ const sameTurn = Boolean(
107
+ previous &&
108
+ previous.threadId &&
109
+ previous.threadId === threadId &&
110
+ previous.turnId &&
111
+ previous.turnId === turnId
112
+ );
113
+ const sameRequest = Boolean(sameTurn && previous.method === request.method && previous.summary === summary);
114
+ const lastInteraction =
115
+ appServerState.lastInteraction?.threadId === threadId && appServerState.lastInteraction?.turnId === turnId
116
+ ? appServerState.lastInteraction
117
+ : null;
118
+ const step = sameTurn ? (previous.step || 0) + 1 : 1;
119
+ const retryAttempt = sameRequest ? (previous.retryAttempt || 1) + 1 : 1;
120
+
121
+ liveState.interactionFlow = {
122
+ method: request.method,
123
+ previousAction: sameRequest ? lastInteraction?.action || null : null,
124
+ previousSummary: sameTurn && !sameRequest ? previous.summary || "" : sameTurn ? previous.previousSummary || "" : "",
125
+ requestId: request.requestId,
126
+ retryAttempt,
127
+ startedAt: sameTurn ? previous.startedAt : nowIso(),
128
+ step,
129
+ summary,
130
+ threadId,
131
+ turnId
132
+ };
133
+
134
+ return liveState.interactionFlow;
135
+ }
136
+
137
+ function clearInteractionFlow({ threadId = null } = {}) {
138
+ if (!threadId || liveState.interactionFlow?.threadId === threadId) {
139
+ liveState.interactionFlow = null;
140
+ }
141
+ }
142
+
143
+ function summarizeNotificationInteraction(pending, request) {
144
+ return {
145
+ at: nowIso(),
146
+ detail: pending?.detail || "",
147
+ flowContinuation: pending?.flowContinuation || "",
148
+ flowLabel: pending?.flowLabel || "",
149
+ flowStep: pending?.flowStep || null,
150
+ itemId: request.params?.itemId || null,
151
+ kind: pending?.kind || "interaction",
152
+ kindLabel: pending?.kindLabel || null,
153
+ requestId: request.requestId,
154
+ retryAttempt: pending?.retryAttempt || 1,
155
+ summary: pending?.summary || interactionRequestSummary(request),
156
+ source: "app-server",
157
+ status: "pending",
158
+ threadId: pending?.threadId || request.params?.threadId || null,
159
+ turnId: pending?.turnId || request.params?.turnId || null
160
+ };
161
+ }
162
+
163
+ function mapPendingInteraction(request, flow = null) {
164
+ const flowStep = flow?.step || 1;
165
+ const kindLabel = interactionKindLabel(request);
166
+ const summary = interactionRequestSummary(request);
167
+ const retryAttempt = flow?.retryAttempt || 1;
168
+ const { flowLabel, flowContinuation } = describeInteractionFlow({
169
+ actionKind:
170
+ request.method === "item/tool/requestUserInput"
171
+ ? "user_input"
172
+ : "approval",
173
+ flowStep,
174
+ previousAction: flow?.previousAction || null,
175
+ previousSummary: flow?.previousSummary || "",
176
+ retryAttempt,
177
+ summary
178
+ });
179
+
180
+ switch (request.method) {
181
+ case "item/commandExecution/requestApproval":
182
+ return {
183
+ actionKind: "approval",
184
+ approveLabel: "Approve once",
185
+ availableDecisions: request.params.availableDecisions || ["accept", "decline", "cancel"],
186
+ canApproveForSession: (request.params.availableDecisions || []).includes("acceptForSession"),
187
+ command: request.params.command || null,
188
+ cwd: request.params.cwd || null,
189
+ declineLabel: "Decline",
190
+ detail:
191
+ request.params.reason ||
192
+ request.params.command ||
193
+ "Codex requested permission to execute a command.",
194
+ flowContinuation,
195
+ flowLabel,
196
+ flowStep,
197
+ itemId: request.params.itemId || null,
198
+ kind: "command",
199
+ kindLabel,
200
+ method: request.method,
201
+ requestId: request.requestId,
202
+ retryAttempt,
203
+ sessionActionLabel: "Approve for session",
204
+ summary,
205
+ threadId: request.params.threadId,
206
+ title: request.params.command || "Approve command",
207
+ turnId: request.params.turnId
208
+ };
209
+ case "item/fileChange/requestApproval":
210
+ return {
211
+ actionKind: "approval",
212
+ approveLabel: "Approve",
213
+ availableDecisions: ["accept", "decline", "cancel"],
214
+ canApproveForSession: false,
215
+ declineLabel: "Decline",
216
+ detail: request.params.reason || "Codex requested approval for a file change.",
217
+ flowContinuation,
218
+ flowLabel,
219
+ flowStep,
220
+ itemId: request.params.itemId || null,
221
+ kind: "file_change",
222
+ kindLabel,
223
+ method: request.method,
224
+ requestId: request.requestId,
225
+ retryAttempt,
226
+ sessionActionLabel: "",
227
+ summary,
228
+ threadId: request.params.threadId,
229
+ title: "Approve file change",
230
+ turnId: request.params.turnId
231
+ };
232
+ case "item/permissions/requestApproval":
233
+ return {
234
+ actionKind: "approval",
235
+ approveLabel: "Allow turn",
236
+ canApproveForSession: true,
237
+ declineLabel: "Decline",
238
+ detail: request.params.reason || "Codex requested additional permissions.",
239
+ flowContinuation,
240
+ flowLabel,
241
+ flowStep,
242
+ itemId: request.params.itemId || null,
243
+ kind: "permissions",
244
+ kindLabel,
245
+ method: request.method,
246
+ permissions: request.params.permissions,
247
+ requestId: request.requestId,
248
+ retryAttempt,
249
+ sessionActionLabel: "Allow session",
250
+ summary,
251
+ threadId: request.params.threadId,
252
+ title: "Grant permissions",
253
+ turnId: request.params.turnId
254
+ };
255
+ case "item/tool/requestUserInput":
256
+ return {
257
+ actionKind: "user_input",
258
+ detail: trimInteractionText(
259
+ request.params.questions?.[0]?.question ||
260
+ request.params.questions?.[0]?.header ||
261
+ "Codex needs user input to continue.",
262
+ 180
263
+ ),
264
+ flowContinuation,
265
+ flowLabel,
266
+ flowStep,
267
+ itemId: request.params.itemId || null,
268
+ kind: "tool_input",
269
+ kindLabel,
270
+ method: request.method,
271
+ questions: request.params.questions || [],
272
+ requestId: request.requestId,
273
+ retryAttempt,
274
+ submitLabel: "Submit",
275
+ summary,
276
+ threadId: request.params.threadId,
277
+ title: "Provide input",
278
+ turnId: request.params.turnId
279
+ };
280
+ default:
281
+ return {
282
+ actionKind: "unsupported",
283
+ detail: `Unsupported server request: ${request.method}`,
284
+ flowContinuation,
285
+ flowLabel,
286
+ flowStep,
287
+ kind: "unsupported",
288
+ kindLabel,
289
+ method: request.method,
290
+ requestId: request.requestId,
291
+ retryAttempt,
292
+ summary,
293
+ title: "Unsupported request"
294
+ };
295
+ }
296
+ }
297
+
298
+ function getScopedEvent(event) {
299
+ if (!event) {
300
+ return null;
301
+ }
302
+
303
+ const selectedThreadId = liveState.selectedThreadId || null;
304
+ if (!selectedThreadId || !event.threadId) {
305
+ return selectedThreadId ? null : event;
306
+ }
307
+
308
+ return event.threadId === selectedThreadId ? event : null;
309
+ }
310
+
311
+ function getLastInteractionForSelectedThread() {
312
+ return getScopedEvent(appServerState.lastInteraction || null);
313
+ }
314
+
315
+ function getPendingInteractionForSelectedThread() {
316
+ const pending = liveState.pendingInteraction || null;
317
+ if (!pending) {
318
+ return null;
319
+ }
320
+
321
+ const selectedThreadId = liveState.selectedThreadId || null;
322
+ if (!selectedThreadId || !pending.threadId) {
323
+ return pending;
324
+ }
325
+
326
+ return pending.threadId === selectedThreadId ? pending : null;
327
+ }
328
+
329
+ function getLastWriteForSelectedThread() {
330
+ return getScopedEvent(appServerState.lastWrite || null);
331
+ }
332
+
333
+ function getLastControlEventForSelectedThread() {
334
+ return getScopedEvent(appServerState.lastControlEvent || null);
335
+ }
336
+
337
+ function getLastSelectionEventForSelectedThread() {
338
+ return getScopedEvent(appServerState.lastSelectionEvent || null);
339
+ }
340
+
341
+ function getLastSurfaceEventForSelectedThread() {
342
+ return getScopedEvent(appServerState.lastSurfaceEvent || null);
343
+ }
344
+
345
+ return {
346
+ beginInteractionFlow,
347
+ clearInteractionFlow,
348
+ describeInteractionFlow,
349
+ getLastControlEventForSelectedThread,
350
+ getLastInteractionForSelectedThread,
351
+ getLastSelectionEventForSelectedThread,
352
+ getLastSurfaceEventForSelectedThread,
353
+ getLastWriteForSelectedThread,
354
+ getPendingInteractionForSelectedThread,
355
+ interactionKindLabel,
356
+ interactionRequestSummary,
357
+ mapPendingInteraction,
358
+ summarizeNotificationInteraction
359
+ };
360
+ }
@@ -0,0 +1,158 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ import { APP_SERVER_DRIFT_RUNBOOK_PATH } from "./app-server-contract.mjs";
7
+
8
+ export function defaultLaunchStatusPath({ cwd = process.cwd() } = {}) {
9
+ return path.join(cwd, ".agent", "artifacts", "launch", "local-launch-status.json");
10
+ }
11
+
12
+ export function computeLaunchFingerprint({
13
+ cwd = process.cwd(),
14
+ execFileSyncImpl = execFileSync
15
+ } = {}) {
16
+ try {
17
+ execFileSyncImpl("git", ["-C", cwd, "rev-parse", "--git-dir"], {
18
+ encoding: "utf8",
19
+ stdio: ["ignore", "pipe", "ignore"]
20
+ }).trim();
21
+
22
+ let head = null;
23
+ try {
24
+ head = execFileSyncImpl("git", ["-C", cwd, "rev-parse", "HEAD"], {
25
+ encoding: "utf8",
26
+ stdio: ["ignore", "pipe", "ignore"]
27
+ }).trim();
28
+ } catch {
29
+ head = null;
30
+ }
31
+
32
+ const status = execFileSyncImpl(
33
+ "git",
34
+ ["-C", cwd, "status", "--porcelain=v1", "--untracked-files=normal"],
35
+ {
36
+ encoding: "utf8",
37
+ stdio: ["ignore", "pipe", "ignore"]
38
+ }
39
+ ).trimEnd();
40
+ const digest = createHash("sha1").update(status).digest("hex");
41
+ return {
42
+ head,
43
+ hasGit: true,
44
+ statusDigest: digest,
45
+ fingerprint: `${head || "nohead"}:${digest}`
46
+ };
47
+ } catch {
48
+ const digest = createHash("sha1").update(cwd).digest("hex");
49
+ return {
50
+ head: null,
51
+ hasGit: false,
52
+ statusDigest: digest,
53
+ fingerprint: `nogit:${digest}`
54
+ };
55
+ }
56
+ }
57
+
58
+ export async function readLaunchAttestation({ statusPath = defaultLaunchStatusPath() } = {}) {
59
+ try {
60
+ const raw = await readFile(statusPath, "utf8");
61
+ return JSON.parse(raw);
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export function deriveLaunchBar({
68
+ fingerprint,
69
+ state,
70
+ requiredManualChecks = DEFAULT_MANUAL_CHECKS
71
+ }) {
72
+ const automatedPass = Boolean(state?.automated?.fingerprint === fingerprint);
73
+ const manualPass = Boolean(state?.manual?.fingerprint === fingerprint);
74
+
75
+ const staleAutomated = Boolean(state?.automated && !automatedPass);
76
+ const staleManual = Boolean(state?.manual && !manualPass);
77
+
78
+ let status = "RED";
79
+ let message = "Automated launch checks have not been confirmed for this build.";
80
+ if (automatedPass && manualPass) {
81
+ status = "GREEN";
82
+ message = "Local launch bar is green.";
83
+ } else if (automatedPass) {
84
+ status = "YELLOW";
85
+ message = "Automated launch bar is green. Manual launch checks still required.";
86
+ } else if (staleAutomated || staleManual) {
87
+ message = "Launch attestations are stale for the current repo state. Re-run the launch checks.";
88
+ }
89
+
90
+ return {
91
+ acceptedLimitations: ACCEPTED_LAUNCH_LIMITATIONS,
92
+ automatedPass,
93
+ docs: LAUNCH_SUPPORT_DOCS,
94
+ manualPass,
95
+ message,
96
+ requiredManualChecks,
97
+ staleAutomated,
98
+ staleManual,
99
+ status
100
+ };
101
+ }
102
+
103
+ export async function writeLaunchAttestation({
104
+ kind,
105
+ cwd = process.cwd(),
106
+ statusPath = defaultLaunchStatusPath({ cwd }),
107
+ now = new Date().toISOString(),
108
+ fingerprint = computeLaunchFingerprint({ cwd })
109
+ } = {}) {
110
+ if (!kind || !["automated", "manual"].includes(kind)) {
111
+ throw new Error("writeLaunchAttestation requires kind=automated|manual");
112
+ }
113
+
114
+ const current = (await readLaunchAttestation({ statusPath })) ?? { version: 1 };
115
+ const next = {
116
+ ...current,
117
+ version: 1,
118
+ [kind]: {
119
+ fingerprint: fingerprint.fingerprint,
120
+ hasGit: fingerprint.hasGit,
121
+ head: fingerprint.head,
122
+ recordedAt: now
123
+ }
124
+ };
125
+
126
+ await mkdir(path.dirname(statusPath), { recursive: true });
127
+ await writeFile(statusPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
128
+ return next;
129
+ }
130
+
131
+ export async function clearLaunchAttestations({
132
+ statusPath = defaultLaunchStatusPath()
133
+ } = {}) {
134
+ await rm(statusPath, { force: true });
135
+ }
136
+
137
+ export const DEFAULT_MANUAL_CHECKS = [
138
+ "Open the remote after a fresh restart and confirm the selected room loads cleanly.",
139
+ "Switch feed filters and confirm the visible lane actually changes, not just the button state.",
140
+ "Send or queue one remote reply and confirm the control and queue UX stays clear on the selected thread.",
141
+ "Trigger Gemini or Oracle from the room and confirm the action only stages advisory text; it must not auto-send or silently take control.",
142
+ "Use Reveal in Codex and confirm the correct desktop thread opens while the restart caveat stays visible.",
143
+ "Confirm the remote clearly signals shared-room behavior and keeps drafts and queue local to that surface.",
144
+ "If testing desktop visibility, restart the Codex app and confirm remote-written turns appear after restart."
145
+ ];
146
+
147
+ export const ACCEPTED_LAUNCH_LIMITATIONS = [
148
+ "Desktop Codex still requires a full app restart to reliably rehydrate externally written turns.",
149
+ "Reveal in Codex is a navigation aid, not a desktop visibility promise.",
150
+ "Desktop recovery is manual: quit and reopen the Codex app when you need to see newer turns there."
151
+ ];
152
+
153
+ export const LAUNCH_SUPPORT_DOCS = [
154
+ "docs/ops/apple-host-options.md",
155
+ "docs/ops/apple-menubar-release.md",
156
+ "docs/ops/bridge-api-contract.md",
157
+ "docs/ops/desktop-sync.md"
158
+ ];