@songsid/agend 2.1.5-beta.14 → 2.1.5-beta.16

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.
package/dist/daemon.js CHANGED
@@ -24,6 +24,8 @@ import { PaneWriteLock } from "./pane-write-lock.js";
24
24
  import { buildFleetInstructions } from "./instructions.js";
25
25
  import { formatCrossInstanceInboundMessage, renderCrossInstanceHandoffMetadata, } from "./cross-instance-envelope.js";
26
26
  import { bottomRowIsReady, inputAreaText, inputShowsPastedText, pastedTextSignature, pasteLeftInInput, strandedAgendMessageInInput } from "./pane-input-residue.js";
27
+ import { TurnReplyGuard } from "./turn-reply-guard.js";
28
+ import { t } from "./locale.js";
27
29
  const __filename = fileURLToPath(import.meta.url);
28
30
  const __dirname = dirname(__filename);
29
31
  // Tool routing sets — module-level to avoid re-creation on every handleToolCall
@@ -34,6 +36,8 @@ const TASK_TOOL = "task";
34
36
  // Tools whose success proves the agent got a message out this turn. While any
35
37
  // of these succeeded, a dead-MCP proxy reply would double-post — suppress it.
36
38
  const TURN_REPLY_TOOLS = new Set(["reply", "send_to_instance", "report_result", "request_information", "delegate_task", "broadcast"]);
39
+ const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
40
+ const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. Use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, send a brief acknowledgement. Do not reply to this system instruction except through the reply tool.";
37
41
  /** Point a resumed CLI at its one backend-native instruction source. */
38
42
  export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
39
43
  const source = binaryName === "codex" || binaryName === "grok"
@@ -377,9 +381,10 @@ export class PendingWorkTracker {
377
381
  // An async pane poll can finish after a newer inbound. Do not let its stale
378
382
  // observation clear work which had not arrived when the pane was captured.
379
383
  if (now < this.lastInboundAt)
380
- return;
384
+ return false;
381
385
  this.lastIdleAt = now;
382
386
  this.lastIdleOrder = ++this.sequence;
387
+ return true;
383
388
  }
