@songsid/agend 2.2.0-beta.3 → 2.2.0-beta.5

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.
Files changed (139) hide show
  1. package/dist/agent-cli.js +6 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/backend/claude-code.d.ts +46 -2
  4. package/dist/backend/claude-code.js +125 -31
  5. package/dist/backend/claude-code.js.map +1 -1
  6. package/dist/backend/credential-profile.d.ts +33 -3
  7. package/dist/backend/credential-profile.js +107 -5
  8. package/dist/backend/credential-profile.js.map +1 -1
  9. package/dist/backend/types.d.ts +1 -1
  10. package/dist/cache-service.d.ts +2 -0
  11. package/dist/cache-service.js +2 -1
  12. package/dist/cache-service.js.map +1 -1
  13. package/dist/channel/adapters/discord.d.ts +13 -0
  14. package/dist/channel/adapters/discord.js +42 -0
  15. package/dist/channel/adapters/discord.js.map +1 -1
  16. package/dist/channel/adapters/telegram.d.ts +6 -1
  17. package/dist/channel/adapters/telegram.js +27 -2
  18. package/dist/channel/adapters/telegram.js.map +1 -1
  19. package/dist/channel/types.d.ts +22 -1
  20. package/dist/cli.js +35 -0
  21. package/dist/cli.js.map +1 -1
  22. package/dist/config-validator.js +7 -0
  23. package/dist/config-validator.js.map +1 -1
  24. package/dist/daemon.d.ts +96 -0
  25. package/dist/daemon.js +339 -16
  26. package/dist/daemon.js.map +1 -1
  27. package/dist/delivery-outbox.d.ts +18 -5
  28. package/dist/delivery-outbox.js +121 -14
  29. package/dist/delivery-outbox.js.map +1 -1
  30. package/dist/fleet-context.d.ts +1 -0
  31. package/dist/fleet-manager.d.ts +89 -3
  32. package/dist/fleet-manager.js +541 -86
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +43 -10
  35. package/dist/host-memory.d.ts +10 -0
  36. package/dist/host-memory.js +33 -1
  37. package/dist/host-memory.js.map +1 -1
  38. package/dist/instance-lifecycle.d.ts +25 -6
  39. package/dist/instance-lifecycle.js +73 -26
  40. package/dist/instance-lifecycle.js.map +1 -1
  41. package/dist/locale.js +48 -18
  42. package/dist/locale.js.map +1 -1
  43. package/dist/login-controller.d.ts +20 -3
  44. package/dist/login-controller.js +71 -19
  45. package/dist/login-controller.js.map +1 -1
  46. package/dist/login-flows.d.ts +5 -2
  47. package/dist/login-flows.js +5 -4
  48. package/dist/login-flows.js.map +1 -1
  49. package/dist/memory-pressure.d.ts +13 -1
  50. package/dist/memory-pressure.js +50 -10
  51. package/dist/memory-pressure.js.map +1 -1
  52. package/dist/outbound-handlers.d.ts +61 -0
  53. package/dist/outbound-handlers.js +100 -35
  54. package/dist/outbound-handlers.js.map +1 -1
  55. package/dist/outbound-schemas.d.ts +1 -0
  56. package/dist/outbound-schemas.js +2 -0
  57. package/dist/outbound-schemas.js.map +1 -1
  58. package/dist/public-preview.d.ts +100 -0
  59. package/dist/public-preview.js +233 -0
  60. package/dist/public-preview.js.map +1 -0
  61. package/dist/public-web-gateway.js +6 -4
  62. package/dist/public-web-gateway.js.map +1 -1
  63. package/dist/public-web-link.d.ts +20 -0
  64. package/dist/public-web-link.js +26 -1
  65. package/dist/public-web-link.js.map +1 -1
  66. package/dist/restart-progress.d.ts +29 -0
  67. package/dist/restart-progress.js +23 -0
  68. package/dist/restart-progress.js.map +1 -1
  69. package/dist/settings-api.js +10 -4
  70. package/dist/settings-api.js.map +1 -1
  71. package/dist/spawn-gate.js +5 -1
  72. package/dist/spawn-gate.js.map +1 -1
  73. package/dist/spawn-hold-reply.d.ts +12 -0
  74. package/dist/spawn-hold-reply.js +39 -0
  75. package/dist/spawn-hold-reply.js.map +1 -0
  76. package/dist/topic-commands.js +9 -2
  77. package/dist/topic-commands.js.map +1 -1
  78. package/dist/tunnel/purpose-lane.d.ts +5 -1
  79. package/dist/tunnel/purpose-lane.js +14 -7
  80. package/dist/tunnel/purpose-lane.js.map +1 -1
  81. package/dist/turn-resume.d.ts +2 -0
  82. package/dist/turn-resume.js +1 -1
  83. package/dist/turn-resume.js.map +1 -1
  84. package/dist/types.d.ts +8 -0
  85. package/dist/ui/chat-mentions.js +63 -0
  86. package/dist/ui/chat-render.js +25 -0
  87. package/dist/ui/chat-strings.js +14 -2
  88. package/dist/ui/chat-thread.js +28 -10
  89. package/dist/ui/panel-chat.js +26 -11
  90. package/dist/ui/panel-details.js +2 -0
  91. package/dist/ui/panel-fleet.js +199 -57
  92. package/dist/ui/panel-needs.js +4 -3
  93. package/dist/ui/panel-settings.js +4 -2
  94. package/dist/ui/preview-panel.js +48 -14
  95. package/dist/ui/preview-status.js +28 -0
  96. package/dist/ui/preview.js +40 -3
  97. package/dist/ui/settings-token.js +1 -1
  98. package/dist/ui/shared/app-i18n.js +4 -0
  99. package/dist/ui/shared/app-prefs.js +1 -0
  100. package/dist/ui/shared/app-shell.js +146 -2
  101. package/dist/ui/shared/app-store.js +3 -1
  102. package/dist/ui/shared/app.css +47 -20
  103. package/dist/ui/shared/instance-nav.js +79 -23
  104. package/dist/ui/shared/view-strings.js +10 -0
  105. package/dist/unrecognised-prompt.d.ts +23 -0
  106. package/dist/unrecognised-prompt.js +74 -0
  107. package/dist/unrecognised-prompt.js.map +1 -0
  108. package/dist/update-install.d.ts +8 -0
  109. package/dist/update-install.js +9 -3
  110. package/dist/update-install.js.map +1 -1
  111. package/dist/update-marker.d.ts +40 -0
  112. package/dist/update-marker.js +49 -0
  113. package/dist/update-marker.js.map +1 -1
  114. package/dist/usage/i18n-keys.d.ts +1 -1
  115. package/dist/usage/i18n-keys.js +1 -1
  116. package/dist/usage/i18n-keys.js.map +1 -1
  117. package/dist/usage/providers.d.ts +34 -5
  118. package/dist/usage/providers.js +160 -27
  119. package/dist/usage/providers.js.map +1 -1
  120. package/dist/usage/statusline-usage.d.ts +6 -2
  121. package/dist/usage/statusline-usage.js +10 -4
  122. package/dist/usage/statusline-usage.js.map +1 -1
  123. package/dist/web-api.d.ts +20 -0
  124. package/dist/web-api.js +216 -30
  125. package/dist/web-api.js.map +1 -1
  126. package/dist/web-auth.js +3 -2
  127. package/dist/web-auth.js.map +1 -1
  128. package/dist/web-mentions.d.ts +44 -0
  129. package/dist/web-mentions.js +75 -0
  130. package/dist/web-mentions.js.map +1 -0
  131. package/dist/web-prefs.js +2 -0
  132. package/dist/web-prefs.js.map +1 -1
  133. package/dist/web-preview.d.ts +14 -1
  134. package/dist/web-preview.js +35 -13
  135. package/dist/web-preview.js.map +1 -1
  136. package/dist/web-schedules.d.ts +12 -0
  137. package/dist/web-schedules.js +34 -0
  138. package/dist/web-schedules.js.map +1 -0
  139. package/package.json +1 -1
