arcane-os 0.64.0 → 0.66.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/CHANGELOG.md +50 -0
- package/README.md +1 -1
- package/browser-runtime/ai/browser-decisions.mjs +3 -2
- package/browser-runtime/ai/decision-runtime.mjs +14 -24
- package/browser-runtime/core/client.mjs +62 -5
- package/docs/guides/native-whisper-openvino.md +334 -0
- package/docs/publishing.md +39 -0
- package/docs/reference/README.md +11 -1
- package/docs/reference/ai/browser-decisions.md +7 -4
- package/docs/reference/arcane-ollama.md +1 -1
- package/docs/reference/codex-app-server.md +269 -0
- package/docs/reference/core-client.md +60 -2
- package/docs/reference/core-development.md +157 -0
- package/docs/reference/core-native-packaging.md +13 -0
- package/docs/reference/core-repositories.md +232 -4
- package/docs/reference/git-text-snapshot.md +15 -1
- package/docs/reference/inventory/package-api.json +174 -6
- package/docs/reference/inventory/runtime-modules.json +8 -6
- package/docs/reference/local-ai.md +80 -3
- package/docs/reference/local-image-generation.md +5 -0
- package/docs/reference/local-image-sdxl.md +1 -1
- package/docs/reference/local-whisper.md +7 -1
- package/docs/reference/mcp-stdio.md +263 -0
- package/docs/reference/native-speech.md +6 -0
- package/docs/reference/runtime-components.md +18 -1
- package/docs/reference/runtime-modules.md +10 -2
- package/docs/reference/sdk-api.md +346 -4
- package/package.json +6 -2
- package/runtime/arcane/modules/AI.js +156 -13
- package/runtime/arcane/modules/Ollama.js +1 -1
- package/runtime/arcane/modules/OllamaModelIdentifier.js +13 -0
- package/schemas/arcane-app.schema.json +8 -1
- package/schemas/arcane-package.schema.json +8 -1
- package/src/codex/app-server.mjs +845 -0
- package/src/core/development.mjs +457 -0
- package/src/core/repositories.mjs +178 -9
- package/src/core/services/local-ai.mjs +80 -10
- package/src/dev-server.mjs +3 -2
- package/src/git-identity.mjs +132 -0
- package/src/git-text-snapshot.mjs +5 -3
- package/src/index.mjs +1 -0
- package/src/local-ai/dev.mjs +80 -426
- package/src/local-ai/whisper/config.mjs +31 -3
- package/src/local-ai/whisper/index.mjs +62 -5
- package/src/local-ai/whisper/install.mjs +72 -14
- package/src/local-ai/whisper/native/main.cpp +33 -2
- package/src/local-ai/whisper/openvino-build.mjs +138 -0
- package/src/mcp/stdio-server.mjs +579 -0
- package/src/native/portable-layout.mjs +3 -1
- package/src/toolchain.mjs +19 -8
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
import Is from 'strong-type';
|
|
2
|
+
import {ArcaneError, ERROR_CODES} from '../errors.mjs';
|
|
3
|
+
import {createArcaneEventSource} from '../event-manager.mjs';
|
|
4
|
+
import {runProcess} from '../process.mjs';
|
|
5
|
+
|
|
6
|
+
const is = new Is(false);
|
|
7
|
+
const eventTypes = {
|
|
8
|
+
status: 'arcane.codex.app-server.status',
|
|
9
|
+
message: 'arcane.codex.app-server.message',
|
|
10
|
+
notification: 'arcane.codex.app-server.notification',
|
|
11
|
+
stderr: 'arcane.codex.app-server.stderr',
|
|
12
|
+
process: 'arcane.codex.app-server.process',
|
|
13
|
+
error: 'arcane.codex.app-server.error',
|
|
14
|
+
serverRequest: 'arcane.codex.app-server.server-request',
|
|
15
|
+
serverRequestResponded: 'arcane.codex.app-server.server-request-responded',
|
|
16
|
+
serverRequestResolved: 'arcane.codex.app-server.server-request-resolved',
|
|
17
|
+
turnAccepted: 'arcane.codex.app-server.turn-accepted',
|
|
18
|
+
delta: 'arcane.codex.app-server.delta',
|
|
19
|
+
item: 'arcane.codex.app-server.item',
|
|
20
|
+
turn: 'arcane.codex.app-server.turn',
|
|
21
|
+
observerError: 'arcane.codex.app-server.observer-error'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Own one explicitly selected native Codex App Server process. Construction has
|
|
26
|
+
* no process side effects. connect() initializes JSONL; runTurn() awaits the
|
|
27
|
+
* matching terminal notification, never merely the turn/start response.
|
|
28
|
+
*/
|
|
29
|
+
export function createCodexAppServerSession({
|
|
30
|
+
executable,
|
|
31
|
+
args = ['app-server', '--listen', 'stdio://'],
|
|
32
|
+
cwd,
|
|
33
|
+
env,
|
|
34
|
+
clientInfo = {name: 'arcane_sdk', title: 'Arcane SDK', version: '1'},
|
|
35
|
+
capabilities
|
|
36
|
+
} = {}) {
|
|
37
|
+
let connection = null;
|
|
38
|
+
let connecting = null;
|
|
39
|
+
let disposing = null;
|
|
40
|
+
let disposed = false;
|
|
41
|
+
let eventsDisposed = false;
|
|
42
|
+
let nextRequestId = 0;
|
|
43
|
+
let nextServerRequestSequence = 0;
|
|
44
|
+
let status = {state: 'disconnected'};
|
|
45
|
+
const seenServerRequestIds = new Set();
|
|
46
|
+
const publishingRequests = new Map();
|
|
47
|
+
const session = {
|
|
48
|
+
connect,
|
|
49
|
+
reconnect,
|
|
50
|
+
dispose,
|
|
51
|
+
subscribe,
|
|
52
|
+
request,
|
|
53
|
+
listThreads,
|
|
54
|
+
readThread,
|
|
55
|
+
readTurn,
|
|
56
|
+
createThread,
|
|
57
|
+
resumeThread,
|
|
58
|
+
runTurn,
|
|
59
|
+
interrupt,
|
|
60
|
+
respond,
|
|
61
|
+
get status() {
|
|
62
|
+
return status;
|
|
63
|
+
},
|
|
64
|
+
get pendingServerRequests() {
|
|
65
|
+
return connection ? [...connection.serverRequests.values()] : [];
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const events = createArcaneEventSource(
|
|
69
|
+
session,
|
|
70
|
+
{
|
|
71
|
+
source: 'codex.app-server',
|
|
72
|
+
eventTypes: Object.values(eventTypes)
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
return session;
|
|
76
|
+
|
|
77
|
+
function emit(name, detail, options) {
|
|
78
|
+
events.dispatch(eventTypes[name], detail, options);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function observe(listener, detail, name, context) {
|
|
82
|
+
if (!is.function(listener)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
function observerFailed(error) {
|
|
86
|
+
if (eventsDisposed) {
|
|
87
|
+
console.error(error);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (name !== 'observerError') {
|
|
91
|
+
emit(
|
|
92
|
+
'observerError',
|
|
93
|
+
{event: name, error, detail}
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
console.error(error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const result = listener(detail, context);
|
|
101
|
+
if (result && is.function(result.then)) {
|
|
102
|
+
Promise.resolve(result).catch(observerFailed);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
observerFailed(error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function subscribe(name, listener, options = {}) {
|
|
110
|
+
const unsubscribe = events.subscribe(
|
|
111
|
+
eventTypes[name],
|
|
112
|
+
function deliver(occurrence) {
|
|
113
|
+
const context = name === 'serverRequest'
|
|
114
|
+
? publishingRequests.get(occurrence.operationId)
|
|
115
|
+
: undefined;
|
|
116
|
+
observe(listener, occurrence.detail, name, context);
|
|
117
|
+
},
|
|
118
|
+
options
|
|
119
|
+
);
|
|
120
|
+
if (name === 'status' && !options.signal?.aborted) {
|
|
121
|
+
observe(listener, status, name);
|
|
122
|
+
if (options.once) {
|
|
123
|
+
unsubscribe();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return unsubscribe;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function setStatus(state, details = {}) {
|
|
130
|
+
status = {state, ...details};
|
|
131
|
+
emit('status', status);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function connect() {
|
|
135
|
+
if (disposed) {
|
|
136
|
+
return Promise.reject(sessionError('DISPOSED', 'This Codex session is disposed.'));
|
|
137
|
+
}
|
|
138
|
+
if (connecting) {
|
|
139
|
+
return connecting;
|
|
140
|
+
}
|
|
141
|
+
if (status.state === 'ready') {
|
|
142
|
+
return Promise.resolve(session);
|
|
143
|
+
}
|
|
144
|
+
// Publish the in-flight promise before lifecycle subscribers can reenter.
|
|
145
|
+
connecting = Promise.resolve().then(startConnection);
|
|
146
|
+
connecting.then(clearConnecting, clearConnecting);
|
|
147
|
+
return connecting;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function clearConnecting() {
|
|
151
|
+
connecting = null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function startConnection() {
|
|
155
|
+
if (!is.string(executable) || !executable) {
|
|
156
|
+
throw new ArcaneError(ERROR_CODES.usage, 'Select a native Codex executable explicitly.');
|
|
157
|
+
}
|
|
158
|
+
// A failed connection must drain its owned process before a replacement.
|
|
159
|
+
if (connection?.process) {
|
|
160
|
+
await connection.process;
|
|
161
|
+
}
|
|
162
|
+
if (disposed) {
|
|
163
|
+
throw sessionError('DISPOSED', 'This Codex session is disposed.');
|
|
164
|
+
}
|
|
165
|
+
const current = {
|
|
166
|
+
controller: new AbortController(),
|
|
167
|
+
input: createInputQueue(),
|
|
168
|
+
pending: new Map(),
|
|
169
|
+
serverRequests: new Map(),
|
|
170
|
+
activeTurns: new Set(),
|
|
171
|
+
fragments: [],
|
|
172
|
+
ended: false,
|
|
173
|
+
failure: null,
|
|
174
|
+
process: null
|
|
175
|
+
};
|
|
176
|
+
connection = current;
|
|
177
|
+
setStatus('connecting');
|
|
178
|
+
if (disposed || current.failure) {
|
|
179
|
+
throw current.failure || sessionError('DISPOSED', 'This Codex session is disposed.');
|
|
180
|
+
}
|
|
181
|
+
current.process = runProcess(
|
|
182
|
+
executable,
|
|
183
|
+
args,
|
|
184
|
+
{
|
|
185
|
+
cwd,
|
|
186
|
+
env,
|
|
187
|
+
signal: current.controller.signal,
|
|
188
|
+
input: current.input,
|
|
189
|
+
captureOutput: false,
|
|
190
|
+
emitOutputEvents: false,
|
|
191
|
+
onOutput: function receiveOutput(record) {
|
|
192
|
+
if (record.stream === 'stderr') {
|
|
193
|
+
emit('stderr', record);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const lines = record.chunk.split('\n');
|
|
197
|
+
const trailing = lines.pop();
|
|
198
|
+
for (const line of lines) {
|
|
199
|
+
current.fragments.push(line);
|
|
200
|
+
const complete = current.fragments.join('');
|
|
201
|
+
current.fragments = [];
|
|
202
|
+
receiveLine(current, complete, record.chunk);
|
|
203
|
+
}
|
|
204
|
+
current.fragments.push(trailing);
|
|
205
|
+
},
|
|
206
|
+
onEvent: function processEvent(event) {
|
|
207
|
+
emit('process', event);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
).then(
|
|
211
|
+
function processExited(result) {
|
|
212
|
+
endConnection(current, null, result);
|
|
213
|
+
return {result};
|
|
214
|
+
},
|
|
215
|
+
function processFailed(error) {
|
|
216
|
+
endConnection(current, error);
|
|
217
|
+
return {error};
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
try {
|
|
221
|
+
const initialized = await sendRequest(
|
|
222
|
+
current,
|
|
223
|
+
'initialize',
|
|
224
|
+
{clientInfo, ...(capabilities === undefined ? {} : {capabilities})}
|
|
225
|
+
);
|
|
226
|
+
send(
|
|
227
|
+
current,
|
|
228
|
+
{method: 'initialized'}
|
|
229
|
+
);
|
|
230
|
+
setStatus(
|
|
231
|
+
'ready',
|
|
232
|
+
{initialized}
|
|
233
|
+
);
|
|
234
|
+
if (disposed || current.failure) {
|
|
235
|
+
throw current.failure || sessionError('DISPOSED', 'This Codex session is disposed.');
|
|
236
|
+
}
|
|
237
|
+
return session;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
failConnection(current, error);
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function send(current, message) {
|
|
245
|
+
if (current.ended || current.failure) {
|
|
246
|
+
throw current.failure || sessionError('CONNECTION_LOST', 'The Codex connection has ended.');
|
|
247
|
+
}
|
|
248
|
+
current.input.send(`${JSON.stringify(message)}\n`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function sendRequest(current, method, params) {
|
|
252
|
+
const id = ++nextRequestId;
|
|
253
|
+
return new Promise(
|
|
254
|
+
function awaitResponse(resolve, reject) {
|
|
255
|
+
current.pending.set(
|
|
256
|
+
id,
|
|
257
|
+
{resolve, reject, method, params}
|
|
258
|
+
);
|
|
259
|
+
try {
|
|
260
|
+
send(
|
|
261
|
+
current,
|
|
262
|
+
{id, method, ...(params === undefined ? {} : {params})}
|
|
263
|
+
);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
current.pending.delete(id);
|
|
266
|
+
reject(error);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function readyConnection() {
|
|
273
|
+
if (disposed || status.state !== 'ready' || !connection || connection.ended || connection.failure) {
|
|
274
|
+
throw sessionError('NOT_READY', 'Connect the Codex session before sending a request.');
|
|
275
|
+
}
|
|
276
|
+
return connection;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function request(method, params) {
|
|
280
|
+
return sendRequest(readyConnection(), method, params);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function listThreads(params) {
|
|
284
|
+
return request('thread/list', params);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function readThread(params) {
|
|
288
|
+
return request('thread/read', params);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function readTurn({threadId, turnId}) {
|
|
292
|
+
const result = await readThread(
|
|
293
|
+
{threadId, includeTurns: true}
|
|
294
|
+
);
|
|
295
|
+
const turn = result.thread?.turns?.find(
|
|
296
|
+
function exactTurn(candidate) {
|
|
297
|
+
return candidate.id === turnId;
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
if (!turn) {
|
|
301
|
+
throw sessionError(
|
|
302
|
+
'TURN_NOT_FOUND',
|
|
303
|
+
'The requested turn was not present in the stored thread response.',
|
|
304
|
+
{threadId, turnId, response: result}
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return turnResult(threadId, turn, turn.items || []);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function createThread(params) {
|
|
311
|
+
return request('thread/start', params);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function resumeThread(params) {
|
|
315
|
+
return request('thread/resume', params);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function interrupt({threadId, turnId}) {
|
|
319
|
+
return request(
|
|
320
|
+
'turn/interrupt',
|
|
321
|
+
{threadId, turnId}
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function respond(response, context) {
|
|
326
|
+
const current = readyConnection();
|
|
327
|
+
const pending = current.serverRequests.get(response.id);
|
|
328
|
+
if (!pending) {
|
|
329
|
+
throw sessionError(
|
|
330
|
+
'REQUEST_NOT_FOUND',
|
|
331
|
+
'This server request is no longer pending.',
|
|
332
|
+
{id: response.id}
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
if (context !== undefined && context !== pending.context) {
|
|
336
|
+
throw sessionError(
|
|
337
|
+
'STALE_REQUEST_CONTEXT',
|
|
338
|
+
'This response belongs to an earlier server request.',
|
|
339
|
+
{id: response.id}
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (context === undefined && pending.contextRequired) {
|
|
343
|
+
throw sessionError(
|
|
344
|
+
'REQUEST_CONTEXT_REQUIRED',
|
|
345
|
+
'This native request ID was reused; reply with its originating request context.',
|
|
346
|
+
{id: response.id}
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (pending.responded) {
|
|
350
|
+
throw sessionError(
|
|
351
|
+
'ALREADY_RESPONDED',
|
|
352
|
+
'A response was already submitted for this server request.',
|
|
353
|
+
{id: response.id}
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
if (Object.hasOwn(response, 'result') === Object.hasOwn(response, 'error')) {
|
|
357
|
+
throw new ArcaneError(ERROR_CODES.usage, 'Respond with exactly one of result or error.');
|
|
358
|
+
}
|
|
359
|
+
const message = Object.hasOwn(response, 'error')
|
|
360
|
+
? {id: response.id, error: response.error}
|
|
361
|
+
: {id: response.id, result: response.result};
|
|
362
|
+
send(current, message);
|
|
363
|
+
pending.responded = true;
|
|
364
|
+
emit(
|
|
365
|
+
'serverRequestResponded',
|
|
366
|
+
{request: pending.request, response: message}
|
|
367
|
+
);
|
|
368
|
+
return {id: response.id, submitted: true};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function receiveLine(current, line, chunk = line) {
|
|
372
|
+
let message;
|
|
373
|
+
try {
|
|
374
|
+
message = JSON.parse(line);
|
|
375
|
+
} catch (cause) {
|
|
376
|
+
const error = sessionError(
|
|
377
|
+
'PROTOCOL',
|
|
378
|
+
'Codex stdout contained an unreadable JSONL message.',
|
|
379
|
+
{line, chunk},
|
|
380
|
+
cause
|
|
381
|
+
);
|
|
382
|
+
failConnection(current, error);
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
if (!message || !is.object(message) || is.array(message)) {
|
|
386
|
+
const error = sessionError(
|
|
387
|
+
'PROTOCOL',
|
|
388
|
+
'Codex stdout contained a non-object message.',
|
|
389
|
+
{line, chunk, message}
|
|
390
|
+
);
|
|
391
|
+
failConnection(current, error);
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
emit('message', message);
|
|
395
|
+
if (is.string(message.method)) {
|
|
396
|
+
if (Object.hasOwn(message, 'id')) {
|
|
397
|
+
const id = message.id;
|
|
398
|
+
const context = {
|
|
399
|
+
id,
|
|
400
|
+
respond: function replyToOrigin(response) {
|
|
401
|
+
return respond(
|
|
402
|
+
{...response, id},
|
|
403
|
+
context
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
const pending = {
|
|
408
|
+
request: message,
|
|
409
|
+
responded: false,
|
|
410
|
+
context,
|
|
411
|
+
contextRequired: seenServerRequestIds.has(id)
|
|
412
|
+
};
|
|
413
|
+
seenServerRequestIds.add(id);
|
|
414
|
+
current.serverRequests.set(id, pending);
|
|
415
|
+
const operationId = `codex-server-request-${++nextServerRequestSequence}`;
|
|
416
|
+
// The event owner shallow-copies detail. Carry reply context
|
|
417
|
+
// separately through this synchronous publication, never by
|
|
418
|
+
// adding SDK fields to the native request envelope.
|
|
419
|
+
publishingRequests.set(operationId, context);
|
|
420
|
+
try {
|
|
421
|
+
emit(
|
|
422
|
+
'serverRequest',
|
|
423
|
+
message,
|
|
424
|
+
{operationId}
|
|
425
|
+
);
|
|
426
|
+
} finally {
|
|
427
|
+
publishingRequests.delete(operationId);
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (message.method === 'serverRequest/resolved') {
|
|
432
|
+
current.serverRequests.delete(message.params?.requestId);
|
|
433
|
+
emit('serverRequestResolved', message.params);
|
|
434
|
+
}
|
|
435
|
+
// Consumers may reply asynchronously; the stdout reader never awaits them.
|
|
436
|
+
for (const active of [...current.activeTurns]) {
|
|
437
|
+
active.receive(message);
|
|
438
|
+
}
|
|
439
|
+
emit('notification', message);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const pending = current.pending.get(message.id);
|
|
443
|
+
if (!pending) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
current.pending.delete(message.id);
|
|
447
|
+
if (Object.hasOwn(message, 'error')) {
|
|
448
|
+
pending.reject(
|
|
449
|
+
sessionError(
|
|
450
|
+
'RPC',
|
|
451
|
+
message.error?.message || 'Codex rejected the request.',
|
|
452
|
+
{method: pending.method, params: pending.params, requestId: message.id, outcome: 'rejected', error: message.error, response: message}
|
|
453
|
+
)
|
|
454
|
+
);
|
|
455
|
+
} else if (Object.hasOwn(message, 'result')) {
|
|
456
|
+
pending.resolve(message.result);
|
|
457
|
+
} else {
|
|
458
|
+
pending.reject(
|
|
459
|
+
sessionError(
|
|
460
|
+
'PROTOCOL',
|
|
461
|
+
'Codex response contained neither result nor error.',
|
|
462
|
+
{message}
|
|
463
|
+
)
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Input strings receive only the native text-item envelope; arrays pass through unchanged. */
|
|
469
|
+
function runTurn({threadId, input, signal, onDelta, onItem, onTurn, ...turnOptions}) {
|
|
470
|
+
let current;
|
|
471
|
+
try {
|
|
472
|
+
current = readyConnection();
|
|
473
|
+
} catch (error) {
|
|
474
|
+
return Promise.reject(error);
|
|
475
|
+
}
|
|
476
|
+
if (signal?.aborted) {
|
|
477
|
+
return Promise.reject(
|
|
478
|
+
new ArcaneError(
|
|
479
|
+
ERROR_CODES.cancelled,
|
|
480
|
+
'The turn was cancelled before submission.',
|
|
481
|
+
{details: {threadId, outcome: 'not-submitted'}}
|
|
482
|
+
)
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
const items = new Map();
|
|
486
|
+
const early = [];
|
|
487
|
+
let turnId = null;
|
|
488
|
+
let acceptance = null;
|
|
489
|
+
let settled = false;
|
|
490
|
+
let interrupted = false;
|
|
491
|
+
let finish;
|
|
492
|
+
let fail;
|
|
493
|
+
const completion = new Promise(
|
|
494
|
+
function terminal(resolve, reject) {
|
|
495
|
+
finish = resolve;
|
|
496
|
+
fail = reject;
|
|
497
|
+
}
|
|
498
|
+
);
|
|
499
|
+
const active = {receive, lost};
|
|
500
|
+
// Register correlation and cancellation before submitting turn/start.
|
|
501
|
+
current.activeTurns.add(active);
|
|
502
|
+
signal?.addEventListener(
|
|
503
|
+
'abort',
|
|
504
|
+
cancel,
|
|
505
|
+
{once: true}
|
|
506
|
+
);
|
|
507
|
+
sendRequest(
|
|
508
|
+
current,
|
|
509
|
+
'turn/start',
|
|
510
|
+
{threadId, input: is.string(input) ? [{type: 'text', text: input}] : input, ...turnOptions}
|
|
511
|
+
).then(accepted, rejected).catch(lost);
|
|
512
|
+
return completion;
|
|
513
|
+
|
|
514
|
+
function cleanup() {
|
|
515
|
+
settled = true;
|
|
516
|
+
current.activeTurns.delete(active);
|
|
517
|
+
signal?.removeEventListener('abort', cancel);
|
|
518
|
+
early.length = 0;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function rejected(error) {
|
|
522
|
+
if (!settled) {
|
|
523
|
+
cleanup();
|
|
524
|
+
fail(error);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function lost(error) {
|
|
529
|
+
rejected(
|
|
530
|
+
sessionError(
|
|
531
|
+
'TURN_UNKNOWN',
|
|
532
|
+
'This Codex turn has no observed terminal result for the operation.',
|
|
533
|
+
{threadId, turnId, outcome: 'unknown', items: [...items.values()], notifications: [...early], acceptance},
|
|
534
|
+
error
|
|
535
|
+
)
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function accepted(result) {
|
|
540
|
+
if (settled) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
acceptance = result;
|
|
544
|
+
turnId = result?.turn?.id;
|
|
545
|
+
if (!turnId) {
|
|
546
|
+
lost(
|
|
547
|
+
sessionError(
|
|
548
|
+
'PROTOCOL',
|
|
549
|
+
'The turn/start response had no turn ID.',
|
|
550
|
+
{response: result}
|
|
551
|
+
)
|
|
552
|
+
);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
emit(
|
|
556
|
+
'turnAccepted',
|
|
557
|
+
{threadId, turnId, turn: result.turn, response: result}
|
|
558
|
+
);
|
|
559
|
+
for (const message of early.splice(0)) {
|
|
560
|
+
receive(message);
|
|
561
|
+
}
|
|
562
|
+
if (signal?.aborted) {
|
|
563
|
+
cancel();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function cancel() {
|
|
568
|
+
if (settled || !turnId || interrupted) {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
interrupted = true;
|
|
572
|
+
interrupt(
|
|
573
|
+
{threadId, turnId}
|
|
574
|
+
).catch(
|
|
575
|
+
function interruptionFailed(error) {
|
|
576
|
+
emit(
|
|
577
|
+
'error',
|
|
578
|
+
{operation: 'turn/interrupt', threadId, turnId, error}
|
|
579
|
+
);
|
|
580
|
+
// A rejected interrupt does not establish that the turn stopped.
|
|
581
|
+
if (!settled) {
|
|
582
|
+
lost(error);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function receive(message) {
|
|
589
|
+
if (settled || message.params?.threadId !== threadId) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (!turnId) {
|
|
593
|
+
early.push(message);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const {method, params} = message;
|
|
597
|
+
if ((params.turnId ?? params.turn?.id) !== turnId) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (method === 'item/started' || method === 'item/completed') {
|
|
601
|
+
const item = params.item;
|
|
602
|
+
if (item) {
|
|
603
|
+
items.set(item.id, item);
|
|
604
|
+
const detail = {
|
|
605
|
+
threadId,
|
|
606
|
+
turnId,
|
|
607
|
+
itemId: item.id,
|
|
608
|
+
text: item.type === 'agentMessage' ? item.text : null,
|
|
609
|
+
phase: item.phase ?? null,
|
|
610
|
+
stage: method === 'item/started' ? 'started' : 'completed',
|
|
611
|
+
item,
|
|
612
|
+
params
|
|
613
|
+
};
|
|
614
|
+
emit('item', detail);
|
|
615
|
+
if (!settled && !signal?.aborted) {
|
|
616
|
+
observe(onItem, detail, 'item');
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
} else if (method === 'item/agentMessage/delta' || method === 'item/commandExecution/outputDelta') {
|
|
620
|
+
const detail = {
|
|
621
|
+
threadId,
|
|
622
|
+
turnId,
|
|
623
|
+
itemId: params.itemId,
|
|
624
|
+
delta: params.delta,
|
|
625
|
+
phase: params.phase ?? items.get(params.itemId)?.phase ?? null,
|
|
626
|
+
method,
|
|
627
|
+
params
|
|
628
|
+
};
|
|
629
|
+
emit('delta', detail);
|
|
630
|
+
if (!settled && !signal?.aborted) {
|
|
631
|
+
observe(onDelta, detail, 'delta');
|
|
632
|
+
}
|
|
633
|
+
} else if (method === 'turn/started' || method === 'turn/completed') {
|
|
634
|
+
const turn = params.turn;
|
|
635
|
+
const detail = {threadId, turnId, status: turn.status, turn};
|
|
636
|
+
if (method === 'turn/completed') {
|
|
637
|
+
// Final provider objects supersede earlier item snapshots by ID.
|
|
638
|
+
for (const item of turn.items || []) {
|
|
639
|
+
items.set(item.id, item);
|
|
640
|
+
}
|
|
641
|
+
const result = turnResult(
|
|
642
|
+
threadId,
|
|
643
|
+
turn,
|
|
644
|
+
[...items.values()]
|
|
645
|
+
);
|
|
646
|
+
// Observers can dispose reentrantly. The observed terminal
|
|
647
|
+
// result already owns settlement before those callbacks run.
|
|
648
|
+
cleanup();
|
|
649
|
+
finish(result);
|
|
650
|
+
}
|
|
651
|
+
emit('turn', detail);
|
|
652
|
+
observe(onTurn, detail, 'turn');
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function failConnection(current, error) {
|
|
658
|
+
if (current.failure || current.ended) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
current.failure = error;
|
|
662
|
+
for (const active of [...current.activeTurns]) {
|
|
663
|
+
active.lost(error);
|
|
664
|
+
}
|
|
665
|
+
for (const [requestId, pending] of current.pending) {
|
|
666
|
+
pending.reject(
|
|
667
|
+
sessionError(
|
|
668
|
+
'CONNECTION_LOST',
|
|
669
|
+
'The Codex request ended without an observed response.',
|
|
670
|
+
{requestId, method: pending.method, params: pending.params, outcome: 'unknown'},
|
|
671
|
+
error
|
|
672
|
+
)
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
current.pending.clear();
|
|
676
|
+
const pendingServerRequests = [...current.serverRequests.values()];
|
|
677
|
+
current.serverRequests.clear();
|
|
678
|
+
if (connection === current) {
|
|
679
|
+
setStatus(
|
|
680
|
+
disposed ? 'disposed' : 'disconnected',
|
|
681
|
+
{error}
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
emit(
|
|
685
|
+
'error',
|
|
686
|
+
{operation: 'connection', error, pendingServerRequests}
|
|
687
|
+
);
|
|
688
|
+
current.input.close();
|
|
689
|
+
current.controller.abort();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function endConnection(current, error, result) {
|
|
693
|
+
const unfinished = current.fragments.join('');
|
|
694
|
+
current.fragments = [];
|
|
695
|
+
if (unfinished) {
|
|
696
|
+
// A final complete JSON record may have no trailing newline at EOF.
|
|
697
|
+
try {
|
|
698
|
+
receiveLine(current, unfinished);
|
|
699
|
+
} catch (cause) {
|
|
700
|
+
error = cause;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
failConnection(
|
|
704
|
+
current,
|
|
705
|
+
error || sessionError(
|
|
706
|
+
'CONNECTION_LOST',
|
|
707
|
+
'The native Codex process exited.',
|
|
708
|
+
{result}
|
|
709
|
+
)
|
|
710
|
+
);
|
|
711
|
+
current.ended = true;
|
|
712
|
+
current.input.close();
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function stopConnection() {
|
|
716
|
+
const current = connection;
|
|
717
|
+
if (!current) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
failConnection(current, sessionError('CONNECTION_CLOSED', 'The caller closed the Codex connection.'));
|
|
721
|
+
await current.process;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function reconnect() {
|
|
725
|
+
if (disposed) {
|
|
726
|
+
throw sessionError('DISPOSED', 'This Codex session is disposed.');
|
|
727
|
+
}
|
|
728
|
+
await stopConnection();
|
|
729
|
+
if (connecting) {
|
|
730
|
+
await connecting.catch(
|
|
731
|
+
function connectionStopped() {
|
|
732
|
+
// The original caller and connection error event own this failure.
|
|
733
|
+
}
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
return connect();
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function dispose() {
|
|
740
|
+
if (disposing) {
|
|
741
|
+
return disposing;
|
|
742
|
+
}
|
|
743
|
+
disposed = true;
|
|
744
|
+
disposing = Promise.resolve().then(finishDispose);
|
|
745
|
+
return disposing;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function finishDispose() {
|
|
749
|
+
await stopConnection();
|
|
750
|
+
seenServerRequestIds.clear();
|
|
751
|
+
setStatus('disposed');
|
|
752
|
+
eventsDisposed = true;
|
|
753
|
+
events.dispose();
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/** Create and initialize one ready session; never creates or resumes a thread. */
|
|
758
|
+
export async function openCodexAppServerSession(options) {
|
|
759
|
+
const session = createCodexAppServerSession(options);
|
|
760
|
+
try {
|
|
761
|
+
await session.connect();
|
|
762
|
+
return session;
|
|
763
|
+
} catch (error) {
|
|
764
|
+
await session.dispose();
|
|
765
|
+
throw error;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function sessionError(kind, message, details, cause) {
|
|
770
|
+
return new ArcaneError(
|
|
771
|
+
`ARCANE_CODEX_${kind}`,
|
|
772
|
+
message,
|
|
773
|
+
{details, cause}
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function turnResult(threadId, turn, items) {
|
|
778
|
+
const visibleItems = [];
|
|
779
|
+
for (const item of items) {
|
|
780
|
+
if (item.type === 'agentMessage' && is.string(item.text)) {
|
|
781
|
+
visibleItems.push(
|
|
782
|
+
{itemId: item.id, text: item.text, phase: item.phase ?? null}
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return {threadId, turnId: turn.id, status: turn.status, items, visibleItems, error: turn.error, turn};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function createInputQueue() {
|
|
790
|
+
const queued = [];
|
|
791
|
+
let next = null;
|
|
792
|
+
let closed = false;
|
|
793
|
+
return {
|
|
794
|
+
[Symbol.asyncIterator]: function iterate() {
|
|
795
|
+
return this;
|
|
796
|
+
},
|
|
797
|
+
next: function read() {
|
|
798
|
+
if (queued.length) {
|
|
799
|
+
return Promise.resolve(
|
|
800
|
+
{value: queued.shift(), done: false}
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
if (closed) {
|
|
804
|
+
return Promise.resolve(
|
|
805
|
+
{done: true}
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
return new Promise(
|
|
809
|
+
function awaitInput(resolve) {
|
|
810
|
+
next = resolve;
|
|
811
|
+
}
|
|
812
|
+
);
|
|
813
|
+
},
|
|
814
|
+
return: function returnInput() {
|
|
815
|
+
this.close();
|
|
816
|
+
return Promise.resolve(
|
|
817
|
+
{done: true}
|
|
818
|
+
);
|
|
819
|
+
},
|
|
820
|
+
send: function sendInput(value) {
|
|
821
|
+
if (closed) {
|
|
822
|
+
throw sessionError('CONNECTION_CLOSED', 'Codex stdin is closed.');
|
|
823
|
+
}
|
|
824
|
+
if (next) {
|
|
825
|
+
const resolve = next;
|
|
826
|
+
next = null;
|
|
827
|
+
resolve(
|
|
828
|
+
{value, done: false}
|
|
829
|
+
);
|
|
830
|
+
} else {
|
|
831
|
+
queued.push(value);
|
|
832
|
+
}
|
|
833
|
+
},
|
|
834
|
+
close: function closeInput() {
|
|
835
|
+
closed = true;
|
|
836
|
+
queued.length = 0;
|
|
837
|
+
if (next) {
|
|
838
|
+
next(
|
|
839
|
+
{done: true}
|
|
840
|
+
);
|
|
841
|
+
next = null;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
}
|