384
389
  hasPendingWork() {
385
390
  return this.lastInboundOrder > this.lastIdleOrder;
@@ -882,19 +887,14 @@ export class Daemon extends EventEmitter {
882
887
  instanceStateMonitorActive = false;
883
888
  sessionCheckpointWarningEmitted = false;
884
889
  statePollInFlight = false;
885
- // ── Dead-MCP proxy reply: per-turn evidence ─────────────────────────────
886
- // Set when an inbound message lands in the CLI; cleared on the idle edge that
887
- // ends the turn. With a dead MCP server and no successful channel tool call in
888
- // between, the agent's answer exists only on screen — the daemon relays it.
889
- turnHadInbound = false;
890
- turnOutboundDelivered = false;
891
- /** Successful `reply` specifically; cross-instance tools do not satisfy #648. */
892
- turnReplyDelivered = false;
893
- turnCorrelationId;
894
- turnInboundMarker;
890
+ // One generation-scoped source of truth for human-turn reply completion.
891
+ // It also supplies the older dead-MCP/malformed-call recovery paths, so those
892
+ // paths cannot disagree about whether the turn already spoke to the channel.
893
+ turnReplyGuard = new TurnReplyGuard();
895
894
  /** Prevent a visible stale XML fragment from being recovered on later turns. */
896
895
  lastMalformedToolCallSignature;
897
896
  proxyReplySeq = 0;
897
+ lastReplyDropWarningAt = 0;
898
898
  autoPauseController;
899
899
  pauseRequested = false;
900
900
  /**
@@ -2519,6 +2519,7 @@ export class Daemon extends EventEmitter {
2519
2519
  }
2520
2520
  async stop() {
2521
2521
  this.logger.info("Stopping daemon instance");
2522
+ this.turnReplyGuard.reset();
2522
2523
  this.freezeRuntimeMonitors();
2523
2524
  this.pendingIpcRequests.clear();
2524
2525
  if (this.adapter)
@@ -2812,7 +2813,7 @@ export class Daemon extends EventEmitter {
2812
2813
  // Only a transition back to idle completes pending work. Repeated idle
2813
2814
  // observations between enqueue and paste must not clear a newer inbound.
2814
2815
  if (snapshot.state === "idle" && previous !== "idle") {
2815
- this.pendingWork.recordIdle(snapshot.observedAt);
2816
+ const acceptedIdle = this.pendingWork.recordIdle(snapshot.observedAt);
2816
2817
  // The turn is over. A transcript can end on a tool_use with no matching
2817
2818
  // tool_result (interrupted, crashed, cancelled), which would otherwise leave
2818
2819
  // the last tool pinned to the progress line for the rest of the session.
@@ -2820,7 +2821,8 @@ export class Daemon extends EventEmitter {
2820
2821
  this.resetToolProgress();
2821
2822
  // Must run before the mcpRestartPending branch below: the pane text is the
2822
2823
  // only copy of the answer, and the revival restart is about to clear it.
2823
- this.maybeProxyReplyOnTurnEnd(pane);
2824
+ if (acceptedIdle)
2825
+ this.maybeProxyReplyOnTurnEnd(pane);
2824
2826
  }
2825
2827
  if (snapshot.state !== previous) {
2826
2828
  this.logger.info({
@@ -2871,13 +2873,17 @@ export class Daemon extends EventEmitter {
2871
2873
  // a task result, and a stale one (sol's review of #515).
2872
2874
  if (meta.from_instance || !meta.chat_id)
2873
2875
  return;
2874
- this.turnHadInbound = true;
2875
- this.turnOutboundDelivered = false;
2876
- this.turnReplyDelivered = false;
2877
- this.turnCorrelationId = meta.correlation_id || undefined;
2878
2876
  // The last non-empty line of what we pasted: everything on screen after it
2879
2877
  // is the agent's own output.
2880
- this.turnInboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
2878
+ const inboundMarker = deliveredText.split(/\r?\n/).map(l => l.trim()).filter(Boolean).pop();
2879
+ this.turnReplyGuard.arm({
2880
+ adapterId: meta.adapter_id || undefined,
2881
+ chatId: meta.chat_id,
2882
+ threadId: meta.thread_id || undefined,
2883
+ messageId: meta.message_id || undefined,
2884
+ correlationId: meta.correlation_id || undefined,
2885
+ inboundMarker,
2886
+ });
2881
2887
  }
2882
2888
  /**
2883
2889
  * Turn ended (busy→idle edge): if the MCP server is dead and none of the
@@ -2887,56 +2893,197 @@ export class Daemon extends EventEmitter {
2887
2893
  * here (edge-triggered, then reset) is what makes it at most once per turn.
2888
2894
  */
2889
2895
  maybeProxyReplyOnTurnEnd(pane) {
2890
- const hadInbound = this.turnHadInbound;
2891
- const delivered = this.turnOutboundDelivered;
2892
- const replyDelivered = this.turnReplyDelivered;
2893
- const correlationId = this.turnCorrelationId;
2894
- const inboundMarker = this.turnInboundMarker;
2895
- this.turnHadInbound = false;
2896
- this.turnOutboundDelivered = false;
2897
- this.turnReplyDelivered = false;
2898
- this.turnCorrelationId = undefined;
2899
- this.turnInboundMarker = undefined;
2900
- if (!hadInbound || this.isPaused)
2896
+ const turn = this.turnReplyGuard.snapshot();
2897
+ if (!turn || this.isPaused)
2898
+ return;
2899
+ if (turn.replyDelivered) {
2900
+ if (turn.phase === "recovering") {
2901
+ this.emit("reply_drop_recovered", {
2902
+ name: this.name,
2903
+ correlationId: turn.target.correlationId,
2904
+ generation: turn.generation,
2905
+ });
2906
+ }
2907
+ this.turnReplyGuard.complete(turn.generation);
2901
2908
  return;
2909
+ }
2910
+ // A second idle edge ends the one permitted recovery turn. Never create a
2911
+ // third turn or guess at terminal text; make the failure visible instead.
2912
+ if (turn.phase === "recovering") {
2913
+ this.turnReplyGuard.complete(turn.generation);
2914
+ this.reportUnrecoveredReplyDrop(turn);
2915
+ return;
2916
+ }
2902
2917
  // Claude sometimes prints a broken XML tool call as plain text and returns
2903
2918
  // idle without ever invoking `reply`. This is independent of MCP liveness:
2904
2919
  // the model malformed the call before the server could receive it.
2905
- if (!replyDelivered && this.isClaudeCodeBackend() && pane) {
2906
- const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker });
2920
+ if (this.isClaudeCodeBackend() && pane) {
2921
+ const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker: turn.target.inboundMarker });
2907
2922
  if (malformed) {
2908
2923
  const stale = malformed.signature === this.lastMalformedToolCallSignature;
2909
2924
  this.lastMalformedToolCallSignature = malformed.signature;
2910
2925
  if (!stale) {
2911
- const recovered = malformed.text != null;
2912
- this.logger.warn({ correlationId, recovered }, recovered
2926
+ this.logger.warn({ correlationId: turn.target.correlationId, extractable: malformed.text != null }, malformed.text
2913
2927
  ? "Malformed Claude tool call detected — attempting to recover reply text"
2914
2928
  : "Malformed Claude tool call detected — reply text could not be extracted");
2915
- this.emit("malformed_tool_call", { name: this.name, correlationId, recovered });
2916
- if (malformed.text)
2917
- this.sendRecoveredMalformedReply(malformed.text, correlationId);
2929
+ if (malformed.text) {
2930
+ this.queueMalformedReplyRecovery(turn, malformed.text);
2931
+ }
2932
+ else if (this.replyCompletionGuardEnabled() && this.mcpServerAlive().alive) {
2933
+ this.emit("malformed_tool_call", {
2934
+ name: this.name,
2935
+ correlationId: turn.target.correlationId,
2936
+ recovered: false,
2937
+ recoveryStarted: true,
2938
+ });
2939
+ this.startReplyRecovery(turn, "malformed_call");
2940
+ }
2941
+ else {
2942
+ this.emit("malformed_tool_call", {
2943
+ name: this.name,
2944
+ correlationId: turn.target.correlationId,
2945
+ recovered: false,
2946
+ });
2947
+ this.turnReplyGuard.complete(turn.generation);
2948
+ }
2949
+ }
2950
+ else {
2951
+ this.turnReplyGuard.complete(turn.generation);
2918
2952
  }
2919
2953
  // Never let the broader dead-MCP fallback relay the XML/chrome too.
2920
2954
  return;
2921
2955
  }
2922
2956
  }
2923
- if (delivered)
2957
+ // Preserve the dead-MCP proxy path and its explicit opt-in. Other outbound
2958
+ // tools still suppress that proxy, but only a delivered `reply` satisfies
2959
+ // the human-facing completion guard below.
2960
+ if (!turn.outboundDelivered && this.config.mcp_proxy_reply === true
2961
+ && mcpServerState(this.instanceDir).state === "dead") {
2962
+ this.turnReplyGuard.complete(turn.generation);
2963
+ void this.sendProxyReply(pane, turn.target);
2924
2964
  return;
2925
- // Opt-in: raw pane text can carry secrets past the regex redaction.
2926
- if (this.config.mcp_proxy_reply !== true)
2965
+ }
2966
+ if (!this.replyCompletionGuardEnabled() || !this.mcpServerAlive().alive) {
2967
+ this.turnReplyGuard.complete(turn.generation);
2927
2968
  return;
2928
- // "dead" only: unknown means not started or exited cleanly — never proxy on it.
2929
- if (mcpServerState(this.instanceDir).state !== "dead")
2969
+ }
2970
+ if (turn.replyAttempted) {
2971
+ // A provider timeout can be "applied, then timed out". Retrying it would
2972
+ // risk a duplicate; report the unknown result and stop here.
2973
+ this.turnReplyGuard.complete(turn.generation);
2974
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
2930
2975
  return;
2931
- void this.sendProxyReply(pane, inboundMarker, correlationId);
2976
+ }
2977
+ this.startReplyRecovery(turn, "no_valid_call");
2932
2978
  }
