@rynx-ai/runtime 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/dist/claude/executor.d.ts +17 -0
- package/dist/claude/executor.js +28 -0
- package/dist/claude/models.d.ts +10 -0
- package/dist/claude/models.js +33 -0
- package/dist/claude/native-bridge.d.ts +133 -0
- package/dist/claude/native-bridge.js +299 -0
- package/dist/claude/native-hook-main.d.ts +2 -0
- package/dist/claude/native-hook-main.js +74 -0
- package/dist/claude/native-hooks.d.ts +41 -0
- package/dist/claude/native-hooks.js +73 -0
- package/dist/claude/native-integration.d.ts +213 -0
- package/dist/claude/native-integration.js +665 -0
- package/dist/claude/native-message-display-main.d.ts +2 -0
- package/dist/claude/native-message-display-main.js +51 -0
- package/dist/claude/native-status-main.d.ts +2 -0
- package/dist/claude/native-status-main.js +105 -0
- package/dist/claude/status.d.ts +23 -0
- package/dist/claude/status.js +118 -0
- package/dist/claude/transcript.d.ts +79 -0
- package/dist/claude/transcript.js +272 -0
- package/dist/claude/trust.d.ts +6 -0
- package/dist/claude/trust.js +85 -0
- package/dist/codex/rollout-synth.d.ts +37 -0
- package/dist/codex/rollout-synth.js +212 -0
- package/dist/codex-app-server/client.d.ts +138 -0
- package/dist/codex-app-server/client.js +341 -0
- package/dist/codex-app-server/forwarder.d.ts +92 -0
- package/dist/codex-app-server/forwarder.js +188 -0
- package/dist/codex-app-server/mapping.d.ts +19 -0
- package/dist/codex-app-server/mapping.js +189 -0
- package/dist/codex-app-server/protocol.d.ts +472 -0
- package/dist/codex-app-server/protocol.js +12 -0
- package/dist/codex-app-server/transport.d.ts +139 -0
- package/dist/codex-app-server/transport.js +422 -0
- package/dist/codex-app-server/ws-channel.d.ts +72 -0
- package/dist/codex-app-server/ws-channel.js +233 -0
- package/dist/codex-child-env.d.ts +1 -0
- package/dist/codex-child-env.js +27 -0
- package/dist/codex-home.d.ts +47 -0
- package/dist/codex-home.js +135 -0
- package/dist/codex-session-store.d.ts +42 -0
- package/dist/codex-session-store.js +126 -0
- package/dist/host.d.ts +324 -0
- package/dist/host.js +1323 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/models-catalog.d.ts +18 -0
- package/dist/models-catalog.js +27 -0
- package/dist/runner/child.d.ts +58 -0
- package/dist/runner/child.js +268 -0
- package/dist/runner/manager.d.ts +175 -0
- package/dist/runner/manager.js +458 -0
- package/dist/runner/protocol.d.ts +195 -0
- package/dist/runner/protocol.js +41 -0
- package/dist/runner/transport.d.ts +36 -0
- package/dist/runner/transport.js +72 -0
- package/dist/runner-main.d.ts +2 -0
- package/dist/runner-main.js +61 -0
- package/dist/runtime-status.d.ts +16 -0
- package/dist/runtime-status.js +80 -0
- package/dist/terminal/claude-tui.d.ts +27 -0
- package/dist/terminal/claude-tui.js +13 -0
- package/dist/terminal/codex-tui.d.ts +54 -0
- package/dist/terminal/codex-tui.js +26 -0
- package/dist/terminal/registry.d.ts +42 -0
- package/dist/terminal/registry.js +70 -0
- package/dist/terminal/tmux.d.ts +150 -0
- package/dist/terminal/tmux.js +364 -0
- package/package.json +32 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CodexAppServerTransport, } from "./transport.js";
|
|
3
|
+
const DEFAULT_CLIENT_INFO = {
|
|
4
|
+
name: "lark-agent-bridge",
|
|
5
|
+
title: "lark-agent-bridge",
|
|
6
|
+
version: "0.1.0",
|
|
7
|
+
};
|
|
8
|
+
/** Falls back to the auto policy this long after an interactive approval is
|
|
9
|
+
* surfaced with no user answer (5 min). */
|
|
10
|
+
const APPROVAL_TIMEOUT_MS = 300_000;
|
|
11
|
+
export class CodexAppServerClient {
|
|
12
|
+
transport;
|
|
13
|
+
logger;
|
|
14
|
+
clientInfo;
|
|
15
|
+
approvalDecisionPolicy;
|
|
16
|
+
interactiveApprovals;
|
|
17
|
+
channel;
|
|
18
|
+
notificationSubscribers = new Set();
|
|
19
|
+
approvalListener = null;
|
|
20
|
+
pendingApprovals = new Map();
|
|
21
|
+
initializeResponse = null;
|
|
22
|
+
constructor({ spawner, channel, logger = defaultLogger, clientInfo = DEFAULT_CLIENT_INFO, approvalDecisionPolicy = "auto-approve-session", interactiveApprovals = false, }) {
|
|
23
|
+
this.logger = logger;
|
|
24
|
+
this.clientInfo = clientInfo;
|
|
25
|
+
this.approvalDecisionPolicy = approvalDecisionPolicy;
|
|
26
|
+
this.interactiveApprovals = interactiveApprovals;
|
|
27
|
+
this.channel = channel ?? null;
|
|
28
|
+
this.transport = new CodexAppServerTransport({
|
|
29
|
+
...(channel ? { channel } : { spawner }),
|
|
30
|
+
logger,
|
|
31
|
+
onNotification: (method, params) => this.dispatchNotification(method, params),
|
|
32
|
+
onServerRequest: (method, params) => this.handleServerRequest(method, params),
|
|
33
|
+
});
|
|
34
|
+
this.transport.on("exit", () => {
|
|
35
|
+
this.initializeResponse = null;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The multi-client endpoint a `codex --remote` TUI can attach to, when this
|
|
40
|
+
* client runs over a {@link WsRpcChannel}. `undefined` for the default stdio
|
|
41
|
+
* transport (single client — not co-drivable). Available after the transport
|
|
42
|
+
* has started (post `ensureInitialized`).
|
|
43
|
+
*/
|
|
44
|
+
terminalRemoteUrl() {
|
|
45
|
+
const url = this.channel?.url;
|
|
46
|
+
return url ? url : undefined;
|
|
47
|
+
}
|
|
48
|
+
async ensureInitialized() {
|
|
49
|
+
if (this.initializeResponse) {
|
|
50
|
+
return this.initializeResponse;
|
|
51
|
+
}
|
|
52
|
+
await this.transport.ensureStarted();
|
|
53
|
+
const response = await this.transport.sendRequest("initialize", {
|
|
54
|
+
clientInfo: this.clientInfo,
|
|
55
|
+
capabilities: { experimentalApi: true },
|
|
56
|
+
});
|
|
57
|
+
this.initializeResponse = response;
|
|
58
|
+
return response;
|
|
59
|
+
}
|
|
60
|
+
async getAuthStatus(params = {}) {
|
|
61
|
+
await this.ensureInitialized();
|
|
62
|
+
return this.transport.sendRequest("getAuthStatus", params);
|
|
63
|
+
}
|
|
64
|
+
async threadStart(params) {
|
|
65
|
+
await this.ensureInitialized();
|
|
66
|
+
const response = await this.transport.sendRequest("thread/start", params);
|
|
67
|
+
return { threadId: response.thread.id };
|
|
68
|
+
}
|
|
69
|
+
async threadResume(params) {
|
|
70
|
+
await this.ensureInitialized();
|
|
71
|
+
const response = await this.transport.sendRequest("thread/resume", params);
|
|
72
|
+
// Expose the whole `thread` (not just its id): its `turns[].items[]` are the
|
|
73
|
+
// backfill the forwarder replays for a fresh thread's first turn.
|
|
74
|
+
return { threadId: response.thread.id, thread: response.thread };
|
|
75
|
+
}
|
|
76
|
+
async turnStart(params) {
|
|
77
|
+
await this.ensureInitialized();
|
|
78
|
+
const response = await this.transport.sendRequest("turn/start", params);
|
|
79
|
+
return { turnId: response.turn.id };
|
|
80
|
+
}
|
|
81
|
+
async turnInterrupt(params) {
|
|
82
|
+
await this.ensureInitialized();
|
|
83
|
+
await this.transport.sendRequest("turn/interrupt", params);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Inject additional user input into the currently running turn. Returns the
|
|
87
|
+
* active turn id (may differ from `expectedTurnId`). Fails if no matching
|
|
88
|
+
* active turn exists, so callers should treat errors as best-effort.
|
|
89
|
+
*/
|
|
90
|
+
async turnSteer(params) {
|
|
91
|
+
await this.ensureInitialized();
|
|
92
|
+
const response = await this.transport.sendRequest("turn/steer", params);
|
|
93
|
+
return { turnId: response.turnId };
|
|
94
|
+
}
|
|
95
|
+
// ---- extended capability methods (driven from Lark slash-commands) ----
|
|
96
|
+
/** List local conversation/thread history (newest first by default). */
|
|
97
|
+
async threadList(params = {}) {
|
|
98
|
+
await this.ensureInitialized();
|
|
99
|
+
return this.transport.sendRequest("thread/list", params);
|
|
100
|
+
}
|
|
101
|
+
/** Fork an existing thread into a new one, optionally overriding settings. */
|
|
102
|
+
async threadFork(params) {
|
|
103
|
+
await this.ensureInitialized();
|
|
104
|
+
return this.transport.sendRequest("thread/fork", params);
|
|
105
|
+
}
|
|
106
|
+
/** Update per-thread settings (model / mode / personality / sandbox …). */
|
|
107
|
+
async threadSettingsUpdate(params) {
|
|
108
|
+
await this.ensureInitialized();
|
|
109
|
+
return this.transport.sendRequest("thread/settings/update", params);
|
|
110
|
+
}
|
|
111
|
+
/** List the models the local Codex install exposes. */
|
|
112
|
+
async modelList(params = {}) {
|
|
113
|
+
await this.ensureInitialized();
|
|
114
|
+
return this.transport.sendRequest("model/list", params);
|
|
115
|
+
}
|
|
116
|
+
/** List the available collaboration-mode presets (experimental). */
|
|
117
|
+
async collaborationModeList() {
|
|
118
|
+
await this.ensureInitialized();
|
|
119
|
+
return this.transport.sendRequest("collaborationMode/list", {});
|
|
120
|
+
}
|
|
121
|
+
/** Set (or update) the thread's tracked goal. */
|
|
122
|
+
async threadGoalSet(params) {
|
|
123
|
+
await this.ensureInitialized();
|
|
124
|
+
return this.transport.sendRequest("thread/goal/set", params);
|
|
125
|
+
}
|
|
126
|
+
/** Read the thread's current goal. */
|
|
127
|
+
async threadGoalGet(params) {
|
|
128
|
+
await this.ensureInitialized();
|
|
129
|
+
return this.transport.sendRequest("thread/goal/get", params);
|
|
130
|
+
}
|
|
131
|
+
/** Clear the thread's tracked goal. */
|
|
132
|
+
async threadGoalClear(params) {
|
|
133
|
+
await this.ensureInitialized();
|
|
134
|
+
return this.transport.sendRequest("thread/goal/clear", params);
|
|
135
|
+
}
|
|
136
|
+
/** Start a non-interactive code review on the thread. */
|
|
137
|
+
async reviewStart(params) {
|
|
138
|
+
await this.ensureInitialized();
|
|
139
|
+
return this.transport.sendRequest("review/start", params);
|
|
140
|
+
}
|
|
141
|
+
onNotification(listener) {
|
|
142
|
+
this.notificationSubscribers.add(listener);
|
|
143
|
+
return () => {
|
|
144
|
+
this.notificationSubscribers.delete(listener);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
async stop() {
|
|
148
|
+
await this.transport.stop();
|
|
149
|
+
}
|
|
150
|
+
dispatchNotification(method, params) {
|
|
151
|
+
for (const listener of this.notificationSubscribers) {
|
|
152
|
+
try {
|
|
153
|
+
listener(method, params);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
this.logger.log({
|
|
157
|
+
event: "client.notification_listener_failed",
|
|
158
|
+
method,
|
|
159
|
+
error: error instanceof Error ? error.message : String(error),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async handleServerRequest(method, params) {
|
|
165
|
+
switch (method) {
|
|
166
|
+
case "item/commandExecution/requestApproval":
|
|
167
|
+
case "execCommandApproval":
|
|
168
|
+
return this.handleApproval("exec", params);
|
|
169
|
+
case "item/fileChange/requestApproval":
|
|
170
|
+
case "applyPatchApproval":
|
|
171
|
+
return this.handleApproval("patch", params);
|
|
172
|
+
case "item/tool/requestUserInput":
|
|
173
|
+
case "mcpServer/elicitation/request":
|
|
174
|
+
return this.respondNotSupported(method);
|
|
175
|
+
case "item/permissions/requestApproval":
|
|
176
|
+
return { decision: this.declineDecisionForApprovals() };
|
|
177
|
+
case "account/chatgptAuthTokens/refresh":
|
|
178
|
+
// Codex asks the host to refresh tokens; without an attached UI the
|
|
179
|
+
// best we can do is decline, surfacing the auth error to the caller
|
|
180
|
+
// via the next turn.
|
|
181
|
+
return null;
|
|
182
|
+
default:
|
|
183
|
+
this.logger.log({
|
|
184
|
+
event: "client.unhandled_server_request",
|
|
185
|
+
method,
|
|
186
|
+
});
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Register the listener that surfaces interactive approval requests. Set by
|
|
192
|
+
* the executor for the duration of a run so requests reach its event stream
|
|
193
|
+
* (and thus the web / Lark approval card).
|
|
194
|
+
*/
|
|
195
|
+
setApprovalRequestListener(listener) {
|
|
196
|
+
this.approvalListener = listener;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Deliver a user's decision for a pending interactive approval. Returns false
|
|
200
|
+
* if the approval id is unknown (already resolved, timed out, or auto-decided).
|
|
201
|
+
*/
|
|
202
|
+
resolveApproval(approvalId, decision) {
|
|
203
|
+
const pending = this.pendingApprovals.get(approvalId);
|
|
204
|
+
if (!pending) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
clearTimeout(pending.timer);
|
|
208
|
+
this.pendingApprovals.delete(approvalId);
|
|
209
|
+
pending.resolve(decision);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Interactive approval path: surface the request and block the codex
|
|
214
|
+
* server-request until the user decides (or the timeout falls back to the
|
|
215
|
+
* auto policy). When interactive approvals are off, decide immediately.
|
|
216
|
+
*/
|
|
217
|
+
async handleApproval(kind, params) {
|
|
218
|
+
const listener = this.approvalListener;
|
|
219
|
+
if (!this.interactiveApprovals || !listener) {
|
|
220
|
+
return kind === "exec"
|
|
221
|
+
? this.respondCommandApproval(params)
|
|
222
|
+
: this.respondFileChangeApproval(params);
|
|
223
|
+
}
|
|
224
|
+
const approvalId = randomUUID();
|
|
225
|
+
const request = { approvalId, kind };
|
|
226
|
+
const p = params;
|
|
227
|
+
if (typeof p.command === "string")
|
|
228
|
+
request.command = p.command;
|
|
229
|
+
if (typeof p.cwd === "string")
|
|
230
|
+
request.cwd = p.cwd;
|
|
231
|
+
if (typeof p.diff === "string")
|
|
232
|
+
request.diff = p.diff;
|
|
233
|
+
try {
|
|
234
|
+
listener(request);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
this.logger.log({
|
|
238
|
+
event: "client.approval_listener_failed",
|
|
239
|
+
error: error instanceof Error ? error.message : String(error),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
const decision = await new Promise((resolve) => {
|
|
243
|
+
const timer = setTimeout(() => {
|
|
244
|
+
this.pendingApprovals.delete(approvalId);
|
|
245
|
+
resolve(this.autoApprovalDecision());
|
|
246
|
+
}, APPROVAL_TIMEOUT_MS);
|
|
247
|
+
timer.unref?.();
|
|
248
|
+
this.pendingApprovals.set(approvalId, { resolve, timer });
|
|
249
|
+
});
|
|
250
|
+
return { decision };
|
|
251
|
+
}
|
|
252
|
+
autoApprovalDecision() {
|
|
253
|
+
switch (this.approvalDecisionPolicy) {
|
|
254
|
+
case "auto-approve-session":
|
|
255
|
+
return "acceptForSession";
|
|
256
|
+
case "auto-decline":
|
|
257
|
+
return "decline";
|
|
258
|
+
case "auto-cancel":
|
|
259
|
+
default:
|
|
260
|
+
return "cancel";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
respondCommandApproval(_params) {
|
|
264
|
+
switch (this.approvalDecisionPolicy) {
|
|
265
|
+
case "auto-approve-session":
|
|
266
|
+
return { decision: "acceptForSession" };
|
|
267
|
+
case "auto-decline":
|
|
268
|
+
return { decision: "decline" };
|
|
269
|
+
case "auto-cancel":
|
|
270
|
+
default:
|
|
271
|
+
return { decision: "cancel" };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
respondFileChangeApproval(_params) {
|
|
275
|
+
switch (this.approvalDecisionPolicy) {
|
|
276
|
+
case "auto-approve-session":
|
|
277
|
+
return { decision: "acceptForSession" };
|
|
278
|
+
case "auto-decline":
|
|
279
|
+
return { decision: "decline" };
|
|
280
|
+
case "auto-cancel":
|
|
281
|
+
default:
|
|
282
|
+
return { decision: "cancel" };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
declineDecisionForApprovals() {
|
|
286
|
+
switch (this.approvalDecisionPolicy) {
|
|
287
|
+
case "auto-approve-session":
|
|
288
|
+
return "accept";
|
|
289
|
+
case "auto-decline":
|
|
290
|
+
return "decline";
|
|
291
|
+
case "auto-cancel":
|
|
292
|
+
default:
|
|
293
|
+
return "cancel";
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
respondNotSupported(method) {
|
|
297
|
+
this.logger.log({
|
|
298
|
+
event: "client.server_request_unsupported",
|
|
299
|
+
method,
|
|
300
|
+
});
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Construct a SandboxPolicy compatible with the App Server turn/start params
|
|
306
|
+
* from the env-configured mode and the allowlist of writable roots. The
|
|
307
|
+
* shape mirrors `SandboxPolicy` in `codex-app-server` generated types.
|
|
308
|
+
*/
|
|
309
|
+
export function buildSandboxPolicy(options) {
|
|
310
|
+
switch (options.mode) {
|
|
311
|
+
case "danger-full-access":
|
|
312
|
+
return { type: "dangerFullAccess" };
|
|
313
|
+
case "read-only":
|
|
314
|
+
return { type: "readOnly", networkAccess: false };
|
|
315
|
+
case "workspace-write":
|
|
316
|
+
default: {
|
|
317
|
+
const writableRoots = options.allowedRoots.length
|
|
318
|
+
? options.allowedRoots
|
|
319
|
+
: [options.cwd];
|
|
320
|
+
return {
|
|
321
|
+
type: "workspaceWrite",
|
|
322
|
+
writableRoots,
|
|
323
|
+
networkAccess: true,
|
|
324
|
+
excludeTmpdirEnvVar: false,
|
|
325
|
+
excludeSlashTmp: false,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
export function buildTextUserInput(message) {
|
|
331
|
+
return [{ type: "text", text: message, text_elements: [] }];
|
|
332
|
+
}
|
|
333
|
+
const defaultLogger = {
|
|
334
|
+
log(entry) {
|
|
335
|
+
console.log(JSON.stringify({
|
|
336
|
+
level: "info",
|
|
337
|
+
type: "codex_app_server_client",
|
|
338
|
+
...entry,
|
|
339
|
+
}));
|
|
340
|
+
},
|
|
341
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent codex session forwarder — the heart of omnigent's codex-native
|
|
3
|
+
* model, ported to rynx. It subscribes to a session's app-server for the whole
|
|
4
|
+
* session lifetime (not just one turn) and maps every thread notification to a
|
|
5
|
+
* typed {@link AgentEvent}, so turns started by ANY client — the web composer OR
|
|
6
|
+
* a co-driving `codex --remote` TUI — are mirrored into the canonical log/bus.
|
|
7
|
+
* That is what makes terminal↔chat one integrated view instead of two.
|
|
8
|
+
*
|
|
9
|
+
* It groups events into turns: `turn/started` opens a response, `turn/completed`
|
|
10
|
+
* (or a fatal error) closes it. The turn's codex `turnId` is surfaced on
|
|
11
|
+
* {@link CodexForwarderSink.onTurnStart} so the normalizer's `responseId` is
|
|
12
|
+
* DETERMINISTIC (`resp_codex_<turnId>`) — the same turn always maps to the same
|
|
13
|
+
* response id across snapshot/live, so items converge (omnigent's `codex_{turnId}`).
|
|
14
|
+
*
|
|
15
|
+
* omnigent's subscribe-then-mirror model (verified against real codex 0.142.5):
|
|
16
|
+
* the app-server delivers a thread's item/turn notifications to a connection that
|
|
17
|
+
* has `thread/resume`d it. A fresh TUI-created thread has no rollout until its
|
|
18
|
+
* first turn, so resume is parked until {@link CodexForwarderSink.onThreadActive}
|
|
19
|
+
* fires (the live stream shows the thread active), then retried — and the retry
|
|
20
|
+
* response carries the first turn's items as backfill, replayed via
|
|
21
|
+
* {@link CodexSessionForwarder.replayBackfill}. Backfill and the live stream are
|
|
22
|
+
* de-duplicated by codex item id.
|
|
23
|
+
*/
|
|
24
|
+
import type { AgentEvent } from "@rynx-ai/core";
|
|
25
|
+
import type { CodexAppServerClient } from "./client.js";
|
|
26
|
+
import type { ResumedTurn } from "./protocol.js";
|
|
27
|
+
export interface CodexForwarderSink {
|
|
28
|
+
/** A turn began. `turnId` is codex's turn id, used to derive a stable
|
|
29
|
+
* `responseId`. Start a fresh normalizer/response. */
|
|
30
|
+
onTurnStart(turnId?: string): void;
|
|
31
|
+
/** One mapped event within the current turn. */
|
|
32
|
+
onEvent(event: AgentEvent): void;
|
|
33
|
+
/** The current turn finished; `usage` is the runtime's raw snapshot if any. */
|
|
34
|
+
onTurnEnd(usage?: Record<string, unknown>): void;
|
|
35
|
+
/** A turn failed on the runtime. */
|
|
36
|
+
onTurnError(error: Error): void;
|
|
37
|
+
/** The user's turn text (sourced from codex's `userMessage` item), so a
|
|
38
|
+
* co-driving TUI's prompt is recorded even though this process never injected it. */
|
|
39
|
+
onUserMessage?(text: string): void;
|
|
40
|
+
/** A thread was announced on the app-server (a `--remote` TUI creating a thread,
|
|
41
|
+
* or a resume). The host persists the id + starts the subscribe loop. */
|
|
42
|
+
onThreadStarted?(threadId: string): void;
|
|
43
|
+
/** The thread showed activity (a turn/item began, so its rollout now exists).
|
|
44
|
+
* Fired once; lets a parked `thread/resume` retry (omnigent's ready signal). */
|
|
45
|
+
onThreadActive?(): void;
|
|
46
|
+
}
|
|
47
|
+
export declare class CodexSessionForwarder {
|
|
48
|
+
private readonly client;
|
|
49
|
+
private readonly sink;
|
|
50
|
+
private unsubscribe;
|
|
51
|
+
private turnOpen;
|
|
52
|
+
private currentTurnIdValue;
|
|
53
|
+
private currentThreadIdValue;
|
|
54
|
+
private activeSignaled;
|
|
55
|
+
/** Completed-item dedup keys already mirrored (live vs resume backfill). Key =
|
|
56
|
+
* `threadId:turnId:item.id`; anonymous items use a per-(thread,turn) position
|
|
57
|
+
* counter. Mirrors omnigent `_completed_item_key` + `synced_item_keys`. */
|
|
58
|
+
private readonly seenCompletedItems;
|
|
59
|
+
/** Per-(thread,turn) position counter for items lacking a stable codex id
|
|
60
|
+
* (peek-then-advance; advanced only on a successful claim). omnigent anon path. */
|
|
61
|
+
private readonly anonCounters;
|
|
62
|
+
constructor(client: CodexAppServerClient, sink: CodexForwarderSink);
|
|
63
|
+
/** Begin mirroring. Idempotent. */
|
|
64
|
+
start(): void;
|
|
65
|
+
stop(): void;
|
|
66
|
+
/** True while a turn is open — injection uses `turn/steer` then, else `turn/start`. */
|
|
67
|
+
isTurnOpen(): boolean;
|
|
68
|
+
/** The current turn's codex id (only meaningful while {@link isTurnOpen}). */
|
|
69
|
+
currentTurnId(): string | null;
|
|
70
|
+
/** The bound codex thread id captured from `thread/started` (null until then). */
|
|
71
|
+
threadId(): string | null;
|
|
72
|
+
/**
|
|
73
|
+
* Replay the backlog turns from a `thread/resume` response as if they were live
|
|
74
|
+
* `item/completed` notifications — the fresh-thread first-turn backfill. Each
|
|
75
|
+
* turn opens its own response (`resp_codex_<turnId>`); items are de-duplicated
|
|
76
|
+
* against the live stream by codex item id, so a turn that also streams live is
|
|
77
|
+
* not doubled.
|
|
78
|
+
*/
|
|
79
|
+
replayBackfill(turns: ResumedTurn[]): void;
|
|
80
|
+
private handle;
|
|
81
|
+
/** Map + emit one completed codex item, deduped by a TOTAL key and routing the
|
|
82
|
+
* user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
|
|
83
|
+
private processCompletedItem;
|
|
84
|
+
/** Build a total dedup key for one completed item. Stable-id items use
|
|
85
|
+
* `threadId:turnId:item.id` — identical across replay + live, so the second
|
|
86
|
+
* delivery is dropped. Items without a codex id fall back to a per-(thread,turn)
|
|
87
|
+
* position counter (peeked here; advanced only on a successful claim, via
|
|
88
|
+
* {@link advanceAnonCounter}). Mirrors omnigent `_completed_item_key`. */
|
|
89
|
+
private completedItemKey;
|
|
90
|
+
private advanceAnonCounter;
|
|
91
|
+
private ensureTurn;
|
|
92
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { mapCodexItem, mapCodexNotification } from "./mapping.js";
|
|
2
|
+
function threadIdFrom(params) {
|
|
3
|
+
const p = params;
|
|
4
|
+
return p?.threadId ?? p?.thread?.id;
|
|
5
|
+
}
|
|
6
|
+
function turnIdFrom(params) {
|
|
7
|
+
const p = params;
|
|
8
|
+
return p?.turnId ?? p?.turn?.id;
|
|
9
|
+
}
|
|
10
|
+
function userMessageText(item) {
|
|
11
|
+
if (item.type !== "userMessage")
|
|
12
|
+
return undefined;
|
|
13
|
+
const content = item.content ?? [];
|
|
14
|
+
const text = content
|
|
15
|
+
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
16
|
+
.map((c) => c.text)
|
|
17
|
+
.join("")
|
|
18
|
+
.trim();
|
|
19
|
+
return text || undefined;
|
|
20
|
+
}
|
|
21
|
+
/** Whether a notification implies the thread is now active (its first turn has
|
|
22
|
+
* begun, so the rollout exists). Mirrors omnigent's `_event_indicates_thread_active`. */
|
|
23
|
+
function indicatesActive(method, params) {
|
|
24
|
+
if (method.startsWith("turn/") || method.startsWith("item/"))
|
|
25
|
+
return true;
|
|
26
|
+
if (method === "thread/status/changed") {
|
|
27
|
+
const status = params?.status;
|
|
28
|
+
return status?.type === "active";
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
export class CodexSessionForwarder {
|
|
33
|
+
client;
|
|
34
|
+
sink;
|
|
35
|
+
unsubscribe = null;
|
|
36
|
+
turnOpen = false;
|
|
37
|
+
currentTurnIdValue = null;
|
|
38
|
+
currentThreadIdValue = null;
|
|
39
|
+
activeSignaled = false;
|
|
40
|
+
/** Completed-item dedup keys already mirrored (live vs resume backfill). Key =
|
|
41
|
+
* `threadId:turnId:item.id`; anonymous items use a per-(thread,turn) position
|
|
42
|
+
* counter. Mirrors omnigent `_completed_item_key` + `synced_item_keys`. */
|
|
43
|
+
seenCompletedItems = new Set();
|
|
44
|
+
/** Per-(thread,turn) position counter for items lacking a stable codex id
|
|
45
|
+
* (peek-then-advance; advanced only on a successful claim). omnigent anon path. */
|
|
46
|
+
anonCounters = new Map();
|
|
47
|
+
constructor(client, sink) {
|
|
48
|
+
this.client = client;
|
|
49
|
+
this.sink = sink;
|
|
50
|
+
}
|
|
51
|
+
/** Begin mirroring. Idempotent. */
|
|
52
|
+
start() {
|
|
53
|
+
if (this.unsubscribe)
|
|
54
|
+
return;
|
|
55
|
+
this.unsubscribe = this.client.onNotification((method, params) => {
|
|
56
|
+
this.handle(method, params);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
stop() {
|
|
60
|
+
this.unsubscribe?.();
|
|
61
|
+
this.unsubscribe = null;
|
|
62
|
+
if (this.turnOpen) {
|
|
63
|
+
this.turnOpen = false;
|
|
64
|
+
this.sink.onTurnEnd();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** True while a turn is open — injection uses `turn/steer` then, else `turn/start`. */
|
|
68
|
+
isTurnOpen() {
|
|
69
|
+
return this.turnOpen;
|
|
70
|
+
}
|
|
71
|
+
/** The current turn's codex id (only meaningful while {@link isTurnOpen}). */
|
|
72
|
+
currentTurnId() {
|
|
73
|
+
return this.currentTurnIdValue;
|
|
74
|
+
}
|
|
75
|
+
/** The bound codex thread id captured from `thread/started` (null until then). */
|
|
76
|
+
threadId() {
|
|
77
|
+
return this.currentThreadIdValue;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Replay the backlog turns from a `thread/resume` response as if they were live
|
|
81
|
+
* `item/completed` notifications — the fresh-thread first-turn backfill. Each
|
|
82
|
+
* turn opens its own response (`resp_codex_<turnId>`); items are de-duplicated
|
|
83
|
+
* against the live stream by codex item id, so a turn that also streams live is
|
|
84
|
+
* not doubled.
|
|
85
|
+
*/
|
|
86
|
+
replayBackfill(turns) {
|
|
87
|
+
for (const turn of turns) {
|
|
88
|
+
const turnId = turn.id ?? turn.turnId;
|
|
89
|
+
if (turnId)
|
|
90
|
+
this.currentTurnIdValue = turnId;
|
|
91
|
+
this.ensureTurn();
|
|
92
|
+
for (const item of turn.items ?? [])
|
|
93
|
+
this.processCompletedItem(item);
|
|
94
|
+
this.turnOpen = false;
|
|
95
|
+
this.sink.onTurnEnd();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
handle(method, params) {
|
|
99
|
+
if (method === "thread/started" || method === "thread.started") {
|
|
100
|
+
const tid = threadIdFrom(params);
|
|
101
|
+
if (tid && tid !== this.currentThreadIdValue) {
|
|
102
|
+
this.currentThreadIdValue = tid;
|
|
103
|
+
this.sink.onThreadStarted?.(tid);
|
|
104
|
+
}
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
// Release a parked resume as soon as the thread shows activity (rollout now
|
|
108
|
+
// exists). Fire once.
|
|
109
|
+
if (!this.activeSignaled && indicatesActive(method, params)) {
|
|
110
|
+
this.activeSignaled = true;
|
|
111
|
+
this.sink.onThreadActive?.();
|
|
112
|
+
}
|
|
113
|
+
const carriedTurnId = turnIdFrom(params);
|
|
114
|
+
if (carriedTurnId)
|
|
115
|
+
this.currentTurnIdValue = carriedTurnId;
|
|
116
|
+
if (method === "turn/started") {
|
|
117
|
+
this.ensureTurn();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Completed items (user echo, assistant, tool, reasoning) go through the
|
|
121
|
+
// deduped path so a resume backfill and the live stream never double them.
|
|
122
|
+
if (method === "item/completed") {
|
|
123
|
+
const item = params?.item;
|
|
124
|
+
if (item) {
|
|
125
|
+
this.processCompletedItem(item);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const mapped = mapCodexNotification(method, params);
|
|
130
|
+
if (mapped.events.length || mapped.turnCompleted || mapped.fatalError) {
|
|
131
|
+
this.ensureTurn();
|
|
132
|
+
}
|
|
133
|
+
for (const event of mapped.events) {
|
|
134
|
+
this.sink.onEvent(event);
|
|
135
|
+
}
|
|
136
|
+
if (mapped.fatalError) {
|
|
137
|
+
this.turnOpen = false;
|
|
138
|
+
this.sink.onTurnError(mapped.fatalError);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (mapped.turnCompleted) {
|
|
142
|
+
this.turnOpen = false;
|
|
143
|
+
this.sink.onTurnEnd(mapped.usage);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Map + emit one completed codex item, deduped by a TOTAL key and routing the
|
|
147
|
+
* user echo to {@link CodexForwarderSink.onUserMessage}. Shared by live + backfill. */
|
|
148
|
+
processCompletedItem(item) {
|
|
149
|
+
const { key, isAnon } = this.completedItemKey(item);
|
|
150
|
+
if (this.seenCompletedItems.has(key))
|
|
151
|
+
return;
|
|
152
|
+
this.seenCompletedItems.add(key);
|
|
153
|
+
if (isAnon)
|
|
154
|
+
this.advanceAnonCounter();
|
|
155
|
+
this.ensureTurn();
|
|
156
|
+
const userText = userMessageText(item);
|
|
157
|
+
if (userText !== undefined) {
|
|
158
|
+
this.sink.onUserMessage?.(userText);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const mapped = mapCodexItem("item/completed", item);
|
|
162
|
+
for (const event of mapped.events)
|
|
163
|
+
this.sink.onEvent(event);
|
|
164
|
+
}
|
|
165
|
+
/** Build a total dedup key for one completed item. Stable-id items use
|
|
166
|
+
* `threadId:turnId:item.id` — identical across replay + live, so the second
|
|
167
|
+
* delivery is dropped. Items without a codex id fall back to a per-(thread,turn)
|
|
168
|
+
* position counter (peeked here; advanced only on a successful claim, via
|
|
169
|
+
* {@link advanceAnonCounter}). Mirrors omnigent `_completed_item_key`. */
|
|
170
|
+
completedItemKey(item) {
|
|
171
|
+
const threadId = this.currentThreadIdValue ?? "thread";
|
|
172
|
+
const turnId = this.currentTurnIdValue ?? "turn";
|
|
173
|
+
if (item.id)
|
|
174
|
+
return { key: `${threadId}:${turnId}:${item.id}`, isAnon: false };
|
|
175
|
+
const n = this.anonCounters.get(`${threadId}:${turnId}`) ?? 0;
|
|
176
|
+
return { key: `${threadId}:${turnId}:anon:${n}`, isAnon: true };
|
|
177
|
+
}
|
|
178
|
+
advanceAnonCounter() {
|
|
179
|
+
const k = `${this.currentThreadIdValue ?? "thread"}:${this.currentTurnIdValue ?? "turn"}`;
|
|
180
|
+
this.anonCounters.set(k, (this.anonCounters.get(k) ?? 0) + 1);
|
|
181
|
+
}
|
|
182
|
+
ensureTurn() {
|
|
183
|
+
if (!this.turnOpen) {
|
|
184
|
+
this.turnOpen = true;
|
|
185
|
+
this.sink.onTurnStart(this.currentTurnIdValue ?? undefined);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure codex app-server notification → {@link AgentEvent} mapping. Extracted from
|
|
3
|
+
* the executor so BOTH the per-turn executor and the persistent session
|
|
4
|
+
* forwarder (which mirrors every thread turn — web- AND TUI-initiated — into the
|
|
5
|
+
* canonical log, omnigent's codex-native model) share one mapping.
|
|
6
|
+
*/
|
|
7
|
+
import type { AgentEvent } from "@rynx-ai/core";
|
|
8
|
+
import type { ThreadItem } from "./protocol.js";
|
|
9
|
+
export interface CodexMapResult {
|
|
10
|
+
events: AgentEvent[];
|
|
11
|
+
finalText?: string;
|
|
12
|
+
usage?: Record<string, unknown>;
|
|
13
|
+
turnCompleted?: boolean;
|
|
14
|
+
fatalError?: Error;
|
|
15
|
+
}
|
|
16
|
+
/** Map one thread item (`item/started` | `item/completed`) to events. */
|
|
17
|
+
export declare function mapCodexItem(method: string, item: ThreadItem): CodexMapResult;
|
|
18
|
+
/** Map one codex app-server notification to events (+ turn/usage/error signals). */
|
|
19
|
+
export declare function mapCodexNotification(method: string, params: unknown): CodexMapResult;
|