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.
- package/LICENSE +211 -0
- package/README.md +112 -0
- package/SECURITY.md +27 -0
- package/SUPPORT.md +43 -0
- package/package.json +44 -0
- package/public/client-shared.js +1831 -0
- package/public/favicon.svg +11 -0
- package/public/host.html +29 -0
- package/public/host.js +2079 -0
- package/public/index.html +28 -0
- package/public/index.js +98 -0
- package/public/live-bridge-lifecycle.js +258 -0
- package/public/live-bridge-retry-state.js +61 -0
- package/public/live-selection-intent.js +79 -0
- package/public/remote-operator-state.js +316 -0
- package/public/remote.html +167 -0
- package/public/remote.js +3967 -0
- package/public/styles.css +2793 -0
- package/public/surface-view-state.js +89 -0
- package/public/voice-dictation.js +45 -0
- package/src/bin/desktop-rehydration-smoke.mjs +111 -0
- package/src/bin/dextunnel.mjs +41 -0
- package/src/bin/doctor.mjs +48 -0
- package/src/bin/launch-attest.mjs +39 -0
- package/src/bin/launch-status.mjs +49 -0
- package/src/bin/mobile-link-proxy.mjs +221 -0
- package/src/bin/mobile-proof.mjs +164 -0
- package/src/bin/mobile-transport-smoke.mjs +200 -0
- package/src/bin/probe-codex-app-server-write.mjs +36 -0
- package/src/bin/probe-codex-app-server.mjs +30 -0
- package/src/lib/agent-room-context.mjs +54 -0
- package/src/lib/agent-room-runtime.mjs +355 -0
- package/src/lib/agent-room-service.mjs +335 -0
- package/src/lib/agent-room-state.mjs +406 -0
- package/src/lib/agent-room-store.mjs +71 -0
- package/src/lib/agent-room-text.mjs +48 -0
- package/src/lib/app-server-contract.mjs +66 -0
- package/src/lib/app-server-runtime.mjs +60 -0
- package/src/lib/attachment-service.mjs +119 -0
- package/src/lib/bridge-api-handler.mjs +719 -0
- package/src/lib/bridge-runtime-lifecycle.mjs +51 -0
- package/src/lib/bridge-status-builder.mjs +60 -0
- package/src/lib/codex-app-server-client.mjs +1511 -0
- package/src/lib/companion-state.mjs +453 -0
- package/src/lib/control-lease-service.mjs +180 -0
- package/src/lib/debug-harness-service.mjs +173 -0
- package/src/lib/desktop-integration.mjs +146 -0
- package/src/lib/desktop-rehydration-smoke.mjs +269 -0
- package/src/lib/dextunnel-cli.mjs +122 -0
- package/src/lib/discovery-docs.mjs +1321 -0
- package/src/lib/fake-codex-app-server-bridge.mjs +340 -0
- package/src/lib/install-preflight.mjs +373 -0
- package/src/lib/interaction-resolution-service.mjs +185 -0
- package/src/lib/interaction-state.mjs +360 -0
- package/src/lib/launch-release-bar.mjs +158 -0
- package/src/lib/live-control-state.mjs +107 -0
- package/src/lib/live-payload-builder.mjs +298 -0
- package/src/lib/live-selection-transition-state.mjs +49 -0
- package/src/lib/live-transcript-state.mjs +549 -0
- package/src/lib/mobile-network-profile.mjs +39 -0
- package/src/lib/mock-codex-adapter.mjs +62 -0
- package/src/lib/operator-diagnostics.mjs +82 -0
- package/src/lib/repo-changes-service.mjs +527 -0
- package/src/lib/runtime-config.mjs +106 -0
- package/src/lib/selection-state-service.mjs +214 -0
- package/src/lib/session-store.mjs +355 -0
- package/src/lib/shared-room-state.mjs +473 -0
- package/src/lib/shared-selection-state.mjs +40 -0
- package/src/lib/sse-hub.mjs +35 -0
- package/src/lib/static-surface-service.mjs +71 -0
- package/src/lib/surface-access.mjs +189 -0
- package/src/lib/surface-presence-service.mjs +118 -0
- package/src/lib/surface-request-guard.mjs +52 -0
- package/src/lib/thread-sync-state.mjs +536 -0
- package/src/lib/watcher-lifecycle.mjs +287 -0
- 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
|
+
];
|