@songsid/agend 2.1.5-beta.13 → 2.1.5-beta.15

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
  /**
@@ -1071,6 +1071,33 @@ export class Daemon extends EventEmitter {
1071
1071
  static errorPatternKey(ep) {
1072
1072
  return `${ep.type}:${ep.pattern.source}`;
1073
1073
  }
1074
+ /**
1075
+ * Undo the auth suspicion a pattern match armed, once the backend's token-free
1076
+ * probe has said the credentials are fine.
1077
+ *
1078
+ * emitErrorPattern arms these BEFORE the pty_error reaches the lifecycle, and
1079
+ * the lifecycle's "valid" verdict used to just drop the incident — leaving the
1080
+ * daemon permanently suspicious of an auth failure that never existed. That
1081
+ * state is not inert: authFailureUnresolved suppresses the stuck/hang
1082
+ * notification and holds MCP auto-restart (a later real MCP death is then read
1083
+ * as already-confirmed auth trouble rather than re-verified), and the recovery
1084
+ * gate suppresses further error detection until a ready pattern shows up.
1085
+ *
1086
+ * Only what an auth match armed is rolled back. If some other pattern has
1087
+ * since armed the recovery gate, that one is still live and stays.
1088
+ */
1089
+ clearSuspectedAuthFailure() {
1090
+ if (!this.authFailureUnresolved && !this.loginScreenReported)
1091
+ return false;
1092
+ this.authFailureUnresolved = false;
1093
+ this.loginScreenReported = false;
1094
+ if (this.lastDetectedErrorType === "auth_error") {
1095
+ this.clearErrorRecoveryGate();
1096
+ this.lastDetectedErrorType = null;
1097
+ }
1098
+ this.logger.info("Auth suspicion withdrawn — the token-free probe reports valid credentials");
1099
+ return true;
1100
+ }
1074
1101
  clearErrorRecoveryGate() {
1075
1102
  this.errorWaitingForRecovery = false;
1076
1103
  this.errorDetectedAt = 0;
@@ -2492,6 +2519,7 @@ export class Daemon extends EventEmitter {
2492
2519
  }
2493
2520
  async stop() {
2494
2521
  this.logger.info("Stopping daemon instance");
2522
+ this.turnReplyGuard.reset();
2495
2523
  this.freezeRuntimeMonitors();
2496
2524
  this.pendingIpcRequests.clear();
2497
2525
  if (this.adapter)
@@ -2785,7 +2813,7 @@ export class Daemon extends EventEmitter {
2785
2813
  // Only a transition back to idle completes pending work. Repeated idle
2786
2814
  // observations between enqueue and paste must not clear a newer inbound.
2787
2815
  if (snapshot.state === "idle" && previous !== "idle") {
2788
- this.pendingWork.recordIdle(snapshot.observedAt);
2816
+ const acceptedIdle = this.pendingWork.recordIdle(snapshot.observedAt);
2789
2817
  // The turn is over. A transcript can end on a tool_use with no matching
2790
2818
  // tool_result (interrupted, crashed, cancelled), which would otherwise leave
2791
2819
  // the last tool pinned to the progress line for the rest of the session.
@@ -2793,7 +2821,8 @@ export class Daemon extends EventEmitter {
2793
2821
  this.resetToolProgress();
2794
2822
  // Must run before the mcpRestartPending branch below: the pane text is the
2795
2823
  // only copy of the answer, and the revival restart is about to clear it.
2796
- this.maybeProxyReplyOnTurnEnd(pane);
2824
+ if (acceptedIdle)
2825
+ this.maybeProxyReplyOnTurnEnd(pane);
2797
2826
  }
2798
2827
  if (snapshot.state !== previous) {
2799
2828
  this.logger.info({
@@ -2844,13 +2873,17 @@ export class Daemon extends EventEmitter {
2844
2873
  // a task result, and a stale one (sol's review of #515).
2845
2874
  if (meta.from_instance || !meta.chat_id)
2846
2875
  return;
2847
- this.turnHadInbound = true;
2848
- this.turnOutboundDelivered = false;
2849
- this.turnReplyDelivered = false;
2850
- this.turnCorrelationId = meta.correlation_id || undefined;
2851
2876
  // The last non-empty line of what we pasted: everything on screen after it
2852
2877
  // is the agent's own output.
2853
- 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
+ });
2854
2887
  }
2855
2888
  /**
2856
2889
  * Turn ended (busy→idle edge): if the MCP server is dead and none of the
@@ -2860,56 +2893,197 @@ export class Daemon extends EventEmitter {
2860
2893
  * here (edge-triggered, then reset) is what makes it at most once per turn.
2861
2894
  */
2862
2895
  maybeProxyReplyOnTurnEnd(pane) {
2863
- const hadInbound = this.turnHadInbound;
2864
- const delivered = this.turnOutboundDelivered;
2865
- const replyDelivered = this.turnReplyDelivered;
2866
- const correlationId = this.turnCorrelationId;
2867
- const inboundMarker = this.turnInboundMarker;
2868
- this.turnHadInbound = false;
2869
- this.turnOutboundDelivered = false;
2870
- this.turnReplyDelivered = false;
2871
- this.turnCorrelationId = undefined;
2872
- this.turnInboundMarker = undefined;
2873
- if (!hadInbound || this.isPaused)
2896
+ const turn = this.turnReplyGuard.snapshot();
2897
+ if (!turn || this.isPaused)
2874
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);
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
+ }
2875
2917
  // Claude sometimes prints a broken XML tool call as plain text and returns
2876
2918
  // idle without ever invoking `reply`. This is independent of MCP liveness:
2877
2919
  // the model malformed the call before the server could receive it.
2878
- if (!replyDelivered && this.isClaudeCodeBackend() && pane) {
2879
- const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker });
2920
+ if (this.isClaudeCodeBackend() && pane) {
2921
+ const malformed = detectMalformedClaudeToolCall(pane, { inboundMarker: turn.target.inboundMarker });
2880
2922
  if (malformed) {
2881
2923
  const stale = malformed.signature === this.lastMalformedToolCallSignature;
2882
2924
  this.lastMalformedToolCallSignature = malformed.signature;
2883
2925
  if (!stale) {
2884
- const recovered = malformed.text != null;
2885
- this.logger.warn({ correlationId, recovered }, recovered
2926
+ this.logger.warn({ correlationId: turn.target.correlationId, extractable: malformed.text != null }, malformed.text
2886
2927
  ? "Malformed Claude tool call detected — attempting to recover reply text"
2887
2928
  : "Malformed Claude tool call detected — reply text could not be extracted");
2888
- this.emit("malformed_tool_call", { name: this.name, correlationId, recovered });
2889
- if (malformed.text)
2890
- 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);
2891
2952
  }
2892
2953
  // Never let the broader dead-MCP fallback relay the XML/chrome too.
2893
2954
  return;
2894
2955
  }
2895
2956
  }
2896
- 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);
2897
2964
  return;
2898
- // Opt-in: raw pane text can carry secrets past the regex redaction.
2899
- if (this.config.mcp_proxy_reply !== true)
2965
+ }
2966
+ if (!this.replyCompletionGuardEnabled() || !this.mcpServerAlive().alive) {
2967
+ this.turnReplyGuard.complete(turn.generation);
2900
2968
  return;
2901
- // "dead" only: unknown means not started or exited cleanly — never proxy on it.
2902
- 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");
2903
2975
  return;
2904
- void this.sendProxyReply(pane, inboundMarker, correlationId);
2976
+ }
2977
+ this.startReplyRecovery(turn, "no_valid_call");
2905
2978
  }
