@sumicom/quicksave 0.8.16 → 0.8.18

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.
@@ -5,7 +5,7 @@ import { buildSandboxMcpServerConfig, SANDBOX_MCP_NAME } from '../sandboxMcp.js'
5
5
  import { normalizePermissionLevelForAgent } from '../provider.js';
6
6
  import { makeQueuedUserPrompt, queueStateFor } from '../queuedUserPrompts.js';
7
7
  import { getEventStore } from '../../storage/eventStore.js';
8
- import { consumeAppServerStream } from './cardAdapter.js';
8
+ import { createCodexTurnStreamConsumer, } from './cardAdapter.js';
9
9
  import { codexApprovalResponse, codexApprovalToPermissionPrompt, } from './approvalMapping.js';
10
10
  import { detectCodexVersion, spawnAppServer } from './processManager.js';
11
11
  import { RuntimeOverrideStore } from './overrideStore.js';
@@ -14,6 +14,13 @@ import { codexProtocolPreview } from './protocolLog.js';
14
14
  import { codexServerRequestInputId } from './serverRequestIds.js';
15
15
  const __ownDir = dirname(fileURLToPath(import.meta.url));
16
16
  const __aiDir = dirname(__ownDir);
17
+ const CODEX_BUILT_IN_SLASH_COMMANDS = [
18
+ {
19
+ name: 'goal',
20
+ description: 'Manage Codex goal mode',
21
+ argumentHint: 'pause | resume | clear | set <objective>',
22
+ },
23
+ ];
17
24
  /**
18
25
  * Codex provider driving the JSON-RPC v2 `app-server` protocol.
19
26
  * Phase 2 of the migration — see
@@ -114,6 +121,29 @@ export class CodexAppServerProvider {
114
121
  scheduleInitialTurn(session, opts.prompt, opts.attachments);
115
122
  return { sessionId: response.thread.id, session };
116
123
  }
124
+ async listNativeSessions(opts) {
125
+ const handle = await spawnCodexNativeListAppServer();
126
+ try {
127
+ const [activeThreads, archivedThreads] = await Promise.all([
128
+ listCodexThreads(handle, opts?.cwd, false),
129
+ listCodexThreads(handle, opts?.cwd, true),
130
+ ]);
131
+ const byId = new Map();
132
+ for (const item of [
133
+ ...activeThreads.map((thread) => codexThreadToNativeSession(thread, false)),
134
+ ...archivedThreads.map((thread) => codexThreadToNativeSession(thread, true)),
135
+ ]) {
136
+ const existing = byId.get(item.sessionId);
137
+ if (!existing || item.lastInteractionAt > existing.lastInteractionAt) {
138
+ byId.set(item.sessionId, item);
139
+ }
140
+ }
141
+ return Array.from(byId.values()).sort((a, b) => b.lastInteractionAt - a.lastInteractionAt);
142
+ }
143
+ finally {
144
+ await handle.shutdown().catch(() => { });
145
+ }
146
+ }
117
147
  }
118
148
  function scheduleInitialTurn(session, prompt, attachments) {
119
149
  // Give SessionManager and messageHandler one macrotask to register the
@@ -135,8 +165,13 @@ export class CodexAppServerSession {
135
165
  currentTurnId = null;
136
166
  pendingTurns = [];
137
167
  running = false;
168
+ startingRunTurn = false;
169
+ prestartedRunTurnId = null;
138
170
  exited = false;
171
+ turnConsumers = new Map();
172
+ settledTurnIds = new Set();
139
173
  unsubscribeSessionNotifications = null;
174
+ unsubscribeTransportClose = null;
140
175
  constructor(args) {
141
176
  this.handle = args.handle;
142
177
  this.tokens = args.tokens;
@@ -152,6 +187,9 @@ export class CodexAppServerSession {
152
187
  this.unsubscribeSessionNotifications = this.handle.rpc.onNotification((notification) => {
153
188
  this.handleSessionNotification(notification);
154
189
  });
190
+ this.unsubscribeTransportClose = this.handle.rpc.onClose(() => {
191
+ this.handleTransportClosed();
192
+ });
155
193
  // If the child exits unexpectedly, mark us dead and notify SessionManager.
156
194
  this.handle.child.once('exit', () => {
157
195
  if (this.exited)
@@ -164,9 +202,9 @@ export class CodexAppServerSession {
164
202
  return !this.exited;
165
203
  }
166
204
  sendUserMessage(prompt, attachments) {
167
- if (this.currentTurnId) {
168
- this.pendingTurns.push(makeQueuedUserPrompt(prompt, attachments));
169
- this.callbacks.onQueueStateChange?.(this.threadId);
205
+ const activeTurnId = this.currentTurnId;
206
+ if (activeTurnId) {
207
+ void this.steerOrQueue(prompt, attachments, activeTurnId);
170
208
  return;
171
209
  }
172
210
  void this.runTurn(prompt, attachments);
@@ -174,12 +212,7 @@ export class CodexAppServerSession {
174
212
  interruptThenSendUserMessage(prompt, attachments) {
175
213
  if (this.currentTurnId)
176
214
  this.interrupt();
177
- if (this.running) {
178
- this.pendingTurns.push(makeQueuedUserPrompt(prompt, attachments));
179
- this.callbacks.onQueueStateChange?.(this.threadId);
180
- return;
181
- }
182
- void this.runTurn(prompt, attachments);
215
+ this.enqueueOrRunTurn(prompt, attachments);
183
216
  }
184
217
  getQueueState() {
185
218
  return queueStateFor(this.pendingTurns, this.currentTurnId !== null);
@@ -230,16 +263,20 @@ export class CodexAppServerSession {
230
263
  cwds: opts?.cwd ? [opts.cwd] : [],
231
264
  forceReload: opts?.forceReload === true,
232
265
  });
233
- return codexSkillsToSlashCommands(response, opts?.cwd);
266
+ const skills = codexSkillsToSlashCommands(response, opts?.cwd)
267
+ .filter((command) => !CODEX_BUILT_IN_SLASH_COMMANDS.some((builtin) => builtin.name === command.name));
268
+ return [...CODEX_BUILT_IN_SLASH_COMMANDS, ...skills];
234
269
  }
235
270
  interrupt() {
236
- if (!this.currentTurnId)
271
+ const turnId = this.currentTurnId;
272
+ if (!turnId)
237
273
  return;
238
274
  void this.handle.rpc
239
- .request('turn/interrupt', { threadId: this.threadId, turnId: this.currentTurnId })
275
+ .request('turn/interrupt', { threadId: this.threadId, turnId })
240
276
  .catch(() => {
241
277
  /* best-effort */
