@logicseek/hello-cc 1.0.1 → 1.0.2
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/CHANGELOG.md +105 -0
- package/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/docs/README.md +9 -0
- package/docs/README.zh-CN.md +9 -0
- package/docs/commands.md +53 -0
- package/docs/commands.zh-CN.md +48 -0
- package/docs/dsh.md +137 -0
- package/docs/dsh.zh-CN.md +139 -0
- package/docs/mcp.md +9 -0
- package/docs/mcp.zh-CN.md +23 -0
- package/docs/native.md +295 -0
- package/docs/native.zh-CN.md +253 -0
- package/docs/web-handoff.zh-CN.md +53 -0
- package/lib/cli/app.mjs +25 -6
- package/lib/cli/commands/doctor.mjs +17 -2
- package/lib/cli/commands/dsh.mjs +79 -0
- package/lib/cli/commands/hook.mjs +241 -90
- package/lib/cli/commands/lock.mjs +34 -19
- package/lib/cli/commands/mcp.mjs +28 -0
- package/lib/cli/commands/native.mjs +130 -0
- package/lib/cli/commands/run.mjs +66 -21
- package/lib/cli/commands/task.mjs +6 -3
- package/lib/cli-args.mjs +4 -0
- package/lib/coordination-state.mjs +15 -1
- package/lib/core/coordination/event-retention.mjs +14 -2
- package/lib/core/coordination/gc-plan.mjs +3 -13
- package/lib/core/coordination/locks.mjs +45 -0
- package/lib/core/coordination/results.mjs +79 -0
- package/lib/core/peers/bindings.mjs +9 -3
- package/lib/diagnostics/codex.mjs +121 -0
- package/lib/integrations/codex-interactions.mjs +114 -0
- package/lib/integrations/dsh-collaboration.mjs +170 -0
- package/lib/integrations/dsh-cordis.d.ts +19 -0
- package/lib/integrations/dsh-cordis.mjs +177 -0
- package/lib/integrations/dsh.bundle.yml +7 -0
- package/lib/integrations/dsh.mjs +287 -0
- package/lib/integrations/native/claude.mjs +451 -0
- package/lib/integrations/native/codex.mjs +242 -0
- package/lib/integrations/native/dsh-acp.mjs +343 -0
- package/lib/integrations/native/index.mjs +19 -0
- package/lib/integrations/native/interactions.mjs +53 -0
- package/lib/integrations/native/jsonrpc.mjs +185 -0
- package/lib/integrations/peers/identity.mjs +36 -10
- package/lib/integrations/peers/processes.mjs +22 -8
- package/lib/integrations/providers.mjs +6 -2
- package/lib/mcp/scope.mjs +104 -0
- package/lib/mcp/stdio.mjs +80 -0
- package/lib/mcp/tools.mjs +197 -0
- package/lib/process/identity.mjs +3 -1
- package/lib/release/release-notes.mjs +9 -0
- package/lib/runtime/native/client.mjs +38 -0
- package/lib/runtime/native/service.mjs +511 -0
- package/lib/runtime/native/store.mjs +121 -0
- package/lib/ui/help.mjs +5 -2
- package/lib/web/adoption-state.mjs +55 -0
- package/lib/web/auto-attach.mjs +18 -2
- package/lib/web/codex-app-server.mjs +557 -0
- package/lib/web/codex-sessions.mjs +287 -0
- package/lib/web/control-lease.mjs +204 -0
- package/lib/web/http-routes.mjs +197 -15
- package/lib/web/http.mjs +5 -4
- package/lib/web/native-sessions.mjs +286 -0
- package/lib/web/project-contexts.mjs +2 -1
- package/lib/web/pty-sessions.mjs +60 -33
- package/lib/web/runtime-main.mjs +98 -6
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/task-results.mjs +76 -0
- package/lib/web/tmux-sessions.mjs +92 -11
- package/lib/web/tmux-stream.mjs +2 -2
- package/lib/web/ui-codex.mjs +550 -0
- package/lib/web/ui-command-palette.mjs +119 -0
- package/lib/web/ui-handoff.mjs +102 -0
- package/lib/web/ui-history.mjs +118 -0
- package/lib/web/ui-i18n.mjs +841 -0
- package/lib/web/ui-interactions.mjs +78 -0
- package/lib/web/ui-login.mjs +101 -0
- package/lib/web/ui-native.mjs +165 -0
- package/lib/web/ui-preferences.mjs +278 -0
- package/lib/web/ui-review.mjs +132 -0
- package/lib/web/ui-template.mjs +1106 -576
- package/lib/web/ui-terminal-find.mjs +98 -0
- package/lib/web/ui-workbench.mjs +94 -0
- package/lib/web/ui-workspace.mjs +222 -0
- package/package.json +11 -3
- package/scripts/check-free-identifiers.mjs +67 -8
- package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
- package/scripts/dsh-cordis-acceptance.mjs +200 -0
- package/scripts/dsh-installed-acceptance.mjs +247 -0
- package/scripts/dsh-official-acceptance.mjs +180 -0
- package/scripts/dsh-web-startup-acceptance.mjs +87 -0
- package/scripts/github-release.mjs +3 -1
- package/scripts/native-live-acceptance.mjs +369 -0
- package/scripts/regression.mjs +243 -89
- package/scripts/web-handoff-installed-acceptance.mjs +98 -0
- package/scripts/web-native-interaction-acceptance.mjs +204 -0
- package/scripts/web-native-model-acceptance.mjs +138 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { CODEX_INTERACTION_METHODS, codexInteractionKind, codexInteractionResponse, cancelCodexInteraction, codexInteractiveConfig } from '../integrations/codex-interactions.mjs';
|
|
5
|
+
|
|
6
|
+
// stdio v2 protocol, checked against openai/codex 14a477ea89712071944244022e8a10142845456e.
|
|
7
|
+
// This adapter is created only by an explicitly selected App Server backend.
|
|
8
|
+
// Transport loss never reconnects or resubmits a mutating request.
|
|
9
|
+
const APPROVAL_METHODS = CODEX_INTERACTION_METHODS;
|
|
10
|
+
const TERMINAL_TURNS = new Set(['completed', 'interrupted', 'failed']);
|
|
11
|
+
const MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
const MAX_TEXT = 65536;
|
|
13
|
+
|
|
14
|
+
export class CodexAppServerError extends Error {
|
|
15
|
+
constructor(code, message, extra = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'CodexAppServerError';
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.extra = extra;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function requiredId(value, name) {
|
|
24
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 512) {
|
|
25
|
+
throw new CodexAppServerError('BAD_REQUEST', `${name} is required`);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function objectParams(value) {
|
|
31
|
+
if (value === undefined) return {};
|
|
32
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
33
|
+
throw new CodexAppServerError('BAD_REQUEST', 'parameters must be an object');
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeInput(input) {
|
|
39
|
+
if (typeof input === 'string' && input.trim()) {
|
|
40
|
+
return [{ type: 'text', text: input, text_elements: [] }];
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(input) && input.length && input.every((item) => item && typeof item === 'object')) {
|
|
43
|
+
return input.map((item) => item.type === 'text' ? { ...item, text_elements: item.text_elements || [] } : { ...item });
|
|
44
|
+
}
|
|
45
|
+
throw new CodexAppServerError('BAD_REQUEST', 'non-empty input is required');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function rpcKey(id) {
|
|
49
|
+
return `${typeof id}:${id}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createCodexAppServer({
|
|
53
|
+
cwd, env = process.env, onChange = () => {},
|
|
54
|
+
onSpawn = async () => null,
|
|
55
|
+
requestTimeoutMs = 30000, closeTimeoutMs = 1000, clientVersion = '1.0.1',
|
|
56
|
+
// Test seam. The production command and its arguments are always fixed below.
|
|
57
|
+
spawnProcess = spawn
|
|
58
|
+
} = {}) {
|
|
59
|
+
const root = path.resolve(cwd || process.cwd());
|
|
60
|
+
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) throw new TypeError('requestTimeoutMs must be positive');
|
|
61
|
+
const executorId = randomUUID();
|
|
62
|
+
let status = 'new';
|
|
63
|
+
let child = null;
|
|
64
|
+
let initialization = null;
|
|
65
|
+
let metadata = null;
|
|
66
|
+
let threadConfig = null;
|
|
67
|
+
let sequence = 0;
|
|
68
|
+
let notificationSequence = 0;
|
|
69
|
+
let currentThreadId = null;
|
|
70
|
+
let buffer = '';
|
|
71
|
+
let truncated = false;
|
|
72
|
+
let closePromise = null;
|
|
73
|
+
const threads = new Map();
|
|
74
|
+
const pending = new Map();
|
|
75
|
+
const approvals = new Map();
|
|
76
|
+
const uncertain = new Map();
|
|
77
|
+
const submittingThreads = new Set();
|
|
78
|
+
const threadNotificationSequences = new Map();
|
|
79
|
+
const events = [];
|
|
80
|
+
|
|
81
|
+
function bounded(value, limit = MAX_TEXT, depth = 0) {
|
|
82
|
+
if (typeof value === 'string') {
|
|
83
|
+
if (value.length <= limit) return value;
|
|
84
|
+
truncated = true;
|
|
85
|
+
return `${value.slice(0, limit)}\n[truncated]`;
|
|
86
|
+
}
|
|
87
|
+
if (depth > 12) { truncated = true; return '[truncated]'; }
|
|
88
|
+
if (Array.isArray(value)) {
|
|
89
|
+
if (value.length > 100) truncated = true;
|
|
90
|
+
return value.slice(-100).map((entry) => bounded(entry, limit, depth + 1));
|
|
91
|
+
}
|
|
92
|
+
if (value && typeof value === 'object') {
|
|
93
|
+
const entries = Object.entries(value);
|
|
94
|
+
if (entries.length > 100) truncated = true;
|
|
95
|
+
return Object.fromEntries(entries.slice(0, 100).map(([key, entry]) => [key, bounded(entry, limit, depth + 1)]));
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function snapshot() {
|
|
101
|
+
const current = threads.get(currentThreadId);
|
|
102
|
+
return structuredClone({
|
|
103
|
+
executorId, backend: 'codex-app-server', status, cwd: root, pid: child?.pid || null,
|
|
104
|
+
processExited: Boolean(child && (child.exitCode != null || child.signalCode != null)),
|
|
105
|
+
metadata, currentThreadId, threadId: currentThreadId, turnId: current?.activeTurnId || null,
|
|
106
|
+
threads: [...threads.values()],
|
|
107
|
+
pendingApprovals: [...approvals.values()].filter((approval) => approval.status === 'pending'),
|
|
108
|
+
uncertainSubmissions: [...uncertain.values()].map(({ params, ...entry }) => entry),
|
|
109
|
+
uncertainRecovery: uncertain.size ? 'stop-executor-and-resume-history' : null,
|
|
110
|
+
events, truncated
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function changed(method, params = {}) {
|
|
115
|
+
const event = { method, params: bounded(params, 8192), at: Date.now() };
|
|
116
|
+
events.push(event);
|
|
117
|
+
if (events.length > 100) events.shift();
|
|
118
|
+
try { onChange(snapshot(), structuredClone(event)); } catch { /* UI observers do not own transport. */ }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ensureThread(id) {
|
|
122
|
+
requiredId(id, 'threadId');
|
|
123
|
+
if (!threads.has(id)) {
|
|
124
|
+
if (threads.size >= 10) {
|
|
125
|
+
const evictable = [...threads.values()].find((thread) => !thread.activeTurnId &&
|
|
126
|
+
![...approvals.values()].some((approval) => approval.threadId === thread.id));
|
|
127
|
+
if (!evictable) throw new CodexAppServerError('CODEX_THREAD_LIMIT', 'too many active threads');
|
|
128
|
+
threads.delete(evictable.id);
|
|
129
|
+
truncated = true;
|
|
130
|
+
}
|
|
131
|
+
threads.set(id, { id, status: { type: 'notLoaded' }, activeTurnId: null, turns: [] });
|
|
132
|
+
}
|
|
133
|
+
return threads.get(id);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function ensureTurn(threadId, turnId) {
|
|
137
|
+
const thread = ensureThread(threadId);
|
|
138
|
+
requiredId(turnId, 'turnId');
|
|
139
|
+
let turn = thread.turns.find((entry) => entry.id === turnId);
|
|
140
|
+
if (!turn) {
|
|
141
|
+
turn = { id: turnId, status: 'inProgress', items: [], diff: '', plan: [] };
|
|
142
|
+
thread.turns.push(turn);
|
|
143
|
+
if (thread.turns.length > 10) { thread.turns.shift(); truncated = true; }
|
|
144
|
+
}
|
|
145
|
+
return turn;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function expireApprovals(threadId, turnId = null) {
|
|
149
|
+
for (const [key, approval] of approvals) {
|
|
150
|
+
if (approval.threadId === threadId && (!turnId || approval.turnId === turnId)) {
|
|
151
|
+
approvals.delete(key);
|
|
152
|
+
if (approval.status === 'pending' && status === 'ready') write({ id: approval.requestId, result: cancelCodexInteraction(approval.method) });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function boundItems(turn) {
|
|
158
|
+
while (turn.items.length > 100 || JSON.stringify(turn.items).length > 1024 * 1024) {
|
|
159
|
+
turn.items.shift(); truncated = true;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function absorbThread(value, requestSequence = null) {
|
|
164
|
+
if (!value?.id) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'thread response has no id');
|
|
165
|
+
const thread = ensureThread(value.id);
|
|
166
|
+
const previousStatus = thread.status;
|
|
167
|
+
const previousActiveTurnId = thread.activeTurnId;
|
|
168
|
+
const newerNotification = requestSequence !== null &&
|
|
169
|
+
(threadNotificationSequences.get(value.id) || 0) > requestSequence;
|
|
170
|
+
const oldTurns = thread.turns;
|
|
171
|
+
Object.assign(thread, bounded(value));
|
|
172
|
+
thread.turns = oldTurns;
|
|
173
|
+
for (const turn of value.turns || []) absorbTurn(value.id, turn);
|
|
174
|
+
if (newerNotification) {
|
|
175
|
+
thread.status = previousStatus;
|
|
176
|
+
thread.activeTurnId = previousActiveTurnId;
|
|
177
|
+
} else if (value.status) thread.status = bounded(value.status);
|
|
178
|
+
if (['idle', 'notLoaded', 'systemError'].includes(thread.status?.type)) {
|
|
179
|
+
thread.activeTurnId = null;
|
|
180
|
+
expireApprovals(value.id);
|
|
181
|
+
}
|
|
182
|
+
currentThreadId = value.id;
|
|
183
|
+
return thread;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function absorbTurn(threadId, value) {
|
|
187
|
+
if (!value?.id) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'turn response has no id');
|
|
188
|
+
const thread = ensureThread(threadId);
|
|
189
|
+
const turn = ensureTurn(threadId, value.id);
|
|
190
|
+
const previousStatus = turn.status;
|
|
191
|
+
const previousItems = turn.items;
|
|
192
|
+
const previousError = turn.error;
|
|
193
|
+
const previousCompletedAt = turn.completedAt;
|
|
194
|
+
Object.assign(turn, bounded(value));
|
|
195
|
+
// Notifications can complete before the turn/start response arrives.
|
|
196
|
+
// A stale response/read must not resurrect that same terminal turn.
|
|
197
|
+
if (TERMINAL_TURNS.has(previousStatus) && !TERMINAL_TURNS.has(value.status)) {
|
|
198
|
+
turn.status = previousStatus;
|
|
199
|
+
turn.error = previousError;
|
|
200
|
+
turn.completedAt = previousCompletedAt;
|
|
201
|
+
}
|
|
202
|
+
// Completion/start envelopes often omit items (itemsView=notLoaded).
|
|
203
|
+
if (value.itemsView === 'notLoaded' || (value.items?.length === 0 && previousItems.length)) {
|
|
204
|
+
turn.items = previousItems;
|
|
205
|
+
}
|
|
206
|
+
turn.items ||= [];
|
|
207
|
+
boundItems(turn);
|
|
208
|
+
if (TERMINAL_TURNS.has(turn.status)) {
|
|
209
|
+
if (thread.activeTurnId === turn.id) thread.activeTurnId = null;
|
|
210
|
+
if (!thread.activeTurnId) thread.status = { type: 'idle' };
|
|
211
|
+
expireApprovals(threadId, turn.id);
|
|
212
|
+
} else {
|
|
213
|
+
thread.activeTurnId = turn.id;
|
|
214
|
+
thread.status = { type: 'active', activeFlags: [] };
|
|
215
|
+
}
|
|
216
|
+
return turn;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function applyResult(method, params, result, requestSequence) {
|
|
220
|
+
if (['thread/start', 'thread/resume', 'thread/read'].includes(method)) absorbThread(result?.thread, requestSequence);
|
|
221
|
+
if (method === 'turn/start') absorbTurn(params.threadId, result?.turn);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function markUncertain(entry, reason) {
|
|
225
|
+
uncertain.set(rpcKey(entry.requestId), {
|
|
226
|
+
requestId: entry.requestId, method: entry.method,
|
|
227
|
+
threadId: entry.method === 'thread/fork' ? null : entry.params.threadId || null, turnId: entry.params.turnId || entry.params.expectedTurnId || null,
|
|
228
|
+
createdAt: entry.createdAt, reason, status: 'uncertain', params: entry.params,
|
|
229
|
+
notificationSequence: entry.notificationSequence, absorb: entry.absorb
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function disconnect(code, message) {
|
|
234
|
+
if (status === 'disconnected' || status === 'closed') return;
|
|
235
|
+
status = 'disconnected';
|
|
236
|
+
for (const entry of pending.values()) {
|
|
237
|
+
clearTimeout(entry.timer);
|
|
238
|
+
const isUncertain = entry.mutation && entry.sent;
|
|
239
|
+
if (isUncertain) markUncertain(entry, code);
|
|
240
|
+
entry.reject(new CodexAppServerError(isUncertain ? 'CODEX_SUBMISSION_UNCERTAIN' : code,
|
|
241
|
+
message, { executorId, requestId: entry.requestId, method: entry.method, uncertain: isUncertain }));
|
|
242
|
+
}
|
|
243
|
+
pending.clear();
|
|
244
|
+
approvals.clear();
|
|
245
|
+
changed('transport.disconnected', { code, message });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function write(message) {
|
|
249
|
+
if (!child?.stdin || child.stdin.destroyed || ['disconnected', 'closed'].includes(status)) {
|
|
250
|
+
throw new CodexAppServerError('CODEX_DISCONNECTED', 'Codex App Server is disconnected');
|
|
251
|
+
}
|
|
252
|
+
const frame = `${JSON.stringify(message)}\n`;
|
|
253
|
+
if (Buffer.byteLength(frame) > MAX_LINE_BYTES) throw new CodexAppServerError('BAD_REQUEST', 'protocol input is too large');
|
|
254
|
+
child.stdin.write(frame, (error) => {
|
|
255
|
+
if (error) disconnect('CODEX_DISCONNECTED', 'Codex App Server input closed');
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function request(method, params, mutation = false, absorb = true) {
|
|
260
|
+
if (['disconnected', 'closed'].includes(status)) {
|
|
261
|
+
return Promise.reject(new CodexAppServerError('CODEX_DISCONNECTED', 'Create a new executor to reconnect; requests are never replayed'));
|
|
262
|
+
}
|
|
263
|
+
if (pending.size >= 128 || (mutation && uncertain.size >= 32)) {
|
|
264
|
+
return Promise.reject(new CodexAppServerError('CODEX_REQUEST_LIMIT', 'too many pending or uncertain requests; recover this executor before submitting more work'));
|
|
265
|
+
}
|
|
266
|
+
if (mutation && [...uncertain.values()].some((entry) => !entry.threadId)) {
|
|
267
|
+
return Promise.reject(new CodexAppServerError('CODEX_SUBMISSION_UNCERTAIN', 'an earlier submission has no confirmed thread identity; stop this executor and inspect history before submitting more work'));
|
|
268
|
+
}
|
|
269
|
+
const requestId = ++sequence;
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
const entry = { requestId, method, params, mutation, absorb, resolve, reject, sent: false, createdAt: Date.now(), notificationSequence };
|
|
272
|
+
entry.timer = setTimeout(() => {
|
|
273
|
+
pending.delete(rpcKey(requestId));
|
|
274
|
+
if (mutation && entry.sent) markUncertain(entry, 'timeout');
|
|
275
|
+
reject(new CodexAppServerError(mutation && entry.sent ? 'CODEX_SUBMISSION_UNCERTAIN' : 'CODEX_REQUEST_TIMEOUT',
|
|
276
|
+
`${method} timed out; it will not be replayed`, { executorId, requestId, method, uncertain: mutation && entry.sent }));
|
|
277
|
+
changed('request.timeout', { requestId, method, uncertain: mutation && entry.sent });
|
|
278
|
+
}, requestTimeoutMs);
|
|
279
|
+
pending.set(rpcKey(requestId), entry);
|
|
280
|
+
try {
|
|
281
|
+
entry.sent = true;
|
|
282
|
+
write({ id: requestId, method, params });
|
|
283
|
+
} catch (error) {
|
|
284
|
+
clearTimeout(entry.timer);
|
|
285
|
+
pending.delete(rpcKey(requestId));
|
|
286
|
+
reject(error);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function serverRequest(message) {
|
|
292
|
+
const params = objectParams(message.params);
|
|
293
|
+
if (!APPROVAL_METHODS.has(message.method)) {
|
|
294
|
+
write({ id: message.id, error: { code: -32601, message: 'This hello-cc adapter does not support this server request' } });
|
|
295
|
+
changed('serverRequest.unsupported', { requestId: message.id, method: message.method, threadId: params.threadId, turnId: params.turnId });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const threadId = requiredId(params.threadId, 'threadId');
|
|
299
|
+
// Standalone MCP elicitations can lack turn correlation. They cannot be
|
|
300
|
+
// approved as work belonging to the visible turn.
|
|
301
|
+
if (message.method === 'mcpServer/elicitation/request' && !params.turnId) {
|
|
302
|
+
write({ id: message.id, result: cancelCodexInteraction(message.method) });
|
|
303
|
+
changed('approval.expired', { requestId: message.id, threadId, turnId: null });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const turnId = requiredId(params.turnId, 'turnId');
|
|
307
|
+
const thread = ensureThread(threadId);
|
|
308
|
+
const turn = ensureTurn(threadId, turnId);
|
|
309
|
+
if (TERMINAL_TURNS.has(turn.status) || (thread.activeTurnId && thread.activeTurnId !== turnId)) {
|
|
310
|
+
write({ id: message.id, result: cancelCodexInteraction(message.method) });
|
|
311
|
+
changed('approval.expired', { requestId: message.id, threadId, turnId });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const key = rpcKey(message.id);
|
|
315
|
+
if (approvals.has(key)) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'server reused an unresolved request id');
|
|
316
|
+
if (approvals.size >= 64) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'too many unresolved approvals');
|
|
317
|
+
thread.activeTurnId = turnId;
|
|
318
|
+
thread.status = { type: 'active', activeFlags: [codexInteractionKind(message.method) === 'userInput' ? 'waitingOnUserInput' : 'waitingOnApproval'] };
|
|
319
|
+
const preview = bounded(params);
|
|
320
|
+
approvals.set(key, {
|
|
321
|
+
executorId, requestId: message.id, threadId, turnId, itemId: params.itemId || null,
|
|
322
|
+
approvalId: params.approvalId || null, method: message.method, kind: codexInteractionKind(message.method), params: preview,
|
|
323
|
+
truncated: JSON.stringify(preview) !== JSON.stringify(params),
|
|
324
|
+
status: 'pending', createdAt: Date.now()
|
|
325
|
+
});
|
|
326
|
+
changed('approval.requested', approvals.get(key));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function notification(method, params) {
|
|
330
|
+
notificationSequence += 1;
|
|
331
|
+
const notifiedThread = params.threadId || params.thread?.id;
|
|
332
|
+
if (notifiedThread) threadNotificationSequences.set(notifiedThread, notificationSequence);
|
|
333
|
+
if (method === 'thread/started') absorbThread(params.thread);
|
|
334
|
+
if (method === 'thread/status/changed') {
|
|
335
|
+
const thread = ensureThread(params.threadId);
|
|
336
|
+
thread.status = bounded(params.status);
|
|
337
|
+
if (['idle', 'notLoaded', 'systemError'].includes(params.status?.type)) {
|
|
338
|
+
thread.activeTurnId = null;
|
|
339
|
+
expireApprovals(params.threadId);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (method === 'turn/started' || method === 'turn/completed') absorbTurn(params.threadId, params.turn);
|
|
343
|
+
if (method === 'serverRequest/resolved') {
|
|
344
|
+
const approval = approvals.get(rpcKey(params.requestId));
|
|
345
|
+
if (approval?.threadId === params.threadId) approvals.delete(rpcKey(params.requestId));
|
|
346
|
+
}
|
|
347
|
+
if (method === 'turn/diff/updated') ensureTurn(params.threadId, params.turnId).diff = bounded(params.diff || '');
|
|
348
|
+
if (method === 'turn/plan/updated') {
|
|
349
|
+
Object.assign(ensureTurn(params.threadId, params.turnId), { plan: bounded(params.plan || []), explanation: bounded(params.explanation) });
|
|
350
|
+
}
|
|
351
|
+
if (['item/started', 'item/completed', 'item/agentMessage/delta'].includes(method)) {
|
|
352
|
+
const turn = ensureTurn(params.threadId, params.turnId);
|
|
353
|
+
const itemId = params.item?.id || params.itemId;
|
|
354
|
+
requiredId(itemId, 'itemId');
|
|
355
|
+
let item = turn.items.find((entry) => entry.id === itemId);
|
|
356
|
+
if (!item) { item = { id: itemId, type: 'agentMessage', text: '' }; turn.items.push(item); }
|
|
357
|
+
if (params.item) Object.assign(item, bounded(params.item));
|
|
358
|
+
else item.text = bounded((item.text || '') + (params.delta || ''));
|
|
359
|
+
boundItems(turn);
|
|
360
|
+
}
|
|
361
|
+
changed(method, params);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function receive(message) {
|
|
365
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
366
|
+
throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'expected a protocol object');
|
|
367
|
+
}
|
|
368
|
+
if (message.method) {
|
|
369
|
+
if (Object.hasOwn(message, 'id')) {
|
|
370
|
+
if (!['string', 'number'].includes(typeof message.id)) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'invalid request id');
|
|
371
|
+
serverRequest(message);
|
|
372
|
+
} else notification(message.method, objectParams(message.params));
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (!Object.hasOwn(message, 'id') || (!Object.hasOwn(message, 'result') && !message.error)) {
|
|
376
|
+
throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'invalid response envelope');
|
|
377
|
+
}
|
|
378
|
+
const key = rpcKey(message.id);
|
|
379
|
+
const entry = pending.get(key);
|
|
380
|
+
const late = uncertain.get(key);
|
|
381
|
+
if (!entry && !late) return;
|
|
382
|
+
if (entry) { clearTimeout(entry.timer); pending.delete(key); }
|
|
383
|
+
const operation = entry || late;
|
|
384
|
+
if (message.error) {
|
|
385
|
+
const error = new CodexAppServerError('CODEX_RPC_ERROR', message.error.message || 'Codex request failed', { rpcCode: message.error.code, data: bounded(message.error.data), method: operation.method });
|
|
386
|
+
entry?.reject(error);
|
|
387
|
+
} else {
|
|
388
|
+
try { if (operation.absorb !== false) applyResult(operation.method, operation.params, message.result, operation.notificationSequence); }
|
|
389
|
+
catch (error) { entry?.reject(error); throw error; }
|
|
390
|
+
entry?.resolve(message.result);
|
|
391
|
+
}
|
|
392
|
+
uncertain.delete(key);
|
|
393
|
+
changed(late ? 'request.reconciled' : 'request.completed', { requestId: message.id, method: operation.method, error: message.error || null });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function initialize() {
|
|
397
|
+
if (initialization) return initialization;
|
|
398
|
+
if (status !== 'new') return Promise.reject(new CodexAppServerError('CODEX_DISCONNECTED', 'executor cannot restart'));
|
|
399
|
+
initialization = (async () => {
|
|
400
|
+
status = 'connecting';
|
|
401
|
+
try {
|
|
402
|
+
child = spawnProcess('codex', ['app-server', '--listen', 'stdio://'], {
|
|
403
|
+
cwd: root, env: { ...env }, stdio: ['pipe', 'pipe', 'pipe']
|
|
404
|
+
});
|
|
405
|
+
if (!child?.stdin || !child.stdout) throw new CodexAppServerError('CODEX_START_FAILED', 'Codex process has no stdio transport');
|
|
406
|
+
child.stdout.setEncoding('utf8');
|
|
407
|
+
child.stdout.on('data', (data) => {
|
|
408
|
+
if (['disconnected', 'closed'].includes(status)) return;
|
|
409
|
+
try {
|
|
410
|
+
buffer += data;
|
|
411
|
+
let end;
|
|
412
|
+
while ((end = buffer.indexOf('\n')) !== -1) {
|
|
413
|
+
const line = buffer.slice(0, end).trim(); buffer = buffer.slice(end + 1);
|
|
414
|
+
if (Buffer.byteLength(line) > MAX_LINE_BYTES) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'protocol frame is too large');
|
|
415
|
+
if (line) receive(JSON.parse(line));
|
|
416
|
+
}
|
|
417
|
+
if (Buffer.byteLength(buffer) > MAX_LINE_BYTES) throw new CodexAppServerError('CODEX_PROTOCOL_ERROR', 'protocol frame is too large');
|
|
418
|
+
} catch (error) {
|
|
419
|
+
disconnect('CODEX_PROTOCOL_ERROR', error.message);
|
|
420
|
+
child.kill('SIGTERM');
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
child.stdout.on('error', () => disconnect('CODEX_DISCONNECTED', 'Codex output closed'));
|
|
424
|
+
child.stdin.on('error', () => disconnect('CODEX_DISCONNECTED', 'Codex input closed'));
|
|
425
|
+
// Drain diagnostics, but never publish environment/auth data or stderr in UI state.
|
|
426
|
+
child.stderr?.on('data', () => {});
|
|
427
|
+
child.on('error', () => disconnect('CODEX_START_FAILED', 'Could not start the installed codex executable'));
|
|
428
|
+
child.on('exit', () => disconnect('CODEX_DISCONNECTED', 'Codex App Server exited'));
|
|
429
|
+
child.stdout.on('end', () => disconnect('CODEX_DISCONNECTED', 'Codex App Server output ended'));
|
|
430
|
+
changed('transport.connecting');
|
|
431
|
+
metadata = bounded(await request('initialize', {
|
|
432
|
+
clientInfo: { name: 'hello_cc', title: 'hello-cc', version: clientVersion },
|
|
433
|
+
capabilities: { experimentalApi: true }
|
|
434
|
+
}));
|
|
435
|
+
write({ method: 'initialized' });
|
|
436
|
+
// The handshake proves that launcher shims have exec'd the actual
|
|
437
|
+
// server before the manager fingerprints its owner. Private thread
|
|
438
|
+
// configuration stays outside snapshots/events and exists before any
|
|
439
|
+
// thread/start or thread/resume can use it.
|
|
440
|
+
threadConfig = await onSpawn({ pid: child.pid, executorId });
|
|
441
|
+
if (status !== 'connecting' || child.exitCode != null || child.signalCode != null || child.stdin.destroyed) {
|
|
442
|
+
throw new CodexAppServerError('CODEX_DISCONNECTED', 'Executor disconnected while preparing its private thread configuration');
|
|
443
|
+
}
|
|
444
|
+
status = 'ready';
|
|
445
|
+
changed('transport.ready', metadata);
|
|
446
|
+
return metadata;
|
|
447
|
+
} catch (error) {
|
|
448
|
+
disconnect(error.code || 'CODEX_START_FAILED', error.message);
|
|
449
|
+
try { child?.kill('SIGTERM'); } catch {}
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
})();
|
|
453
|
+
return initialization;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function call(method, params, mutation = false, absorb = true) {
|
|
457
|
+
await initialize();
|
|
458
|
+
return request(method, params, mutation, absorb);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function assertActiveTurn(threadId, turnId) {
|
|
462
|
+
requiredId(threadId, 'threadId'); requiredId(turnId, 'turnId');
|
|
463
|
+
if (threads.get(threadId)?.activeTurnId !== turnId) {
|
|
464
|
+
throw new CodexAppServerError('CODEX_TURN_MISMATCH', 'turn is no longer active on this executor');
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function close() {
|
|
469
|
+
if (closePromise) return closePromise;
|
|
470
|
+
closePromise = (async () => {
|
|
471
|
+
disconnect('CODEX_CLOSED', 'Codex App Server was closed');
|
|
472
|
+
status = 'closed'; approvals.clear();
|
|
473
|
+
changed('transport.closed');
|
|
474
|
+
if (!child || child.exitCode != null || child.signalCode != null) return;
|
|
475
|
+
await new Promise((resolve) => {
|
|
476
|
+
let timer;
|
|
477
|
+
const done = () => { clearTimeout(timer); child.removeListener('exit', done); resolve(); };
|
|
478
|
+
child.once('exit', done);
|
|
479
|
+
timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} done(); }, closeTimeoutMs);
|
|
480
|
+
try { child.stdin?.end(); child.kill('SIGTERM'); } catch { done(); }
|
|
481
|
+
});
|
|
482
|
+
})();
|
|
483
|
+
return closePromise;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
initialize, snapshot, close,
|
|
488
|
+
async startThread(params = {}) {
|
|
489
|
+
await initialize();
|
|
490
|
+
return request('thread/start', { cwd: root, sandbox: 'workspace-write', approvalPolicy: 'on-request', approvalsReviewer: 'user',
|
|
491
|
+
...objectParams(params), config: codexInteractiveConfig({ ...objectParams(params).config, ...threadConfig }) }, true);
|
|
492
|
+
},
|
|
493
|
+
async resumeThread(threadId, params = {}) {
|
|
494
|
+
await initialize();
|
|
495
|
+
return request('thread/resume', { cwd: root, approvalPolicy: 'on-request', approvalsReviewer: 'user',
|
|
496
|
+
...objectParams(params), config: codexInteractiveConfig({ ...objectParams(params).config, ...threadConfig }),
|
|
497
|
+
threadId: requiredId(threadId, 'threadId') }, true);
|
|
498
|
+
},
|
|
499
|
+
readThread(threadId, params = {}) {
|
|
500
|
+
return call('thread/read', { includeTurns: true, ...objectParams(params), threadId: requiredId(threadId, 'threadId') });
|
|
501
|
+
},
|
|
502
|
+
peekThread(threadId) {
|
|
503
|
+
return call('thread/read', { includeTurns: true, threadId: requiredId(threadId, 'threadId') }, false, false);
|
|
504
|
+
},
|
|
505
|
+
async forkThread(threadId, params = {}, beforeSubmit = () => {}) {
|
|
506
|
+
await initialize();
|
|
507
|
+
beforeSubmit();
|
|
508
|
+
return request('thread/fork', { cwd: root, approvalPolicy: 'on-request', approvalsReviewer: 'user', sandbox: 'workspace-write',
|
|
509
|
+
...objectParams(params), config: codexInteractiveConfig({ ...objectParams(params).config, ...threadConfig }),
|
|
510
|
+
threadId: requiredId(threadId, 'threadId') }, true, false);
|
|
511
|
+
},
|
|
512
|
+
listThreads(params = {}) { return call('thread/list', { limit: 50, cwd: root, ...objectParams(params) }); },
|
|
513
|
+
async startTurn(threadId, input, params = {}) {
|
|
514
|
+
requiredId(threadId, 'threadId');
|
|
515
|
+
const normalized = normalizeInput(input);
|
|
516
|
+
await initialize();
|
|
517
|
+
const thread = threads.get(threadId);
|
|
518
|
+
if (!thread || thread.status?.type === 'notLoaded') throw new CodexAppServerError('CODEX_THREAD_NOT_LOADED', 'start or resume the thread on this executor first');
|
|
519
|
+
if (thread.activeTurnId || thread.status?.type === 'active' || submittingThreads.has(threadId) ||
|
|
520
|
+
[...uncertain.values()].some((entry) => entry.threadId === threadId)) {
|
|
521
|
+
throw new CodexAppServerError('CODEX_THREAD_BUSY', 'thread has an active or uncertain turn; no input was submitted');
|
|
522
|
+
}
|
|
523
|
+
currentThreadId = threadId; submittingThreads.add(threadId);
|
|
524
|
+
try {
|
|
525
|
+
return await request('turn/start', { ...objectParams(params), threadId, input: normalized }, true);
|
|
526
|
+
} finally { submittingThreads.delete(threadId); }
|
|
527
|
+
},
|
|
528
|
+
steer(threadId, turnId, input) {
|
|
529
|
+
assertActiveTurn(threadId, turnId);
|
|
530
|
+
return call('turn/steer', { threadId, expectedTurnId: turnId, input: normalizeInput(input) }, true);
|
|
531
|
+
},
|
|
532
|
+
interrupt(threadId, turnId) {
|
|
533
|
+
assertActiveTurn(threadId, turnId);
|
|
534
|
+
return call('turn/interrupt', { threadId, turnId }, true).then(result => { expireApprovals(threadId, turnId); changed('approval.expired', { threadId, turnId }); return result; });
|
|
535
|
+
},
|
|
536
|
+
async approve({ executorId: submittedExecutorId, threadId, turnId, requestId, decision, permissions, scope, answers } = {}) {
|
|
537
|
+
if (submittedExecutorId !== executorId) throw new CodexAppServerError('CODEX_APPROVAL_MISMATCH', 'approval identity or decision is invalid');
|
|
538
|
+
assertActiveTurn(threadId, turnId);
|
|
539
|
+
const approval = approvals.get(rpcKey(requestId));
|
|
540
|
+
if (!approval || approval.status !== 'pending' || approval.threadId !== threadId || approval.turnId !== turnId) {
|
|
541
|
+
throw new CodexAppServerError('CODEX_APPROVAL_MISMATCH', 'approval is expired or belongs to another turn');
|
|
542
|
+
}
|
|
543
|
+
if (approval.truncated && decision === 'accept') {
|
|
544
|
+
throw new CodexAppServerError('CODEX_APPROVAL_TRUNCATED', 'approval details exceed the preview limit; decline or cancel instead');
|
|
545
|
+
}
|
|
546
|
+
const result = codexInteractionResponse({ ...approval, kind: approval.kind || codexInteractionKind(approval.method) }, { decision, permissions, scope, answers });
|
|
547
|
+
await initialize();
|
|
548
|
+
// No await between checking a live request and replying to its exact RPC id.
|
|
549
|
+
assertActiveTurn(threadId, turnId);
|
|
550
|
+
if (approvals.get(rpcKey(requestId)) !== approval) throw new CodexAppServerError('CODEX_APPROVAL_MISMATCH', 'approval was resolved');
|
|
551
|
+
write({ id: requestId, result });
|
|
552
|
+
approval.status = 'submitted';
|
|
553
|
+
changed('approval.submitted', { executorId, threadId, turnId, requestId, decision });
|
|
554
|
+
return { executorId, threadId, turnId, requestId, decision, status: 'submitted' };
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
}
|