@songsid/agend 2.1.11-beta.6 → 2.1.12-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -15
- package/README.zh-TW.md +50 -11
- package/dist/backend/claude-code.d.ts +2 -0
- package/dist/backend/claude-code.js +63 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/types.d.ts +25 -0
- package/dist/backend/types.js.map +1 -1
- 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/daemon.d.ts +41 -0
- package/dist/daemon.js +299 -16
- package/dist/daemon.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/fleet-manager.d.ts +14 -1
- package/dist/fleet-manager.js +75 -8
- package/dist/fleet-manager.js.map +1 -1
- 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 +22 -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.js +1 -1
- 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/usage/providers.d.ts +11 -0
- package/dist/usage/providers.js +34 -7
- package/dist/usage/providers.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -34,6 +34,8 @@ 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 { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
|
|
37
39
|
const __filename = fileURLToPath(import.meta.url);
|
|
38
40
|
const __dirname = dirname(__filename);
|
|
39
41
|
// Tool routing sets — module-level to avoid re-creation on every handleToolCall
|
|
@@ -440,6 +442,18 @@ const POST_ENTER_PROOF_WINDOW_MS = 3_000;
|
|
|
440
442
|
/** A final bounded observation before an ambiguous Codex submit can be classified. */
|
|
441
443
|
const CODEX_LATE_PROOF_MS = 5_000;
|
|
442
444
|
const POST_ENTER_PROOF_POLL_MS = 250;
|
|
445
|
+
/**
|
|
446
|
+
* A readerless CLI's delivery is proven by its own transcript, which lags the pane by a flush. Only a delta that was
|
|
447
|
+
* read and carried no marker for this long is held against the delivery — a late flush must not raise a false ⚠️.
|
|
448
|
+
* Ten seconds is a chosen grace window: success markers arrive in about a second, and the bound keeps the failure
|
|
449
|
+
* path small — the fleet lane and the shared pump budget stay held until the verdict (Phase 2c "lane held until
|
|
450
|
+
* verdict", Option A #1207), so a lost paste blocks its lane for ~10 s. No flush-visibility claim is made here: a
|
|
451
|
+
* flush landing after the window still settles `uncertain`, and only a final-file scan (see the PR evidence) shows
|
|
452
|
+
* every recorded delivery did reach its transcript.
|
|
453
|
+
*/
|
|
454
|
+
const TRANSCRIPT_PROOF_WINDOW_MS = 10_000;
|
|
455
|
+
const TRANSCRIPT_PROOF_POLL_MS = 1_000;
|
|
456
|
+
const OUTPUT_EDGE_ONLY_EVIDENCE = "output-edge-only; submission-unverifiable";
|
|
443
457
|
/** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
|
|
444
458
|
const BASELINE_CAPTURE_ATTEMPTS = 3;
|
|
445
459
|
const BASELINE_CAPTURE_RETRY_MS = 150;
|
|
@@ -851,17 +865,20 @@ export class InteractivePromptDetector {
|
|
|
851
865
|
constructor(stableMs = 10_000) {
|
|
852
866
|
this.stableMs = stableMs;
|
|
853
867
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
const tailText =
|
|
857
|
-
let matched = null;
|
|
868
|
+
/** Recognition is persistent; observe() separately deduplicates notifications. */
|
|
869
|
+
match(pane) {
|
|
870
|
+
const tailText = sanitizePaneTail(pane, 5).join("\n");
|
|
858
871
|
for (const candidate of INTERACTIVE_PROMPT_PATTERNS) {
|
|
859
872
|
const match = tailText.match(candidate.pattern);
|
|
860
|
-
if (
|
|
861
|
-
|
|
862
|
-
matched = { kind: candidate.kind, prompt: match[0].trim().slice(0, 200) };
|
|
863
|
-
break;
|
|
873
|
+
if (match)
|
|
874
|
+
return { kind: candidate.kind, prompt: match[0].trim().slice(0, 200) };
|
|
864
875
|
}
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
observe(pane, now = Date.now(), outputAt = 0) {
|
|
879
|
+
const tail = sanitizePaneTail(pane, 5);
|
|
880
|
+
const tailText = tail.join("\n");
|
|
881
|
+
const matched = this.match(pane);
|
|
865
882
|
if (!matched) {
|
|
866
883
|
this.reset();
|
|
867
884
|
this.lastOutputAt = outputAt;
|
|
@@ -1055,6 +1072,7 @@ export class Daemon extends EventEmitter {
|
|
|
1055
1072
|
dialogParkedSince = 0;
|
|
1056
1073
|
/** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
|
|
1057
1074
|
dialogParkedKey = null;
|
|
1075
|
+
dialogParkedInteractionEpisode = null;
|
|
1058
1076
|
dialogParkedReported = false;
|
|
1059
1077
|
/**
|
|
1060
1078
|
* The runtime dialog the monitor is answering, and how many of its answers in a row did nothing (the dialog was
|
|
@@ -1080,6 +1098,13 @@ export class Daemon extends EventEmitter {
|
|
|
1080
1098
|
capacityNudge = null;
|
|
1081
1099
|
/** Current stdin-owning runtime dialog, independent from execution state. */
|
|
1082
1100
|
inputBlockedDialogKey = null;
|
|
1101
|
+
interactionObservation = new InteractionObservation();
|
|
1102
|
+
interactionConfirmationTimer = null;
|
|
1103
|
+
interactionOutputRevision = 0;
|
|
1104
|
+
interactionObservedOutputRevision = 0;
|
|
1105
|
+
interactionCaptureSerial = 0;
|
|
1106
|
+
interactionDialogKey = null;
|
|
1107
|
+
acceptedInstanceSnapshot = null;
|
|
1083
1108
|
/** Generation/signature fence for safety dialogs while their screen is still visible. */
|
|
1084
1109
|
autoResolvedDialogKey = null;
|
|
1085
1110
|
autoResolvedDialogGeneration = 0;
|
|
@@ -1633,8 +1658,82 @@ export class Daemon extends EventEmitter {
|
|
|
1633
1658
|
this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable");
|
|
1634
1659
|
return;
|
|
1635
1660
|
}
|
|
1661
|
+
// A backend whose input row cannot be read: all that vouched for this delivery is that the pane printed something
|
|
1662
|
+
// after Enter, which a redraw that wiped the paste satisfies too (#758). Its transcript can say more.
|
|
1663
|
+
if (verdict.proof === "output-edge") {
|
|
1664
|
+
const checkpoint = verdict.transcriptCheckpoint;
|
|
1665
|
+
if (delivery && checkpoint && ["claude-code", "codex"].includes(checkpoint.backend)) {
|
|
1666
|
+
void this.proveDeliveryFromTranscript(delivery, checkpoint);
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
// No transcript to ask: nothing to hold against it and nothing to prove it with. Today's outcome, honestly labelled.
|
|
1670
|
+
this.finishDurableDelivery(delivery, "delivered", OUTPUT_EDGE_ONLY_EVIDENCE);
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1636
1673
|
this.finishDurableDelivery(delivery, "delivered", "positive submission proof");
|
|
1637
1674
|
}
|
|
1675
|
+
/**
|
|
1676
|
+
* Finish a delivery that only the output edge vouched for, by the delivery's own marker in the CLI's transcript (#758).
|
|
1677
|
+
*
|
|
1678
|
+
* - marker found → delivered (`transcript-marker`; `transcript-marker-queued` when the CLI has it queued, not yet read);
|
|
1679
|
+
* - delta read, no marker for the whole window → uncertain (`unverifiable-no-transcript-marker`) — the pane printed
|
|
1680
|
+
* something, yet the CLI never took the message in. Never a re-paste: the message may still land;
|
|
1681
|
+
* - transcript unreadable on the last look → today's outcome, labelled — there is nothing to hold against it.
|
|
1682
|
+
*
|
|
1683
|
+
* It runs after the paste lock is released, but the fleet lane and the shared pump budget stay held until the
|
|
1684
|
+
* verdict settles the row (Phase 2c "lane held until verdict", Option A #1207): a success settles in about a
|
|
1685
|
+
* second, a delivery whose marker never arrives holds them for the bounded proof window (see
|
|
1686
|
+
* TRANSCRIPT_PROOF_WINDOW_MS). It never finishes a row that stop has handed to reconciliation; a pause or respawn
|
|
1687
|
+
* only ends the wait, falling back to today's outcome.
|
|
1688
|
+
*/
|
|
1689
|
+
async proveDeliveryFromTranscript(delivery, checkpoint) {
|
|
1690
|
+
const spawn = this.spawnGeneration;
|
|
1691
|
+
const fence = this.launchFenceEpoch;
|
|
1692
|
+
let last = "unavailable";
|
|
1693
|
+
let outcome = "delivered";
|
|
1694
|
+
let evidence = OUTPUT_EDGE_ONLY_EVIDENCE;
|
|
1695
|
+
try {
|
|
1696
|
+
for (let polls = 0; polls <= TRANSCRIPT_PROOF_WINDOW_MS / TRANSCRIPT_PROOF_POLL_MS; polls++) {
|
|
1697
|
+
if (this.deliveryWritesStopping)
|
|
1698
|
+
return; // reconciliation owns the row now
|
|
1699
|
+
const found = await scanTranscriptForDeliveryMarker(checkpoint.path, checkpoint.offset, checkpoint.backend, delivery.deliveryId);
|
|
1700
|
+
if (found === "user" || found === "queued") {
|
|
1701
|
+
outcome = "delivered";
|
|
1702
|
+
evidence = found === "user" ? "transcript-marker" : "transcript-marker-queued";
|
|
1703
|
+
break;
|
|
1704
|
+
}
|
|
1705
|
+
last = found;
|
|
1706
|
+
if (spawn !== this.spawnGeneration || fence !== this.launchFenceEpoch) {
|
|
1707
|
+
last = "unavailable";
|
|
1708
|
+
break;
|
|
1709
|
+
}
|
|
1710
|
+
await new Promise(r => setTimeout(r, TRANSCRIPT_PROOF_POLL_MS));
|
|
1711
|
+
}
|
|
1712
|
+
// Only a delta that was read on the last look and still lacks the marker is held against the delivery. A
|
|
1713
|
+
// pause/freeze or respawn during the final sleep must end the wait with today's labelled outcome, not
|
|
1714
|
+
// `uncertain`: re-check the same fixed epochs after the last await, before settling (#1207 P2).
|
|
1715
|
+
if (spawn !== this.spawnGeneration || fence !== this.launchFenceEpoch)
|
|
1716
|
+
last = "unavailable";
|
|
1717
|
+
if (last === "no-match" && evidence === OUTPUT_EDGE_ONLY_EVIDENCE) {
|
|
1718
|
+
outcome = "uncertain";
|
|
1719
|
+
evidence = "unverifiable-no-transcript-marker";
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
catch (err) {
|
|
1723
|
+
this.logger.debug({ err }, "Transcript proof of a delivery failed — keeping the output-edge outcome");
|
|
1724
|
+
}
|
|
1725
|
+
// The settlement itself can throw (e.g. the outbox store is unhealthy), and this runs on a detached `void`
|
|
1726
|
+
// promise: an uncaught throw is an unhandled rejection, which the fleet restart path turns into a global fault.
|
|
1727
|
+
// Settle observably and leave the row for reconciliation — never a false completion, never another paste (#1207 P2).
|
|
1728
|
+
try {
|
|
1729
|
+
if (this.deliveryWritesStopping)
|
|
1730
|
+
return;
|
|
1731
|
+
this.finishDurableDelivery(delivery, outcome, evidence);
|
|
1732
|
+
}
|
|
1733
|
+
catch (err) {
|
|
1734
|
+
this.logger.error({ err, deliveryId: delivery.deliveryId, attemptNo: delivery.attemptNo, outcome, evidence }, "Durable delivery settlement failed — row left for reconciliation");
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1638
1737
|
deferDurableDelivery(meta, reason) {
|
|
1639
1738
|
const delivery = this.durableDeliveryAttempt(meta);
|
|
1640
1739
|
if (delivery === null || delivery === false)
|
|
@@ -1691,6 +1790,19 @@ export class Daemon extends EventEmitter {
|
|
|
1691
1790
|
return false;
|
|
1692
1791
|
}
|
|
1693
1792
|
}
|
|
1793
|
+
/**
|
|
1794
|
+
* #1206: rotate + attach the pipe-pane log feed. An attach failure warns
|
|
1795
|
+
* (like the wake path) instead of vanishing silently and leaving the
|
|
1796
|
+
* instance with no log feed. Extracted so the failure path is unit-testable
|
|
1797
|
+
* without driving the real lifecycle.
|
|
1798
|
+
*/
|
|
1799
|
+
async attachPipePaneLog() {
|
|
1800
|
+
const outputLog = join(this.instanceDir, "output.log");
|
|
1801
|
+
await rotateLogIfNeededAsync(outputLog);
|
|
1802
|
+
await this.tmux.pipeOutput(outputLog).catch(err => {
|
|
1803
|
+
this.logger.warn({ err }, "Failed to attach pipe-pane — instance output log will be missing");
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1694
1806
|
async start() {
|
|
1695
1807
|
ensureInstanceDir(this.instanceDir);
|
|
1696
1808
|
writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
|
|
@@ -1854,9 +1966,7 @@ export class Daemon extends EventEmitter {
|
|
|
1854
1966
|
// previous stuck splash (hundreds of MB of ANSI frames) is truncated before
|
|
1855
1967
|
// we attach — pipe-pane uses `cat >>` on the same inode, so copytruncate
|
|
1856
1968
|
// keeps the writer attached after size resets.
|
|
1857
|
-
|
|
1858
|
-
await rotateLogIfNeededAsync(outputLog);
|
|
1859
|
-
await this.tmux.pipeOutput(outputLog).catch(() => { });
|
|
1969
|
+
await this.attachPipePaneLog();
|
|
1860
1970
|
// 4. Transcript monitor. claude-code is handled inside the monitor
|
|
1861
1971
|
// (statusline transcript); codex/kiro/opencode read their CLI's own
|
|
1862
1972
|
// conversation store via a pluggable source. Backends with no known
|
|
@@ -2811,14 +2921,28 @@ export class Daemon extends EventEmitter {
|
|
|
2811
2921
|
// a pause, a respawn); a stale poll must not touch state a newer one owns, so it checks before committing.
|
|
2812
2922
|
const pollSpawn = this.spawnGeneration;
|
|
2813
2923
|
const pollFence = this.launchFenceEpoch;
|
|
2814
|
-
const
|
|
2924
|
+
const pollOwner = this.interactionOwner();
|
|
2925
|
+
const captureOrder = ++this.interactionCaptureSerial;
|
|
2926
|
+
const captureMono = performance.now();
|
|
2927
|
+
const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch
|
|
2928
|
+
|| !sameInteractionOwner(pollOwner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning;
|
|
2815
2929
|
try {
|
|
2816
2930
|
const alive = await this.tmux.isWindowAlive();
|
|
2931
|
+
if (stale())
|
|
2932
|
+
return;
|
|
2817
2933
|
if (!alive)
|
|
2818
2934
|
return;
|
|
2935
|
+
const captureAt = Date.now();
|
|
2819
2936
|
const pane = await this.tmux.capturePane();
|
|
2820
2937
|
if (stale())
|
|
2821
2938
|
return;
|
|
2939
|
+
if (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureAt) {
|
|
2940
|
+
this.unverifyInteraction(captureMono, captureOrder);
|
|
2941
|
+
}
|
|
2942
|
+
else {
|
|
2943
|
+
if (!this.observeInteractionPane(pane, captureAt, captureMono, dialogs, captureOrder))
|
|
2944
|
+
return;
|
|
2945
|
+
}
|
|
2822
2946
|
this.takeNudgeBaselines(pane, patterns);
|
|
2823
2947
|
this.tickCapacityNudge(pane);
|
|
2824
2948
|
const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
|
|
@@ -2901,12 +3025,21 @@ export class Daemon extends EventEmitter {
|
|
|
2901
3025
|
// Re-read under the lock: the first capture may have gone stale
|
|
2902
3026
|
// while a delivery was finishing. A stale danger menu must never
|
|
2903
3027
|
// receive a blind key sequence.
|
|
3028
|
+
const currentAt = Date.now();
|
|
3029
|
+
const currentMono = performance.now();
|
|
3030
|
+
const currentOrder = ++this.interactionCaptureSerial;
|
|
2904
3031
|
const currentPane = await this.tmux.capturePane();
|
|
2905
3032
|
// Every await below can outlive the spawn / monitors this poll started under: after each one, before
|
|
2906
3033
|
// any state is touched or any further key is sent, `stale()` (fixed at the poll's start) is asked again.
|
|
2907
3034
|
if (stale())
|
|
2908
3035
|
return;
|
|
2909
3036
|
if (!Daemon.dialogMatches(dialog, currentPane)) {
|
|
3037
|
+
if (this.instanceStateLastOutputAt >= currentAt && this.instanceStateLastOutputAt > 0) {
|
|
3038
|
+
this.unverifyInteraction(currentMono, currentOrder);
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
if (!this.observeInteractionPane(currentPane, currentAt, currentMono, dialogs, currentOrder))
|
|
3042
|
+
return;
|
|
2910
3043
|
this.updateInputBlockedState(currentPane, dialogs);
|
|
2911
3044
|
this.endDialogEpisode(); // somebody (or the CLI) already cleared it: that screen is over
|
|
2912
3045
|
return;
|
|
@@ -2952,6 +3085,9 @@ export class Daemon extends EventEmitter {
|
|
|
2952
3085
|
return;
|
|
2953
3086
|
}
|
|
2954
3087
|
if (dialog.verifyAfterKeys) {
|
|
3088
|
+
const afterKeysAt = Date.now();
|
|
3089
|
+
const afterKeysMono = performance.now();
|
|
3090
|
+
const afterKeysOrder = ++this.interactionCaptureSerial;
|
|
2955
3091
|
const afterKeysPane = await this.tmux.capturePane();
|
|
2956
3092
|
if (stale())
|
|
2957
3093
|
return;
|
|
@@ -2962,6 +3098,12 @@ export class Daemon extends EventEmitter {
|
|
|
2962
3098
|
this.logger.warn({ dialog: dialog.description }, "Runtime dialog remained after its safety choice");
|
|
2963
3099
|
return;
|
|
2964
3100
|
}
|
|
3101
|
+
if (this.instanceStateLastOutputAt >= afterKeysAt && this.instanceStateLastOutputAt > 0) {
|
|
3102
|
+
this.unverifyInteraction(afterKeysMono, afterKeysOrder);
|
|
3103
|
+
return;
|
|
3104
|
+
}
|
|
3105
|
+
if (!this.observeInteractionPane(afterKeysPane, afterKeysAt, afterKeysMono, dialogs, afterKeysOrder))
|
|
3106
|
+
return;
|
|
2965
3107
|
this.updateInputBlockedState(afterKeysPane, dialogs);
|
|
2966
3108
|
if (dialog.postDismissNotice) {
|
|
2967
3109
|
resolved = await this.submitSystemPaste(dialog.postDismissNotice.text, dialog.postDismissNotice.label);
|
|
@@ -3010,6 +3152,8 @@ export class Daemon extends EventEmitter {
|
|
|
3010
3152
|
this.evaluateErrorPatterns(pane, patterns, readyPattern, Date.now(), busyPattern);
|
|
3011
3153
|
}
|
|
3012
3154
|
catch {
|
|
3155
|
+
if (!stale())
|
|
3156
|
+
this.unverifyInteraction(captureMono, captureOrder);
|
|
3013
3157
|
// capturePane can fail if window is transitioning — ignore
|
|
3014
3158
|
}
|
|
3015
3159
|
}, 5_000); // Check every 5 seconds (runtime dialogs need fast response)
|
|
@@ -3683,12 +3827,107 @@ export class Daemon extends EventEmitter {
|
|
|
3683
3827
|
return this.isPaused ? "paused" : this.instanceState;
|
|
3684
3828
|
}
|
|
3685
3829
|
getInstanceStateSnapshot() {
|
|
3686
|
-
|
|
3830
|
+
const snapshot = this.instanceStateMachine?.snapshot() ?? {
|
|
3687
3831
|
state: this.instanceState,
|
|
3688
3832
|
unchangedForMs: 0,
|
|
3689
3833
|
observedAt: Date.now(),
|
|
3690
3834
|
stateChangedAt: Date.now(),
|
|
3691
3835
|
};
|
|
3836
|
+
// A machine observation rejected by the modal hold never became a control
|
|
3837
|
+
// state. A query must not publish that unaccepted idle as permission to act.
|
|
3838
|
+
if (this.inputBlockedDialogKey !== null) {
|
|
3839
|
+
snapshot.state = this.instanceState;
|
|
3840
|
+
snapshot.stateChangedAt = this.acceptedInstanceSnapshot?.stateChangedAt ?? snapshot.stateChangedAt;
|
|
3841
|
+
}
|
|
3842
|
+
return { ...snapshot, interaction: this.getInteractionSnapshot() };
|
|
3843
|
+
}
|
|
3844
|
+
interactionOwner() {
|
|
3845
|
+
return { bootId: this.bootId, spawnGeneration: this.spawnGeneration,
|
|
3846
|
+
launchAttempt: this.launchAttempt, launchFenceEpoch: this.launchFenceEpoch };
|
|
3847
|
+
}
|
|
3848
|
+
/** Cached, pure observation read; it never captures, constructs a backend or writes stdin. */
|
|
3849
|
+
getInteractionSnapshot() {
|
|
3850
|
+
return this.interactionObservation.snapshot(this.interactionOwner());
|
|
3851
|
+
}
|
|
3852
|
+
publishInteraction() {
|
|
3853
|
+
const interaction = this.getInteractionSnapshot();
|
|
3854
|
+
// Presentation telemetry must not interrupt the execution-state/hold path.
|
|
3855
|
+
try {
|
|
3856
|
+
this.emit("instance_interaction", { name: this.name, interaction });
|
|
3857
|
+
}
|
|
3858
|
+
catch (err) {
|
|
3859
|
+
this.logger.debug({ err }, "Interaction listener failed");
|
|
3860
|
+
}
|
|
3861
|
+
try {
|
|
3862
|
+
this.ipcServer?.broadcast({ type: "instance_interaction", instanceName: this.name, interaction });
|
|
3863
|
+
}
|
|
3864
|
+
catch (err) {
|
|
3865
|
+
this.logger.debug({ err }, "Interaction broadcast failed");
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
clearInteractionConfirmation() {
|
|
3869
|
+
if (this.interactionConfirmationTimer)
|
|
3870
|
+
clearTimeout(this.interactionConfirmationTimer);
|
|
3871
|
+
this.interactionConfirmationTimer = null;
|
|
3872
|
+
}
|
|
3873
|
+
unverifyInteraction(monotonicAt = performance.now(), order = ++this.interactionCaptureSerial) {
|
|
3874
|
+
if (!this.interactionObservation.unverify(this.interactionOwner(), monotonicAt, order))
|
|
3875
|
+
return;
|
|
3876
|
+
this.clearInteractionConfirmation();
|
|
3877
|
+
this.publishInteraction();
|
|
3878
|
+
}
|
|
3879
|
+
interactionEvidence(pane, dialogs) {
|
|
3880
|
+
const login = LOGIN_FLOWS[this.config.backend ?? "claude-code"];
|
|
3881
|
+
let evidence = null;
|
|
3882
|
+
if (login?.loginScreenActive?.(pane)) {
|
|
3883
|
+
evidence = { kind: "login", identity: pane.trim() };
|
|
3884
|
+
}
|
|
3885
|
+
else {
|
|
3886
|
+
// Bare backend regexes are not proof of a live modal. The canonical
|
|
3887
|
+
// isActive predicate replaces pattern.test(), preserving table order.
|
|
3888
|
+
const dialog = dialogs.find(d => d.isActive && (d.inputBlocked || d.blocksDelivery || d.holdOnly)
|
|
3889
|
+
&& Daemon.dialogMatches(d, pane));
|
|
3890
|
+
if (dialog) {
|
|
3891
|
+
const identity = dialog.requestIdentity?.(pane) ?? pane.replace(/\r/g, "")
|
|
3892
|
+
.split("\n").map(row => row.replace(/^([ \t]*)[❯›]([ \t]+)(?=\d\.|Yes\b|No\b|Resume\b)/, "$1$2").trimEnd())
|
|
3893
|
+
.filter(row => row.trim() !== "").join("\n");
|
|
3894
|
+
evidence = { kind: dialog.interactionKind ?? "dialog", dialogKey: Daemon.dialogKey(dialog), identity: `${dialog.pattern.source}\n${identity}` };
|
|
3895
|
+
}
|
|
3896
|
+
else {
|
|
3897
|
+
const weak = this.interactivePromptDetector.match(pane);
|
|
3898
|
+
if (weak)
|
|
3899
|
+
evidence = { kind: "suspected_terminal_input", identity: sanitizePaneTail(pane, 5).join("\n"), suspected: true };
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
3902
|
+
// No captured text leaves this function or the private observer identity.
|
|
3903
|
+
return evidence ? { ...evidence, identity: createHash("sha256").update(evidence.identity).digest("hex") } : null;
|
|
3904
|
+
}
|
|
3905
|
+
observeInteractionPane(pane, observedAt, monotonicAt, dialogs = this.backend?.getRuntimeDialogs?.() ?? [], order = ++this.interactionCaptureSerial, allowConfirmation = true) {
|
|
3906
|
+
const owner = this.interactionOwner();
|
|
3907
|
+
const evidence = this.interactionEvidence(pane, dialogs);
|
|
3908
|
+
if (!this.interactionObservation.observe(evidence, owner, observedAt, monotonicAt, this.interactionOutputRevision !== this.interactionObservedOutputRevision, order))
|
|
3909
|
+
return false;
|
|
3910
|
+
this.interactionDialogKey = evidence?.dialogKey ?? null;
|
|
3911
|
+
this.interactionObservedOutputRevision = this.interactionOutputRevision;
|
|
3912
|
+
const interaction = this.getInteractionSnapshot();
|
|
3913
|
+
this.publishInteraction();
|
|
3914
|
+
if (interaction.phase !== "candidate" || interaction.suspected) {
|
|
3915
|
+
this.clearInteractionConfirmation();
|
|
3916
|
+
return true;
|
|
3917
|
+
}
|
|
3918
|
+
// One bounded extra read per ordinary observation. A confirmation that
|
|
3919
|
+
// finds a changed request waits for the existing monitor's next capture;
|
|
3920
|
+
// it cannot recursively create a permanent fast poll.
|
|
3921
|
+
if (allowConfirmation && !this.interactionConfirmationTimer) {
|
|
3922
|
+
this.interactionConfirmationTimer = setTimeout(() => {
|
|
3923
|
+
this.interactionConfirmationTimer = null;
|
|
3924
|
+
if (!sameInteractionOwner(owner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning)
|
|
3925
|
+
return;
|
|
3926
|
+
void this.captureAndEvaluateInstanceState("interaction_confirmation");
|
|
3927
|
+
}, INTERACTION_CONFIRM_MS);
|
|
3928
|
+
this.interactionConfirmationTimer.unref?.();
|
|
3929
|
+
}
|
|
3930
|
+
return true;
|
|
3692
3931
|
}
|
|
3693
3932
|
/**
|
|
3694
3933
|
* Answer a FleetManager state query. Most callers only need the cached state,
|
|
@@ -3954,6 +4193,7 @@ export class Daemon extends EventEmitter {
|
|
|
3954
4193
|
return;
|
|
3955
4194
|
}
|
|
3956
4195
|
this.instanceState = snapshot.state;
|
|
4196
|
+
this.acceptedInstanceSnapshot = { ...snapshot };
|
|
3957
4197
|
// OpenCode creates its session lazily on the first submitted message.
|
|
3958
4198
|
// Waiting until stop/pause to persist that id loses resume state when the
|
|
3959
4199
|
// fleet is SIGKILLed or the host reboots.
|
|
@@ -4481,11 +4721,17 @@ export class Daemon extends EventEmitter {
|
|
|
4481
4721
|
}
|
|
4482
4722
|
this.statePollInFlight = true;
|
|
4483
4723
|
const captureStartedAt = Date.now();
|
|
4724
|
+
const interactionCaptureAt = performance.now();
|
|
4725
|
+
const interactionCaptureOrder = ++this.interactionCaptureSerial;
|
|
4726
|
+
const interactionOwner = this.interactionOwner();
|
|
4484
4727
|
const captureEpoch = `${this.spawnGeneration}:${this.launchAttempt}`;
|
|
4485
4728
|
const currentDeliveryCapture = () => captureEpoch === `${this.spawnGeneration}:${this.launchAttempt}`
|
|
4486
4729
|
&& !this.spawning && !this.runtimeMonitorsFrozen && this.instanceStateMonitorActive;
|
|
4487
4730
|
try {
|
|
4488
|
-
const pane =
|
|
4731
|
+
const pane = reason === "interaction_confirmation"
|
|
4732
|
+
? await this.tmux.capturePane(1_000) : await this.tmux.capturePane();
|
|
4733
|
+
if (!currentDeliveryCapture() || !sameInteractionOwner(interactionOwner, this.interactionOwner()))
|
|
4734
|
+
return;
|
|
4489
4735
|
// An old capture must not retire a new launch's transient guard either.
|
|
4490
4736
|
if (reason === "delivery_idle_gate" && !currentDeliveryCapture()) {
|
|
4491
4737
|
this.resetFooterFallback();
|
|
@@ -4499,7 +4745,6 @@ export class Daemon extends EventEmitter {
|
|
|
4499
4745
|
this.resetFooterFallback();
|
|
4500
4746
|
return;
|
|
4501
4747
|
}
|
|
4502
|
-
this.updateInputBlockedState(pane);
|
|
4503
4748
|
// A monitor/lifecycle capture can see a busy moment between fleet polls.
|
|
4504
4749
|
// Even a stale capture of changed content disproves continuous stability;
|
|
4505
4750
|
// invalidate it before the output-freshness check can return early.
|
|
@@ -4512,6 +4757,7 @@ export class Daemon extends EventEmitter {
|
|
|
4512
4757
|
const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
|
|
4513
4758
|
|| (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureStartedAt);
|
|
4514
4759
|
if (outputMovedDuringCapture) {
|
|
4760
|
+
this.unverifyInteraction(interactionCaptureAt, interactionCaptureOrder);
|
|
4515
4761
|
if (reason === "delivery_idle_gate")
|
|
4516
4762
|
this.resetFooterFallback();
|
|
4517
4763
|
if (reason === "output_probe") {
|
|
@@ -4537,6 +4783,11 @@ export class Daemon extends EventEmitter {
|
|
|
4537
4783
|
}
|
|
4538
4784
|
return;
|
|
4539
4785
|
}
|
|
4786
|
+
if (!currentDeliveryCapture() || !sameInteractionOwner(interactionOwner, this.interactionOwner()))
|
|
4787
|
+
return;
|
|
4788
|
+
if (!this.observeInteractionPane(pane, captureStartedAt, interactionCaptureAt, undefined, interactionCaptureOrder, reason !== "interaction_confirmation"))
|
|
4789
|
+
return;
|
|
4790
|
+
this.updateInputBlockedState(pane);
|
|
4540
4791
|
// Advance the shared 10s timer only for a fresh capture of this launch.
|
|
4541
4792
|
const deliveryFallback = deliveryCandidate === true
|
|
4542
4793
|
? this.deliveryInputReadyPane(pane) : deliveryCandidate;
|
|
@@ -4709,6 +4960,8 @@ export class Daemon extends EventEmitter {
|
|
|
4709
4960
|
}
|
|
4710
4961
|
}
|
|
4711
4962
|
catch (err) {
|
|
4963
|
+
if (currentDeliveryCapture() && sameInteractionOwner(interactionOwner, this.interactionOwner()))
|
|
4964
|
+
this.unverifyInteraction(interactionCaptureAt, interactionCaptureOrder);
|
|
4712
4965
|
if (reason === "delivery_idle_gate")
|
|
4713
4966
|
this.resetFooterFallback();
|
|
4714
4967
|
this.logger.debug({ err: err.message, reason }, "Instance state capture failed");
|
|
@@ -4724,6 +4977,7 @@ export class Daemon extends EventEmitter {
|
|
|
4724
4977
|
if (!windowId || event.windowId !== windowId || !this.instanceStateMachine)
|
|
4725
4978
|
return;
|
|
4726
4979
|
this.instanceStateLastOutputAt = event.at;
|
|
4980
|
+
this.interactionOutputRevision++;
|
|
4727
4981
|
const canProvePeriodicIdle = !!this.backend?.isPeriodicRedrawIdlePane;
|
|
4728
4982
|
if (canProvePeriodicIdle || (this.backend?.hasPeriodicPaneRedraw?.() === true && this.instanceState === "idle")) {
|
|
4729
4983
|
// Do not emit idle→working solely because agy repainted an identical
|
|
@@ -4787,6 +5041,7 @@ export class Daemon extends EventEmitter {
|
|
|
4787
5041
|
void this.captureAndEvaluateInstanceState("initial");
|
|
4788
5042
|
}
|
|
4789
5043
|
stopInstanceStateMonitor() {
|
|
5044
|
+
this.clearInteractionConfirmation();
|
|
4790
5045
|
this.instanceStateMonitorActive = false;
|
|
4791
5046
|
this.instanceStatePeriodicIdleConfirmations = 0;
|
|
4792
5047
|
this.clearInstanceStateIdleTimer();
|
|
@@ -4845,6 +5100,8 @@ export class Daemon extends EventEmitter {
|
|
|
4845
5100
|
freezeRuntimeMonitors() {
|
|
4846
5101
|
this.runtimeMonitorsFrozen = true;
|
|
4847
5102
|
this.launchFenceEpoch++;
|
|
5103
|
+
this.interactionObservation.reset(this.interactionOwner());
|
|
5104
|
+
this.clearInteractionConfirmation();
|
|
4848
5105
|
if (this.healthCheckTimer) {
|
|
4849
5106
|
clearTimeout(this.healthCheckTimer);
|
|
4850
5107
|
this.healthCheckTimer = null;
|
|
@@ -6113,10 +6370,18 @@ export class Daemon extends EventEmitter {
|
|
|
6113
6370
|
this.logger.info({ dialog: this.dialogParkedKey }, "Blocking dialog is gone from the pane");
|
|
6114
6371
|
this.dialogParkedSince = 0;
|
|
6115
6372
|
this.dialogParkedKey = null;
|
|
6373
|
+
this.dialogParkedInteractionEpisode = null;
|
|
6116
6374
|
this.dialogParkedReported = false;
|
|
6117
6375
|
return;
|
|
6118
6376
|
}
|
|
6119
6377
|
const key = Daemon.dialogKey(dialog);
|
|
6378
|
+
const interaction = this.getInteractionSnapshot();
|
|
6379
|
+
const episode = this.interactionDialogKey === key ? interaction.episode : null;
|
|
6380
|
+
if (episode !== null && this.dialogParkedInteractionEpisode !== null && episode !== this.dialogParkedInteractionEpisode) {
|
|
6381
|
+
this.dialogParkedSince = 0;
|
|
6382
|
+
}
|
|
6383
|
+
if (episode !== null)
|
|
6384
|
+
this.dialogParkedInteractionEpisode = episode;
|
|
6120
6385
|
if (this.dialogParkedSince === 0 || this.dialogParkedKey !== key) {
|
|
6121
6386
|
this.dialogParkedSince = Date.now();
|
|
6122
6387
|
this.dialogParkedKey = key;
|
|
@@ -6126,7 +6391,8 @@ export class Daemon extends EventEmitter {
|
|
|
6126
6391
|
if (!this.dialogParkedReported && Date.now() - this.dialogParkedSince >= DIALOG_PARKED_NOTIFY_MS) {
|
|
6127
6392
|
this.dialogParkedReported = true;
|
|
6128
6393
|
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
|
|
6394
|
+
this.emit("dialog_parked", { name: this.name, description: dialog.description, holdOnly: dialog.holdOnly === true,
|
|
6395
|
+
...(episode !== null && interaction.kind ? { kind: interaction.kind, episode, backend: this.config.backend ?? "claude-code" } : {}) });
|
|
6130
6396
|
}
|
|
6131
6397
|
}
|
|
6132
6398
|
/**
|
|
@@ -6882,6 +7148,13 @@ export class Daemon extends EventEmitter {
|
|
|
6882
7148
|
return false;
|
|
6883
7149
|
}
|
|
6884
7150
|
verdict.durableBeginCommitted = true;
|
|
7151
|
+
if (attemptEvidence.transcriptPath && attemptEvidence.transcriptOffset !== null) {
|
|
7152
|
+
verdict.transcriptCheckpoint = {
|
|
7153
|
+
backend: attemptEvidence.backend,
|
|
7154
|
+
path: attemptEvidence.transcriptPath,
|
|
7155
|
+
offset: attemptEvidence.transcriptOffset,
|
|
7156
|
+
};
|
|
7157
|
+
}
|
|
6885
7158
|
}
|
|
6886
7159
|
if (rawPaste)
|
|
6887
7160
|
verdict.paneWriteStarted = true;
|
|
@@ -7174,6 +7447,12 @@ export class Daemon extends EventEmitter {
|
|
|
7174
7447
|
if (becameBusy) {
|
|
7175
7448
|
if (status)
|
|
7176
7449
|
this.emit("message_confirmed", status); // ✅
|
|
7450
|
+
// A backend whose input row cannot be read was confirmed by "the pane printed something after Enter" — which a
|
|
7451
|
+
// redraw that wiped the paste also satisfies (#758). The outbox must not call that a submission proof.
|
|
7452
|
+
// A steer keeps its pre-#758 settlement (steer-accepted / positive submission proof): routing it into the
|
|
7453
|
+
// transcript-proof wait would hold a steer to `uncertain` whenever the CLI files no transcript marker (#1207 P2).
|
|
7454
|
+
if (!rawPaste && !steer && !this.canProveSubmission())
|
|
7455
|
+
verdict.proof = "output-edge";
|
|
7177
7456
|
}
|
|
7178
7457
|
else {
|
|
7179
7458
|
const proof = this.backend?.isDeliveryInputReadyPane
|
|
@@ -8176,6 +8455,8 @@ export class Daemon extends EventEmitter {
|
|
|
8176
8455
|
this.turnReplyGuard.reset();
|
|
8177
8456
|
if (this.spawnDepth === 0) {
|
|
8178
8457
|
this.spawnGeneration++;
|
|
8458
|
+
this.interactionObservation.reset(this.interactionOwner());
|
|
8459
|
+
this.clearInteractionConfirmation();
|
|
8179
8460
|
this.inputTransientGuardGeneration = (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0
|
|
8180
8461
|
? this.spawnGeneration
|
|
8181
8462
|
: null;
|
|
@@ -8850,6 +9131,8 @@ export class Daemon extends EventEmitter {
|
|
|
8850
9131
|
// Every launched command re-arms the passive-transient check, including a
|
|
8851
9132
|
// retry inside the same spawn: its load is a new one.
|
|
8852
9133
|
this.launchAttempt++;
|
|
9134
|
+
this.interactionObservation.reset(this.interactionOwner());
|
|
9135
|
+
this.clearInteractionConfirmation();
|
|
8853
9136
|
// A launch that cannot paint a passive transient (Codex: a fresh start
|
|
8854
9137
|
// never shows "Resuming session…") must not honour one quoted on screen.
|
|
8855
9138
|
if (this.backend.launchMayShowInputTransient?.() === false)
|