@omercnet/paseo-omp 0.2.1 → 0.3.0-next.101.1

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 (64) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +25 -13
  3. package/SUPPORT.md +7 -3
  4. package/TESTING.md +21 -18
  5. package/client/composer-pill-settings.tsx +157 -0
  6. package/client/external-url.ts +15 -0
  7. package/client/mcp-authorization.tsx +169 -0
  8. package/client/mcp-popover.tsx +155 -0
  9. package/client/memory-panel.tsx +8 -3
  10. package/client/memory-popover.tsx +8 -4
  11. package/client/omp-config-surface.tsx +189 -29
  12. package/client/omp-plugin-manager.tsx +302 -131
  13. package/client/omp-store-picker.tsx +89 -0
  14. package/client/omp-store-state.ts +45 -0
  15. package/client/paseo-types.ts +9 -0
  16. package/client/provider-diagnostics-state.ts +18 -7
  17. package/client/quota-popover.tsx +8 -3
  18. package/client/quota-state.ts +16 -7
  19. package/client/sessions-popover.tsx +8 -3
  20. package/docs/alpha-release-checklist.md +6 -8
  21. package/docs/configuration.md +8 -4
  22. package/docs/core-provider-issue-audit.md +3 -2
  23. package/docs/images/mcp-authorization-compact.png +0 -0
  24. package/docs/images/mcp-controls-wide.png +0 -0
  25. package/docs/images/plugin-manager.png +0 -0
  26. package/docs/images/workspace-settings.png +0 -0
  27. package/docs/installation.md +35 -19
  28. package/index.client.tsx +339 -123
  29. package/index.server.ts +44 -14
  30. package/package.json +7 -8
  31. package/paseo-plugin.json +2 -2
  32. package/scripts/prepare-dependencies.mjs +24 -0
  33. package/server/mcp-browser.ts +95 -0
  34. package/server/memory.ts +2 -2
  35. package/server/omp-config.ts +16 -7
  36. package/server/omp-plugins.ts +70 -21
  37. package/server/omp-settings.ts +232 -24
  38. package/server/paths.ts +128 -11
  39. package/server/provider/catalog.ts +3 -4
  40. package/server/provider/connection.ts +248 -17
  41. package/server/provider/host-tools.ts +294 -26
  42. package/server/provider/omp-rpc.ts +499 -72
  43. package/server/provider/profile-providers.ts +249 -0
  44. package/server/provider/registration.ts +11 -0
  45. package/server/provider/security.ts +8 -10
  46. package/server/provider/session-descriptors.ts +340 -1
  47. package/server/provider/session.ts +716 -249
  48. package/server/provider/subsessions.ts +25 -2
  49. package/server/provider/timeline-projector.ts +104 -44
  50. package/server/provider-diagnostics.ts +122 -36
  51. package/server/quota.ts +3 -2
  52. package/server/sessions.ts +2 -2
  53. package/shared/composer-pill-settings.ts +28 -0
  54. package/shared/external-url.ts +21 -0
  55. package/shared/hub.ts +3 -3
  56. package/shared/mcp.ts +47 -0
  57. package/shared/memory.ts +2 -1
  58. package/shared/omp-config.ts +5 -1
  59. package/shared/omp-plugins.ts +74 -33
  60. package/shared/omp-settings.ts +8 -1
  61. package/shared/omp-store.ts +58 -0
  62. package/shared/provider-diagnostics.ts +12 -3
  63. package/shared/quota.ts +2 -1
  64. package/shared/sessions.ts +2 -1
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import type {
3
3
  ProviderConfigState,
4
4
  ProviderContent,
5
+ ProviderError,
5
6
  ProviderEvent,
6
7
  ProviderInput,
7
8
  ProviderPermissionResponse,
@@ -72,6 +73,7 @@ type OmpQuestionRequest = Extract<
72
73
  type Emit = (event: ProviderEvent) => void;
73
74
  const LOCAL_ONLY_SETTLE_MS = 5_000;
74
75
  const AGENT_END_STATE_TIMEOUT_MS = 2_000;
76
+ const AGENT_END_HISTORY_TIMEOUT_MS = 2_000;
75
77
  const CONFIG_REFRESH_RETRY_BASE_MS = 250;
76
78
  const CONFIG_REFRESH_MAX_ATTEMPTS = 3;
77
79
  const USAGE_POLL_MS = 1_000;
@@ -82,6 +84,7 @@ const AGENT_END_SETTLE_MS = 5_000;
82
84
  const MAX_PROMPT_PARTS = 64;
83
85
  const MAX_PROMPT_TEXT_LENGTH = 1024 * 1024;
84
86
  const RPC_REQUEST_ID_BYTES = 36;
87
+ const MAX_AGENT_END_CORRELATION_MESSAGES = 512;
85
88
  const MAX_TRACKED_ENTRY_IDS = 1_024;
86
89
  const MAX_UNCLAIMED_BRANCH_ENTRIES = 1_024;
87
90
  const MAX_PENDING_USERS = 256;
@@ -140,6 +143,16 @@ function applicableThinkingLevel(
140
143
  return model?.reasoning === false ? undefined : level;
141
144
  }
142
145
 
146
+ function fixedSessionMode(modeId = "full") {
147
+ const mode = OMP_MODES.find((candidate) => candidate.id === modeId);
148
+ if (!mode) throw new OmpPublicError("OMP mode is unavailable");
149
+ return {
150
+ ...mode,
151
+ label: `${mode.label} (fixed for session)`,
152
+ description: `${mode.description} Approval mode is fixed for this session; create a new session to choose another mode.`,
153
+ };
154
+ }
155
+
143
156
  export function ompPersistenceSessionId(input: SessionOpenInput): string | undefined {
144
157
  if (!input.persistence) return;
145
158
  if (input.persistence.version !== 1) {
@@ -215,6 +228,24 @@ type PendingUser = {
215
228
  fallbackOnFinish: boolean;
216
229
  bufferedEchoes: OmpMessage[];
217
230
  };
231
+ type TerminalCorrelationEvidence = "fresh-native-user" | "current-assistant";
232
+
233
+ type TerminalCorrelation = {
234
+ policy: "initial-turn" | "ordered-legacy" | "native-command";
235
+ evidence: Map<TerminalCorrelationEvidence, number>;
236
+ };
237
+
238
+ type TerminalCandidate = {
239
+ event: Extract<OmpRpcEvent, { type: "agent_end" }>;
240
+ arrivalSequence: number;
241
+ confidence:
242
+ | "keyed"
243
+ | "initial-turn"
244
+ | "ordered-legacy"
245
+ | "native-command"
246
+ | "interrupted"
247
+ | "ambiguous";
248
+ };
218
249
 
219
250
  type ActiveTurn = {
220
251
  turnId: string;
@@ -232,10 +263,8 @@ type ActiveTurn = {
232
263
  awaitingPermissionEvidence: boolean;
233
264
  activitySequence: number;
234
265
  acknowledged: boolean;
235
- terminalOwnershipEvidence: boolean;
236
- terminalOwnershipRequired: boolean;
266
+ terminalCorrelation: TerminalCorrelation;
237
267
  replayingBufferedEvents: boolean;
238
- bufferedTerminalOwnershipEvidence: boolean;
239
268
  agentInvoked?: boolean;
240
269
  nativeRequestId?: string;
241
270
  promptAcceptedEventIndex?: number;
@@ -250,21 +279,25 @@ type ActiveTurn = {
250
279
  agentEndRetryTimer?: unknown;
251
280
  agentEndDeadlineTimer?: unknown;
252
281
  agentEndCheck?: Promise<void>;
253
- terminalOwnershipTimer?: unknown;
282
+ ambiguousTerminalTimer?: unknown;
254
283
  terminalizing: boolean;
255
284
  terminalization?: Promise<void>;
256
285
  terminalOutcome?: TurnOutcome;
257
286
  terminalWake?: VoidDeferred;
258
287
  steerReady: VoidDeferred;
259
288
  steersInFlight: number;
260
- deferredAgentEnd?: Extract<OmpRpcEvent, { type: "agent_end" }>;
289
+ deferredAgentEnd?: TerminalCandidate;
290
+ activeToolCallIds: Set<string>;
261
291
  bufferedEvents: OmpRpcEvent[];
262
292
  pendingUsers: PendingUser[];
263
293
  userEchoes: OmpMessage[];
264
294
  userCorrelationActive: boolean;
265
295
  userLookups: Set<Promise<void>>;
266
296
  completedMessageCount: number;
267
- lastCompletedAssistantFailed?: boolean;
297
+ streamedMessageEntryIds: string[];
298
+ streamedMessageIdentityComplete: boolean;
299
+ lastCompletedAssistantOutcome?: AgentEndOutcome;
300
+ lastCompletedAssistantEntryId?: string;
268
301
  };
269
302
 
270
303
  type PendingAbort = {
@@ -322,7 +355,7 @@ type VoidDeferred = {
322
355
 
323
356
  type TurnOutcome = {
324
357
  state: "completed" | "failed" | "canceled";
325
- error?: { message: string };
358
+ error?: ProviderError;
326
359
  usageSampled: boolean;
327
360
  };
328
361
 
@@ -598,30 +631,130 @@ function nativeEntryId(message: OmpMessage): string | undefined {
598
631
  return message.entryId;
599
632
  }
600
633
 
601
- function terminalError(
634
+ type AgentEndOutcome = "completed" | "failed" | "canceled";
635
+ type AssistantTerminalStatus = AgentEndOutcome | "unavailable";
636
+
637
+ function assistantTerminalOutcome(
638
+ message: Extract<OmpMessage, { role: "assistant" }>,
639
+ ): AgentEndOutcome {
640
+ const stopReason = message.stopReason?.toLowerCase();
641
+ if (stopReason === "aborted" || stopReason === "canceled" || stopReason === "cancelled") {
642
+ return "canceled";
643
+ }
644
+ return stopReason === "error" || message.errorMessage ? "failed" : "completed";
645
+ }
646
+
647
+ function lastAssistantStatus(
648
+ messages: readonly OmpMessage[],
649
+ startIndex = 0,
650
+ ): AssistantTerminalStatus {
651
+ for (let index = messages.length - 1; index >= startIndex; index -= 1) {
652
+ const message = messages[index];
653
+ if (message?.role !== "assistant") continue;
654
+ return assistantTerminalOutcome(message);
655
+ }
656
+ return "unavailable";
657
+ }
658
+
659
+ function terminalOutcome(
602
660
  event: Extract<OmpRpcEvent, { type: "agent_end" }>,
603
- turn: Pick<ActiveTurn, "completedMessageCount" | "lastCompletedAssistantFailed">,
604
- ): string | undefined {
661
+ turn: Pick<ActiveTurn, "completedMessageCount" | "lastCompletedAssistantOutcome">,
662
+ ): AgentEndOutcome | undefined {
605
663
  const messages = event.messages;
606
- if (messages) {
607
- for (let index = messages.length - 1; index >= 0; index -= 1) {
608
- const message = messages[index];
609
- if (message?.role !== "assistant") continue;
610
- return message.stopReason === "error" || message.errorMessage
611
- ? "OMP assistant turn failed"
612
- : undefined;
613
- }
664
+ if (
665
+ messages !== undefined &&
666
+ (event.messageCount === undefined || messages.length >= event.messageCount)
667
+ ) {
668
+ const status = lastAssistantStatus(messages);
669
+ return status === "unavailable" ? "completed" : status;
614
670
  }
615
- if (messages && event.messageCount === undefined) return undefined;
616
- if (event.messageCount === 0) return undefined;
671
+ if (event.messageCount === 0) return "completed";
617
672
  if (
618
- turn.lastCompletedAssistantFailed !== undefined &&
619
- (event.messageCount === undefined ||
620
- turn.completedMessageCount + (messages?.length ?? 0) >= event.messageCount)
673
+ turn.lastCompletedAssistantOutcome !== undefined &&
674
+ (event.messageCount === undefined || turn.completedMessageCount >= event.messageCount)
621
675
  ) {
622
- return turn.lastCompletedAssistantFailed ? "OMP assistant turn failed" : undefined;
676
+ return turn.lastCompletedAssistantOutcome;
623
677
  }
624
- return "OMP agent_end omitted terminal messages; outcome is unknown";
678
+ return undefined;
679
+ }
680
+
681
+ function historyTerminalOutcome(
682
+ messages: readonly OmpMessage[],
683
+ declaredCount: number,
684
+ turn: Pick<
685
+ ActiveTurn,
686
+ | "streamedMessageEntryIds"
687
+ | "streamedMessageIdentityComplete"
688
+ | "lastCompletedAssistantOutcome"
689
+ | "lastCompletedAssistantEntryId"
690
+ >,
691
+ retainedMessages: readonly OmpMessage[],
692
+ ): AgentEndOutcome | undefined {
693
+ if (
694
+ messages.length < declaredCount ||
695
+ !turn.streamedMessageIdentityComplete ||
696
+ turn.streamedMessageEntryIds.length === 0
697
+ ) {
698
+ return undefined;
699
+ }
700
+ const startIndex = messages.length - declaredCount;
701
+ let historyIndex = startIndex;
702
+ for (const entryId of turn.streamedMessageEntryIds) {
703
+ while (historyIndex < messages.length) {
704
+ const historyMessage = messages[historyIndex];
705
+ if (historyMessage && nativeEntryId(historyMessage) === entryId) break;
706
+ historyIndex += 1;
707
+ }
708
+ if (historyIndex >= messages.length) return undefined;
709
+ const correlated = messages[historyIndex];
710
+ if (
711
+ entryId === turn.lastCompletedAssistantEntryId &&
712
+ (correlated?.role !== "assistant" ||
713
+ assistantTerminalOutcome(correlated) !== turn.lastCompletedAssistantOutcome)
714
+ )
715
+ return undefined;
716
+ historyIndex += 1;
717
+ }
718
+ historyIndex = startIndex;
719
+ for (const retained of retainedMessages) {
720
+ const entryId = nativeEntryId(retained);
721
+ if (!entryId) return undefined;
722
+ while (historyIndex < messages.length) {
723
+ const historyMessage = messages[historyIndex];
724
+ if (historyMessage && nativeEntryId(historyMessage) === entryId) break;
725
+ historyIndex += 1;
726
+ }
727
+ if (historyIndex >= messages.length) return undefined;
728
+ const correlated = messages[historyIndex];
729
+ if (!correlated || correlated.role !== retained.role) return undefined;
730
+ if (
731
+ retained.role === "assistant" &&
732
+ correlated.role === "assistant" &&
733
+ assistantTerminalOutcome(retained) !== assistantTerminalOutcome(correlated)
734
+ )
735
+ return undefined;
736
+ historyIndex += 1;
737
+ }
738
+ const status = lastAssistantStatus(messages, startIndex);
739
+ return status === "unavailable" ? undefined : status;
740
+ }
741
+
742
+ function unknownTerminalOutcomeError(
743
+ event: Extract<OmpRpcEvent, { type: "agent_end" }>,
744
+ turn: Pick<ActiveTurn, "completedMessageCount" | "lastCompletedAssistantOutcome">,
745
+ ): string {
746
+ const retainedMessages = event.messages?.length ?? 0;
747
+ const retainedStatus = event.messages ? lastAssistantStatus(event.messages) : "unavailable";
748
+ const lastStatus =
749
+ retainedStatus === "unavailable"
750
+ ? (turn.lastCompletedAssistantOutcome ?? "unavailable")
751
+ : retainedStatus;
752
+ return (
753
+ "OMP agent_end omitted terminal messages; outcome is unknown " +
754
+ `(declaredCount=${event.messageCount ?? "unavailable"}, ` +
755
+ `observedCount=${turn.completedMessageCount}, ` +
756
+ `retainedTerminalMessages=${retainedMessages}, lastAssistantStatus=${lastStatus})`
757
+ );
625
758
  }
626
759
 
627
760
  function isNativeTurnActivity(event: OmpRpcEvent): boolean {
@@ -668,7 +801,7 @@ function createActiveTurn(
668
801
  clientMessageId: string,
669
802
  text: string,
670
803
  generation: number,
671
- terminalOwnershipRequired: boolean,
804
+ terminalPolicy: TerminalCorrelation["policy"],
672
805
  manualCompaction = false,
673
806
  ): ActiveTurn {
674
807
  return {
@@ -693,17 +826,18 @@ function createActiveTurn(
693
826
  manualCompaction,
694
827
  activitySequence: 0,
695
828
  acknowledged: false,
696
- terminalOwnershipEvidence: false,
829
+ terminalCorrelation: { policy: terminalPolicy, evidence: new Map() },
697
830
  replayingBufferedEvents: false,
698
- bufferedTerminalOwnershipEvidence: false,
699
- terminalOwnershipRequired,
700
831
  steersInFlight: 0,
832
+ activeToolCallIds: new Set(),
701
833
  steerReady: Promise.withResolvers<void>(),
702
834
  userCorrelationActive: false,
703
835
  userLookups: new Set(),
704
836
  userEchoes: [],
705
837
  completedMessageCount: 0,
706
838
  bufferedEvents: [],
839
+ streamedMessageEntryIds: [],
840
+ streamedMessageIdentityComplete: true,
707
841
  pendingUsers: [
708
842
  {
709
843
  clientMessageId,
@@ -715,6 +849,35 @@ function createActiveTurn(
715
849
  ],
716
850
  };
717
851
  }
852
+ function classifyTerminalCandidate(
853
+ turn: ActiveTurn,
854
+ event: Extract<OmpRpcEvent, { type: "agent_end" }>,
855
+ arrivalSequence = turn.activitySequence,
856
+ ): TerminalCandidate {
857
+ if (event.requestId !== undefined) return { event, confidence: "keyed", arrivalSequence };
858
+ if (turn.agentInvoked === false) return { event, confidence: "ambiguous", arrivalSequence };
859
+ if (turn.terminalCorrelation.policy === "initial-turn") {
860
+ return { event, confidence: "initial-turn", arrivalSequence };
861
+ }
862
+ const assistantSequence = turn.terminalCorrelation.evidence.get("current-assistant");
863
+ if (
864
+ turn.terminalCorrelation.policy === "native-command" &&
865
+ assistantSequence !== undefined &&
866
+ assistantSequence <= arrivalSequence
867
+ ) {
868
+ return { event, confidence: "native-command", arrivalSequence };
869
+ }
870
+ const userSequence = turn.terminalCorrelation.evidence.get("fresh-native-user");
871
+ if (
872
+ userSequence !== undefined &&
873
+ assistantSequence !== undefined &&
874
+ userSequence < assistantSequence &&
875
+ assistantSequence <= arrivalSequence
876
+ ) {
877
+ return { event, confidence: "ordered-legacy", arrivalSequence };
878
+ }
879
+ return { event, confidence: "ambiguous", arrivalSequence };
880
+ }
718
881
 
719
882
  export class OmpProviderSession {
720
883
  readonly id: string;
@@ -731,6 +894,7 @@ export class OmpProviderSession {
731
894
  private readonly emittedEntryIds = new BoundedStringSet(MAX_TRACKED_ENTRY_IDS);
732
895
  private readonly seenEntryIds = new BoundedStringSet(MAX_TRACKED_ENTRY_IDS);
733
896
  private branchWatermarkValid = true;
897
+ private readonly branchEntryIds = new Set<string>();
734
898
  private readonly unclaimedBranchEntries: Array<{ entryId: string; text: string }> = [];
735
899
  private readonly scheduler: OmpTimelineScheduler;
736
900
  private readonly dataFilter: OmpPublicDataSerializer;
@@ -782,7 +946,7 @@ export class OmpProviderSession {
782
946
  private recoveryOptions: OmpRecoveryOptions,
783
947
  private readonly hostTools: OmpHostToolsBridge,
784
948
  private nativeSessionId: string,
785
- nativeSessionFile: string | undefined,
949
+ private readonly nativeSessionFile: string | undefined,
786
950
  private readonly config: ProviderSessionConfig,
787
951
  private configState: ProviderConfigState,
788
952
  outputRedactionValues: readonly string[],
@@ -813,6 +977,7 @@ export class OmpProviderSession {
813
977
  scheduler,
814
978
  outputRedactionValues,
815
979
  capabilities.includes("session.revert.conversation"),
980
+ capabilities.includes("timeline.plugin"),
816
981
  hostTools.labels,
817
982
  );
818
983
  this.subsessions = capabilities.includes("session.subsession")
@@ -825,6 +990,7 @@ export class OmpProviderSession {
825
990
  scheduler,
826
991
  () => this.resumeDeferredAgentEnd(),
827
992
  outputRedactionValues,
993
+ capabilities.includes("timeline.plugin"),
828
994
  )
829
995
  : null;
830
996
  this.bindRuntime(runtime);
@@ -832,6 +998,13 @@ export class OmpProviderSession {
832
998
  get persistenceSessionId(): string | undefined {
833
999
  return this.persistSession ? this.nativeSessionId : undefined;
834
1000
  }
1001
+ async openPaseoBrowser(url: string): Promise<void> {
1002
+ if (this.closed) throw new OmpPublicError("The OMP session is closed");
1003
+ await this.hostTools.openPaseoBrowser(url);
1004
+ }
1005
+ setBrowserAuthorizationIssuer(issue: ((url: string) => string | undefined) | null): void {
1006
+ this.projector.setBrowserAuthorizationIssuer(issue);
1007
+ }
835
1008
 
836
1009
  private readonly imageMaterializer = new OmpImageMaterializer();
837
1010
  static async open(
@@ -976,10 +1149,12 @@ export class OmpProviderSession {
976
1149
  }
977
1150
  const configState: ProviderConfigState = {
978
1151
  ...(state.model ? { model: ompModelId(state.model) } : {}),
979
- mode: normalizedConfig.mode,
1152
+ mode: normalizedConfig.mode ?? "full",
980
1153
  ...(committedThinkingLevel ? { thinkingOption: committedThinkingLevel } : {}),
981
1154
  models,
982
- modes: OMP_MODES,
1155
+ // OMP fixes approval mode at process launch. Publish only the selected mode so
1156
+ // Paseo shows the security state without offering unsupported transitions.
1157
+ modes: [fixedSessionMode(normalizedConfig.mode)],
983
1158
  thinkingOptions,
984
1159
  settings: [],
985
1160
  };
@@ -1093,7 +1268,7 @@ export class OmpProviderSession {
1093
1268
  ...(this.config.title ? { title: this.dataFilter.text(this.config.title, 256) } : {}),
1094
1269
  });
1095
1270
  this.emit({ type: "session.config", sessionId: this.id, config: this.configState });
1096
- if (this.replayHistoryOnOpen) await this.replayHistory();
1271
+ if (this.replayHistoryOnOpen) await this.replayHistory(true);
1097
1272
  this.publishCommands(this.commandCatalog);
1098
1273
  this.emit({ type: "session.ready", requestId, sessionId: this.id });
1099
1274
  this.readyPublished = true;
@@ -1383,7 +1558,7 @@ export class OmpProviderSession {
1383
1558
  });
1384
1559
  }
1385
1560
 
1386
- private async replayHistory(): Promise<void> {
1561
+ private async replayHistory(preferPersistedTranscript = false): Promise<void> {
1387
1562
  if (!this.runtime.canReplayHistory) {
1388
1563
  throw new OmpPublicError("OMP session history cannot be replayed safely");
1389
1564
  }
@@ -1397,19 +1572,60 @@ export class OmpProviderSession {
1397
1572
  this.replayTimeoutMs,
1398
1573
  );
1399
1574
  try {
1400
- const messages = await waitForReplay(this.runtime.getMessages(), replay.signal);
1401
- replay.signal.throwIfAborted();
1402
- if (messages.length > MAX_REPLAY_MESSAGES) {
1403
- throw new OmpPublicError("OMP session history exceeds replay limits");
1575
+ let messages: OmpMessage[] | undefined;
1576
+ // OMP's RPC history is model context, which excludes failed/aborted turns. On initial
1577
+ // resume/import prefer the already authorized journal; rewinds still use the runtime's
1578
+ // in-memory branch because an uncommitted leaf move is not represented by file order.
1579
+ if (
1580
+ preferPersistedTranscript &&
1581
+ this.nativeSessionFile &&
1582
+ this.runtimeFactory.readPersistedSessionTranscript
1583
+ ) {
1584
+ try {
1585
+ const transcript = await waitForReplay(
1586
+ this.runtimeFactory.readPersistedSessionTranscript({
1587
+ sessionFile: this.nativeSessionFile,
1588
+ sessionId: this.nativeSessionId,
1589
+ cwd: this.cwd,
1590
+ signal: replay.signal,
1591
+ }),
1592
+ replay.signal,
1593
+ );
1594
+ messages = transcript.messages;
1595
+ if (transcript.imageReplayWarning) {
1596
+ this.emit({
1597
+ type: "timeline.item",
1598
+ sessionId: this.id,
1599
+ item: {
1600
+ id: "omp:replay-image-unavailable",
1601
+ type: "notification",
1602
+ level: "warning",
1603
+ message: "OMP skipped one or more unavailable images while replaying this session.",
1604
+ },
1605
+ });
1606
+ }
1607
+ } catch (error) {
1608
+ if (replay.signal.aborted) throw error;
1609
+ this.emit({
1610
+ type: "timeline.item",
1611
+ sessionId: this.id,
1612
+ item: {
1613
+ id: "omp:replay-incomplete",
1614
+ type: "error",
1615
+ message:
1616
+ "OMP could not read its complete persisted transcript; displayed history may be incomplete.",
1617
+ },
1618
+ });
1619
+ }
1404
1620
  }
1405
- this.unclaimedBranchEntries.length = 0;
1621
+ messages ??= await waitForReplay(this.runtime.getMessages(), replay.signal);
1622
+ this.quarantineBranchEntries();
1406
1623
  for (const message of messages) {
1407
1624
  replay.signal.throwIfAborted();
1408
1625
  const entryId = nativeEntryId(message);
1409
1626
  if (entryId) this.seenEntryIds.add(entryId);
1410
1627
  this.projector.projectReplayMessage(message);
1411
1628
  }
1412
- this.branchWatermarkValid = true;
1413
1629
  this.projector.finishReplay();
1414
1630
  await this.subsessions?.replay(messages, this.runtime, this.runtimeFactory, replay.signal);
1415
1631
  replay.signal.throwIfAborted();
@@ -1705,7 +1921,7 @@ export class OmpProviderSession {
1705
1921
  input.prompt.clientMessageId,
1706
1922
  payload.text,
1707
1923
  this.generation,
1708
- this.runtimeTurnCompleted,
1924
+ this.runtimeTurnCompleted ? "ordered-legacy" : "initial-turn",
1709
1925
  slashCommandName(payload.text) === "compact",
1710
1926
  );
1711
1927
  this.activeTurn = turn;
@@ -1741,9 +1957,32 @@ export class OmpProviderSession {
1741
1957
  }, COMPACTION_MAX_WAIT_MS);
1742
1958
  return;
1743
1959
  }
1744
- const acknowledgement = await runtime.prompt(payload.text, payload.images, () => {
1745
- turn.promptAcceptedEventIndex ??= turn.bufferedEvents.length;
1746
- });
1960
+ const ownershipPending = turn.pendingUsers[0];
1961
+ if (
1962
+ turn.terminalCorrelation.policy === "ordered-legacy" &&
1963
+ !this.branchWatermarkValid &&
1964
+ ownershipPending
1965
+ ) {
1966
+ await this.refreshBranchEntries(turn, ownershipPending);
1967
+ if (
1968
+ this.closed ||
1969
+ turn.terminal ||
1970
+ this.activeTurn !== turn ||
1971
+ turn.generation !== this.generation
1972
+ ) {
1973
+ return;
1974
+ }
1975
+ }
1976
+ const acknowledgement = await runtime.prompt(
1977
+ payload.text,
1978
+ payload.images,
1979
+ () => {
1980
+ turn.promptAcceptedEventIndex ??= turn.bufferedEvents.length;
1981
+ },
1982
+ (requestId) => {
1983
+ turn.nativeRequestId = requestId;
1984
+ },
1985
+ );
1747
1986
  if (this.closed || turn.terminal) return;
1748
1987
  turn.nativeRequestId = acknowledgement.requestId;
1749
1988
  this.publishPromptResult(turn, { type: "turn", turnId: turn.turnId });
@@ -1761,13 +2000,10 @@ export class OmpProviderSession {
1761
2000
  0,
1762
2001
  turn.promptAcceptedEventIndex ?? bufferedEvents.length,
1763
2002
  );
1764
- for (const event of preAcceptanceEvents) this.handleTurnEvent(turn, event);
2003
+ for (const event of preAcceptanceEvents) this.handleTurnEvent(turn, event, false);
1765
2004
  this.projector.acceptLiveTurn(turn.turnId);
1766
2005
  for (const event of bufferedEvents) this.handleTurnEvent(turn, event);
1767
2006
  turn.replayingBufferedEvents = false;
1768
- if (acknowledgement.agentInvoked === true && turn.bufferedTerminalOwnershipEvidence) {
1769
- this.markTerminalOwnershipEvidence(turn);
1770
- }
1771
2007
  if (
1772
2008
  acknowledgement.agentInvoked === false &&
1773
2009
  turn.agentInvoked !== true &&
@@ -1915,11 +2151,26 @@ export class OmpProviderSession {
1915
2151
  text: string,
1916
2152
  invoke: () => Promise<void>,
1917
2153
  ): Promise<void> {
1918
- const turn = createActiveTurn(clientMessageId, text, this.generation, false);
2154
+ if (this.activeTurn) {
2155
+ throw new OmpPublicError("OMP already has an active turn; send this message as a steer");
2156
+ }
2157
+ const turn = createActiveTurn(clientMessageId, text, this.generation, "native-command");
1919
2158
  this.activeTurn = turn;
1920
2159
  try {
1921
2160
  await invoke();
1922
- if (this.closed || turn.terminal || this.activeTurn !== turn) return;
2161
+ if (this.closed || turn.terminal || this.activeTurn !== turn) {
2162
+ if (!turn.terminal) {
2163
+ this.publishPendingUsers(turn);
2164
+ this.publishPromptResult(turn, {
2165
+ type: "failed",
2166
+ error: {
2167
+ message: this.closed ? "OMP session is closed" : "OMP command lost turn ownership",
2168
+ },
2169
+ });
2170
+ this.settleUnstartedTurn(turn);
2171
+ }
2172
+ return;
2173
+ }
1923
2174
  this.publishPromptResult(turn, { type: "turn", turnId: turn.turnId });
1924
2175
  turn.starting = false;
1925
2176
  turn.acknowledged = true;
@@ -1930,16 +2181,13 @@ export class OmpProviderSession {
1930
2181
  for (const event of bufferedEvents) this.handleTurnEvent(turn, event);
1931
2182
  turn.replayingBufferedEvents = false;
1932
2183
  } catch (error) {
1933
- if (turn.terminal || this.activeTurn !== turn) return;
2184
+ if (turn.terminal) return;
2185
+ const ownsTurn = this.activeTurn === turn;
1934
2186
  const failure = providerError(error, "OMP command failed");
1935
2187
  this.publishPendingUsers(turn);
1936
2188
  this.publishPromptResult(turn, { type: "failed", error: failure });
1937
- turn.steerReady.resolve();
1938
- turn.terminal = true;
1939
- this.imageMaterializer.clear();
1940
- this.resolveTurnPermissions(turn.turnId);
1941
- this.projector.finishTurn(turn.turnId);
1942
- this.activeTurn = null;
2189
+ this.settleUnstartedTurn(turn);
2190
+ if (ownsTurn) this.imageMaterializer.clear();
1943
2191
  }
1944
2192
  }
1945
2193
 
@@ -2040,6 +2288,14 @@ export class OmpProviderSession {
2040
2288
  this.lifetime.abort(new Error("OMP provider connection closed"));
2041
2289
  this.unsubscribe();
2042
2290
  this.unsubscribe = () => {};
2291
+ const turn = this.activeTurn;
2292
+ if (turn && !turn.started) {
2293
+ this.publishPromptResult(turn, {
2294
+ type: "failed",
2295
+ error: { message: "OMP session closed before the prompt was accepted" },
2296
+ });
2297
+ this.settleUnstartedTurn(turn);
2298
+ }
2043
2299
  }
2044
2300
  async configure(input: SessionConfigureInput): Promise<void> {
2045
2301
  if (this.revertInFlight) {
@@ -2296,6 +2552,23 @@ export class OmpProviderSession {
2296
2552
  }
2297
2553
  }
2298
2554
 
2555
+ private async readRuntimeHistoryWithTimeout(
2556
+ runtime: OmpRuntimeSession,
2557
+ ): Promise<OmpMessage[] | undefined> {
2558
+ if (!runtime.canReplayHistory) return undefined;
2559
+ const historyRequest = runtime.getMessages();
2560
+ void historyRequest.catch(() => undefined);
2561
+ const timeout = Promise.withResolvers<null>();
2562
+ const timer = this.scheduler.set(() => timeout.resolve(null), AGENT_END_HISTORY_TIMEOUT_MS);
2563
+ try {
2564
+ return (await Promise.race([historyRequest, timeout.promise])) ?? undefined;
2565
+ } catch {
2566
+ return undefined;
2567
+ } finally {
2568
+ this.scheduler.clear(timer);
2569
+ }
2570
+ }
2571
+
2299
2572
  private publishCommittedConfig(
2300
2573
  state: OmpSessionState,
2301
2574
  runtime: OmpRuntimeSession,
@@ -2499,12 +2772,8 @@ export class OmpProviderSession {
2499
2772
  });
2500
2773
  if (turn.started) await this.finishTurn(turn, "canceled", undefined, true, true);
2501
2774
  else {
2502
- turn.steerReady.resolve();
2503
- turn.terminal = true;
2504
- this.stopUsagePoll(turn);
2775
+ this.settleUnstartedTurn(turn);
2505
2776
  this.finishCompaction("canceled");
2506
- this.projector.finishTurn(turn.turnId);
2507
- if (this.activeTurn === turn) this.activeTurn = null;
2508
2777
  }
2509
2778
  }
2510
2779
  this.imageMaterializer.clear();
@@ -2783,6 +3052,7 @@ export class OmpProviderSession {
2783
3052
  return;
2784
3053
  }
2785
3054
  turn.localOnlyDisabled = true;
3055
+ this.cancelAmbiguousTerminal(turn);
2786
3056
  turn.deferredAgentEnd = undefined;
2787
3057
  this.acceptPendingUser(turn, pending);
2788
3058
  this.emit({
@@ -2905,6 +3175,13 @@ export class OmpProviderSession {
2905
3175
  }
2906
3176
  return;
2907
3177
  }
3178
+ if (
3179
+ event.type === "agent_end" &&
3180
+ event.requestId !== undefined &&
3181
+ event.requestId !== turn.nativeRequestId
3182
+ ) {
3183
+ return;
3184
+ }
2908
3185
  if (turn.starting) {
2909
3186
  if (
2910
3187
  turn.bufferedEvents.length >= MAX_BUFFERED_TURN_EVENTS ||
@@ -2927,7 +3204,11 @@ export class OmpProviderSession {
2927
3204
  this.handleTurnEvent(turn, event);
2928
3205
  }
2929
3206
 
2930
- private handleTurnEvent(turn: ActiveTurn, event: OmpRpcEvent): void {
3207
+ private handleTurnEvent(
3208
+ turn: ActiveTurn,
3209
+ event: OmpRpcEvent,
3210
+ correlatesToAcceptedPrompt = true,
3211
+ ): void {
2931
3212
  if (
2932
3213
  turn.generation !== this.generation ||
2933
3214
  turn.terminal ||
@@ -2936,16 +3217,32 @@ export class OmpProviderSession {
2936
3217
  ) {
2937
3218
  return;
2938
3219
  }
2939
- if (event.type === "message_end") {
3220
+ if (
3221
+ event.type === "agent_end" &&
3222
+ event.requestId !== undefined &&
3223
+ event.requestId !== turn.nativeRequestId
3224
+ ) {
3225
+ return;
3226
+ }
3227
+ if (event.type === "message_end" && correlatesToAcceptedPrompt) {
3228
+ const entryId = nativeEntryId(event.message);
3229
+ if (!entryId || turn.streamedMessageEntryIds.length >= MAX_AGENT_END_CORRELATION_MESSAGES) {
3230
+ turn.streamedMessageIdentityComplete = false;
3231
+ } else {
3232
+ turn.streamedMessageEntryIds.push(entryId);
3233
+ }
2940
3234
  turn.completedMessageCount += 1;
2941
3235
  if (event.message.role === "assistant") {
2942
- turn.lastCompletedAssistantFailed =
2943
- event.message.stopReason === "error" || !!event.message.errorMessage;
3236
+ turn.lastCompletedAssistantOutcome = assistantTerminalOutcome(event.message);
3237
+ turn.lastCompletedAssistantEntryId = entryId;
2944
3238
  }
2945
3239
  }
2946
3240
  if (event.type === "prompt_error") {
2947
3241
  if (event.id !== turn.nativeRequestId) return;
2948
- const error = { message: event.error };
3242
+ const error: ProviderError = {
3243
+ message: this.dataFilter.text(event.error, 4_096),
3244
+ ...(event.code ? { code: this.dataFilter.text(event.code, 256) } : {}),
3245
+ };
2949
3246
  this.publishPendingUsers(turn);
2950
3247
  void this.finishTurn(turn, "failed", error);
2951
3248
  return;
@@ -2953,15 +3250,19 @@ export class OmpProviderSession {
2953
3250
  if (event.type === "prompt_result") {
2954
3251
  if (!event.id || event.id !== turn.nativeRequestId) return;
2955
3252
  if (event.agentInvoked) {
3253
+ // A request-keyed prompt result proves dispatch, but an unkeyed terminal still needs
3254
+ // ordered native user and assistant evidence from this accepted prompt.
2956
3255
  this.markAgentEvidence(turn);
2957
- if (turn.replayingBufferedEvents) turn.bufferedTerminalOwnershipEvidence = true;
2958
- else this.markTerminalOwnershipEvidence(turn);
2959
3256
  return;
2960
3257
  }
2961
3258
  if (turn.localOnlyDisabled || turn.steersInFlight > 0) return;
2962
3259
  if (!turn.nativeActivity && !turn.awaitingPermissionEvidence) {
2963
3260
  turn.agentInvoked = false;
2964
3261
  turn.localOnlyEligible = true;
3262
+ if (turn.deferredAgentEnd?.confidence === "ambiguous") {
3263
+ turn.deferredAgentEnd = undefined;
3264
+ this.cancelAmbiguousTerminal(turn);
3265
+ }
2965
3266
  this.scheduleLocalOnlyCompletion(turn);
2966
3267
  }
2967
3268
  return;
@@ -3009,8 +3310,10 @@ export class OmpProviderSession {
3009
3310
  return;
3010
3311
  }
3011
3312
  if (event.type === "agent_end" && turn.manualCompactionPending) return;
3012
- if (event.type === "tool_execution_end" && event.toolName === "ask_user") {
3013
- this.pendingFreeformSelection = null;
3313
+ if (event.type === "tool_execution_start") turn.activeToolCallIds.add(event.toolCallId);
3314
+ if (event.type === "tool_execution_end") {
3315
+ turn.activeToolCallIds.delete(event.toolCallId);
3316
+ if (event.toolName === "ask_user") this.pendingFreeformSelection = null;
3014
3317
  }
3015
3318
  if (event.type === "tool_execution_start" || event.type === "tool_execution_end") {
3016
3319
  try {
@@ -3020,6 +3323,37 @@ export class OmpProviderSession {
3020
3323
  return;
3021
3324
  }
3022
3325
  }
3326
+ if (event.type === "agent_end") {
3327
+ if (event.isTerminal === false) {
3328
+ turn.completedMessageCount = 0;
3329
+ turn.streamedMessageEntryIds.length = 0;
3330
+ turn.streamedMessageIdentityComplete = true;
3331
+ turn.lastCompletedAssistantOutcome = undefined;
3332
+ turn.lastCompletedAssistantEntryId = undefined;
3333
+ return;
3334
+ }
3335
+ const candidate: TerminalCandidate = turn.interrupted
3336
+ ? { event, confidence: "interrupted", arrivalSequence: turn.activitySequence }
3337
+ : classifyTerminalCandidate(turn, event);
3338
+ if (candidate.confidence === "ambiguous") {
3339
+ this.deferAmbiguousTerminal(turn, candidate);
3340
+ return;
3341
+ }
3342
+ turn.nativeActivity = true;
3343
+ this.cancelLocalOnlyCompletion(turn);
3344
+ if (
3345
+ (!turn.interrupted &&
3346
+ candidate.confidence !== "keyed" &&
3347
+ this.hasTerminalConflict(turn, false)) ||
3348
+ turn.steersInFlight > 0 ||
3349
+ turn.terminalizing
3350
+ ) {
3351
+ turn.deferredAgentEnd = candidate;
3352
+ return;
3353
+ }
3354
+ this.beginTerminalization(turn, candidate);
3355
+ return;
3356
+ }
3023
3357
  if (isNativeTurnActivity(event)) {
3024
3358
  turn.nativeActivity = true;
3025
3359
  this.cancelLocalOnlyCompletion(turn);
@@ -3036,34 +3370,13 @@ export class OmpProviderSession {
3036
3370
  event.message.role === "assistant"
3037
3371
  ) {
3038
3372
  turn.awaitingPermissionEvidence = false;
3039
- }
3040
- if (event.type === "agent_end") {
3041
- const hasAssistantEvidence =
3042
- event.messages?.some((message) => message.role === "assistant") ?? false;
3043
- if (turn.awaitingPermissionEvidence && !hasAssistantEvidence) {
3044
- turn.deferredAgentEnd = event;
3045
- return;
3046
- }
3047
- if (hasAssistantEvidence) turn.awaitingPermissionEvidence = false;
3048
- if (event.isTerminal === false) return;
3049
- if (turn.terminalizing) {
3050
- turn.deferredAgentEnd = event;
3051
- return;
3373
+ if (correlatesToAcceptedPrompt) {
3374
+ turn.terminalCorrelation.evidence.set("current-assistant", turn.activitySequence + 1);
3052
3375
  }
3053
- if (turn.steersInFlight > 0) {
3054
- turn.deferredAgentEnd = event;
3055
- return;
3056
- }
3057
- this.beginTerminalization(turn, event);
3058
- return;
3059
3376
  }
3060
3377
  if (isNativeTurnActivity(event)) this.markAgentEvidence(turn);
3061
- if (event.type === "message_end" && event.message.role === "user") {
3062
- this.markAgentEvidence(turn);
3063
- this.projectUserEcho(turn, event.message);
3064
- return;
3065
- }
3066
3378
  this.projector.project(event, turn.turnId);
3379
+ if (event.type === "tool_execution_end") this.resumeDeferredAgentEnd();
3067
3380
  }
3068
3381
 
3069
3382
  private projectUserEcho(turn: ActiveTurn, message: OmpMessage): void {
@@ -3134,52 +3447,10 @@ export class OmpProviderSession {
3134
3447
  pending.bufferedEchoes.push(...turn.userEchoes.splice(0));
3135
3448
  return;
3136
3449
  }
3137
- let resolvedId = entryId ?? this.claimUnclaimedBranchEntry(pending.text);
3138
- if (!resolvedId) {
3139
- try {
3140
- const messages = await this.runtime.getBranchMessages();
3141
- if (
3142
- this.closed ||
3143
- turn.terminal ||
3144
- this.activeTurn !== turn ||
3145
- turn.pendingUsers[0] !== pending
3146
- ) {
3147
- return;
3148
- }
3149
- if (retainedBytes(messages, MAX_UNCLAIMED_BRANCH_BYTES) === Number.POSITIVE_INFINITY) {
3150
- this.quarantineBranchEntries();
3151
- return;
3152
- }
3153
- const unseen: Array<{ entryId: string; text: string }> = [];
3154
- for (const branchMessage of messages) {
3155
- if (!this.seenEntryIds.has(branchMessage.entryId)) unseen.push(branchMessage);
3156
- }
3157
- if (!this.branchWatermarkValid) {
3158
- this.unclaimedBranchEntries.length = 0;
3159
- this.branchWatermarkValid = true;
3160
- } else if (
3161
- unseen.length <= MAX_UNCLAIMED_BRANCH_ENTRIES - this.unclaimedBranchEntries.length &&
3162
- retainedBytes(this.unclaimedBranchEntries, MAX_UNCLAIMED_BRANCH_BYTES) +
3163
- retainedBytes(unseen, MAX_UNCLAIMED_BRANCH_BYTES) <=
3164
- MAX_UNCLAIMED_BRANCH_BYTES
3165
- ) {
3166
- this.unclaimedBranchEntries.push(...unseen);
3167
- } else {
3168
- this.quarantineBranchEntries();
3169
- }
3170
- for (const branchMessage of messages) this.seenEntryIds.add(branchMessage.entryId);
3171
- resolvedId = this.claimUnclaimedBranchEntry(pending.text);
3172
- } catch {
3173
- if (
3174
- this.closed ||
3175
- turn.terminal ||
3176
- this.activeTurn !== turn ||
3177
- turn.pendingUsers[0] !== pending
3178
- ) {
3179
- return;
3180
- }
3181
- this.quarantineBranchEntries();
3182
- }
3450
+ const observedSequence = turn.activitySequence;
3451
+ let resolvedId = entryId ?? this.claimUnclaimedBranchEntry(turn, pending.text);
3452
+ if (!resolvedId && (await this.refreshBranchEntries(turn, pending))) {
3453
+ resolvedId = this.claimUnclaimedBranchEntry(turn, pending.text);
3183
3454
  }
3184
3455
  if (
3185
3456
  this.closed ||
@@ -3192,18 +3463,94 @@ export class OmpProviderSession {
3192
3463
  turn.userEchoes.shift();
3193
3464
  if (!resolvedId) return;
3194
3465
  turn.pendingUsers.shift();
3195
- this.publishCorrelatedUser(turn, pending, resolvedId);
3466
+ this.publishCorrelatedUser(turn, pending, resolvedId, observedSequence);
3467
+ }
3468
+ }
3469
+ private async refreshBranchEntries(turn: ActiveTurn, pending: PendingUser): Promise<boolean> {
3470
+ const runtime = this.runtime;
3471
+ try {
3472
+ const messages = await runtime.getBranchMessages();
3473
+ if (
3474
+ this.closed ||
3475
+ turn.terminal ||
3476
+ this.activeTurn !== turn ||
3477
+ turn.pendingUsers[0] !== pending ||
3478
+ turn.generation !== this.generation ||
3479
+ runtime !== this.runtime
3480
+ ) {
3481
+ return false;
3482
+ }
3483
+ if (
3484
+ messages.length > MAX_UNCLAIMED_BRANCH_ENTRIES ||
3485
+ retainedBytes(messages, MAX_UNCLAIMED_BRANCH_BYTES) === Number.POSITIVE_INFINITY
3486
+ ) {
3487
+ this.quarantineBranchEntries();
3488
+ return false;
3489
+ }
3490
+ const unseen: Array<{ entryId: string; text: string }> = [];
3491
+ const snapshotIds = new Set<string>();
3492
+ for (const message of messages) {
3493
+ if (snapshotIds.has(message.entryId)) {
3494
+ this.quarantineBranchEntries();
3495
+ return false;
3496
+ }
3497
+ snapshotIds.add(message.entryId);
3498
+ if (!this.branchEntryIds.has(message.entryId)) unseen.push(message);
3499
+ }
3500
+ if (!this.branchWatermarkValid) {
3501
+ this.unclaimedBranchEntries.length = 0;
3502
+ this.branchWatermarkValid = true;
3503
+ } else if (
3504
+ unseen.length <= MAX_UNCLAIMED_BRANCH_ENTRIES - this.unclaimedBranchEntries.length &&
3505
+ this.branchEntryIds.size + unseen.length <= MAX_UNCLAIMED_BRANCH_ENTRIES &&
3506
+ retainedBytes(this.unclaimedBranchEntries, MAX_UNCLAIMED_BRANCH_BYTES) +
3507
+ retainedBytes(unseen, MAX_UNCLAIMED_BRANCH_BYTES) <=
3508
+ MAX_UNCLAIMED_BRANCH_BYTES
3509
+ ) {
3510
+ this.unclaimedBranchEntries.push(...unseen);
3511
+ } else {
3512
+ this.quarantineBranchEntries();
3513
+ return false;
3514
+ }
3515
+ for (const message of messages) {
3516
+ this.branchEntryIds.add(message.entryId);
3517
+ this.seenEntryIds.add(message.entryId);
3518
+ }
3519
+ return true;
3520
+ } catch {
3521
+ if (
3522
+ !this.closed &&
3523
+ !turn.terminal &&
3524
+ this.activeTurn === turn &&
3525
+ turn.pendingUsers[0] === pending &&
3526
+ turn.generation === this.generation &&
3527
+ runtime === this.runtime
3528
+ ) {
3529
+ this.quarantineBranchEntries();
3530
+ }
3531
+ return false;
3196
3532
  }
3197
3533
  }
3198
3534
 
3199
- private claimUnclaimedBranchEntry(text: string): string | undefined {
3535
+ private claimUnclaimedBranchEntry(turn: ActiveTurn, text: string): string | undefined {
3200
3536
  const index = this.unclaimedBranchEntries.findIndex((entry) => entry.text === text);
3201
3537
  if (index < 0) return undefined;
3538
+ let matches = 0;
3539
+ let expected = 0;
3540
+ for (const entry of this.unclaimedBranchEntries) if (entry.text === text) matches += 1;
3541
+ for (const pending of turn.pendingUsers) {
3542
+ if (pending.accepted && pending.text === text) expected += 1;
3543
+ }
3544
+ if (matches > expected) {
3545
+ this.quarantineBranchEntries();
3546
+ return undefined;
3547
+ }
3202
3548
  return this.unclaimedBranchEntries.splice(index, 1)[0]?.entryId;
3203
3549
  }
3204
3550
 
3205
3551
  private quarantineBranchEntries(): void {
3206
3552
  this.unclaimedBranchEntries.length = 0;
3553
+ this.branchEntryIds.clear();
3207
3554
  this.branchWatermarkValid = false;
3208
3555
  }
3209
3556
 
@@ -3950,23 +4297,27 @@ export class OmpProviderSession {
3950
4297
  return this.activeTurn?.turnId === pending.turnId && !this.activeTurn.terminal;
3951
4298
  }
3952
4299
 
3953
- private reevaluateDeferredPermissionTerminal(): void {
3954
- const turn = this.activeTurn;
3955
- if (!turn?.deferredAgentEnd || turn.terminal || turn.terminalizing) return;
4300
+ private hasTerminalConflict(turn: ActiveTurn, includeChildren = true): boolean {
3956
4301
  const ownsTurn = (pending: PendingPermission | PendingToolPermission) =>
3957
4302
  pending.turnId === turn.turnId;
3958
- if (
4303
+ return (
4304
+ turn.awaitingPermissionEvidence ||
3959
4305
  [...this.pendingPermissions.values()].some(ownsTurn) ||
3960
4306
  [...this.inFlightPermissions.values()].some(ownsTurn) ||
3961
4307
  [...this.pendingToolPermissions.values()].some(ownsTurn) ||
3962
- [...this.inFlightToolPermissions.values()].some(ownsTurn)
3963
- ) {
3964
- return;
3965
- }
3966
- const hasAssistantEvidence =
3967
- turn.deferredAgentEnd.messages?.some((message) => message.role === "assistant") ?? false;
3968
- if (!hasAssistantEvidence || turn.awaitingPermissionEvidence) return;
4308
+ [...this.inFlightToolPermissions.values()].some(ownsTurn) ||
4309
+ turn.activeToolCallIds.size > 0 ||
4310
+ turn.steersInFlight > 0 ||
4311
+ (includeChildren && Boolean(this.subsessions?.hasActiveChildren()))
4312
+ );
4313
+ }
4314
+
4315
+ private reevaluateDeferredPermissionTerminal(): void {
4316
+ const turn = this.activeTurn;
4317
+ if (!turn?.deferredAgentEnd || turn.terminal || turn.terminalizing) return;
4318
+ if (this.hasTerminalConflict(turn)) return;
3969
4319
  const deferred = turn.deferredAgentEnd;
4320
+ if (deferred.confidence === "ambiguous") return;
3970
4321
  turn.deferredAgentEnd = undefined;
3971
4322
  this.beginTerminalization(turn, deferred);
3972
4323
  }
@@ -3982,12 +4333,23 @@ export class OmpProviderSession {
3982
4333
  return this.slashCommands.has(commandName);
3983
4334
  }
3984
4335
 
3985
- private publishCorrelatedUser(turn: ActiveTurn, pending: PendingUser, entryId?: string): void {
4336
+ private publishCorrelatedUser(
4337
+ turn: ActiveTurn,
4338
+ pending: PendingUser,
4339
+ entryId: string | undefined,
4340
+ observedSequence: number,
4341
+ ): void {
3986
4342
  if (entryId) {
3987
4343
  if (this.emittedEntryIds.has(entryId)) return;
3988
4344
  this.seenEntryIds.add(entryId);
3989
4345
  this.emittedEntryIds.add(entryId);
3990
- this.markTerminalOwnershipEvidence(turn);
4346
+ turn.terminalCorrelation.evidence.set("fresh-native-user", observedSequence);
4347
+ this.refreshAmbiguousTerminal(turn);
4348
+ if (this.branchWatermarkValid && !this.branchEntryIds.has(entryId)) {
4349
+ if (this.branchEntryIds.size >= MAX_UNCLAIMED_BRANCH_ENTRIES)
4350
+ this.quarantineBranchEntries();
4351
+ else this.branchEntryIds.add(entryId);
4352
+ }
3991
4353
  const unclaimedIndex = this.unclaimedBranchEntries.findIndex(
3992
4354
  (entry) => entry.entryId === entryId,
3993
4355
  );
@@ -4002,12 +4364,7 @@ export class OmpProviderSession {
4002
4364
  turn.activitySequence += 1;
4003
4365
  turn.localOnlyEligible = false;
4004
4366
  this.cancelLocalOnlyCompletion(turn);
4005
- if (!turn.awaitingPermissionEvidence) turn.deferredAgentEnd = undefined;
4006
- }
4007
-
4008
- private markTerminalOwnershipEvidence(turn: ActiveTurn): void {
4009
- turn.terminalOwnershipEvidence = true;
4010
- this.cancelTerminalOwnershipTimeout(turn);
4367
+ this.refreshAmbiguousTerminal(turn);
4011
4368
  }
4012
4369
 
4013
4370
  private scheduleLocalOnlyCompletion(turn: ActiveTurn): void {
@@ -4033,32 +4390,68 @@ export class OmpProviderSession {
4033
4390
  turn.localOnlyTimer = undefined;
4034
4391
  }
4035
4392
 
4036
- private scheduleTerminalOwnershipTimeout(turn: ActiveTurn): void {
4037
- if (
4038
- turn.terminalOwnershipTimer !== undefined ||
4039
- turn.terminalOwnershipEvidence ||
4040
- (turn.agentInvoked === false && turn.localOnlyEligible)
4041
- ) {
4393
+ private refreshAmbiguousTerminal(turn: ActiveTurn): void {
4394
+ const candidate = turn.deferredAgentEnd;
4395
+ if (candidate?.confidence !== "ambiguous") return;
4396
+ const resolved = classifyTerminalCandidate(turn, candidate.event, candidate.arrivalSequence);
4397
+ if (resolved.confidence !== "ambiguous") {
4398
+ this.cancelAmbiguousTerminal(turn);
4399
+ turn.deferredAgentEnd = undefined;
4400
+ if (turn.terminalizing || this.hasTerminalConflict(turn, false)) {
4401
+ turn.deferredAgentEnd = resolved;
4402
+ } else {
4403
+ this.beginTerminalization(turn, resolved);
4404
+ }
4042
4405
  return;
4043
4406
  }
4044
- turn.terminalOwnershipTimer = this.scheduler.set(() => {
4045
- turn.terminalOwnershipTimer = undefined;
4046
- if (
4047
- this.closed ||
4048
- turn.terminal ||
4049
- this.activeTurn !== turn ||
4050
- turn.terminalOwnershipEvidence
4051
- ) {
4052
- return;
4053
- }
4054
- this.handleRuntimeFailure("OMP terminal ownership could not be confirmed");
4407
+ if (turn.activitySequence > candidate.arrivalSequence) {
4408
+ turn.deferredAgentEnd = undefined;
4409
+ this.cancelAmbiguousTerminal(turn);
4410
+ }
4411
+ }
4412
+
4413
+ private deferAmbiguousTerminal(turn: ActiveTurn, candidate: TerminalCandidate): void {
4414
+ if (turn.agentInvoked === false && turn.localOnlyEligible) return;
4415
+ this.cancelAmbiguousTerminal(turn);
4416
+ turn.deferredAgentEnd = candidate;
4417
+ turn.ambiguousTerminalTimer = this.scheduler.set(() => {
4418
+ turn.ambiguousTerminalTimer = undefined;
4419
+ void this.settleAmbiguousTerminal(turn, candidate);
4055
4420
  }, AGENT_END_STATE_TIMEOUT_MS);
4056
4421
  }
4057
4422
 
4058
- private cancelTerminalOwnershipTimeout(turn: ActiveTurn): void {
4059
- if (turn.terminalOwnershipTimer === undefined) return;
4060
- this.scheduler.clear(turn.terminalOwnershipTimer);
4061
- turn.terminalOwnershipTimer = undefined;
4423
+ private cancelAmbiguousTerminal(turn: ActiveTurn): void {
4424
+ if (turn.ambiguousTerminalTimer === undefined) return;
4425
+ this.scheduler.clear(turn.ambiguousTerminalTimer);
4426
+ turn.ambiguousTerminalTimer = undefined;
4427
+ }
4428
+
4429
+ private async settleAmbiguousTerminal(
4430
+ turn: ActiveTurn,
4431
+ candidate: TerminalCandidate,
4432
+ ): Promise<void> {
4433
+ if (
4434
+ this.closed ||
4435
+ turn.terminal ||
4436
+ this.activeTurn !== turn ||
4437
+ turn.deferredAgentEnd !== candidate
4438
+ ) {
4439
+ return;
4440
+ }
4441
+ await Promise.allSettled(turn.userLookups);
4442
+ this.refreshAmbiguousTerminal(turn);
4443
+ if (turn.terminal || this.activeTurn !== turn || turn.deferredAgentEnd !== candidate) return;
4444
+ const state = await this.boundedTerminalState(turn, FINAL_USAGE_WAIT_MS);
4445
+ if (turn.terminal || this.activeTurn !== turn || turn.deferredAgentEnd !== candidate) return;
4446
+ if (!state) {
4447
+ this.handleRuntimeFailure("OMP agent_end state could not be confirmed");
4448
+ return;
4449
+ }
4450
+ turn.deferredAgentEnd = undefined;
4451
+ if (state.isStreaming || state.isCompacting) return;
4452
+ await this.finishTurn(turn, "failed", {
4453
+ message: "OMP unkeyed agent_end could not be correlated to the current prompt",
4454
+ });
4062
4455
  }
4063
4456
 
4064
4457
  private async completeLocalOnlyTurn(turn: ActiveTurn): Promise<void> {
@@ -4084,15 +4477,44 @@ export class OmpProviderSession {
4084
4477
  await this.finishTurn(turn, "completed", undefined, false, false, true);
4085
4478
  }
4086
4479
 
4087
- private beginTerminalization(
4088
- turn: ActiveTurn,
4089
- event: Extract<OmpRpcEvent, { type: "agent_end" }>,
4090
- ): void {
4480
+ private resetAgentEndProbe(turn: ActiveTurn): void {
4481
+ turn.agentEndPending = false;
4482
+ turn.terminalizing = false;
4483
+ if (turn.agentEndRetryTimer !== undefined) {
4484
+ this.scheduler.clear(turn.agentEndRetryTimer);
4485
+ turn.agentEndRetryTimer = undefined;
4486
+ }
4487
+ if (turn.agentEndDeadlineTimer !== undefined) {
4488
+ this.scheduler.clear(turn.agentEndDeadlineTimer);
4489
+ turn.agentEndDeadlineTimer = undefined;
4490
+ }
4491
+ }
4492
+ private ignoreActiveTerminalCandidate(turn: ActiveTurn, candidate: TerminalCandidate): void {
4493
+ const replacement =
4494
+ turn.deferredAgentEnd && turn.deferredAgentEnd !== candidate
4495
+ ? turn.deferredAgentEnd
4496
+ : undefined;
4497
+ this.resetAgentEndProbe(turn);
4498
+ turn.deferredAgentEnd = replacement;
4499
+ if (replacement && replacement.confidence !== "ambiguous") {
4500
+ turn.deferredAgentEnd = undefined;
4501
+ this.beginTerminalization(turn, replacement);
4502
+ return;
4503
+ }
4504
+ this.pollUsage(turn);
4505
+ }
4506
+
4507
+ private beginTerminalization(turn: ActiveTurn, candidate: TerminalCandidate): void {
4091
4508
  if (turn.terminal || turn.terminalizing || turn.agentEndPending || this.activeTurn !== turn) {
4092
4509
  return;
4093
4510
  }
4094
- if (!turn.interrupted && this.deferAgentEndForSubsessions(turn, event)) return;
4511
+ if (!turn.interrupted && this.deferAgentEndForSubsessions(turn, candidate)) return;
4095
4512
  turn.agentEndPending = true;
4513
+ if (turn.deferredAgentEnd?.confidence === "ambiguous") {
4514
+ turn.deferredAgentEnd = undefined;
4515
+ }
4516
+ turn.terminalizing = true;
4517
+ this.cancelAmbiguousTerminal(turn);
4096
4518
  turn.usageSampleFloor = this.usageSequence + 1;
4097
4519
  if (this.usageSample?.turn === turn && this.usageSample.sequence < turn.usageSampleFloor) {
4098
4520
  this.usageSample = null;
@@ -4101,19 +4523,22 @@ export class OmpProviderSession {
4101
4523
  turn.agentEndDeadlineTimer = this.scheduler.set(() => {
4102
4524
  turn.agentEndDeadlineTimer = undefined;
4103
4525
  if (!turn.agentEndPending || turn.terminal || this.activeTurn !== turn) return;
4104
- if (turn.userEchoObserved) {
4105
- this.handleRuntimeFailure("OMP agent_end state could not be confirmed");
4526
+ if (
4527
+ candidate.confidence === "keyed" ||
4528
+ (candidate.confidence === "initial-turn" && !turn.userEchoObserved)
4529
+ ) {
4530
+ void this.completeAgentEnd(turn, candidate.event);
4106
4531
  } else {
4107
- void this.completeAgentEnd(turn, event);
4532
+ this.handleRuntimeFailure("OMP agent_end state could not be confirmed");
4108
4533
  }
4109
4534
  }, AGENT_END_SETTLE_MS);
4110
- this.finishFromAgentEnd(turn, event);
4535
+ this.finishFromAgentEnd(turn, candidate);
4111
4536
  }
4112
4537
 
4113
4538
  private resumeAfterFailedSteer(turn: ActiveTurn): void {
4114
4539
  if (turn.terminal || this.activeTurn !== turn || turn.steersInFlight > 0) return;
4115
4540
  const deferred = turn.deferredAgentEnd;
4116
- if (deferred) {
4541
+ if (deferred && deferred.confidence !== "ambiguous" && !this.hasTerminalConflict(turn)) {
4117
4542
  turn.deferredAgentEnd = undefined;
4118
4543
  this.beginTerminalization(turn, deferred);
4119
4544
  return;
@@ -4122,13 +4547,11 @@ export class OmpProviderSession {
4122
4547
  this.scheduleLocalOnlyCompletion(turn);
4123
4548
  }
4124
4549
  }
4125
- private deferAgentEndForSubsessions(
4126
- turn: ActiveTurn,
4127
- event: Extract<OmpRpcEvent, { type: "agent_end" }>,
4128
- ): boolean {
4550
+
4551
+ private deferAgentEndForSubsessions(turn: ActiveTurn, candidate: TerminalCandidate): boolean {
4129
4552
  if (!this.subsessions?.hasActiveChildren()) return false;
4130
4553
  turn.terminalizing = false;
4131
- turn.deferredAgentEnd = event;
4554
+ turn.deferredAgentEnd = candidate;
4132
4555
  void this.subsessions.reconcile(this.runtime).catch(() => {
4133
4556
  if (!turn.terminal && this.activeTurn === turn) {
4134
4557
  this.handleRuntimeFailure("OMP subagent reconciliation failed");
@@ -4139,31 +4562,22 @@ export class OmpProviderSession {
4139
4562
 
4140
4563
  private resumeDeferredAgentEnd(): void {
4141
4564
  const turn = this.activeTurn;
4142
- if (
4143
- !turn ||
4144
- turn.terminal ||
4145
- turn.terminalizing ||
4146
- turn.steersInFlight > 0 ||
4147
- this.subsessions?.hasActiveChildren()
4148
- ) {
4565
+ if (!turn || turn.terminal || turn.terminalizing || this.hasTerminalConflict(turn)) {
4149
4566
  return;
4150
4567
  }
4151
- const event = turn.deferredAgentEnd;
4152
- if (!event) return;
4568
+ const candidate = turn.deferredAgentEnd;
4569
+ if (!candidate || candidate.confidence === "ambiguous") return;
4153
4570
  turn.deferredAgentEnd = undefined;
4154
- this.beginTerminalization(turn, event);
4571
+ this.beginTerminalization(turn, candidate);
4155
4572
  }
4156
4573
 
4157
- private finishFromAgentEnd(
4158
- turn: ActiveTurn,
4159
- event: Extract<OmpRpcEvent, { type: "agent_end" }>,
4160
- ): void {
4574
+ private finishFromAgentEnd(turn: ActiveTurn, candidate: TerminalCandidate): void {
4161
4575
  if (!turn.agentEndPending || turn.terminal) return;
4162
4576
  if (turn.agentEndCheck) {
4163
- turn.deferredAgentEnd = event;
4577
+ turn.deferredAgentEnd = candidate;
4164
4578
  return;
4165
4579
  }
4166
- const check = this.checkAgentEndState(turn, event);
4580
+ const check = this.checkAgentEndState(turn, candidate);
4167
4581
  turn.agentEndCheck = check;
4168
4582
  void check.finally(() => {
4169
4583
  if (turn.agentEndCheck !== check) return;
@@ -4175,10 +4589,7 @@ export class OmpProviderSession {
4175
4589
  });
4176
4590
  }
4177
4591
 
4178
- private async checkAgentEndState(
4179
- turn: ActiveTurn,
4180
- event: Extract<OmpRpcEvent, { type: "agent_end" }>,
4181
- ): Promise<void> {
4592
+ private async checkAgentEndState(turn: ActiveTurn, candidate: TerminalCandidate): Promise<void> {
4182
4593
  await Promise.allSettled(turn.userLookups);
4183
4594
  if (!turn.agentEndPending || turn.terminal || this.activeTurn !== turn) return;
4184
4595
  while (turn.userEchoes.length > 0) {
@@ -4187,37 +4598,42 @@ export class OmpProviderSession {
4187
4598
  await Promise.allSettled(turn.userLookups);
4188
4599
  if (!turn.agentEndPending || turn.terminal || this.activeTurn !== turn) return;
4189
4600
  }
4601
+ if (
4602
+ !turn.interrupted &&
4603
+ candidate.confidence !== "keyed" &&
4604
+ this.hasTerminalConflict(turn, false)
4605
+ ) {
4606
+ this.resetAgentEndProbe(turn);
4607
+ turn.deferredAgentEnd = candidate;
4608
+ this.pollUsage(turn);
4609
+ return;
4610
+ }
4190
4611
  if (turn.interrupted) {
4191
- await this.completeAgentEnd(turn, event);
4612
+ await this.completeAgentEnd(turn, candidate.event);
4192
4613
  return;
4193
4614
  }
4194
4615
  const state = await this.boundedTerminalState(turn, FINAL_USAGE_WAIT_MS);
4195
4616
  if (!turn.agentEndPending || turn.terminal || this.activeTurn !== turn) return;
4196
4617
  if (!turn.interrupted && this.subsessions?.hasActiveChildren()) {
4197
- turn.agentEndPending = false;
4198
- if (this.deferAgentEndForSubsessions(turn, event)) return;
4618
+ this.resetAgentEndProbe(turn);
4619
+ if (this.deferAgentEndForSubsessions(turn, candidate)) return;
4620
+ }
4621
+ if (
4622
+ !turn.interrupted &&
4623
+ candidate.confidence !== "keyed" &&
4624
+ this.hasTerminalConflict(turn, false)
4625
+ ) {
4626
+ this.resetAgentEndProbe(turn);
4627
+ turn.deferredAgentEnd = candidate;
4628
+ this.pollUsage(turn);
4629
+ return;
4199
4630
  }
4200
4631
  if (state) {
4201
4632
  if (state.isStreaming || state.isCompacting) {
4202
- if (!turn.terminalOwnershipEvidence && !turn.terminalOwnershipRequired) {
4203
- const message = "OMP agent_end arrived while the native runtime remained active";
4204
- this.invalidateRuntime(message);
4205
- await this.finishTurn(turn, "failed", { message }, true, true);
4206
- return;
4207
- }
4208
- turn.agentEndPending = false;
4209
- turn.terminalizing = false;
4210
- turn.deferredAgentEnd = undefined;
4211
- return;
4212
- }
4213
- if (!turn.terminalOwnershipEvidence && turn.terminalOwnershipRequired) {
4214
- turn.agentEndPending = false;
4215
- turn.terminalizing = false;
4216
- turn.deferredAgentEnd = undefined;
4217
- this.scheduleTerminalOwnershipTimeout(turn);
4633
+ this.ignoreActiveTerminalCandidate(turn, candidate);
4218
4634
  return;
4219
4635
  }
4220
- await this.completeAgentEnd(turn, event);
4636
+ await this.completeAgentEnd(turn, candidate.event, true);
4221
4637
  return;
4222
4638
  }
4223
4639
  if (turn.userEchoObserved) {
@@ -4227,7 +4643,7 @@ export class OmpProviderSession {
4227
4643
  if (turn.agentEndRetryTimer === undefined) {
4228
4644
  turn.agentEndRetryTimer = this.scheduler.set(() => {
4229
4645
  turn.agentEndRetryTimer = undefined;
4230
- this.finishFromAgentEnd(turn, event);
4646
+ this.finishFromAgentEnd(turn, candidate);
4231
4647
  }, USAGE_POLL_MS);
4232
4648
  }
4233
4649
  }
@@ -4235,17 +4651,56 @@ export class OmpProviderSession {
4235
4651
  private async completeAgentEnd(
4236
4652
  turn: ActiveTurn,
4237
4653
  event: Extract<OmpRpcEvent, { type: "agent_end" }>,
4238
- usageSampled = false,
4654
+ providerIdle = false,
4239
4655
  ): Promise<void> {
4240
4656
  if (turn.terminal || this.activeTurn !== turn) return;
4241
- const error = terminalError(event, turn);
4242
- if (turn.interrupted) this.subsessions?.terminalize("canceled");
4243
- else if (error) this.subsessions?.terminalize("failed");
4244
- if (turn.interrupted) await this.finishTurn(turn, "canceled", undefined, usageSampled);
4245
- else if (error) await this.finishTurn(turn, "failed", { message: error }, usageSampled);
4246
- else await this.finishTurn(turn, "completed", undefined, usageSampled);
4657
+ let outcome = turn.interrupted ? ("canceled" as const) : terminalOutcome(event, turn);
4658
+ if (!outcome && providerIdle && event.messageCount !== undefined) {
4659
+ const runtime = this.runtime;
4660
+ const history = await this.readRuntimeHistoryWithTimeout(runtime);
4661
+ if (
4662
+ history &&
4663
+ history.length <= MAX_REPLAY_MESSAGES &&
4664
+ this.isCurrentRuntime(runtime, turn.generation) &&
4665
+ !turn.terminal &&
4666
+ !turn.interrupted &&
4667
+ this.activeTurn === turn
4668
+ ) {
4669
+ const state = await this.boundedTerminalState(turn, FINAL_USAGE_WAIT_MS);
4670
+ if (
4671
+ turn.terminal ||
4672
+ this.activeTurn !== turn ||
4673
+ !this.isCurrentRuntime(runtime, turn.generation)
4674
+ )
4675
+ return;
4676
+ if (!turn.interrupted && state && (state.isStreaming || state.isCompacting)) {
4677
+ this.ignoreActiveTerminalCandidate(turn, {
4678
+ event,
4679
+ arrivalSequence: turn.activitySequence,
4680
+ confidence: event.requestId === undefined ? "ordered-legacy" : "keyed",
4681
+ });
4682
+ return;
4683
+ }
4684
+ if (state && !state.isStreaming && !state.isCompacting) {
4685
+ outcome = historyTerminalOutcome(history, event.messageCount, turn, event.messages ?? []);
4686
+ }
4687
+ }
4688
+ }
4689
+ if (turn.terminal || this.activeTurn !== turn) return;
4690
+ if (turn.interrupted || outcome === "canceled") {
4691
+ this.subsessions?.terminalize("canceled");
4692
+ await this.finishTurn(turn, "canceled");
4693
+ return;
4694
+ }
4695
+ if (outcome === "completed") {
4696
+ await this.finishTurn(turn, "completed");
4697
+ return;
4698
+ }
4699
+ const error =
4700
+ outcome === "failed" ? "OMP assistant turn failed" : unknownTerminalOutcomeError(event, turn);
4701
+ this.subsessions?.terminalize("failed");
4702
+ await this.finishTurn(turn, "failed", { message: error });
4247
4703
  }
4248
-
4249
4704
  private publishPendingUsers(turn: ActiveTurn): void {
4250
4705
  for (const pending of turn.pendingUsers.splice(0)) {
4251
4706
  for (const echo of pending.bufferedEchoes) {
@@ -4283,6 +4738,18 @@ export class OmpProviderSession {
4283
4738
  pending.bufferedEchoes.length = 0;
4284
4739
  }
4285
4740
 
4741
+ private settleUnstartedTurn(turn: ActiveTurn): void {
4742
+ if (turn.started || turn.terminal) return;
4743
+ turn.steerReady.resolve();
4744
+ turn.starting = false;
4745
+ turn.terminal = true;
4746
+ this.stopUsagePoll(turn);
4747
+ this.cancelAmbiguousTerminal(turn);
4748
+ this.resolveTurnPermissions(turn.turnId);
4749
+ this.projector.finishTurn(turn.turnId);
4750
+ if (this.activeTurn === turn) this.activeTurn = null;
4751
+ }
4752
+
4286
4753
  private publishPromptResult(
4287
4754
  turn: ActiveTurn,
4288
4755
  result: Extract<ProviderEvent, { type: "session.prompt_result" }>["result"],
@@ -4308,7 +4775,7 @@ export class OmpProviderSession {
4308
4775
  private finishTurn(
4309
4776
  turn: ActiveTurn,
4310
4777
  state: "completed" | "failed" | "canceled",
4311
- error?: { message: string },
4778
+ error?: ProviderError,
4312
4779
  usageSampled = false,
4313
4780
  override = false,
4314
4781
  preserveCompactions = false,
@@ -4332,7 +4799,7 @@ export class OmpProviderSession {
4332
4799
  turn.manualCompactionPending = false;
4333
4800
  turn.agentEndPending = false;
4334
4801
  this.cancelLocalOnlyCompletion(turn);
4335
- this.cancelTerminalOwnershipTimeout(turn);
4802
+ this.cancelAmbiguousTerminal(turn);
4336
4803
  this.stopUsagePoll(turn);
4337
4804
  if (turn.manualCompactionDeadlineTimer !== undefined) {
4338
4805
  this.scheduler.clear(turn.manualCompactionDeadlineTimer);