2906
2979
  isClaudeCodeBackend() {
2907
2980
  return this.runtimeIdentity?.backend === "claude-code"
2908
2981
  || this.config.backend === "claude-code"
2909
2982
  || this.backend?.binaryName === "claude";
2910
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
+ }
2911
3085
  /** Relay the pane's final text to the channel, marked as a daemon proxy reply. */
2912
- async sendProxyReply(pane, inboundMarker, correlationId) {
3086
+ async sendProxyReply(pane, target) {
2913
3087
  try {
2914
3088
  // The idle capture normally hands its pane in; fall back only when the
2915
3089
  // edge came from a path without one.
@@ -2917,74 +3091,105 @@ export class Daemon extends EventEmitter {
2917
3091
  pane = await this.tmux?.capturePane();
2918
3092
  if (!pane)
2919
3093
  return;
2920
- const text = extractProxyReplyText(pane, { inboundMarker, readyPattern: this.instanceStateReadyPattern });
3094
+ const text = extractProxyReplyText(pane, { inboundMarker: target.inboundMarker, readyPattern: this.instanceStateReadyPattern });
2921
3095
  if (!text) {
2922
3096
  this.logger.debug("Dead-MCP proxy reply skipped — pane tail is trivial");
2923
3097
  return;
2924
3098
  }
2925
3099
  let body = `⚠️ [MCP unavailable — proxy reply]\n\n${text}`;
2926
- if (correlationId)
2927
- body += `\n\n(correlation_id: ${correlationId})`;
2928
- this.logger.warn({ correlationId }, "MCP server dead and the turn sent no reply — relaying the pane text as a proxy reply");
2929
- this.emit("mcp_proxy_reply", { name: this.name, correlationId });
2930
- 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 });
2931
3106
  }
2932
3107
  catch (err) {
2933
3108
  this.logger.error({ err: err.message }, "Dead-MCP proxy reply attempt failed");
2934
3109
  }
2935
3110
  }
