@songsid/agend 2.1.7 → 2.1.8-beta.2

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
@@ -979,6 +979,25 @@ export class Daemon extends EventEmitter {
979
979
  * after the startup scan can still swallow that message's Enter.
980
980
  */
981
981
  inputTransientGuardGeneration = null;
982
+ /**
983
+ * The spawn whose passive transients are over, independently of the
984
+ * startup-proof tail above. A transient screen can be quoted verbatim by the
985
+ * transcript (a cropped quote of the resume screen is byte-identical to the
986
+ * real one), so the pane alone must not be able to hold input. AgEnD's own
987
+ * launch state retires the check, per launch attempt: a launch that cannot show one, the real
988
+ * transient seen and then gone, or a "transient" that never changed for
989
+ * INPUT_TRANSIENT_STALL_MS. (The first submitted message retires the whole
990
+ * guard, as before.)
991
+ */
992
+ inputTransientRetiredAttempt = null;
993
+ /** The launch attempt in which a passive transient was actually observed on screen. */
994
+ inputTransientSeenAttempt = null;
995
+ /**
996
+ * One per command actually launched. A spawn can launch more than once
997
+ * (spawnClaudeWindow retries a failed resume without a new beginSpawn), and
998
+ * each launch paints its own transient, so retirement is per attempt.
999
+ */
1000
+ launchAttempt = 0;
982
1001
  skipResume = false;
983
1002
  startupAborted = false;
984
1003
  /** First time the current on-screen blocking dialog was seen (0 = none); drives the parked report. */
@@ -4945,19 +4964,39 @@ export class Daemon extends EventEmitter {
4945
4964
  if (this.inputTransientGuardGeneration === null
4946
4965
  || this.inputTransientGuardGeneration !== this.spawnGeneration)
4947
4966
  return [];
4967
+ if (this.inputTransientRetiredAttempt === this.launchAttempt)
4968
+ return [];
4948
4969
  return this.backend?.getInputUnavailableTransients?.() ?? [];
4949
4970
  }
4971
+ /** Stop honouring passive transients for the current spawn. */
4972
+ retireInputTransients(reason) {
4973
+ if (this.inputTransientRetiredAttempt === this.launchAttempt)
4974
+ return;
4975
+ this.inputTransientRetiredAttempt = this.launchAttempt;
4976
+ this.logger.debug({ reason, generation: this.spawnGeneration, attempt: this.launchAttempt }, "Startup input transient retired for this launch");
4977
+ }
4950
4978
  /** Match a passive transient against the current interactive screen. */
4951
4979
  inputTransientInPane(pane) {
4952
- for (const transient of this.guardedInputTransients()) {
4980
+ const transients = this.guardedInputTransients();
4981
+ if (transients.length === 0)
4982
+ return null;
4983
+ for (const transient of transients) {
4953
4984
  // `pattern` is only a cheap pre-filter. `isActive` is deliberately the
4954
4985
  // authority because users and agents routinely quote diagnostic text.
4955
4986
  transient.pattern.lastIndex = 0;
4956
4987
  if (!transient.pattern.test(pane))
4957
4988
  continue;
4958
- if (transient.isActive(pane))
4989
+ if (transient.isActive(pane)) {
4990
+ this.inputTransientSeenAttempt = this.launchAttempt;
4959
4991
  return transient;
4992
+ }
4960
4993
  }
4994
+ // The phase is over once it has been seen to end; from then on the same
4995
+ // screen is a quote. A settled-looking pane before that proves nothing:
4996
+ // codex 0.154 painted the prompt and footer first and "Resuming session…"
4997
+ // a moment later — the reason this guard outlives the startup scan.
4998
+ if (this.inputTransientSeenAttempt === this.launchAttempt)
4999
+ this.retireInputTransients("transient ended");
4961
5000
  return null;
4962
5001
  }
4963
5002
  /** Capture failure is unknown, never evidence that input is available. */
@@ -4993,7 +5032,12 @@ export class Daemon extends EventEmitter {
4993
5032
  return budget;
4994
5033
  }
4995
5034
  async probeInputTransient() {
4996
- if (this.guardedInputTransients().length === 0 || !this.tmux)
5035
+ // A retired transient still leaves the startup guard armed: an unreadable
5036
+ // pane keeps answering "unknown", so it stalls out on the same budget
5037
+ // instead of letting a delivery poll a dark pane for ever.
5038
+ const armed = this.inputTransientGuardGeneration !== null && this.inputTransientGuardGeneration === this.spawnGeneration
5039
+ && (this.backend?.getInputUnavailableTransients?.().length ?? 0) > 0;
5040
+ if (!armed || !this.tmux)
4997
5041
  return { state: "clear" };
4998
5042
  try {
4999
5043
  const pane = await this.tmux.capturePane();
@@ -5001,10 +5045,13 @@ export class Daemon extends EventEmitter {
5001
5045
  // The pane text comes back with the verdict: a transient that is still
5002
5046
  // repainting is still working, and that is the only thing separating a
5003
5047
  // slow resume from a wedged one.
5048
+ if (transient)
5049
+ this.resetFooterFallback();
5004
5050
  return transient ? { state: "active", transient, pane } : { state: "clear" };
5005
5051
  }
5006
5052
  catch (err) {
5007
5053
  this.logger.debug({ err }, "capture-pane failed during the input-transient probe — pane state unknown");
5054
+ this.resetFooterFallback();
5008
5055
  return { state: "unknown" };
5009
5056
  }
5010
5057
  }
@@ -5059,6 +5106,11 @@ export class Daemon extends EventEmitter {
5059
5106
  stalledForMs: budget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
5060
5107
  ceilingMs: timeoutMs,
5061
5108
  }, "CLI input stayed unavailable — refusing to send Enter");
5109
+ // A screen that did not change for the whole stall window is not a
5110
+ // load in progress. This delivery still fails, but the same screen
5111
+ // (possibly a quote) must not fail every later one too.
5112
+ if (budget.stalled && observedDescription !== null)
5113
+ this.retireInputTransients("transient stalled");
5062
5114
  return false;
5063
5115
  }
5064
5116
  await new Promise(r => setTimeout(r, INPUT_TRANSIENT_POLL_MS));
@@ -5210,14 +5262,78 @@ export class Daemon extends EventEmitter {
5210
5262
  return !check;
5211
5263
  try {
5212
5264
  const mode = await this.tmux.getPaneInputMode?.();
5213
- if (mode !== "raw")
5265
+ if (mode !== "raw") {
5266
+ this.resetFooterFallback();
5214
5267
  return false;
5215
- return check.call(this.backend, await this.tmux.capturePane());
5268
+ }
5269
+ return this.deliveryInputReadyPane(await this.tmux.capturePane());
5216
5270
  }
5217
5271
  catch {
5272
+ this.resetFooterFallback();
5218
5273
  return false;
5219
5274
  }
5220
5275
  }
5276
+ /** Pane text of the current footer-fallback candidate, and since when it has been unchanged. */
5277
+ footerFallbackPaneKey = null;
5278
+ footerFallbackStableSince = 0;
5279
+ /** The launch the candidate was seen in: a new spawn or launch attempt starts over. */
5280
+ footerFallbackEpoch = null;
5281
+ /**
5282
+ * Forget the footer-fallback candidate. Called whenever the pane is seen
5283
+ * outside the candidate state (busy, a dialog, a transient, unreadable) and
5284
+ * on every spawn: "stable for UNKNOWN_LAYOUT_STABLE_MS" must mean this screen,
5285
+ * continuously, not time accumulated across an interruption.
5286
+ */
5287
+ resetFooterFallback() {
5288
+ this.footerFallbackPaneKey = null;
5289
+ this.footerFallbackEpoch = null;
5290
+ }
5291
+ footerFallbackWarnedGeneration = null;
5292
+ /**
5293
+ * The input-row proof used before a paste (#931/#947), with one fallback
5294
+ * (#1031). Codex sometimes paints an idle composer without its Context
5295
+ * status item even though tui.status_line asks for it: after four fleet
5296
+ * restarts, sol's resumed session showed only `⚠ 2 warnings · f2 to view`.
5297
+ * The proof needs a recognised footer, so the first delivery after each
5298
+ * restart waited 30 minutes, failed, and waited again, for seven hours.
5299
+ *
5300
+ * When the footer is the only thing missing, the #978 structural evidence
5301
+ * stands in for it, all of it required: the backend's own stable-unknown
5302
+ * idle check (an empty live composer; no busy row, esc-to-interrupt row,
5303
+ * queued ↳ input or known picker), no input transient such as the resume
5304
+ * load, and the same screen for UNKNOWN_LAYOUT_STABLE_MS. The raw-tty
5305
+ * requirement stays with the caller.
5306
+ */
5307
+ deliveryInputReadyPane(pane) {
5308
+ const check = this.backend?.isDeliveryInputReadyPane;
5309
+ if (!check)
5310
+ return true;
5311
+ if (check.call(this.backend, pane)) {
5312
+ this.footerFallbackPaneKey = null;
5313
+ return true;
5314
+ }
5315
+ const stableUnknown = this.backend?.isStableUnknownLayoutIdlePane;
5316
+ if (!stableUnknown?.call(this.backend, pane) || this.inputTransientInPane(pane)) {
5317
+ this.footerFallbackPaneKey = null;
5318
+ return false;
5319
+ }
5320
+ const key = pane.replace(/⋆/gu, "").replace(/[ \t]+$/gmu, "");
5321
+ const epoch = `${this.spawnGeneration}:${this.launchAttempt}`;
5322
+ const now = Date.now();
5323
+ if (key !== this.footerFallbackPaneKey || epoch !== this.footerFallbackEpoch) {
5324
+ this.footerFallbackPaneKey = key;
5325
+ this.footerFallbackEpoch = epoch;
5326
+ this.footerFallbackStableSince = now;
5327
+ return false;
5328
+ }
5329
+ if (now - this.footerFallbackStableSince < UNKNOWN_LAYOUT_STABLE_MS)
5330
+ return false;
5331
+ if (this.footerFallbackWarnedGeneration !== this.spawnGeneration) {
5332
+ this.footerFallbackWarnedGeneration = this.spawnGeneration;
5333
+ this.logger.warn({ backend: this.backend?.binaryName, stableForMs: now - this.footerFallbackStableSince }, "Idle footer not recognised — delivering on the #978 fallback (stable empty composer, no busy/queued/dialog, raw tty, no transient)");
5334
+ }
5335
+ return true;
5336
+ }
5221
5337
  /**
5222
5338
  * Why a pane is not deliverable matters. "busy" may be handed to a native
5223
5339
  * input queue or steered (after its own dialog probe); "dialog", "transient"
@@ -5226,16 +5342,26 @@ export class Daemon extends EventEmitter {
5226
5342
  * pane proves nothing. The silence gate comes first and costs no capture.
5227
5343
  */
5228
5344
  async paneReadinessForDelivery(windowId) {
5229
- if (!this.isPaneIdleForDelivery(windowId))
5345
+ // Each early return below is the pane seen outside the footer-fallback
5346
+ // candidate state, and never reaches deliveryInputReadyPane: reset here.
5347
+ if (!this.isPaneIdleForDelivery(windowId)) {
5348
+ this.resetFooterFallback();
5230
5349
  return "busy";
5350
+ }
5231
5351
  const probe = await this.probeBlockingDialog();
5232
- if (probe.state !== "clear")
5352
+ if (probe.state !== "clear") {
5353
+ this.resetFooterFallback();
5233
5354
  return probe.state;
5355
+ }
5234
5356
  const transient = await this.probeInputTransient();
5235
- if (transient.state === "active")
5357
+ if (transient.state === "active") {
5358
+ this.resetFooterFallback();
5236
5359
  return "transient";
5237
- if (transient.state === "unknown")
5360
+ }
5361
+ if (transient.state === "unknown") {
5362
+ this.resetFooterFallback();
5238
5363
  return "unknown";
5364
+ }
5239
5365
  if (this.needsStartupInputProof()) {
5240
5366
  return await this.hasPositiveDeliveryInput() ? "ready" : "transient";
5241
5367
  }
@@ -5306,6 +5432,11 @@ export class Daemon extends EventEmitter {
5306
5432
  stalledForMs: transientBudget.stalled ? INPUT_TRANSIENT_STALL_MS : undefined,
5307
5433
  ceilingMs: INPUT_TRANSIENT_WAIT_MS,
5308
5434
  }, "CLI input stayed unavailable during the delivery-readiness wait");
5435
+ // Same rule as the pre-Enter wait: a screen frozen for the whole
5436
+ // stall window is not a load in progress, so it holds this delivery
5437
+ // only.
5438
+ if (transientBudget.stalled)
5439
+ this.retireInputTransients("transient stalled");
5309
5440
  return false;
5310
5441
  }
