@songsid/agend 2.1.5-beta.18 → 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/daemon.js CHANGED
@@ -431,6 +431,9 @@ export class BackendUnreachableStartupError extends Error {
431
431
  }
432
432
  /** Bounded wait (under the pane lock) for the prompt to return before retrying a dropped Enter. */
433
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;
434
437
  /** Max "stranded text → submit → wait for prompt → re-check" rounds per delivery; each may send one recovery Enter. */
435
438
  const STRANDED_INPUT_MAX_ROUNDS = 3;
436
439
  const FIRST_ENTER_SETTLE_MS = 1_750;
@@ -857,6 +860,14 @@ export class Daemon extends EventEmitter {
857
860
  spawnSettled = null;
858
861
  resolveSpawnSettled = null;
859
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;
860
871
  skipResume = false;
861
872
  startupAborted = false;
862
873
  /** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
@@ -2523,6 +2534,9 @@ export class Daemon extends EventEmitter {
2523
2534
  async stop() {
2524
2535
  this.logger.info("Stopping daemon instance");
2525
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;
2526
2540
  this.freezeRuntimeMonitors();
2527
2541
  this.pendingIpcRequests.clear();
2528
2542
  if (this.adapter)
@@ -4046,16 +4060,24 @@ export class Daemon extends EventEmitter {
4046
4060
  }
4047
4061
  }
4048
4062
  }
4049
- // Everything above is *waiting*; everything below *writes*. Only the write is
4050
- // held under the pane lock — holding it across the idle wait (up to 30 min)
4051
- // would starve the runtime-dialog dismisser, which is often the very thing
4052
- // that would let the pane go idle again.
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.
4053
4067
  for (let round = 0;; round++) {
4054
4068
  const outcome = await this.paneWriteLock.run(async () => {
4055
4069
  if (cancelled())
4056
4070
  return false;
4057
4071
  if (this.refuseFatalStartupDelivery(status))
4058
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
+ }
4059
4081
  // TOCTOU: the probes above ran outside this lock, and the CLI repaints
4060
4082
  // whenever it likes — a resume prompt can be painted between "clear" and
4061
4083
  // this critical section. Re-probe here, right before the write; a
@@ -4099,6 +4121,75 @@ export class Daemon extends EventEmitter {
4099
4121
  deliveryBlockingDialogs() {
4100
4122
  return (this.backend?.getRuntimeDialogs?.() ?? []).filter(d => d.blocksDelivery || d.holdOnly);
4101
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
+ }
4102
4193
  static dialogKey(dialog) {
4103
4194
  return `${dialog.pattern.source}/${dialog.pattern.flags}`;
4104
4195
  }
@@ -4209,10 +4300,10 @@ export class Daemon extends EventEmitter {
4209
4300
  }
4210
4301
  /**
4211
4302
  * Why a pane is not deliverable matters. "busy" may be handed to a native
4212
- * input queue or steered (after its own dialog probe); "dialog" and "unknown"
4213
- * must never be — a paste + Enter into a dialog answers it with its default,
4214
- * and an unreadable pane proves nothing. The silence gate comes first and
4215
- * costs no capture, so a CLI that is simply working never pays for the probe.
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.
4216
4307
  */
4217
4308
  async paneReadinessForDelivery(windowId) {
4218
4309
  if (!this.isPaneIdleForDelivery(windowId))
@@ -4220,6 +4311,11 @@ export class Daemon extends EventEmitter {
4220
4311
  const probe = await this.probeBlockingDialog();
4221
4312
  if (probe.state !== "clear")
4222
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";
4223
4319
  if (this.backend?.dropsEnterWhileBusy?.() !== true)
4224
4320
  return "ready";
4225
4321
  const prompt = this.backend.getBottomReadyPattern?.();
@@ -4266,6 +4362,7 @@ export class Daemon extends EventEmitter {
4266
4362
  const deadline = Date.now() + timeoutMs;
4267
4363
  const bottomGated = this.backend?.dropsEnterWhileBusy?.() === true;
4268
4364
  let unknownStreak = 0;
4365
+ let transientDeadline = 0;
4269
4366
  for (;;) {
4270
4367
  const remaining = deadline - Date.now();
4271
4368
  if (remaining <= 0)
@@ -4275,6 +4372,26 @@ export class Daemon extends EventEmitter {
4275
4372
  return false;
4276
4373
  // The idle wait just resolved, so re-reading the silence gate would only
4277
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
+ }
4278
4395
  if (bottomGated) {
4279
4396
  if (await this.isPaneReadyForDelivery(windowId))
4280
4397
  return true;
@@ -4310,15 +4427,16 @@ export class Daemon extends EventEmitter {
4310
4427
  * must fail rather than paste on top of the stranded text).
4311
4428
  */
4312
4429
  async submitStrandedInputIfAny(windowId) {
4313
- if (this.backend?.dropsEnterWhileBusy?.() !== true || !this.tmux)
4430
+ if (!this.tmux || !this.backend)
4314
4431
  return "idle";
4315
4432
  const prompt = this.backend.getBottomReadyPattern?.();
4316
4433
  if (!prompt)
4317
4434
  return "idle";
4435
+ const requireBottomReady = this.backend.dropsEnterWhileBusy?.() === true;
4318
4436
  // Pre-check outside the lock so the common case (verified clear) never
4319
4437
  // contends with the dialog dismisser. Only a POSITIVE "clear" skips the
4320
4438
  // lock; an unreadable pane is re-examined under it, not waved through.
4321
- const pre = await this.strandedInputState(prompt);
4439
+ const pre = await this.strandedInputState(prompt, requireBottomReady);
4322
4440
  if (pre === "clear")
4323
4441
  return "idle";
4324
4442
  if (pre === "busy")
@@ -4334,7 +4452,7 @@ export class Daemon extends EventEmitter {
4334
4452
  // no-op at an empty prompt, but into a modal it is a confirmation.
4335
4453
  if (this.fatalStartupBlocked)
4336
4454
  return "busy";
4337
- const state = await this.strandedInputState(prompt);
4455
+ const state = await this.strandedInputState(prompt, requireBottomReady);
4338
4456
  if (state === "clear")
4339
4457
  return "idle";
4340
4458
  if (state !== "stranded")
@@ -4344,12 +4462,12 @@ export class Daemon extends EventEmitter {
4344
4462
  });
4345
4463
  }
4346
4464
  /**
4347
- * "stranded": an AgEnD message sits in the input row; "clear": the prompt is
4348
- * at the bottom with no stranded text; "busy": generating, or the prompt is
4349
- * not at the bottom (incl. a blank frame); "unknown": the pane could not be
4350
- * read. Only "clear" is positive evidence.
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.
4351
4469
  */
4352
- async strandedInputState(prompt) {
4470
+ async strandedInputState(prompt, requireBottomReady) {
4353
4471
  let pane;
4354
4472
  try {
4355
4473
  pane = await this.tmux.capturePane();
@@ -4357,11 +4475,16 @@ export class Daemon extends EventEmitter {
4357
4475
  catch {
4358
4476
  return "unknown";
4359
4477
  }
4478
+ if (this.inputTransientInPane(pane))
4479
+ return "busy";
4360
4480
  if (this.backend?.getBusyPattern?.()?.test(pane))
4361
4481
  return "busy";
4362
4482
  if (strandedAgendMessageInInput(pane, prompt))
4363
4483
  return "stranded";
4364
- return bottomRowIsReady(pane, prompt) ? "clear" : "busy";
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";
4365
4488
  }
4366
4489
  /**
4367
4490
  * F2: for Enter-dropping TUIs, "some output after Enter" proves nothing — the
@@ -4465,6 +4588,8 @@ export class Daemon extends EventEmitter {
4465
4588
  * an invariant maintained by comments.
4466
4589
  */
4467
4590
  async sendDeliveryEnter(phase) {
4591
+ if (!(await this.waitForInputTransientToClear(phase)))
4592
+ return false;
4468
4593
  const sent = await this.tmux.sendSpecialKey("Enter");
4469
4594
  if (!sent) {
4470
4595
  this.logger.error({
@@ -4759,6 +4884,13 @@ export class Daemon extends EventEmitter {
4759
4884
  canProveSubmission() {
4760
4885
  return !!this.backend?.getBottomReadyPattern?.();
4761
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
+ }
4762
4894
  /**
4763
4895
  * Decide a submission after an Enter, with one bounded retry.
4764
4896
  *
@@ -4852,16 +4984,16 @@ export class Daemon extends EventEmitter {
4852
4984
  // failure to read the pane BEFORE pasting cannot turn a delivered
4853
4985
  // message into a re-paste.
4854
4986
  if (signature.unique && after.payload > 0)
4855
- return "submitted";
4987
+ return this.submittedProof();
4856
4988
  // 3. Otherwise the evidence must be NEW relative to the pane as it was
4857
4989
  // before we pasted: a queue marker left by an earlier message, or an
4858
4990
  // older transcript entry that opens the same way, are on screen either
4859
4991
  // way and would otherwise confirm a paste that never landed.
4860
4992
  if (baseline) {
4861
4993
  if (after.queued > baseline.queued)
4862
- return "submitted";
4994
+ return this.submittedProof();
4863
4995
  if (after.payload > baseline.payload)
4864
- return "submitted";
4996
+ return this.submittedProof();
4865
4997
  // 4. Nothing new of ours anywhere: the paste was swallowed by a redraw.
4866
4998
  return "unproven";
4867
4999
  }
@@ -5510,6 +5642,12 @@ export class Daemon extends EventEmitter {
5510
5642
  // warning is safer than treating the replacement's startup idle as the old
5511
5643
  // turn ending and injecting a stale recovery prompt.
5512
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
+ }
5513
5651
  this.spawnDepth++;
5514
5652
  this.spawning = true;
5515
5653
  if (!this.spawnSettled) {
@@ -6109,6 +6247,7 @@ export class Daemon extends EventEmitter {
6109
6247
  await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
6110
6248
  let cleanReadyPolls = 0;
6111
6249
  let lastDialog = null;
6250
+ let lastTransient = null;
6112
6251
  let captureFailures = 0;
6113
6252
  let attempts = 0;
6114
6253
  do {
@@ -6130,6 +6269,18 @@ export class Daemon extends EventEmitter {
6130
6269
  continue;
6131
6270
  }
6132
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;
6133
6284
  // Try each startup dialog pattern before checking ready state
6134
6285
  let matched = false;
6135
6286
  lastDialog = null;
@@ -6257,7 +6408,10 @@ export class Daemon extends EventEmitter {
6257
6408
  await sleep();
6258
6409
  } while (remaining() > 0);
6259
6410
  // Budget exhausted. Never silently: say which way it went.
6260
- if (lastDialog) {
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) {
6261
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");
6262
6416
  }
6263
6417
  else {