@ouro.bot/cli 0.1.0-alpha.729 → 0.1.0-alpha.730

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.
@@ -68,7 +68,7 @@ const cli_logging_1 = require("../nerves/cli-logging");
68
68
  const runtime_1 = require("../nerves/runtime");
69
69
  const trust_gate_1 = require("./trust-gate");
70
70
  const pipeline_1 = require("./pipeline");
71
- const session_lock_1 = require("./session-lock");
71
+ const session_transaction_1 = require("../mind/session-transaction");
72
72
  const update_hooks_1 = require("../heart/versioning/update-hooks");
73
73
  const bundle_meta_1 = require("../heart/daemon/hooks/bundle-meta");
74
74
  const agent_config_v2_1 = require("../heart/daemon/hooks/agent-config-v2");
@@ -797,96 +797,102 @@ async function runCliSession(options) {
797
797
  continue;
798
798
  }
799
799
  }
800
- // Check for slash commands (legacy path only — pipeline handles commands for runTurn path)
801
- if (!options.runTurn) {
802
- const parsed = (0, commands_1.parseSlashCommand)(input);
803
- if (parsed) {
804
- const dispatchResult = registry.dispatch(parsed.command, { channel: "cli" });
805
- if (dispatchResult.handled && dispatchResult.result) {
806
- if (dispatchResult.result.action === "exit") {
807
- break;
800
+ await options.onTurnStart?.(messages);
801
+ try {
802
+ // Check for slash commands (legacy path only — pipeline handles commands for runTurn path)
803
+ if (!options.runTurn) {
804
+ const parsed = (0, commands_1.parseSlashCommand)(input);
805
+ if (parsed) {
806
+ const dispatchResult = registry.dispatch(parsed.command, { channel: "cli" });
807
+ if (dispatchResult.handled && dispatchResult.result) {
808
+ if (dispatchResult.result.action === "exit") {
809
+ break;
810
+ }
811
+ else if (dispatchResult.result.action === "response") {
812
+ display.text(dispatchResult.result.message || "");
813
+ continue;
814
+ }
808
815
  }
809
- else if (dispatchResult.result.action === "response") {
810
- display.text(dispatchResult.result.message || "");
816
+ }
817
+ }
818
+ // Track user message in TUI for display
819
+ if (tuiStore)
820
+ tuiStore.addUserMessage(input);
821
+ currentAbort = new AbortController();
822
+ if (tuiStore)
823
+ tuiStore.suppressInput();
824
+ inputCtrl?.suppress(() => { currentAbort?.abort(); });
825
+ // Resolve pending image content before the turn executes
826
+ let contentParts = null;
827
+ const currentImages = pendingImages;
828
+ if (currentImages && currentImages.size > 0) {
829
+ contentParts = await (0, image_paste_1.resolveImageContent)(input, currentImages);
830
+ pendingImages = null;
831
+ }
832
+ let result;
833
+ try {
834
+ if (options.runTurn) {
835
+ // Pipeline-based turn: the runTurn callback handles user message assembly,
836
+ // pending drain, trust gate, runAgent, postTurn, and token accumulation.
837
+ result = await options.runTurn(messages, input, cliCallbacks, currentAbort.signal, effectiveToolContext, contentParts ?? undefined);
838
+ // Handle pipeline-intercepted commands with loop-control side effects
839
+ if (result?.turnOutcome === "command") {
840
+ if (result.commandAction === "exit") {
841
+ break;
842
+ }
843
+ // For "response" commands: the pipeline already emitted the response via onTextChunk
844
+ cliCallbacks.flushMarkdown();
811
845
  continue;
812
846
  }
813
847
  }
848
+ else {
849
+ // Legacy path: inline runAgent (used by serpent guide and tests)
850
+ const prefix = options.getContentPrefix?.();
851
+ const userContent = contentParts
852
+ ? contentParts
853
+ : (prefix ? `${prefix}\n\n${input}` : input);
854
+ const userMsg = { role: "user", content: userContent };
855
+ (0, session_events_1.stampIngressTime)(userMsg);
856
+ messages.push(userMsg);
857
+ const traceId = (0, nerves_1.createTraceId)();
858
+ result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
859
+ toolChoiceRequired: getEffectiveToolChoiceRequired(),
860
+ traceId,
861
+ tools: options.tools,
862
+ execTool: wrappedExecTool,
863
+ toolContext: effectiveToolContext,
864
+ });
865
+ }
814
866
  }
815
- }
816
- // Track user message in TUI for display
817
- if (tuiStore)
818
- tuiStore.addUserMessage(input);
819
- currentAbort = new AbortController();
820
- if (tuiStore)
821
- tuiStore.suppressInput();
822
- inputCtrl?.suppress(() => { currentAbort?.abort(); });
823
- // Resolve pending image content before the turn executes
824
- let contentParts = null;
825
- const currentImages = pendingImages;
826
- if (currentImages && currentImages.size > 0) {
827
- contentParts = await (0, image_paste_1.resolveImageContent)(input, currentImages);
828
- pendingImages = null;
829
- }
830
- let result;
831
- try {
832
- if (options.runTurn) {
833
- // Pipeline-based turn: the runTurn callback handles user message assembly,
834
- // pending drain, trust gate, runAgent, postTurn, and token accumulation.
835
- result = await options.runTurn(messages, input, cliCallbacks, currentAbort.signal, effectiveToolContext, contentParts ?? undefined);
836
- // Handle pipeline-intercepted commands with loop-control side effects
837
- if (result?.turnOutcome === "command") {
838
- if (result.commandAction === "exit") {
839
- break;
840
- }
841
- // For "response" commands: the pipeline already emitted the response via onTextChunk
842
- cliCallbacks.flushMarkdown();
843
- continue;
867
+ catch (err) {
868
+ if (!(err instanceof DOMException && err.name === "AbortError")) {
869
+ display.error(err instanceof Error ? err.message : String(err));
844
870
  }
845
871
  }
846
- else {
847
- // Legacy path: inline runAgent (used by serpent guide and tests)
848
- const prefix = options.getContentPrefix?.();
849
- const userContent = contentParts
850
- ? contentParts
851
- : (prefix ? `${prefix}\n\n${input}` : input);
852
- const userMsg = { role: "user", content: userContent };
853
- (0, session_events_1.stampIngressTime)(userMsg);
854
- messages.push(userMsg);
855
- const traceId = (0, nerves_1.createTraceId)();
856
- result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
857
- toolChoiceRequired: getEffectiveToolChoiceRequired(),
858
- traceId,
859
- tools: options.tools,
860
- execTool: wrappedExecTool,
861
- toolContext: effectiveToolContext,
862
- });
872
+ cliCallbacks.flushMarkdown();
873
+ if (!tuiStore)
874
+ process.stdout.write("\n"); // ensure response ends with newline before prompt (imperative only)
875
+ if (tuiStore)
876
+ tuiStore.restoreInput();
877
+ inputCtrl?.restore();
878
+ currentAbort = null;
879
+ // Check if exit tool was fired during this turn
880
+ if (exitToolFired) {
881
+ exitReason = "tool_exit";
882
+ break;
863
883
  }
864
- }
865
- catch (err) {
866
- if (!(err instanceof DOMException && err.name === "AbortError")) {
867
- display.error(err instanceof Error ? err.message : String(err));
884
+ // Safety net: never silently swallow an empty response
885
+ const lastMsg = messages[messages.length - 1];
886
+ if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
887
+ display.warn("(empty response)");
888
+ }
889
+ // Post-turn hook (session persistence, pending drain, prompt refresh, etc.)
890
+ if (options.onTurnEnd) {
891
+ await options.onTurnEnd(messages, result ?? { usage: undefined });
868
892
  }
869
893
  }
870
- cliCallbacks.flushMarkdown();
871
- if (!tuiStore)
872
- process.stdout.write("\n"); // ensure response ends with newline before prompt (imperative only)
873
- if (tuiStore)
874
- tuiStore.restoreInput();
875
- inputCtrl?.restore();
876
- currentAbort = null;
877
- // Check if exit tool was fired during this turn
878
- if (exitToolFired) {
879
- exitReason = "tool_exit";
880
- break;
881
- }
882
- // Safety net: never silently swallow an empty response
883
- const lastMsg = messages[messages.length - 1];
884
- if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
885
- display.warn("(empty response)");
886
- }
887
- // Post-turn hook (session persistence, pending drain, prompt refresh, etc.)
888
- if (options.onTurnEnd) {
889
- await options.onTurnEnd(messages, result ?? { usage: undefined });
894
+ finally {
895
+ await options.onTurnFinish?.();
890
896
  }
891
897
  if (closed)
892
898
  break;
@@ -963,149 +969,163 @@ async function main(agentName, options) {
963
969
  sinks: agentConfig.logging?.sinks,
964
970
  });
965
971
  const sessPath = (0, config_1.sessionPath)(friendId, "cli", "session");
966
- let sessionLock = null;
967
- try {
968
- sessionLock = (0, session_lock_1.acquireSessionLock)(`${sessPath}.lock`, (0, identity_1.getAgentName)());
969
- }
970
- catch (error) {
971
- /* v8 ignore start -- integration: main() is interactive, lock tested in session-lock.test.ts @preserve */
972
- if (error instanceof session_lock_1.SessionLockError) {
973
- process.stderr.write(`${error.message}\n`);
974
- return;
975
- }
976
- throw error;
977
- /* v8 ignore stop */
978
- }
979
- // Load existing session or start fresh
980
- const existing = (0, context_1.loadSession)(sessPath);
981
- let sessionState = existing?.state;
982
- let sessionEvents = existing?.events ?? [];
972
+ const acquireTurnLease = options?._acquireSessionTurnLease ?? session_transaction_1.acquireSessionTurnLease;
973
+ let sessionTurnLease = null;
974
+ let pendingPersists = [];
975
+ let existing = null;
976
+ let sessionState;
977
+ let sessionEvents = [];
983
978
  const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
984
- const sessionMessages = existing?.messages && existing.messages.length > 0
985
- ? existing.messages
986
- : [{ role: "system", content: (0, prompt_1.flattenSystemPrompt)(await (0, prompt_1.buildSystem)("cli", {}, resolvedContext)) }];
987
- // Repair any orphaned tool calls from a crash mid-turn
988
- (0, core_1.repairOrphanedToolCalls)(sessionMessages);
979
+ const sessionMessages = [
980
+ { role: "system", content: (0, prompt_1.flattenSystemPrompt)(await (0, prompt_1.buildSystem)("cli", {}, resolvedContext)) },
981
+ ];
989
982
  // Per-turn pipeline input: CLI capabilities and pending dir
990
983
  const cliCapabilities = (0, friends_1.getChannelCapabilities)("cli");
991
984
  const currentAgentName = (0, identity_1.getAgentName)();
992
985
  const pendingDir = (0, pending_1.getPendingDir)(currentAgentName, friendId, "cli", "session");
993
986
  const summarize = (0, core_1.createSummarize)("human");
994
987
  const cliFailoverState = { pending: null };
995
- try {
996
- await runCliSession({
997
- agentName: currentAgentName,
998
- pasteDebounceMs,
999
- messages: sessionMessages,
1000
- lastActivityAt: sessionState?.lastFriendActivityAt,
1001
- _testInputSource: options?._testInputSource,
1002
- onAsyncAssistantMessage: async (messages, _assistantMessage) => {
1003
- const prepared = (0, context_1.postTurnTrim)(messages);
1004
- const events = (0, context_1.postTurnPersist)(sessPath, prepared, undefined, sessionState);
1005
- /* v8 ignore next -- defensive: postTurnPersist always returns events in practice @preserve */
1006
- sessionEvents = events.length > 0 ? events : sessionEvents;
1007
- },
1008
- runTurn: async (messages, userInput, callbacks, signal, toolContext, userContent) => {
1009
- // Run the full per-turn pipeline: resolve -> gate -> session -> drain -> runAgent -> postTurn -> tokens
1010
- // User message passed via input.messages so the pipeline can prepend pending messages to it.
1011
- const failoverState = cliFailoverState;
1012
- // Capture terminal errors instead of displaying immediately — the failover
1013
- // message replaces the raw error if failover triggers successfully.
1014
- let capturedTerminalError = null;
1015
- /* v8 ignore start -- failover-aware callback wrapper: tested via pipeline integration @preserve */
1016
- const failoverAwareCallbacks = {
1017
- ...callbacks,
1018
- // Save session after each tool result for crash recovery (deferred to avoid blocking)
1019
- onToolResult: (turnMessages) => {
1020
- const prepared = (0, context_1.postTurnTrim)(turnMessages);
1021
- (0, context_1.deferPostTurnPersist)(sessPath, prepared, undefined, sessionState);
1022
- },
1023
- onError: (error, severity) => {
1024
- if (severity === "terminal" && failoverState) {
1025
- capturedTerminalError = error;
1026
- callbacks.onError(new Error(""), "transient");
1027
- return;
1028
- }
1029
- callbacks.onError(error, severity);
1030
- },
1031
- };
1032
- /* v8 ignore stop */
1033
- const result = await (0, pipeline_1.handleInboundTurn)({
1034
- channel: "cli",
1035
- sessionKey: "session",
1036
- capabilities: cliCapabilities,
1037
- messages: [{ role: "user", content: userContent ?? userInput }],
1038
- continuityIngressTexts: getCliContinuityIngressTexts(userInput),
1039
- callbacks: failoverAwareCallbacks,
1040
- friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
1041
- sessionLoader: {
1042
- loadOrCreate: () => Promise.resolve({
1043
- messages,
1044
- sessionPath: sessPath,
1045
- state: sessionState,
1046
- events: sessionEvents,
1047
- structuredOutputs: existing?.structuredOutputs,
1048
- }),
1049
- },
1050
- pendingDir,
1051
- friendStore,
1052
- provider: "local",
1053
- externalId: localExternalId,
1054
- enforceTrustGate: trust_gate_1.enforceTrustGate,
1055
- drainPending: pending_1.drainPending,
1056
- drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(currentAgentName, deferredFriendId),
1057
- runAgent: (msgs, cb, channel, sig, opts) => (0, core_1.runAgent)(msgs, cb, channel, sig, {
1058
- ...opts,
1059
- toolContext: {
1060
- /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
1061
- signin: async () => undefined,
1062
- ...opts?.toolContext,
1063
- summarize,
1064
- },
988
+ await runCliSession({
989
+ agentName: currentAgentName,
990
+ pasteDebounceMs,
991
+ messages: sessionMessages,
992
+ lastActivityAt: sessionState?.lastFriendActivityAt,
993
+ _testInputSource: options?._testInputSource,
994
+ onTurnStart: async (messages) => {
995
+ sessionTurnLease = await acquireTurnLease(sessPath);
996
+ try {
997
+ pendingPersists = [];
998
+ existing = (0, context_1.loadSession)(sessPath);
999
+ sessionState = existing?.state;
1000
+ sessionEvents = existing?.events ?? [];
1001
+ const loadedMessages = existing?.messages && existing.messages.length > 0
1002
+ ? existing.messages
1003
+ : [{ role: "system", content: (0, prompt_1.flattenSystemPrompt)(await (0, prompt_1.buildSystem)("cli", {}, resolvedContext)) }];
1004
+ (0, core_1.repairOrphanedToolCalls)(loadedMessages);
1005
+ messages.splice(0, messages.length, ...loadedMessages);
1006
+ }
1007
+ catch (error) {
1008
+ const lease = sessionTurnLease;
1009
+ sessionTurnLease = null;
1010
+ await lease.release();
1011
+ throw error;
1012
+ }
1013
+ },
1014
+ onTurnFinish: async () => {
1015
+ try {
1016
+ await Promise.all(pendingPersists);
1017
+ }
1018
+ finally {
1019
+ const lease = sessionTurnLease;
1020
+ sessionTurnLease = null;
1021
+ await lease.release();
1022
+ }
1023
+ },
1024
+ onAsyncAssistantMessage: async (messages, _assistantMessage) => {
1025
+ const prepared = (0, context_1.postTurnTrim)(messages);
1026
+ const events = (0, context_1.postTurnPersist)(sessPath, prepared, undefined, sessionState, sessionTurnLease ?? undefined);
1027
+ /* v8 ignore next -- defensive: postTurnPersist always returns events in practice @preserve */
1028
+ sessionEvents = events.length > 0 ? events : sessionEvents;
1029
+ },
1030
+ runTurn: async (messages, userInput, callbacks, signal, toolContext, userContent) => {
1031
+ // Run the full per-turn pipeline: resolve -> gate -> session -> drain -> runAgent -> postTurn -> tokens
1032
+ // User message passed via input.messages so the pipeline can prepend pending messages to it.
1033
+ const failoverState = cliFailoverState;
1034
+ // Capture terminal errors instead of displaying immediately — the failover
1035
+ // message replaces the raw error if failover triggers successfully.
1036
+ let capturedTerminalError = null;
1037
+ /* v8 ignore start -- failover-aware callback wrapper: tested via pipeline integration @preserve */
1038
+ const failoverAwareCallbacks = {
1039
+ ...callbacks,
1040
+ // Save session after each tool result for crash recovery (deferred to avoid blocking)
1041
+ onToolResult: (turnMessages) => {
1042
+ const prepared = (0, context_1.postTurnTrim)(turnMessages);
1043
+ pendingPersists.push((0, context_1.deferPostTurnPersist)(sessPath, prepared, undefined, sessionState, sessionTurnLease ?? undefined));
1044
+ },
1045
+ onError: (error, severity) => {
1046
+ if (severity === "terminal" && failoverState) {
1047
+ capturedTerminalError = error;
1048
+ callbacks.onError(new Error(""), "transient");
1049
+ return;
1050
+ }
1051
+ callbacks.onError(error, severity);
1052
+ },
1053
+ };
1054
+ /* v8 ignore stop */
1055
+ const result = await (0, pipeline_1.handleInboundTurn)({
1056
+ channel: "cli",
1057
+ sessionKey: "session",
1058
+ capabilities: cliCapabilities,
1059
+ messages: [{ role: "user", content: userContent ?? userInput }],
1060
+ continuityIngressTexts: getCliContinuityIngressTexts(userInput),
1061
+ callbacks: failoverAwareCallbacks,
1062
+ sessionTurnLease: sessionTurnLease,
1063
+ friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
1064
+ sessionLoader: {
1065
+ loadOrCreate: () => Promise.resolve({
1066
+ messages,
1067
+ sessionPath: sessPath,
1068
+ state: sessionState,
1069
+ events: sessionEvents,
1070
+ structuredOutputs: existing?.structuredOutputs,
1065
1071
  }),
1066
- postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
1067
- // Trim synchronously (mutates turnMessages for next turn),
1068
- // then defer envelope build + disk I/O to avoid blocking the TUI.
1069
- const prepared = (0, context_1.postTurnTrim)(turnMessages, usage, hooks);
1070
- sessionState = state;
1071
- (0, context_1.deferPostTurnPersist)(sessionPathArg, prepared, usage, state).then((events) => {
1072
- /* v8 ignore next -- defensive: deferPostTurnPersist always resolves events in practice @preserve */
1073
- sessionEvents = events.length > 0 ? events : sessionEvents;
1074
- });
1072
+ },
1073
+ pendingDir,
1074
+ friendStore,
1075
+ provider: "local",
1076
+ externalId: localExternalId,
1077
+ enforceTrustGate: trust_gate_1.enforceTrustGate,
1078
+ drainPending: pending_1.drainPending,
1079
+ drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(currentAgentName, deferredFriendId),
1080
+ runAgent: (msgs, cb, channel, sig, opts) => (0, core_1.runAgent)(msgs, cb, channel, sig, {
1081
+ ...opts,
1082
+ toolContext: {
1083
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
1084
+ signin: async () => undefined,
1085
+ ...opts?.toolContext,
1086
+ summarize,
1075
1087
  },
1076
- accumulateFriendTokens: friends_1.accumulateFriendTokens,
1077
- signal,
1078
- runAgentOptions: {
1079
- toolChoiceRequired: (0, commands_1.getToolChoiceRequired)(),
1080
- traceId: (0, nerves_1.createTraceId)(),
1081
- mcpManager,
1082
- toolContext,
1083
- },
1084
- failoverState,
1085
- });
1086
- /* v8 ignore start -- failover display: tested via pipeline integration tests @preserve */
1087
- if (result.failoverMessage) {
1088
- // Failover handled it — show the actionable message instead of the raw error
1089
- process.stdout.write(`\x1b[33m${result.failoverMessage}\x1b[0m\n`);
1090
- }
1091
- else if (capturedTerminalError) {
1092
- // Failover didn't trigger (no failoverState, or sequence failed) — show the raw error
1093
- process.stderr.write(`\x1b[31m${(0, format_1.formatError)(capturedTerminalError)}\x1b[0m\n`);
1094
- }
1095
- /* v8 ignore stop */
1096
- // Handle gate rejection: display auto-reply if present
1097
- if (!result.gateResult.allowed) {
1098
- if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
1099
- process.stdout.write(`${result.gateResult.autoReply}\n`);
1100
- }
1088
+ }),
1089
+ postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
1090
+ // Trim synchronously (mutates turnMessages for next turn),
1091
+ // then defer envelope build + disk I/O to avoid blocking the TUI.
1092
+ const prepared = (0, context_1.postTurnTrim)(turnMessages, usage, hooks);
1093
+ sessionState = state;
1094
+ const persist = (0, context_1.deferPostTurnPersist)(sessionPathArg, prepared, usage, state, sessionTurnLease ?? undefined).then((events) => {
1095
+ /* v8 ignore next -- defensive: deferPostTurnPersist always resolves events in practice @preserve */
1096
+ sessionEvents = events.length > 0 ? events : sessionEvents;
1097
+ });
1098
+ pendingPersists.push(persist);
1099
+ },
1100
+ accumulateFriendTokens: friends_1.accumulateFriendTokens,
1101
+ signal,
1102
+ runAgentOptions: {
1103
+ toolChoiceRequired: (0, commands_1.getToolChoiceRequired)(),
1104
+ traceId: (0, nerves_1.createTraceId)(),
1105
+ mcpManager,
1106
+ toolContext,
1107
+ },
1108
+ failoverState,
1109
+ });
1110
+ /* v8 ignore start -- failover display: tested via pipeline integration tests @preserve */
1111
+ if (result.failoverMessage) {
1112
+ // Failover handled it — show the actionable message instead of the raw error
1113
+ process.stdout.write(`\x1b[33m${result.failoverMessage}\x1b[0m\n`);
1114
+ }
1115
+ else if (capturedTerminalError) {
1116
+ // Failover didn't trigger (no failoverState, or sequence failed) — show the raw error
1117
+ process.stderr.write(`\x1b[31m${(0, format_1.formatError)(capturedTerminalError)}\x1b[0m\n`);
1118
+ }
1119
+ /* v8 ignore stop */
1120
+ // Handle gate rejection: display auto-reply if present
1121
+ if (!result.gateResult.allowed) {
1122
+ if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
1123
+ process.stdout.write(`${result.gateResult.autoReply}\n`);
1101
1124
  }
1102
- return { usage: result.usage, turnOutcome: result.turnOutcome, commandAction: result.commandAction };
1103
- },
1104
- });
1105
- }
1106
- finally {
1107
- sessionLock?.release();
1108
- }
1125
+ }
1126
+ return { usage: result.usage, turnOutcome: result.turnOutcome, commandAction: result.commandAction };
1127
+ },
1128
+ });
1109
1129
  // Force exit: lingering handles (Ink cleanup timers, MCP connections) keep the
1110
1130
  // event loop alive after the interactive session ends. This is safe because all
1111
1131
  // session persistence has already completed in the finally block above.
@@ -690,6 +690,9 @@ async function handleInboundTurn(input) {
690
690
  }
691
691
  // Step 3: Load/create session
692
692
  const session = await input.sessionLoader.loadOrCreate();
693
+ if (input.sessionTurnLease && path.resolve(session.sessionPath) !== input.sessionTurnLease.sessionPath) {
694
+ throw new Error(`leased session path mismatch: expected ${input.sessionTurnLease.sessionPath}, loaded ${path.resolve(session.sessionPath)}`);
695
+ }
693
696
  const sessionMessages = session.messages;
694
697
  const sessionEvents = session.events ?? [];
695
698
  let mustResolveBeforeHandoff = (0, continuity_1.resolveMustResolveBeforeHandoff)(session.state?.mustResolveBeforeHandoff === true, input.continuityIngressTexts);
@@ -1001,6 +1004,26 @@ async function handleInboundTurn(input) {
1001
1004
  ...(result.error ? { errorName: result.error.name } : {}),
1002
1005
  ...(result.errorClassification ? { errorCode: result.errorClassification } : {}),
1003
1006
  }));
1007
+ if (result.outcome === "suspended") {
1008
+ if (!result.suspension)
1009
+ throw new Error("suspended agent turn omitted durable approval suspension");
1010
+ (0, runtime_1.emitNervesEvent)({
1011
+ component: "senses",
1012
+ event: "senses.pipeline_approval_suspended",
1013
+ message: "pipeline returned a durably checkpointed approval suspension without duplicate post-turn persistence",
1014
+ meta: { approvalId: result.suspension.approvalId, sessionPath: session.sessionPath },
1015
+ });
1016
+ return {
1017
+ resolvedContext,
1018
+ gateResult,
1019
+ usage: result.usage,
1020
+ turnOutcome: result.outcome,
1021
+ suspension: result.suspension,
1022
+ sessionPath: session.sessionPath,
1023
+ messages: sessionMessages,
1024
+ drainedPending: pending,
1025
+ };
1026
+ }
1004
1027
  // Step 5b: Failover on terminal error
1005
1028
  if (result.outcome === "errored" && input.failoverState) {
1006
1029
  try {