remote-codex 0.11.49 → 0.11.51

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.
Files changed (38) hide show
  1. package/README.md +38 -5
  2. package/apps/supervisor-api/dist/index.js +2137 -422
  3. package/apps/supervisor-web/dist/assets/index-7LiZQ3aJ.js +22 -0
  4. package/apps/supervisor-web/dist/assets/{index-Dy8PgXgw.css → index-Bg8FdhS1.css} +1 -1
  5. package/apps/supervisor-web/dist/assets/{thread-ui-gcslNXur.js → thread-ui-BbtcUwps.js} +25 -19
  6. package/apps/supervisor-web/dist/index.html +3 -3
  7. package/bin/remote-codex.mjs +4 -2
  8. package/package.json +7 -1
  9. package/packages/acp/src/agent-catalog.test.ts +25 -0
  10. package/packages/acp/src/agent-catalog.ts +3 -1
  11. package/packages/acp/src/capabilities.ts +139 -0
  12. package/packages/acp/src/capability-parity.test.ts +99 -0
  13. package/packages/acp/src/catalog-runtime.test.ts +200 -6
  14. package/packages/acp/src/catalog-runtime.ts +239 -31
  15. package/packages/acp/src/extension-registry.test.ts +198 -0
  16. package/packages/acp/src/extension-registry.ts +285 -0
  17. package/packages/acp/src/extensions.test.ts +45 -0
  18. package/packages/acp/src/extensions.ts +103 -0
  19. package/packages/acp/src/harness-contract.test.ts +81 -0
  20. package/packages/acp/src/harness-contract.ts +62 -0
  21. package/packages/acp/src/index.ts +7 -0
  22. package/packages/acp/src/item-mapper.ts +28 -2
  23. package/packages/acp/src/prompt-content.test.ts +90 -0
  24. package/packages/acp/src/prompt-content.ts +99 -0
  25. package/packages/acp/src/runtimeAdapter.test.ts +458 -5
  26. package/packages/acp/src/runtimeAdapter.ts +791 -60
  27. package/packages/acp/src/session-hydrator.test.ts +135 -0
  28. package/packages/acp/src/session-hydrator.ts +147 -0
  29. package/packages/acp/src/terminal-service.test.ts +21 -2
  30. package/packages/acp/src/terminal-service.ts +23 -3
  31. package/packages/acp/src/test/fixtures/fake-acp-agent.mjs +514 -0
  32. package/packages/acp/src/workspace-boundary.test.ts +32 -0
  33. package/packages/acp/src/workspace-boundary.ts +47 -0
  34. package/packages/agent-runtime/src/types.ts +61 -1
  35. package/packages/db/src/repositories.ts +3 -2
  36. package/packages/shared/src/index.ts +32 -1
  37. package/scripts/service-manager.mjs +13 -0
  38. package/apps/supervisor-web/dist/assets/index-DZI1aSXo.js +0 -22