242
278
  });
279
+ this.closeTurnAsInterrupted(turnId);
243
280
  }
244
281
  async kill() {
245
282
  if (this.exited)
@@ -248,6 +285,9 @@ export class CodexAppServerSession {
248
285
  this.pendingTurns = [];
249
286
  this.unsubscribeSessionNotifications?.();
250
287
  this.unsubscribeSessionNotifications = null;
288
+ this.unsubscribeTransportClose?.();
289
+ this.unsubscribeTransportClose = null;
290
+ this.closeTurnConsumersAsInterrupted();
251
291
  try {
252
292
  await this.cardBuilder.persistCards();
253
293
  }
@@ -267,8 +307,8 @@ export class CodexAppServerSession {
267
307
  // best-effort
268
308
  }
269
309
  }
270
- /** Run a single turn end-to-end: cb.startNewTurn → cb.userMessage
271
- * turn/start → consume notifications emit stream-end. */
310
+ /** Run a single explicit turn end-to-end: cb.startNewTurn → cb.userMessage
311
+ * turn/start → wait for the session-routed turn consumer to settle. */
272
312
  async runTurn(prompt, attachments) {
273
313
  if (this.exited)
274
314
  return;
@@ -317,6 +357,22 @@ export class CodexAppServerSession {
317
357
  this.callbacks.onQueueStateChange?.(this.threadId);
318
358
  return true;
319
359
  }
360
+ async steerOrQueue(prompt, attachments, expectedTurnId) {
361
+ const steered = await this.steerCurrentTurn(prompt, attachments, expectedTurnId);
362
+ if (steered)
363
+ return;
364
+ this.enqueueOrRunTurn(prompt, attachments);
365
+ }
366
+ enqueueOrRunTurn(prompt, attachments) {
367
+ if (this.exited)
368
+ return;
369
+ if (this.running || this.currentTurnId) {
370
+ this.pendingTurns.push(makeQueuedUserPrompt(prompt, attachments));
371
+ this.callbacks.onQueueStateChange?.(this.threadId);
372
+ return;
373
+ }
374
+ void this.runTurn(prompt, attachments);
375
+ }
320
376
  async steerCurrentTurn(prompt, attachments, expectedTurnId, opts) {
321
377
  try {
322
378
  if (attachments && attachments.length > 0) {
@@ -372,17 +428,24 @@ export class CodexAppServerSession {
372
428
  input: attachmentsToCodexUserInput(prompt, attachments),
373
429
  ...drained,
374
430
  };
375
- const response = await this.handle.rpc.request('turn/start', params);
431
+ this.startingRunTurn = true;
432
+ this.prestartedRunTurnId = null;
433
+ let response;
434
+ try {
435
+ response = await this.handle.rpc.request('turn/start', params);
436
+ }
437
+ finally {
438
+ this.startingRunTurn = false;
439
+ }
376
440
  this.overrideStore.commit();
377
441
  turnId = response.turn.id;
378
442
  this.currentTurnId = turnId;
379
443
  cb.setCurrentTurnId(turnId);
380
- const result = await consumeAppServerStream(this.handle.rpc, cb, {
381
- sessionId: this.threadId,
382
- threadId: this.threadId,
383
- turnId,
384
- tokens: this.tokens,
385
- }, this.callbacks);
444
+ const prestartedTurnId = this.prestartedRunTurnId;
445
+ if (prestartedTurnId && prestartedTurnId !== turnId) {
446
+ console.warn(`[codex-app] turn/start response mismatch session=${this.threadId.slice(0, 8)} response=${turnId.slice(0, 8)} notification=${prestartedTurnId.slice(0, 8)}`);
447
+ }
448
+ const result = await this.beginTurnConsumer(turnId, { managedByRunTurn: true }).result;
386
449
  void result; // settled; stream-end already emitted by adapter
387
450
  }
388
451
  catch (err) {
@@ -396,15 +459,10 @@ export class CodexAppServerSession {
396
459
  });
397
460
  }
398
461
  finally {
399
- this.currentTurnId = null;
400
- try {
401
- await cb.persistCards();
402
- }
403
- catch {
404
- // best-effort
405
- }
406
- cb.clearCards();
407
- this.callbacks.onTurnSettled?.(this.threadId);
462
+ this.startingRunTurn = false;
463
+ const lifecycleTurnId = turnId ?? this.prestartedRunTurnId;
464
+ this.prestartedRunTurnId = null;
465
+ await this.finishTurnLifecycle(lifecycleTurnId);
408
466
  }
409
467
  }
410
468
  async refreshGoalConfigAndReturn() {
@@ -431,8 +489,96 @@ export class CodexAppServerSession {
431
489
  }
432
490
  return out;
433
491
  }
492
+ beginTurnConsumer(turnId, opts) {
493
+ const existing = this.turnConsumers.get(turnId);
494
+ if (existing)
495
+ return existing;
496
+ this.settledTurnIds.delete(turnId);
497
+ const consumer = createCodexTurnStreamConsumer(this.cardBuilder, {
498
+ sessionId: this.threadId,
499
+ threadId: this.threadId,
500
+ turnId,
501
+ tokens: this.tokens,
502
+ }, this.callbacks);
503
+ this.turnConsumers.set(turnId, consumer);
504
+ void consumer.result.then(() => {
505
+ void this.handleTurnConsumerSettled(turnId, consumer, opts.managedByRunTurn).catch((err) => {
506
+ console.warn(`[codex-app] failed to settle turn consumer session=${this.threadId.slice(0, 8)} turn=${turnId.slice(0, 8)} error=${err instanceof Error ? err.message : String(err)}`);
507
+ });
508
+ });
509
+ return consumer;
510
+ }
511
+ async handleTurnConsumerSettled(turnId, consumer, managedByRunTurn) {
512
+ if (this.turnConsumers.get(turnId) === consumer) {
513
+ this.turnConsumers.delete(turnId);
514
+ }
515
+ this.rememberSettledTurn(turnId);
516
+ consumer.dispose();
517
+ if (managedByRunTurn)
518
+ return;
519
+ await this.finishTurnLifecycle(turnId);
520
+ this.drainPendingTurnsAfterObservedTurn();
521
+ }
522
+ async finishTurnLifecycle(turnId) {
523
+ const builderTurnId = this.cardBuilder.getCurrentTurnId();
524
+ const shouldClearCards = !turnId || !builderTurnId || builderTurnId === turnId;
525
+ if (!turnId || this.currentTurnId === turnId) {
526
+ this.currentTurnId = null;
527
+ }
528
+ try {
529
+ await this.cardBuilder.persistCards();
530
+ }
531
+ catch {
532
+ // best-effort
533
+ }
534
+ if (shouldClearCards) {
535
+ this.cardBuilder.clearCards();
536
+ }
537
+ this.callbacks.onTurnSettled?.(this.threadId);
538
+ }
539
+ drainPendingTurnsAfterObservedTurn() {
540
+ if (this.exited || this.running || this.currentTurnId || this.pendingTurns.length === 0) {
541
+ return;
542
+ }
543
+ const next = this.pendingTurns.shift();
544
+ this.callbacks.onQueueStateChange?.(this.threadId);
545
+ void this.runTurn(next.prompt, next.attachments);
546
+ }
547
+ closeTurnConsumersAsInterrupted() {
548
+ for (const consumer of this.turnConsumers.values()) {
549
+ consumer.closeAsInterrupted();
550
+ }
551
+ }
552
+ closeTurnAsInterrupted(turnId) {
553
+ const consumer = this.turnConsumers.get(turnId);
554
+ if (consumer) {
555
+ consumer.closeAsInterrupted();
556
+ return;
557
+ }
558
+ this.callbacks.emitStreamEnd({
559
+ sessionId: this.threadId,
560
+ turnId,
561
+ success: false,
562
+ interrupted: true,
563
+ });
564
+ void this.finishTurnLifecycle(turnId);
565
+ }
566
+ handleTransportClosed() {
567
+ this.closeTurnConsumersAsInterrupted();
568
+ }
569
+ rememberSettledTurn(turnId) {
570
+ this.settledTurnIds.add(turnId);
571
+ if (this.settledTurnIds.size <= 100)
572
+ return;
573
+ const oldest = this.settledTurnIds.values().next().value;
574
+ if (oldest)
575
+ this.settledTurnIds.delete(oldest);
576
+ }
434
577
  handleSessionNotification(notification) {
435
578
  try {
579
+ this.observeTokenUsageNotification(notification);
580
+ const turnId = this.ensureTurnConsumerForNotification(notification);
581
+ this.dispatchTurnNotification(notification, turnId);
436
582
  switch (notification.method) {
437
583
  case 'thread/goal/updated': {
438
584
  const params = notification.params;
@@ -456,6 +602,53 @@ export class CodexAppServerSession {
456
602
  console.warn(`[codex-app] failed to handle session notification method=${notification.method} session=${this.threadId.slice(0, 8)} params=${codexProtocolPreview(notification.params)} error=${err instanceof Error ? err.message : String(err)}`);
457
603
  }
458
604
  }
605
+ observeTokenUsageNotification(notification) {
606
+ if (notification.method !== 'thread/tokenUsage/updated')
607
+ return;
608
+ if (!notificationBelongsToThread(notification.params, this.threadId))
609
+ return;
610
+ this.tokens.observe(notification.params);
611
+ }
612
+ ensureTurnConsumerForNotification(notification) {
613
+ if (!notificationBelongsToThread(notification.params, this.threadId))
614
+ return null;
615
+ const turnId = notificationTurnId(notification);
616
+ if (!turnId)
617
+ return null;
618
+ if (this.settledTurnIds.has(turnId))
619
+ return turnId;
620
+ const existing = this.turnConsumers.get(turnId);
621
+ if (existing)
622
+ return turnId;
623
+ if (!shouldCreateTurnConsumerForNotification(notification.method))
624
+ return turnId;
625
+ const managedByRunTurn = this.startingRunTurn || this.currentTurnId === turnId;
626
+ if (this.startingRunTurn) {
627
+ this.prestartedRunTurnId = turnId;
628
+ }
629
+ if (managedByRunTurn) {
630
+ this.currentTurnId = turnId;
631
+ this.cardBuilder.setCurrentTurnId(turnId);
632
+ }
633
+ else {
634
+ this.cardBuilder.startNewTurn(turnId);
635
+ this.currentTurnId = turnId;
636
+ this.callbacks.onQueueStateChange?.(this.threadId);
637
+ }
638
+ this.beginTurnConsumer(turnId, { managedByRunTurn });
639
+ return turnId;
640
+ }
641
+ dispatchTurnNotification(notification, turnId) {
642
+ if (!notificationBelongsToThread(notification.params, this.threadId))
643
+ return;
644
+ if (turnId) {
645
+ this.turnConsumers.get(turnId)?.dispatch(notification);
646
+ return;
647
+ }
648
+ for (const consumer of this.turnConsumers.values()) {
649
+ consumer.dispatch(notification);
650
+ }
651
+ }
459
652
  emitGoalConfig(goal) {
460
653
  this.callbacks.onSessionConfigPatch?.(this.threadId, codexGoalToConfigPatch(goal));
461
654
  }
@@ -563,6 +756,57 @@ export class CodexAppServerSession {
563
756
  };
564
757
  }
565
758
  }
759
+ // ── helpers ──
760
+ function notificationBelongsToThread(params, threadId) {
761
+ if (typeof params !== 'object' || params === null)
762
+ return true;
763
+ const candidate = params.threadId;
764
+ return typeof candidate !== 'string' || candidate === threadId;
765
+ }
766
+ function notificationTurnId(notification) {
767
+ switch (notification.method) {
768
+ case 'turn/started': {
769
+ const params = notification.params;
770
+ return params.turn?.id ?? null;
771
+ }
772
+ case 'turn/completed': {
773
+ const params = notification.params;
774
+ return params.turn?.id ?? null;
775
+ }
776
+ default: {
777
+ if (typeof notification.params !== 'object' || notification.params === null)
778
+ return null;
779
+ const candidate = notification.params.turnId;
780
+ return typeof candidate === 'string' && candidate.length > 0 ? candidate : null;
781
+ }
782
+ }
783
+ }
784
+ function shouldCreateTurnConsumerForNotification(method) {
785
+ switch (method) {
786
+ case 'turn/started':
787
+ case 'turn/completed':
788
+ case 'turn/plan/updated':
789
+ case 'item/started':
790
+ case 'item/autoApprovalReview/started':
791
+ case 'item/autoApprovalReview/completed':
792
+ case 'item/completed':
793
+ case 'item/agentMessage/delta':
794
+ case 'item/plan/delta':
795
+ case 'item/commandExecution/outputDelta':
796
+ case 'item/fileChange/outputDelta':
797
+ case 'item/fileChange/patchUpdated':
798
+ case 'item/mcpToolCall/progress':
799
+ case 'item/reasoning/summaryTextDelta':
800
+ case 'item/reasoning/summaryPartAdded':
801
+ case 'item/reasoning/textDelta':
802
+ case 'model/rerouted':
803
+ case 'model/verification':
804
+ case 'error':
805
+ return true;
806
+ default:
807
+ return false;
808
+ }
809
+ }
566
810
  function codexToolQuestionToPromptQuestion(question) {
567
811
  return {
568
812
  id: question.id,
@@ -780,15 +1024,21 @@ function clearedGoalConfigPatch() {
780
1024
  codexGoalUpdatedAt: null,
781
1025
  };
782
1026
  }
783
- function spawnCodexAppServer(opts) {
784
- return spawnAppServer({
1027
+ function codexAppServerInit() {
1028
+ return {
785
1029
  clientInfo: {
786
1030
  name: 'quicksave-agent',
787
1031
  title: 'Quicksave Agent',
788
1032
  version: '0.0.0',
789
1033
  },
790
1034
  capabilities: { experimentalApi: true, requestAttestation: false, optOutNotificationMethods: null },
791
- }, {
1035
+ };
1036
+ }
1037
+ function spawnCodexNativeListAppServer() {
1038
+ return spawnAppServer(codexAppServerInit());
1039
+ }
1040
+ function spawnCodexAppServer(opts) {
1041
+ return spawnAppServer(codexAppServerInit(), {
792
1042
  extraArgs: buildCodexSandboxMcpConfigArgs({
793
1043
  cwd: opts.cwd,
794
1044
  sessionId: 'sessionId' in opts ? opts.sessionId : undefined,
@@ -796,6 +1046,42 @@ function spawnCodexAppServer(opts) {
796
1046
  }),
797
1047
  });
798
1048
  }
1049
+ async function listCodexThreads(handle, cwd, archived) {
1050
+ const threads = [];
1051
+ let cursor = null;
1052
+ do {
1053
+ const params = {
1054
+ cursor,
1055
+ limit: 100,
1056
+ sortKey: 'updated_at',
1057
+ sortDirection: 'desc',
1058
+ cwd: cwd ?? null,
1059
+ archived,
1060
+ };
1061
+ const response = await handle.rpc.request('thread/list', params);
1062
+ threads.push(...response.data.filter((thread) => !thread.ephemeral &&
1063
+ thread.parentThreadId === null &&
1064
+ (!cwd || thread.cwd === cwd)));
1065
+ cursor = response.nextCursor;
1066
+ } while (cursor);
1067
+ return threads;
1068
+ }
1069
+ function codexThreadToNativeSession(thread, archived) {
1070
+ return {
1071
+ sessionId: thread.id,
1072
+ cwd: thread.cwd,
1073
+ agent: 'codex',
1074
+ title: thread.name ?? undefined,
1075
+ firstPrompt: thread.preview || undefined,
1076
+ createdAt: codexSecondsToMs(thread.createdAt),
1077
+ lastInteractionAt: codexSecondsToMs(thread.updatedAt),
1078
+ gitBranch: thread.gitInfo?.branch ?? undefined,
1079
+ archived,
1080
+ };
1081
+ }
1082
+ function codexSecondsToMs(value) {
1083
+ return value < 10_000_000_000 ? value * 1000 : value;
1084
+ }
799
1085
  export function buildCodexSandboxMcpConfigArgs(opts) {
800
1086
  const config = buildSandboxMcpServerConfig({
801
1087
  ownDir: __aiDir,