@songsid/agend 2.1.11-beta.3 → 2.1.11-beta.5
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/backend/codex.d.ts +2 -0
- package/dist/backend/codex.js +55 -14
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/muse.d.ts +50 -5
- package/dist/backend/muse.js +166 -32
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +39 -1
- package/dist/backend/types.js.map +1 -1
- package/dist/command-table.d.ts +33 -0
- package/dist/command-table.js +45 -0
- package/dist/command-table.js.map +1 -1
- package/dist/daemon.d.ts +93 -1
- package/dist/daemon.js +456 -31
- package/dist/daemon.js.map +1 -1
- package/dist/darwin-memory.d.ts +34 -0
- package/dist/darwin-memory.js +160 -0
- package/dist/darwin-memory.js.map +1 -0
- package/dist/fleet-manager.d.ts +10 -0
- package/dist/fleet-manager.js +30 -7
- package/dist/fleet-manager.js.map +1 -1
- package/dist/host-memory.d.ts +3 -2
- package/dist/host-memory.js +14 -0
- package/dist/host-memory.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +11 -0
- package/dist/instance-lifecycle.js +45 -8
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +11 -0
- package/dist/locale.js.map +1 -1
- package/dist/memory-pressure.d.ts +20 -1
- package/dist/memory-pressure.js +87 -6
- package/dist/memory-pressure.js.map +1 -1
- package/dist/resource-report.d.ts +1 -1
- package/dist/resource-report.js +5 -4
- package/dist/resource-report.js.map +1 -1
- package/dist/spawn-gate.d.ts +4 -0
- package/dist/spawn-gate.js +36 -8
- package/dist/spawn-gate.js.map +1 -1
- package/dist/tmux-manager.d.ts +7 -0
- package/dist/tmux-manager.js +19 -0
- package/dist/tmux-manager.js.map +1 -1
- package/dist/topic-commands.d.ts +16 -2
- package/dist/topic-commands.js +85 -94
- package/dist/topic-commands.js.map +1 -1
- package/dist/turn-reply-guard.d.ts +3 -0
- package/dist/turn-reply-guard.js +12 -2
- package/dist/turn-reply-guard.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -300,11 +300,14 @@ export class PaneStateMachine {
|
|
|
300
300
|
busyPattern;
|
|
301
301
|
lastPaneHash = null;
|
|
302
302
|
lastPaneChangeAt;
|
|
303
|
+
lastProgressTick = null;
|
|
304
|
+
progressTick;
|
|
303
305
|
lastObservedAt;
|
|
304
306
|
stateChangedAt;
|
|
305
307
|
currentState = "idle";
|
|
306
|
-
constructor(readyPattern, stuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS, now = Date.now(), busyPattern) {
|
|
308
|
+
constructor(readyPattern, stuckTimeoutMs = DEFAULT_STUCK_TIMEOUT_MS, now = Date.now(), busyPattern, progressTick) {
|
|
307
309
|
this.stuckTimeoutMs = stuckTimeoutMs;
|
|
310
|
+
this.progressTick = progressTick ?? null;
|
|
308
311
|
// Stateful g/y regexes mutate lastIndex and can alternate true/false across
|
|
309
312
|
// polls. State detection must be deterministic for identical pane content.
|
|
310
313
|
this.readyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
|
|
@@ -350,7 +353,18 @@ export class PaneStateMachine {
|
|
|
350
353
|
const paneChanged = this.lastPaneHash !== paneHash;
|
|
351
354
|
if (paneChanged) {
|
|
352
355
|
this.lastPaneHash = paneHash;
|
|
353
|
-
|
|
356
|
+
// Never backwards: a capture taken for an old output event (the stuck-deadline probe passes the time of the LAST
|
|
357
|
+
// output, minutes ago) must not erase the progress a later capture already proved (#1188).
|
|
358
|
+
this.lastPaneChangeAt = Math.max(this.lastPaneChangeAt, changeAt);
|
|
359
|
+
}
|
|
360
|
+
// The CLI's own turn clock (Codex: the elapsed seconds of its live status row). A counter that moved since the last
|
|
361
|
+
// look is proof of life whatever the rest of the screen did; one that stands still is not — a frozen pane stays
|
|
362
|
+
// detectable.
|
|
363
|
+
if (this.progressTick) {
|
|
364
|
+
const tick = this.progressTick(pane);
|
|
365
|
+
if (tick !== null && tick !== this.lastProgressTick)
|
|
366
|
+
this.lastPaneChangeAt = Math.max(this.lastPaneChangeAt, now);
|
|
367
|
+
this.lastProgressTick = tick;
|
|
354
368
|
}
|
|
355
369
|
this.lastObservedAt = now;
|
|
356
370
|
// Some TUIs keep their ready footer visible while a foreground tool owns
|
|
@@ -376,7 +390,7 @@ export class PaneStateMachine {
|
|
|
376
390
|
}
|
|
377
391
|
/** Record pane motion from tmux control mode without capturing pane content. */
|
|
378
392
|
recordOutput(now = Date.now()) {
|
|
379
|
-
this.lastPaneChangeAt = now;
|
|
393
|
+
this.lastPaneChangeAt = Math.max(this.lastPaneChangeAt, now);
|
|
380
394
|
this.lastObservedAt = now;
|
|
381
395
|
if (this.currentState !== "working") {
|
|
382
396
|
this.currentState = "working";
|
|
@@ -536,6 +550,12 @@ const INPUT_TRANSIENT_WAIT_MS = 10 * 60_000;
|
|
|
536
550
|
const INPUT_TRANSIENT_POLL_MS = 250;
|
|
537
551
|
/** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
|
|
538
552
|
const STRANDED_INPUT_MAX_ROUNDS = 3;
|
|
553
|
+
/** #829: most clearing rounds for a restored draft (one row each), and how long one round may take to show. */
|
|
554
|
+
const INPUT_RESIDUE_MAX_ROUNDS = 60;
|
|
555
|
+
const INPUT_RESIDUE_ROUND_WAIT_MS = 1_000;
|
|
556
|
+
const INPUT_RESIDUE_POLL_MS = 100;
|
|
557
|
+
/** #829: how many of the latest pastes can prove a draft is AgEnD's own. */
|
|
558
|
+
const RECENT_PASTES_KEPT = 8;
|
|
539
559
|
/** Build a status holder from channel metadata, keeping chat and thread apart. */
|
|
540
560
|
function channelStatus(meta) {
|
|
541
561
|
const chatId = meta.chat_id;
|
|
@@ -1042,6 +1062,22 @@ export class Daemon extends EventEmitter {
|
|
|
1042
1062
|
* dialog on screen at every poll and ignores nothing.
|
|
1043
1063
|
*/
|
|
1044
1064
|
dialogAnswers = null;
|
|
1065
|
+
/**
|
|
1066
|
+
* The last error occurrence handed to the lifecycle: what an action that needs the screen it was seen on (a
|
|
1067
|
+
* `nudge_continue` arming) reads back, with the spawn / monitors it was seen under.
|
|
1068
|
+
*/
|
|
1069
|
+
lastErrorEpisode = null;
|
|
1070
|
+
/**
|
|
1071
|
+
* An armed "tell the agent to keep going" (Codex model at capacity). One per episode; the monitor owns it: every poll
|
|
1072
|
+
* cancels it when anything changed, and it fires at most once. The due time is on the monotonic clock.
|
|
1073
|
+
*/
|
|
1074
|
+
/**
|
|
1075
|
+
* How many occurrences of each `nudge_continue` pattern were already on screen when this spawn first showed a live
|
|
1076
|
+
* composer (null until then): resumed scrollback, i.e. HISTORY. An occurrence is a new episode only beyond that — a
|
|
1077
|
+
* resumed session whose scrollback holds an old capacity line must not be told to "keep going" for it (#949).
|
|
1078
|
+
*/
|
|
1079
|
+
nudgeBaselines = null;
|
|
1080
|
+
capacityNudge = null;
|
|
1045
1081
|
/** Current stdin-owning runtime dialog, independent from execution state. */
|
|
1046
1082
|
inputBlockedDialogKey = null;
|
|
1047
1083
|
/** Generation/signature fence for safety dialogs while their screen is still visible. */
|
|
@@ -1186,6 +1222,8 @@ export class Daemon extends EventEmitter {
|
|
|
1186
1222
|
* comes from. See PaneWriteLock for why interleaving is destructive. */
|
|
1187
1223
|
paneWriteLock = new PaneWriteLock();
|
|
1188
1224
|
deliveryWritesStopping = false;
|
|
1225
|
+
/** #829: the latest pastes, by spawn generation (see ownedInputDraft). */
|
|
1226
|
+
recentPastes = [];
|
|
1189
1227
|
pendingInstructionsUpdate;
|
|
1190
1228
|
pendingInstructionsNotice = false;
|
|
1191
1229
|
// Whether the warmup steering-reload notice should be injected after spawn.
|
|
@@ -1587,6 +1625,14 @@ export class Daemon extends EventEmitter {
|
|
|
1587
1625
|
this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified`);
|
|
1588
1626
|
return;
|
|
1589
1627
|
}
|
|
1628
|
+
// A steer into a busy pane of a CLI whose input row cannot be read: the paste and the one Enter went through and the
|
|
1629
|
+
// delivery's own unique marker is on the pane. That is "accepted into the live turn's input" — not "the model has read
|
|
1630
|
+
// it", and delivery_status carries delivery_mode=steer — and it is all this class of backend can ever show for a steer
|
|
1631
|
+
// (#1197). Labelled, so it is never mistaken for a proof that the input row was cleared.
|
|
1632
|
+
if (verdict.proof === "steer-marker-on-pane") {
|
|
1633
|
+
this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable");
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1590
1636
|
this.finishDurableDelivery(delivery, "delivered", "positive submission proof");
|
|
1591
1637
|
}
|
|
1592
1638
|
deferDurableDelivery(meta, reason) {
|
|
@@ -1649,19 +1695,29 @@ export class Daemon extends EventEmitter {
|
|
|
1649
1695
|
ensureInstanceDir(this.instanceDir);
|
|
1650
1696
|
writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
|
|
1651
1697
|
this.logger.info(`Starting ${this.name}`);
|
|
1652
|
-
//
|
|
1698
|
+
// Recovery intent is one-shot, but only the reader may consume it. Keep
|
|
1699
|
+
// an unreadable marker for a later start; clear successfully read bytes
|
|
1700
|
+
// even when the JSON is corrupt so it cannot remain forever.
|
|
1653
1701
|
const crashStatePath = join(this.instanceDir, "crash-state.json");
|
|
1654
1702
|
try {
|
|
1655
1703
|
if (existsSync(crashStatePath)) {
|
|
1656
|
-
const
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1704
|
+
const content = readFileSync(crashStatePath, "utf-8");
|
|
1705
|
+
try {
|
|
1706
|
+
const state = JSON.parse(content);
|
|
1707
|
+
if (state?.resumeDisabled === true) {
|
|
1708
|
+
this.skipResume = true;
|
|
1709
|
+
this.logger.warn("Previous crash loop detected — starting without resume");
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
finally {
|
|
1713
|
+
try {
|
|
1714
|
+
unlinkSync(crashStatePath);
|
|
1715
|
+
}
|
|
1716
|
+
catch { /* best effort */ }
|
|
1660
1717
|
}
|
|
1661
|
-
unlinkSync(crashStatePath);
|
|
1662
1718
|
}
|
|
1663
1719
|
}
|
|
1664
|
-
catch { /* corrupt file — ignore */ }
|
|
1720
|
+
catch { /* unreadable or corrupt file — ignore */ }
|
|
1665
1721
|
// Restore last reply target so a fleet-topic instance can reply correctly
|
|
1666
1722
|
// BEFORE its first post-restart inbound arrives (otherwise lastChatId is empty
|
|
1667
1723
|
// → reply tool sends to "" → Discord 404).
|
|
@@ -2763,6 +2819,8 @@ export class Daemon extends EventEmitter {
|
|
|
2763
2819
|
const pane = await this.tmux.capturePane();
|
|
2764
2820
|
if (stale())
|
|
2765
2821
|
return;
|
|
2822
|
+
this.takeNudgeBaselines(pane, patterns);
|
|
2823
|
+
this.tickCapacityNudge(pane);
|
|
2766
2824
|
const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
|
|
2767
2825
|
// A sign-in screen that appears AFTER the startup scan ended (first run:
|
|
2768
2826
|
// theme → login can take longer than its budget) is the same auth
|
|
@@ -3142,6 +3200,7 @@ export class Daemon extends EventEmitter {
|
|
|
3142
3200
|
if (ep.type === "auth_error")
|
|
3143
3201
|
this.authFailureUnresolved = true;
|
|
3144
3202
|
this.logger.warn({ errorType: ep.type, action: ep.action }, `PTY error detected: ${message}`);
|
|
3203
|
+
this.lastErrorEpisode = { key, pattern: ep.pattern, pane, spawn: this.spawnGeneration, fence: this.launchFenceEpoch };
|
|
3145
3204
|
this.emit("pty_error", { name: this.name, ...ep, message });
|
|
3146
3205
|
}
|
|
3147
3206
|
/**
|
|
@@ -3189,6 +3248,9 @@ export class Daemon extends EventEmitter {
|
|
|
3189
3248
|
* transaction; the interrupt key still cancels the CLI generation.
|
|
3190
3249
|
*/
|
|
3191
3250
|
clearPendingDeliveries(fleetEpoch) {
|
|
3251
|
+
// FleetManager calls this synchronously before sendEscape. Cancel only the
|
|
3252
|
+
// current human turn; leave already-forwarded tool requests to settle.
|
|
3253
|
+
this.turnReplyGuard.cancelByUser();
|
|
3192
3254
|
this.deliveryEpoch = fleetEpoch === undefined
|
|
3193
3255
|
? this.deliveryEpoch + 1
|
|
3194
3256
|
: Math.max(this.deliveryEpoch, fleetEpoch);
|
|
@@ -3971,7 +4033,11 @@ export class Daemon extends EventEmitter {
|
|
|
3971
4033
|
* message can arrive mid-turn and sit queued, and marking at arrival would let
|
|
3972
4034
|
* the PREVIOUS turn's idle edge consume (and reset) the new turn's state.
|
|
3973
4035
|
*/
|
|
3974
|
-
markTurnStarted(meta, deliveredText) {
|
|
4036
|
+
markTurnStarted(meta, deliveredText, deliveryEpoch = this.deliveryEpoch) {
|
|
4037
|
+
// Cancel may have interrupted an in-flight paste before its promise settled.
|
|
4038
|
+
// Its late success must not re-arm the turn that the user just cancelled.
|
|
4039
|
+
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
4040
|
+
return;
|
|
3975
4041
|
// Channel turns only. A cross-instance inbound (from_instance, empty
|
|
3976
4042
|
// chat_id) does not update lastChatId, so its proxy reply would land in
|
|
3977
4043
|
// whatever USER topic spoke to this instance last — the wrong audience for
|
|
@@ -4012,6 +4078,12 @@ export class Daemon extends EventEmitter {
|
|
|
4012
4078
|
this.turnReplyGuard.complete(turn.generation);
|
|
4013
4079
|
return;
|
|
4014
4080
|
}
|
|
4081
|
+
// An intentional stop is not a dropped reply. Successful in-flight replies
|
|
4082
|
+
// still settle above, but cancellation cannot start another model turn.
|
|
4083
|
+
if (turn.cancelledByUser) {
|
|
4084
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
4085
|
+
return;
|
|
4086
|
+
}
|
|
4015
4087
|
// A second idle edge ends the one permitted recovery turn. Never create a
|
|
4016
4088
|
// third turn or guess at terminal text; make the failure visible instead.
|
|
4017
4089
|
if (turn.phase === "recovering") {
|
|
@@ -4091,6 +4163,11 @@ export class Daemon extends EventEmitter {
|
|
|
4091
4163
|
&& this.backend?.replyCompletionGuard === true;
|
|
4092
4164
|
}
|
|
4093
4165
|
startReplyRecovery(turn, reason) {
|
|
4166
|
+
const current = this.turnReplyGuard.snapshot();
|
|
4167
|
+
if (current?.generation === turn.generation && current.cancelledByUser) {
|
|
4168
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
4169
|
+
return;
|
|
4170
|
+
}
|
|
4094
4171
|
if (!this.turnReplyGuard.beginRecovery(turn.generation))
|
|
4095
4172
|
return;
|
|
4096
4173
|
this.emit("reply_drop_detected", {
|
|
@@ -4118,8 +4195,8 @@ export class Daemon extends EventEmitter {
|
|
|
4118
4195
|
this.pasteQueueDepth++;
|
|
4119
4196
|
this.pasteLock = this.pasteLock.then(async () => {
|
|
4120
4197
|
try {
|
|
4121
|
-
const current = this.
|
|
4122
|
-
if (!current
|
|
4198
|
+
const current = this.currentReplyRecovery(turn.generation, deliveryEpoch);
|
|
4199
|
+
if (!current)
|
|
4123
4200
|
return;
|
|
4124
4201
|
if (current.completionDelivered) {
|
|
4125
4202
|
this.turnReplyGuard.complete(turn.generation);
|
|
@@ -4133,6 +4210,8 @@ export class Daemon extends EventEmitter {
|
|
|
4133
4210
|
const delivered = await this.deliverMessage(REPLY_RECOVERY_PROMPT, undefined, {
|
|
4134
4211
|
deliveryEpoch,
|
|
4135
4212
|
});
|
|
4213
|
+
if (!this.currentReplyRecovery(turn.generation, deliveryEpoch))
|
|
4214
|
+
return;
|
|
4136
4215
|
if (!delivered) {
|
|
4137
4216
|
this.turnReplyGuard.complete(turn.generation);
|
|
4138
4217
|
this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
|
|
@@ -4143,14 +4222,28 @@ export class Daemon extends EventEmitter {
|
|
|
4143
4222
|
}
|
|
4144
4223
|
}).catch(err => {
|
|
4145
4224
|
this.logger.error({ err: err.message }, "Reply-drop recovery prompt failed");
|
|
4225
|
+
if (!this.currentReplyRecovery(turn.generation, deliveryEpoch))
|
|
4226
|
+
return;
|
|
4146
4227
|
this.turnReplyGuard.complete(turn.generation);
|
|
4147
4228
|
this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
|
|
4148
4229
|
});
|
|
4149
4230
|
}
|
|
4231
|
+
/** Recheck after queue waits and IO; cancellation must not become a failure notice. */
|
|
4232
|
+
currentReplyRecovery(generation, deliveryEpoch) {
|
|
4233
|
+
const current = this.turnReplyGuard.snapshot();
|
|
4234
|
+
if (!current || current.generation !== generation || current.phase !== "recovering")
|
|
4235
|
+
return null;
|
|
4236
|
+
if (current.cancelledByUser || !this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4237
|
+
this.turnReplyGuard.complete(generation);
|
|
4238
|
+
return null;
|
|
4239
|
+
}
|
|
4240
|
+
return current;
|
|
4241
|
+
}
|
|
4150
4242
|
queueMalformedReplyRecovery(turn, text) {
|
|
4151
4243
|
// Serialize the delivery decision ahead of any new pane input. If direct
|
|
4152
4244
|
// delivery is positively acknowledged, there is no recovery turn. If its
|
|
4153
4245
|
// outcome is unknown, do not ask the model to duplicate it.
|
|
4246
|
+
const deliveryEpoch = this.deliveryEpoch;
|
|
4154
4247
|
const delivery = this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery", turn.target);
|
|
4155
4248
|
this.pasteQueueDepth++;
|
|
4156
4249
|
this.pasteLock = this.pasteLock.then(async () => {
|
|
@@ -4161,17 +4254,24 @@ export class Daemon extends EventEmitter {
|
|
|
4161
4254
|
correlationId: turn.target.correlationId,
|
|
4162
4255
|
recovered: delivered,
|
|
4163
4256
|
});
|
|
4257
|
+
const current = this.turnReplyGuard.snapshot();
|
|
4164
4258
|
this.turnReplyGuard.complete(turn.generation);
|
|
4165
|
-
if (!delivered
|
|
4259
|
+
if (!delivered && current?.generation === turn.generation && !current.cancelledByUser
|
|
4260
|
+
&& this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4166
4261
|
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
4262
|
+
}
|
|
4167
4263
|
}
|
|
4168
4264
|
finally {
|
|
4169
4265
|
this.pasteQueueDepth--;
|
|
4170
4266
|
}
|
|
4171
4267
|
}).catch(err => {
|
|
4172
4268
|
this.logger.error({ err: err.message }, "Malformed tool-call recovery attempt failed");
|
|
4269
|
+
const current = this.turnReplyGuard.snapshot();
|
|
4173
4270
|
this.turnReplyGuard.complete(turn.generation);
|
|
4174
|
-
|
|
4271
|
+
if (current?.generation === turn.generation && !current.cancelledByUser
|
|
4272
|
+
&& this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4273
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
4274
|
+
}
|
|
4175
4275
|
});
|
|
4176
4276
|
}
|
|
4177
4277
|
reportUnrecoveredReplyDrop(turn, reason = "recovery_turn_missing_reply") {
|
|
@@ -4602,7 +4702,10 @@ export class Daemon extends EventEmitter {
|
|
|
4602
4702
|
// the new progress timestamp and re-arms both deadlines.
|
|
4603
4703
|
if (!expectedOutputAt)
|
|
4604
4704
|
this.instanceStateLastOutputAt = captureStartedAt;
|
|
4605
|
-
|
|
4705
|
+
// The deadline follows the state machine's latest sign of life (output events, content changes and the CLI's own
|
|
4706
|
+
// turn clock all feed it). Keyed on the output clock alone it would be "now" again the moment a long quiet turn
|
|
4707
|
+
// proved alive, and re-fire in a loop.
|
|
4708
|
+
this.scheduleInstanceStateStuckDeadline(snapshot.observedAt - snapshot.unchangedForMs || captureStartedAt);
|
|
4606
4709
|
}
|
|
4607
4710
|
}
|
|
4608
4711
|
catch (err) {
|
|
@@ -4665,7 +4768,7 @@ export class Daemon extends EventEmitter {
|
|
|
4665
4768
|
: DEFAULT_STATE_IDLE_DEBOUNCE_MS;
|
|
4666
4769
|
this.instanceStateReadyPattern = this.backend.getReadyPattern();
|
|
4667
4770
|
this.instanceStateBusyPattern = this.backend.getBusyPattern?.() ?? null;
|
|
4668
|
-
this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs, Date.now(), this.instanceStateBusyPattern);
|
|
4771
|
+
this.instanceStateMachine = new PaneStateMachine(this.instanceStateReadyPattern, this.instanceStateStuckTimeoutMs, Date.now(), this.instanceStateBusyPattern, this.backend.getLiveProgressTick ? (pane) => this.backend.getLiveProgressTick(pane) : null);
|
|
4669
4772
|
this.instanceStateLastOutputAt = 0;
|
|
4670
4773
|
this.instanceStatePeriodicIdleConfirmations = 0;
|
|
4671
4774
|
this.instanceStateMonitorActive = true;
|
|
@@ -4975,6 +5078,16 @@ export class Daemon extends EventEmitter {
|
|
|
4975
5078
|
return ""; // skip channel tools
|
|
4976
5079
|
return name;
|
|
4977
5080
|
}
|
|
5081
|
+
/**
|
|
5082
|
+
* A steered message reads like a queued one plus a banner. The delivery marker (`[agend-delivery-id:…]`, when the message
|
|
5083
|
+
* is a durable delivery) must LEAD the persisted user message — restart reconciliation recognises a delivery only by a
|
|
5084
|
+
* marker at the start of the body (#1197) — so the banner goes right after it, not in front of it.
|
|
5085
|
+
*/
|
|
5086
|
+
static steerEnvelope(inbound) {
|
|
5087
|
+
const banner = "[STEERING — mid-task course correction. Fold this into the CURRENT work if one is active.]\n";
|
|
5088
|
+
const marker = inbound.match(/^\[agend-delivery-id:[^\]\r\n]+\]\r?\n/);
|
|
5089
|
+
return marker ? marker[0] + banner + inbound.slice(marker[0].length) : banner + inbound;
|
|
5090
|
+
}
|
|
4978
5091
|
/**
|
|
4979
5092
|
* The one place an inbound message grows its metadata wrapper ([user:]/
|
|
4980
5093
|
* [from:] prefix, pending reactions, handoff metadata, reply instructions).
|
|
@@ -5084,8 +5197,7 @@ export class Daemon extends EventEmitter {
|
|
|
5084
5197
|
this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
|
|
5085
5198
|
this.pendingWork.recordInbound();
|
|
5086
5199
|
this.recordRecentUserMessage(content, meta);
|
|
5087
|
-
const formatted =
|
|
5088
|
-
+ this.formatInboundMessage(content, meta);
|
|
5200
|
+
const formatted = Daemon.steerEnvelope(this.formatInboundMessage(content, meta));
|
|
5089
5201
|
const status = channelStatus(meta);
|
|
5090
5202
|
const durableAttempt = this.durableDeliveryAttempt(meta, content);
|
|
5091
5203
|
if (durableAttempt === false) {
|
|
@@ -5112,7 +5224,7 @@ export class Daemon extends EventEmitter {
|
|
|
5112
5224
|
durableAttempt: durableAttempt || undefined,
|
|
5113
5225
|
})) {
|
|
5114
5226
|
this.finishDurableSubmission(durableAttempt, verdict);
|
|
5115
|
-
this.markTurnStarted(meta, formatted);
|
|
5227
|
+
this.markTurnStarted(meta, formatted, deliveryEpoch);
|
|
5116
5228
|
}
|
|
5117
5229
|
else if (durableAttempt && verdict.paneWriteStarted) {
|
|
5118
5230
|
this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
|
|
@@ -5167,7 +5279,7 @@ export class Daemon extends EventEmitter {
|
|
|
5167
5279
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
5168
5280
|
return;
|
|
5169
5281
|
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
|
|
5170
|
-
this.markTurnStarted(meta, formatted);
|
|
5282
|
+
this.markTurnStarted(meta, formatted, deliveryEpoch);
|
|
5171
5283
|
}
|
|
5172
5284
|
}).catch(err => {
|
|
5173
5285
|
this.logger.warn({ err: err.message }, "btw delivery error");
|
|
@@ -5280,7 +5392,7 @@ export class Daemon extends EventEmitter {
|
|
|
5280
5392
|
durableAttempt: durableAttempt || undefined,
|
|
5281
5393
|
})) {
|
|
5282
5394
|
this.finishDurableSubmission(durable, verdict);
|
|
5283
|
-
this.markTurnStarted(meta, formatted);
|
|
5395
|
+
this.markTurnStarted(meta, formatted, deliveryEpoch);
|
|
5284
5396
|
}
|
|
5285
5397
|
else if (durableAttempt && verdict.paneWriteStarted) {
|
|
5286
5398
|
this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
|
|
@@ -5384,6 +5496,7 @@ export class Daemon extends EventEmitter {
|
|
|
5384
5496
|
// this spawn generation. A spawn that starts afterwards is detected inside
|
|
5385
5497
|
// the critical section, where this delivery backs out and redoes itself.
|
|
5386
5498
|
const settleGeneration = this.spawnGeneration;
|
|
5499
|
+
const settleFence = this.launchFenceEpoch;
|
|
5387
5500
|
if (cancelled())
|
|
5388
5501
|
return false;
|
|
5389
5502
|
if (this.refuseFatalStartupDelivery(verdict, status))
|
|
@@ -5508,7 +5621,28 @@ export class Daemon extends EventEmitter {
|
|
|
5508
5621
|
}
|
|
5509
5622
|
if (!(await this.hasPositiveDeliveryInput()))
|
|
5510
5623
|
return "dialog";
|
|
5511
|
-
|
|
5624
|
+
// #829: a CLI that restores a cancelled prompt into its input box would
|
|
5625
|
+
// have this message pasted onto it and both submitted as one. Clear it
|
|
5626
|
+
// first, or do not write at all. From here every await is fenced: a
|
|
5627
|
+
// cancel, stop, freeze or spawn ends this attempt unwritten.
|
|
5628
|
+
// The CLI process is its spawn generation; a recovered window id is the
|
|
5629
|
+
// same process, a pause/stop/respawn is not (launchFenceEpoch moves even
|
|
5630
|
+
// when a failed pause resumes the monitors).
|
|
5631
|
+
const writeCurrent = () => !cancelled()
|
|
5632
|
+
&& !this.deliveryWritesStopping
|
|
5633
|
+
&& !this.runtimeMonitorsFrozen
|
|
5634
|
+
&& !this.spawning
|
|
5635
|
+
&& settleGeneration === this.spawnGeneration
|
|
5636
|
+
&& settleFence === this.launchFenceEpoch;
|
|
5637
|
+
const stale = () => (this.spawning || settleGeneration !== this.spawnGeneration ? "spawn-started" : false);
|
|
5638
|
+
const residue = await this.clearRestoredInputDraft(handingOffToNativeQueue, writeCurrent);
|
|
5639
|
+
if (residue === "stale")
|
|
5640
|
+
return stale();
|
|
5641
|
+
if (residue !== "clear")
|
|
5642
|
+
return this.failDelivery(verdict, status, "input-residue", residue);
|
|
5643
|
+
const written = await this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict, opts?.durableAttempt, opts?.steer === true, writeCurrent);
|
|
5644
|
+
// Fenced before its first write: not attempted — redo after a spawn, else drop without a ❌.
|
|
5645
|
+
return written === false && verdict.fenced ? stale() : written;
|
|
5512
5646
|
});
|
|
5513
5647
|
if (outcome === "spawn-started") {
|
|
5514
5648
|
// The pane changed under this delivery: its queued paste must not land
|
|
@@ -5809,6 +5943,104 @@ export class Daemon extends EventEmitter {
|
|
|
5809
5943
|
this.trackDialogParked(null);
|
|
5810
5944
|
return { state: "clear" };
|
|
5811
5945
|
}
|
|
5946
|
+
/**
|
|
5947
|
+
* Arm the "keep going" nudge for the error occurrence just reported (`pattern` must be the one the lifecycle was
|
|
5948
|
+
* handed). The nudge is sent once, `delayMs` from now on the MONOTONIC clock (a wall-clock step changes nothing),
|
|
5949
|
+
* and only if nothing has changed by then — see {@link tickCapacityNudge}. Returns false when there is nothing to arm.
|
|
5950
|
+
*/
|
|
5951
|
+
armCapacityNudge(pattern, delayMs) {
|
|
5952
|
+
const episode = this.lastErrorEpisode;
|
|
5953
|
+
if (!episode || episode.pattern !== pattern)
|
|
5954
|
+
return false;
|
|
5955
|
+
if (episode.spawn !== this.spawnGeneration || episode.fence !== this.launchFenceEpoch)
|
|
5956
|
+
return false;
|
|
5957
|
+
if (this.isPaused || this.getProcessStatus() === "stopped")
|
|
5958
|
+
return false;
|
|
5959
|
+
// Only an occurrence that appeared during this spawn's live session: before the baseline exists, or not beyond it,
|
|
5960
|
+
// it is scrollback from a resumed session.
|
|
5961
|
+
const baseline = this.nudgeBaselines?.get(episode.key);
|
|
5962
|
+
if (baseline === undefined || Daemon.countOccurrences(pattern, episode.pane) <= baseline)
|
|
5963
|
+
return false;
|
|
5964
|
+
this.capacityNudge = {
|
|
5965
|
+
pane: episode.pane, spawn: episode.spawn, fence: episode.fence,
|
|
5966
|
+
epoch: this.deliveryEpoch, dueAt: performance.now() + delayMs,
|
|
5967
|
+
};
|
|
5968
|
+
this.logger.info({ delayMs }, "Model-capacity nudge armed");
|
|
5969
|
+
return true;
|
|
5970
|
+
}
|
|
5971
|
+
/** Drop an armed nudge (the user stopped / paused the instance, or the lifecycle gave up on it). */
|
|
5972
|
+
cancelCapacityNudge(reason) {
|
|
5973
|
+
if (!this.capacityNudge)
|
|
5974
|
+
return;
|
|
5975
|
+
this.capacityNudge = null;
|
|
5976
|
+
this.logger.info({ reason }, "Model-capacity nudge cancelled");
|
|
5977
|
+
}
|
|
5978
|
+
/** The first live composer of a spawn fixes what is history (see {@link nudgeBaselines}). */
|
|
5979
|
+
takeNudgeBaselines(pane, patterns) {
|
|
5980
|
+
if (!this.isCodexLivePaneSnapshot(pane))
|
|
5981
|
+
return;
|
|
5982
|
+
const baselines = this.nudgeBaselines ?? new Map();
|
|
5983
|
+
for (const ep of patterns) {
|
|
5984
|
+
if (ep.action !== "nudge_continue")
|
|
5985
|
+
continue;
|
|
5986
|
+
const key = Daemon.errorPatternKey(ep);
|
|
5987
|
+
const count = Daemon.countOccurrences(ep.pattern, pane);
|
|
5988
|
+
// History that has scrolled away must not hide the next real occurrence.
|
|
5989
|
+
baselines.set(key, Math.min(baselines.get(key) ?? count, count));
|
|
5990
|
+
}
|
|
5991
|
+
this.nudgeBaselines = baselines;
|
|
5992
|
+
}
|
|
5993
|
+
static countOccurrences(pattern, pane) {
|
|
5994
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
|
|
5995
|
+
return (pane.match(new RegExp(pattern.source, flags)) || []).length;
|
|
5996
|
+
}
|
|
5997
|
+
/**
|
|
5998
|
+
* One monitor poll's look at an armed nudge. It is CANCELLED — never postponed — when anything about the instance
|
|
5999
|
+
* moved: a stop / pause / respawn (spawn generation or monitor epoch), a user cancel (delivery epoch), the pane no
|
|
6000
|
+
* longer being exactly the one the error was seen on (it moved on by itself, a person typed, another dialog), the
|
|
6001
|
+
* agent working, or a message waiting for delivery. Only when it is due and still untouched is it fired — once — and
|
|
6002
|
+
* the injection re-verifies everything under the pane-write lock.
|
|
6003
|
+
*/
|
|
6004
|
+
tickCapacityNudge(pane) {
|
|
6005
|
+
const nudge = this.capacityNudge;
|
|
6006
|
+
if (!nudge)
|
|
6007
|
+
return;
|
|
6008
|
+
const gone = nudge.spawn !== this.spawnGeneration || nudge.fence !== this.launchFenceEpoch ? "the instance was stopped, paused or respawned"
|
|
6009
|
+
: !this.isDeliveryEpochCurrent(nudge.epoch) ? "the user cancelled"
|
|
6010
|
+
: this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped" ? "the instance is not running"
|
|
6011
|
+
: pane !== nudge.pane ? "the screen changed"
|
|
6012
|
+
: this.instanceState === "working" || this.pasteQueueDepth > 0 ? "the instance has work of its own"
|
|
6013
|
+
: null;
|
|
6014
|
+
if (gone) {
|
|
6015
|
+
this.cancelCapacityNudge(gone);
|
|
6016
|
+
return;
|
|
6017
|
+
}
|
|
6018
|
+
if (performance.now() < nudge.dueAt)
|
|
6019
|
+
return;
|
|
6020
|
+
this.capacityNudge = null; // fired: once per episode
|
|
6021
|
+
void this.sendCapacityNudge(nudge).catch(err => this.logger.warn({ err: err.message }, "Model-capacity nudge failed"));
|
|
6022
|
+
}
|
|
6023
|
+
async sendCapacityNudge(nudge) {
|
|
6024
|
+
// Everything the nudge was armed under, asked again after EVERY await that precedes a write — inside the paste
|
|
6025
|
+
// primitive too (its capture, its paste, its pause, its Enter, its retry): once any of it is false nothing more is
|
|
6026
|
+
// written. A message queued behind the lock, or the agent starting a turn, is "work of its own" like at the poll.
|
|
6027
|
+
const current = () => nudge.spawn === this.spawnGeneration && nudge.fence === this.launchFenceEpoch
|
|
6028
|
+
&& this.isDeliveryEpochCurrent(nudge.epoch) && !this.isPaused && !this.healthCheckPaused
|
|
6029
|
+
&& !this.startupAborted && this.getProcessStatus() !== "stopped"
|
|
6030
|
+
&& this.pasteQueueDepth === 0 && this.instanceState !== "working";
|
|
6031
|
+
// Exclusive with every delivery: nothing can paste a turn between the verdict below and the nudge.
|
|
6032
|
+
await this.paneWriteLock.run(async () => {
|
|
6033
|
+
if (!this.tmux)
|
|
6034
|
+
return;
|
|
6035
|
+
// ONE capture decides: it must be exactly the screen the error was seen on AND an idle composer. (A second picture
|
|
6036
|
+
// vouching for the first — "same screen" here, "idle" there — would let a half-typed draft through.)
|
|
6037
|
+
const sent = await this.submitSystemPaste(t("inst.codex_capacity_nudge_text"), "capacity-continue", {
|
|
6038
|
+
current,
|
|
6039
|
+
accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane),
|
|
6040
|
+
});
|
|
6041
|
+
this.logger.info({ sent }, "Model-capacity nudge attempted");
|
|
6042
|
+
});
|
|
6043
|
+
}
|
|
5812
6044
|
/** What identifies the request on screen: the backend's own identity when it has one, else the whole screen verbatim. */
|
|
5813
6045
|
static screenOf(dialog, pane) {
|
|
5814
6046
|
return dialog.requestIdentity?.(pane) ?? pane;
|
|
@@ -6174,6 +6406,114 @@ export class Daemon extends EventEmitter {
|
|
|
6174
6406
|
* else submitted it); "failed" = the Enter could not be sent (the delivery
|
|
6175
6407
|
* must fail rather than paste on top of the stranded text).
|
|
6176
6408
|
*/
|
|
6409
|
+
/** #829: remember what this pane generation was given, so a draft can be proven to be ours. */
|
|
6410
|
+
/**
|
|
6411
|
+
* #829: remember what was pasted, under the spawn generation it was written
|
|
6412
|
+
* to — fixed by the caller BEFORE its paste await, so a write that lands
|
|
6413
|
+
* while the CLI is replaced is never credited to the replacement.
|
|
6414
|
+
*/
|
|
6415
|
+
rememberPaste(text, generation) {
|
|
6416
|
+
this.recentPastes.push({ generation, text });
|
|
6417
|
+
if (this.recentPastes.length > RECENT_PASTES_KEPT)
|
|
6418
|
+
this.recentPastes.splice(0, this.recentPastes.length - RECENT_PASTES_KEPT);
|
|
6419
|
+
}
|
|
6420
|
+
/**
|
|
6421
|
+
* #829: whether the input box shows one of this generation's own pastes —
|
|
6422
|
+
* exactly as the backend draws it, wrapping included (inputDraftShows). A
|
|
6423
|
+
* marker that looks like AgEnD's, a collapsed paste whose content cannot be
|
|
6424
|
+
* seen, or text that differs only in spacing proves nothing.
|
|
6425
|
+
*/
|
|
6426
|
+
ownsInputDraft(draft) {
|
|
6427
|
+
const shows = this.backend?.inputDraftShows;
|
|
6428
|
+
if (!shows)
|
|
6429
|
+
return false;
|
|
6430
|
+
return this.recentPastes.some(p => p.generation === this.spawnGeneration && shows.call(this.backend, draft, p.text));
|
|
6431
|
+
}
|
|
6432
|
+
/**
|
|
6433
|
+
* #829: empty the CLI's input box of a draft the CLI put back there itself
|
|
6434
|
+
* (muse restores a cancelled prompt) before a delivery is pasted. Only for a
|
|
6435
|
+
* backend that can read its box and names the keys that clear it; every other
|
|
6436
|
+
* backend is untouched.
|
|
6437
|
+
*
|
|
6438
|
+
* Only a draft that is provably one of this pane's own pastes is cleared, one
|
|
6439
|
+
* line per round, and every round must leave the first or last rows of what
|
|
6440
|
+
* was there — anything else in the box is somebody's typing and stops the
|
|
6441
|
+
* clearing. `current` is the caller's fixed fence (cancel, stop, freeze,
|
|
6442
|
+
* pause, spawn): it is asked after every await, and a stale answer means
|
|
6443
|
+
* nothing more is sent. A capture that fails, a box that does not empty or a
|
|
6444
|
+
* draft that is not ours stops the delivery; it is never pasted onto.
|
|
6445
|
+
*/
|
|
6446
|
+
async clearRestoredInputDraft(handingOffToNativeQueue, current) {
|
|
6447
|
+
const backend = this.backend;
|
|
6448
|
+
const tmux = this.tmux;
|
|
6449
|
+
const keys = backend?.getClearInputKeys?.();
|
|
6450
|
+
if (!backend?.inputDraft || !keys?.length || handingOffToNativeQueue || !tmux)
|
|
6451
|
+
return "clear";
|
|
6452
|
+
// undefined: the capture itself failed. null: read, but not a layout the backend knows.
|
|
6453
|
+
const read = async () => {
|
|
6454
|
+
let pane;
|
|
6455
|
+
try {
|
|
6456
|
+
pane = await tmux.capturePane();
|
|
6457
|
+
}
|
|
6458
|
+
catch {
|
|
6459
|
+
return undefined;
|
|
6460
|
+
}
|
|
6461
|
+
return backend.inputDraft(pane);
|
|
6462
|
+
};
|
|
6463
|
+
const first = await read();
|
|
6464
|
+
if (!current())
|
|
6465
|
+
return "stale";
|
|
6466
|
+
if (first === undefined) {
|
|
6467
|
+
this.logger.warn("Could not read the input box before this delivery — not writing it");
|
|
6468
|
+
return "unreadable";
|
|
6469
|
+
}
|
|
6470
|
+
// A screen the backend does not recognise keeps today's behaviour; the
|
|
6471
|
+
// readiness checks before this one decide whether it is writable at all.
|
|
6472
|
+
if (first === null || first.rows.length === 0)
|
|
6473
|
+
return "clear";
|
|
6474
|
+
if (!this.ownsInputDraft(first)) {
|
|
6475
|
+
this.logger.warn({ rows: first.rows.length }, "The input box holds text AgEnD did not put there — not clearing it, and not writing this delivery onto it");
|
|
6476
|
+
return "foreign-draft";
|
|
6477
|
+
}
|
|
6478
|
+
const original = first.rows;
|
|
6479
|
+
// A round removes whole lines from one end: what is left is the original's
|
|
6480
|
+
// first or last rows, unchanged.
|
|
6481
|
+
const remainderOk = (rows) => rows.length <= original.length
|
|
6482
|
+
&& (rows.every((r, k) => r === original[k]) || rows.every((r, k) => r === original[original.length - rows.length + k]));
|
|
6483
|
+
const same = (a, b) => a.length === b.length && a.every((r, k) => r === b[k]);
|
|
6484
|
+
const rounds = Math.min(INPUT_RESIDUE_MAX_ROUNDS, original.length + 3);
|
|
6485
|
+
this.logger.info({ rows: original.length }, "Clearing a restored draft from the input box before this delivery");
|
|
6486
|
+
// `current` is asked after every await; between one of those answers and
|
|
6487
|
+
// the next key there is no await.
|
|
6488
|
+
let rows = original;
|
|
6489
|
+
for (let round = 0; round < rounds; round++) {
|
|
6490
|
+
const before = rows;
|
|
6491
|
+
const sent = await tmux.sendKeySequence(keys);
|
|
6492
|
+
if (!current())
|
|
6493
|
+
return "stale";
|
|
6494
|
+
if (!sent)
|
|
6495
|
+
return "not-cleared";
|
|
6496
|
+
const deadline = Date.now() + INPUT_RESIDUE_ROUND_WAIT_MS;
|
|
6497
|
+
let next;
|
|
6498
|
+
do {
|
|
6499
|
+
await new Promise(r => setTimeout(r, INPUT_RESIDUE_POLL_MS));
|
|
6500
|
+
next = await read();
|
|
6501
|
+
if (!current())
|
|
6502
|
+
return "stale";
|
|
6503
|
+
} while (next && next.rows.length > 0 && same(next.rows, before) && Date.now() < deadline);
|
|
6504
|
+
if (!next)
|
|
6505
|
+
return "not-cleared";
|
|
6506
|
+
if (next.rows.length === 0)
|
|
6507
|
+
return "clear";
|
|
6508
|
+
if (!remainderOk(next.rows)) {
|
|
6509
|
+
this.logger.warn({ rows: next.rows.length }, "The input box changed to text AgEnD did not put there — stopped clearing, not writing this delivery");
|
|
6510
|
+
return "foreign-draft";
|
|
6511
|
+
}
|
|
6512
|
+
rows = next.rows;
|
|
6513
|
+
}
|
|
6514
|
+
this.logger.error({ rows: rows.length }, "The input box did not empty — not writing this delivery onto it");
|
|
6515
|
+
return "not-cleared";
|
|
6516
|
+
}
|
|
6177
6517
|
async submitStrandedInputIfAny(windowId) {
|
|
6178
6518
|
if (!this.tmux || !this.backend)
|
|
6179
6519
|
return "idle";
|
|
@@ -6495,10 +6835,15 @@ export class Daemon extends EventEmitter {
|
|
|
6495
6835
|
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId,
|
|
6496
6836
|
// Last and defaulted so the positional callers that ignore the outcome stay
|
|
6497
6837
|
// readable; deliverMessage, the only one that reports, always passes its own.
|
|
6498
|
-
verdict = { reached: false }, durableAttempt, steer = false
|
|
6838
|
+
verdict = { reached: false }, durableAttempt, steer = false,
|
|
6839
|
+
// #829: the caller's fence, asked once more after the last await before the
|
|
6840
|
+
// first side effect. Stale means unwritten: false, with no failure verdict.
|
|
6841
|
+
stillCurrent) {
|
|
6499
6842
|
const signature = this.submissionSignature(formatted, submissionId);
|
|
6500
6843
|
const rawPaste = durableAttempt?.submissionMode === "raw_paste";
|
|
6501
6844
|
let windowId = initialWindowId;
|
|
6845
|
+
// The spawn this write belongs to: acceptance evidence is only read while it is still the current one (#1197).
|
|
6846
|
+
const spawnAtWrite = this.spawnGeneration;
|
|
6502
6847
|
// Bug A: paste with backoff. Transient failures are usually a stale window id
|
|
6503
6848
|
// after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
|
|
6504
6849
|
const maxAttempts = 3;
|
|
@@ -6508,6 +6853,12 @@ export class Daemon extends EventEmitter {
|
|
|
6508
6853
|
// Read the pane BEFORE writing to it, so the submission check can require
|
|
6509
6854
|
// evidence this paste ADDED rather than evidence that was already there.
|
|
6510
6855
|
const pasteBaseline = await this.capturePaneEvidence(signature);
|
|
6856
|
+
// Every attempt, retries included: a stop or respawn during a recovery wait ends it unwritten.
|
|
6857
|
+
// After a durable begin, the caller's abort path owns the row; only an attempt with no begin may be redone.
|
|
6858
|
+
if (stillCurrent && !stillCurrent()) {
|
|
6859
|
+
verdict.fenced = !verdict.durableBeginCommitted;
|
|
6860
|
+
return false;
|
|
6861
|
+
}
|
|
6511
6862
|
// Readiness, idle-gate, dialog, spawn and pane-lock waits have all ended.
|
|
6512
6863
|
// Commit the submission fence at the last possible point before the
|
|
6513
6864
|
// first side effect; a crash during those waits remains safely replayable.
|
|
@@ -6520,6 +6871,10 @@ export class Daemon extends EventEmitter {
|
|
|
6520
6871
|
pastedContentSha256: durableAttempt.contentSha256 ?? null,
|
|
6521
6872
|
pastedBytesSha256: deliveryContentDigest(formatted),
|
|
6522
6873
|
};
|
|
6874
|
+
if (stillCurrent && !stillCurrent()) {
|
|
6875
|
+
verdict.fenced = true;
|
|
6876
|
+
return false;
|
|
6877
|
+
}
|
|
6523
6878
|
if (!this.beginDurableDelivery(durableAttempt, attemptEvidence)) {
|
|
6524
6879
|
verdict.durableBeginRejected = true;
|
|
6525
6880
|
verdict.phase = "submission-begin";
|
|
@@ -6534,7 +6889,10 @@ export class Daemon extends EventEmitter {
|
|
|
6534
6889
|
// failed/throwing write as uncertain rather than retrying bytes into the
|
|
6535
6890
|
// same pane; the write-ahead Enter marker later separates safe pre-Enter
|
|
6536
6891
|
// recovery from a possibly executed command.
|
|
6892
|
+
const pasteGeneration = this.spawnGeneration;
|
|
6537
6893
|
const pasted = await this.tmux.pasteBuffer(formatted);
|
|
6894
|
+
if (pasted)
|
|
6895
|
+
this.rememberPaste(formatted, pasteGeneration);
|
|
6538
6896
|
if (!pasted) {
|
|
6539
6897
|
if (rawPaste) {
|
|
6540
6898
|
verdict.phase = "raw-paste-write";
|
|
@@ -6640,6 +6998,18 @@ export class Daemon extends EventEmitter {
|
|
|
6640
6998
|
verdict.proof = proof;
|
|
6641
6999
|
return false;
|
|
6642
7000
|
}
|
|
7001
|
+
// A steer into a busy pane of a CLI with no input-row reader (claude-code, grok, muse): it can never be shown to
|
|
7002
|
+
// have left the input row, and the idle→busy edge that proves an ordinary submission cannot exist in a pane that
|
|
7003
|
+
// is already busy. What it CAN show, and what is enough to call the steer accepted, is its own trusted unique
|
|
7004
|
+
// marker on the pane after the one Enter — with the dialog, spawn and window unchanged (#1197).
|
|
7005
|
+
if (steer && signature.unique && !this.backend?.getBottomReadyPattern?.()
|
|
7006
|
+
&& await this.steerMarkerAccepted(signature, pasteBaseline, spawnAtWrite, windowId)) {
|
|
7007
|
+
verdict.phase = "steer-accepted";
|
|
7008
|
+
verdict.proof = "steer-marker-on-pane";
|
|
7009
|
+
if (status)
|
|
7010
|
+
this.emit("message_confirmed", status); // ✅ accepted into the live turn
|
|
7011
|
+
return true;
|
|
7012
|
+
}
|
|
6643
7013
|
// Re-pasting on a backend without structured Codex pane evidence
|
|
6644
7014
|
// would risk duplication; retain its legacy best-effort behavior.
|
|
6645
7015
|
this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
|
|
@@ -6868,6 +7238,29 @@ export class Daemon extends EventEmitter {
|
|
|
6868
7238
|
* — fall back to "the pane printed something" whenever a paste left no trace,
|
|
6869
7239
|
* which is the same false confirmation one layer down.
|
|
6870
7240
|
*/
|
|
7241
|
+
/**
|
|
7242
|
+
* The acceptance evidence for a steer whose input row cannot be read: a FRESH capture shows this delivery's trusted unique
|
|
7243
|
+
* marker more often than the pre-paste baseline did, no blocking dialog owns the pane, and it is still the spawn and the
|
|
7244
|
+
* window the paste went into. (The marker is the `message_id:` value AgEnD put in the handoff metadata — never text
|
|
7245
|
+
* scanned out of a body — so an older or quoted line cannot match it.)
|
|
7246
|
+
*/
|
|
7247
|
+
async steerMarkerAccepted(signature, baseline, spawnGeneration, windowId) {
|
|
7248
|
+
if (!this.tmux)
|
|
7249
|
+
return false;
|
|
7250
|
+
let pane;
|
|
7251
|
+
try {
|
|
7252
|
+
pane = await this.tmux.capturePane();
|
|
7253
|
+
}
|
|
7254
|
+
catch {
|
|
7255
|
+
return false;
|
|
7256
|
+
}
|
|
7257
|
+
// "More often than before the paste" needs a before: a baseline that could not be read is not a count of zero.
|
|
7258
|
+
if (!baseline || this.paneEvidence(pane, signature).payload <= baseline.payload)
|
|
7259
|
+
return false;
|
|
7260
|
+
const dialogClear = (await this.probeBlockingDialog()).state === "clear";
|
|
7261
|
+
// Asked last, after every await: the read evidence is only worth anything for the pane it was read from.
|
|
7262
|
+
return dialogClear && this.spawnGeneration === spawnGeneration && this.getWindowId() === windowId;
|
|
7263
|
+
}
|
|
6871
7264
|
canProveSubmission() {
|
|
6872
7265
|
return !!this.backend?.getBottomReadyPattern?.();
|
|
6873
7266
|
}
|
|
@@ -7143,17 +7536,40 @@ export class Daemon extends EventEmitter {
|
|
|
7143
7536
|
* Best effort by design: these notices must never fail a delivery or throw.
|
|
7144
7537
|
* The caller already holds paneWriteLock.
|
|
7145
7538
|
*/
|
|
7146
|
-
|
|
7539
|
+
/**
|
|
7540
|
+
* `guard` is for a paste that must not outlive the reason it was made (the continue nudge): `accept` judges the ONE
|
|
7541
|
+
* capture taken here — the same picture is the baseline, the expected-screen check and the idle check — and `current`
|
|
7542
|
+
* is asked again after every await that precedes a write, so a cancelled paste adds no further paste and no key
|
|
7543
|
+
* (the retries included). Without a guard it is exactly the old unconditional path.
|
|
7544
|
+
*/
|
|
7545
|
+
async submitSystemPaste(text, label, guard) {
|
|
7147
7546
|
if (!this.tmux)
|
|
7148
7547
|
return false;
|
|
7149
7548
|
const signature = this.submissionSignature(text);
|
|
7150
|
-
|
|
7549
|
+
let baseline;
|
|
7550
|
+
if (guard) {
|
|
7551
|
+
let pane;
|
|
7552
|
+
try {
|
|
7553
|
+
pane = await this.tmux.capturePane();
|
|
7554
|
+
}
|
|
7555
|
+
catch {
|
|
7556
|
+
return false;
|
|
7557
|
+
}
|
|
7558
|
+
if (!guard.current() || !guard.accept(pane))
|
|
7559
|
+
return false;
|
|
7560
|
+
baseline = this.paneEvidence(pane, signature);
|
|
7561
|
+
}
|
|
7562
|
+
else {
|
|
7563
|
+
baseline = await this.capturePaneEvidence(signature);
|
|
7564
|
+
}
|
|
7565
|
+
const pasteGeneration = this.spawnGeneration;
|
|
7151
7566
|
if (!(await this.tmux.pasteBuffer(text))) {
|
|
7152
7567
|
this.logger.warn({ label }, "System paste failed to reach the pane");
|
|
7153
7568
|
return false;
|
|
7154
7569
|
}
|
|
7570
|
+
this.rememberPaste(text, pasteGeneration);
|
|
7155
7571
|
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
7156
|
-
if (!(await this.sendDeliveryEnter(label)))
|
|
7572
|
+
if (!(await this.sendDeliveryEnter(label, guard?.current)))
|
|
7157
7573
|
return false;
|
|
7158
7574
|
let proof = await this.confirmSubmitted(signature, baseline);
|
|
7159
7575
|
if (proof === "unverifiable") {
|
|
@@ -7171,7 +7587,7 @@ export class Daemon extends EventEmitter {
|
|
|
7171
7587
|
// exists to remove — visible text is also what a strand looks like.
|
|
7172
7588
|
if (this.systemPasteOptions().retryEnter) {
|
|
7173
7589
|
await new Promise(r => setTimeout(r, 1_000));
|
|
7174
|
-
await this.sendDeliveryEnter(`${label}-defensive-retry
|
|
7590
|
+
await this.sendDeliveryEnter(`${label}-defensive-retry`, guard?.current);
|
|
7175
7591
|
}
|
|
7176
7592
|
return true; // best effort, exactly as before — nothing here is verified
|
|
7177
7593
|
}
|
|
@@ -7180,7 +7596,7 @@ export class Daemon extends EventEmitter {
|
|
|
7180
7596
|
// first one did land; when the text is still in the input row it is the
|
|
7181
7597
|
// submit it never got. Re-pasting would append the text to itself.
|
|
7182
7598
|
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
7183
|
-
if (!(await this.sendDeliveryEnter(`${label}-retry
|
|
7599
|
+
if (!(await this.sendDeliveryEnter(`${label}-retry`, guard?.current)))
|
|
7184
7600
|
return false;
|
|
7185
7601
|
proof = await this.confirmSubmitted(signature, baseline);
|
|
7186
7602
|
}
|
|
@@ -7748,6 +8164,9 @@ export class Daemon extends EventEmitter {
|
|
|
7748
8164
|
*/
|
|
7749
8165
|
beginSpawn() {
|
|
7750
8166
|
this.dialogAnswers = null;
|
|
8167
|
+
this.capacityNudge = null;
|
|
8168
|
+
this.lastErrorEpisode = null;
|
|
8169
|
+
this.nudgeBaselines = null;
|
|
7751
8170
|
// A relaunch settles any outstanding quit watch: the CLI came back.
|
|
7752
8171
|
this.clearQuitRelaunchWatch();
|
|
7753
8172
|
// A restarted CLI has no trustworthy turn edge for the process it replaced.
|
|
@@ -8133,8 +8552,7 @@ export class Daemon extends EventEmitter {
|
|
|
8133
8552
|
* a live turn. Fail closed: an unreadable pane is never idle.
|
|
8134
8553
|
*/
|
|
8135
8554
|
async isPaneAuthoritativelyIdle() {
|
|
8136
|
-
|
|
8137
|
-
if (!this.tmux || !backend)
|
|
8555
|
+
if (!this.tmux || !this.backend)
|
|
8138
8556
|
return false;
|
|
8139
8557
|
let pane;
|
|
8140
8558
|
try {
|
|
@@ -8143,6 +8561,13 @@ export class Daemon extends EventEmitter {
|
|
|
8143
8561
|
catch {
|
|
8144
8562
|
return false;
|
|
8145
8563
|
}
|
|
8564
|
+
return this.paneAuthoritativelyIdle(pane);
|
|
8565
|
+
}
|
|
8566
|
+
/** The verdict of {@link isPaneAuthoritativelyIdle} on a capture the caller already holds. */
|
|
8567
|
+
paneAuthoritativelyIdle(pane) {
|
|
8568
|
+
const backend = this.backend;
|
|
8569
|
+
if (!backend)
|
|
8570
|
+
return false;
|
|
8146
8571
|
if (backend.getBusyPattern?.()?.test(pane))
|
|
8147
8572
|
return false;
|
|
8148
8573
|
for (const dialog of this.deliveryBlockingDialogs()) {
|