@@ -11,6 +11,7 @@ import {
11
11
  AgentRuntimeError,
12
12
  type AgentActionRequestResponseInput,
13
13
  type AgentHistoryItem,
14
+ type AgentGoal,
14
15
  type AgentModel,
15
16
  type AgentPendingProviderRequest,
16
17
  type AgentProviderCapabilities,
@@ -21,10 +22,13 @@ import {
21
22
  type AgentRuntimeManagementSchema,
22
23
  type AgentRuntimeStatus,
23
24
  type AgentSessionDetail,
25
+ type AgentSessionHistoryCoverage,
24
26
  type AgentSessionSummary,
25
27
  type AgentTurn,
26
28
  type InterruptAgentTurnInput,
27
29
  type ResumeAgentSessionInput,
30
+ type SendAgentInputInput,
31
+ type SetAgentGoalInput,
28
32
  type StartAgentSessionInput,
29
33
  type StartAgentSessionResult,
30
34
  type StartAgentTurnInput,
@@ -38,7 +42,17 @@ import {
38
42
  AcpTurnItemMapper,
39
43
  type AcpMappedItemUpdate,
40
44
  } from './item-mapper';
45
+ import { snapshotAcpInitializeResponse } from './capabilities';
46
+ import { buildAcpPromptContent } from './prompt-content';
47
+ import { HarnessExtensionRegistry } from './extension-registry';
48
+ import {
49
+ REMOTE_CODEX_HARNESS_EXTENSION_EVENT_METHOD,
50
+ type HarnessExtensionCallEnvelope,
51
+ type HarnessExtensionEventEnvelope,
52
+ } from './extensions';
53
+ import { AcpSessionHydrator } from './session-hydrator';
41
54
  import { AcpTerminalService } from './terminal-service';
55
+ import { resolveAcpWorkspacePath } from './workspace-boundary';
42
56
 
43
57
  interface AcpRuntimeOptions {
44
58
  command: string;
@@ -66,6 +80,8 @@ interface AcpSessionState {
66
80
  modes: acp.SessionModeState | null;
67
81
  configOptions: acp.SessionConfigOption[];
68
82
  availableCommands: acp.AvailableCommand[];
83
+ hydrationCoverage: AgentSessionHistoryCoverage | null;
84
+ goal: AgentGoal | null;
69
85
  }
70
86
 
71
87
  interface PendingPermission {
@@ -74,12 +90,15 @@ interface PendingPermission {
74
90
  timer: NodeJS.Timeout;
75
91
  }
76
92
 
77
- const acpCapabilities: AgentProviderCapabilities = {
93
+ export const acpCapabilities: AgentProviderCapabilities = {
78
94
  sessions: {
79
- list: true,
95
+ list: false,
80
96
  read: true,
81
- resume: true,
97
+ resume: false,
82
98
  importLocal: false,
99
+ load: false,
100
+ close: false,
101
+ delete: false,
83
102
  },
84
103
  turns: {
85
104
  start: true,
@@ -135,6 +154,23 @@ function cloneCapabilities(): AgentProviderCapabilities {
135
154
  return structuredClone(acpCapabilities);
136
155
  }
137
156
 
157
+ export function applyNegotiatedAcpCapabilities(
158
+ target: AgentProviderCapabilities,
159
+ capabilities: acp.AgentCapabilities | null | undefined,
160
+ ) {
161
+ target.sessions.list = Boolean(capabilities?.sessionCapabilities?.list);
162
+ target.sessions.load = capabilities?.loadSession === true;
163
+ target.sessions.resume = Boolean(
164
+ capabilities?.loadSession || capabilities?.sessionCapabilities?.resume,
165
+ );
166
+ target.sessions.close = Boolean(capabilities?.sessionCapabilities?.close);
167
+ target.sessions.delete = Boolean(capabilities?.sessionCapabilities?.delete);
168
+ // Imported ACP sessions need Supervisor-owned stable history before import is safe.
169
+ target.sessions.importLocal = false;
170
+ target.branching.fork = Boolean(capabilities?.sessionCapabilities?.fork);
171
+ return target;
172
+ }
173
+
138
174
  function isRecord(value: unknown): value is Record<string, unknown> {
139
175
  return typeof value === 'object' && value !== null && !Array.isArray(value);
140
176
  }
@@ -249,8 +285,14 @@ function sessionDetail(state: AcpSessionState): AgentSessionDetail {
249
285
  createdAt: state.createdAt,
250
286
  updatedAt: state.updatedAt,
251
287
  status: state.status,
252
- turns: state.turns,
288
+ turns: state.turns.map((turn) => ({
289
+ ...turn,
290
+ items: turn.items.map((item) => ({ ...item })),
291
+ })),
253
292
  totalTurnCount: state.turns.length,
293
+ ...(state.hydrationCoverage
294
+ ? { historyCoverage: { ...state.hydrationCoverage } }
295
+ : {}),
254
296
  };
255
297
  }
256
298
 
@@ -288,6 +330,8 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
288
330
  private readonly sessions = new Map<string, AcpSessionState>();
289
331
  private readonly knownSessions = new Map<string, AgentSessionSummary>();
290
332
  private readonly pendingPermissions = new Map<number, PendingPermission>();
333
+ private readonly hydrators = new Map<string, AcpSessionHydrator>();
334
+ private readonly extensionRegistry = new HarnessExtensionRegistry();
291
335
  private readonly terminalService: AcpTerminalService;
292
336
  private child: ChildProcess | null = null;
293
337
  private connection: acp.ClientConnection | null = null;
@@ -306,8 +350,25 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
306
350
 
307
351
  constructor(private readonly options: AcpRuntimeOptions) {
308
352
  super();
353
+ this.extensionRegistry.on(
354
+ 'event',
355
+ (event: HarnessExtensionEventEnvelope) => this.emitRuntimeEvent({
356
+ type: 'harness.extension',
357
+ provider: 'acp',
358
+ providerSessionId: event.providerSessionId,
359
+ providerTurnId: event.providerTurnId,
360
+ providerItemId: event.providerItemId,
361
+ extensionId: event.extensionId,
362
+ extensionVersion: event.extensionVersion,
363
+ event: event.event,
364
+ operationId: event.operationId,
365
+ sequence: event.sequence,
366
+ payload: event.payload,
367
+ }),
368
+ );
309
369
  this.terminalService = new AcpTerminalService(
310
370
  (sessionId) => this.sessions.get(sessionId)?.cwd ?? null,
371
+ (operation) => this.emit('fs-operation', operation),
311
372
  );
312
373
  }
313
374
 
@@ -315,6 +376,10 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
315
376
  return { ...this.status };
316
377
  }
317
378
 
379
+ getProtocolSnapshot() {
380
+ return snapshotAcpInitializeResponse(this.initializeResponse);
381
+ }
382
+
318
383
  async start() {
319
384
  if (this.status.state === 'ready') {
320
385
  return;
@@ -374,6 +439,16 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
374
439
  this.requestPermission(request.params))
375
440
  .onNotification(acp.methods.client.session.update, (notification) =>
376
441
  this.handleSessionUpdate(notification.params))
442
+ .onNotification<HarnessExtensionEventEnvelope>(
443
+ REMOTE_CODEX_HARNESS_EXTENSION_EVENT_METHOD,
444
+ (params) => params as HarnessExtensionEventEnvelope,
445
+ (notification) => {
446
+ this.extensionRegistry.handleEvent(
447
+ 'acp-agent',
448
+ notification.params,
449
+ );
450
+ },
451
+ )
377
452
  .onRequest(acp.methods.client.fs.readTextFile, (request) =>
378
453
  this.readTextFile(request.params))
379
454
  .onRequest(acp.methods.client.fs.writeTextFile, (request) =>
@@ -409,7 +484,9 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
409
484
  }) as Promise<acp.InitializeResponse>;
410
485
  const initializeResponse = await this.withStartupTimeout(initialize);
411
486
  this.initializeResponse = initializeResponse;
487
+ this.resetCapabilities();
412
488
  this.applyAgentCapabilities(initializeResponse.agentCapabilities);
489
+ this.registerNegotiatedExtensions(initializeResponse);
413
490
  this.status = {
414
491
  ...this.status,
415
492
  state: 'ready',
@@ -444,11 +521,11 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
444
521
  async stop() {
445
522
  this.stopping = true;
446
523
  this.terminalService.stop();
447
- for (const [id, permission] of this.pendingPermissions) {
448
- clearTimeout(permission.timer);
449
- permission.resolve(cancelledPermission());
450
- this.pendingPermissions.delete(id);
451
- }
524
+ this.settleActiveTurns('interrupted', 'ACP runtime stopped.');
525
+ this.cancelPendingPermissions();
526
+ this.hydrators.clear();
527
+ await this.closeLoadedSessionsBeforeStop();
528
+ this.extensionRegistry.unregisterOwner('acp-agent');
452
529
  this.connection?.close();
453
530
  this.child?.kill('SIGTERM');
454
531
  this.connection = null;
@@ -464,6 +541,26 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
464
541
  this.emit('status', this.getStatus());
465
542
  }
466
543
 
544
+ private async closeLoadedSessionsBeforeStop() {
545
+ if (
546
+ !this.context ||
547
+ !this.initializeResponse?.agentCapabilities?.sessionCapabilities?.close
548
+ ) {
549
+ return;
550
+ }
551
+ const sessionIds = [...this.sessions.keys()];
552
+ const results = await Promise.allSettled(sessionIds.map((sessionId) =>
553
+ this.context!.request(acp.methods.agent.session.close, { sessionId })));
554
+ results.forEach((result, index) => {
555
+ if (result.status === 'rejected') {
556
+ this.emit(
557
+ 'warning',
558
+ `Unable to close ACP session ${sessionIds[index] ?? 'unknown'} before stop: ${errorMessage(result.reason)}`,
559
+ );
560
+ }
561
+ });
562
+ }
563
+
467
564
  async listModels(): Promise<AgentModel[]> {
468
565
  return [{
469
566
  id: 'default',
@@ -478,6 +575,11 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
478
575
  }
479
576
 
480
577
  async inspectModelOptions(cwd: string): Promise<AgentModel[]> {
578
+ const sessionCapabilities =
579
+ this.initializeResponse?.agentCapabilities?.sessionCapabilities;
580
+ if (!sessionCapabilities?.delete) {
581
+ return this.listModels();
582
+ }
481
583
  const context = await this.requireContext();
482
584
  const response = await context.request(acp.methods.agent.session.new, {
483
585
  cwd: path.resolve(cwd),
@@ -485,6 +587,10 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
485
587
  _meta: { yoloMode: false, remoteCodexCapabilityProbe: true },
486
588
  });
487
589
  let configOptions = response.configOptions ?? [];
590
+ this.updateCapabilitiesFromConfigOptions(configOptions);
591
+ const supportsPerformanceMode = configOptions.some(
592
+ (option) => option.id === 'fast-mode' || option.category === 'model_config',
593
+ );
488
594
  try {
489
595
  const modelOption = configOptionByCategory(configOptions, 'model');
490
596
  if (!modelOption || modelOption.type !== 'select') {
@@ -496,6 +602,7 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
496
602
  description: 'Use the model configured by the ACP agent.',
497
603
  isDefault: true,
498
604
  hidden: false,
605
+ supportsPerformanceMode,
499
606
  supportedReasoningEfforts: reasoning.efforts,
500
607
  defaultReasoningEffort: reasoning.defaultEffort,
501
608
  selectionKind: 'model',
@@ -524,6 +631,7 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
524
631
  description: option.description ?? '',
525
632
  isDefault: option.value === modelOption.currentValue,
526
633
  hidden: false,
634
+ supportsPerformanceMode,
527
635
  supportedReasoningEfforts: reasoning.efforts,
528
636
  defaultReasoningEffort: reasoning.defaultEffort,
529
637
  selectionKind: 'model',
@@ -531,11 +639,9 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
531
639
  }
532
640
  return models;
533
641
  } finally {
534
- if (this.initializeResponse?.agentCapabilities?.sessionCapabilities?.close) {
535
- await context.request(acp.methods.agent.session.close, {
536
- sessionId: response.sessionId,
537
- }).catch(() => undefined);
538
- }
642
+ await context.request(acp.methods.agent.session.delete, {
643
+ sessionId: response.sessionId,
644
+ }).catch(() => undefined);
539
645
  }
540
646
  }
541
647
 
@@ -573,19 +679,198 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
573
679
  return [...this.sessions.keys()];
574
680
  }
575
681
 
682
+ async closeSession(providerSessionId: string) {
683
+ if (!this.initializeResponse?.agentCapabilities?.sessionCapabilities?.close) {
684
+ throw new AgentRuntimeError(
685
+ 'ACP agent does not support session/close.',
686
+ 'acp',
687
+ 'request_failed',
688
+ );
689
+ }
690
+ await (await this.requireContext()).request(acp.methods.agent.session.close, {
691
+ sessionId: providerSessionId,
692
+ });
693
+ this.sessions.delete(providerSessionId);
694
+ }
695
+
696
+ async deleteSession(providerSessionId: string) {
697
+ if (!this.initializeResponse?.agentCapabilities?.sessionCapabilities?.delete) {
698
+ throw new AgentRuntimeError(
699
+ 'ACP agent does not support session/delete.',
700
+ 'acp',
701
+ 'request_failed',
702
+ );
703
+ }
704
+ await (await this.requireContext()).request(acp.methods.agent.session.delete, {
705
+ sessionId: providerSessionId,
706
+ });
707
+ this.sessions.delete(providerSessionId);
708
+ this.knownSessions.delete(providerSessionId);
709
+ }
710
+
711
+ async sendInput(input: SendAgentInputInput): Promise<AgentTurn | null> {
712
+ const state = this.sessions.get(input.providerSessionId);
713
+ if (!state?.activeMapper || state.activeMapper.turnId !== input.providerTurnId) {
714
+ return null;
715
+ }
716
+ if (!this.extensionRegistry.supports('acp.steering', 1, 'steer')) {
717
+ throw new AgentRuntimeError(
718
+ 'The selected ACP agent does not support running-turn steering.',
719
+ 'acp',
720
+ 'request_failed',
721
+ );
722
+ }
723
+ const prompt = await buildAcpPromptContent({
724
+ prompt: input.prompt,
725
+ workspacePath: input.workspacePath ?? state.cwd,
726
+ promptCapabilities: this.initializeResponse?.agentCapabilities?.promptCapabilities,
727
+ });
728
+ const operationId = randomUUID();
729
+ await this.extensionRegistry.invoke({
730
+ extensionId: 'acp.steering',
731
+ extensionVersion: 1,
732
+ method: 'steer',
733
+ operationId,
734
+ idempotencyKey: `${input.providerSessionId}:${input.providerTurnId}:steer:${operationId}`,
735
+ params: {
736
+ providerSessionId: input.providerSessionId,
737
+ prompt,
738
+ },
739
+ });
740
+ return state.activeMapper.turn();
741
+ }
742
+
743
+ async compactSession(providerSessionId: string) {
744
+ const operationId = randomUUID();
745
+ await this.extensionRegistry.invoke({
746
+ extensionId: 'codex.control',
747
+ extensionVersion: 1,
748
+ method: 'compact',
749
+ operationId,
750
+ idempotencyKey: `${providerSessionId}:compact:${operationId}`,
751
+ params: { providerSessionId },
752
+ timeoutMs: 180_000,
753
+ });
754
+ }
755
+
756
+ async forkSession(input: { providerSessionId: string; atTurnId?: string | null }) {
757
+ if (!this.initializeResponse?.agentCapabilities?.sessionCapabilities?.fork) {
758
+ throw new AgentRuntimeError(
759
+ 'The selected ACP agent does not support session/fork.',
760
+ 'acp',
761
+ 'request_failed',
762
+ );
763
+ }
764
+ const source = this.sessions.get(input.providerSessionId) ??
765
+ await this.restoreSession(input.providerSessionId);
766
+ const response = await (await this.requireContext()).request(
767
+ acp.methods.agent.session.fork,
768
+ {
769
+ sessionId: source.providerSessionId,
770
+ cwd: source.cwd,
771
+ mcpServers: [],
772
+ },
773
+ );
774
+ const now = new Date().toISOString();
775
+ this.knownSessions.set(response.sessionId, {
776
+ provider: 'acp',
777
+ providerSessionId: response.sessionId,
778
+ cwd: source.cwd,
779
+ title: source.title,
780
+ preview: source.turns.at(-1)?.items
781
+ .filter((item) => item.kind === 'agentMessage')
782
+ .map((item) => item.text)
783
+ .join('\n') || null,
784
+ createdAt: now,
785
+ updatedAt: now,
786
+ status: 'not_loaded',
787
+ });
788
+ const forked = await this.restoreSession(response.sessionId);
789
+ if (forked.turns.length === 0 && source.turns.length > 0) {
790
+ forked.turns = structuredClone(source.turns);
791
+ forked.hydrationCoverage = null;
792
+ }
793
+ forked.modes = response.modes ?? forked.modes;
794
+ forked.configOptions = response.configOptions ?? forked.configOptions;
795
+ this.syncStateFromConfigOptions(forked);
796
+ return sessionDetail(forked);
797
+ }
798
+
799
+ async getGoal(providerSessionId: string) {
800
+ return this.sessions.get(providerSessionId)?.goal ?? null;
801
+ }
802
+
803
+ async setGoal(input: SetAgentGoalInput) {
804
+ const state = this.sessions.get(input.providerSessionId);
805
+ if (!state) {
806
+ throw new AgentRuntimeError('ACP session is not loaded.', 'acp', 'request_failed');
807
+ }
808
+ if (input.tokenBudget !== undefined && input.tokenBudget !== null) {
809
+ throw new AgentRuntimeError(
810
+ 'Codex ACP goal control does not expose token budgets.',
811
+ 'acp',
812
+ 'request_failed',
813
+ );
814
+ }
815
+ const action: { action: 'set'; objective: string } |
816
+ { action: 'pause' | 'resume' | 'clear' } | null = input.objective?.trim()
817
+ ? { action: 'set', objective: input.objective.trim() }
818
+ : input.status === 'paused'
819
+ ? { action: 'pause' }
820
+ : input.status === 'active'
821
+ ? { action: 'resume' }
822
+ : null;
823
+ if (!action) {
824
+ throw new AgentRuntimeError(
825
+ 'Codex ACP goal update requires an objective, pause, or resume action.',
826
+ 'acp',
827
+ 'request_failed',
828
+ );
829
+ }
830
+ await this.invokeAcpGoal(input.providerSessionId, action);
831
+ if (!state.goal) {
832
+ throw new AgentRuntimeError(
833
+ 'Codex ACP completed goal control without publishing a goal snapshot.',
834
+ 'acp',
835
+ 'invalid_response',
836
+ );
837
+ }
838
+ return state.goal;
839
+ }
840
+
841
+ async clearGoal(providerSessionId: string) {
842
+ const state = this.sessions.get(providerSessionId);
843
+ if (!state) {
844
+ return false;
845
+ }
846
+ const existed = Boolean(state.goal);
847
+ await this.invokeAcpGoal(providerSessionId, { action: 'clear' });
848
+ return existed;
849
+ }
850
+
851
+ listHarnessExtensions() {
852
+ return this.extensionRegistry.list();
853
+ }
854
+
855
+ invokeHarnessExtension<T = unknown>(input: {
856
+ extensionId: string;
857
+ extensionVersion: number;
858
+ method: string;
859
+ operationId: string;
860
+ idempotencyKey: string;
861
+ params: unknown;
862
+ timeoutMs?: number;
863
+ signal?: AbortSignal;
864
+ }) {
865
+ return this.extensionRegistry.invoke<T>(input);
866
+ }
867
+
576
868
  async readSession(
577
869
  providerSessionId: string,
578
870
  ): Promise<AgentSessionDetail> {
579
- const state = this.sessions.get(providerSessionId);
580
- if (state) {
581
- return sessionDetail(state);
582
- }
583
- throw new AgentRuntimeError(
584
- 'ACP session history is owned by the Remote Codex supervisor and is not materialized in this runtime process.',
585
- 'acp',
586
- 'request_failed',
587
- { historyUnavailable: true, providerSessionId },
588
- );
871
+ const state = this.sessions.get(providerSessionId) ??
872
+ await this.restoreSession(providerSessionId);
873
+ return sessionDetail(state);
589
874
  }
590
875
 
591
876
  async startSession(input: StartAgentSessionInput): Promise<StartAgentSessionResult> {
@@ -604,8 +889,8 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
604
889
  title: null,
605
890
  createdAt: now,
606
891
  updatedAt: now,
607
- model: input.model === 'default' ? null : input.model,
608
- reasoningEffort: input.reasoningEffort ?? null,
892
+ model: null,
893
+ reasoningEffort: null,
609
894
  sandboxMode: input.sandboxMode ?? null,
610
895
  status: 'idle',
611
896
  turns: [],
@@ -613,10 +898,20 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
613
898
  modes: response.modes ?? null,
614
899
  configOptions: response.configOptions ?? [],
615
900
  availableCommands: [],
901
+ hydrationCoverage: null,
902
+ goal: null,
616
903
  };
617
904
  this.sessions.set(state.providerSessionId, state);
905
+ this.syncStateFromConfigOptions(state);
618
906
  this.knownSessions.set(state.providerSessionId, sessionDetail(state));
619
- await this.applySessionSettings(state, input.model, input.reasoningEffort, input.sandboxMode);
907
+ await this.applySessionSettings(
908
+ state,
909
+ input.model,
910
+ input.reasoningEffort,
911
+ input.sandboxMode,
912
+ undefined,
913
+ input.performanceMode,
914
+ );
620
915
  const session = sessionDetail(state);
621
916
  return {
622
917
  provider: 'acp',
@@ -633,7 +928,14 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
633
928
  async resumeSession(input: ResumeAgentSessionInput): Promise<StartAgentSessionResult> {
634
929
  const existing = this.sessions.get(input.providerSessionId);
635
930
  const state = existing ?? await this.restoreSession(input.providerSessionId);
636
- await this.applySessionSettings(state, input.model, undefined, input.sandboxMode);
931
+ await this.applySessionSettings(
932
+ state,
933
+ input.model,
934
+ undefined,
935
+ input.sandboxMode,
936
+ undefined,
937
+ input.performanceMode,
938
+ );
637
939
  state.status = 'idle';
638
940
  state.updatedAt = new Date().toISOString();
639
941
  const session = sessionDetail(state);
@@ -665,6 +967,7 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
665
967
  input.reasoningEffort,
666
968
  input.sandboxMode,
667
969
  input.collaborationMode,
970
+ input.performanceMode,
668
971
  );
669
972
 
670
973
  const turnId = input.displayTurnId ?? randomUUID();
@@ -690,13 +993,19 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
690
993
  turn: startedTurn,
691
994
  });
692
995
 
693
- const prompt = input.developerInstructions?.trim()
694
- ? `${input.developerInstructions.trim()}\n\n${input.prompt}`
695
- : input.prompt;
996
+ const prompt = await buildAcpPromptContent({
997
+ prompt: input.prompt,
998
+ workspacePath: input.workspacePath ?? state.cwd,
999
+ promptCapabilities: this.initializeResponse?.agentCapabilities?.promptCapabilities,
1000
+ ...(input.content ? { content: input.content } : {}),
1001
+ });
1002
+ if (input.developerInstructions?.trim()) {
1003
+ prompt.unshift({ type: 'text', text: `${input.developerInstructions.trim()}\n\n` });
1004
+ }
696
1005
  const context = await this.requireContext();
697
1006
  void context.request(acp.methods.agent.session.prompt, {
698
1007
  sessionId: state.providerSessionId,
699
- prompt: [{ type: 'text', text: prompt }],
1008
+ prompt,
700
1009
  }).then(
701
1010
  (response) => this.completePrompt(state, mapper, response),
702
1011
  (error) => this.failPrompt(state, mapper, error),
@@ -826,18 +1135,29 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
826
1135
  modes: null,
827
1136
  configOptions: [],
828
1137
  availableCommands: [],
1138
+ hydrationCoverage: null,
1139
+ goal: null,
829
1140
  };
830
1141
  this.sessions.set(providerSessionId, state);
831
1142
  try {
832
1143
  const capabilities = this.initializeResponse?.agentCapabilities;
833
1144
  if (capabilities?.loadSession) {
834
- const response = await context.request(acp.methods.agent.session.load, {
835
- sessionId: providerSessionId,
836
- cwd: summary.cwd,
837
- mcpServers: [],
838
- });
839
- state.modes = response.modes ?? null;
840
- state.configOptions = response.configOptions ?? [];
1145
+ const hydrator = new AcpSessionHydrator(providerSessionId);
1146
+ this.hydrators.set(providerSessionId, hydrator);
1147
+ try {
1148
+ const response = await context.request(acp.methods.agent.session.load, {
1149
+ sessionId: providerSessionId,
1150
+ cwd: summary.cwd,
1151
+ mcpServers: [],
1152
+ });
1153
+ state.modes = response.modes ?? null;
1154
+ state.configOptions = response.configOptions ?? [];
1155
+ state.turns = hydrator.complete();
1156
+ state.hydrationCoverage = hydrator.coverage();
1157
+ this.syncStateFromConfigOptions(state);
1158
+ } finally {
1159
+ this.hydrators.delete(providerSessionId);
1160
+ }
841
1161
  } else if (capabilities?.sessionCapabilities?.resume) {
842
1162
  const response = await context.request(acp.methods.agent.session.resume, {
843
1163
  sessionId: providerSessionId,
@@ -846,6 +1166,7 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
846
1166
  });
847
1167
  state.modes = response.modes ?? null;
848
1168
  state.configOptions = response.configOptions ?? [];
1169
+ this.syncStateFromConfigOptions(state);
849
1170
  } else {
850
1171
  throw new Error('ACP agent does not support session/load or session/resume.');
851
1172
  }
@@ -869,18 +1190,25 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
869
1190
  reasoningEffort?: string | null,
870
1191
  sandboxMode?: string | null,
871
1192
  collaborationMode?: 'default' | 'plan' | null,
1193
+ performanceMode?: 'standard' | 'fast' | null,
872
1194
  ) {
873
1195
  if (model && model !== 'default') {
874
- await this.setConfigOption(state, 'model', model);
875
- state.model = model;
1196
+ state.model = await this.setConfigOption(state, 'model', model);
876
1197
  }
877
1198
  if (reasoningEffort) {
878
- await this.setConfigOption(state, 'thought_level', reasoningEffort);
879
- state.reasoningEffort = reasoningEffort;
1199
+ const applied = await this.setConfigOption(
1200
+ state,
1201
+ 'thought_level',
1202
+ reasoningEffort,
1203
+ );
1204
+ state.reasoningEffort = normalizeAcpEffort(applied);
880
1205
  }
881
1206
  if (sandboxMode !== undefined) {
882
1207
  state.sandboxMode = sandboxMode;
883
1208
  }
1209
+ if (performanceMode) {
1210
+ await this.setFastMode(state, performanceMode === 'fast');
1211
+ }
884
1212
 
885
1213
  const modes = state.modes?.availableModes ?? [];
886
1214
  if (modes.length === 0) {
@@ -920,16 +1248,27 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
920
1248
  sessionId: state.providerSessionId,
921
1249
  modelId: value,
922
1250
  });
923
- return;
1251
+ return value;
924
1252
  }
925
1253
  if (!option || option.type !== 'select') {
926
- return;
1254
+ throw new AgentRuntimeError(
1255
+ `ACP agent does not expose a ${category} config option.`,
1256
+ 'acp',
1257
+ 'request_failed',
1258
+ );
927
1259
  }
928
1260
  const selected = allSelectOptions(option).find((candidate) =>
929
1261
  candidate.value === value || candidate.name.toLowerCase() === value.toLowerCase(),
930
1262
  );
931
- if (!selected || selected.value === option.currentValue) {
932
- return;
1263
+ if (!selected) {
1264
+ throw new AgentRuntimeError(
1265
+ `ACP agent rejected unknown ${category} option: ${value}`,
1266
+ 'acp',
1267
+ 'request_failed',
1268
+ );
1269
+ }
1270
+ if (selected.value === option.currentValue) {
1271
+ return selected.value;
933
1272
  }
934
1273
  const context = await this.requireContext();
935
1274
  const response = await context.request(acp.methods.agent.session.setConfigOption, {
@@ -938,6 +1277,73 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
938
1277
  value: selected.value,
939
1278
  });
940
1279
  state.configOptions = response.configOptions;
1280
+ return selected.value;
1281
+ }
1282
+
1283
+ private syncStateFromConfigOptions(state: AcpSessionState) {
1284
+ this.updateCapabilitiesFromConfigOptions(state.configOptions);
1285
+ const modelOption = configOptionByCategory(state.configOptions, 'model');
1286
+ if (modelOption?.type === 'select') {
1287
+ state.model = modelOption.currentValue;
1288
+ }
1289
+ const thoughtOption = configOptionByCategory(
1290
+ state.configOptions,
1291
+ 'thought_level',
1292
+ );
1293
+ if (thoughtOption?.type === 'select') {
1294
+ state.reasoningEffort = normalizeAcpEffort(thoughtOption.currentValue);
1295
+ }
1296
+ }
1297
+
1298
+ private updateCapabilitiesFromConfigOptions(
1299
+ configOptions: acp.SessionConfigOption[],
1300
+ ) {
1301
+ this.capabilities.management.models ||= Boolean(
1302
+ configOptionByCategory(configOptions, 'model'),
1303
+ );
1304
+ this.capabilities.controls.performanceMode ||= configOptions.some(
1305
+ (option) => option.id === 'fast-mode' || option.category === 'model_config',
1306
+ );
1307
+ }
1308
+
1309
+ private async setFastMode(state: AcpSessionState, enabled: boolean) {
1310
+ const option = state.configOptions.find(
1311
+ (candidate) =>
1312
+ candidate.id === 'fast-mode' || candidate.category === 'model_config',
1313
+ );
1314
+ if (!option) {
1315
+ throw new AgentRuntimeError(
1316
+ 'The selected ACP agent does not expose fast mode.',
1317
+ 'acp',
1318
+ 'request_failed',
1319
+ );
1320
+ }
1321
+ const value = option.type === 'boolean'
1322
+ ? enabled
1323
+ : allSelectOptions(option).find((candidate) =>
1324
+ enabled
1325
+ ? ['on', 'true', 'fast'].includes(candidate.value.toLowerCase())
1326
+ : ['off', 'false', 'standard'].includes(candidate.value.toLowerCase()))?.value;
1327
+ if (value === undefined || value === option.currentValue) {
1328
+ return;
1329
+ }
1330
+ const response = await (await this.requireContext()).request(
1331
+ acp.methods.agent.session.setConfigOption,
1332
+ option.type === 'boolean'
1333
+ ? {
1334
+ sessionId: state.providerSessionId,
1335
+ configId: option.id,
1336
+ type: 'boolean',
1337
+ value: value as boolean,
1338
+ }
1339
+ : {
1340
+ sessionId: state.providerSessionId,
1341
+ configId: option.id,
1342
+ value: value as string,
1343
+ },
1344
+ );
1345
+ state.configOptions = response.configOptions;
1346
+ this.updateCapabilitiesFromConfigOptions(state.configOptions);
941
1347
  }
942
1348
 
943
1349
  private handleSessionUpdate(notification: acp.SessionNotification) {
@@ -947,8 +1353,10 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
947
1353
  }
948
1354
  state.updatedAt = new Date().toISOString();
949
1355
  const update = notification.update;
1356
+ const hydrator = this.hydrators.get(notification.sessionId);
950
1357
  if (update.sessionUpdate === 'config_option_update') {
951
1358
  state.configOptions = update.configOptions;
1359
+ this.syncStateFromConfigOptions(state);
952
1360
  } else if (update.sessionUpdate === 'current_mode_update' && state.modes) {
953
1361
  state.modes = { ...state.modes, currentModeId: update.currentModeId };
954
1362
  } else if (update.sessionUpdate === 'available_commands_update') {
@@ -956,7 +1364,7 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
956
1364
  } else if (update.sessionUpdate === 'session_info_update') {
957
1365
  if (update.title !== undefined) {
958
1366
  state.title = update.title;
959
- if (update.title) {
1367
+ if (update.title && !hydrator) {
960
1368
  this.emitRuntimeEvent({
961
1369
  type: 'session.title.updated',
962
1370
  provider: 'acp',
@@ -965,6 +1373,33 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
965
1373
  });
966
1374
  }
967
1375
  }
1376
+ const goal = this.goalFromSessionInfoUpdate(
1377
+ state.providerSessionId,
1378
+ update,
1379
+ );
1380
+ if (goal !== undefined) {
1381
+ state.goal = goal;
1382
+ if (!hydrator) {
1383
+ this.emitRuntimeEvent(goal
1384
+ ? {
1385
+ type: 'goal.updated',
1386
+ provider: 'acp',
1387
+ providerSessionId: state.providerSessionId,
1388
+ providerTurnId: state.activeMapper?.turnId ?? null,
1389
+ goal,
1390
+ }
1391
+ : {
1392
+ type: 'goal.cleared',
1393
+ provider: 'acp',
1394
+ providerSessionId: state.providerSessionId,
1395
+ });
1396
+ }
1397
+ }
1398
+ }
1399
+
1400
+ if (hydrator) {
1401
+ hydrator.apply(update);
1402
+ return;
968
1403
  }
969
1404
 
970
1405
  const mapper = state.activeMapper;
@@ -1118,10 +1553,17 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
1118
1553
  }
1119
1554
 
1120
1555
  private async readTextFile(params: acp.ReadTextFileRequest): Promise<acp.ReadTextFileResponse> {
1121
- if (!path.isAbsolute(params.path)) {
1122
- throw new Error('ACP file paths must be absolute.');
1556
+ const session = this.sessions.get(params.sessionId);
1557
+ if (!session) {
1558
+ throw new Error(`ACP session workspace not found: ${params.sessionId}`);
1123
1559
  }
1124
- const content = await fs.readFile(params.path, 'utf8');
1560
+ const filePath = await resolveAcpWorkspacePath(session.cwd, params.path);
1561
+ this.emit('fs-operation', {
1562
+ operation: 'fs.readTextFile',
1563
+ sessionId: params.sessionId,
1564
+ path: filePath,
1565
+ });
1566
+ const content = await fs.readFile(filePath, 'utf8');
1125
1567
  if (params.line === undefined && params.limit === undefined) {
1126
1568
  return { content };
1127
1569
  }
@@ -1132,11 +1574,18 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
1132
1574
  }
1133
1575
 
1134
1576
  private async writeTextFile(params: acp.WriteTextFileRequest) {
1135
- if (!path.isAbsolute(params.path)) {
1136
- throw new Error('ACP file paths must be absolute.');
1577
+ const session = this.sessions.get(params.sessionId);
1578
+ if (!session) {
1579
+ throw new Error(`ACP session workspace not found: ${params.sessionId}`);
1137
1580
  }
1138
- await fs.mkdir(path.dirname(params.path), { recursive: true });
1139
- await fs.writeFile(params.path, params.content, 'utf8');
1581
+ const filePath = await resolveAcpWorkspacePath(session.cwd, params.path);
1582
+ this.emit('fs-operation', {
1583
+ operation: 'fs.writeTextFile',
1584
+ sessionId: params.sessionId,
1585
+ path: filePath,
1586
+ });
1587
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
1588
+ await fs.writeFile(filePath, params.content, 'utf8');
1140
1589
  return {};
1141
1590
  }
1142
1591
 
@@ -1149,11 +1598,251 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
1149
1598
  }
1150
1599
 
1151
1600
  private applyAgentCapabilities(capabilities: acp.AgentCapabilities | null | undefined) {
1152
- this.capabilities.sessions.list = Boolean(capabilities?.sessionCapabilities?.list);
1153
- // Imported ACP sessions have no supervisor-owned item history yet. Keep
1154
- // import disabled until replayed updates can be assigned stable turn ids.
1155
- this.capabilities.sessions.importLocal = false;
1156
- this.capabilities.branching.fork = false;
1601
+ applyNegotiatedAcpCapabilities(this.capabilities, capabilities);
1602
+ }
1603
+
1604
+ private resetCapabilities() {
1605
+ const baseline = cloneCapabilities();
1606
+ for (const section of Object.keys(baseline) as Array<keyof AgentProviderCapabilities>) {
1607
+ Object.assign(this.capabilities[section], baseline[section]);
1608
+ }
1609
+ }
1610
+
1611
+ private goalFromSessionInfoUpdate(
1612
+ providerSessionId: string,
1613
+ update: Extract<acp.SessionUpdate, { sessionUpdate: 'session_info_update' }>,
1614
+ ): AgentGoal | null | undefined {
1615
+ const meta = isRecord(update._meta) ? update._meta : null;
1616
+ if (!meta || !Object.hasOwn(meta, 'goal')) {
1617
+ return undefined;
1618
+ }
1619
+ if (meta.goal === null) {
1620
+ return null;
1621
+ }
1622
+ if (!isRecord(meta.goal)) {
1623
+ return undefined;
1624
+ }
1625
+ const objective = typeof meta.goal.objective === 'string'
1626
+ ? meta.goal.objective.trim()
1627
+ : '';
1628
+ if (!objective) {
1629
+ return undefined;
1630
+ }
1631
+ return {
1632
+ providerSessionId,
1633
+ objective,
1634
+ status: typeof meta.goal.status === 'string' ? meta.goal.status : 'active',
1635
+ tokenBudget: typeof meta.goal.tokenBudget === 'number'
1636
+ ? meta.goal.tokenBudget
1637
+ : null,
1638
+ tokensUsed: typeof meta.goal.tokensUsed === 'number' ? meta.goal.tokensUsed : 0,
1639
+ timeUsedSeconds: typeof meta.goal.timeUsedSeconds === 'number'
1640
+ ? meta.goal.timeUsedSeconds
1641
+ : 0,
1642
+ createdAt: typeof meta.goal.createdAt === 'number'
1643
+ ? meta.goal.createdAt
1644
+ : Date.now(),
1645
+ updatedAt: typeof meta.goal.updatedAt === 'number'
1646
+ ? meta.goal.updatedAt
1647
+ : Date.now(),
1648
+ rawGoal: structuredClone(meta.goal),
1649
+ };
1650
+ }
1651
+
1652
+ private async invokeAcpGoal(
1653
+ providerSessionId: string,
1654
+ action: { action: 'set'; objective: string } | { action: 'pause' | 'resume' | 'clear' },
1655
+ ) {
1656
+ if (!this.extensionRegistry.supports('acp.goal', 1, action.action)) {
1657
+ throw new AgentRuntimeError(
1658
+ 'The selected ACP agent does not expose goal control.',
1659
+ 'acp',
1660
+ 'request_failed',
1661
+ );
1662
+ }
1663
+ const operationId = randomUUID();
1664
+ await this.extensionRegistry.invoke({
1665
+ extensionId: 'acp.goal',
1666
+ extensionVersion: 1,
1667
+ method: action.action,
1668
+ operationId,
1669
+ idempotencyKey: `${providerSessionId}:goal:${operationId}`,
1670
+ params: { providerSessionId, ...action },
1671
+ timeoutMs: 180_000,
1672
+ });
1673
+ }
1674
+
1675
+ private async runControlPrompt(
1676
+ providerSessionId: string,
1677
+ prompt: string,
1678
+ signal: AbortSignal,
1679
+ ) {
1680
+ const state = this.sessions.get(providerSessionId);
1681
+ if (!state || state.activeMapper) {
1682
+ throw new AgentRuntimeError(
1683
+ 'ACP control prompt requires an idle loaded session.',
1684
+ 'acp',
1685
+ 'request_failed',
1686
+ );
1687
+ }
1688
+ let startedTurnId: string | null = null;
1689
+ let cleanupCompletion!: () => void;
1690
+ const completed = new Promise<Extract<AgentRuntimeEvent, { type: 'turn.completed' }>>(
1691
+ (resolve, reject) => {
1692
+ const cleanup = () => {
1693
+ this.off('event', onEvent);
1694
+ signal.removeEventListener('abort', onAbort);
1695
+ };
1696
+ const onEvent = (event: AgentRuntimeEvent) => {
1697
+ if (
1698
+ event.type === 'turn.completed' &&
1699
+ event.providerSessionId === providerSessionId &&
1700
+ (!startedTurnId || event.turn.providerTurnId === startedTurnId)
1701
+ ) {
1702
+ cleanup();
1703
+ resolve(event);
1704
+ }
1705
+ };
1706
+ const onAbort = () => {
1707
+ cleanup();
1708
+ reject(signal.reason ?? new Error('ACP control prompt cancelled.'));
1709
+ };
1710
+ cleanupCompletion = cleanup;
1711
+ this.on('event', onEvent);
1712
+ signal.addEventListener('abort', onAbort, { once: true });
1713
+ },
1714
+ );
1715
+ try {
1716
+ const turn = await this.startTurn({
1717
+ providerSessionId,
1718
+ prompt,
1719
+ hidden: true,
1720
+ });
1721
+ startedTurnId = turn.providerTurnId;
1722
+ const event = await completed;
1723
+ if (event.turn.status === 'failed') {
1724
+ throw new Error(event.turn.error?.message ?? 'ACP control prompt failed.');
1725
+ }
1726
+ return { providerTurnId: event.turn.providerTurnId, status: event.turn.status };
1727
+ } catch (error) {
1728
+ cleanupCompletion();
1729
+ throw error;
1730
+ }
1731
+ }
1732
+
1733
+ private registerNegotiatedExtensions(response: acp.InitializeResponse) {
1734
+ const snapshot = snapshotAcpInitializeResponse(response);
1735
+ if (!snapshot || !this.context) {
1736
+ return;
1737
+ }
1738
+ const wireTransport = {
1739
+ request: (method: string, params: unknown, signal: AbortSignal) =>
1740
+ this.context!.request<unknown, typeof params>(method, params, {
1741
+ cancellationSignal: signal,
1742
+ }),
1743
+ };
1744
+ for (const descriptor of snapshot.harnessExtensions) {
1745
+ this.extensionRegistry.register({
1746
+ ownerId: 'acp-agent',
1747
+ descriptor,
1748
+ transport: wireTransport,
1749
+ });
1750
+ }
1751
+ if (snapshot.legacyExtensions.steering?.supported) {
1752
+ this.extensionRegistry.register({
1753
+ ownerId: 'acp-agent',
1754
+ descriptor: {
1755
+ id: 'acp.steering',
1756
+ version: 1,
1757
+ stability: 'experimental',
1758
+ methods: ['steer'],
1759
+ events: [],
1760
+ },
1761
+ transport: wireTransport,
1762
+ wireMethods: { steer: '_session/steering' },
1763
+ paramMappers: {
1764
+ steer: (envelope) => {
1765
+ const params = isRecord(envelope.params) ? envelope.params : {};
1766
+ return {
1767
+ sessionId: params.providerSessionId,
1768
+ prompt: params.prompt,
1769
+ };
1770
+ },
1771
+ },
1772
+ capabilityPatch: { turns: { steer: true } },
1773
+ });
1774
+ }
1775
+ const goal = snapshot.legacyExtensions.goal;
1776
+ const goalVersion = typeof goal?.version === 'number'
1777
+ ? goal.version
1778
+ : goal?.version === '1'
1779
+ ? 1
1780
+ : null;
1781
+ if (
1782
+ goal?.controlMethod &&
1783
+ goalVersion === 1 &&
1784
+ goal.actions.includes('set') &&
1785
+ goal.actions.includes('clear')
1786
+ ) {
1787
+ const actions = goal.actions.filter((action) =>
1788
+ ['set', 'pause', 'resume', 'clear'].includes(action));
1789
+ this.extensionRegistry.register({
1790
+ ownerId: 'acp-agent',
1791
+ descriptor: {
1792
+ id: 'acp.goal',
1793
+ version: goalVersion,
1794
+ stability: 'experimental',
1795
+ methods: actions,
1796
+ events: [],
1797
+ },
1798
+ transport: wireTransport,
1799
+ wireMethods: Object.fromEntries(actions.map((action) => [action, goal.controlMethod!])),
1800
+ paramMappers: Object.fromEntries(actions.map((action) => [
1801
+ action,
1802
+ (envelope: HarnessExtensionCallEnvelope) => {
1803
+ const params = isRecord(envelope.params) ? envelope.params : {};
1804
+ return {
1805
+ sessionId: params.providerSessionId,
1806
+ action,
1807
+ ...(action === 'set' ? { objective: params.objective } : {}),
1808
+ };
1809
+ },
1810
+ ])),
1811
+ capabilityPatch: { controls: { goals: true } },
1812
+ });
1813
+ }
1814
+ if (snapshot.agentInfo?.name === '@agentclientprotocol/codex-acp') {
1815
+ this.extensionRegistry.register({
1816
+ ownerId: 'acp-agent',
1817
+ descriptor: {
1818
+ id: 'codex.control',
1819
+ version: 1,
1820
+ stability: 'experimental',
1821
+ methods: ['compact'],
1822
+ events: [],
1823
+ },
1824
+ transport: {
1825
+ request: async (_method, params, signal) => {
1826
+ const providerSessionId = isRecord(params)
1827
+ ? String(params.providerSessionId ?? '')
1828
+ : '';
1829
+ return this.runControlPrompt(providerSessionId, '/compact', signal);
1830
+ },
1831
+ },
1832
+ paramMappers: {
1833
+ compact: (envelope) => ({
1834
+ providerSessionId: isRecord(envelope.params)
1835
+ ? envelope.params.providerSessionId
1836
+ : null,
1837
+ }),
1838
+ },
1839
+ capabilityPatch: { turns: { compact: true } },
1840
+ });
1841
+ }
1842
+ const effective = this.extensionRegistry.effectiveCapabilities(this.capabilities);
1843
+ for (const section of Object.keys(effective) as Array<keyof AgentProviderCapabilities>) {
1844
+ Object.assign(this.capabilities[section], effective[section]);
1845
+ }
1157
1846
  }
1158
1847
 
1159
1848
  private withStartupTimeout<T>(promise: Promise<T>) {
@@ -1178,6 +1867,8 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
1178
1867
 
1179
1868
  private markFailed(error: unknown) {
1180
1869
  const message = errorMessage(error);
1870
+ this.settleActiveTurns('failed', message);
1871
+ this.cancelPendingPermissions();
1181
1872
  this.status = {
1182
1873
  ...this.status,
1183
1874
  state: 'failed',
@@ -1187,6 +1878,46 @@ export class AcpRuntimeAdapter extends EventEmitter implements AgentRuntime {
1187
1878
  this.emit('status', this.getStatus());
1188
1879
  }
1189
1880
 
1881
+ private cancelPendingPermissions() {
1882
+ for (const [id, permission] of this.pendingPermissions) {
1883
+ clearTimeout(permission.timer);
1884
+ permission.resolve(cancelledPermission());
1885
+ this.pendingPermissions.delete(id);
1886
+ }
1887
+ }
1888
+
1889
+ private settleActiveTurns(
1890
+ status: 'failed' | 'interrupted',
1891
+ error: string,
1892
+ ) {
1893
+ for (const state of this.sessions.values()) {
1894
+ const mapper = state.activeMapper;
1895
+ if (!mapper) {
1896
+ continue;
1897
+ }
1898
+ const completed = mapper.complete(status, error);
1899
+ for (const itemUpdate of completed.updates) {
1900
+ this.emitItemUpdate(state, mapper.turnId, itemUpdate);
1901
+ }
1902
+ const turn = {
1903
+ ...completed.turn,
1904
+ startedAt: state.turns.find(
1905
+ (candidate) => candidate.providerTurnId === mapper.turnId,
1906
+ )?.startedAt ?? null,
1907
+ };
1908
+ this.replaceTurn(state, turn);
1909
+ state.activeMapper = null;
1910
+ state.status = status;
1911
+ state.updatedAt = new Date().toISOString();
1912
+ this.emitRuntimeEvent({
1913
+ type: 'turn.completed',
1914
+ provider: 'acp',
1915
+ providerSessionId: state.providerSessionId,
1916
+ turn,
1917
+ });
1918
+ }
1919
+ }
1920
+
1190
1921
  private emitRuntimeEvent(event: AgentRuntimeEvent) {
1191
1922
  this.emit('event', event);
1192
1923
  }