package/dist/daemon.js CHANGED
@@ -45,7 +45,9 @@ import { measureSyncWork, noteSyncWork } from "./sync-work-attribution.js";
45
45
  import { TranscriptDeltaReader } from "./transcript-delta-reader.js";
46
46
  import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMarker, decideTurnResume, normalizeResumeBackend, readResumeFingerprint, RESUME_BLOCKING_DELIVERY_STATES, writeInFlightTurnMarker, } from "./turn-resume.js";
47
47
  import { codexLiveRowMatches } from "./backend/codex.js";
48
+ import { claudeConfigDirFor, claudeProfileConfigDir, claudeSwitchDialog, claudeSwitchTargetMatches } from "./backend/claude-code.js";
48
49
  import { InteractionObservation, INTERACTION_CONFIRM_MS, sameInteractionOwner } from "./interaction-observation.js";
50
+ import { unrecognisedPromptShape } from "./unrecognised-prompt.js";
49
51
  const __filename = fileURLToPath(import.meta.url);
50
52
  const __dirname = dirname(__filename);
51
53
  // Tool routing sets — module-level to avoid re-creation on every handleToolCall
@@ -522,8 +524,16 @@ const DIALOG_PROBE_UNKNOWN_MAX = 40;
522
524
  /** Startup dialog scan: poll cadence and wall-clock budget (keys and waits included). */
523
525
  const STARTUP_DIALOG_POLL_MS = 500;
524
526
  const STARTUP_DIALOG_BUDGET_MS = 30_000;
527
+ /** #1690: after AgEnD's own `/model`, how long the pane is read for claude's "Switch model?" or its result. */
528
+ export const OWN_SWITCH_WAIT_MS = 6_000;
529
+ /** #1690: after an answer to that dialog, how long it gets to go away. */
530
+ const OWN_SWITCH_SETTLE_MS = 3_000;
525
531
  /** A blocking dialog still on screen this long after first being seen is reported for a human. */
526
532
  const DIALOG_PARKED_NOTIFY_MS = 60_000;
533
+ /** #1691: a prompt no table knows must sit unchanged this long (monotonic) before it holds deliveries and is reported. */
534
+ export const UNRECOGNISED_PROMPT_STABLE_MS = 60_000;
535
+ /** #1691: the rows under a prompt a backend without a composer reader shows its ready chrome in (status bar, hint). */
536
+ const COMPOSER_TAIL_ROWS = 4;
527
537
  /** After a runtime dialog's keys, how long the pane gets to repaint before it is read again to see whether they took. */
528
538
  const DIALOG_ANSWER_SETTLE_MS = 300;
529
539
  /** Consecutive answers one runtime dialog ignored (still on screen right after the keys) before it is reported for a human. */
