@songsid/agend 2.1.11 → 2.1.12-beta.2
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/README.md +23 -15
- package/README.zh-TW.md +50 -11
- package/dist/agent-cli.js +36 -4
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.d.ts +3 -1
- package/dist/agent-endpoint.js +7 -1
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/backend/claude-code.d.ts +4 -0
- package/dist/backend/claude-code.js +65 -2
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/session-signals.d.ts +154 -0
- package/dist/backend/session-signals.js +430 -0
- package/dist/backend/session-signals.js.map +1 -0
- package/dist/backend/types.d.ts +25 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +38 -1
- package/dist/channel/adapters/discord.js +131 -8
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +19 -1
- package/dist/channel/adapters/telegram.js +55 -0
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/mcp-tools.js +5 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/channel/message-queue.d.ts +6 -0
- package/dist/channel/message-queue.js +21 -0
- package/dist/channel/message-queue.js.map +1 -1
- package/dist/channel/sticker-download.d.ts +14 -0
- package/dist/channel/sticker-download.js +42 -0
- package/dist/channel/sticker-download.js.map +1 -0
- package/dist/channel/tool-router.js +12 -5
- package/dist/channel/tool-router.js.map +1 -1
- package/dist/channel/types.d.ts +52 -0
- package/dist/classic-bindings.d.ts +48 -0
- package/dist/classic-bindings.js +128 -0
- package/dist/classic-bindings.js.map +1 -0
- package/dist/classic-channel-manager.d.ts +21 -0
- package/dist/classic-channel-manager.js +31 -0
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli-flags.d.ts +8 -0
- package/dist/cli-flags.js +22 -0
- package/dist/cli-flags.js.map +1 -0
- package/dist/config-validator.js +25 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +61 -0
- package/dist/daemon.js +454 -19
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +8 -0
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +13 -0
- package/dist/delivery-reconciliation.js +71 -7
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/delivery-reconciliation.test.js +115 -2
- package/dist/delivery-reconciliation.test.js.map +1 -1
- package/dist/event-loop-watch.d.ts +25 -0
- package/dist/event-loop-watch.js +40 -0
- package/dist/event-loop-watch.js.map +1 -0
- package/dist/fleet-manager.d.ts +52 -4
- package/dist/fleet-manager.js +323 -20
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +4 -2
- package/dist/instance-lifecycle.js +4 -2
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/interaction-observation.d.ts +32 -0
- package/dist/interaction-observation.js +118 -0
- package/dist/interaction-observation.js.map +1 -0
- package/dist/kiro-engine-status.d.ts +2 -0
- package/dist/kiro-engine-status.js +3 -1
- package/dist/kiro-engine-status.js.map +1 -1
- package/dist/locale.js +26 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.d.ts +2 -0
- package/dist/outbound-handlers.js +28 -11
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +15 -1
- package/dist/outbound-schemas.js +20 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/output-log-tail.d.ts +21 -0
- package/dist/output-log-tail.js +89 -0
- package/dist/output-log-tail.js.map +1 -0
- package/dist/tool-permissions.js +8 -1
- package/dist/tool-permissions.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +7 -0
- package/dist/turn-reply-guard.js +11 -0
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/turn-resume.d.ts +80 -0
- package/dist/turn-resume.js +217 -0
- package/dist/turn-resume.js.map +1 -0
- package/dist/ui/view.html +8 -2
- package/dist/usage/format-rich.js +6 -1
- package/dist/usage/format-rich.js.map +1 -1
- package/dist/usage/i18n.d.ts +2 -0
- package/dist/usage/i18n.js +14 -1
- package/dist/usage/i18n.js.map +1 -1
- package/dist/usage/providers.d.ts +21 -0
- package/dist/usage/providers.js +36 -7
- package/dist/usage/providers.js.map +1 -1
- package/dist/usage/usage-api.d.ts +9 -0
- package/dist/usage/usage-api.js +31 -2
- package/dist/usage/usage-api.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -34,6 +34,9 @@ import { t } from "./locale.js";
|
|
|
34
34
|
import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
|
|
35
35
|
import { deliveryContentDigest } from "./delivery-outbox.js";
|
|
36
36
|
import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
|
|
37
|
+
import { scanTranscriptForDeliveryMarker } from "./delivery-reconciliation.js";
|
|
38
|
+
import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMarker, decideTurnResume, normalizeResumeBackend, readResumeFingerprint, RESUME_BLOCKING_DELIVERY_STATES, writeInFlightTurnMarker, } from "./turn-resume.js";
|
|
39
|
+
import { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
|
|
37
40
|
const __filename = fileURLToPath(import.meta.url);
|
|
38
41
|
const __dirname = dirname(__filename);
|
|
39
42
|
// Tool routing sets — module-level to avoid re-creation on every handleToolCall
|
|
@@ -440,6 +443,18 @@ const POST_ENTER_PROOF_WINDOW_MS = 3_000;
|
|
|
440
443
|
/** A final bounded observation before an ambiguous Codex submit can be classified. */
|
|
441
444
|
const CODEX_LATE_PROOF_MS = 5_000;
|
|
442
445
|
const POST_ENTER_PROOF_POLL_MS = 250;
|
|
446
|
+
/**
|
|
447
|
+
* A readerless CLI's delivery is proven by its own transcript, which lags the pane by a flush. Only a delta that was
|
|
448
|
+
* read and carried no marker for this long is held against the delivery — a late flush must not raise a false ⚠️.
|
|
449
|
+
* Ten seconds is a chosen grace window: success markers arrive in about a second, and the bound keeps the failure
|
|
450
|
+
* path small — the fleet lane and the shared pump budget stay held until the verdict (Phase 2c "lane held until
|
|
451
|
+
* verdict", Option A #1207), so a lost paste blocks its lane for ~10 s. No flush-visibility claim is made here: a
|
|
452
|
+
* flush landing after the window still settles `uncertain`, and only a final-file scan (see the PR evidence) shows
|
|
453
|
+
* every recorded delivery did reach its transcript.
|
|
454
|
+
*/
|
|
455
|
+
const TRANSCRIPT_PROOF_WINDOW_MS = 10_000;
|
|
456
|
+
const TRANSCRIPT_PROOF_POLL_MS = 1_000;
|
|
457
|
+
const OUTPUT_EDGE_ONLY_EVIDENCE = "output-edge-only; submission-unverifiable";
|
|
443
458
|
/** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
|
|
444
459
|
const BASELINE_CAPTURE_ATTEMPTS = 3;
|
|
445
460
|
const BASELINE_CAPTURE_RETRY_MS = 150;
|
|
@@ -851,17 +866,20 @@ export class InteractivePromptDetector {
|
|
|
851
866
|
constructor(stableMs = 10_000) {
|
|
852
867
|
this.stableMs = stableMs;
|
|
853
868
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
const tailText =
|
|
857
|
-
let matched = null;
|
|
869
|
+
/** Recognition is persistent; observe() separately deduplicates notifications. */
|
|
870
|
+
match(pane) {
|
|
871
|
+
const tailText = sanitizePaneTail(pane, 5).join("\n");
|
|
858
872
|
for (const candidate of INTERACTIVE_PROMPT_PATTERNS) {
|
|
859
873
|
const match = tailText.match(candidate.pattern);
|
|
860
|
-
if (
|
|
861
|
-
|
|
862
|
-
matched = { kind: candidate.kind, prompt: match[0].trim().slice(0, 200) };
|
|
863
|
-
break;
|
|
874
|
+
if (match)
|
|
875
|
+
return { kind: candidate.kind, prompt: match[0].trim().slice(0, 200) };
|
|
864
876
|
}
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
observe(pane, now = Date.now(), outputAt = 0) {
|
|
880
|
+
const tail = sanitizePaneTail(pane, 5);
|
|
881
|
+
const tailText = tail.join("\n");
|
|
882
|
+
const matched = this.match(pane);
|
|
865
883
|
if (!matched) {
|
|
866
884
|
this.reset();
|
|
867
885
|
this.lastOutputAt = outputAt;
|
|
@@ -1050,11 +1068,18 @@ export class Daemon extends EventEmitter {
|
|
|
1050
1068
|
*/
|
|
1051
1069
|
launchAttempt = 0;
|
|
1052
1070
|
skipResume = false;
|
|
1071
|
+
/**
|
|
1072
|
+
* #1209: whether THIS boot was asked to skip resume by a crash-loop
|
|
1073
|
+
* (crash-state.json). Captured at boot before spawnClaudeWindow resets
|
|
1074
|
+
* skipResume — the #1209 gate reads this, never the resettable flag.
|
|
1075
|
+
*/
|
|
1076
|
+
bootSkippedResume = false;
|
|
1053
1077
|
startupAborted = false;
|
|
1054
1078
|
/** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
|
|
1055
1079
|
dialogParkedSince = 0;
|
|
1056
1080
|
/** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
|
|
1057
1081
|
dialogParkedKey = null;
|
|
1082
|
+
dialogParkedInteractionEpisode = null;
|
|
1058
1083
|
dialogParkedReported = false;
|
|
1059
1084
|
/**
|
|
1060
1085
|
* The runtime dialog the monitor is answering, and how many of its answers in a row did nothing (the dialog was
|
|
@@ -1080,6 +1105,13 @@ export class Daemon extends EventEmitter {
|
|
|
1080
1105
|
capacityNudge = null;
|
|
1081
1106
|
/** Current stdin-owning runtime dialog, independent from execution state. */
|
|
1082
1107
|
inputBlockedDialogKey = null;
|
|
1108
|
+
interactionObservation = new InteractionObservation();
|
|
1109
|
+
interactionConfirmationTimer = null;
|
|
1110
|
+
interactionOutputRevision = 0;
|
|
1111
|
+
interactionObservedOutputRevision = 0;
|
|
1112
|
+
interactionCaptureSerial = 0;
|
|
1113
|
+
interactionDialogKey = null;
|
|
1114
|
+
acceptedInstanceSnapshot = null;
|
|
1083
1115
|
/** Generation/signature fence for safety dialogs while their screen is still visible. */
|
|
1084
1116
|
autoResolvedDialogKey = null;
|
|
1085
1117
|
autoResolvedDialogGeneration = 0;
|
|
@@ -1517,6 +1549,11 @@ export class Daemon extends EventEmitter {
|
|
|
1517
1549
|
const isGeneral = config.general_topic === true || name === "general";
|
|
1518
1550
|
const autoPauseMinutes = isGeneral ? 0 : (typeof config.auto_pause_after === "number" ? config.auto_pause_after : 0); // default: disabled
|
|
1519
1551
|
this.autoPauseController = new AutoPauseController(Math.max(0, autoPauseMinutes) * 60_000, readLastInboundAt(instanceDir) ?? Date.now());
|
|
1552
|
+
// #1209: every real completion clears the in-flight marker, so a
|
|
1553
|
+
// finished turn can never resume after a later restart. reset() is
|
|
1554
|
+
// deliberately NOT hooked: stop/spawn abandon the in-memory turn while
|
|
1555
|
+
// the on-disk interruption must survive for the resume gate.
|
|
1556
|
+
this.turnReplyGuard.onComplete = () => clearInFlightTurnMarker(this.instanceDir);
|
|
1520
1557
|
}
|
|
1521
1558
|
/** FleetManager and this daemon share a process, so durable begin/ACK is a direct synchronous store call. */
|
|
1522
1559
|
setDeliveryOutboxPort(port) {
|
|
@@ -1633,8 +1670,82 @@ export class Daemon extends EventEmitter {
|
|
|
1633
1670
|
this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable");
|
|
1634
1671
|
return;
|
|
1635
1672
|
}
|
|
1673
|
+
// A backend whose input row cannot be read: all that vouched for this delivery is that the pane printed something
|
|
1674
|
+
// after Enter, which a redraw that wiped the paste satisfies too (#758). Its transcript can say more.
|
|
1675
|
+
if (verdict.proof === "output-edge") {
|
|
1676
|
+
const checkpoint = verdict.transcriptCheckpoint;
|
|
1677
|
+
if (delivery && checkpoint && ["claude-code", "codex"].includes(checkpoint.backend)) {
|
|
1678
|
+
void this.proveDeliveryFromTranscript(delivery, checkpoint);
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
// No transcript to ask: nothing to hold against it and nothing to prove it with. Today's outcome, honestly labelled.
|
|
1682
|
+
this.finishDurableDelivery(delivery, "delivered", OUTPUT_EDGE_ONLY_EVIDENCE);
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1636
1685
|
this.finishDurableDelivery(delivery, "delivered", "positive submission proof");
|
|
1637
1686
|
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Finish a delivery that only the output edge vouched for, by the delivery's own marker in the CLI's transcript (#758).
|
|
1689
|
+
*
|
|
1690
|
+
* - marker found → delivered (`transcript-marker`; `transcript-marker-queued` when the CLI has it queued, not yet read);
|
|
1691
|
+
* - delta read, no marker for the whole window → uncertain (`unverifiable-no-transcript-marker`) — the pane printed
|
|
1692
|
+
* something, yet the CLI never took the message in. Never a re-paste: the message may still land;
|
|
1693
|
+
* - transcript unreadable on the last look → today's outcome, labelled — there is nothing to hold against it.
|
|
1694
|
+
*
|
|
1695
|
+
* It runs after the paste lock is released, but the fleet lane and the shared pump budget stay held until the
|
|
1696
|
+
* verdict settles the row (Phase 2c "lane held until verdict", Option A #1207): a success settles in about a
|
|
1697
|
+
* second, a delivery whose marker never arrives holds them for the bounded proof window (see
|
|
1698
|
+
* TRANSCRIPT_PROOF_WINDOW_MS). It never finishes a row that stop has handed to reconciliation; a pause or respawn
|
|
1699
|
+
* only ends the wait, falling back to today's outcome.
|
|
1700
|
+
*/
|
|
1701
|
+
async proveDeliveryFromTranscript(delivery, checkpoint) {
|
|
1702
|
+
const spawn = this.spawnGeneration;
|
|
1703
|
+
const fence = this.launchFenceEpoch;
|
|
1704
|
+
let last = "unavailable";
|
|
1705
|
+
let outcome = "delivered";
|
|
1706
|
+
let evidence = OUTPUT_EDGE_ONLY_EVIDENCE;
|
|
1707
|
+
try {
|
|
1708
|
+
for (let polls = 0; polls <= TRANSCRIPT_PROOF_WINDOW_MS / TRANSCRIPT_PROOF_POLL_MS; polls++) {
|
|
1709
|
+
if (this.deliveryWritesStopping)
|
|
1710
|
+
return; // reconciliation owns the row now
|
|
1711
|
+
const found = await scanTranscriptForDeliveryMarker(checkpoint.path, checkpoint.offset, checkpoint.backend, delivery.deliveryId);
|
|
1712
|
+
if (found === "user" || found === "queued") {
|
|
1713
|
+
outcome = "delivered";
|
|
1714
|
+
evidence = found === "user" ? "transcript-marker" : "transcript-marker-queued";
|
|
1715
|
+
break;
|
|
1716
|
+
}
|
|
1717
|
+
last = found;
|
|
1718
|
+
if (spawn !== this.spawnGeneration || fence !== this.launchFenceEpoch) {
|
|
1719
|
+
last = "unavailable";
|
|
1720
|
+
break;
|
|
1721
|
+
}
|
|
1722
|
+
await new Promise(r => setTimeout(r, TRANSCRIPT_PROOF_POLL_MS));
|
|
1723
|
+
}
|
|
1724
|
+
// Only a delta that was read on the last look and still lacks the marker is held against the delivery. A
|
|
1725
|
+
// pause/freeze or respawn during the final sleep must end the wait with today's labelled outcome, not
|
|
1726
|
+
// `uncertain`: re-check the same fixed epochs after the last await, before settling (#1207 P2).
|
|
1727
|
+
if (spawn !== this.spawnGeneration || fence !== this.launchFenceEpoch)
|
|
1728
|
+
last = "unavailable";
|
|
1729
|
+
if (last === "no-match" && evidence === OUTPUT_EDGE_ONLY_EVIDENCE) {
|
|
1730
|
+
outcome = "uncertain";
|
|
1731
|
+
evidence = "unverifiable-no-transcript-marker";
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
catch (err) {
|
|
1735
|
+
this.logger.debug({ err }, "Transcript proof of a delivery failed — keeping the output-edge outcome");
|
|
1736
|
+
}
|
|
1737
|
+
// The settlement itself can throw (e.g. the outbox store is unhealthy), and this runs on a detached `void`
|
|
1738
|
+
// promise: an uncaught throw is an unhandled rejection, which the fleet restart path turns into a global fault.
|
|
1739
|
+
// Settle observably and leave the row for reconciliation — never a false completion, never another paste (#1207 P2).
|
|
1740
|
+
try {
|
|
1741
|
+
if (this.deliveryWritesStopping)
|
|
1742
|
+
return;
|
|
1743
|
+
this.finishDurableDelivery(delivery, outcome, evidence);
|
|
1744
|
+
}
|
|
1745
|
+
catch (err) {
|
|
1746
|
+
this.logger.error({ err, deliveryId: delivery.deliveryId, attemptNo: delivery.attemptNo, outcome, evidence }, "Durable delivery settlement failed — row left for reconciliation");
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1638
1749
|
deferDurableDelivery(meta, reason) {
|
|
1639
1750
|
const delivery = this.durableDeliveryAttempt(meta);
|
|
1640
1751
|
if (delivery === null || delivery === false)
|
|
@@ -1691,6 +1802,19 @@ export class Daemon extends EventEmitter {
|
|
|
1691
1802
|
return false;
|
|
1692
1803
|
}
|
|
1693
1804
|
}
|
|
1805
|
+
/**
|
|
1806
|
+
* #1206: rotate + attach the pipe-pane log feed. An attach failure warns
|
|
1807
|
+
* (like the wake path) instead of vanishing silently and leaving the
|
|
1808
|
+
* instance with no log feed. Extracted so the failure path is unit-testable
|
|
1809
|
+
* without driving the real lifecycle.
|
|
1810
|
+
*/
|
|
1811
|
+
async attachPipePaneLog() {
|
|
1812
|
+
const outputLog = join(this.instanceDir, "output.log");
|
|
1813
|
+
await rotateLogIfNeededAsync(outputLog);
|
|
1814
|
+
await this.tmux.pipeOutput(outputLog).catch(err => {
|
|
1815
|
+
this.logger.warn({ err }, "Failed to attach pipe-pane — instance output log will be missing");
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1694
1818
|
async start() {
|
|
1695
1819
|
ensureInstanceDir(this.instanceDir);
|
|
1696
1820
|
writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
|
|
@@ -1699,6 +1823,7 @@ export class Daemon extends EventEmitter {
|
|
|
1699
1823
|
// an unreadable marker for a later start; clear successfully read bytes
|
|
1700
1824
|
// even when the JSON is corrupt so it cannot remain forever.
|
|
1701
1825
|
const crashStatePath = join(this.instanceDir, "crash-state.json");
|
|
1826
|
+
this.bootSkippedResume = false;
|
|
1702
1827
|
try {
|
|
1703
1828
|
if (existsSync(crashStatePath)) {
|
|
1704
1829
|
const content = readFileSync(crashStatePath, "utf-8");
|
|
@@ -1706,6 +1831,7 @@ export class Daemon extends EventEmitter {
|
|
|
1706
1831
|
const state = JSON.parse(content);
|
|
1707
1832
|
if (state?.resumeDisabled === true) {
|
|
1708
1833
|
this.skipResume = true;
|
|
1834
|
+
this.bootSkippedResume = true;
|
|
1709
1835
|
this.logger.warn("Previous crash loop detected — starting without resume");
|
|
1710
1836
|
}
|
|
1711
1837
|
}
|
|
@@ -1844,6 +1970,10 @@ export class Daemon extends EventEmitter {
|
|
|
1844
1970
|
}
|
|
1845
1971
|
catch { /* may not exist */ }
|
|
1846
1972
|
}
|
|
1973
|
+
// #1209: conditional continuation for the turn interrupted by the last
|
|
1974
|
+
// restart, if the gates allow. Detached like the warmup notice; the gate
|
|
1975
|
+
// consumes its one-shot marker first and never throws into boot.
|
|
1976
|
+
void this.maybeResumeInterruptedTurn().catch(() => { });
|
|
1847
1977
|
// Warmup: wait for CLI idle, then trigger steering reload — but only when
|
|
1848
1978
|
// the instructions actually changed since the agent last saw them.
|
|
1849
1979
|
// Skipping the no-op reload saves 10-30s of agent time on every restart
|
|
@@ -1854,9 +1984,7 @@ export class Daemon extends EventEmitter {
|
|
|
1854
1984
|
// previous stuck splash (hundreds of MB of ANSI frames) is truncated before
|
|
1855
1985
|
// we attach — pipe-pane uses `cat >>` on the same inode, so copytruncate
|
|
1856
1986
|
// keeps the writer attached after size resets.
|
|
1857
|
-
|
|
1858
|
-
await rotateLogIfNeededAsync(outputLog);
|
|
1859
|
-
await this.tmux.pipeOutput(outputLog).catch(() => { });
|
|
1987
|
+
await this.attachPipePaneLog();
|
|
1860
1988
|
// 4. Transcript monitor. claude-code is handled inside the monitor
|
|
1861
1989
|
// (statusline transcript); codex/kiro/opencode read their CLI's own
|
|
1862
1990
|
// conversation store via a pluggable source. Backends with no known
|
|
@@ -2811,14 +2939,28 @@ export class Daemon extends EventEmitter {
|
|
|
2811
2939
|
// a pause, a respawn); a stale poll must not touch state a newer one owns, so it checks before committing.
|
|
2812
2940
|
const pollSpawn = this.spawnGeneration;
|
|
2813
2941
|
const pollFence = this.launchFenceEpoch;
|
|
2814
|
-
const
|
|
2942
|
+
const pollOwner = this.interactionOwner();
|
|
2943
|
+
const captureOrder = ++this.interactionCaptureSerial;
|
|
2944
|
+
const captureMono = performance.now();
|
|
2945
|
+
const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch
|
|
2946
|
+
|| !sameInteractionOwner(pollOwner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning;
|
|
2815
2947
|
try {
|
|
2816
2948
|
const alive = await this.tmux.isWindowAlive();
|
|
2949
|
+
if (stale())
|
|
2950
|
+
return;
|
|
2817
2951
|
if (!alive)
|
|
2818
2952
|
return;
|
|
2953
|
+
const captureAt = Date.now();
|
|
2819
2954
|
const pane = await this.tmux.capturePane();
|
|
2820
2955
|
if (stale())
|
|
2821
2956
|
return;
|
|
2957
|
+
if (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureAt) {
|
|
2958
|
+
this.unverifyInteraction(captureMono, captureOrder);
|
|
2959
|
+
}
|
|
2960
|
+
else {
|
|
2961
|
+
if (!this.observeInteractionPane(pane, captureAt, captureMono, dialogs, captureOrder))
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2822
2964
|
this.takeNudgeBaselines(pane, patterns);
|
|
2823
2965
|
this.tickCapacityNudge(pane);
|
|
2824
2966
|
const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
|
|
@@ -2901,12 +3043,21 @@ export class Daemon extends EventEmitter {
|
|
|
2901
3043
|
// Re-read under the lock: the first capture may have gone stale
|
|
2902
3044
|
// while a delivery was finishing. A stale danger menu must never
|
|
2903
3045
|
// receive a blind key sequence.
|
|
3046
|
+
const currentAt = Date.now();
|
|
3047
|
+
const currentMono = performance.now();
|
|
3048
|
+
const currentOrder = ++this.interactionCaptureSerial;
|
|
2904
3049
|
const currentPane = await this.tmux.capturePane();
|
|
2905
3050
|
// Every await below can outlive the spawn / monitors this poll started under: after each one, before
|
|
2906
3051
|
// any state is touched or any further key is sent, `stale()` (fixed at the poll's start) is asked again.
|
|
2907
3052
|
if (stale())
|
|
2908
3053
|
return;
|
|
2909
3054
|
if (!Daemon.dialogMatches(dialog, currentPane)) {
|
|
3055
|
+
if (this.instanceStateLastOutputAt >= currentAt && this.instanceStateLastOutputAt > 0) {
|
|
3056
|
+
this.unverifyInteraction(currentMono, currentOrder);
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
if (!this.observeInteractionPane(currentPane, currentAt, currentMono, dialogs, currentOrder))
|
|
3060
|
+
return;
|
|
2910
3061
|
this.updateInputBlockedState(currentPane, dialogs);
|
|
2911
3062
|
this.endDialogEpisode(); // somebody (or the CLI) already cleared it: that screen is over
|
|
2912
3063
|
return;
|
|
@@ -2952,6 +3103,9 @@ export class Daemon extends EventEmitter {
|
|
|
2952
3103
|
return;
|
|
2953
3104
|
}
|
|
2954
3105
|
if (dialog.verifyAfterKeys) {
|
|
3106
|
+
const afterKeysAt = Date.now();
|
|
3107
|
+
const afterKeysMono = performance.now();
|
|
3108
|
+
const afterKeysOrder = ++this.interactionCaptureSerial;
|
|
2955
3109
|
const afterKeysPane = await this.tmux.capturePane();
|
|
2956
3110
|
if (stale())
|
|
2957
3111
|
return;
|
|
@@ -2962,6 +3116,12 @@ export class Daemon extends EventEmitter {
|
|
|
2962
3116
|
this.logger.warn({ dialog: dialog.description }, "Runtime dialog remained after its safety choice");
|
|
2963
3117
|
return;
|
|
2964
3118
|
}
|
|
3119
|
+
if (this.instanceStateLastOutputAt >= afterKeysAt && this.instanceStateLastOutputAt > 0) {
|
|
3120
|
+
this.unverifyInteraction(afterKeysMono, afterKeysOrder);
|
|
3121
|
+
return;
|
|
3122
|
+
}
|
|
3123
|
+
if (!this.observeInteractionPane(afterKeysPane, afterKeysAt, afterKeysMono, dialogs, afterKeysOrder))
|
|
3124
|
+
return;
|
|
2965
3125
|
this.updateInputBlockedState(afterKeysPane, dialogs);
|
|
2966
3126
|
if (dialog.postDismissNotice) {
|
|
2967
3127
|
resolved = await this.submitSystemPaste(dialog.postDismissNotice.text, dialog.postDismissNotice.label);
|
|
@@ -3010,6 +3170,8 @@ export class Daemon extends EventEmitter {
|
|
|
3010
3170
|
this.evaluateErrorPatterns(pane, patterns, readyPattern, Date.now(), busyPattern);
|
|
3011
3171
|
}
|
|
3012
3172
|
catch {
|
|
3173
|
+
if (!stale())
|
|
3174
|
+
this.unverifyInteraction(captureMono, captureOrder);
|
|
3013
3175
|
// capturePane can fail if window is transitioning — ignore
|
|
3014
3176
|
}
|
|
3015
3177
|
}, 5_000); // Check every 5 seconds (runtime dialogs need fast response)
|
|
@@ -3251,6 +3413,10 @@ export class Daemon extends EventEmitter {
|
|
|
3251
3413
|
// FleetManager calls this synchronously before sendEscape. Cancel only the
|
|
3252
3414
|
// current human turn; leave already-forwarded tool requests to settle.
|
|
3253
3415
|
this.turnReplyGuard.cancelByUser();
|
|
3416
|
+
// #1209: a cancelled turn must never resume (#1199) — drop its marker.
|
|
3417
|
+
// (Completion paths clear via the guard's onComplete; a cancel with no
|
|
3418
|
+
// later idle edge would otherwise leave the marker behind.)
|
|
3419
|
+
clearInFlightTurnMarker(this.instanceDir);
|
|
3254
3420
|
this.deliveryEpoch = fleetEpoch === undefined
|
|
3255
3421
|
? this.deliveryEpoch + 1
|
|
3256
3422
|
: Math.max(this.deliveryEpoch, fleetEpoch);
|
|
@@ -3683,12 +3849,107 @@ export class Daemon extends EventEmitter {
|
|
|
3683
3849
|
return this.isPaused ? "paused" : this.instanceState;
|
|
3684
3850
|
}
|
|
3685
3851
|
getInstanceStateSnapshot() {
|
|
3686
|
-
|
|
3852
|
+
const snapshot = this.instanceStateMachine?.snapshot() ?? {
|
|
3687
3853
|
state: this.instanceState,
|
|
3688
3854
|
unchangedForMs: 0,
|
|
3689
3855
|
observedAt: Date.now(),
|
|
3690
3856
|
stateChangedAt: Date.now(),
|
|
3691
3857
|
};
|
|
3858
|
+
// A machine observation rejected by the modal hold never became a control
|
|
3859
|
+
// state. A query must not publish that unaccepted idle as permission to act.
|
|
3860
|
+
if (this.inputBlockedDialogKey !== null) {
|
|
3861
|
+
snapshot.state = this.instanceState;
|
|
3862
|
+
snapshot.stateChangedAt = this.acceptedInstanceSnapshot?.stateChangedAt ?? snapshot.stateChangedAt;
|
|
3863
|
+
}
|
|
3864
|
+
return { ...snapshot, interaction: this.getInteractionSnapshot() };
|
|
3865
|
+
}
|
|
3866
|
+
interactionOwner() {
|
|
3867
|
+
return { bootId: this.bootId, spawnGeneration: this.spawnGeneration,
|
|
3868
|
+
launchAttempt: this.launchAttempt, launchFenceEpoch: this.launchFenceEpoch };
|
|
3869
|
+
}
|
|
3870
|
+
/** Cached, pure observation read; it never captures, constructs a backend or writes stdin. */
|
|
3871
|
+
getInteractionSnapshot() {
|
|
3872
|
+
return this.interactionObservation.snapshot(this.interactionOwner());
|
|
3873
|
+
}
|
|
3874
|
+
publishInteraction() {
|
|
3875
|
+
const interaction = this.getInteractionSnapshot();
|
|
3876
|
+
// Presentation telemetry must not interrupt the execution-state/hold path.
|
|
3877
|
+
try {
|
|
3878
|
+
this.emit("instance_interaction", { name: this.name, interaction });
|
|
3879
|
+
}
|
|
3880
|
+
catch (err) {
|
|
3881
|
+
this.logger.debug({ err }, "Interaction listener failed");
|
|
3882
|
+
}
|
|
3883
|
+
try {
|
|
3884
|
+
this.ipcServer?.broadcast({ type: "instance_interaction", instanceName: this.name, interaction });
|
|
3885
|
+
}
|
|
3886
|
+
catch (err) {
|
|
3887
|
+
this.logger.debug({ err }, "Interaction broadcast failed");
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
clearInteractionConfirmation() {
|
|
3891
|
+
if (this.interactionConfirmationTimer)
|
|
3892
|
+
clearTimeout(this.interactionConfirmationTimer);
|
|
3893
|
+
this.interactionConfirmationTimer = null;
|
|
3894
|
+
}
|
|
3895
|
+
unverifyInteraction(monotonicAt = performance.now(), order = ++this.interactionCaptureSerial) {
|
|
3896
|
+
if (!this.interactionObservation.unverify(this.interactionOwner(), monotonicAt, order))
|
|
3897
|
+
return;
|
|
3898
|
+
this.clearInteractionConfirmation();
|
|
3899
|
+
this.publishInteraction();
|
|
3900
|
+
}
|
|
3901
|
+
interactionEvidence(pane, dialogs) {
|
|
3902
|
+
const login = LOGIN_FLOWS[this.config.backend ?? "claude-code"];
|
|
3903
|
+
let evidence = null;
|
|
3904
|
+
if (login?.loginScreenActive?.(pane)) {
|
|
3905
|
+
evidence = { kind: "login", identity: pane.trim() };
|
|
3906
|
+
}
|
|
3907
|
+
else {
|
|
3908
|
+
// Bare backend regexes are not proof of a live modal. The canonical
|
|
3909
|
+
// isActive predicate replaces pattern.test(), preserving table order.
|
|
3910
|
+
const dialog = dialogs.find(d => d.isActive && (d.inputBlocked || d.blocksDelivery || d.holdOnly)
|
|
3911
|
+
&& Daemon.dialogMatches(d, pane));
|
|
3912
|
+
if (dialog) {
|
|
3913
|
+
const identity = dialog.requestIdentity?.(pane) ?? pane.replace(/\r/g, "")
|
|
3914
|
+
.split("\n").map(row => row.replace(/^([ \t]*)[❯›]([ \t]+)(?=\d\.|Yes\b|No\b|Resume\b)/, "$1$2").trimEnd())
|
|
3915
|
+
.filter(row => row.trim() !== "").join("\n");
|
|
3916
|
+
evidence = { kind: dialog.interactionKind ?? "dialog", dialogKey: Daemon.dialogKey(dialog), identity: `${dialog.pattern.source}\n${identity}` };
|
|
3917
|
+
}
|
|
3918
|
+
else {
|
|
3919
|
+
const weak = this.interactivePromptDetector.match(pane);
|
|
3920
|
+
if (weak)
|
|
3921
|
+
evidence = { kind: "suspected_terminal_input", identity: sanitizePaneTail(pane, 5).join("\n"), suspected: true };
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
// No captured text leaves this function or the private observer identity.
|
|
3925
|
+
return evidence ? { ...evidence, identity: createHash("sha256").update(evidence.identity).digest("hex") } : null;
|
|
3926
|
+
}
|
|
3927
|
+
observeInteractionPane(pane, observedAt, monotonicAt, dialogs = this.backend?.getRuntimeDialogs?.() ?? [], order = ++this.interactionCaptureSerial, allowConfirmation = true) {
|
|
3928
|
+
const owner = this.interactionOwner();
|
|
3929
|
+
const evidence = this.interactionEvidence(pane, dialogs);
|
|
3930
|
+
if (!this.interactionObservation.observe(evidence, owner, observedAt, monotonicAt, this.interactionOutputRevision !== this.interactionObservedOutputRevision, order))
|
|
3931
|
+
return false;
|
|
3932
|
+
this.interactionDialogKey = evidence?.dialogKey ?? null;
|
|
3933
|
+
this.interactionObservedOutputRevision = this.interactionOutputRevision;
|
|
3934
|
+
const interaction = this.getInteractionSnapshot();
|
|
3935
|
+
this.publishInteraction();
|
|
3936
|
+
if (interaction.phase !== "candidate" || interaction.suspected) {
|
|
3937
|
+
this.clearInteractionConfirmation();
|
|
3938
|
+
return true;
|
|
3939
|
+
}
|
|
3940
|
+
// One bounded extra read per ordinary observation. A confirmation that
|
|
3941
|
+
// finds a changed request waits for the existing monitor's next capture;
|
|
3942
|
+
// it cannot recursively create a permanent fast poll.
|
|
3943
|
+
if (allowConfirmation && !this.interactionConfirmationTimer) {
|
|
3944
|
+
this.interactionConfirmationTimer = setTimeout(() => {
|
|
3945
|
+
this.interactionConfirmationTimer = null;
|
|
3946
|
+
if (!sameInteractionOwner(owner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning)
|
|
3947
|
+
return;
|
|
3948
|
+
void this.captureAndEvaluateInstanceState("interaction_confirmation");
|
|
3949
|
+
}, INTERACTION_CONFIRM_MS);
|
|
3950
|
+
this.interactionConfirmationTimer.unref?.();
|
|
3951
|
+
}
|
|
3952
|
+
return true;
|
|
3692
3953
|
}
|
|
3693
3954
|
/**
|
|
3694
3955
|
* Answer a FleetManager state query. Most callers only need the cached state,
|
|
@@ -3954,6 +4215,7 @@ export class Daemon extends EventEmitter {
|
|
|
3954
4215
|
return;
|
|
3955
4216
|
}
|
|
3956
4217
|
this.instanceState = snapshot.state;
|
|
4218
|
+
this.acceptedInstanceSnapshot = { ...snapshot };
|
|
3957
4219
|
// OpenCode creates its session lazily on the first submitted message.
|
|
3958
4220
|
// Waiting until stop/pause to persist that id loses resume state when the
|
|
3959
4221
|
// fleet is SIGKILLed or the host reboots.
|
|
@@ -4055,6 +4317,125 @@ export class Daemon extends EventEmitter {
|
|
|
4055
4317
|
correlationId: meta.correlation_id || undefined,
|
|
4056
4318
|
inboundMarker,
|
|
4057
4319
|
});
|
|
4320
|
+
// #1209: persist the in-flight turn with a seam checkpoint. A marker
|
|
4321
|
+
// that survives a restart is an interrupted turn by construction —
|
|
4322
|
+
// completion (via the guard's onComplete) and cancel paths delete it.
|
|
4323
|
+
// Synchronous by necessity: a crash between arm and write must not lose
|
|
4324
|
+
// the interruption. Reads are windowed/bounded (see turn-resume.ts).
|
|
4325
|
+
// A resumed continuation never re-arms (P3a): episode-once means a crash
|
|
4326
|
+
// during the continuation boots clean instead of chaining resumes.
|
|
4327
|
+
if (!meta.resumedContinuationOf) {
|
|
4328
|
+
const resumeBackend = this.config.backend ?? this.backend?.binaryName ?? this.runtimeIdentity?.backend;
|
|
4329
|
+
const resumeCwd = this.config.working_directory ?? "";
|
|
4330
|
+
writeInFlightTurnMarker(this.instanceDir, {
|
|
4331
|
+
version: 1,
|
|
4332
|
+
deliveryId: meta.delivery_id || undefined,
|
|
4333
|
+
correlationId: meta.correlation_id || undefined,
|
|
4334
|
+
messageId: meta.message_id || undefined,
|
|
4335
|
+
chatId: meta.chat_id,
|
|
4336
|
+
threadId: meta.thread_id || undefined,
|
|
4337
|
+
adapterId: meta.adapter_id || undefined,
|
|
4338
|
+
backend: resumeBackend ?? "unknown",
|
|
4339
|
+
cwd: resumeCwd,
|
|
4340
|
+
armedAt: Date.now(),
|
|
4341
|
+
before: readResumeFingerprint(normalizeResumeBackend(resumeBackend), {
|
|
4342
|
+
instanceDir: this.instanceDir,
|
|
4343
|
+
workingDirectory: resumeCwd,
|
|
4344
|
+
}),
|
|
4345
|
+
});
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
/** #1209: which seam reader serves this instance (live config preferred). */
|
|
4349
|
+
resolveResumeBackend() {
|
|
4350
|
+
return normalizeResumeBackend(this.config.backend ?? this.backend?.binaryName ?? this.runtimeIdentity?.backend);
|
|
4351
|
+
}
|
|
4352
|
+
/** #1209: whether the marker's delivery is still owned by the durable path. */
|
|
4353
|
+
isMarkerDeliveryPending(marker) {
|
|
4354
|
+
const id = marker.deliveryId;
|
|
4355
|
+
if (!id)
|
|
4356
|
+
return false;
|
|
4357
|
+
try {
|
|
4358
|
+
const row = this.deliveryOutbox?.get?.(id);
|
|
4359
|
+
return !!row && RESUME_BLOCKING_DELIVERY_STATES.includes(row.state);
|
|
4360
|
+
}
|
|
4361
|
+
catch {
|
|
4362
|
+
return false;
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
4365
|
+
/**
|
|
4366
|
+
* #1209 ACP-resume-like gate: one bounded continuation for the turn that
|
|
4367
|
+
* was in flight when the process died. The one-shot marker is consumed
|
|
4368
|
+
* FIRST (episode-once: at most one continuation per interrupted turn, even
|
|
4369
|
+
* if the inject below throws or the process dies holding the grace wait),
|
|
4370
|
+
* then the pure gate in turn-resume.ts decides. A hold inside the flush
|
|
4371
|
+
* grace sleeps once and re-gates with a fresh read; a second hold denies
|
|
4372
|
+
* rather than waiting forever. Returns a short outcome for logs/tests.
|
|
4373
|
+
*/
|
|
4374
|
+
async maybeResumeInterruptedTurn() {
|
|
4375
|
+
const marker = consumeInFlightTurnMarker(this.instanceDir);
|
|
4376
|
+
if (!marker)
|
|
4377
|
+
return "none";
|
|
4378
|
+
// P1 fence: capture before any await. A cancel (deliveryEpoch),
|
|
4379
|
+
// respawn (spawnGeneration) or stop/pause (launchFenceEpoch) during the
|
|
4380
|
+
// grace hold must stale this gate — the push below rechecks, and the
|
|
4381
|
+
// push path itself carries the then-current epoch, so a post-push
|
|
4382
|
+
// cancel still drops the queued paste. Fence follows the write (#1192).
|
|
4383
|
+
const gateEpoch = this.deliveryEpoch;
|
|
4384
|
+
const gateSpawn = this.spawnGeneration;
|
|
4385
|
+
const gateFence = this.launchFenceEpoch;
|
|
4386
|
+
const gateFresh = () => this.deliveryEpoch === gateEpoch
|
|
4387
|
+
&& this.spawnGeneration === gateSpawn
|
|
4388
|
+
&& this.launchFenceEpoch === gateFence;
|
|
4389
|
+
const backend = this.resolveResumeBackend() ?? normalizeResumeBackend(marker.backend);
|
|
4390
|
+
const roots = { instanceDir: this.instanceDir, workingDirectory: this.config.working_directory ?? "" };
|
|
4391
|
+
// This boot's CLI spawn is what the daemon itself caused: pre-restart
|
|
4392
|
+
// writes (including pre-crash progress) are excluded, and the flush
|
|
4393
|
+
// grace holds the decision until post-spawn writes are observable.
|
|
4394
|
+
const daemonCausedMtimeMs = this.lastSpawnAt > 0 ? this.lastSpawnAt : -1;
|
|
4395
|
+
const gate = (after, nowMs) => decideTurnResume({
|
|
4396
|
+
marker,
|
|
4397
|
+
before: marker.before,
|
|
4398
|
+
after,
|
|
4399
|
+
daemonCausedMtimeMs,
|
|
4400
|
+
nowMs,
|
|
4401
|
+
crashLoopBoot: this.bootSkippedResume,
|
|
4402
|
+
deliveryPending: this.isMarkerDeliveryPending(marker),
|
|
4403
|
+
});
|
|
4404
|
+
const readAfter = () => backend ? readResumeFingerprint(backend, roots) : null;
|
|
4405
|
+
let decision = gate(readAfter(), Date.now());
|
|
4406
|
+
if (decision.action === "wait") {
|
|
4407
|
+
const waitMs = decision.waitMs;
|
|
4408
|
+
await new Promise(resolve => setTimeout(resolve, waitMs));
|
|
4409
|
+
const recheck = gate(readAfter(), Date.now());
|
|
4410
|
+
decision = recheck.action === "wait"
|
|
4411
|
+
? {
|
|
4412
|
+
action: "skip",
|
|
4413
|
+
reason: "unobservable",
|
|
4414
|
+
detail: "still inside the flush grace after the hold; default-deny",
|
|
4415
|
+
}
|
|
4416
|
+
: recheck;
|
|
4417
|
+
}
|
|
4418
|
+
// The hold above is the only await on this path: recheck the fence
|
|
4419
|
+
// after it (and before the write). Stale → skip quietly, never an
|
|
4420
|
+
// error notice — the cancel/stop owner already reported itself.
|
|
4421
|
+
if (!gateFresh()) {
|
|
4422
|
+
this.logger.info("Interrupted-turn continuation dropped — cancel/stop/pause/respawn during the grace hold");
|
|
4423
|
+
return "skipped-stale";
|
|
4424
|
+
}
|
|
4425
|
+
if (decision.action === "inject") {
|
|
4426
|
+
const continuation = buildResumeContinuation(marker);
|
|
4427
|
+
try {
|
|
4428
|
+
this.pushChannelMessage(continuation.text, continuation.meta);
|
|
4429
|
+
}
|
|
4430
|
+
catch (err) {
|
|
4431
|
+
this.logger.warn({ err }, "Interrupted-turn continuation failed to queue");
|
|
4432
|
+
return "inject-failed";
|
|
4433
|
+
}
|
|
4434
|
+
this.logger.info({ correlationId: marker.correlationId }, "Injected one bounded continuation for the interrupted turn");
|
|
4435
|
+
return "injected";
|
|
4436
|
+
}
|
|
4437
|
+
this.logger.info({ reason: decision.reason, detail: decision.detail }, "Interrupted turn NOT resumed");
|
|
4438
|
+
return `skipped-${decision.reason}`;
|
|
4058
4439
|
}
|
|
4059
4440
|
/**
|
|
4060
4441
|
* Turn ended (busy→idle edge): if the MCP server is dead and none of the
|
|
@@ -4481,11 +4862,17 @@ export class Daemon extends EventEmitter {
|
|
|
4481
4862
|
}
|
|
4482
4863
|
this.statePollInFlight = true;
|
|
4483
4864
|
const captureStartedAt = Date.now();
|
|
4865
|
+
const interactionCaptureAt = performance.now();
|
|
4866
|
+
const interactionCaptureOrder = ++this.interactionCaptureSerial;
|
|
4867
|
+
const interactionOwner = this.interactionOwner();
|
|
4484
4868
|
const captureEpoch = `${this.spawnGeneration}:${this.launchAttempt}`;
|
|
4485
4869
|
const currentDeliveryCapture = () => captureEpoch === `${this.spawnGeneration}:${this.launchAttempt}`
|
|
4486
4870
|
&& !this.spawning && !this.runtimeMonitorsFrozen && this.instanceStateMonitorActive;
|
|
4487
4871
|
try {
|
|
4488
|
-
const pane =
|
|
4872
|
+
const pane = reason === "interaction_confirmation"
|
|
4873
|
+
? await this.tmux.capturePane(1_000) : await this.tmux.capturePane();
|
|
4874
|
+
if (!currentDeliveryCapture() || !sameInteractionOwner(interactionOwner, this.interactionOwner()))
|
|
4875
|
+
return;
|
|
4489
4876
|
// An old capture must not retire a new launch's transient guard either.
|
|
4490
4877
|
if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
|
|
4491
4878
|
this.resetFooterFallback();
|
|
@@ -4499,7 +4886,6 @@ export class Daemon extends EventEmitter {
|
|
|
4499
4886
|
this.resetFooterFallback();
|
|
4500
4887
|
return;
|
|
4501
4888
|
}
|
|
4502
|
-
this.updateInputBlockedState(pane);
|
|
4503
4889
|
// A monitor/lifecycle capture can see a busy moment between fleet polls.
|
|
4504
4890
|
// Even a stale capture of changed content disproves continuous stability;
|
|
4505
4891
|
// invalidate it before the output-freshness check can return early.
|
|
@@ -4512,6 +4898,7 @@ export class Daemon extends EventEmitter {
|
|
|
4512
4898
|
const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
|
|
4513
4899
|
|| (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt);
|
|
4514
4900
|
if (outputMovedDuringCapture) {
|
|
4901
|
+
this.unverifyInteraction(interactionCaptureAt, interactionCaptureOrder);
|
|
4515
4902
|
if (reason === "delivery_idle_gate")
|
|
4516
4903
|
this.resetFooterFallback();
|
|
4517
4904
|
if (reason === "output_probe") {
|
|
@@ -4537,6 +4924,11 @@ export class Daemon extends EventEmitter {
|
|
|
4537
4924
|
}
|
|
4538
4925
|
return;
|
|
4539
4926
|
}
|
|
4927
|
+
if (!currentDeliveryCapture() || !sameInteractionOwner(interactionOwner, this.interactionOwner()))
|
|
4928
|
+
return;
|
|
4929
|
+
if (!this.observeInteractionPane(pane, captureStartedAt, interactionCaptureAt, undefined, interactionCaptureOrder, reason !== "interaction_confirmation"))
|
|
4930
|
+
return;
|
|
4931
|
+
this.updateInputBlockedState(pane);
|
|
4540
4932
|
// Advance the shared 10s timer only for a fresh capture of this launch.
|
|
4541
4933
|
const deliveryFallback = deliveryCandidate === true
|
|
4542
4934
|
? this.deliveryInputReadyPane(pane) : deliveryCandidate;
|
|
@@ -4709,6 +5101,8 @@ export class Daemon extends EventEmitter {
|
|
|
4709
5101
|
}
|
|
4710
5102
|
}
|
|
4711
5103
|
catch (err) {
|
|
5104
|
+
if (currentDeliveryCapture() && sameInteractionOwner(interactionOwner, this.interactionOwner()))
|
|
5105
|
+
this.unverifyInteraction(interactionCaptureAt, interactionCaptureOrder);
|
|
4712
5106
|
if (reason === "delivery_idle_gate")
|
|
4713
5107
|
this.resetFooterFallback();
|
|
4714
5108
|
this.logger.debug({ err: err.message, reason }, "Instance state capture failed");
|
|
@@ -4724,6 +5118,7 @@ export class Daemon extends EventEmitter {
|
|
|
4724
5118
|
if (!windowId || event.windowId !== windowId || !this.instanceStateMachine)
|
|
4725
5119
|
return;
|
|
4726
5120
|
this.instanceStateLastOutputAt = event.at;
|
|
5121
|
+
this.interactionOutputRevision++;
|
|
4727
5122
|
const canProvePeriodicIdle = !!this.backend?.isPeriodicRedrawIdlePane;
|
|
4728
5123
|
if (canProvePeriodicIdle || (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle")) {
|
|
4729
5124
|
// Do not emit idle→working solely because agy repainted an identical
|
|
@@ -4787,6 +5182,7 @@ export class Daemon extends EventEmitter {
|
|
|
4787
5182
|
void this.captureAndEvaluateInstanceState("initial");
|
|
4788
5183
|
}
|
|
4789
5184
|
stopInstanceStateMonitor() {
|
|
5185
|
+
this.clearInteractionConfirmation();
|
|
4790
5186
|
this.instanceStateMonitorActive = false;
|
|
4791
5187
|
this.instanceStatePeriodicIdleConfirmations = 0;
|
|
4792
5188
|
this.clearInstanceStateIdleTimer();
|
|
@@ -4845,6 +5241,8 @@ export class Daemon extends EventEmitter {
|
|
|
4845
5241
|
freezeRuntimeMonitors() {
|
|
4846
5242
|
this.runtimeMonitorsFrozen = true;
|
|
4847
5243
|
this.launchFenceEpoch++;
|
|
5244
|
+
this.interactionObservation.reset(this.interactionOwner());
|
|
5245
|
+
this.clearInteractionConfirmation();
|
|
4848
5246
|
if (this.healthCheckTimer) {
|
|
4849
5247
|
clearTimeout(this.healthCheckTimer);
|
|
4850
5248
|
this.healthCheckTimer = null;
|
|
@@ -6113,10 +6511,18 @@ export class Daemon extends EventEmitter {
|
|
|
6113
6511
|
this.logger.info({ dialog: this.dialogParkedKey }, "Blocking dialog is gone from the pane");
|
|
6114
6512
|
this.dialogParkedSince = 0;
|
|
6115
6513
|
this.dialogParkedKey = null;
|
|
6514
|
+
this.dialogParkedInteractionEpisode = null;
|
|
6116
6515
|
this.dialogParkedReported = false;
|
|
6117
6516
|
return;
|
|
6118
6517
|
}
|
|
6119
6518
|
const key = Daemon.dialogKey(dialog);
|
|
6519
|
+
const interaction = this.getInteractionSnapshot();
|
|
6520
|
+
const episode = this.interactionDialogKey === key ? interaction.episode : null;
|
|
6521
|
+
if (episode !== null && this.dialogParkedInteractionEpisode !== null && episode !== this.dialogParkedInteractionEpisode) {
|
|
6522
|
+
this.dialogParkedSince = 0;
|
|
6523
|
+
}
|
|
6524
|
+
if (episode !== null)
|
|
6525
|
+
this.dialogParkedInteractionEpisode = episode;
|
|
6120
6526
|
if (this.dialogParkedSince === 0 || this.dialogParkedKey !== key) {
|
|
6121
6527
|
this.dialogParkedSince = Date.now();
|
|
6122
6528
|
this.dialogParkedKey = key;
|
|
@@ -6126,7 +6532,8 @@ export class Daemon extends EventEmitter {
|
|
|
6126
6532
|
if (!this.dialogParkedReported && Date.now() - this.dialogParkedSince >= DIALOG_PARKED_NOTIFY_MS) {
|
|
6127
6533
|
this.dialogParkedReported = true;
|
|
6128
6534
|
this.logger.warn({ dialog: dialog.description, parkedForMs: Date.now() - this.dialogParkedSince }, "CLI dialog is still on screen — not auto-answering, reporting for a human");
|
|
6129
|
-
this.emit("dialog_parked", { name: this.name, description: dialog.description, holdOnly: dialog.holdOnly === true
|
|
6535
|
+
this.emit("dialog_parked", { name: this.name, description: dialog.description, holdOnly: dialog.holdOnly === true,
|
|
6536
|
+
...(episode !== null && interaction.kind ? { kind: interaction.kind, episode, backend: this.config.backend ?? "claude-code" } : {}) });
|
|
6130
6537
|
}
|
|
6131
6538
|
}
|
|
6132
6539
|
/**
|
|
@@ -6882,6 +7289,13 @@ export class Daemon extends EventEmitter {
|
|
|
6882
7289
|
return false;
|
|
6883
7290
|
}
|
|
6884
7291
|
verdict.durableBeginCommitted = true;
|
|
7292
|
+
if (attemptEvidence.transcriptPath && attemptEvidence.transcriptOffset !== null) {
|
|
7293
|
+
verdict.transcriptCheckpoint = {
|
|
7294
|
+
backend: attemptEvidence.backend,
|
|
7295
|
+
path: attemptEvidence.transcriptPath,
|
|
7296
|
+
offset: attemptEvidence.transcriptOffset,
|
|
7297
|
+
};
|
|
7298
|
+
}
|
|
6885
7299
|
}
|
|
6886
7300
|
if (rawPaste)
|
|
6887
7301
|
verdict.paneWriteStarted = true;
|
|
@@ -7174,6 +7588,12 @@ export class Daemon extends EventEmitter {
|
|
|
7174
7588
|
if (becameBusy) {
|
|
7175
7589
|
if (status)
|
|
7176
7590
|
this.emit("message_confirmed", status); // ✅
|
|
7591
|
+
// A backend whose input row cannot be read was confirmed by "the pane printed something after Enter" — which a
|
|
7592
|
+
// redraw that wiped the paste also satisfies (#758). The outbox must not call that a submission proof.
|
|
7593
|
+
// A steer keeps its pre-#758 settlement (steer-accepted / positive submission proof): routing it into the
|
|
7594
|
+
// transcript-proof wait would hold a steer to `uncertain` whenever the CLI files no transcript marker (#1207 P2).
|
|
7595
|
+
if (!rawPaste && !steer && !this.canProveSubmission())
|
|
7596
|
+
verdict.proof = "output-edge";
|
|
7177
7597
|
}
|
|
7178
7598
|
else {
|
|
7179
7599
|
const proof = this.backend?.isDeliveryInputReadyPane
|
|
@@ -7725,6 +8145,12 @@ export class Daemon extends EventEmitter {
|
|
|
7725
8145
|
// for this turn: the agent proved it can still speak for itself.
|
|
7726
8146
|
if (!error && result != null && TURN_OUTBOUND_TOOLS.has(tool)) {
|
|
7727
8147
|
this.turnReplyGuard.settleToolAttempt(replyAttempt, true);
|
|
8148
|
+
// #1209 P2: the user got an answer — the turn is over for resume
|
|
8149
|
+
// purposes even before the idle edge completes the guard. Without
|
|
8150
|
+
// this, a restart in the reply-to-idle window would re-inject a
|
|
8151
|
+
// continuation for an already-answered turn.
|
|
8152
|
+
if (replyAttempt?.completionAction)
|
|
8153
|
+
clearInFlightTurnMarker(this.instanceDir);
|
|
7728
8154
|
}
|
|
7729
8155
|
const sent = this.ipcServer?.send(socket, { requestId, result, error, operationId }) ?? false;
|
|
7730
8156
|
if (!sent) {
|
|
@@ -7743,16 +8169,21 @@ export class Daemon extends EventEmitter {
|
|
|
7743
8169
|
this.handleReleaseRepo(args, respond);
|
|
7744
8170
|
return;
|
|
7745
8171
|
}
|
|
7746
|
-
if (tool === "set_display_name" || tool === "set_description" || tool === "list_emojis" || tool === "set_persona_emoji" || tool === "preview_emojis"
|
|
8172
|
+
if (tool === "set_display_name" || tool === "set_description" || tool === "list_emojis" || tool === "set_persona_emoji" || tool === "preview_emojis"
|
|
8173
|
+
|| tool === "list_stickers" || tool === "preview_stickers") {
|
|
7747
8174
|
const type = tool === "set_display_name" ? "fleet_set_display_name"
|
|
7748
8175
|
: tool === "set_description" ? "fleet_set_description"
|
|
7749
8176
|
: tool === "list_emojis" ? "fleet_list_emojis"
|
|
7750
|
-
: tool === "preview_emojis" ? "fleet_preview_emojis"
|
|
8177
|
+
: tool === "preview_emojis" ? "fleet_preview_emojis"
|
|
8178
|
+
: tool === "list_stickers" ? "fleet_list_stickers"
|
|
8179
|
+
: tool === "preview_stickers" ? "fleet_preview_stickers" : "fleet_set_persona_emoji";
|
|
7751
8180
|
const fleetReqId = `${tool === "set_display_name" ? "dn" : tool === "set_description" ? "desc" : "emoji"}_${++this.fleetRequestSeq}_${requestId}`;
|
|
7752
8181
|
const timeout = setTimeout(() => {
|
|
7753
8182
|
this.pendingIpcRequests.delete(fleetReqId);
|
|
7754
8183
|
respond(null, `${tool} timed out`);
|
|
7755
|
-
|
|
8184
|
+
// The shared budget (ipc-timeouts.ts), so the MCP ceiling stays above it and this message is the one seen;
|
|
8185
|
+
// a preview downloads pictures and needs more than the old fixed 10s.
|
|
8186
|
+
}, daemonBudgetMs(tool));
|
|
7756
8187
|
this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
|
|
7757
8188
|
clearTimeout(timeout);
|
|
7758
8189
|
respond(respMsg.result, respMsg.error);
|
|
@@ -8176,6 +8607,8 @@ export class Daemon extends EventEmitter {
|
|
|
8176
8607
|
this.turnReplyGuard.reset();
|
|
8177
8608
|
if (this.spawnDepth === 0) {
|
|
8178
8609
|
this.spawnGeneration++;
|
|
8610
|
+
this.interactionObservation.reset(this.interactionOwner());
|
|
8611
|
+
this.clearInteractionConfirmation();
|
|
8179
8612
|
this.inputTransientGuardGeneration = (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0
|
|
8180
8613
|
? this.spawnGeneration
|
|
8181
8614
|
: null;
|
|
@@ -8850,6 +9283,8 @@ export class Daemon extends EventEmitter {
|
|
|
8850
9283
|
// Every launched command re-arms the passive-transient check, including a
|
|
8851
9284
|
// retry inside the same spawn: its load is a new one.
|
|
8852
9285
|
this.launchAttempt++;
|
|
9286
|
+
this.interactionObservation.reset(this.interactionOwner());
|
|
9287
|
+
this.clearInteractionConfirmation();
|
|
8853
9288
|
// A launch that cannot paint a passive transient (Codex: a fresh start
|
|
8854
9289
|
// never shows "Resuming session…") must not honour one quoted on screen.
|
|
8855
9290
|
if (this.backend.launchMayShowInputTransient?.() === false)
|