@songsid/agend 2.2.0-beta.2 → 2.2.0-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.
Files changed (131) hide show
  1. package/dist/agent-cli.js +6 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/auth-api.js +2 -0
  4. package/dist/auth-api.js.map +1 -1
  5. package/dist/backend/claude-code.d.ts +39 -2
  6. package/dist/backend/claude-code.js +115 -31
  7. package/dist/backend/claude-code.js.map +1 -1
  8. package/dist/backend/credential-profile.d.ts +33 -3
  9. package/dist/backend/credential-profile.js +107 -5
  10. package/dist/backend/credential-profile.js.map +1 -1
  11. package/dist/backend/types.d.ts +1 -1
  12. package/dist/cache-service.d.ts +2 -0
  13. package/dist/cache-service.js +2 -1
  14. package/dist/cache-service.js.map +1 -1
  15. package/dist/channel/adapters/discord.d.ts +14 -1
  16. package/dist/channel/adapters/discord.js +42 -0
  17. package/dist/channel/adapters/discord.js.map +1 -1
  18. package/dist/channel/adapters/telegram.d.ts +18 -3
  19. package/dist/channel/adapters/telegram.js +66 -10
  20. package/dist/channel/adapters/telegram.js.map +1 -1
  21. package/dist/channel/telegram-markdown.d.ts +6 -0
  22. package/dist/channel/telegram-markdown.js +104 -0
  23. package/dist/channel/telegram-markdown.js.map +1 -0
  24. package/dist/channel/tool-router.js +5 -4
  25. package/dist/channel/tool-router.js.map +1 -1
  26. package/dist/channel/types.d.ts +27 -4
  27. package/dist/config-validator.js +10 -5
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/daemon.d.ts +108 -0
  30. package/dist/daemon.js +432 -32
  31. package/dist/daemon.js.map +1 -1
  32. package/dist/delivery-outbox.d.ts +29 -7
  33. package/dist/delivery-outbox.js +134 -16
  34. package/dist/delivery-outbox.js.map +1 -1
  35. package/dist/delivery-reconciliation.d.ts +18 -1
  36. package/dist/delivery-reconciliation.js +67 -2
  37. package/dist/delivery-reconciliation.js.map +1 -1
  38. package/dist/fleet-context.d.ts +1 -0
  39. package/dist/fleet-manager.d.ts +81 -4
  40. package/dist/fleet-manager.js +452 -85
  41. package/dist/fleet-manager.js.map +1 -1
  42. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +43 -10
  43. package/dist/host-memory.d.ts +10 -0
  44. package/dist/host-memory.js +33 -1
  45. package/dist/host-memory.js.map +1 -1
  46. package/dist/instance-lifecycle.d.ts +25 -6
  47. package/dist/instance-lifecycle.js +73 -26
  48. package/dist/instance-lifecycle.js.map +1 -1
  49. package/dist/locale.js +48 -18
  50. package/dist/locale.js.map +1 -1
  51. package/dist/login-controller.d.ts +20 -3
  52. package/dist/login-controller.js +71 -19
  53. package/dist/login-controller.js.map +1 -1
  54. package/dist/login-flows.d.ts +5 -2
  55. package/dist/login-flows.js +5 -4
  56. package/dist/login-flows.js.map +1 -1
  57. package/dist/memory-pressure.d.ts +13 -1
  58. package/dist/memory-pressure.js +50 -10
  59. package/dist/memory-pressure.js.map +1 -1
  60. package/dist/outbound-handlers.d.ts +63 -0
  61. package/dist/outbound-handlers.js +121 -46
  62. package/dist/outbound-handlers.js.map +1 -1
  63. package/dist/outbound-schemas.d.ts +3 -2
  64. package/dist/outbound-schemas.js +3 -1
  65. package/dist/outbound-schemas.js.map +1 -1
  66. package/dist/public-web-gateway.js +8 -5
  67. package/dist/public-web-gateway.js.map +1 -1
  68. package/dist/settings-api.js +10 -4
  69. package/dist/settings-api.js.map +1 -1
  70. package/dist/settings-change.js +2 -2
  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/tips.js +1 -1
  77. package/dist/tips.js.map +1 -1
  78. package/dist/topic-commands.js +9 -2
  79. package/dist/topic-commands.js.map +1 -1
  80. package/dist/transcript-delta-reader.d.ts +3 -3
  81. package/dist/transcript-delta-reader.js +8 -8
  82. package/dist/transcript-delta-reader.js.map +1 -1
  83. package/dist/turn-resume.d.ts +2 -0
  84. package/dist/turn-resume.js +1 -1
  85. package/dist/turn-resume.js.map +1 -1
  86. package/dist/types.d.ts +2 -0
  87. package/dist/ui/chat-mentions.js +63 -0
  88. package/dist/ui/chat-render.js +25 -0
  89. package/dist/ui/chat-store.js +1 -1
  90. package/dist/ui/chat-strings.js +12 -12
  91. package/dist/ui/chat-thread.js +48 -11
  92. package/dist/ui/panel-chat.js +20 -9
  93. package/dist/ui/panel-details.js +2 -0
  94. package/dist/ui/panel-fleet.js +51 -22
  95. package/dist/ui/panel-settings.js +18 -9
  96. package/dist/ui/preview-panel.js +48 -14
  97. package/dist/ui/preview.js +5 -1
  98. package/dist/ui/settings-strings.js +6 -2
  99. package/dist/ui/settings-token.js +1 -1
  100. package/dist/ui/shared/app-i18n.js +35 -13
  101. package/dist/ui/shared/app-prefs.js +249 -0
  102. package/dist/ui/shared/app-shell.js +17 -4
  103. package/dist/ui/shared/app-store.js +5 -1
  104. package/dist/ui/shared/app.css +41 -7
  105. package/dist/ui/shared/app.js +6 -1
  106. package/dist/ui/shared/header-tools.js +55 -5
  107. package/dist/ui/shared/instance-nav.js +131 -38
  108. package/dist/ui/shared/panel-view.js +6 -0
  109. package/dist/ui/shared/theme.js +39 -7
  110. package/dist/ui/shared/ui-icons.js +1 -0
  111. package/dist/ui/shared/usage-dialog.js +13 -4
  112. package/dist/ui/shared/view-strings.js +26 -2
  113. package/dist/unrecognised-prompt.d.ts +23 -0
  114. package/dist/unrecognised-prompt.js +74 -0
  115. package/dist/unrecognised-prompt.js.map +1 -0
  116. package/dist/view-api.js +5 -0
  117. package/dist/view-api.js.map +1 -1
  118. package/dist/web-api.d.ts +13 -1
  119. package/dist/web-api.js +218 -18
  120. package/dist/web-api.js.map +1 -1
  121. package/dist/web-auth.js +3 -2
  122. package/dist/web-auth.js.map +1 -1
  123. package/dist/web-mentions.d.ts +44 -0
  124. package/dist/web-mentions.js +75 -0
  125. package/dist/web-mentions.js.map +1 -0
  126. package/dist/web-prefs.d.ts +63 -0
  127. package/dist/web-prefs.js +263 -0
  128. package/dist/web-prefs.js.map +1 -0
  129. package/dist/web-session.js +1 -1
  130. package/dist/web-session.js.map +1 -1
  131. 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