2933
2979
  isClaudeCodeBackend() {
2934
2980
  return this.runtimeIdentity?.backend === "claude-code"
2935
2981
  || this.config.backend === "claude-code"
2936
2982
  || this.backend?.binaryName === "claude";
2937
2983
  }
2984
+ replyCompletionGuardEnabled() {
2985
+ return this.backend?.replyCompletionGuard === true;
2986
+ }
2987
+ startReplyRecovery(turn, reason) {
2988
+ if (!this.turnReplyGuard.beginRecovery(turn.generation))
2989
+ return;
2990
+ this.emit("reply_drop_detected", {
2991
+ name: this.name,
2992
+ correlationId: turn.target.correlationId,
2993
+ generation: turn.generation,
2994
+ reason,
2995
+ recoveryStarted: true,
2996
+ });
2997
+ void this.deliverDaemonReply(t("inst.reply_drop_retrying"), "replydrop", "Reply-drop status", turn.target, true);
2998
+ this.queueReplyRecoveryPrompt(turn);
2999
+ }
3000
+ reportReplyDropWithoutRetry(turn, reason) {
3001
+ this.emit("reply_drop_detected", {
3002
+ name: this.name,
3003
+ correlationId: turn.target.correlationId,
3004
+ generation: turn.generation,
3005
+ reason,
3006
+ recoveryStarted: false,
3007
+ });
3008
+ void this.deliverDaemonReply(t("inst.reply_drop_unknown"), "replydrop", "Unconfirmed reply status", turn.target, true);
3009
+ }
3010
+ queueReplyRecoveryPrompt(turn) {
3011
+ const deliveryEpoch = this.deliveryEpoch;
3012
+ this.pasteQueueDepth++;
3013
+ this.pasteLock = this.pasteLock.then(async () => {
3014
+ try {
3015
+ const current = this.turnReplyGuard.snapshot();
3016
+ if (!current || current.generation !== turn.generation || current.phase !== "recovering")
3017
+ return;
3018
+ if (current.replyDelivered) {
3019
+ this.turnReplyGuard.complete(turn.generation);
3020
+ this.emit("reply_drop_recovered", {
3021
+ name: this.name,
3022
+ correlationId: current.target.correlationId,
3023
+ generation: current.generation,
3024
+ });
3025
+ return;
3026
+ }
3027
+ const delivered = await this.deliverMessage(REPLY_RECOVERY_PROMPT, undefined, {
3028
+ deliveryEpoch,
3029
+ });
3030
+ if (!delivered) {
3031
+ this.turnReplyGuard.complete(turn.generation);
3032
+ this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
3033
+ }
3034
+ }
3035
+ finally {
3036
+ this.pasteQueueDepth--;
3037
+ }
3038
+ }).catch(err => {
3039
+ this.logger.error({ err: err.message }, "Reply-drop recovery prompt failed");
3040
+ this.turnReplyGuard.complete(turn.generation);
3041
+ this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
3042
+ });
3043
+ }
3044
+ queueMalformedReplyRecovery(turn, text) {
3045
+ // Serialize the delivery decision ahead of any new pane input. If direct
3046
+ // delivery is positively acknowledged, there is no recovery turn. If its
3047
+ // outcome is unknown, do not ask the model to duplicate it.
3048
+ const delivery = this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery", turn.target);
3049
+ this.pasteQueueDepth++;
3050
+ this.pasteLock = this.pasteLock.then(async () => {
3051
+ try {
3052
+ const delivered = await delivery;
3053
+ this.emit("malformed_tool_call", {
3054
+ name: this.name,
3055
+ correlationId: turn.target.correlationId,
3056
+ recovered: delivered,
3057
+ });
3058
+ this.turnReplyGuard.complete(turn.generation);
3059
+ if (!delivered)
3060
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
3061
+ }
3062
+ finally {
3063
+ this.pasteQueueDepth--;
3064
+ }
3065
+ }).catch(err => {
3066
+ this.logger.error({ err: err.message }, "Malformed tool-call recovery attempt failed");
3067
+ this.turnReplyGuard.complete(turn.generation);
3068
+ this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
3069
+ });
3070
+ }
3071
+ reportUnrecoveredReplyDrop(turn, reason = "recovery_turn_missing_reply") {
3072
+ this.emit("reply_drop_unrecovered", {
3073
+ name: this.name,
3074
+ correlationId: turn.target.correlationId,
3075
+ generation: turn.generation,
3076
+ reason,
3077
+ });
3078
+ const now = Date.now();
3079
+ if (this.lastReplyDropWarningAt !== 0
3080
+ && now - this.lastReplyDropWarningAt < REPLY_DROP_WARNING_COOLDOWN_MS)
3081
+ return;
3082
+ this.lastReplyDropWarningAt = now;
3083
+ void this.deliverDaemonReply(t("inst.reply_drop_unrecovered"), "replydropwarn", "Reply-drop recovery warning", turn.target, true);
3084
+ }
2938
3085
  /** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
2939
- async sendProxyReply(pane, inboundMarker, correlationId) {
3086
+ async sendProxyReply(pane, target) {
2940
3087
  try {
2941
3088
  // The idle capture normally hands its pane in; fall back only when the
2942
3089
  // edge came from a path without one.
@@ -2944,74 +3091,110 @@ export class Daemon extends EventEmitter {
2944
3091
  pane = await this.tmux?.capturePane();
2945
3092
  if (!pane)
2946
3093
  return;
2947
- const text = extractProxyReplyText(pane, { inboundMarker, readyPattern: this.instanceStateReadyPattern });
3094
+ const text = extractProxyReplyText(pane, { inboundMarker: target.inboundMarker, readyPattern: this.instanceStateReadyPattern });
2948
3095
  if (!text) {
2949
3096
  this.logger.debug("Dead-MCP proxy reply skipped — pane tail is trivial");
2950
3097
  return;
2951
3098
  }
2952
3099
  let body = `⚠️ [MCP unavailable — proxy reply]\n\n${text}`;
2953
- if (correlationId)
2954
- body += `\n\n(correlation_id: ${correlationId})`;
2955
- this.logger.warn({ correlationId }, "MCP server dead and the turn sent no reply — relaying the pane text as a proxy reply");
2956
- this.emit("mcp_proxy_reply", { name: this.name, correlationId });
2957
- this.deliverDaemonReply(body, "proxyreply", "Dead-MCP proxy reply");
3100
+ if (target.correlationId)
3101
+ body += `\n\n(correlation_id: ${target.correlationId})`;
3102
+ this.logger.warn({ correlationId: target.correlationId }, "MCP server dead and the turn sent no reply — attempting a pane-text proxy reply");
3103
+ const delivered = await this.deliverDaemonReply(body, "proxyreply", "Dead-MCP proxy reply", target);
3104
+ if (delivered)
3105
+ this.emit("mcp_proxy_reply", { name: this.name, correlationId: target.correlationId });
2958
3106
  }
2959
3107
  catch (err) {
2960
3108
  this.logger.error({ err: err.message }, "Dead-MCP proxy reply attempt failed");
2961
3109
  }
2962
3110
  }
2963
- /** Send a narrowly extracted #648 reply; operator notification is a separate event. */
2964
- sendRecoveredMalformedReply(text, correlationId) {
2965
- try {
2966
- this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery");
2967
- }
2968
- catch (err) {
2969
- this.logger.error({ err: err.message, correlationId }, "Malformed tool-call recovery attempt failed");
2970
- }
2971
- }
2972
3111
  /** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
2973
- deliverDaemonReply(body, requestPrefix, logLabel) {
3112
+ deliverDaemonReply(body, requestPrefix, logLabel, target, statusOnly = false) {
2974
3113
  const args = { text: body };
2975
- if (this.lastChatId) {
2976
- args.chat_id = this.lastChatId;
2977
- if (this.lastThreadId)
2978
- args.thread_id = this.lastThreadId;
3114
+ const chatId = target?.chatId ?? this.lastChatId;
3115
+ const threadId = target?.threadId ?? this.lastThreadId;
3116
+ const adapterId = target?.adapterId ?? this.lastAdapterId;
3117
+ if (chatId) {
3118
+ args.chat_id = chatId;
3119
+ if (threadId)
3120
+ args.thread_id = threadId;
2979
3121
  }
2980
3122
  const adapters = this.messageBus.getAllAdapters();
2981
3123
  if (adapters.length > 0) {
2982
- routeToolCall(adapters[0], "reply", args, this.lastThreadId, (_result, error) => {
2983
- if (error)
2984
- this.logger.error({ error }, `${logLabel} failed`);
2985
- else
2986
- this.logger.info(`${logLabel} delivered`);
3124
+ // With one adapter there is nothing to choose and adapters[0] is exact.
3125
+ // With several, "no adapter named" is a routing failure, not a default:
3126
+ // picking the first sends one world's chat id through another world's bot.
3127
+ const adapter = adapterId
3128
+ ? this.messageBus.getAdapter(adapterId)
3129
+ : (adapters.length === 1 ? adapters[0] : undefined);
3130
+ if (!adapter) {
3131
+ this.logger.error({ adapterId, adapterCount: adapters.length }, `${logLabel} failed — ${adapterId ? "target adapter is unavailable" : "no adapter bound to this chat context"}`);
3132
+ return Promise.resolve(false);
3133
+ }
3134
+ return new Promise(resolve => {
3135
+ let settled = false;
3136
+ const finish = (delivered, error) => {
3137
+ if (settled)
3138
+ return;
3139
+ settled = true;
3140
+ clearTimeout(timeout);
3141
+ if (delivered)
3142
+ this.logger.info(`${logLabel} delivered`);
3143
+ else
3144
+ this.logger.error({ error }, `${logLabel} failed`);
3145
+ resolve(delivered);
3146
+ };
3147
+ const timeout = setTimeout(() => finish(false, "adapter reply timed out"), daemonBudgetMs("reply"));
3148
+ timeout.unref?.();
3149
+ try {
3150
+ if (!routeToolCall(adapter, "reply", args, threadId, (result, error) => finish(!error && result != null, error))) {
3151
+ finish(false, "reply route unavailable");
3152
+ }
3153
+ }
3154
+ catch {
3155
+ finish(false, "adapter reply threw before returning a promise");
3156
+ }
2987
3157
  });
2988
- return;
2989
3158
  }
2990
3159
  if (!this.ipcServer) {
2991
3160
  this.logger.error(`${logLabel} failed — no adapter or fleet IPC route`);
2992
- return;
3161
+ return Promise.resolve(false);
2993
3162
  }
2994
- const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
2995
- const timeout = setTimeout(() => {
2996
- this.pendingIpcRequests.delete(fleetReqId);
2997
- this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
2998
- }, daemonBudgetMs("reply"));
2999
- timeout.unref?.();
3000
- // Register before broadcast: an in-process/fast adapter can answer in the
3001
- // same tick, and a waiter installed afterwards would miss that response.
3002
- this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
3003
- clearTimeout(timeout);
3004
- if (respMsg.error)
3005
- this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
3006
- else
3007
- this.logger.info(`${logLabel} delivered`);
3008
- });
3009
- this.ipcServer.broadcast({
3010
- type: "fleet_outbound",
3011
- tool: "reply",
3012
- args,
3013
- fleetRequestId: fleetReqId,
3014
- adapterId: this.lastAdapterId,
3163
+ return new Promise(resolve => {
3164
+ const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
3165
+ const timeout = setTimeout(() => {
3166
+ this.pendingIpcRequests.delete(fleetReqId);
3167
+ this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
3168
+ resolve(false);
3169
+ }, daemonBudgetMs("reply"));
3170
+ timeout.unref?.();
3171
+ // Register before broadcast: an in-process/fast adapter can answer in the
3172
+ // same tick, and a waiter installed afterwards would miss that response.
3173
+ this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
3174
+ clearTimeout(timeout);
3175
+ const delivered = !respMsg.error && respMsg.result != null;
3176
+ if (!delivered)
3177
+ this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
3178
+ else
3179
+ this.logger.info(`${logLabel} delivered`);
3180
+ resolve(delivered);
3181
+ });
3182
+ try {
3183
+ this.ipcServer.broadcast({
3184
+ type: "fleet_outbound",
3185
+ tool: "reply",
3186
+ args,
3187
+ fleetRequestId: fleetReqId,
3188
+ adapterId,
3189
+ statusOnly,
3190
+ });
3191
+ }
3192
+ catch {
3193
+ clearTimeout(timeout);
3194
+ this.pendingIpcRequests.delete(fleetReqId);
3195
+ this.logger.error(`${logLabel} failed — fleet IPC broadcast threw`);
3196
+ resolve(false);
3197
+ }
3015
3198
  });
3016
3199
  }
3017
3200
  clearInstanceStateIdleTimer() {
@@ -3668,8 +3851,7 @@ export class Daemon extends EventEmitter {
3668
3851
  this.pasteLock = this.pasteLock.then(async () => {
3669
3852
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3670
3853
  return;
3671
- if (await this.deliverMessage(rawText, undefined, { deliveryEpoch }))
3672
- this.markTurnStarted(meta, rawText);
3854
+ await this.deliverMessage(rawText, undefined, { deliveryEpoch });
3673
3855
  }).catch(err => {
3674
3856
  this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
3675
3857
  });
@@ -4841,14 +5023,21 @@ export class Daemon extends EventEmitter {
4841
5023
  if (this.socketSessionNames.get(socket) === this.name) {
4842
5024
  this.noteMcpProofOfLife("tool_call", this.socketPids.get(socket));
4843
5025
  }
5026
+ // A tool invocation is not delivery evidence. Capture which obligation it
5027
+ // belongs to now, then settle it only after the adapter/fleet responds.
5028
+ // Explicitly mapped sibling sessions must not satisfy this instance's turn;
5029
+ // an unmapped socket is retained for legacy/tests where mcp_ready was not
5030
+ // observed before the first tool call.
5031
+ const sourceSession = this.socketSessionNames.get(socket);
5032
+ const replyAttempt = TURN_REPLY_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
5033
+ ? this.turnReplyGuard.beginToolAttempt(tool === "reply")
5034
+ : null;
4844
5035
  // For now, log and respond. Full adapter routing will be wired in fleet manager.
4845
5036
  const respond = (result, error) => {
4846
5037
  // A message that verifiably went out stands down the dead-MCP proxy reply
4847
5038
  // for this turn: the agent proved it can still speak for itself.
4848
5039
  if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
4849
- this.turnOutboundDelivered = true;
4850
- if (tool === "reply")
4851
- this.turnReplyDelivered = true;
5040
+ this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
4852
5041
  }
4853
5042
  const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
4854
5043
  if (!sent) {
@@ -5039,7 +5228,15 @@ export class Daemon extends EventEmitter {
5039
5228
  });
5040
5229
  return;
5041
5230
  }
5042
- const adapter = adapters[0];
5231
+ // Same rule as deliverDaemonReply: one adapter is unambiguous, several with
5232
+ // no bound world is a routing failure rather than a first-wins guess.
5233
+ const adapter = this.lastAdapterId
5234
+ ? this.messageBus.getAdapter(this.lastAdapterId) ?? (adapters.length === 1 ? adapters[0] : undefined)
5235
+ : (adapters.length === 1 ? adapters[0] : undefined);
5236
+ if (!adapter) {
5237
+ respond(null, "No channel world bound to this chat context — awaiting an inbound message to establish it");
5238
+ return;
5239
+ }
5043
5240
  if (!routeToolCall(adapter, tool, args, this.lastThreadId, respond)) {
5044
5241
  respond(null, `Unknown tool: ${tool}`);
5045
5242
  }
@@ -5255,6 +5452,11 @@ export class Daemon extends EventEmitter {
5255
5452
  * into exactly the window this exists to close.
5256
5453
  */