2936
- /** Send a narrowly extracted #648 reply; operator notification is a separate event. */
2937
- sendRecoveredMalformedReply(text, correlationId) {
2938
- try {
2939
- this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery");
2940
- }
2941
- catch (err) {
2942
- this.logger.error({ err: err.message, correlationId }, "Malformed tool-call recovery attempt failed");
2943
- }
2944
- }
2945
3111
  /** Context-bound reply path shared by dead-MCP and malformed-call recovery. */
2946
- deliverDaemonReply(body, requestPrefix, logLabel) {
3112
+ deliverDaemonReply(body, requestPrefix, logLabel, target, statusOnly = false) {
2947
3113
  const args = { text: body };
2948
- if (this.lastChatId) {
2949
- args.chat_id = this.lastChatId;
2950
- if (this.lastThreadId)
2951
- 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;
2952
3121
  }
2953
3122
  const adapters = this.messageBus.getAllAdapters();
2954
3123
  if (adapters.length > 0) {
2955
- routeToolCall(adapters[0], "reply", args, this.lastThreadId, (_result, error) => {
2956
- if (error)
2957
- this.logger.error({ error }, `${logLabel} failed`);
2958
- else
2959
- this.logger.info(`${logLabel} delivered`);
3124
+ const adapter = adapterId ? this.messageBus.getAdapter(adapterId) : adapters[0];
3125
+ if (!adapter) {
3126
+ this.logger.error({ adapterId }, `${logLabel} failed — target adapter is unavailable`);
3127
+ return Promise.resolve(false);
3128
+ }
3129
+ return new Promise(resolve => {
3130
+ let settled = false;
3131
+ const finish = (delivered, error) => {
3132
+ if (settled)
3133
+ return;
3134
+ settled = true;
3135
+ clearTimeout(timeout);
3136
+ if (delivered)
3137
+ this.logger.info(`${logLabel} delivered`);
3138
+ else
3139
+ this.logger.error({ error }, `${logLabel} failed`);
3140
+ resolve(delivered);
3141
+ };
3142
+ const timeout = setTimeout(() => finish(false, "adapter reply timed out"), daemonBudgetMs("reply"));
3143
+ timeout.unref?.();
3144
+ try {
3145
+ if (!routeToolCall(adapter, "reply", args, threadId, (result, error) => finish(!error && result != null, error))) {
3146
+ finish(false, "reply route unavailable");
3147
+ }
3148
+ }
3149
+ catch {
3150
+ finish(false, "adapter reply threw before returning a promise");
3151
+ }
2960
3152
  });
2961
- return;
2962
3153
  }
2963
3154
  if (!this.ipcServer) {
2964
3155
  this.logger.error(`${logLabel} failed — no adapter or fleet IPC route`);
2965
- return;
3156
+ return Promise.resolve(false);
2966
3157
  }
2967
- const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
2968
- const timeout = setTimeout(() => {
2969
- this.pendingIpcRequests.delete(fleetReqId);
2970
- this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
2971
- }, daemonBudgetMs("reply"));
2972
- timeout.unref?.();
2973
- // Register before broadcast: an in-process/fast adapter can answer in the
2974
- // same tick, and a waiter installed afterwards would miss that response.
2975
- this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
2976
- clearTimeout(timeout);
2977
- if (respMsg.error)
2978
- this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
2979
- else
2980
- this.logger.info(`${logLabel} delivered`);
2981
- });
2982
- this.ipcServer.broadcast({
2983
- type: "fleet_outbound",
2984
- tool: "reply",
2985
- args,
2986
- fleetRequestId: fleetReqId,
2987
- adapterId: this.lastAdapterId,
3158
+ return new Promise(resolve => {
3159
+ const fleetReqId = `${requestPrefix}_${++this.proxyReplySeq}`;
3160
+ const timeout = setTimeout(() => {
3161
+ this.pendingIpcRequests.delete(fleetReqId);
3162
+ this.logger.error(`${logLabel} timed out waiting for the fleet manager`);
3163
+ resolve(false);
3164
+ }, daemonBudgetMs("reply"));
3165
+ timeout.unref?.();
3166
+ // Register before broadcast: an in-process/fast adapter can answer in the
3167
+ // same tick, and a waiter installed afterwards would miss that response.
3168
+ this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
3169
+ clearTimeout(timeout);
3170
+ const delivered = !respMsg.error && respMsg.result != null;
3171
+ if (!delivered)
3172
+ this.logger.error({ error: respMsg.error }, `${logLabel} failed`);
3173
+ else
3174
+ this.logger.info(`${logLabel} delivered`);
3175
+ resolve(delivered);
3176
+ });
3177
+ try {
3178
+ this.ipcServer.broadcast({
3179
+ type: "fleet_outbound",
3180
+ tool: "reply",
3181
+ args,
3182
+ fleetRequestId: fleetReqId,
3183
+ adapterId,
3184
+ statusOnly,
3185
+ });
3186
+ }
3187
+ catch {
3188
+ clearTimeout(timeout);
3189
+ this.pendingIpcRequests.delete(fleetReqId);
3190
+ this.logger.error(`${logLabel} failed — fleet IPC broadcast threw`);
3191
+ resolve(false);
3192
+ }
2988
3193
  });
2989
3194
  }
2990
3195
  clearInstanceStateIdleTimer() {
@@ -3641,8 +3846,7 @@ export class Daemon extends EventEmitter {
3641
3846
  this.pasteLock = this.pasteLock.then(async () => {
3642
3847
  if (!this.isDeliveryEpochCurrent(deliveryEpoch))
3643
3848
  return;
3644
- if (await this.deliverMessage(rawText, undefined, { deliveryEpoch }))
3645
- this.markTurnStarted(meta, rawText);
3849
+ await this.deliverMessage(rawText, undefined, { deliveryEpoch });
3646
3850
  }).catch(err => {
3647
3851
  this.logger.warn({ err: err.message }, "pasteLock raw delivery error");
3648
3852
  });
@@ -4814,14 +5018,21 @@ export class Daemon extends EventEmitter {
4814
5018
  if (this.socketSessionNames.get(socket) === this.name) {
4815
5019
  this.noteMcpProofOfLife("tool_call", this.socketPids.get(socket));
4816
5020
  }
5021
+ // A tool invocation is not delivery evidence. Capture which obligation it
5022
+ // belongs to now, then settle it only after the adapter/fleet responds.
5023
+ // Explicitly mapped sibling sessions must not satisfy this instance's turn;
5024
+ // an unmapped socket is retained for legacy/tests where mcp_ready was not
5025
+ // observed before the first tool call.
5026
+ const sourceSession = this.socketSessionNames.get(socket);
5027
+ const replyAttempt = TURN_REPLY_TOOLS.has(tool) && (!sourceSession || sourceSession === this.name)
5028
+ ? this.turnReplyGuard.beginToolAttempt(tool === "reply")
5029
+ : null;
4817
5030
  // For now, log and respond. Full adapter routing will be wired in fleet manager.
4818
5031
  const respond = (result, error) => {
4819
5032
  // A message that verifiably went out stands down the dead-MCP proxy reply
4820
5033
  // for this turn: the agent proved it can still speak for itself.
4821
5034
  if (!error && result != null && TURN_REPLY_TOOLS.has(tool)) {
4822
- this.turnOutboundDelivered = true;
4823
- if (tool === "reply")
4824
- this.turnReplyDelivered = true;
5035
+ this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
4825
5036
  }
4826
5037
  const sent = this.ipcServer?.send(socket, { requestId, result, error }) ?? false;
4827
5038
  if (!sent) {
@@ -5228,6 +5439,11 @@ export class Daemon extends EventEmitter {
5228
5439
  * into exactly the window this exists to close.
5229
5440
  */
5230
5441
  beginSpawn() {
5442
+ // A restarted CLI has no trustworthy turn edge for the process it replaced.
5443
+ // v1 deliberately does not persist obligations across restarts: missing one
5444
+ // warning is safer than treating the replacement's startup idle as the old
5445
+ // turn ending and injecting a stale recovery prompt.
5446
+ this.turnReplyGuard.reset();
5231
5447
  this.spawnDepth++;
5232
5448
  this.spawning = true;
5233
5449
  if (!this.spawnSettled) {