@songsid/agend 2.1.11-beta.4 → 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 +44 -7
- 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 +34 -0
- 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 +49 -1
- package/dist/daemon.js +302 -24
- package/dist/daemon.js.map +1 -1
- package/dist/fleet-manager.d.ts +10 -0
- package/dist/fleet-manager.js +26 -7
- package/dist/fleet-manager.js.map +1 -1
- package/dist/instance-lifecycle.js +3 -6
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +5 -0
- package/dist/locale.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;
|
|
@@ -1202,6 +1222,8 @@ export class Daemon extends EventEmitter {
|
|
|
1202
1222
|
* comes from. See PaneWriteLock for why interleaving is destructive. */
|
|
1203
1223
|
paneWriteLock = new PaneWriteLock();
|
|
1204
1224
|
deliveryWritesStopping = false;
|
|
1225
|
+
/** #829: the latest pastes, by spawn generation (see ownedInputDraft). */
|
|
1226
|
+
recentPastes = [];
|
|
1205
1227
|
pendingInstructionsUpdate;
|
|
1206
1228
|
pendingInstructionsNotice = false;
|
|
1207
1229
|
// Whether the warmup steering-reload notice should be injected after spawn.
|
|
@@ -1603,6 +1625,14 @@ export class Daemon extends EventEmitter {
|
|
|
1603
1625
|
this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified`);
|
|
1604
1626
|
return;
|
|
1605
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
|
+
}
|
|
1606
1636
|
this.finishDurableDelivery(delivery, "delivered", "positive submission proof");
|
|
1607
1637
|
}
|
|
1608
1638
|
deferDurableDelivery(meta, reason) {
|
|
@@ -1665,19 +1695,29 @@ export class Daemon extends EventEmitter {
|
|
|
1665
1695
|
ensureInstanceDir(this.instanceDir);
|
|
1666
1696
|
writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
|
|
1667
1697
|
this.logger.info(`Starting ${this.name}`);
|
|
1668
|
-
//
|
|
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.
|
|
1669
1701
|
const crashStatePath = join(this.instanceDir, "crash-state.json");
|
|
1670
1702
|
try {
|
|
1671
1703
|
if (existsSync(crashStatePath)) {
|
|
1672
|
-
const
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
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 */ }
|
|
1676
1717
|
}
|
|
1677
|
-
unlinkSync(crashStatePath);
|
|
1678
1718
|
}
|
|
1679
1719
|
}
|
|
1680
|
-
catch { /* corrupt file — ignore */ }
|
|
1720
|
+
catch { /* unreadable or corrupt file — ignore */ }
|
|
1681
1721
|
// Restore last reply target so a fleet-topic instance can reply correctly
|
|
1682
1722
|
// BEFORE its first post-restart inbound arrives (otherwise lastChatId is empty
|
|
1683
1723
|
// → reply tool sends to "" → Discord 404).
|
|
@@ -3208,6 +3248,9 @@ export class Daemon extends EventEmitter {
|
|
|
3208
3248
|
* transaction; the interrupt key still cancels the CLI generation.
|
|
3209
3249
|
*/
|
|
3210
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();
|
|
3211
3254
|
this.deliveryEpoch = fleetEpoch === undefined
|
|
3212
3255
|
? this.deliveryEpoch + 1
|
|
3213
3256
|
: Math.max(this.deliveryEpoch, fleetEpoch);
|
|
@@ -3990,7 +4033,11 @@ export class Daemon extends EventEmitter {
|
|
|
3990
4033
|
* message can arrive mid-turn and sit queued, and marking at arrival would let
|
|
3991
4034
|
* the PREVIOUS turn's idle edge consume (and reset) the new turn's state.
|
|
3992
4035
|
*/
|
|
3993
|
-
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;
|
|
3994
4041
|
// Channel turns only. A cross-instance inbound (from_instance, empty
|
|
3995
4042
|
// chat_id) does not update lastChatId, so its proxy reply would land in
|
|
3996
4043
|
// whatever USER topic spoke to this instance last — the wrong audience for
|
|
@@ -4031,6 +4078,12 @@ export class Daemon extends EventEmitter {
|
|
|
4031
4078
|
this.turnReplyGuard.complete(turn.generation);
|
|
4032
4079
|
return;
|
|
4033
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
|
+
}
|
|
4034
4087
|
// A second idle edge ends the one permitted recovery turn. Never create a
|
|
4035
4088
|
// third turn or guess at terminal text; make the failure visible instead.
|
|
4036
4089
|
if (turn.phase === "recovering") {
|
|
@@ -4110,6 +4163,11 @@ export class Daemon extends EventEmitter {
|
|
|
4110
4163
|
&& this.backend?.replyCompletionGuard === true;
|
|
4111
4164
|
}
|
|
4112
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
|
+
}
|
|
4113
4171
|
if (!this.turnReplyGuard.beginRecovery(turn.generation))
|
|
4114
4172
|
return;
|
|
4115
4173
|
this.emit("reply_drop_detected", {
|
|
@@ -4137,8 +4195,8 @@ export class Daemon extends EventEmitter {
|
|
|
4137
4195
|
this.pasteQueueDepth++;
|
|
4138
4196
|
this.pasteLock = this.pasteLock.then(async () => {
|
|
4139
4197
|
try {
|
|
4140
|
-
const current = this.
|
|
4141
|
-
if (!current
|
|
4198
|
+
const current = this.currentReplyRecovery(turn.generation, deliveryEpoch);
|
|
4199
|
+
if (!current)
|
|
4142
4200
|
return;
|
|
4143
4201
|
if (current.completionDelivered) {
|
|
4144
4202
|
this.turnReplyGuard.complete(turn.generation);
|
|
@@ -4152,6 +4210,8 @@ export class Daemon extends EventEmitter {
|
|
|
4152
4210
|
const delivered = await this.deliverMessage(REPLY_RECOVERY_PROMPT, undefined, {
|
|
4153
4211
|
deliveryEpoch,
|
|
4154
4212
|
});
|
|
4213
|
+
if (!this.currentReplyRecovery(turn.generation, deliveryEpoch))
|
|
4214
|
+
return;
|
|
4155
4215
|
if (!delivered) {
|
|
4156
4216
|
this.turnReplyGuard.complete(turn.generation);
|
|
4157
4217
|
this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
|
|
@@ -4162,14 +4222,28 @@ export class Daemon extends EventEmitter {
|
|
|
4162
4222
|
}
|
|
4163
4223
|
}).catch(err => {
|
|
4164
4224
|
this.logger.error({ err: err.message }, "Reply-drop recovery prompt failed");
|
|
4225
|
+
if (!this.currentReplyRecovery(turn.generation, deliveryEpoch))
|
|
4226
|
+
return;
|
|
4165
4227
|
this.turnReplyGuard.complete(turn.generation);
|
|
4166
4228
|
this.reportUnrecoveredReplyDrop(turn, "recovery_prompt_delivery_failed");
|
|
4167
4229
|
});
|
|
4168
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
|
+
}
|
|
4169
4242
|
queueMalformedReplyRecovery(turn, text) {
|
|
4170
4243
|
// Serialize the delivery decision ahead of any new pane input. If direct
|
|
4171
4244
|
// delivery is positively acknowledged, there is no recovery turn. If its
|
|
4172
4245
|
// outcome is unknown, do not ask the model to duplicate it.
|
|
4246
|
+
const deliveryEpoch = this.deliveryEpoch;
|
|
4173
4247
|
const delivery = this.deliverDaemonReply(text, "malformedreply", "Malformed tool-call recovery", turn.target);
|
|
4174
4248
|
this.pasteQueueDepth++;
|
|
4175
4249
|
this.pasteLock = this.pasteLock.then(async () => {
|
|
@@ -4180,17 +4254,24 @@ export class Daemon extends EventEmitter {
|
|
|
4180
4254
|
correlationId: turn.target.correlationId,
|
|
4181
4255
|
recovered: delivered,
|
|
4182
4256
|
});
|
|
4257
|
+
const current = this.turnReplyGuard.snapshot();
|
|
4183
4258
|
this.turnReplyGuard.complete(turn.generation);
|
|
4184
|
-
if (!delivered
|
|
4259
|
+
if (!delivered && current?.generation === turn.generation && !current.cancelledByUser
|
|
4260
|
+
&& this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4185
4261
|
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
4262
|
+
}
|
|
4186
4263
|
}
|
|
4187
4264
|
finally {
|
|
4188
4265
|
this.pasteQueueDepth--;
|
|
4189
4266
|
}
|
|
4190
4267
|
}).catch(err => {
|
|
4191
4268
|
this.logger.error({ err: err.message }, "Malformed tool-call recovery attempt failed");
|
|
4269
|
+
const current = this.turnReplyGuard.snapshot();
|
|
4192
4270
|
this.turnReplyGuard.complete(turn.generation);
|
|
4193
|
-
|
|
4271
|
+
if (current?.generation === turn.generation && !current.cancelledByUser
|
|
4272
|
+
&& this.isDeliveryEpochCurrent(deliveryEpoch)) {
|
|
4273
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
4274
|
+
}
|
|
4194
4275
|
});
|
|
4195
4276
|
}
|
|
4196
4277
|
reportUnrecoveredReplyDrop(turn, reason = "recovery_turn_missing_reply") {
|
|
@@ -4621,7 +4702,10 @@ export class Daemon extends EventEmitter {
|
|
|
4621
4702
|
// the new progress timestamp and re-arms both deadlines.
|
|
4622
4703
|
if (!expectedOutputAt)
|
|
4623
4704
|
this.instanceStateLastOutputAt = captureStartedAt;
|
|
4624
|
-
|
|
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);
|
|
4625
4709
|
}
|
|
4626
4710
|
}
|
|
4627
4711
|
catch (err) {
|
|
@@ -4684,7 +4768,7 @@ export class Daemon extends EventEmitter {
|
|
|
4684
4768
|
: DEFAULT_STATE_IDLE_DEBOUNCE_MS;
|
|
4685
4769
|
this.instanceStateReadyPattern = this.backend.getReadyPattern();
|
|
4686
4770
|
this.instanceStateBusyPattern = this.backend.getBusyPattern?.() ?? null;
|
|
4687
|
-
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);
|
|
4688
4772
|
this.instanceStateLastOutputAt = 0;
|
|
4689
4773
|
this.instanceStatePeriodicIdleConfirmations = 0;
|
|
4690
4774
|
this.instanceStateMonitorActive = true;
|
|
@@ -4994,6 +5078,16 @@ export class Daemon extends EventEmitter {
|
|
|
4994
5078
|
return ""; // skip channel tools
|
|
4995
5079
|
return name;
|
|
4996
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
|
+
}
|
|
4997
5091
|
/**
|
|
4998
5092
|
* The one place an inbound message grows its metadata wrapper ([user:]/
|
|
4999
5093
|
* [from:] prefix, pending reactions, handoff metadata, reply instructions).
|
|
@@ -5103,8 +5197,7 @@ export class Daemon extends EventEmitter {
|
|
|
5103
5197
|
this.updateLastChat(meta.chat_id, meta.thread_id, meta.adapter_id);
|
|
5104
5198
|
this.pendingWork.recordInbound();
|
|
5105
5199
|
this.recordRecentUserMessage(content, meta);
|
|
5106
|
-
const formatted =
|
|
5107
|
-
+ this.formatInboundMessage(content, meta);
|
|
5200
|
+
const formatted = Daemon.steerEnvelope(this.formatInboundMessage(content, meta));
|
|
5108
5201
|
const status = channelStatus(meta);
|
|
5109
5202
|
const durableAttempt = this.durableDeliveryAttempt(meta, content);
|
|
5110
5203
|
if (durableAttempt === false) {
|
|
@@ -5131,7 +5224,7 @@ export class Daemon extends EventEmitter {
|
|
|
5131
5224
|
durableAttempt: durableAttempt || undefined,
|
|
5132
5225
|
})) {
|
|
5133
5226
|
this.finishDurableSubmission(durableAttempt, verdict);
|
|
5134
|
-
this.markTurnStarted(meta, formatted);
|
|
5227
|
+
this.markTurnStarted(meta, formatted, deliveryEpoch);
|
|
5135
5228
|
}
|
|
5136
5229
|
else if (durableAttempt && verdict.paneWriteStarted) {
|
|
5137
5230
|
this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
|
|
@@ -5186,7 +5279,7 @@ export class Daemon extends EventEmitter {
|
|
|
5186
5279
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
5187
5280
|
return;
|
|
5188
5281
|
if (await this.deliverMessage(formatted, status, { steer: true, deliveryEpoch, submissionId: meta.message_id })) {
|
|
5189
|
-
this.markTurnStarted(meta, formatted);
|
|
5282
|
+
this.markTurnStarted(meta, formatted, deliveryEpoch);
|
|
5190
5283
|
}
|
|
5191
5284
|
}).catch(err => {
|
|
5192
5285
|
this.logger.warn({ err: err.message }, "btw delivery error");
|
|
@@ -5299,7 +5392,7 @@ export class Daemon extends EventEmitter {
|
|
|
5299
5392
|
durableAttempt: durableAttempt || undefined,
|
|
5300
5393
|
})) {
|
|
5301
5394
|
this.finishDurableSubmission(durable, verdict);
|
|
5302
|
-
this.markTurnStarted(meta, formatted);
|
|
5395
|
+
this.markTurnStarted(meta, formatted, deliveryEpoch);
|
|
5303
5396
|
}
|
|
5304
5397
|
else if (durableAttempt && verdict.paneWriteStarted) {
|
|
5305
5398
|
this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`);
|
|
@@ -5403,6 +5496,7 @@ export class Daemon extends EventEmitter {
|
|
|
5403
5496
|
// this spawn generation. A spawn that starts afterwards is detected inside
|
|
5404
5497
|
// the critical section, where this delivery backs out and redoes itself.
|
|
5405
5498
|
const settleGeneration = this.spawnGeneration;
|
|
5499
|
+
const settleFence = this.launchFenceEpoch;
|
|
5406
5500
|
if (cancelled())
|
|
5407
5501
|
return false;
|
|
5408
5502
|
if (this.refuseFatalStartupDelivery(verdict, status))
|
|
@@ -5527,7 +5621,28 @@ export class Daemon extends EventEmitter {
|
|
|
5527
5621
|
}
|
|
5528
5622
|
if (!(await this.hasPositiveDeliveryInput()))
|
|
5529
5623
|
return "dialog";
|
|
5530
|
-
|
|
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;
|
|
5531
5646
|
});
|
|
5532
5647
|
if (outcome === "spawn-started") {
|
|
5533
5648
|
// The pane changed under this delivery: its queued paste must not land
|
|
@@ -6291,6 +6406,114 @@ export class Daemon extends EventEmitter {
|
|
|
6291
6406
|
* else submitted it); "failed" = the Enter could not be sent (the delivery
|
|
6292
6407
|
* must fail rather than paste on top of the stranded text).
|
|
6293
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
|
+
}
|
|
6294
6517
|
async submitStrandedInputIfAny(windowId) {
|
|
6295
6518
|
if (!this.tmux || !this.backend)
|
|
6296
6519
|
return "idle";
|
|
@@ -6612,10 +6835,15 @@ export class Daemon extends EventEmitter {
|
|
|
6612
6835
|
async writeMessageToPane(formatted, initialWindowId, handingOffToNativeQueue, status, submissionId,
|
|
6613
6836
|
// Last and defaulted so the positional callers that ignore the outcome stay
|
|
6614
6837
|
// readable; deliverMessage, the only one that reports, always passes its own.
|
|
6615
|
-
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) {
|
|
6616
6842
|
const signature = this.submissionSignature(formatted, submissionId);
|
|
6617
6843
|
const rawPaste = durableAttempt?.submissionMode === "raw_paste";
|
|
6618
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;
|
|
6619
6847
|
// Bug A: paste with backoff. Transient failures are usually a stale window id
|
|
6620
6848
|
// after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
|
|
6621
6849
|
const maxAttempts = 3;
|
|
@@ -6625,6 +6853,12 @@ export class Daemon extends EventEmitter {
|
|
|
6625
6853
|
// Read the pane BEFORE writing to it, so the submission check can require
|
|
6626
6854
|
// evidence this paste ADDED rather than evidence that was already there.
|
|
6627
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
|
+
}
|
|
6628
6862
|
// Readiness, idle-gate, dialog, spawn and pane-lock waits have all ended.
|
|
6629
6863
|
// Commit the submission fence at the last possible point before the
|
|
6630
6864
|
// first side effect; a crash during those waits remains safely replayable.
|
|
@@ -6637,6 +6871,10 @@ export class Daemon extends EventEmitter {
|
|
|
6637
6871
|
pastedContentSha256: durableAttempt.contentSha256 ?? null,
|
|
6638
6872
|
pastedBytesSha256: deliveryContentDigest(formatted),
|
|
6639
6873
|
};
|
|
6874
|
+
if (stillCurrent && !stillCurrent()) {
|
|
6875
|
+
verdict.fenced = true;
|
|
6876
|
+
return false;
|
|
6877
|
+
}
|
|
6640
6878
|
if (!this.beginDurableDelivery(durableAttempt, attemptEvidence)) {
|
|
6641
6879
|
verdict.durableBeginRejected = true;
|
|
6642
6880
|
verdict.phase = "submission-begin";
|
|
@@ -6651,7 +6889,10 @@ export class Daemon extends EventEmitter {
|
|
|
6651
6889
|
// failed/throwing write as uncertain rather than retrying bytes into the
|
|
6652
6890
|
// same pane; the write-ahead Enter marker later separates safe pre-Enter
|
|
6653
6891
|
// recovery from a possibly executed command.
|
|
6892
|
+
const pasteGeneration = this.spawnGeneration;
|
|
6654
6893
|
const pasted = await this.tmux.pasteBuffer(formatted);
|
|
6894
|
+
if (pasted)
|
|
6895
|
+
this.rememberPaste(formatted, pasteGeneration);
|
|
6655
6896
|
if (!pasted) {
|
|
6656
6897
|
if (rawPaste) {
|
|
6657
6898
|
verdict.phase = "raw-paste-write";
|
|
@@ -6757,6 +6998,18 @@ export class Daemon extends EventEmitter {
|
|
|
6757
6998
|
verdict.proof = proof;
|
|
6758
6999
|
return false;
|
|
6759
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
|
+
}
|
|
6760
7013
|
// Re-pasting on a backend without structured Codex pane evidence
|
|
6761
7014
|
// would risk duplication; retain its legacy best-effort behavior.
|
|
6762
7015
|
this.logger.warn("Paste reached the pane but could not be verified as submitted — accepting without proof");
|
|
@@ -6985,6 +7238,29 @@ export class Daemon extends EventEmitter {
|
|
|
6985
7238
|
* — fall back to "the pane printed something" whenever a paste left no trace,
|
|
6986
7239
|
* which is the same false confirmation one layer down.
|
|
6987
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
|
+
}
|
|
6988
7264
|
canProveSubmission() {
|
|
6989
7265
|
return !!this.backend?.getBottomReadyPattern?.();
|
|
6990
7266
|
}
|
|
@@ -7286,10 +7562,12 @@ export class Daemon extends EventEmitter {
|
|
|
7286
7562
|
else {
|
|
7287
7563
|
baseline = await this.capturePaneEvidence(signature);
|
|
7288
7564
|
}
|
|
7565
|
+
const pasteGeneration = this.spawnGeneration;
|
|
7289
7566
|
if (!(await this.tmux.pasteBuffer(text))) {
|
|
7290
7567
|
this.logger.warn({ label }, "System paste failed to reach the pane");
|
|
7291
7568
|
return false;
|
|
7292
7569
|
}
|
|
7570
|
+
this.rememberPaste(text, pasteGeneration);
|
|
7293
7571
|
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
7294
7572
|
if (!(await this.sendDeliveryEnter(label, guard?.current)))
|
|
7295
7573
|
return false;
|