5311
5442
  await new Promise(r => setTimeout(r, BOTTOM_READY_POLL_MS));
@@ -5407,7 +5538,7 @@ export class Daemon extends EventEmitter {
5407
5538
  return "busy";
5408
5539
  if (this.backend?.getBusyPattern?.()?.test(pane))
5409
5540
  return "busy";
5410
- if (this.backend?.isDeliveryInputReadyPane && !this.backend.isDeliveryInputReadyPane(pane))
5541
+ if (!this.deliveryInputReadyPane(pane))
5411
5542
  return "busy";
5412
5543
  if (strandedAgendMessageInInput(pane, prompt))
5413
5544
  return "stranded";
@@ -7570,6 +7701,13 @@ export class Daemon extends EventEmitter {
7570
7701
  }
7571
7702
  }
7572
7703
  const cmd = `${envPrefix} ` + this.backend.buildCommand(launchConfig);
7704
+ // Every launched command re-arms the passive-transient check, including a
7705
+ // retry inside the same spawn: its load is a new one.
7706
+ this.launchAttempt++;
7707
+ // A launch that cannot paint a passive transient (Codex: a fresh start
7708
+ // never shows "Resuming session…") must not honour one quoted on screen.
7709
+ if (this.backend.launchMayShowInputTransient?.() === false)
7710
+ this.retireInputTransients("launch shows none");
7573
7711
  // e.g. Codex could not read its session DB and chose a fallback (#984):
7574
7712
  // the operator should know which conversation this launch continues.
7575
7713
  const launchWarning = this.backend.consumeLaunchWarning?.() ?? null;