@@ -1163,6 +1173,34 @@ export class Daemon extends EventEmitter {
1163
1173
  dialogParkedKey = null;
1164
1174
  dialogParkedInteractionEpisode = null;
1165
1175
  dialogParkedReported = false;
1176
+ /**
1177
+ * #1691: the prompt at the bottom of the pane that no backend table knows — its block, since when (performance.now)
1178
+ * it has been unchanged, and whether that has lasted UNRECOGNISED_PROMPT_STABLE_MS. Updated by the runtime scan.
1179
+ */
1180
+ unrecognisedPrompt = null;
1181
+ /**
1182
+ * #1691: when (performance.now) the newest pane read that decided the evidence above was taken — a scan or a fresh
1183
+ * read, confirming, restarting or ending it. A read taken before it finishes late and says nothing about now: it may
1184
+ * neither clear, restart nor confirm (#1695 r4).
1185
+ */
1186
+ unrecognisedPromptReadAt = -Infinity;
1187
+ /**
1188
+ * #1691: the catch-all for that prompt, as a hold-only runtime dialog — so the delivery hold, awaiting_input, the
1189
+ * Needs-you item and the single parked report are the existing ones. Never answered (hold-only, no keys).
1190
+ */
1191
+ unrecognisedPromptDialog = {
1192
+ pattern: /(?!)unrecognised-prompt-1691/,
1193
+ keys: [],
1194
+ description: "a prompt AgEnD does not recognise",
1195
+ holdOnly: true,
1196
+ isActive: (pane) => this.unrecognisedPromptActive(pane),
1197
+ requestIdentity: () => this.unrecognisedPrompt?.block ?? null,
1198
+ };
1199
+ /**
1200
+ * #1690: AgEnD's own runtime `/model` in flight — from the moment its paste went in until its "Switch model?" dialog
1201
+ * (if any) is answered. Every delivery is held meanwhile: the dialog's default cursor is Yes, a stray Enter confirms it.
1202
+ */
1203
+ ownSwitch = null;
1166
1204
  /**
1167
1205
  * The runtime dialog the monitor is answering, and how many of its answers in a row did nothing (the dialog was
1168
1206
  * still on screen right after the keys). Not the on-screen duration: a burst of FRESH prompts, each answered, has a
@@ -3216,7 +3254,6 @@ export class Daemon extends EventEmitter {
3216
3254
  if (this.runtimeMonitorsFrozen || this.errorMonitorTimer)
3217
3255
  return;
3218
3256
  const patterns = this.backend?.getErrorPatterns?.() ?? [];
3219
- const dialogs = this.backend?.getRuntimeDialogs?.() ?? [];
3220
3257
  if (!this.tmux)
3221
3258
  return;
3222
3259
  if (!this.backend)
@@ -3252,6 +3289,9 @@ export class Daemon extends EventEmitter {
3252
3289
  scanStartedAt = performance.now();
3253
3290
  if (stale())
3254
3291
  return;
3292
+ this.trackUnrecognisedPrompt(pane, captureMono, this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureAt);
3293
+ // The backend's dialogs, and #1691's catch-all while an unrecognised prompt is active (decided just above).
3294
+ const dialogs = this.runtimeDialogs();
3255
3295
  if (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureAt) {
3256
3296
  this.unverifyInteraction(captureMono, captureOrder);
3257
3297
  }
@@ -3731,14 +3771,21 @@ export class Daemon extends EventEmitter {
3731
3771
  isDeliveryEpochCurrent(epoch) {
3732
3772
  return epoch === this.deliveryEpoch;
3733
3773
  }
3734
- queueRawPaste(rawText, deliveryEpoch = this.deliveryEpoch, confirmClear = false, durableAttempt) {
3774
+ queueRawPaste(rawText, deliveryEpoch = this.deliveryEpoch, confirmClear = false, durableAttempt, ownSwitch) {
3775
+ const reportSwitch = (result) => {
3776
+ if (!ownSwitch)
3777
+ return;
3778
+ this.ipcServer?.broadcast({ type: "runtime_switch_result", id: ownSwitch.id, ...result });
3779
+ };
3735
3780
  if (!this.tmux || !this.isDeliveryEpochCurrent(deliveryEpoch)) {
3736
3781
  if (durableAttempt)
3737
3782
  this.retryDurableDeliveryBeforeBegin(durableAttempt, "raw_paste target unavailable before pane queue");
3783
+ reportSwitch({ outcome: "not_sent" });
3738
3784
  return;
3739
3785
  }
3740
3786
  this.pasteLock = this.pasteLock.then(async () => {
3741
3787
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
3788
+ reportSwitch({ outcome: "not_sent" });
3742
3789
  this.logger.info("Pending raw delivery dropped by user cancel");
3743
3790
  if (durableAttempt)
3744
3791
  this.retryDurableDeliveryBeforeBegin(durableAttempt, "raw_paste epoch changed while queued");
@@ -3750,6 +3797,7 @@ export class Daemon extends EventEmitter {
3750
3797
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
3751
3798
  if (durableAttempt)
3752
3799
  this.retryDurableDeliveryBeforeBegin(durableAttempt, "raw_paste epoch changed while waking");
3800
+ reportSwitch({ outcome: "not_sent" }); // #1690: cancelled before anything was typed — said at once, nothing saved
3753
3801
  return;
3754
3802
  }
3755
3803
  const delivered = await this.deliverMessage(rawText, undefined, {
@@ -3757,6 +3805,9 @@ export class Daemon extends EventEmitter {
3757
3805
  verdict,
3758
3806
  durableAttempt,
3759
3807
  preserveExactBytes: true,
3808
+ // #1690: our own /model holds every other delivery from its write on — before the lock is released, so a steer
3809
+ // or /btw waiting on it cannot type into the gap before claude paints "Switch model?" (default cursor: Yes).
3810
+ ...(ownSwitch ? { onPaneWritten: () => { this.ownSwitch = ownSwitch; } } : {}),
3760
3811
  });
3761
3812
  if (delivered)
3762
3813
  this.finishDurableSubmission(durableAttempt ?? null, verdict);
@@ -3789,10 +3840,31 @@ export class Daemon extends EventEmitter {
3789
3840
  else if (durableAttempt)
3790
3841
  this.retryDurableDeliveryBeforeBegin(durableAttempt, err.message);
3791
3842
  this.logger.warn({ err: err.message }, "raw_paste delivery error");
3843
+ if (ownSwitch) {
3844
+ if (this.ownSwitch === ownSwitch)
3845
+ this.ownSwitch = null;
3846
+ reportSwitch({ outcome: "not_confirmed" });
3847
+ }
3792
3848
  return;
3793
3849
  }
3794
3850
  if (confirmClear)
3795
3851
  await this.confirmBackendClearDialog();
3852
+ if (ownSwitch) {
3853
+ if (this.ownSwitch !== ownSwitch)
3854
+ reportSwitch({ outcome: "not_sent" }); // its write never began
3855
+ else {
3856
+ try {
3857
+ reportSwitch(await this.answerOwnSwitchDialog(ownSwitch));
3858
+ }
3859
+ catch (err) {
3860
+ this.logger.warn({ err: err.message }, "Answering our own model switch failed");
3861
+ reportSwitch({ outcome: "not_confirmed" });
3862
+ }
3863
+ finally {
3864
+ this.ownSwitch = null;
3865
+ }
3866
+ }
3867
+ }
3796
3868
  this.logger.debug({ text: rawText.slice(0, 100) }, "Raw paste delivered");
3797
3869
  });
3798
3870
  this.pasteLock = this.pasteLock.catch(err => {
@@ -3811,7 +3883,93 @@ export class Daemon extends EventEmitter {
3811
3883
  this.deferDurableDelivery(deliveryMeta, "invalid raw_paste delivery attempt metadata");
3812
3884
  return;
3813
3885
  }
3814
- this.queueRawPaste(msg.content, this.captureDeliveryEpoch(msg.delivery_epoch), msg.confirm_clear === true, durableAttempt ? { ...durableAttempt, submissionMode: "raw_paste" } : undefined);
3886
+ const sw = msg.runtime_switch;
3887
+ const ownSwitch = sw && typeof sw.id === "string" && sw.kind === "model" && typeof sw.requested === "string"
3888
+ ? { id: sw.id, kind: "model", requested: sw.requested } : undefined;
3889
+ this.queueRawPaste(msg.content, this.captureDeliveryEpoch(msg.delivery_epoch), msg.confirm_clear === true, durableAttempt ? { ...durableAttempt, submissionMode: "raw_paste" } : undefined, ownSwitch);
3890
+ }
3891
+ /**
3892
+ * #1690 (the user's choice A): after AgEnD's own `/model <name>`, claude may ask "Switch model?" (a warm cache for
3893
+ * another model). When the model it names is the one asked for, answer Yes; otherwise No (Escape — `Kept model as …`),
3894
+ * and say so. Every key goes on a freshly read, verified dialog under the pane write lock: the same target, and for
3895
+ * Enter the cursor on "1. Yes"; the cursor is walked there with Up, one verified step at a time, never a blind key.
3896
+ * Without the dialog, claude's own "Set model to …" line after the command is the result.
3897
+ */
3898
+ async answerOwnSwitchDialog(spec) {
3899
+ const tmux = this.tmux;
3900
+ if (!tmux)
3901
+ return { outcome: "not_confirmed" };
3902
+ const read = () => tmux.capturePane().catch(() => "");
3903
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
3904
+ /** One key on a dialog re-read under the lock: still the same target, and (when given) the cursor where expected. */
3905
+ const press = (target, cursor, key) => this.paneWriteLock.run(async () => {
3906
+ if (this.tmux !== tmux)
3907
+ return false;
3908
+ const now = claudeSwitchDialog(await read());
3909
+ if (!now || now.kind !== "model" || now.target !== target || (cursor !== null && now.cursor !== cursor))
3910
+ return false;
3911
+ return tmux.sendSpecialKey(key);
3912
+ });
3913
+ const gone = async () => {
3914
+ const until = performance.now() + OWN_SWITCH_SETTLE_MS;
3915
+ while (performance.now() < until) {
3916
+ if (!claudeSwitchDialog(await read()))
3917
+ return true;
3918
+ await sleep(150);
3919
+ }
3920
+ return false;
3921
+ };
3922
+ const deadline = performance.now() + OWN_SWITCH_WAIT_MS;
3923
+ while (performance.now() < deadline && this.tmux === tmux) {
3924
+ const pane = await read();
3925
+ const dialog = claudeSwitchDialog(pane);
3926
+ if (dialog && dialog.kind === "model") {
3927
+ const shown = dialog.target;
3928
+ if (!claudeSwitchTargetMatches(spec.requested, shown)) {
3929
+ const answered = await press(shown, null, "Escape") && await gone();
3930
+ this.logger.warn({ requested: spec.requested, shown, answered }, "claude would switch to another model than asked — answered No");
3931
+ return { outcome: "mismatch", shown, answered };
3932
+ }
3933
+ // Walk the cursor to "1. Yes" (it starts there; a human may have moved it): Up from row 2, re-read each time.
3934
+ let current = dialog;
3935
+ for (let step = 0; step < 2 && current.cursor === 2; step++) {
3936
+ if (!await press(shown, 2, "Up"))
3937
+ break;
3938
+ await sleep(120);
3939
+ const next = claudeSwitchDialog(await read());
3940
+ if (!next || next.target !== shown)
3941
+ break;
3942
+ current = next;
3943
+ }
3944
+ if (current.cursor !== 1 || !await press(shown, 1, "Enter") || !await gone()) {
3945
+ this.logger.warn({ requested: spec.requested, shown, cursor: current.cursor }, "Our own model switch dialog could not be confirmed on a verified cursor — left for a human");
3946
+ return { outcome: "not_confirmed", shown };
3947
+ }
3948
+ this.logger.info({ requested: spec.requested, shown }, "Confirmed our own model switch (claude: the next reply re-reads the history)");
3949
+ return { outcome: "switched", confirmed: true, shown };
3950
+ }
3951
+ if (Daemon.switchResultAfterCommand(pane, spec.requested))
3952
+ return { outcome: "switched", confirmed: false };
3953
+ await sleep(200);
3954
+ }
3955
+ return { outcome: "not_confirmed" };
3956
+ }
3957
+ /**
3958
+ * #1690: claude's "Set model to …" line under our `/model <requested>` (no dialog was needed). Only the newest command
3959
+ * on the pane counts: an older identical `/model` with its result further up (switched away and back) must not pass
3960
+ * for this one before it has even painted — that left its dialog unanswered (live, 2.1.296).
3961
+ */
3962
+ static switchResultAfterCommand(pane, requested) {
3963
+ const rows = pane.replace(/\r/g, "").split("\n");
3964
+ let last = -1;
3965
+ for (let i = rows.length - 1; i >= 0; i--)
3966
+ if (/^❯[ \t\u00a0]+\S/.test(rows[i])) {
3967
+ last = i;
3968
+ break;
3969
+ }
3970
+ if (last < 0 || rows[last].replace(/^❯[ \t\u00a0]+/, "").trimEnd() !== `/model ${requested}`)
3971
+ return false;
3972
+ return rows.slice(last + 1).some(row => /^\s*⎿?\s*Set model to /.test(row));
3815
3973
  }
3816
3974
  /**
3817
3975
  * Confirm a backend's terminal-level /clear prompt after the channel-level
@@ -4275,7 +4433,9 @@ export class Daemon extends EventEmitter {
4275
4433
  // Bare backend regexes are not proof of a live modal. The canonical
4276
4434
  // isActive predicate replaces pattern.test(), preserving table order.
4277
4435
  const dialog = dialogs.find(d => d.isActive && (d.inputBlocked || d.blocksDelivery || d.holdOnly)
4278
- && Daemon.dialogMatches(d, pane));
4436
+ && Daemon.dialogMatches(d, pane)
4437
+ // #1690: AgEnD's own switch dialog, being answered right now, is nobody's to answer.
4438
+ && !(this.ownSwitch && claudeSwitchDialog(pane) !== null));
4279
4439
  if (dialog) {
4280
4440
  const identity = dialog.requestIdentity?.(pane) ?? pane.replace(/\r/g, "")
4281
4441
  .split("\n").map(row => row.replace(/^([ \t]*)[❯›]([ \t]+)(?=\d\.|Yes\b|No\b|Resume\b)/, "$1$2").trimEnd())
@@ -4291,7 +4451,7 @@ export class Daemon extends EventEmitter {
4291
4451
  // No captured text leaves this function or the private observer identity.
4292
4452
  return evidence ? { ...evidence, identity: createHash("sha256").update(evidence.identity).digest("hex") } : null;
4293
4453
  }
4294
- observeInteractionPane(pane, observedAt, monotonicAt, dialogs = this.backend?.getRuntimeDialogs?.() ?? [], order = ++this.interactionCaptureSerial, allowConfirmation = true) {
4454
+ observeInteractionPane(pane, observedAt, monotonicAt, dialogs = this.runtimeDialogs(), order = ++this.interactionCaptureSerial, allowConfirmation = true) {
4295
4455
  const owner = this.interactionOwner();
4296
4456
  const evidence = this.interactionEvidence(pane, dialogs);
4297
4457
  if (!this.interactionObservation.observe(evidence, owner, observedAt, monotonicAt, this.interactionOutputRevision !== this.interactionObservedOutputRevision, order))
@@ -4776,10 +4936,7 @@ export class Daemon extends EventEmitter {
4776
4936
  backend: resumeBackend ?? "unknown",
4777
4937
  cwd: resumeCwd,
4778
4938
  armedAt: Date.now(),
4779
- before: readResumeFingerprint(normalizeResumeBackend(resumeBackend), {
4780
- instanceDir: this.instanceDir,
4781
- workingDirectory: resumeCwd,
4782
- }),
4939
+ before: readResumeFingerprint(normalizeResumeBackend(resumeBackend), this.resumeRoots(resumeCwd)),
4783
4940
  });
4784
4941
  }
4785
4942
  }
@@ -4825,7 +4982,7 @@ export class Daemon extends EventEmitter {
4825
4982
  && this.spawnGeneration === gateSpawn
4826
4983
  && this.launchFenceEpoch === gateFence;
4827
4984
  const backend = this.resolveResumeBackend() ?? normalizeResumeBackend(marker.backend);
4828
- const roots = { instanceDir: this.instanceDir, workingDirectory: this.config.working_directory ?? "" };
4985
+ const roots = this.resumeRoots(this.config.working_directory ?? "");
4829
4986
  // This boot's CLI spawn is what the daemon itself caused: pre-restart
4830
4987
  // writes (including pre-crash progress) are excluded, and the flush
4831
4988
  // grace holds the decision until post-spawn writes are observable.
@@ -5554,6 +5711,8 @@ export class Daemon extends EventEmitter {
5554
5711
  return;
5555
5712
  if (!this.observeInteractionPane(pane, captureStartedAt, interactionCaptureAt, undefined, interactionCaptureOrder, reason !== "interaction_confirmation"))
5556
5713
  return;
5714
+ // #1691: an accepted capture of this launch is a fresh read for the unrecognised-prompt evidence too (#1695 r3).
5715
+ this.noteFreshPaneRead(pane, interactionCaptureAt);
5557
5716
  this.updateInputBlockedState(pane);
5558
5717
  // Advance the shared 10s timer only for a fresh capture of this launch.
5559
5718
  const deliveryFallback = deliveryCandidate === true
@@ -6712,6 +6871,9 @@ export class Daemon extends EventEmitter {
6712
6871
  if (residue !== "clear")
6713
6872
  return this.failDelivery(verdict, status, "input-residue", residue);
6714
6873
  const written = await this.writeMessageToPane(formatted, windowId, handingOffToNativeQueue, status, opts?.submissionId, verdict, opts?.durableAttempt, opts?.steer === true, writeCurrent);
6874
+ // #1690: still under the lock — whatever waits on it next must already see what this write started.
6875
+ if (verdict.paneWriteStarted)
6876
+ opts?.onPaneWritten?.();
6715
6877
  // The hand-off's last capture had no box: nothing was begun or written — wait for readiness, then try again.
6716
6878
  if (written === "handoff-box-unread")
6717
6879
  return "dialog";
@@ -6759,7 +6921,148 @@ export class Daemon extends EventEmitter {
6759
6921
  * on purpose ("I trust", "Yes, continue") and would match ordinary prose.
6760
6922
  */
6761
6923
  deliveryBlockingDialogs() {
6762
- return (this.backend?.getRuntimeDialogs?.() ?? []).filter(d => d.blocksDelivery || d.holdOnly);
6924
+ return this.runtimeDialogs().filter(d => d.blocksDelivery || d.holdOnly);
6925
+ }
6926
+ /**
6927
+ * The backend's runtime dialogs, then #1691's catch-all for a prompt none of them knows (last: known ones win). The
6928
+ * catch-all is listed only while such a prompt is active: otherwise it could match nothing, and a backend with no
6929
+ * dialogs keeps its empty list (its delivery path skips the dialog probe, as before).
6930
+ */
6931
+ runtimeDialogs() {
6932
+ const dialogs = this.backend?.getRuntimeDialogs?.() ?? [];
6933
+ return this.unrecognisedPromptCurrent()?.active ? [...dialogs, this.unrecognisedPromptDialog] : dialogs;
6934
+ }
6935
+ /**
6936
+ * #1691: is the pane at the CLI's own idle composer? Then whatever looks like a prompt above it is transcript output
6937
+ * (an agent's numbered list, a quoted "[y/n]"), not a prompt. The backend's own reading, most specific first.
6938
+ */
6939
+ cliComposerShowing(pane, shape) {
6940
+ const backend = this.backend;
6941
+ if (!backend)
6942
+ return false;
6943
+ if (backend.readInputRow)
6944
+ return backend.readInputRow(pane) !== null;
6945
+ // muse: its live input box — null when the bottom of the screen is not its layout (#829), e.g. a selection menu.
6946
+ if (backend.inputDraft)
6947
+ return backend.inputDraft(pane) !== null;
6948
+ if (backend.isDeliveryInputReadyPane)
6949
+ return backend.isDeliveryInputReadyPane(pane);
6950
+ const bottom = backend.getBottomReadyPattern?.();
6951
+ if (bottom) {
6952
+ bottom.lastIndex = 0;
6953
+ return bottom.test(pane);
6954
+ }
6955
+ // No reader of its own (opencode, grok, …): its ready chrome BELOW the prompt rows — a status bar or prompt hint drawn
6956
+ // under its input box (opencode's "ctrl+p commands"), which a prompt shown in place of the input has nothing of. Not
6957
+ // the whole pane (a reply quoting "ctrl+p commands" higher up), and never the prompt's own rows: a ready pattern that
6958
+ // is just a cursor glyph would otherwise read the menu's own "❯ 1." as the CLI's prompt (#1695 r2).
6959
+ const rows = pane.replace(/\r/g, "").split("\n").map(r => r.trimEnd());
6960
+ const lastOwn = (shape.block.split("\n").at(-1) ?? "").trim();
6961
+ let end = -1;
6962
+ for (let i = rows.length - 1; i >= 0; i--)
6963
+ if (rows[i].trim() === lastOwn) {
6964
+ end = i;
6965
+ break;
6966
+ }
6967
+ if (end < 0)
6968
+ return false;
6969
+ const below = rows.slice(end + 1).filter(r => r.trim() !== "").slice(0, COMPOSER_TAIL_ROWS).join("\n");
6970
+ const ready = backend.getReadyPattern();
6971
+ ready.lastIndex = 0;
6972
+ return below !== "" && ready.test(below);
6973
+ }
6974
+ /**
6975
+ * #1691: the prompt shape this pane shows, when nothing else explains it (no known dialog, login, idle composer, and
6976
+ * the CLI is not showing its busy indicator).
6977
+ */
6978
+ unrecognisedPromptCandidate(pane) {
6979
+ const shape = unrecognisedPromptShape(pane);
6980
+ if (!shape)
6981
+ return null;
6982
+ const busy = this.backend?.getBusyPattern?.() ?? null;
6983
+ if (busy) {
6984
+ busy.lastIndex = 0;
6985
+ if (busy.test(pane))
6986
+ return null;
6987
+ }
6988
+ if ((this.backend?.getRuntimeDialogs?.() ?? []).some(dialog => Daemon.dialogMatches(dialog, pane)))
6989
+ return null;
6990
+ if (LOGIN_FLOWS[this.config.backend ?? "claude-code"]?.loginScreenActive?.(pane))
6991
+ return null;
6992
+ if (this.cliComposerShowing(pane, shape))
6993
+ return null;
6994
+ return shape;
6995
+ }
6996
+ /**
6997
+ * #1691, from each runtime scan: the same unexplained prompt block unchanged for UNRECOGNISED_PROMPT_STABLE_MS
6998
+ * becomes active (held, awaiting_input, reported once by trackDialogParked). Any other screen ends it. A turn keeps
6999
+ * changing the pane (spinner, timer, streamed text), so its clock never completes mid-turn.
7000
+ */
7001
+ trackUnrecognisedPrompt(pane, mono, outputMoved) {
7002
+ if (mono < this.unrecognisedPromptReadAt)
7003
+ return;
7004
+ this.unrecognisedPromptReadAt = mono;
7005
+ const shape = this.unrecognisedPromptCandidate(pane);
7006
+ if (!shape) {
7007
+ this.unrecognisedPrompt = null;
7008
+ return;
7009
+ }
7010
+ const screen = Daemon.stableScreen(pane);
7011
+ const owner = this.interactionOwner();
7012
+ const current = this.unrecognisedPromptCurrent();
7013
+ // Restart the minute on any change: another prompt, motion anywhere on the screen, output that arrived while this
7014
+ // capture was taken, or evidence from another launch (#1695 review).
7015
+ if (!current || current.block !== shape.block || current.screen !== screen || outputMoved) {
7016
+ this.unrecognisedPrompt = { block: shape.block, shape, since: mono, active: false, reportedElsewhere: false, screen, owner };
7017
+ return;
7018
+ }
7019
+ if (!current.active && mono - current.since >= UNRECOGNISED_PROMPT_STABLE_MS) {
7020
+ current.active = true;
7021
+ // A [y/n] the interactive-prompt detector knows was already told (interactive_prompt, after 10 s): held and
7022
+ // awaiting input from here on, but not reported a second time.
7023
+ current.reportedElsewhere = shape.kind === "yes-no" && this.interactivePromptDetector.match(pane) !== null;
7024
+ this.logger.warn({ shape: shape.kind, options: shape.options, question: shape.question, unchangedForMs: Math.round(mono - current.since) }, "CLI is waiting on a prompt AgEnD does not recognise — holding deliveries, reporting for a human (capture this pane for the per-version audit, ffb8104e)");
7025
+ }
7026
+ }
7027
+ /** #1691: the tracked evidence, if it still belongs to this launch (else it is dropped). */
7028
+ unrecognisedPromptCurrent() {
7029
+ const current = this.unrecognisedPrompt;
7030
+ if (current && (this.runtimeMonitorsFrozen || !sameInteractionOwner(current.owner, this.interactionOwner()))) {
7031
+ this.unrecognisedPrompt = null;
7032
+ return null;
7033
+ }
7034
+ return current;
7035
+ }
7036
+ /**
7037
+ * #1691: the catch-all's predicate — the tracked prompt is active and this pane still shows exactly it (any reader:
7038
+ * the scan, a probe, an interaction re-capture). Ending the evidence on a read without it is noteFreshPaneRead's.
7039
+ */
7040
+ unrecognisedPromptActive(pane) {
7041
+ const current = this.unrecognisedPromptCurrent();
7042
+ return !!current?.active && this.unrecognisedPromptCandidate(pane)?.block === current.block && Daemon.stableScreen(pane) === current.screen;
7043
+ }
7044
+ /**
7045
+ * #1691: a fresh pane read outside the scan (a delivery probe, an accepted state re-capture), taken at `mono`. When it
7046
+ * no longer shows exactly the tracked prompt, the evidence ends — counting or active, so a return needs a fresh minute
7047
+ * (#1695 r2). When it does, it is the newest confirmation. Either way only a read newer than the newest read that
7048
+ * decided the evidence counts: an older one, finishing late, says nothing about now (#1695 r3, r4).
7049
+ */
7050
+ noteFreshPaneRead(pane, mono) {
7051
+ if (mono < this.unrecognisedPromptReadAt)
7052
+ return;
7053
+ // Advanced even with nothing tracked: a scan whose capture is older must not start evidence this read contradicts
7054
+ // (#1695 r5).
7055
+ this.unrecognisedPromptReadAt = mono;
7056
+ const current = this.unrecognisedPromptCurrent();
7057
+ if (!current)
7058
+ return;
7059
+ if (this.unrecognisedPromptCandidate(pane)?.block === current.block && Daemon.stableScreen(pane) === current.screen)
7060
+ return;
7061
+ this.unrecognisedPrompt = null;
7062
+ }
7063
+ /** #1691: the screen as the stability clock compares it (trailing blanks are not motion). */
7064
+ static stableScreen(pane) {
7065
+ return pane.replace(/\r/g, "").split("\n").map(r => r.trimEnd()).join("\n").trimEnd();
6763
7066
  }
6764
7067
  /** Passive input-unavailable phases relevant to the current spawn only. */
6765
7068
  guardedInputTransients() {
@@ -6927,7 +7230,7 @@ export class Daemon extends EventEmitter {
6927
7230
  return dialog.isActive ? dialog.isActive(pane) : dialog.pattern.test(pane);
6928
7231
  }
6929
7232
  /** Refresh the separate stdin-blocked observation from one pane capture. */
6930
- updateInputBlockedState(pane, dialogs = this.backend?.getRuntimeDialogs?.() ?? []) {
7233
+ updateInputBlockedState(pane, dialogs = this.runtimeDialogs()) {
6931
7234
  const active = dialogs.find(dialog => dialog.inputBlocked && Daemon.dialogMatches(dialog, pane)) ?? null;
6932
7235
  const nextKey = active ? Daemon.dialogKey(active) : null;
6933
7236
  if (nextKey !== this.inputBlockedDialogKey) {
@@ -7015,9 +7318,15 @@ export class Daemon extends EventEmitter {
7015
7318
  async probeBlockingDialog() {
7016
7319
  if (!this.tmux || !this.backend)
7017
7320
  return { state: "clear" };
7321
+ // #1691: a probe is a fresh read — it may end unrecognised-prompt evidence even when no dialog is listed.
7322
+ const tracking = this.unrecognisedPrompt !== null;
7018
7323
  const dialogs = this.deliveryBlockingDialogs();
7019
- if (dialogs.length === 0)
7324
+ // #1690: our own switch is being answered — nothing else types into the pane until it has settled.
7325
+ if (this.ownSwitch && dialogs.length > 0)
7326
+ return { state: "dialog", dialog: dialogs[0] };
7327
+ if (dialogs.length === 0 && !tracking)
7020
7328
  return { state: "clear" };
7329
+ const probeMono = performance.now();
7021
7330
  let pane;
7022
7331
  try {
7023
7332
  pane = await this.tmux.capturePane();
@@ -7026,6 +7335,7 @@ export class Daemon extends EventEmitter {
7026
7335
  this.logger.debug({ err }, "capture-pane failed during the dialog probe — pane state unknown");
7027
7336
  return { state: "unknown" };
7028
7337
  }
7338
+ this.noteFreshPaneRead(pane, probeMono);
7029
7339
  for (const dialog of dialogs) {
7030
7340
  if (!Daemon.dialogMatches(dialog, pane))
7031
7341
  continue;
@@ -7235,11 +7545,16 @@ export class Daemon extends EventEmitter {
7235
7545
  this.dialogParkedReported = false;
7236
7546
  return;
7237
7547
  }
7238
- if (!this.dialogParkedReported && Date.now() - this.dialogParkedSince >= DIALOG_PARKED_NOTIFY_MS) {
7548
+ // #1691: the catch-all waited its minute before it became active; it is reported on the next scan.
7549
+ const unrecognised = dialog === this.unrecognisedPromptDialog;
7550
+ if (unrecognised && this.unrecognisedPrompt?.reportedElsewhere)
7551
+ this.dialogParkedReported = true;
7552
+ if (!this.dialogParkedReported && Date.now() - this.dialogParkedSince >= (unrecognised ? 0 : DIALOG_PARKED_NOTIFY_MS)) {
7239
7553
  this.dialogParkedReported = true;
7240
7554
  this.logger.warn({ dialog: dialog.description, parkedForMs: Date.now() - this.dialogParkedSince }, "CLI dialog is still on screen — not auto-answering, reporting for a human");
7241
7555
  this.emit("dialog_parked", { name: this.name, description: dialog.description, holdOnly: dialog.holdOnly === true,
7242
- ...(episode !== null && interaction.kind ? { kind: interaction.kind, episode, backend: this.config.backend ?? "claude-code" } : {}) });
7556
+ ...(episode !== null && interaction.kind ? { kind: interaction.kind, episode, backend: this.config.backend ?? "claude-code" } : {}),
7557
+ ...(unrecognised ? { unrecognised: true, question: this.unrecognisedPrompt?.shape.question ?? null } : {}) });
7243
7558
  }
7244
7559
  }
7245
7560
  /**
@@ -10704,7 +11019,7 @@ export class Daemon extends EventEmitter {
10704
11019
  if (launchFence !== this.launchFenceEpoch)
10705
11020
  throw new Error("Launch cancelled: the instance was stopped or paused while the launch was being prepared");
10706
11021
  this.backend.writeConfig(backendConfig);
10707
- this.backend.preTrust?.(this.config.working_directory);
11022
+ this.backend.preTrust?.(this.config.working_directory, backendConfig);
10708
11023
  // Resolve working directory (e.g. symlink for hidden paths)
10709
11024
  const resolvedCwd = this.backend.resolveWorkingDirectory?.(this.config.working_directory, this.name) ?? this.config.working_directory;
10710
11025
  // Generate a fresh per-instance agent token each spawn. agent-cli reads
@@ -11344,6 +11659,14 @@ export class Daemon extends EventEmitter {
11344
11659
  }, "Snapshot written");
11345
11660
  return snapshot;
11346
11661
  }
11662
+ /** #1699: the claude config dir this instance's CLI uses — its credential profile's, else exactly as before. */
11663
+ claudeConfigDir() {
11664
+ return claudeConfigDirFor(claudeProfileConfigDir(this.config.backend_options?.["claude-code"]));
11665
+ }
11666
+ /** Where this instance's CLI keeps the conversation a turn resume is judged on (#1699: claude's follows the profile). */
11667
+ resumeRoots(workingDirectory) {
11668
+ return { instanceDir: this.instanceDir, workingDirectory, claudeConfigDir: this.claudeConfigDir() };
11669
+ }
11347
11670
  /**
11348
11671
  * The credential profile's store this instance's CLI writes to, if any.
11349
11672
  *