ohmyvibe 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.
@@ -0,0 +1,1192 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { randomUUID } from "node:crypto";
3
+ import path from "node:path";
4
+ import { CodexAppServerClient } from "./codexAppServerClient.js";
5
+ import { SessionStore } from "./sessionStore.js";
6
+ export class SessionManager extends EventEmitter {
7
+ sessions = new Map();
8
+ store = new SessionStore();
9
+ configCache;
10
+ constructor() {
11
+ super();
12
+ this.restorePersistedSessions();
13
+ }
14
+ list() {
15
+ return Array.from(this.sessions.values())
16
+ .map((session) => this.toSummary(session))
17
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
18
+ }
19
+ get(sessionId) {
20
+ const session = this.sessions.get(sessionId);
21
+ if (!session) {
22
+ return undefined;
23
+ }
24
+ return {
25
+ ...this.toSummary(session),
26
+ transcript: [...session.transcript],
27
+ };
28
+ }
29
+ async getConfig() {
30
+ if (this.configCache) {
31
+ return this.configCache;
32
+ }
33
+ const config = await this.readConfig();
34
+ this.configCache = config;
35
+ return config;
36
+ }
37
+ async listHistory() {
38
+ const client = new CodexAppServerClient({ cwd: process.cwd() });
39
+ try {
40
+ await client.initialize();
41
+ const response = await client.threadList({
42
+ limit: 200,
43
+ sortKey: "updated_at",
44
+ sourceKinds: ["cli", "vscode", "appServer", "unknown"],
45
+ });
46
+ return Array.isArray(response?.data)
47
+ ? response.data.map((thread) => ({
48
+ id: thread.id,
49
+ title: thread.name || thread.preview || path.basename(thread.cwd || "") || "Codex Session",
50
+ cwd: thread.cwd || "",
51
+ createdAt: this.toIsoFromUnixSeconds(thread.createdAt),
52
+ updatedAt: this.toIsoFromUnixSeconds(thread.updatedAt),
53
+ status: thread.status?.type || "unknown",
54
+ path: thread.path,
55
+ source: thread.source,
56
+ modelProvider: thread.modelProvider,
57
+ }))
58
+ : [];
59
+ }
60
+ finally {
61
+ await client.close();
62
+ }
63
+ }
64
+ async create(input) {
65
+ const sessionId = randomUUID();
66
+ const cwd = path.resolve(input.cwd);
67
+ const now = new Date().toISOString();
68
+ const session = {
69
+ id: sessionId,
70
+ title: path.basename(cwd) || "Codex Session",
71
+ cwd,
72
+ createdAt: now,
73
+ updatedAt: now,
74
+ status: "starting",
75
+ origin: "created",
76
+ model: input.model,
77
+ reasoningEffort: input.reasoningEffort,
78
+ sandbox: input.sandbox ?? "workspace-write",
79
+ approvalPolicy: input.approvalPolicy ?? "never",
80
+ transcript: [],
81
+ liveMessages: new Map(),
82
+ liveReasoning: new Map(),
83
+ pendingApprovals: new Map(),
84
+ };
85
+ this.sessions.set(sessionId, session);
86
+ this.persist();
87
+ this.emitChange({ type: "session-created", session: this.toSummary(session) });
88
+ session.startupPromise = this.trackStartup(session, this.startSessionInBackground(session, input));
89
+ return this.getOrThrow(sessionId);
90
+ }
91
+ async restore(input) {
92
+ const existing = Array.from(this.sessions.values()).find((session) => session.codexThreadId === input.threadId);
93
+ if (existing) {
94
+ await this.ensureCodexClient(existing, input);
95
+ return this.getOrThrow(existing.id);
96
+ }
97
+ const cwd = path.resolve(input.cwd ?? process.cwd());
98
+ const now = new Date().toISOString();
99
+ const session = {
100
+ id: randomUUID(),
101
+ title: "Restored Session",
102
+ cwd,
103
+ createdAt: now,
104
+ updatedAt: now,
105
+ status: "starting",
106
+ origin: "restored",
107
+ model: input.model,
108
+ reasoningEffort: input.reasoningEffort,
109
+ sandbox: input.sandbox ?? "workspace-write",
110
+ approvalPolicy: input.approvalPolicy ?? "never",
111
+ codexThreadId: input.threadId,
112
+ transcript: [],
113
+ liveMessages: new Map(),
114
+ liveReasoning: new Map(),
115
+ pendingApprovals: new Map(),
116
+ };
117
+ this.sessions.set(session.id, session);
118
+ this.persist();
119
+ this.emitChange({ type: "session-created", session: this.toSummary(session) });
120
+ session.startupPromise = this.trackStartup(session, this.restoreSessionInBackground(session, input));
121
+ return this.getOrThrow(session.id);
122
+ }
123
+ async updateConfig(sessionId, input) {
124
+ const session = this.getSessionOrThrow(sessionId);
125
+ const model = typeof input.model === "string" && input.model.trim() ? input.model.trim() : session.model;
126
+ const reasoningEffort = this.normalizeReasoningEffort(input.reasoningEffort ?? session.reasoningEffort);
127
+ const sandbox = input.sandbox ?? session.sandbox ?? "workspace-write";
128
+ const approvalPolicy = input.approvalPolicy ?? session.approvalPolicy ?? "never";
129
+ const runtimeChanged = sandbox !== session.sandbox;
130
+ session.model = model;
131
+ session.reasoningEffort = reasoningEffort;
132
+ session.sandbox = sandbox;
133
+ session.approvalPolicy = approvalPolicy;
134
+ session.configDirty = session.configDirty || runtimeChanged;
135
+ this.touch(session);
136
+ this.persist();
137
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
138
+ if (session.status !== "running" && session.status !== "starting") {
139
+ await this.applyPendingConfig(session);
140
+ }
141
+ return this.getOrThrow(sessionId);
142
+ }
143
+ async sendMessage(sessionId, text) {
144
+ const session = this.getSessionOrThrow(sessionId);
145
+ await this.applyPendingConfig(session);
146
+ await this.ensureCodexClient(session);
147
+ if (!session.codex || !session.codexThreadId) {
148
+ throw new Error("Session is not ready");
149
+ }
150
+ this.addEntry(session, { kind: "user", text });
151
+ session.status = "running";
152
+ session.currentTurnMetrics = {
153
+ startedAt: Date.now(),
154
+ outputEntryIds: new Set(),
155
+ };
156
+ this.persist();
157
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
158
+ void this.startTurnInBackground(session, text);
159
+ }
160
+ async interrupt(sessionId) {
161
+ const session = this.getSessionOrThrow(sessionId);
162
+ if (!session.codexThreadId || !session.activeTurnId) {
163
+ return;
164
+ }
165
+ await this.ensureCodexClient(session);
166
+ if (!session.codex) {
167
+ return;
168
+ }
169
+ await session.codex.turnInterrupt(session.codexThreadId, session.activeTurnId);
170
+ session.status = "interrupted";
171
+ session.activeTurnId = undefined;
172
+ this.touch(session);
173
+ this.persist();
174
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
175
+ }
176
+ async respondApproval(sessionId, approvalRequestId, decision) {
177
+ const session = this.getSessionOrThrow(sessionId);
178
+ const pending = session.pendingApprovals.get(approvalRequestId);
179
+ if (!pending || !session.codex) {
180
+ throw new Error(`Approval request not found: ${approvalRequestId}`);
181
+ }
182
+ const response = this.mapApprovalResponse(pending.method, pending.params, decision);
183
+ session.codex.respond(pending.requestIdRaw, response);
184
+ session.pendingApprovals.delete(approvalRequestId);
185
+ const entry = session.transcript.find((item) => item.id === pending.entryId);
186
+ if (entry) {
187
+ entry.status = decision === "approve" ? "approved" : "declined";
188
+ entry.meta = {
189
+ ...(entry.meta ?? {}),
190
+ resolvedAt: new Date().toISOString(),
191
+ decision,
192
+ };
193
+ }
194
+ this.touch(session);
195
+ this.persist();
196
+ this.emitChange({
197
+ type: "session-reset",
198
+ sessionId: session.id,
199
+ transcript: [...session.transcript],
200
+ });
201
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
202
+ return this.getOrThrow(sessionId);
203
+ }
204
+ async close(sessionId) {
205
+ const session = this.getSessionOrThrow(sessionId);
206
+ session.status = "closed";
207
+ if (session.codex) {
208
+ await session.codex.close();
209
+ }
210
+ this.sessions.delete(sessionId);
211
+ this.persist();
212
+ this.emitChange({ type: "session-deleted", sessionId });
213
+ }
214
+ restorePersistedSessions() {
215
+ for (const persisted of this.store.load()) {
216
+ this.sessions.set(persisted.id, {
217
+ id: persisted.id,
218
+ title: persisted.title,
219
+ cwd: persisted.cwd,
220
+ createdAt: persisted.createdAt,
221
+ updatedAt: persisted.updatedAt,
222
+ status: persisted.status === "closed" ? "idle" : persisted.status,
223
+ origin: persisted.origin ?? "created",
224
+ model: persisted.model,
225
+ reasoningEffort: persisted.reasoningEffort,
226
+ sandbox: persisted.sandbox,
227
+ approvalPolicy: persisted.approvalPolicy,
228
+ codexThreadId: persisted.codexThreadId,
229
+ codexPath: persisted.codexPath,
230
+ codexSource: persisted.codexSource,
231
+ lastError: persisted.lastError,
232
+ transcript: Array.isArray(persisted.transcript) ? persisted.transcript : [],
233
+ liveMessages: new Map(),
234
+ liveReasoning: new Map(),
235
+ pendingApprovals: new Map(),
236
+ });
237
+ }
238
+ }
239
+ persist() {
240
+ this.store.save(Array.from(this.sessions.values())
241
+ .map((session) => this.get(session.id))
242
+ .filter((session) => Boolean(session)));
243
+ }
244
+ async startSessionInBackground(session, input) {
245
+ try {
246
+ await this.startFreshThread(session, input);
247
+ }
248
+ catch (error) {
249
+ session.activeTurnId = undefined;
250
+ session.status = "failed";
251
+ session.lastError = this.errorMessage(error);
252
+ this.addEntry(session, {
253
+ kind: "system",
254
+ text: `Session startup failed: ${session.lastError}`,
255
+ status: "failed",
256
+ });
257
+ this.persist();
258
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
259
+ }
260
+ }
261
+ async restoreSessionInBackground(session, input) {
262
+ try {
263
+ await this.ensureCodexClient(session, input);
264
+ }
265
+ catch (error) {
266
+ session.activeTurnId = undefined;
267
+ session.status = "failed";
268
+ session.lastError = this.errorMessage(error);
269
+ this.addEntry(session, {
270
+ kind: "system",
271
+ text: `Session restore failed: ${session.lastError}`,
272
+ status: "failed",
273
+ });
274
+ this.persist();
275
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
276
+ }
277
+ }
278
+ async startTurnInBackground(session, text) {
279
+ if (!session.codex || !session.codexThreadId) {
280
+ session.activeTurnId = undefined;
281
+ session.status = "failed";
282
+ session.lastError = "Session is not ready";
283
+ this.addEntry(session, {
284
+ kind: "system",
285
+ text: `Turn start failed: ${session.lastError}`,
286
+ status: "failed",
287
+ });
288
+ this.persist();
289
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
290
+ return;
291
+ }
292
+ try {
293
+ const response = await session.codex.turnStart({
294
+ threadId: session.codexThreadId,
295
+ text,
296
+ effort: session.reasoningEffort,
297
+ model: session.model,
298
+ approvalPolicy: session.approvalPolicy,
299
+ summary: "detailed",
300
+ });
301
+ if (typeof response?.turn?.id === "string") {
302
+ session.activeTurnId = response.turn.id;
303
+ }
304
+ }
305
+ catch (error) {
306
+ session.activeTurnId = undefined;
307
+ session.currentTurnMetrics = undefined;
308
+ session.status = "failed";
309
+ session.lastError = this.errorMessage(error);
310
+ this.addEntry(session, {
311
+ kind: "system",
312
+ text: `Turn start failed: ${session.lastError}`,
313
+ status: "failed",
314
+ });
315
+ this.persist();
316
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
317
+ }
318
+ }
319
+ async startFreshThread(session, input) {
320
+ const codex = new CodexAppServerClient({ cwd: session.cwd });
321
+ session.codex = codex;
322
+ this.attachCodexHooks(session, codex);
323
+ await codex.initialize();
324
+ const response = await codex.threadStart({
325
+ cwd: session.cwd,
326
+ model: input.model,
327
+ sandbox: input.sandbox ?? session.sandbox,
328
+ approvalPolicy: input.approvalPolicy ?? session.approvalPolicy,
329
+ });
330
+ session.codexThreadId = response.thread.id;
331
+ session.codexPath = response.thread.path;
332
+ session.codexSource = response.thread.source;
333
+ session.model = response.model;
334
+ session.reasoningEffort = input.reasoningEffort ?? response.reasoningEffort ?? "medium";
335
+ session.sandbox = input.sandbox ?? session.sandbox;
336
+ session.approvalPolicy = input.approvalPolicy ?? session.approvalPolicy;
337
+ session.status = "idle";
338
+ session.title = response.thread.preview || session.title;
339
+ session.configDirty = false;
340
+ this.touch(session);
341
+ this.persist();
342
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
343
+ }
344
+ async ensureCodexClient(session, overrides) {
345
+ if (session.codex && session.codexThreadId) {
346
+ return;
347
+ }
348
+ if (session.startupPromise) {
349
+ await session.startupPromise;
350
+ if (session.codex && session.codexThreadId) {
351
+ return;
352
+ }
353
+ }
354
+ const startup = (async () => {
355
+ if (!session.codexThreadId) {
356
+ await this.startFreshThread(session, {
357
+ cwd: session.cwd,
358
+ model: overrides?.model ?? session.model,
359
+ reasoningEffort: this.normalizeReasoningEffort(overrides?.reasoningEffort ?? session.reasoningEffort),
360
+ sandbox: overrides?.sandbox ?? session.sandbox,
361
+ approvalPolicy: overrides?.approvalPolicy ?? session.approvalPolicy,
362
+ });
363
+ return;
364
+ }
365
+ const codex = new CodexAppServerClient({ cwd: session.cwd });
366
+ session.codex = codex;
367
+ this.attachCodexHooks(session, codex);
368
+ await codex.initialize();
369
+ const response = await codex.threadResume({
370
+ threadId: session.codexThreadId,
371
+ cwd: overrides?.cwd ?? session.cwd,
372
+ model: overrides?.model ?? session.model,
373
+ sandbox: overrides?.sandbox ?? session.sandbox,
374
+ approvalPolicy: overrides?.approvalPolicy ?? session.approvalPolicy,
375
+ });
376
+ session.cwd = response.cwd || session.cwd;
377
+ session.model = response.model || session.model;
378
+ session.reasoningEffort = this.normalizeReasoningEffort(overrides?.reasoningEffort ?? response.reasoningEffort ?? session.reasoningEffort);
379
+ session.sandbox = overrides?.sandbox ?? session.sandbox;
380
+ session.approvalPolicy = overrides?.approvalPolicy ?? session.approvalPolicy;
381
+ session.codexPath = response.thread?.path || session.codexPath;
382
+ session.codexSource = response.thread?.source || session.codexSource;
383
+ session.title = response.thread?.name || response.thread?.preview || session.title;
384
+ session.status = this.mapThreadStatus(response.thread?.status);
385
+ session.transcript = this.threadToTranscript(response.thread);
386
+ session.liveMessages.clear();
387
+ session.liveReasoning.clear();
388
+ session.pendingApprovals.clear();
389
+ session.configDirty = false;
390
+ this.touch(session);
391
+ this.persist();
392
+ this.emitChange({
393
+ type: "session-reset",
394
+ sessionId: session.id,
395
+ transcript: [...session.transcript],
396
+ });
397
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
398
+ })();
399
+ session.startupPromise = this.trackStartup(session, startup);
400
+ await session.startupPromise;
401
+ }
402
+ attachCodexHooks(session, codex) {
403
+ codex.onNotification(async (notification) => {
404
+ try {
405
+ await this.handleNotification(session, notification);
406
+ }
407
+ catch (error) {
408
+ session.lastError = this.errorMessage(error);
409
+ session.status = "failed";
410
+ this.addEntry(session, {
411
+ kind: "system",
412
+ text: `Notification handling failed: ${session.lastError}`,
413
+ status: "failed",
414
+ });
415
+ this.persist();
416
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
417
+ }
418
+ });
419
+ codex.onRequest((request) => {
420
+ this.handleCodexRequest(session, request);
421
+ });
422
+ codex.onStderr((chunk) => {
423
+ const text = this.cleanStderr(chunk);
424
+ if (!text || this.shouldIgnoreStderr(text)) {
425
+ return;
426
+ }
427
+ this.addEntry(session, {
428
+ kind: "system",
429
+ text,
430
+ status: "stderr",
431
+ });
432
+ });
433
+ codex.onExit(({ code, signal }) => {
434
+ session.codex = undefined;
435
+ session.activeTurnId = undefined;
436
+ session.currentTurnMetrics = undefined;
437
+ if (session.suppressNextExitFailure) {
438
+ session.suppressNextExitFailure = false;
439
+ return;
440
+ }
441
+ if (session.status === "closed") {
442
+ return;
443
+ }
444
+ session.status = "failed";
445
+ session.lastError = `Codex process exited (code=${code}, signal=${signal})`;
446
+ this.addEntry(session, {
447
+ kind: "system",
448
+ text: session.lastError,
449
+ status: "failed",
450
+ });
451
+ this.persist();
452
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
453
+ });
454
+ }
455
+ async handleNotification(session, notification) {
456
+ switch (notification.method) {
457
+ case "thread/started": {
458
+ const preview = notification.params?.thread?.preview;
459
+ session.codexPath = notification.params?.thread?.path || session.codexPath;
460
+ session.codexSource = notification.params?.thread?.source || session.codexSource;
461
+ if (typeof preview === "string" && preview.trim()) {
462
+ session.title = preview.trim();
463
+ }
464
+ this.touch(session);
465
+ this.persist();
466
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
467
+ return;
468
+ }
469
+ case "thread/nameUpdated": {
470
+ const title = notification.params?.name;
471
+ if (typeof title === "string" && title.trim()) {
472
+ session.title = title.trim();
473
+ this.touch(session);
474
+ this.persist();
475
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
476
+ }
477
+ return;
478
+ }
479
+ case "thread/statusChanged": {
480
+ session.status = this.mapThreadStatus(notification.params?.status);
481
+ this.touch(session);
482
+ this.persist();
483
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
484
+ return;
485
+ }
486
+ case "turn/started": {
487
+ if (typeof notification.params?.turn?.id === "string") {
488
+ session.activeTurnId = notification.params.turn.id;
489
+ }
490
+ session.status = "running";
491
+ this.touch(session);
492
+ this.persist();
493
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
494
+ return;
495
+ }
496
+ case "item/started": {
497
+ const item = notification.params?.item;
498
+ if (item?.type === "agentMessage") {
499
+ const entry = this.ensureAssistantEntry(session, item.id);
500
+ entry.status = "streaming";
501
+ this.trackTurnEntry(session, entry);
502
+ }
503
+ this.touch(session);
504
+ this.persist();
505
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
506
+ return;
507
+ }
508
+ case "item/agentMessage/delta": {
509
+ const itemId = notification.params?.itemId;
510
+ const delta = notification.params?.delta;
511
+ if (typeof itemId === "string" && typeof delta === "string") {
512
+ const entry = this.ensureAssistantEntry(session, itemId);
513
+ entry.text += delta;
514
+ entry.status = "streaming";
515
+ this.markTurnOutput(session, entry);
516
+ this.touch(session);
517
+ this.persist();
518
+ this.emitChange({
519
+ type: "session-reset",
520
+ sessionId: session.id,
521
+ transcript: [...session.transcript],
522
+ });
523
+ }
524
+ return;
525
+ }
526
+ case "item/reasoning/textDelta":
527
+ case "item/reasoning/summaryTextDelta": {
528
+ const itemId = notification.params?.itemId;
529
+ const delta = notification.params?.delta;
530
+ if (typeof itemId === "string" && typeof delta === "string") {
531
+ const entry = this.ensureReasoningEntry(session, itemId);
532
+ entry.text += delta;
533
+ entry.status = "streaming";
534
+ this.markTurnOutput(session, entry);
535
+ this.touch(session);
536
+ this.persist();
537
+ this.emitChange({
538
+ type: "session-reset",
539
+ sessionId: session.id,
540
+ transcript: [...session.transcript],
541
+ });
542
+ }
543
+ return;
544
+ }
545
+ case "item/reasoning/summaryPartAdded": {
546
+ const itemId = notification.params?.itemId;
547
+ if (typeof itemId === "string") {
548
+ this.ensureReasoningEntry(session, itemId);
549
+ this.touch(session);
550
+ this.persist();
551
+ this.emitChange({
552
+ type: "session-reset",
553
+ sessionId: session.id,
554
+ transcript: [...session.transcript],
555
+ });
556
+ }
557
+ return;
558
+ }
559
+ case "turn/completed": {
560
+ const turn = notification.params?.turn;
561
+ session.activeTurnId = undefined;
562
+ session.status = this.mapTurnStatus(turn?.status);
563
+ this.finalizeTurnMetrics(session);
564
+ for (const entry of session.liveMessages.values()) {
565
+ entry.status = "completed";
566
+ }
567
+ for (const [itemId, entry] of session.liveReasoning.entries()) {
568
+ if (entry.text.trim()) {
569
+ entry.status = "completed";
570
+ continue;
571
+ }
572
+ session.transcript = session.transcript.filter((item) => item.id !== itemId);
573
+ }
574
+ session.liveMessages.clear();
575
+ session.liveReasoning.clear();
576
+ session.currentTurnMetrics = undefined;
577
+ this.touch(session);
578
+ this.persist();
579
+ this.emitChange({
580
+ type: "session-reset",
581
+ sessionId: session.id,
582
+ transcript: [...session.transcript],
583
+ });
584
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
585
+ return;
586
+ }
587
+ case "item/completed": {
588
+ if (notification.params?.item?.type === "userMessage") {
589
+ return;
590
+ }
591
+ const entry = this.itemToEntry(notification.params?.item);
592
+ if (!entry) {
593
+ return;
594
+ }
595
+ this.trackTurnEntry(session, entry);
596
+ this.markTurnOutput(session, entry);
597
+ this.upsertEntry(session, entry);
598
+ return;
599
+ }
600
+ default:
601
+ return;
602
+ }
603
+ }
604
+ handleCodexRequest(session, request) {
605
+ switch (request.method) {
606
+ case "item/commandExecution/requestApproval":
607
+ case "item/fileChange/requestApproval":
608
+ case "item/permissions/requestApproval":
609
+ case "execCommandApproval":
610
+ case "applyPatchApproval": {
611
+ const requestId = String(request.id);
612
+ const entry = this.addEntry(session, {
613
+ kind: "approval",
614
+ text: this.describeApprovalRequest(request.method, request.params),
615
+ status: "pending",
616
+ meta: {
617
+ requestId,
618
+ approvalKind: request.method,
619
+ payload: request.params,
620
+ },
621
+ });
622
+ session.pendingApprovals.set(requestId, {
623
+ entryId: entry.id,
624
+ requestIdRaw: request.id,
625
+ method: request.method,
626
+ params: request.params,
627
+ });
628
+ return;
629
+ }
630
+ default:
631
+ session.codex?.respondError(request.id, `Unsupported client request: ${request.method}`);
632
+ return;
633
+ }
634
+ }
635
+ threadToTranscript(thread) {
636
+ if (!thread || !Array.isArray(thread.turns)) {
637
+ return [];
638
+ }
639
+ const createdAt = this.toIsoFromUnixSeconds(thread.createdAt) || new Date().toISOString();
640
+ const transcript = [];
641
+ let offset = 0;
642
+ for (const turn of thread.turns) {
643
+ if (!Array.isArray(turn?.items)) {
644
+ continue;
645
+ }
646
+ for (const item of turn.items) {
647
+ const entry = this.itemToEntry(item, new Date(new Date(createdAt).getTime() + offset * 1000).toISOString());
648
+ offset += 1;
649
+ if (entry) {
650
+ transcript.push(entry);
651
+ }
652
+ }
653
+ }
654
+ return transcript;
655
+ }
656
+ itemToEntry(item, createdAt = new Date().toISOString()) {
657
+ switch (item?.type) {
658
+ case "userMessage": {
659
+ const text = Array.isArray(item.content)
660
+ ? item.content
661
+ .filter((part) => part?.type === "text")
662
+ .map((part) => part.text)
663
+ .join("\n")
664
+ : "";
665
+ return {
666
+ id: item.id,
667
+ kind: "user",
668
+ text,
669
+ createdAt,
670
+ };
671
+ }
672
+ case "agentMessage":
673
+ return {
674
+ id: item.id,
675
+ kind: "assistant",
676
+ text: item.text ?? "",
677
+ phase: item.phase ?? undefined,
678
+ status: "completed",
679
+ createdAt,
680
+ };
681
+ case "reasoning":
682
+ if (!this.extractRichText(item).trim()) {
683
+ return undefined;
684
+ }
685
+ return {
686
+ id: item.id,
687
+ kind: "reasoning",
688
+ text: this.extractRichText(item),
689
+ status: "completed",
690
+ createdAt,
691
+ };
692
+ case "commandExecution":
693
+ return {
694
+ id: item.id,
695
+ kind: "command",
696
+ text: `${item.command}\n\n${item.aggregatedOutput ?? ""}`.trim(),
697
+ status: item.status,
698
+ createdAt,
699
+ meta: {
700
+ cwd: item.cwd,
701
+ exitCode: item.exitCode,
702
+ },
703
+ };
704
+ case "fileChange":
705
+ return {
706
+ id: item.id,
707
+ kind: "file_change",
708
+ text: Array.isArray(item.changes)
709
+ ? item.changes.map((change) => `${change.path}\n${change.diff}`).join("\n\n")
710
+ : "",
711
+ status: item.status,
712
+ createdAt,
713
+ };
714
+ case "mcpToolCall":
715
+ case "dynamicToolCall":
716
+ case "webSearch":
717
+ return {
718
+ id: item.id,
719
+ kind: "tool",
720
+ text: JSON.stringify(item, null, 2),
721
+ status: item.status ?? "completed",
722
+ createdAt,
723
+ };
724
+ case "function_call": {
725
+ return {
726
+ id: item.call_id || item.id,
727
+ kind: "tool",
728
+ text: this.formatFunctionCall(item),
729
+ status: item.status ?? "completed",
730
+ createdAt,
731
+ meta: {
732
+ name: item.name,
733
+ },
734
+ };
735
+ }
736
+ case "function_call_output":
737
+ return {
738
+ id: item.call_id || item.id,
739
+ kind: "tool",
740
+ text: this.formatFunctionCallOutput(item),
741
+ status: item.status ?? "completed",
742
+ createdAt,
743
+ };
744
+ case "custom_tool_call":
745
+ return {
746
+ id: item.call_id || item.id,
747
+ kind: "tool",
748
+ text: this.formatCustomToolCall(item),
749
+ status: item.status ?? "completed",
750
+ createdAt,
751
+ meta: {
752
+ name: item.name,
753
+ },
754
+ };
755
+ case "plan":
756
+ return {
757
+ id: item.id,
758
+ kind: "system",
759
+ text: item.text ?? "",
760
+ createdAt,
761
+ status: "completed",
762
+ };
763
+ case "enteredReviewMode":
764
+ case "exitedReviewMode":
765
+ case "contextCompaction":
766
+ return {
767
+ id: item.id,
768
+ kind: "system",
769
+ text: JSON.stringify(item, null, 2),
770
+ createdAt,
771
+ };
772
+ default:
773
+ return undefined;
774
+ }
775
+ }
776
+ addEntry(session, input) {
777
+ const entry = {
778
+ id: randomUUID(),
779
+ createdAt: new Date().toISOString(),
780
+ ...input,
781
+ };
782
+ session.transcript.push(entry);
783
+ this.touch(session);
784
+ this.persist();
785
+ this.emitChange({ type: "session-entry", sessionId: session.id, entry });
786
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
787
+ return entry;
788
+ }
789
+ upsertEntry(session, entry) {
790
+ const existingIndex = session.transcript.findIndex((item) => item.id === entry.id);
791
+ if (existingIndex === -1) {
792
+ session.transcript.push(entry);
793
+ }
794
+ else {
795
+ const existing = session.transcript[existingIndex];
796
+ const mergedText = existing.kind === "tool" && entry.kind === "tool" && existing.text && entry.text && existing.text !== entry.text
797
+ ? `${existing.text}\n\n${entry.text}`.trim()
798
+ : entry.text || existing.text;
799
+ session.transcript[existingIndex] = {
800
+ ...existing,
801
+ ...entry,
802
+ text: mergedText,
803
+ };
804
+ }
805
+ this.touch(session);
806
+ this.persist();
807
+ this.emitChange({
808
+ type: "session-reset",
809
+ sessionId: session.id,
810
+ transcript: [...session.transcript],
811
+ });
812
+ this.emitChange({ type: "session-updated", session: this.toSummary(session) });
813
+ }
814
+ touch(session) {
815
+ session.updatedAt = new Date().toISOString();
816
+ }
817
+ async applyPendingConfig(session) {
818
+ if (!session.configDirty || session.status === "running" || session.status === "starting") {
819
+ return;
820
+ }
821
+ if (session.codex) {
822
+ session.suppressNextExitFailure = true;
823
+ await session.codex.close();
824
+ session.codex = undefined;
825
+ }
826
+ await this.ensureCodexClient(session, {
827
+ cwd: session.cwd,
828
+ model: session.model,
829
+ reasoningEffort: this.normalizeReasoningEffort(session.reasoningEffort),
830
+ sandbox: session.sandbox,
831
+ approvalPolicy: session.approvalPolicy,
832
+ });
833
+ }
834
+ emitChange(event) {
835
+ this.emit("event", event);
836
+ }
837
+ toSummary(session) {
838
+ return {
839
+ id: session.id,
840
+ title: session.title,
841
+ cwd: session.cwd,
842
+ createdAt: session.createdAt,
843
+ updatedAt: session.updatedAt,
844
+ status: session.status,
845
+ origin: session.origin,
846
+ model: session.model,
847
+ reasoningEffort: session.reasoningEffort,
848
+ sandbox: session.sandbox,
849
+ approvalPolicy: session.approvalPolicy,
850
+ codexThreadId: session.codexThreadId,
851
+ codexPath: session.codexPath,
852
+ codexSource: session.codexSource,
853
+ lastError: session.lastError,
854
+ transcriptCount: session.transcript.length,
855
+ };
856
+ }
857
+ getSessionOrThrow(sessionId) {
858
+ const session = this.sessions.get(sessionId);
859
+ if (!session) {
860
+ throw new Error(`Session not found: ${sessionId}`);
861
+ }
862
+ return session;
863
+ }
864
+ getOrThrow(sessionId) {
865
+ const session = this.get(sessionId);
866
+ if (!session) {
867
+ throw new Error(`Session not found: ${sessionId}`);
868
+ }
869
+ return session;
870
+ }
871
+ mapThreadStatus(status) {
872
+ switch (status?.type) {
873
+ case "idle":
874
+ case "notLoaded":
875
+ return "idle";
876
+ case "active":
877
+ return "running";
878
+ case "systemError":
879
+ return "failed";
880
+ default:
881
+ return "idle";
882
+ }
883
+ }
884
+ mapTurnStatus(status) {
885
+ switch (status) {
886
+ case "completed":
887
+ return "completed";
888
+ case "interrupted":
889
+ return "interrupted";
890
+ case "failed":
891
+ return "failed";
892
+ case "inProgress":
893
+ return "running";
894
+ default:
895
+ return "idle";
896
+ }
897
+ }
898
+ errorMessage(error) {
899
+ return error instanceof Error ? error.message : String(error);
900
+ }
901
+ cleanStderr(text) {
902
+ return text.replace(/\u001b\[[0-9;]*m/g, "").replace(/\r/g, "").trim();
903
+ }
904
+ shouldIgnoreStderr(text) {
905
+ const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
906
+ return ((normalized.includes("rmcp::transport::worker") &&
907
+ normalized.includes("data did not match any variant of untagged enum jsonrpcmessage")) ||
908
+ (normalized.includes("rmcp::transport::worker") &&
909
+ normalized.includes("transport channel closed") &&
910
+ normalized.includes("unexpected eof during handshake")) ||
911
+ (normalized.includes("responses_websocket") &&
912
+ normalized.includes("failed to connect to websocket") &&
913
+ normalized.includes("tls handshake eof")) ||
914
+ (normalized.includes("models_manager::manager") &&
915
+ normalized.includes("failed to refresh available models")) ||
916
+ normalized.includes("timeout waiting for child process to exit"));
917
+ }
918
+ describeApprovalRequest(method, params) {
919
+ if (method === "item/permissions/requestApproval") {
920
+ const lines = ["Additional permissions requested"];
921
+ if (params?.reason) {
922
+ lines.push(`Reason: ${params.reason}`);
923
+ }
924
+ const payload = this.prettyJson(params?.permissions);
925
+ if (payload) {
926
+ lines.push("", payload);
927
+ }
928
+ return lines.join("\n");
929
+ }
930
+ if (method === "item/commandExecution/requestApproval") {
931
+ const lines = ["Command approval requested"];
932
+ if (params?.command) {
933
+ lines.push(`Command: ${params.command}`);
934
+ }
935
+ if (params?.cwd) {
936
+ lines.push(`Cwd: ${params.cwd}`);
937
+ }
938
+ if (params?.reason) {
939
+ lines.push(`Reason: ${params.reason}`);
940
+ }
941
+ return lines.join("\n");
942
+ }
943
+ if (method === "item/fileChange/requestApproval") {
944
+ const lines = ["File change approval requested"];
945
+ if (params?.grantRoot) {
946
+ lines.push(`Grant root: ${params.grantRoot}`);
947
+ }
948
+ if (params?.reason) {
949
+ lines.push(`Reason: ${params.reason}`);
950
+ }
951
+ return lines.join("\n");
952
+ }
953
+ if (method === "execCommandApproval") {
954
+ const lines = ["Command approval requested"];
955
+ const command = Array.isArray(params?.command) ? params.command.join(" ") : "";
956
+ if (command) {
957
+ lines.push(`Command: ${command}`);
958
+ }
959
+ if (params?.cwd) {
960
+ lines.push(`Cwd: ${params.cwd}`);
961
+ }
962
+ if (params?.reason) {
963
+ lines.push(`Reason: ${params.reason}`);
964
+ }
965
+ return lines.join("\n");
966
+ }
967
+ if (method === "applyPatchApproval") {
968
+ const lines = ["File change approval requested"];
969
+ if (params?.grantRoot) {
970
+ lines.push(`Grant root: ${params.grantRoot}`);
971
+ }
972
+ if (params?.reason) {
973
+ lines.push(`Reason: ${params.reason}`);
974
+ }
975
+ const changes = this.prettyJson(params?.fileChanges);
976
+ if (changes) {
977
+ lines.push("", changes);
978
+ }
979
+ return lines.join("\n");
980
+ }
981
+ return JSON.stringify(params ?? {}, null, 2);
982
+ }
983
+ mapApprovalResponse(method, params, decision) {
984
+ switch (method) {
985
+ case "item/permissions/requestApproval":
986
+ return {
987
+ permissions: decision === "approve" ? (params?.permissions ?? {}) : {},
988
+ scope: "turn",
989
+ };
990
+ case "item/commandExecution/requestApproval":
991
+ return {
992
+ decision: decision === "approve" ? "accept" : "decline",
993
+ };
994
+ case "item/fileChange/requestApproval":
995
+ return {
996
+ decision: decision === "approve" ? "accept" : "decline",
997
+ };
998
+ case "execCommandApproval":
999
+ return {
1000
+ decision: decision === "approve" ? "approved" : "denied",
1001
+ };
1002
+ case "applyPatchApproval":
1003
+ return {
1004
+ decision: decision === "approve" ? "approved" : "denied",
1005
+ };
1006
+ default:
1007
+ throw new Error(`Unsupported approval method: ${method}`);
1008
+ }
1009
+ }
1010
+ trackStartup(session, promise) {
1011
+ const tracked = promise.finally(() => {
1012
+ if (session.startupPromise === tracked) {
1013
+ session.startupPromise = undefined;
1014
+ }
1015
+ });
1016
+ return tracked;
1017
+ }
1018
+ async readConfig() {
1019
+ const cwd = process.cwd();
1020
+ const client = new CodexAppServerClient({ cwd });
1021
+ try {
1022
+ await client.initialize();
1023
+ const response = await client.modelList();
1024
+ const models = response.data;
1025
+ return {
1026
+ models,
1027
+ defaultModel: models.find((model) => model.isDefault)?.model ?? models[0]?.model,
1028
+ };
1029
+ }
1030
+ finally {
1031
+ await client.close();
1032
+ }
1033
+ }
1034
+ ensureAssistantEntry(session, itemId) {
1035
+ let entry = session.liveMessages.get(itemId);
1036
+ if (!entry) {
1037
+ entry = {
1038
+ id: itemId,
1039
+ kind: "assistant",
1040
+ text: "",
1041
+ createdAt: new Date().toISOString(),
1042
+ status: "streaming",
1043
+ };
1044
+ session.liveMessages.set(itemId, entry);
1045
+ session.transcript.push(entry);
1046
+ this.trackTurnEntry(session, entry);
1047
+ this.persist();
1048
+ this.emitChange({ type: "session-entry", sessionId: session.id, entry });
1049
+ }
1050
+ return entry;
1051
+ }
1052
+ ensureReasoningEntry(session, itemId) {
1053
+ let entry = session.liveReasoning.get(itemId);
1054
+ if (!entry) {
1055
+ entry = {
1056
+ id: itemId,
1057
+ kind: "reasoning",
1058
+ text: "",
1059
+ createdAt: new Date().toISOString(),
1060
+ status: "streaming",
1061
+ };
1062
+ session.liveReasoning.set(itemId, entry);
1063
+ session.transcript.push(entry);
1064
+ this.trackTurnEntry(session, entry);
1065
+ this.persist();
1066
+ this.emitChange({ type: "session-entry", sessionId: session.id, entry });
1067
+ }
1068
+ return entry;
1069
+ }
1070
+ trackTurnEntry(session, entry) {
1071
+ if (!session.currentTurnMetrics || (entry.kind !== "assistant" && entry.kind !== "reasoning")) {
1072
+ return;
1073
+ }
1074
+ session.currentTurnMetrics.outputEntryIds.add(entry.id);
1075
+ }
1076
+ markTurnOutput(session, entry) {
1077
+ if (!session.currentTurnMetrics ||
1078
+ (entry.kind !== "assistant" && entry.kind !== "reasoning") ||
1079
+ !entry.text.trim()) {
1080
+ return;
1081
+ }
1082
+ this.trackTurnEntry(session, entry);
1083
+ if (!session.currentTurnMetrics.firstOutputAt) {
1084
+ session.currentTurnMetrics.firstOutputAt = Date.now();
1085
+ }
1086
+ const meta = { ...(entry.meta ?? {}) };
1087
+ meta.firstByteMs = Math.max(0, session.currentTurnMetrics.firstOutputAt - session.currentTurnMetrics.startedAt);
1088
+ entry.meta = meta;
1089
+ }
1090
+ finalizeTurnMetrics(session) {
1091
+ if (!session.currentTurnMetrics) {
1092
+ return;
1093
+ }
1094
+ const totalMs = Math.max(0, Date.now() - session.currentTurnMetrics.startedAt);
1095
+ const firstByteMs = session.currentTurnMetrics.firstOutputAt
1096
+ ? Math.max(0, session.currentTurnMetrics.firstOutputAt - session.currentTurnMetrics.startedAt)
1097
+ : undefined;
1098
+ for (const entryId of session.currentTurnMetrics.outputEntryIds) {
1099
+ const entry = session.transcript.find((item) => item.id === entryId);
1100
+ if (!entry) {
1101
+ continue;
1102
+ }
1103
+ const meta = { ...(entry.meta ?? {}) };
1104
+ if (typeof firstByteMs === "number") {
1105
+ meta.firstByteMs = firstByteMs;
1106
+ }
1107
+ meta.totalMs = totalMs;
1108
+ entry.meta = meta;
1109
+ }
1110
+ }
1111
+ extractRichText(item) {
1112
+ const fromContent = this.flattenText(item?.content);
1113
+ const fromSummary = this.flattenText(item?.summary);
1114
+ return [fromContent, fromSummary].filter(Boolean).join("\n").trim();
1115
+ }
1116
+ flattenText(value) {
1117
+ if (typeof value === "string") {
1118
+ return value;
1119
+ }
1120
+ if (!Array.isArray(value)) {
1121
+ return "";
1122
+ }
1123
+ return value
1124
+ .map((part) => {
1125
+ if (typeof part === "string") {
1126
+ return part;
1127
+ }
1128
+ if (part && typeof part === "object" && "text" in part && typeof part.text === "string") {
1129
+ return part.text;
1130
+ }
1131
+ return "";
1132
+ })
1133
+ .filter(Boolean)
1134
+ .join("\n");
1135
+ }
1136
+ toIsoFromUnixSeconds(value) {
1137
+ return typeof value === "number" && Number.isFinite(value)
1138
+ ? new Date(value * 1000).toISOString()
1139
+ : new Date().toISOString();
1140
+ }
1141
+ formatFunctionCall(item) {
1142
+ const name = item?.name || "tool";
1143
+ const args = this.prettyJsonString(item?.arguments);
1144
+ return args ? `${name}\n\n${args}` : name;
1145
+ }
1146
+ formatFunctionCallOutput(item) {
1147
+ return String(item?.output ?? "").trim();
1148
+ }
1149
+ formatCustomToolCall(item) {
1150
+ const name = item?.name || "custom_tool";
1151
+ const input = typeof item?.input === "string" ? item.input : this.prettyJson(item?.input);
1152
+ return input ? `${name}\n\n${input}` : name;
1153
+ }
1154
+ prettyJsonString(value) {
1155
+ if (typeof value !== "string" || !value.trim()) {
1156
+ return "";
1157
+ }
1158
+ try {
1159
+ return JSON.stringify(JSON.parse(value), null, 2);
1160
+ }
1161
+ catch {
1162
+ return value;
1163
+ }
1164
+ }
1165
+ prettyJson(value) {
1166
+ if (value === undefined || value === null) {
1167
+ return "";
1168
+ }
1169
+ if (typeof value === "string") {
1170
+ return value;
1171
+ }
1172
+ try {
1173
+ return JSON.stringify(value, null, 2);
1174
+ }
1175
+ catch {
1176
+ return String(value);
1177
+ }
1178
+ }
1179
+ normalizeReasoningEffort(value) {
1180
+ switch (value) {
1181
+ case "none":
1182
+ case "minimal":
1183
+ case "low":
1184
+ case "medium":
1185
+ case "high":
1186
+ case "xhigh":
1187
+ return value;
1188
+ default:
1189
+ return "medium";
1190
+ }
1191
+ }
1192
+ }