5257
5454
  beginSpawn() {
5455
+ // A restarted CLI has no trustworthy turn edge for the process it replaced.
5456
+ // v1 deliberately does not persist obligations across restarts: missing one
5457
+ // warning is safer than treating the replacement's startup idle as the old
5458
+ // turn ending and injecting a stale recovery prompt.
5459
+ this.turnReplyGuard.reset();
5258
5460
  this.spawnDepth++;
5259
5461
  this.spawning = true;
5260
5462
  if (!this.spawnSettled) {
@@ -6018,12 +6220,21 @@ export class Daemon extends EventEmitter {
6018
6220
  updateLastChat(chatId, threadId, adapterId) {
6019
6221
  if (!chatId)
6020
6222
  return;
6223
+ const chatChanged = this.lastChatId !== chatId;
6021
6224
  this.lastChatId = chatId;
6022
6225
  // An unthreaded inbound must clear a previous topic rather than leaking it
6023
6226
  // into the next reply target.
6024
6227
  this.lastThreadId = threadId || undefined;
6025
6228
  if (adapterId)
6026
6229
  this.lastAdapterId = adapterId;
6230
+ else if (chatChanged) {
6231
+ // A NEW chat with no adapter named alongside it: the old adapter belonged
6232
+ // to the old chat, and keeping it asserts a pairing nobody supplied. That
6233
+ // is how one platform's chat id ends up being sent through another
6234
+ // platform's bot. Unknown is recoverable (a single-adapter fleet is
6235
+ // unambiguous anyway); a confidently wrong world is not.
6236
+ this.lastAdapterId = undefined;
6237
+ }
6027
6238
  try {
6028
6239
  writeFileSync(join(this.instanceDir, "last-chat.json"), JSON.stringify({ chatId: this.lastChatId, threadId: this.lastThreadId, adapterId: this.lastAdapterId }));
6029
6240
  }