@songsid/agend 2.1.6-beta.7 → 2.1.6-beta.9

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
@@ -896,6 +896,11 @@ export class Daemon extends EventEmitter {
896
896
  /** Identity of that dialog: its pattern, not its description (two tables may describe one screen differently). */
897
897
  dialogParkedKey = null;
898
898
  dialogParkedReported = false;
899
+ /** Current stdin-owning runtime dialog, independent from execution state. */
900
+ inputBlockedDialogKey = null;
901
+ /** Generation/signature fence for safety dialogs while their screen is still visible. */
902
+ autoResolvedDialogKey = null;
903
+ autoResolvedDialogGeneration = 0;
899
904
  backgroundSessionRecoveryAttempted = false;
900
905
  /** Whether the last spawn started a fresh session (not resumed). */
901
906
  isNewSession = false;
@@ -2186,6 +2191,7 @@ export class Daemon extends EventEmitter {
2186
2191
  if (!alive)
2187
2192
  return;
2188
2193
  const pane = await this.tmux.capturePane();
2194
+ const inputBlockedDialog = this.updateInputBlockedState(pane, dialogs);
2189
2195
  const interactivePrompt = this.interactivePromptDetector.observe(pane, Date.now(), this.instanceStateLastOutputAt);
2190
2196
  if (interactivePrompt) {
2191
2197
  this.logger.warn(interactivePrompt, "Interactive terminal prompt is waiting for human input");
@@ -2202,7 +2208,7 @@ export class Daemon extends EventEmitter {
2202
2208
  const hasPendingWork = this.pendingWork.hasPendingWork();
2203
2209
  if (!hasPendingWork)
2204
2210
  this.blockingProcessDetector.reset();
2205
- const blockingProcess = hasPendingWork
2211
+ const blockingProcess = hasPendingWork && !inputBlockedDialog
2206
2212
  ? this.blockingProcessDetector.observe(pane, paneActivity, Date.now())
2207
2213
  : null;
2208
2214
  if (blockingProcess) {
@@ -2224,27 +2230,80 @@ export class Daemon extends EventEmitter {
2224
2230
  blockingSeen = dialog;
2225
2231
  if (dialog.holdOnly)
2226
2232
  break; // recognised, deliberately not answered; trackDialogParked reports it
2233
+ const autoKey = dialog.autoResolutionKey;
2234
+ if (dialog.verifyAfterKeys && autoKey
2235
+ && this.autoResolvedDialogGeneration === this.spawnGeneration
2236
+ && this.autoResolvedDialogKey === autoKey) {
2237
+ // A previous poll sent the safety choice but the CLI has not
2238
+ // repainted yet. Never send another Enter into the same screen.
2239
+ continue;
2240
+ }
2227
2241
  // These keys go straight into the pane. Sent while a message delivery is
2228
2242
  // mid-transaction, an `Escape` wipes the pasted text and an `Enter`
2229
2243
  // submits it half-composed — the user sees a message that vanished. Skip
2230
2244
  // (not queue) when the pane is busy: this poller runs every 5s, and the
2231
2245
  // dialog will still be on screen next tick.
2246
+ let resolved = false;
2232
2247
  const dismissed = await this.paneWriteLock.tryRun(async () => {
2248
+ // Re-read under the lock: the first capture may have gone stale
2249
+ // while a delivery was finishing. A stale danger menu must never
2250
+ // receive a blind key sequence.
2251
+ const currentPane = await this.tmux.capturePane();
2252
+ if (!Daemon.dialogMatches(dialog, currentPane)) {
2253
+ this.updateInputBlockedState(currentPane, dialogs);
2254
+ return;
2255
+ }
2256
+ if (dialog.verifyAfterKeys && autoKey
2257
+ && this.autoResolvedDialogGeneration === this.spawnGeneration
2258
+ && this.autoResolvedDialogKey === autoKey)
2259
+ return;
2260
+ if (dialog.verifyAfterKeys && autoKey) {
2261
+ this.autoResolvedDialogGeneration = this.spawnGeneration;
2262
+ this.autoResolvedDialogKey = autoKey;
2263
+ }
2233
2264
  this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
2234
2265
  const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
2235
2266
  for (const key of dialog.keys) {
2267
+ let sent = false;
2236
2268
  if (SPECIAL_KEYS.has(key)) {
2237
- await this.tmux.sendSpecialKey(key);
2269
+ sent = await this.tmux.sendSpecialKey(key);
2238
2270
  }
2239
2271
  else {
2240
- await this.tmux.pasteText(key, this.systemPasteOptions());
2272
+ sent = await this.tmux.pasteText(key, this.systemPasteOptions());
2273
+ }
2274
+ if (!sent) {
2275
+ if (dialog.verifyAfterKeys && autoKey) {
2276
+ this.autoResolvedDialogGeneration = 0;
2277
+ this.autoResolvedDialogKey = null;
2278
+ }
2279
+ return;
2241
2280
  }
2242
2281
  await new Promise(r => setTimeout(r, 200));
2243
2282
  }
2283
+ if (dialog.verifyAfterKeys) {
2284
+ const afterKeysPane = await this.tmux.capturePane();
2285
+ const dialogStillActive = dialog.inputBlocked
2286
+ ? dialogs.some(candidate => candidate.inputBlocked && Daemon.dialogMatches(candidate, afterKeysPane))
2287
+ : Daemon.dialogMatches(dialog, afterKeysPane);
2288
+ if (dialogStillActive) {
2289
+ this.logger.warn({ dialog: dialog.description }, "Runtime dialog remained after its safety choice");
2290
+ return;
2291
+ }
2292
+ this.updateInputBlockedState(afterKeysPane, dialogs);
2293
+ if (dialog.postDismissNotice) {
2294
+ resolved = await this.submitSystemPaste(dialog.postDismissNotice.text, dialog.postDismissNotice.label);
2295
+ }
2296
+ else {
2297
+ resolved = true;
2298
+ }
2299
+ }
2244
2300
  });
2245
2301
  if (!dismissed) {
2246
2302
  this.logger.info({ dialog: dialog.description }, "Dialog dismissal deferred — pane write in flight");
2247
2303
  }
2304
+ else if (dialog.verifyAfterKeys && !resolved) {
2305
+ this.logger.warn({ dialog: dialog.description }, "Safety dialog choice was sent but follow-up verification or notice submission failed");
2306
+ }
2248
2307
  this.trackDialogParked(blockingSeen);
2249
2308
  return; // Dialog handled (or deliberately deferred): skip error checks this cycle
2250
2309
  }
@@ -2842,6 +2901,15 @@ export class Daemon extends EventEmitter {
2842
2901
  }
2843
2902
  applyInstanceStateSnapshot(snapshot, pane) {
2844
2903
  const previous = this.instanceState;
2904
+ // A live safety menu owns stdin. It may still contain Claude's persistent
2905
+ // `❯` ready marker, but that is not an idle turn and must not clear pending
2906
+ // work, retire Cancel, arm auto-pause, or enter the generic stuck path.
2907
+ // Keep the public execution state unchanged until the menu disappears; the
2908
+ // dedicated input_blocked event carries the reason to observers.
2909
+ if (this.inputBlockedDialogKey !== null) {
2910
+ this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing execution-state edge while CLI input is blocked by a dialog");
2911
+ return;
2912
+ }
2845
2913
  this.instanceState = snapshot.state;
2846
2914
  // OpenCode creates its session lazily on the first submitted message.
2847
2915
  // Waiting until stop/pause to persist that id loses resume state when the
@@ -3315,6 +3383,7 @@ export class Daemon extends EventEmitter {
3315
3383
  const captureStartedAt = Date.now();
3316
3384
  try {
3317
3385
  const pane = await this.tmux.capturePane();
3386
+ this.updateInputBlockedState(pane);
3318
3387
  // Output received while capture-pane was in flight makes this snapshot
3319
3388
  // stale. Its output handler has already armed a new debounce.
3320
3389
  const outputMovedDuringCapture = (expectedOutputAt > 0 && this.instanceStateLastOutputAt > expectedOutputAt)
@@ -3551,6 +3620,10 @@ export class Daemon extends EventEmitter {
3551
3620
  this.instanceStateSafetyListener = null;
3552
3621
  }
3553
3622
  handleStuckTransition(pane, snapshot, readyPattern) {
3623
+ if (this.inputBlockedDialogKey !== null) {
3624
+ this.logger.debug({ dialog: this.inputBlockedDialogKey }, "Suppressing stuck notification while CLI input is blocked by a dialog");
3625
+ return;
3626
+ }
3554
3627
  const deterministicReadyPattern = new RegExp(readyPattern.source, readyPattern.flags.replace(/[gy]/g, ""));
3555
3628
  const diagnostic = {
3556
3629
  backend: this.backend?.binaryName ?? this.config.backend ?? "unknown",
@@ -4388,8 +4461,33 @@ export class Daemon extends EventEmitter {
4388
4461
  }
4389
4462
  /** A dialog counts only when it is the CURRENT interactive region, if the backend can tell; else by pattern. */
4390
4463
  static dialogMatches(dialog, pane) {
4464
+ dialog.pattern.lastIndex = 0;
4391
4465
  return dialog.isActive ? dialog.isActive(pane) : dialog.pattern.test(pane);
4392
4466
  }
4467
+ /** Refresh the separate stdin-blocked observation from one pane capture. */
4468
+ updateInputBlockedState(pane, dialogs = this.backend?.getRuntimeDialogs?.() ?? []) {
4469
+ const active = dialogs.find(dialog => dialog.inputBlocked && Daemon.dialogMatches(dialog, pane)) ?? null;
4470
+ const nextKey = active ? Daemon.dialogKey(active) : null;
4471
+ if (nextKey !== this.inputBlockedDialogKey) {
4472
+ this.inputBlockedDialogKey = nextKey;
4473
+ this.emit("input_blocked", {
4474
+ name: this.name,
4475
+ blocked: active !== null,
4476
+ description: active?.description,
4477
+ });
4478
+ }
4479
+ if (!active && this.autoResolvedDialogGeneration === this.spawnGeneration) {
4480
+ // The old screen is gone. A later dangerous command in the same spawn
4481
+ // must get its own one-shot answer.
4482
+ this.autoResolvedDialogKey = null;
4483
+ this.autoResolvedDialogGeneration = 0;
4484
+ }
4485
+ return active;
4486
+ }
4487
+ /** Whether a runtime prompt currently owns the pane's stdin. */
4488
+ isInputBlocked() {
4489
+ return this.inputBlockedDialogKey !== null;
4490
+ }
4393
4491
  /**
4394
4492
  * Tri-state liveness. tmux's own isWindowAlive folds every query failure
4395
4493
  * into `false`, so a transient tmux hiccup would read as "the CLI died" —