@@ -86,8 +88,10 @@ const TRANSCRIPT_RUNNING_HOLD_MAX_MS = 30 * 60_000;
86
88
  export const TRANSCRIPT_OPEN_TURN_QUIET_MS = 15 * 60_000;
87
89
  const REPLY_RECOVERY_PROMPT = "[system:reply-required] The previous human-facing turn ended without a successfully delivered reply. Do not redo the work. React with an emoji or use the reply tool exactly once now to send the user a concise conclusion. If no substantive answer is needed, a brief react is sufficient. If you already replied to this message, do nothing. Do not reply to this system instruction except through the react or reply tool.";
88
90
  /** Point a resumed CLI at its one backend-native instruction source. */
89
- /** The outbox evidence for a delivery the CLI's transcript shows taken (#758; #1201 absorbed). */
90
- function transcriptMarkerEvidence(kind) {
91
+ /** The outbox evidence for a delivery the CLI's transcript shows taken (#758; #1201 absorbed; #1647 a raw paste's text). */
92
+ function transcriptMarkerEvidence(kind, rawPaste = false) {
93
+ if (rawPaste)
94
+ return "transcript-turn-user-entry-raw-paste-text";
91
95
  return kind === "user" ? "transcript-marker" : kind === "absorbed" ? "transcript-marker-absorbed" : "transcript-marker-queued";
92
96
  }
93
97
  export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir, backendSource) {
@@ -479,6 +483,16 @@ const POST_ENTER_PROOF_WINDOW_MS = 3_000;
479
483
  /** A final bounded observation before an ambiguous Codex submit can be classified. */
480
484
  const CODEX_LATE_PROOF_MS = 5_000;
481
485
  const POST_ENTER_PROOF_POLL_MS = 250;
486
+ /** #1647: a raw paste a pane with no transcript showed taken — its text left the input row (or the CLI's own busy marker
487
+ * replaced it), with output after the Enter. */
488
+ const RAW_PASTE_LEFT_INPUT = "raw-paste-left-input-after-output";
489
+ /**
490
+ * #1647/#1663 P1-2: backends whose transcript AgEnD holds for this instance alone, so a raw paste's exact text in it is
491
+ * this paste's turn. claude-code: the path is its own statusline.json's transcript_path (written by this instance's
492
+ * CLI). Not codex: its rollout is chosen by working directory, which another instance can share, and AgEnD holds no
493
+ * session id to bind it — so a codex raw paste the pane could not prove stays uncertain.
494
+ */
495
+ const RAW_TEXT_TRANSCRIPT_BACKENDS = ["claude-code"];
482
496
  /**
483
497
  * A readerless CLI's delivery is proven by its own transcript, which lags the pane by a flush. Only a delta that was
484
498
  * read and carried no marker for this long is held against the delivery — a late flush must not raise a false ⚠️.
@@ -510,8 +524,16 @@ const DIALOG_PROBE_UNKNOWN_MAX = 40;
510
524
  /** Startup dialog scan: poll cadence and wall-clock budget (keys and waits included). */
511
525
  const STARTUP_DIALOG_POLL_MS = 500;
512
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;
513
531
  /** A blocking dialog still on screen this long after first being seen is reported for a human. */
514
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;
515
537
  /** After a runtime dialog's keys, how long the pane gets to repaint before it is read again to see whether they took. */
516
538
  const DIALOG_ANSWER_SETTLE_MS = 300;
517
539
  /** Consecutive answers one runtime dialog ignored (still on screen right after the keys) before it is reported for a human. */
@@ -1151,6 +1173,34 @@ export class Daemon extends EventEmitter {
1151
1173
  dialogParkedKey = null;
1152
1174
  dialogParkedInteractionEpisode = null;
1153
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;
1154
1204
  /**
1155
1205
  * The runtime dialog the monitor is answering, and how many of its answers in a row did nothing (the dialog was
1156
1206
  * still on screen right after the keys). Not the on-screen duration: a burst of FRESH prompts, each answered, has a
@@ -1870,8 +1920,14 @@ export class Daemon extends EventEmitter {
1870
1920
  this.finishDurableDelivery(delivery, "delivered", OUTPUT_EDGE_ONLY_EVIDENCE);
1871
1921
  return;
1872
1922
  }
1923
+ // #1647: a raw paste on a pane with no transcript (kiro), taken by the CLI as its own input row shows.
1924
+ if (verdict.proof === RAW_PASTE_LEFT_INPUT) {
1925
+ this.finishDurableDelivery(delivery, "delivered", verdict.proof, verdict);
1926
+ return;
1927
+ }
1873
1928
  this.finishDurableDelivery(delivery, "delivered", verdict.transcriptProof
1874
- ? `positive submission proof; ${transcriptMarkerEvidence(verdict.transcriptProof)}` : "positive submission proof", verdict);
1929
+ ? `positive submission proof; ${transcriptMarkerEvidence(verdict.transcriptProof, verdict.submissionMode === "raw_paste")}`
1930
+ : "positive submission proof", verdict);
1875
1931
  }
1876
1932
  /**
1877
1933
  * Finish a delivery that only the output edge vouched for, by the delivery's own marker in the CLI's transcript (#758).
@@ -1887,23 +1943,28 @@ export class Daemon extends EventEmitter {
1887
1943
  * TRANSCRIPT_PROOF_WINDOW_MS). It never finishes a row that stop has handed to reconciliation; a pause or respawn
1888
1944
  * only ends the wait, falling back to today's outcome.
1889
1945
  */
1890
- async proveDeliveryFromTranscript(delivery, checkpoint) {
1946
+ async proveDeliveryFromTranscript(delivery, checkpoint, rawPaste) {
1891
1947
  const spawn = this.spawnGeneration;
1892
1948
  const fence = this.launchFenceEpoch;
1893
1949
  let last = "unavailable";
1894
1950
  let outcome = "delivered";
1895
1951
  let evidence = OUTPUT_EDGE_ONLY_EVIDENCE;
1952
+ // #1647: a raw paste has no marker; its text is the needle, and its pane proof already failed — so without the turn
1953
+ // in the transcript it stays uncertain (with the pane's reason), never the output-edge outcome.
1954
+ const needle = rawPaste ? { rawText: rawPaste.text } : delivery.deliveryId;
1955
+ let found = false;
1896
1956
  try {
1897
1957
  for (let polls = 0; polls <= TRANSCRIPT_PROOF_WINDOW_MS / TRANSCRIPT_PROOF_POLL_MS; polls++) {
1898
1958
  if (this.deliveryWritesStopping)
1899
1959
  return; // reconciliation owns the row now
1900
- const found = await scanTranscriptForDeliveryMarker(checkpoint.path, checkpoint.offset, checkpoint.backend, delivery.deliveryId);
1901
- if (found === "user" || found === "absorbed" || found === "queued") {
1960
+ const look = await scanTranscriptForDeliveryMarker(checkpoint.path, checkpoint.offset, checkpoint.backend, needle);
1961
+ if (look === "user" || look === "absorbed" || look === "queued") {
1962
+ found = true;
1902
1963
  outcome = "delivered";
1903
- evidence = transcriptMarkerEvidence(found);
1964
+ evidence = transcriptMarkerEvidence(look, !!rawPaste);
1904
1965
  break;
1905
1966
  }
1906
- last = found;
1967
+ last = look;
1907
1968
  if (spawn !== this.spawnGeneration || fence !== this.launchFenceEpoch) {
1908
1969
  last = "unavailable";
1909
1970
  break;
@@ -1923,6 +1984,11 @@ export class Daemon extends EventEmitter {
1923
1984
  catch (err) {
1924
1985
  this.logger.debug({ err }, "Transcript proof of a delivery failed — keeping the output-edge outcome");
1925
1986
  }
1987
+ if (rawPaste && !found) {
1988
+ outcome = "uncertain";
1989
+ evidence = `${rawPaste.unproven}; no-transcript-turn (${last})`;
1990
+ this.logger.warn({ deliveryId: delivery.deliveryId, cause: evidence }, "Raw paste outcome uncertain — not re-pasting");
1991
+ }
1926
1992
  // The settlement itself can throw (e.g. the outbox store is unhealthy), and this runs on a detached `void`
1927
1993
  // promise: an uncaught throw is an unhandled rejection, which the fleet restart path turns into a global fault.
1928
1994
  // Settle observably and leave the row for reconciliation — never a false completion, never another paste (#1207 P2).
@@ -3188,7 +3254,6 @@ export class Daemon extends EventEmitter {
3188
3254
  if (this.runtimeMonitorsFrozen || this.errorMonitorTimer)
3189
3255
  return;
3190
3256
  const patterns = this.backend?.getErrorPatterns?.() ?? [];
3191
- const dialogs = this.backend?.getRuntimeDialogs?.() ?? [];
3192
3257
  if (!this.tmux)
3193
3258
  return;
3194
3259
  if (!this.backend)
@@ -3224,6 +3289,9 @@ export class Daemon extends EventEmitter {
3224
3289
  scanStartedAt = performance.now();
3225
3290
  if (stale())
3226
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();
3227
3295
  if (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureAt) {
3228
3296
  this.unverifyInteraction(captureMono, captureOrder);
3229
3297
  }
@@ -3703,14 +3771,21 @@ export class Daemon extends EventEmitter {
3703
3771
  isDeliveryEpochCurrent(epoch) {
3704
3772
  return epoch === this.deliveryEpoch;
3705
3773
  }
3706
- 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
+ };
3707
3780
  if (!this.tmux || !this.isDeliveryEpochCurrent(deliveryEpoch)) {
3708
3781
  if (durableAttempt)
3709
3782
  this.retryDurableDeliveryBeforeBegin(durableAttempt, "raw_paste target unavailable before pane queue");
3783
+ reportSwitch({ outcome: "not_sent" });
3710
3784
  return;
3711
3785
  }
3712
3786
  this.pasteLock = this.pasteLock.then(async () => {
3713
3787
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
3788
+ reportSwitch({ outcome: "not_sent" });
3714
3789
  this.logger.info("Pending raw delivery dropped by user cancel");
3715
3790
  if (durableAttempt)
3716
3791
  this.retryDurableDeliveryBeforeBegin(durableAttempt, "raw_paste epoch changed while queued");
@@ -3722,6 +3797,7 @@ export class Daemon extends EventEmitter {
3722
3797
  if (!this.isDeliveryEpochCurrent(deliveryEpoch)) {
3723
3798
  if (durableAttempt)
3724
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
3725
3801
  return;
3726
3802
  }
3727
3803
  const delivered = await this.deliverMessage(rawText, undefined, {
@@ -3729,11 +3805,25 @@ export class Daemon extends EventEmitter {
3729
3805
  verdict,
3730
3806
  durableAttempt,
3731
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; } } : {}),
3732
3811
  });
3733
3812
  if (delivered)
3734
3813
  this.finishDurableSubmission(durableAttempt ?? null, verdict);
3735
3814
  else if (durableAttempt && verdict.paneWriteStarted) {
3736
- this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "raw-paste"}:${verdict.proof ?? "unverified"}`);
3815
+ const unproven = `${verdict.phase ?? "raw-paste"}:${verdict.proof ?? "unverified"}`;
3816
+ const checkpoint = verdict.transcriptCheckpoint;
3817
+ if (checkpoint && RAW_TEXT_TRANSCRIPT_BACKENDS.includes(checkpoint.backend)) {
3818
+ // #1647: the pane could not show it, but the CLI's transcript records the turn the paste started — later
3819
+ // than the pane window sometimes. Wait for it there (bounded), off the paste lock.
3820
+ this.logger.info({ deliveryId: durableAttempt.deliveryId, unproven }, "Raw paste not proven on the pane — asking the CLI's transcript for the turn it started");
3821
+ void this.proveDeliveryFromTranscript(durableAttempt, checkpoint, { text: rawText, unproven });
3822
+ }
3823
+ else {
3824
+ this.logger.warn({ deliveryId: durableAttempt.deliveryId, cause: unproven }, "Raw paste outcome uncertain — not re-pasting");
3825
+ this.finishDurableDelivery(durableAttempt, "uncertain", unproven);
3826
+ }
3737
3827
  }
3738
3828
  else if (durableAttempt && verdict.durableBeginCommitted) {
3739
3829
  this.abortDurableDelivery(durableAttempt, `${verdict.phase ?? "raw-paste"}:${verdict.proof ?? "no-pane-write"}`);
@@ -3750,10 +3840,31 @@ export class Daemon extends EventEmitter {
3750
3840
  else if (durableAttempt)
3751
3841
  this.retryDurableDeliveryBeforeBegin(durableAttempt, err.message);
3752
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
+ }
3753
3848
  return;
3754
3849
  }
3755
3850
  if (confirmClear)
3756
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
+ }
3757
3868
  this.logger.debug({ text: rawText.slice(0, 100) }, "Raw paste delivered");
3758
3869
  });
3759
3870
  this.pasteLock = this.pasteLock.catch(err => {
@@ -3772,7 +3883,93 @@ export class Daemon extends EventEmitter {
3772
3883
  this.deferDurableDelivery(deliveryMeta, "invalid raw_paste delivery attempt metadata");
3773
3884
  return;
3774
3885
  }
3775
- 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));
3776
3973
  }
3777
3974
  /**
3778
3975
  * Confirm a backend's terminal-level /clear prompt after the channel-level
@@ -4236,7 +4433,9 @@ export class Daemon extends EventEmitter {
4236
4433
  // Bare backend regexes are not proof of a live modal. The canonical
4237
4434
  // isActive predicate replaces pattern.test(), preserving table order.
4238
4435
  const dialog = dialogs.find(d => d.isActive && (d.inputBlocked || d.blocksDelivery || d.holdOnly)
4239
- && 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));
4240
4439
  if (dialog) {
4241
4440
  const identity = dialog.requestIdentity?.(pane) ?? pane.replace(/\r/g, "")
4242
4441
  .split("\n").map(row => row.replace(/^([ \t]*)[❯›]([ \t]+)(?=\d\.|Yes\b|No\b|Resume\b)/, "$1$2").trimEnd())
@@ -4252,7 +4451,7 @@ export class Daemon extends EventEmitter {
4252
4451
  // No captured text leaves this function or the private observer identity.
4253
4452
  return evidence ? { ...evidence, identity: createHash("sha256").update(evidence.identity).digest("hex") } : null;
4254
4453
  }
4255
- 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) {
4256
4455
  const owner = this.interactionOwner();
4257
4456
  const evidence = this.interactionEvidence(pane, dialogs);
4258
4457
  if (!this.interactionObservation.observe(evidence, owner, observedAt, monotonicAt, this.interactionOutputRevision !== this.interactionObservedOutputRevision, order))
@@ -4737,10 +4936,7 @@ export class Daemon extends EventEmitter {
4737
4936
  backend: resumeBackend ?? "unknown",
4738
4937
  cwd: resumeCwd,
4739
4938
  armedAt: Date.now(),
4740
- before: readResumeFingerprint(normalizeResumeBackend(resumeBackend), {
4741
- instanceDir: this.instanceDir,
4742
- workingDirectory: resumeCwd,
4743
- }),
4939
+ before: readResumeFingerprint(normalizeResumeBackend(resumeBackend), this.resumeRoots(resumeCwd)),
4744
4940
  });
4745
4941
  }
4746
4942
  }
@@ -4786,7 +4982,7 @@ export class Daemon extends EventEmitter {
4786
4982
  && this.spawnGeneration === gateSpawn
4787
4983
  && this.launchFenceEpoch === gateFence;
4788
4984
  const backend = this.resolveResumeBackend() ?? normalizeResumeBackend(marker.backend);
4789
- const roots = { instanceDir: this.instanceDir, workingDirectory: this.config.working_directory ?? "" };
4985
+ const roots = this.resumeRoots(this.config.working_directory ?? "");
4790
4986
  // This boot's CLI spawn is what the daemon itself caused: pre-restart
4791
4987
  // writes (including pre-crash progress) are excluded, and the flush
4792
4988
  // grace holds the decision until post-spawn writes are observable.
@@ -5515,6 +5711,8 @@ export class Daemon extends EventEmitter {
5515
5711
  return;
5516
5712
  if (!this.observeInteractionPane(pane, captureStartedAt, interactionCaptureAt, undefined, interactionCaptureOrder, reason !== "interaction_confirmation"))
5517
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);
5518
5716
  this.updateInputBlockedState(pane);
5519
5717
  // Advance the shared 10s timer only for a fresh capture of this launch.
5520
5718
  const deliveryFallback = deliveryCandidate === true
@@ -6673,6 +6871,9 @@ export class Daemon extends EventEmitter {
6673
6871
  if (residue !== "clear")
6674
6872
  return this.failDelivery(verdict, status, "input-residue", residue);
6675
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?.();
6676
6877
  // The hand-off's last capture had no box: nothing was begun or written — wait for readiness, then try again.
6677
6878
  if (written === "handoff-box-unread")
6678
6879
  return "dialog";
@@ -6720,7 +6921,148 @@ export class Daemon extends EventEmitter {
6720
6921
  * on purpose ("I trust", "Yes, continue") and would match ordinary prose.
6721
6922
  */
6722
6923
  deliveryBlockingDialogs() {
6723
- 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();
6724
7066
  }
6725
7067
  /** Passive input-unavailable phases relevant to the current spawn only. */
6726
7068
  guardedInputTransients() {
@@ -6888,7 +7230,7 @@ export class Daemon extends EventEmitter {
6888
7230
  return dialog.isActive ? dialog.isActive(pane) : dialog.pattern.test(pane);
6889
7231
  }
6890
7232
  /** Refresh the separate stdin-blocked observation from one pane capture. */
6891
- updateInputBlockedState(pane, dialogs = this.backend?.getRuntimeDialogs?.() ?? []) {
7233
+ updateInputBlockedState(pane, dialogs = this.runtimeDialogs()) {
6892
7234
  const active = dialogs.find(dialog => dialog.inputBlocked && Daemon.dialogMatches(dialog, pane)) ?? null;
6893
7235
  const nextKey = active ? Daemon.dialogKey(active) : null;
6894
7236
  if (nextKey !== this.inputBlockedDialogKey) {
@@ -6976,9 +7318,15 @@ export class Daemon extends EventEmitter {
6976
7318
  async probeBlockingDialog() {
6977
7319
  if (!this.tmux || !this.backend)
6978
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;
6979
7323
  const dialogs = this.deliveryBlockingDialogs();
6980
- 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)
6981
7328
  return { state: "clear" };
7329
+ const probeMono = performance.now();
6982
7330
  let pane;
6983
7331
  try {
6984
7332
  pane = await this.tmux.capturePane();
@@ -6987,6 +7335,7 @@ export class Daemon extends EventEmitter {
6987
7335
  this.logger.debug({ err }, "capture-pane failed during the dialog probe — pane state unknown");
6988
7336
  return { state: "unknown" };
6989
7337
  }
7338
+ this.noteFreshPaneRead(pane, probeMono);
6990
7339
  for (const dialog of dialogs) {
6991
7340
  if (!Daemon.dialogMatches(dialog, pane))
6992
7341
  continue;
@@ -7196,11 +7545,16 @@ export class Daemon extends EventEmitter {
7196
7545
  this.dialogParkedReported = false;
7197
7546
  return;
7198
7547
  }
7199
- 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)) {
7200
7553
  this.dialogParkedReported = true;
7201
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");
7202
7555
  this.emit("dialog_parked", { name: this.name, description: dialog.description, holdOnly: dialog.holdOnly === true,
7203
- ...(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 } : {}) });
7204
7558
  }
7205
7559
  }
7206
7560
  /**
@@ -7769,6 +8123,33 @@ export class Daemon extends EventEmitter {
7769
8123
  this.logger.warn("Could not read the pane after Enter — not confirming the submission");
7770
8124
  return sawOutput && residue === "absent";
7771
8125
  }
8126
+ /**
8127
+ * #1647: whether a raw paste on a pane with no transcript (kiro) was taken: its text gone from the input row (or the
8128
+ * CLI's own busy marker shown instead — pasteResidue's rule) with output after the Enter. That is the confirmation's
8129
+ * own rule, looked at more than once: kiro repaints its composer late, so the single look right after Enter misread
8130
+ * pastes it took. A generic `working` reading is not evidence (#1663 P1-3: a repaint looks like work).
8131
+ *
8132
+ * Bounded by the budget that confirmation already has, measured from the Enter (#1663 P2-1): the busy wait plus the
8133
+ * post-Enter proof window. Every reading is asked again after its await — still this write's owner (`current`: the
8134
+ * caller's fence, no stop, the same tmux and spawn, #1663 P2-2) and still inside the budget — before it counts; a late
8135
+ * or fenced answer proves nothing. Null: not shown — the caller settles uncertain, never a second Enter.
8136
+ */
8137
+ async rawPasteTakenOnPane(windowId, enterAt, enterMono, formatted, current) {
8138
+ const deadline = enterMono + CONFIRM_BUSY_POLLS * CONFIRM_BUSY_POLL_MS + POST_ENTER_PROOF_WINDOW_MS;
8139
+ const live = () => current() && performance.now() <= deadline;
8140
+ for (;;) {
8141
+ if (!live())
8142
+ return null;
8143
+ if (this.controlClient?.hasOutputSince?.(windowId, enterAt)) {
8144
+ const residue = await this.pasteResidue(formatted);
8145
+ if (!live())
8146
+ return null;
8147
+ if (residue === "absent")
8148
+ return RAW_PASTE_LEFT_INPUT;
8149
+ }
8150
+ await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
8151
+ }
8152
+ }
7772
8153
  /** Once Kiro returns to its prompt, its unique envelope id in history proves submission. */
7773
8154
  async kiroSubmissionEvidence(windowId, formatted, signature) {
7774
8155
  if (!this.tmux)
@@ -8081,9 +8462,11 @@ export class Daemon extends EventEmitter {
8081
8462
  offset: attemptEvidence.transcriptOffset,
8082
8463
  };
8083
8464
  // From here every proof of this write may also ask the CLI's transcript (the pane can lose the echo to the
8084
- // reply, or still be painting the paste) — this delivery's exact marker only.
8085
- if (["claude-code", "codex"].includes(attemptEvidence.backend)) {
8086
- signature.transcript = this.transcriptProofFor(verdict.transcriptCheckpoint, durableAttempt.deliveryId, spawnAtWrite, stillCurrent, kind => { verdict.transcriptProof = kind; });
8465
+ // reply, or still be painting the paste) — this delivery's exact marker only, or a raw paste's exact text (#1647).
8466
+ // A raw paste's text only where the transcript is this instance's alone (RAW_TEXT_TRANSCRIPT_BACKENDS).
8467
+ if (["claude-code", "codex"].includes(attemptEvidence.backend)
8468
+ && (!rawPaste || RAW_TEXT_TRANSCRIPT_BACKENDS.includes(attemptEvidence.backend))) {
8469
+ signature.transcript = this.transcriptProofFor(verdict.transcriptCheckpoint, rawPaste ? { rawText: formatted } : durableAttempt.deliveryId, spawnAtWrite, stillCurrent, kind => { verdict.transcriptProof = kind; });
8087
8470
  }
8088
8471
  }
8089
8472
  }
@@ -8174,6 +8557,7 @@ export class Daemon extends EventEmitter {
8174
8557
  steerEnterDeadline = deadline;
8175
8558
  }
8176
8559
  let enterAt = Date.now();
8560
+ const enterMono = performance.now();
8177
8561
  const steerEnterCurrent = () => steerCurrent() && performance.now() <= steerEnterDeadline;
8178
8562
  if (!(await this.sendDeliveryEnter("initial-submit", steerComposerProof ? steerEnterCurrent : undefined, durableAttempt))) {
8179
8563
  if (steerComposerProof && !steerCurrent()) {
@@ -8419,12 +8803,20 @@ export class Daemon extends EventEmitter {
8419
8803
  return { kind: "kiro-pending", windowId, spawnGeneration: this.spawnGeneration, signature };
8420
8804
  }
8421
8805
  }
8422
- if (!submitted) {
8423
- if (rawPaste) {
8806
+ if (!submitted && rawPaste) {
8807
+ // #1647: kiro paints output at once but its composer late, so one residue look right after Enter misreads a
8808
+ // paste it took. Keep looking within the confirmation's own budget from the Enter, under this write's fence;
8809
+ // never a second Enter (a raw command must not run twice).
8810
+ const taken = await this.rawPasteTakenOnPane(windowId, enterAt, enterMono, formatted, steerCurrent);
8811
+ if (!taken) {
8424
8812
  verdict.phase = "raw-paste-submit-proof";
8425
8813
  verdict.proof = "no-positive-acceptance-proof";
8426
8814
  return false;
8427
8815
  }
8816
+ verdict.proof = taken;
8817
+ submitted = true;
8818
+ }
8819
+ if (!submitted) {
8428
8820
  // Legacy system pastes have no unique envelope id. Preserve their
8429
8821
  // existing bounded retry until they can be tied to a specific turn.
8430
8822
  this.logger.warn("Message not submitted after Enter — waiting for the prompt, then re-sending Enter once");
@@ -8737,10 +9129,10 @@ export class Daemon extends EventEmitter {
8737
9129
  * A write's transcript proof: bounded looks from its checkpoint, fenced to the write — its spawn and launch, a stop,
8738
9130
  * and the caller's own fence — so a look that lands after any of them is never used (#1380 review).
8739
9131
  */
8740
- transcriptProofFor(checkpoint, deliveryId, spawnAtWrite, stillCurrent, onProof) {
9132
+ transcriptProofFor(checkpoint, needle, spawnAtWrite, stillCurrent, onProof) {
8741
9133
  const launchAtWrite = this.launchFenceEpoch;
8742
9134
  return {
8743
- reader: new TranscriptDeltaReader(checkpoint.path, checkpoint.offset, checkpoint.backend, deliveryId),
9135
+ reader: new TranscriptDeltaReader(checkpoint.path, checkpoint.offset, checkpoint.backend, needle),
8744
9136
  current: () => !this.deliveryWritesStopping && this.spawnGeneration === spawnAtWrite
8745
9137
  && this.launchFenceEpoch === launchAtWrite && (stillCurrent?.() ?? true),
8746
9138
  onProof,
@@ -10627,7 +11019,7 @@ export class Daemon extends EventEmitter {
10627
11019
  if (launchFence !== this.launchFenceEpoch)
10628
11020
  throw new Error("Launch cancelled: the instance was stopped or paused while the launch was being prepared");
10629
11021
  this.backend.writeConfig(backendConfig);
10630
- this.backend.preTrust?.(this.config.working_directory);
11022
+ this.backend.preTrust?.(this.config.working_directory, backendConfig);
10631
11023
  // Resolve working directory (e.g. symlink for hidden paths)
10632
11024
  const resolvedCwd = this.backend.resolveWorkingDirectory?.(this.config.working_directory, this.name) ?? this.config.working_directory;
10633
11025
  // Generate a fresh per-instance agent token each spawn. agent-cli reads
@@ -11267,6 +11659,14 @@ export class Daemon extends EventEmitter {
11267
11659
  }, "Snapshot written");
11268
11660
  return snapshot;
11269
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
+ }
11270
11670
  /**
11271
11671
  * The credential profile's store this instance's CLI writes to, if any.
11272
11672
  *