@songsid/agend 2.1.11-beta.2 → 2.1.11-beta.4

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.d.ts CHANGED
@@ -480,6 +480,22 @@ export declare class Daemon extends EventEmitter {
480
480
  * dialog on screen at every poll and ignores nothing.
481
481
  */
482
482
  private dialogAnswers;
483
+ /**
484
+ * The last error occurrence handed to the lifecycle: what an action that needs the screen it was seen on (a
485
+ * `nudge_continue` arming) reads back, with the spawn / monitors it was seen under.
486
+ */
487
+ private lastErrorEpisode;
488
+ /**
489
+ * An armed "tell the agent to keep going" (Codex model at capacity). One per episode; the monitor owns it: every poll
490
+ * cancels it when anything changed, and it fires at most once. The due time is on the monotonic clock.
491
+ */
492
+ /**
493
+ * How many occurrences of each `nudge_continue` pattern were already on screen when this spawn first showed a live
494
+ * composer (null until then): resumed scrollback, i.e. HISTORY. An occurrence is a new episode only beyond that — a
495
+ * resumed session whose scrollback holds an old capacity line must not be told to "keep going" for it (#949).
496
+ */
497
+ private nudgeBaselines;
498
+ private capacityNudge;
483
499
  /** Current stdin-owning runtime dialog, independent from execution state. */
484
500
  private inputBlockedDialogKey;
485
501
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
@@ -1338,6 +1354,26 @@ export declare class Daemon extends EventEmitter {
1338
1354
  * within a bound and then fail loudly.
1339
1355
  */
1340
1356
  private probeBlockingDialog;
1357
+ /**
1358
+ * Arm the "keep going" nudge for the error occurrence just reported (`pattern` must be the one the lifecycle was
1359
+ * handed). The nudge is sent once, `delayMs` from now on the MONOTONIC clock (a wall-clock step changes nothing),
1360
+ * and only if nothing has changed by then — see {@link tickCapacityNudge}. Returns false when there is nothing to arm.
1361
+ */
1362
+ armCapacityNudge(pattern: RegExp, delayMs: number): boolean;
1363
+ /** Drop an armed nudge (the user stopped / paused the instance, or the lifecycle gave up on it). */
1364
+ cancelCapacityNudge(reason: string): void;
1365
+ /** The first live composer of a spawn fixes what is history (see {@link nudgeBaselines}). */
1366
+ private takeNudgeBaselines;
1367
+ private static countOccurrences;
1368
+ /**
1369
+ * One monitor poll's look at an armed nudge. It is CANCELLED — never postponed — when anything about the instance
1370
+ * moved: a stop / pause / respawn (spawn generation or monitor epoch), a user cancel (delivery epoch), the pane no
1371
+ * longer being exactly the one the error was seen on (it moved on by itself, a person typed, another dialog), the
1372
+ * agent working, or a message waiting for delivery. Only when it is due and still untouched is it fired — once — and
1373
+ * the injection re-verifies everything under the pane-write lock.
1374
+ */
1375
+ private tickCapacityNudge;
1376
+ private sendCapacityNudge;
1341
1377
  /** What identifies the request on screen: the backend's own identity when it has one, else the whole screen verbatim. */
1342
1378
  private static screenOf;
1343
1379
  private static answerKey;
@@ -1578,6 +1614,12 @@ export declare class Daemon extends EventEmitter {
1578
1614
  * Best effort by design: these notices must never fail a delivery or throw.
1579
1615
  * The caller already holds paneWriteLock.
1580
1616
  */
1617
+ /**
1618
+ * `guard` is for a paste that must not outlive the reason it was made (the continue nudge): `accept` judges the ONE
1619
+ * capture taken here — the same picture is the baseline, the expected-screen check and the idle check — and `current`
1620
+ * is asked again after every await that precedes a write, so a cancelled paste adds no further paste and no key
1621
+ * (the retries included). Without a guard it is exactly the old unconditional path.
1622
+ */
1581
1623
  private submitSystemPaste;
1582
1624
  /** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
1583
1625
  private recoverWindow;
@@ -1733,6 +1775,8 @@ export declare class Daemon extends EventEmitter {
1733
1775
  * a live turn. Fail closed: an unreadable pane is never idle.
1734
1776
  */
1735
1777
  private isPaneAuthoritativelyIdle;
1778
+ /** The verdict of {@link isPaneAuthoritativelyIdle} on a capture the caller already holds. */
1779
+ private paneAuthoritativelyIdle;
1736
1780
  /**
1737
1781
  * Poll the authoritative pane check until it reports idle or the budget runs
1738
1782
  * out. Each poll is a fresh capture, so a turn that ends mid-wait is seen.
package/dist/daemon.js CHANGED
@@ -1042,6 +1042,22 @@ export class Daemon extends EventEmitter {
1042
1042
  * dialog on screen at every poll and ignores nothing.
1043
1043
  */
1044
1044
  dialogAnswers = null;
1045
+ /**
1046
+ * The last error occurrence handed to the lifecycle: what an action that needs the screen it was seen on (a
1047
+ * `nudge_continue` arming) reads back, with the spawn / monitors it was seen under.
1048
+ */
1049
+ lastErrorEpisode = null;
1050
+ /**
1051
+ * An armed "tell the agent to keep going" (Codex model at capacity). One per episode; the monitor owns it: every poll
1052
+ * cancels it when anything changed, and it fires at most once. The due time is on the monotonic clock.
1053
+ */
1054
+ /**
1055
+ * How many occurrences of each `nudge_continue` pattern were already on screen when this spawn first showed a live
1056
+ * composer (null until then): resumed scrollback, i.e. HISTORY. An occurrence is a new episode only beyond that — a
1057
+ * resumed session whose scrollback holds an old capacity line must not be told to "keep going" for it (#949).
1058
+ */
1059
+ nudgeBaselines = null;
1060
+ capacityNudge = null;
1045
1061
  /** Current stdin-owning runtime dialog, independent from execution state. */
1046
1062
  inputBlockedDialogKey = null;
1047
1063
  /** Generation/signature fence for safety dialogs while their screen is still visible. */
@@ -2763,6 +2779,8 @@ export class Daemon extends EventEmitter {
2763
2779
  const pane = await this.tmux.capturePane();
2764
2780
  if (stale())
2765
2781
  return;
2782
+ this.takeNudgeBaselines(pane, patterns);
2783
+ this.tickCapacityNudge(pane);
2766
2784
  const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
2767
2785
  // A sign-in screen that appears AFTER the startup scan ended (first run:
2768
2786
  // theme → login can take longer than its budget) is the same auth
@@ -3142,6 +3160,7 @@ export class Daemon extends EventEmitter {
3142
3160
  if (ep.type === "auth_error")
3143
3161
  this.authFailureUnresolved = true;
3144
3162
  this.logger.warn({ errorType: ep.type, action: ep.action }, `PTY error detected: ${message}`);
3163
+ this.lastErrorEpisode = { key, pattern: ep.pattern, pane, spawn: this.spawnGeneration, fence: this.launchFenceEpoch };
3145
3164
  this.emit("pty_error", { name: this.name, ...ep, message });
3146
3165
  }
3147
3166
  /**
@@ -5809,6 +5828,104 @@ export class Daemon extends EventEmitter {
5809
5828
  this.trackDialogParked(null);
5810
5829
  return { state: "clear" };
5811
5830
  }
5831
+ /**
5832
+ * Arm the "keep going" nudge for the error occurrence just reported (`pattern` must be the one the lifecycle was
5833
+ * handed). The nudge is sent once, `delayMs` from now on the MONOTONIC clock (a wall-clock step changes nothing),
5834
+ * and only if nothing has changed by then — see {@link tickCapacityNudge}. Returns false when there is nothing to arm.
5835
+ */
5836
+ armCapacityNudge(pattern, delayMs) {
5837
+ const episode = this.lastErrorEpisode;
5838
+ if (!episode || episode.pattern !== pattern)
5839
+ return false;
5840
+ if (episode.spawn !== this.spawnGeneration || episode.fence !== this.launchFenceEpoch)
5841
+ return false;
5842
+ if (this.isPaused || this.getProcessStatus() === "stopped")
5843
+ return false;
5844
+ // Only an occurrence that appeared during this spawn's live session: before the baseline exists, or not beyond it,
5845
+ // it is scrollback from a resumed session.
5846
+ const baseline = this.nudgeBaselines?.get(episode.key);
5847
+ if (baseline === undefined || Daemon.countOccurrences(pattern, episode.pane) <= baseline)
5848
+ return false;
5849
+ this.capacityNudge = {
5850
+ pane: episode.pane, spawn: episode.spawn, fence: episode.fence,
5851
+ epoch: this.deliveryEpoch, dueAt: performance.now() + delayMs,
5852
+ };
5853
+ this.logger.info({ delayMs }, "Model-capacity nudge armed");
5854
+ return true;
5855
+ }
5856
+ /** Drop an armed nudge (the user stopped / paused the instance, or the lifecycle gave up on it). */
5857
+ cancelCapacityNudge(reason) {
5858
+ if (!this.capacityNudge)
5859
+ return;
5860
+ this.capacityNudge = null;
5861
+ this.logger.info({ reason }, "Model-capacity nudge cancelled");
5862
+ }
5863
+ /** The first live composer of a spawn fixes what is history (see {@link nudgeBaselines}). */
5864
+ takeNudgeBaselines(pane, patterns) {
5865
+ if (!this.isCodexLivePaneSnapshot(pane))
5866
+ return;
5867
+ const baselines = this.nudgeBaselines ?? new Map();
5868
+ for (const ep of patterns) {
5869
+ if (ep.action !== "nudge_continue")
5870
+ continue;
5871
+ const key = Daemon.errorPatternKey(ep);
5872
+ const count = Daemon.countOccurrences(ep.pattern, pane);
5873
+ // History that has scrolled away must not hide the next real occurrence.
5874
+ baselines.set(key, Math.min(baselines.get(key) ?? count, count));
5875
+ }
5876
+ this.nudgeBaselines = baselines;
5877
+ }
5878
+ static countOccurrences(pattern, pane) {
5879
+ const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
5880
+ return (pane.match(new RegExp(pattern.source, flags)) || []).length;
5881
+ }
5882
+ /**
5883
+ * One monitor poll's look at an armed nudge. It is CANCELLED — never postponed — when anything about the instance
5884
+ * moved: a stop / pause / respawn (spawn generation or monitor epoch), a user cancel (delivery epoch), the pane no
5885
+ * longer being exactly the one the error was seen on (it moved on by itself, a person typed, another dialog), the
5886
+ * agent working, or a message waiting for delivery. Only when it is due and still untouched is it fired — once — and
5887
+ * the injection re-verifies everything under the pane-write lock.
5888
+ */
5889
+ tickCapacityNudge(pane) {
5890
+ const nudge = this.capacityNudge;
5891
+ if (!nudge)
5892
+ return;
5893
+ const gone = nudge.spawn !== this.spawnGeneration || nudge.fence !== this.launchFenceEpoch ? "the instance was stopped, paused or respawned"
5894
+ : !this.isDeliveryEpochCurrent(nudge.epoch) ? "the user cancelled"
5895
+ : this.isPaused || this.healthCheckPaused || this.getProcessStatus() === "stopped" ? "the instance is not running"
5896
+ : pane !== nudge.pane ? "the screen changed"
5897
+ : this.instanceState === "working" || this.pasteQueueDepth > 0 ? "the instance has work of its own"
5898
+ : null;
5899
+ if (gone) {
5900
+ this.cancelCapacityNudge(gone);
5901
+ return;
5902
+ }
5903
+ if (performance.now() < nudge.dueAt)
5904
+ return;
5905
+ this.capacityNudge = null; // fired: once per episode
5906
+ void this.sendCapacityNudge(nudge).catch(err => this.logger.warn({ err: err.message }, "Model-capacity nudge failed"));
5907
+ }
5908
+ async sendCapacityNudge(nudge) {
5909
+ // Everything the nudge was armed under, asked again after EVERY await that precedes a write — inside the paste
5910
+ // primitive too (its capture, its paste, its pause, its Enter, its retry): once any of it is false nothing more is
5911
+ // written. A message queued behind the lock, or the agent starting a turn, is "work of its own" like at the poll.
5912
+ const current = () => nudge.spawn === this.spawnGeneration && nudge.fence === this.launchFenceEpoch
5913
+ && this.isDeliveryEpochCurrent(nudge.epoch) && !this.isPaused && !this.healthCheckPaused
5914
+ && !this.startupAborted && this.getProcessStatus() !== "stopped"
5915
+ && this.pasteQueueDepth === 0 && this.instanceState !== "working";
5916
+ // Exclusive with every delivery: nothing can paste a turn between the verdict below and the nudge.
5917
+ await this.paneWriteLock.run(async () => {
5918
+ if (!this.tmux)
5919
+ return;
5920
+ // ONE capture decides: it must be exactly the screen the error was seen on AND an idle composer. (A second picture
5921
+ // vouching for the first — "same screen" here, "idle" there — would let a half-typed draft through.)
5922
+ const sent = await this.submitSystemPaste(t("inst.codex_capacity_nudge_text"), "capacity-continue", {
5923
+ current,
5924
+ accept: pane => pane === nudge.pane && this.paneAuthoritativelyIdle(pane),
5925
+ });
5926
+ this.logger.info({ sent }, "Model-capacity nudge attempted");
5927
+ });
5928
+ }
5812
5929
  /** What identifies the request on screen: the backend's own identity when it has one, else the whole screen verbatim. */
5813
5930
  static screenOf(dialog, pane) {
5814
5931
  return dialog.requestIdentity?.(pane) ?? pane;
@@ -7143,17 +7260,38 @@ export class Daemon extends EventEmitter {
7143
7260
  * Best effort by design: these notices must never fail a delivery or throw.
7144
7261
  * The caller already holds paneWriteLock.
7145
7262
  */
7146
- async submitSystemPaste(text, label) {
7263
+ /**
7264
+ * `guard` is for a paste that must not outlive the reason it was made (the continue nudge): `accept` judges the ONE
7265
+ * capture taken here — the same picture is the baseline, the expected-screen check and the idle check — and `current`
7266
+ * is asked again after every await that precedes a write, so a cancelled paste adds no further paste and no key
7267
+ * (the retries included). Without a guard it is exactly the old unconditional path.
7268
+ */
7269
+ async submitSystemPaste(text, label, guard) {
7147
7270
  if (!this.tmux)
7148
7271
  return false;
7149
7272
  const signature = this.submissionSignature(text);
7150
- const baseline = await this.capturePaneEvidence(signature);
7273
+ let baseline;
7274
+ if (guard) {
7275
+ let pane;
7276
+ try {
7277
+ pane = await this.tmux.capturePane();
7278
+ }
7279
+ catch {
7280
+ return false;
7281
+ }
7282
+ if (!guard.current() || !guard.accept(pane))
7283
+ return false;
7284
+ baseline = this.paneEvidence(pane, signature);
7285
+ }
7286
+ else {
7287
+ baseline = await this.capturePaneEvidence(signature);
7288
+ }
7151
7289
  if (!(await this.tmux.pasteBuffer(text))) {
7152
7290
  this.logger.warn({ label }, "System paste failed to reach the pane");
7153
7291
  return false;
7154
7292
  }
7155
7293
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
7156
- if (!(await this.sendDeliveryEnter(label)))
7294
+ if (!(await this.sendDeliveryEnter(label, guard?.current)))
7157
7295
  return false;
7158
7296
  let proof = await this.confirmSubmitted(signature, baseline);
7159
7297
  if (proof === "unverifiable") {
@@ -7171,7 +7309,7 @@ export class Daemon extends EventEmitter {
7171
7309
  // exists to remove — visible text is also what a strand looks like.
7172
7310
  if (this.systemPasteOptions().retryEnter) {
7173
7311
  await new Promise(r => setTimeout(r, 1_000));
7174
- await this.sendDeliveryEnter(`${label}-defensive-retry`);
7312
+ await this.sendDeliveryEnter(`${label}-defensive-retry`, guard?.current);
7175
7313
  }
7176
7314
  return true; // best effort, exactly as before — nothing here is verified
7177
7315
  }
@@ -7180,7 +7318,7 @@ export class Daemon extends EventEmitter {
7180
7318
  // first one did land; when the text is still in the input row it is the
7181
7319
  // submit it never got. Re-pasting would append the text to itself.
7182
7320
  await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
7183
- if (!(await this.sendDeliveryEnter(`${label}-retry`)))
7321
+ if (!(await this.sendDeliveryEnter(`${label}-retry`, guard?.current)))
7184
7322
  return false;
7185
7323
  proof = await this.confirmSubmitted(signature, baseline);
7186
7324
  }
@@ -7748,6 +7886,9 @@ export class Daemon extends EventEmitter {
7748
7886
  */
7749
7887
  beginSpawn() {
7750
7888
  this.dialogAnswers = null;
7889
+ this.capacityNudge = null;
7890
+ this.lastErrorEpisode = null;
7891
+ this.nudgeBaselines = null;
7751
7892
  // A relaunch settles any outstanding quit watch: the CLI came back.
7752
7893
  this.clearQuitRelaunchWatch();
7753
7894
  // A restarted CLI has no trustworthy turn edge for the process it replaced.
@@ -8133,8 +8274,7 @@ export class Daemon extends EventEmitter {
8133
8274
  * a live turn. Fail closed: an unreadable pane is never idle.
8134
8275
  */
8135
8276
  async isPaneAuthoritativelyIdle() {
8136
- const backend = this.backend;
8137
- if (!this.tmux || !backend)
8277
+ if (!this.tmux || !this.backend)
8138
8278
  return false;
8139
8279
  let pane;
8140
8280
  try {
@@ -8143,6 +8283,13 @@ export class Daemon extends EventEmitter {
8143
8283
  catch {
8144
8284
  return false;
8145
8285
  }
8286
+ return this.paneAuthoritativelyIdle(pane);
8287
+ }
8288
+ /** The verdict of {@link isPaneAuthoritativelyIdle} on a capture the caller already holds. */
8289
+ paneAuthoritativelyIdle(pane) {
8290
+ const backend = this.backend;
8291
+ if (!backend)
8292
+ return false;
8146
8293
  if (backend.getBusyPattern?.()?.test(pane))
8147
8294
  return false;
8148
8295
  for (const dialog of this.deliveryBlockingDialogs()) {