@songsid/agend 2.1.5-beta.17 → 2.1.5-beta.19
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 -1
- package/dist/backend/codex.js +33 -0
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/types.d.ts +24 -0
- package/dist/backend/types.js.map +1 -1
- package/dist/daemon.d.ts +55 -8
- package/dist/daemon.js +238 -70
- package/dist/daemon.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon.js
CHANGED
|
@@ -391,6 +391,9 @@ export class PendingWorkTracker {
|
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
393
|
const NORMAL_ENTER_SETTLE_MS = 500;
|
|
394
|
+
/** How long to keep looking for proof after a retry Enter before failing it. */
|
|
395
|
+
const POST_ENTER_PROOF_WINDOW_MS = 3_000;
|
|
396
|
+
const POST_ENTER_PROOF_POLL_MS = 250;
|
|
394
397
|
/** Attempts to read the pre-paste pane before a delivery gives up on a baseline. */
|
|
395
398
|
const BASELINE_CAPTURE_ATTEMPTS = 3;
|
|
396
399
|
const BASELINE_CAPTURE_RETRY_MS = 150;
|
|
@@ -428,6 +431,9 @@ export class BackendUnreachableStartupError extends Error {
|
|
|
428
431
|
}
|
|
429
432
|
/** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
|
|
430
433
|
const STRANDED_RETRY_READY_WAIT_MS = 30_000;
|
|
434
|
+
/** A passive startup phase must clear within this bound before an Enter is sent. */
|
|
435
|
+
const INPUT_TRANSIENT_WAIT_MS = 30_000;
|
|
436
|
+
const INPUT_TRANSIENT_POLL_MS = 250;
|
|
431
437
|
/** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
|
|
432
438
|
const STRANDED_INPUT_MAX_ROUNDS = 3;
|
|
433
439
|
const FIRST_ENTER_SETTLE_MS = 1_750;
|
|
@@ -854,6 +860,14 @@ export class Daemon extends EventEmitter {
|
|
|
854
860
|
spawnSettled = null;
|
|
855
861
|
resolveSpawnSettled = null;
|
|
856
862
|
spawnDepth = 0;
|
|
863
|
+
/** Monotonic identity for nested beginSpawn/endSpawn lifecycles. */
|
|
864
|
+
spawnGeneration = 0;
|
|
865
|
+
/**
|
|
866
|
+
* Keeps passive startup-transient checks alive through the first positively
|
|
867
|
+
* submitted post-spawn message. Without this tail, a transient painted just
|
|
868
|
+
* after the startup scan can still swallow that message's Enter.
|
|
869
|
+
*/
|
|
870
|
+
inputTransientGuardGeneration = null;
|
|
857
871
|
skipResume = false;
|
|
858
872
|
startupAborted = false;
|
|
859
873
|
/** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
|
|
@@ -2520,6 +2534,9 @@ export class Daemon extends EventEmitter {
|
|
|
2520
2534
|
async stop() {
|
|
2521
2535
|
this.logger.info("Stopping daemon instance");
|
|
2522
2536
|
this.turnReplyGuard.reset();
|
|
2537
|
+
// Invalidate any bounded pre-Enter wait from the process generation being
|
|
2538
|
+
// stopped. It must fail closed, not press Enter in a replacement pane.
|
|
2539
|
+
this.inputTransientGuardGeneration = null;
|
|
2523
2540
|
this.freezeRuntimeMonitors();
|
|
2524
2541
|
this.pendingIpcRequests.clear();
|
|
2525
2542
|
if (this.adapter)
|
|
@@ -4043,16 +4060,24 @@ export class Daemon extends EventEmitter {
|
|
|
4043
4060
|
}
|
|
4044
4061
|
}
|
|
4045
4062
|
}
|
|
4046
|
-
// Everything above is *waiting*; everything below
|
|
4047
|
-
//
|
|
4048
|
-
//
|
|
4049
|
-
//
|
|
4063
|
+
// Everything above is *waiting*; everything below owns the write lock.
|
|
4064
|
+
// Long idle/dialog waits stay outside because the runtime dismisser may be
|
|
4065
|
+
// what makes progress. The one under-lock wait below is explicitly passive:
|
|
4066
|
+
// it clears by itself and closes a repaint race immediately before paste.
|
|
4050
4067
|
for (let round = 0;; round++) {
|
|
4051
4068
|
const outcome = await this.paneWriteLock.run(async () => {
|
|
4052
4069
|
if (cancelled())
|
|
4053
4070
|
return false;
|
|
4054
4071
|
if (this.refuseFatalStartupDelivery(status))
|
|
4055
4072
|
return false;
|
|
4073
|
+
// A passive startup phase may paint after the outer readiness probe.
|
|
4074
|
+
// It clears without input, so waiting under the pane lock cannot starve
|
|
4075
|
+
// a dialog dismisser and closes the final clear→paste TOCTOU window.
|
|
4076
|
+
if (!(await this.waitForInputTransientToClear("pre-write"))) {
|
|
4077
|
+
if (status)
|
|
4078
|
+
this.emit("message_failed", status); // ❌
|
|
4079
|
+
return false;
|
|
4080
|
+
}
|
|
4056
4081
|
// TOCTOU: the probes above ran outside this lock, and the CLI repaints
|
|
4057
4082
|
// whenever it likes — a resume prompt can be painted between "clear" and
|
|
4058
4083
|
// this critical section. Re-probe here, right before the write; a
|
|
@@ -4096,6 +4121,75 @@ export class Daemon extends EventEmitter {
|
|
|
4096
4121
|
deliveryBlockingDialogs() {
|
|
4097
4122
|
return (this.backend?.getRuntimeDialogs?.() ?? []).filter(d => d.blocksDelivery || d.holdOnly);
|
|
4098
4123
|
}
|
|
4124
|
+
/** Passive input-unavailable phases relevant to the current spawn only. */
|
|
4125
|
+
guardedInputTransients() {
|
|
4126
|
+
if (this.inputTransientGuardGeneration === null
|
|
4127
|
+
|| this.inputTransientGuardGeneration !== this.spawnGeneration)
|
|
4128
|
+
return [];
|
|
4129
|
+
return this.backend?.getInputUnavailableTransients?.() ?? [];
|
|
4130
|
+
}
|
|
4131
|
+
/** Match a passive transient against the current interactive screen. */
|
|
4132
|
+
inputTransientInPane(pane) {
|
|
4133
|
+
for (const transient of this.guardedInputTransients()) {
|
|
4134
|
+
// `pattern` is only a cheap pre-filter. `isActive` is deliberately the
|
|
4135
|
+
// authority because users and agents routinely quote diagnostic text.
|
|
4136
|
+
transient.pattern.lastIndex = 0;
|
|
4137
|
+
if (!transient.pattern.test(pane))
|
|
4138
|
+
continue;
|
|
4139
|
+
if (transient.isActive(pane))
|
|
4140
|
+
return transient;
|
|
4141
|
+
}
|
|
4142
|
+
return null;
|
|
4143
|
+
}
|
|
4144
|
+
/** Capture failure is unknown, never evidence that input is available. */
|
|
4145
|
+
async probeInputTransient() {
|
|
4146
|
+
if (this.guardedInputTransients().length === 0 || !this.tmux)
|
|
4147
|
+
return { state: "clear" };
|
|
4148
|
+
try {
|
|
4149
|
+
const pane = await this.tmux.capturePane();
|
|
4150
|
+
const transient = this.inputTransientInPane(pane);
|
|
4151
|
+
return transient ? { state: "active", transient } : { state: "clear" };
|
|
4152
|
+
}
|
|
4153
|
+
catch (err) {
|
|
4154
|
+
this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
|
|
4155
|
+
return { state: "unknown" };
|
|
4156
|
+
}
|
|
4157
|
+
}
|
|
4158
|
+
/**
|
|
4159
|
+
* Wait under the pane lock for a passive transient to disappear. Unlike a
|
|
4160
|
+
* dialog, this state clears by itself, so the wait cannot starve a keypress
|
|
4161
|
+
* dismisser. Stop or a replacement spawn invalidates the captured identity.
|
|
4162
|
+
*/
|
|
4163
|
+
async waitForInputTransientToClear(phase, timeoutMs = INPUT_TRANSIENT_WAIT_MS) {
|
|
4164
|
+
const generation = this.inputTransientGuardGeneration;
|
|
4165
|
+
if (generation === null || generation !== this.spawnGeneration)
|
|
4166
|
+
return true;
|
|
4167
|
+
const deadline = Date.now() + timeoutMs;
|
|
4168
|
+
let observedDescription = null;
|
|
4169
|
+
for (;;) {
|
|
4170
|
+
if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
|
|
4171
|
+
this.logger.warn({ phase, generation }, "Spawn changed while waiting for input availability — refusing to send Enter into the replacement pane");
|
|
4172
|
+
return false;
|
|
4173
|
+
}
|
|
4174
|
+
const probe = await this.probeInputTransient();
|
|
4175
|
+
if (this.inputTransientGuardGeneration !== generation || this.spawnGeneration !== generation) {
|
|
4176
|
+
this.logger.warn({ phase, generation }, "Spawn changed during the input-availability probe — refusing to send Enter into the replacement pane");
|
|
4177
|
+
return false;
|
|
4178
|
+
}
|
|
4179
|
+
if (probe.state === "clear")
|
|
4180
|
+
return true;
|
|
4181
|
+
if (probe.state === "active" && observedDescription !== probe.transient.description) {
|
|
4182
|
+
observedDescription = probe.transient.description;
|
|
4183
|
+
this.logger.info({ phase, transient: probe.transient.description, generation }, "CLI is still completing startup — waiting before sending Enter");
|
|
4184
|
+
}
|
|
4185
|
+
const remaining = deadline - Date.now();
|
|
4186
|
+
if (remaining <= 0) {
|
|
4187
|
+
this.logger.error({ phase, generation, timeoutMs, transient: observedDescription }, "CLI input stayed unavailable — refusing to send Enter");
|
|
4188
|
+
return false;
|
|
4189
|
+
}
|
|
4190
|
+
await new Promise(r => setTimeout(r, Math.min(INPUT_TRANSIENT_POLL_MS, remaining)));
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
4099
4193
|
static dialogKey(dialog) {
|
|
4100
4194
|
return `${dialog.pattern.source}/${dialog.pattern.flags}`;
|
|
4101
4195
|
}
|
|
@@ -4206,10 +4300,10 @@ export class Daemon extends EventEmitter {
|
|
|
4206
4300
|
}
|
|
4207
4301
|
/**
|
|
4208
4302
|
* Why a pane is not deliverable matters. "busy" may be handed to a native
|
|
4209
|
-
* input queue or steered (after its own dialog probe); "dialog"
|
|
4210
|
-
* must never be — a paste + Enter into a dialog answers it
|
|
4211
|
-
*
|
|
4212
|
-
*
|
|
4303
|
+
* input queue or steered (after its own dialog probe); "dialog", "transient"
|
|
4304
|
+
* and "unknown" must never be — a paste + Enter into a dialog answers it
|
|
4305
|
+
* with its default, while a startup transient swallows it and an unreadable
|
|
4306
|
+
* pane proves nothing. The silence gate comes first and costs no capture.
|
|
4213
4307
|
*/
|
|
4214
4308
|
async paneReadinessForDelivery(windowId) {
|
|
4215
4309
|
if (!this.isPaneIdleForDelivery(windowId))
|
|
@@ -4217,6 +4311,11 @@ export class Daemon extends EventEmitter {
|
|
|
4217
4311
|
const probe = await this.probeBlockingDialog();
|
|
4218
4312
|
if (probe.state !== "clear")
|
|
4219
4313
|
return probe.state;
|
|
4314
|
+
const transient = await this.probeInputTransient();
|
|
4315
|
+
if (transient.state === "active")
|
|
4316
|
+
return "transient";
|
|
4317
|
+
if (transient.state === "unknown")
|
|
4318
|
+
return "unknown";
|
|
4220
4319
|
if (this.backend?.dropsEnterWhileBusy?.() !== true)
|
|
4221
4320
|
return "ready";
|
|
4222
4321
|
const prompt = this.backend.getBottomReadyPattern?.();
|
|
@@ -4263,6 +4362,7 @@ export class Daemon extends EventEmitter {
|
|
|
4263
4362
|
const deadline = Date.now() + timeoutMs;
|
|
4264
4363
|
const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
|
|
4265
4364
|
let unknownStreak = 0;
|
|
4365
|
+
let transientDeadline = 0;
|
|
4266
4366
|
for (;;) {
|
|
4267
4367
|
const remaining = deadline - Date.now();
|
|
4268
4368
|
if (remaining <= 0)
|
|
@@ -4272,6 +4372,26 @@ export class Daemon extends EventEmitter {
|
|
|
4272
4372
|
return false;
|
|
4273
4373
|
// The idle wait just resolved, so re-reading the silence gate would only
|
|
4274
4374
|
// add a stale-cache race; ask the pane the remaining questions directly.
|
|
4375
|
+
const transient = await this.probeInputTransient();
|
|
4376
|
+
if (transient.state === "active") {
|
|
4377
|
+
unknownStreak = 0;
|
|
4378
|
+
transientDeadline ||= Math.min(deadline, Date.now() + INPUT_TRANSIENT_WAIT_MS);
|
|
4379
|
+
if (Date.now() >= transientDeadline) {
|
|
4380
|
+
this.logger.error({ transient: transient.transient.description, timeoutMs: INPUT_TRANSIENT_WAIT_MS }, "CLI input stayed unavailable during the delivery-readiness wait");
|
|
4381
|
+
return false;
|
|
4382
|
+
}
|
|
4383
|
+
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
4384
|
+
continue;
|
|
4385
|
+
}
|
|
4386
|
+
transientDeadline = 0;
|
|
4387
|
+
if (transient.state === "unknown") {
|
|
4388
|
+
if (++unknownStreak >= DIALOG_PROBE_UNKNOWN_MAX) {
|
|
4389
|
+
this.logger.error({ probes: unknownStreak }, "Pane stayed unreadable during the input-transient probe — refusing to deliver blind");
|
|
4390
|
+
return false;
|
|
4391
|
+
}
|
|
4392
|
+
await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
|
|
4393
|
+
continue;
|
|
4394
|
+
}
|
|
4275
4395
|
if (bottomGated) {
|
|
4276
4396
|
if (await this.isPaneReadyForDelivery(windowId))
|
|
4277
4397
|
return true;
|
|
@@ -4307,15 +4427,16 @@ export class Daemon extends EventEmitter {
|
|
|
4307
4427
|
* must fail rather than paste on top of the stranded text).
|
|
4308
4428
|
*/
|
|
4309
4429
|
async submitStrandedInputIfAny(windowId) {
|
|
4310
|
-
if (this.
|
|
4430
|
+
if (!this.tmux || !this.backend)
|
|
4311
4431
|
return "idle";
|
|
4312
4432
|
const prompt = this.backend.getBottomReadyPattern?.();
|
|
4313
4433
|
if (!prompt)
|
|
4314
4434
|
return "idle";
|
|
4435
|
+
const requireBottomReady = this.backend.dropsEnterWhileBusy?.() === true;
|
|
4315
4436
|
// Pre-check outside the lock so the common case (verified clear) never
|
|
4316
4437
|
// contends with the dialog dismisser. Only a POSITIVE "clear" skips the
|
|
4317
4438
|
// lock; an unreadable pane is re-examined under it, not waved through.
|
|
4318
|
-
const pre = await this.strandedInputState(prompt);
|
|
4439
|
+
const pre = await this.strandedInputState(prompt, requireBottomReady);
|
|
4319
4440
|
if (pre === "clear")
|
|
4320
4441
|
return "idle";
|
|
4321
4442
|
if (pre === "busy")
|
|
@@ -4331,7 +4452,7 @@ export class Daemon extends EventEmitter {
|
|
|
4331
4452
|
// no-op at an empty prompt, but into a modal it is a confirmation.
|
|
4332
4453
|
if (this.fatalStartupBlocked)
|
|
4333
4454
|
return "busy";
|
|
4334
|
-
const state = await this.strandedInputState(prompt);
|
|
4455
|
+
const state = await this.strandedInputState(prompt, requireBottomReady);
|
|
4335
4456
|
if (state === "clear")
|
|
4336
4457
|
return "idle";
|
|
4337
4458
|
if (state !== "stranded")
|
|
@@ -4341,12 +4462,12 @@ export class Daemon extends EventEmitter {
|
|
|
4341
4462
|
});
|
|
4342
4463
|
}
|
|
4343
4464
|
/**
|
|
4344
|
-
* "stranded": an AgEnD message sits in the input row; "clear":
|
|
4345
|
-
*
|
|
4346
|
-
*
|
|
4347
|
-
* read. Only "clear"
|
|
4465
|
+
* "stranded": an AgEnD message sits in the input row; "clear": no strand is
|
|
4466
|
+
* visible under the backend's readiness policy; "busy": generating, or (for
|
|
4467
|
+
* a bottom-gated backend) the prompt is not at the bottom; "unknown": the
|
|
4468
|
+
* pane could not be read. Only "clear" permits the next paste.
|
|
4348
4469
|
*/
|
|
4349
|
-
async strandedInputState(prompt) {
|
|
4470
|
+
async strandedInputState(prompt, requireBottomReady) {
|
|
4350
4471
|
let pane;
|
|
4351
4472
|
try {
|
|
4352
4473
|
pane = await this.tmux.capturePane();
|
|
@@ -4354,11 +4475,16 @@ export class Daemon extends EventEmitter {
|
|
|
4354
4475
|
catch {
|
|
4355
4476
|
return "unknown";
|
|
4356
4477
|
}
|
|
4478
|
+
if (this.inputTransientInPane(pane))
|
|
4479
|
+
return "busy";
|
|
4357
4480
|
if (this.backend?.getBusyPattern?.()?.test(pane))
|
|
4358
4481
|
return "busy";
|
|
4359
4482
|
if (strandedAgendMessageInInput(pane, prompt))
|
|
4360
4483
|
return "stranded";
|
|
4361
|
-
|
|
4484
|
+
// Kiro needs a bottom-anchored prompt as positive readiness evidence. Codex
|
|
4485
|
+
// does not: this preflight runs only after its ordinary silence gate and is
|
|
4486
|
+
// enabled solely to recover a positively identified old AgEnD strand.
|
|
4487
|
+
return !requireBottomReady || bottomRowIsReady(pane, prompt) ? "clear" : "busy";
|
|
4362
4488
|
}
|
|
4363
4489
|
/**
|
|
4364
4490
|
* F2: for Enter-dropping TUIs, "some output after Enter" proves nothing — the
|
|
@@ -4462,6 +4588,8 @@ export class Daemon extends EventEmitter {
|
|
|
4462
4588
|
* an invariant maintained by comments.
|
|
4463
4589
|
*/
|
|
4464
4590
|
async sendDeliveryEnter(phase) {
|
|
4591
|
+
if (!(await this.waitForInputTransientToClear(phase)))
|
|
4592
|
+
return false;
|
|
4465
4593
|
const sent = await this.tmux.sendSpecialKey("Enter");
|
|
4466
4594
|
if (!sent) {
|
|
4467
4595
|
this.logger.error({
|
|
@@ -4647,18 +4775,7 @@ export class Daemon extends EventEmitter {
|
|
|
4647
4775
|
return false;
|
|
4648
4776
|
}
|
|
4649
4777
|
if (windowId && this.controlClient) {
|
|
4650
|
-
|
|
4651
|
-
if (!becameBusy) {
|
|
4652
|
-
this.logger.warn("No idle→busy after idle-gated redelivery — re-sending Enter once");
|
|
4653
|
-
const retry2At = Date.now();
|
|
4654
|
-
if (!(await this.sendDeliveryEnter("native-queue-idle-redelivery-retry"))) {
|
|
4655
|
-
if (status)
|
|
4656
|
-
this.emit("message_failed", status); // ❌
|
|
4657
|
-
return false;
|
|
4658
|
-
}
|
|
4659
|
-
becameBusy = await this.confirmBusyAfterEnter(windowId, retry2At);
|
|
4660
|
-
}
|
|
4661
|
-
if (becameBusy) {
|
|
4778
|
+
if (await this.confirmAfterEnter(windowId, retryAt, signature, pasteBaseline, "native-queue-idle-redelivery-retry")) {
|
|
4662
4779
|
if (status)
|
|
4663
4780
|
this.emit("message_confirmed", status); // ✅
|
|
4664
4781
|
return true;
|
|
@@ -4714,43 +4831,7 @@ export class Daemon extends EventEmitter {
|
|
|
4714
4831
|
// is disqualifying and no amount of output may override it. Where it
|
|
4715
4832
|
// cannot (a backend with no prompt pattern), the output signal remains
|
|
4716
4833
|
// exactly as before — this must not regress backends we cannot read.
|
|
4717
|
-
|
|
4718
|
-
if (proof === "stranded") {
|
|
4719
|
-
this.logger.warn("Enter did not submit — text is still in the input row; waiting for the prompt and re-sending once");
|
|
4720
|
-
const promptBack = await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS);
|
|
4721
|
-
if (promptBack && !(await this.sendDeliveryEnter("idle-stranded-retry"))) {
|
|
4722
|
-
if (status)
|
|
4723
|
-
this.emit("message_failed", status); // ❌
|
|
4724
|
-
return false;
|
|
4725
|
-
}
|
|
4726
|
-
proof = promptBack ? await this.confirmSubmitted(signature, pasteBaseline) : proof;
|
|
4727
|
-
if (proof !== "submitted") {
|
|
4728
|
-
this.logger.error({ proof }, "Message pasted but never submitted (still in the input row after the retry)");
|
|
4729
|
-
if (status)
|
|
4730
|
-
this.emit("message_failed", status); // ❌
|
|
4731
|
-
return false;
|
|
4732
|
-
}
|
|
4733
|
-
}
|
|
4734
|
-
let becameBusy = proof === "submitted";
|
|
4735
|
-
if (!becameBusy)
|
|
4736
|
-
becameBusy = await this.confirmBusyAfterEnter(windowId, enterAt);
|
|
4737
|
-
if (!becameBusy) {
|
|
4738
|
-
this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
|
|
4739
|
-
const retryAt = Date.now();
|
|
4740
|
-
if (!(await this.sendDeliveryEnter("idle-to-busy-retry"))) {
|
|
4741
|
-
if (status)
|
|
4742
|
-
this.emit("message_failed", status); // ❌
|
|
4743
|
-
return false;
|
|
4744
|
-
}
|
|
4745
|
-
// The input row still outranks output on the retry too.
|
|
4746
|
-
if (await this.confirmSubmitted(signature, pasteBaseline) === "stranded") {
|
|
4747
|
-
this.logger.error("Message pasted but never submitted (still in the input row after two Enters)");
|
|
4748
|
-
if (status)
|
|
4749
|
-
this.emit("message_failed", status); // ❌
|
|
4750
|
-
return false;
|
|
4751
|
-
}
|
|
4752
|
-
becameBusy = await this.confirmBusyAfterEnter(windowId, retryAt);
|
|
4753
|
-
}
|
|
4834
|
+
const becameBusy = await this.confirmAfterEnter(windowId, enterAt, signature, pasteBaseline, "idle-to-busy-retry");
|
|
4754
4835
|
if (becameBusy) {
|
|
4755
4836
|
if (status)
|
|
4756
4837
|
this.emit("message_confirmed", status); // ✅
|
|
@@ -4791,6 +4872,71 @@ export class Daemon extends EventEmitter {
|
|
|
4791
4872
|
* anywhere. Evidence is weighed in order of what it can prove, and the
|
|
4792
4873
|
* disqualifying evidence is checked first.
|
|
4793
4874
|
*/
|
|
4875
|
+
/**
|
|
4876
|
+
* Whether this backend exposes an input row we can actually read, i.e. whether
|
|
4877
|
+
* confirmSubmitted can ever return a verdict stronger than a guess.
|
|
4878
|
+
*
|
|
4879
|
+
* This is a property of the BACKEND, not of one delivery's verdict. Keying the
|
|
4880
|
+
* output-signal fallback off the verdict instead let codex — which can be read
|
|
4881
|
+
* — fall back to "the pane printed something" whenever a paste left no trace,
|
|
4882
|
+
* which is the same false confirmation one layer down.
|
|
4883
|
+
*/
|
|
4884
|
+
canProveSubmission() {
|
|
4885
|
+
return !!this.backend?.getBottomReadyPattern?.();
|
|
4886
|
+
}
|
|
4887
|
+
/** Positive submission retires the startup transient guard for this spawn. */
|
|
4888
|
+
submittedProof() {
|
|
4889
|
+
if (this.inputTransientGuardGeneration === this.spawnGeneration) {
|
|
4890
|
+
this.inputTransientGuardGeneration = null;
|
|
4891
|
+
}
|
|
4892
|
+
return "submitted";
|
|
4893
|
+
}
|
|
4894
|
+
/**
|
|
4895
|
+
* Decide a submission after an Enter, with one bounded retry.
|
|
4896
|
+
*
|
|
4897
|
+
* Backends we can read must SHOW the text left the input row: neither a
|
|
4898
|
+
* strand nor a vanished paste may be upgraded to success by output, because
|
|
4899
|
+
* the output that would do the upgrading is usually the very redraw the Enter
|
|
4900
|
+
* was lost to. Backends we cannot read keep the legacy output signal — there
|
|
4901
|
+
* is nothing better available for them, and taking it away would fail every
|
|
4902
|
+
* one of their deliveries.
|
|
4903
|
+
*
|
|
4904
|
+
* One ladder, used by every post-Enter confirmation, so an escape hatch
|
|
4905
|
+
* cannot reappear in just one of them (it did: three copies, two of which
|
|
4906
|
+
* still confirmed on output alone).
|
|
4907
|
+
*/
|
|
4908
|
+
async confirmAfterEnter(windowId, enterAt, signature, baseline, retryPhase) {
|
|
4909
|
+
if (!this.canProveSubmission()) {
|
|
4910
|
+
let busy = await this.confirmBusyAfterEnter(windowId, enterAt);
|
|
4911
|
+
if (!busy) {
|
|
4912
|
+
this.logger.warn("No idle→busy transition after Enter — re-sending Enter once");
|
|
4913
|
+
const retryAt = Date.now();
|
|
4914
|
+
if (!(await this.sendDeliveryEnter(retryPhase)))
|
|
4915
|
+
return false;
|
|
4916
|
+
busy = await this.confirmBusyAfterEnter(windowId, retryAt);
|
|
4917
|
+
}
|
|
4918
|
+
return busy;
|
|
4919
|
+
}
|
|
4920
|
+
if (await this.confirmSubmitted(signature, baseline) === "submitted")
|
|
4921
|
+
return true;
|
|
4922
|
+
// Retry once the prompt is back — an Enter sent into a mid-redraw TUI is
|
|
4923
|
+
// how we got here. Then poll briefly rather than judging on one capture:
|
|
4924
|
+
// the key takes a moment to be digested, and a single early look would
|
|
4925
|
+
// report a failure for a message that did go out.
|
|
4926
|
+
this.logger.warn("Enter did not submit — waiting for the prompt, then re-sending once");
|
|
4927
|
+
if (!(await this.waitForPaneReadyForDelivery(windowId, STRANDED_RETRY_READY_WAIT_MS)))
|
|
4928
|
+
return false;
|
|
4929
|
+
if (!(await this.sendDeliveryEnter(retryPhase)))
|
|
4930
|
+
return false;
|
|
4931
|
+
const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
|
|
4932
|
+
for (;;) {
|
|
4933
|
+
if (await this.confirmSubmitted(signature, baseline) === "submitted")
|
|
4934
|
+
return true;
|
|
4935
|
+
if (Date.now() >= deadline)
|
|
4936
|
+
return false;
|
|
4937
|
+
await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
|
|
4938
|
+
}
|
|
4939
|
+
}
|
|
4794
4940
|
async confirmSubmitted(signature, baseline) {
|
|
4795
4941
|
if (!this.tmux)
|
|
4796
4942
|
return "unproven";
|
|
@@ -4838,16 +4984,16 @@ export class Daemon extends EventEmitter {
|
|
|
4838
4984
|
// failure to read the pane BEFORE pasting cannot turn a delivered
|
|
4839
4985
|
// message into a re-paste.
|
|
4840
4986
|
if (signature.unique && after.payload > 0)
|
|
4841
|
-
return
|
|
4987
|
+
return this.submittedProof();
|
|
4842
4988
|
// 3. Otherwise the evidence must be NEW relative to the pane as it was
|
|
4843
4989
|
// before we pasted: a queue marker left by an earlier message, or an
|
|
4844
4990
|
// older transcript entry that opens the same way, are on screen either
|
|
4845
4991
|
// way and would otherwise confirm a paste that never landed.
|
|
4846
4992
|
if (baseline) {
|
|
4847
4993
|
if (after.queued > baseline.queued)
|
|
4848
|
-
return
|
|
4994
|
+
return this.submittedProof();
|
|
4849
4995
|
if (after.payload > baseline.payload)
|
|
4850
|
-
return
|
|
4996
|
+
return this.submittedProof();
|
|
4851
4997
|
// 4. Nothing new of ours anywhere: the paste was swallowed by a redraw.
|
|
4852
4998
|
return "unproven";
|
|
4853
4999
|
}
|
|
@@ -5496,6 +5642,12 @@ export class Daemon extends EventEmitter {
|
|
|
5496
5642
|
// warning is safer than treating the replacement's startup idle as the old
|
|
5497
5643
|
// turn ending and injecting a stale recovery prompt.
|
|
5498
5644
|
this.turnReplyGuard.reset();
|
|
5645
|
+
if (this.spawnDepth === 0) {
|
|
5646
|
+
this.spawnGeneration++;
|
|
5647
|
+
this.inputTransientGuardGeneration = (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0
|
|
5648
|
+
? this.spawnGeneration
|
|
5649
|
+
: null;
|
|
5650
|
+
}
|
|
5499
5651
|
this.spawnDepth++;
|
|
5500
5652
|
this.spawning = true;
|
|
5501
5653
|
if (!this.spawnSettled) {
|
|
@@ -6095,6 +6247,7 @@ export class Daemon extends EventEmitter {
|
|
|
6095
6247
|
await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
|
|
6096
6248
|
let cleanReadyPolls = 0;
|
|
6097
6249
|
let lastDialog = null;
|
|
6250
|
+
let lastTransient = null;
|
|
6098
6251
|
let captureFailures = 0;
|
|
6099
6252
|
let attempts = 0;
|
|
6100
6253
|
do {
|
|
@@ -6116,6 +6269,18 @@ export class Daemon extends EventEmitter {
|
|
|
6116
6269
|
continue;
|
|
6117
6270
|
}
|
|
6118
6271
|
try {
|
|
6272
|
+
// Some TUIs expose a real-looking input row before they accept Enter.
|
|
6273
|
+
// This phase clears by itself: never send a key, and let its structural
|
|
6274
|
+
// current-screen matcher outrank the otherwise-valid ready pattern.
|
|
6275
|
+
const transient = this.inputTransientInPane(pane);
|
|
6276
|
+
if (transient) {
|
|
6277
|
+
cleanReadyPolls = 0;
|
|
6278
|
+
lastDialog = null;
|
|
6279
|
+
lastTransient = transient;
|
|
6280
|
+
await sleep();
|
|
6281
|
+
continue;
|
|
6282
|
+
}
|
|
6283
|
+
lastTransient = null;
|
|
6119
6284
|
// Try each startup dialog pattern before checking ready state
|
|
6120
6285
|
let matched = false;
|
|
6121
6286
|
lastDialog = null;
|
|
@@ -6243,7 +6408,10 @@ export class Daemon extends EventEmitter {
|
|
|
6243
6408
|
await sleep();
|
|
6244
6409
|
} while (remaining() > 0);
|
|
6245
6410
|
// Budget exhausted. Never silently: say which way it went.
|
|
6246
|
-
if (
|
|
6411
|
+
if (lastTransient) {
|
|
6412
|
+
this.logger.warn({ description: lastTransient.description, attempts, budgetMs }, "Startup scan exhausted while CLI input was still unavailable — retaining the delivery guard");
|
|
6413
|
+
}
|
|
6414
|
+
else if (lastDialog) {
|
|
6247
6415
|
this.logger.warn({ description: lastDialog.description, attempts, budgetMs }, "Startup scan exhausted with a dialog still on screen — reporting the CLI alive; deliveries stay blocked until it is answered");
|
|
6248
6416
|
}
|
|
6249
6417
|
else {
|