@songsid/agend 2.1.12-beta.9 → 2.2.0-alpha.1
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/README.md +2 -2
- package/README.zh-TW.md +2 -2
- package/dist/auth/one-time-code.d.ts +19 -0
- package/dist/auth/one-time-code.js +49 -0
- package/dist/auth/one-time-code.js.map +1 -0
- package/dist/auth-api.d.ts +52 -0
- package/dist/auth-api.js +346 -0
- package/dist/auth-api.js.map +1 -0
- package/dist/backend/claude-code.d.ts +4 -0
- package/dist/backend/claude-code.js +97 -0
- package/dist/backend/claude-code.js.map +1 -1
- package/dist/backend/codex.js +59 -30
- package/dist/backend/codex.js.map +1 -1
- package/dist/backend/kiro-agent.d.ts +66 -0
- package/dist/backend/kiro-agent.js +302 -0
- package/dist/backend/kiro-agent.js.map +1 -0
- package/dist/backend/kiro-identity.d.ts +80 -0
- package/dist/backend/kiro-identity.js +537 -0
- package/dist/backend/kiro-identity.js.map +1 -0
- package/dist/backend/kiro-v2-store-worker.d.ts +1 -0
- package/dist/backend/kiro-v2-store-worker.js +87 -0
- package/dist/backend/kiro-v2-store-worker.js.map +1 -0
- package/dist/backend/kiro-v2-store.d.ts +54 -0
- package/dist/backend/kiro-v2-store.js +131 -0
- package/dist/backend/kiro-v2-store.js.map +1 -0
- package/dist/backend/kiro-v3-identity.d.ts +5 -0
- package/dist/backend/kiro-v3-identity.js +22 -0
- package/dist/backend/kiro-v3-identity.js.map +1 -1
- package/dist/backend/kiro.d.ts +83 -7
- package/dist/backend/kiro.js +418 -91
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/types.d.ts +45 -3
- package/dist/backend/types.js.map +1 -1
- package/dist/channel/adapters/discord.d.ts +1 -0
- package/dist/channel/adapters/discord.js +29 -10
- package/dist/channel/adapters/discord.js.map +1 -1
- package/dist/channel/adapters/telegram.d.ts +1 -0
- package/dist/channel/adapters/telegram.js +13 -1
- package/dist/channel/adapters/telegram.js.map +1 -1
- package/dist/channel/types.d.ts +9 -1
- package/dist/classic-channel-manager.d.ts +18 -10
- package/dist/classic-channel-manager.js +45 -18
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/cli.js +225 -127
- package/dist/cli.js.map +1 -1
- package/dist/command-table.d.ts +5 -3
- package/dist/command-table.js +29 -27
- package/dist/command-table.js.map +1 -1
- package/dist/config-validator.js +46 -0
- package/dist/config-validator.js.map +1 -1
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -1
- package/dist/connection-secrets.d.ts +6 -0
- package/dist/connection-secrets.js +4 -0
- package/dist/connection-secrets.js.map +1 -1
- package/dist/daemon.d.ts +89 -4
- package/dist/daemon.js +661 -77
- package/dist/daemon.js.map +1 -1
- package/dist/darwin-memory.d.ts +6 -1
- package/dist/darwin-memory.js +17 -6
- package/dist/darwin-memory.js.map +1 -1
- package/dist/delivery-consumed-watch.d.ts +85 -0
- package/dist/delivery-consumed-watch.js +244 -0
- package/dist/delivery-consumed-watch.js.map +1 -0
- package/dist/delivery-outbox.d.ts +61 -0
- package/dist/delivery-outbox.js +120 -2
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/delivery-reconciliation.d.ts +14 -5
- package/dist/delivery-reconciliation.js +27 -4
- package/dist/delivery-reconciliation.js.map +1 -1
- package/dist/delivery-reconciliation.test.js +35 -1
- package/dist/delivery-reconciliation.test.js.map +1 -1
- package/dist/event-loop-watch.d.ts +28 -0
- package/dist/event-loop-watch.js +75 -3
- package/dist/event-loop-watch.js.map +1 -1
- package/dist/fleet-context.d.ts +22 -2
- package/dist/fleet-level-config.d.ts +2 -2
- package/dist/fleet-level-config.js +6 -1
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +293 -12
- package/dist/fleet-manager.js +2364 -449
- package/dist/fleet-manager.js.map +1 -1
- package/dist/host-memory.d.ts +4 -0
- package/dist/host-memory.js.map +1 -1
- package/dist/instance-config-impact.js +5 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-lifecycle.d.ts +10 -5
- package/dist/instance-lifecycle.js +162 -53
- package/dist/instance-lifecycle.js.map +1 -1
- package/dist/locale.js +150 -16
- package/dist/locale.js.map +1 -1
- package/dist/login-controller.d.ts +2 -0
- package/dist/login-controller.js +17 -5
- package/dist/login-controller.js.map +1 -1
- package/dist/memory-pressure.d.ts +7 -8
- package/dist/memory-pressure.js +76 -15
- package/dist/memory-pressure.js.map +1 -1
- package/dist/monotonic-budget.d.ts +2 -0
- package/dist/monotonic-budget.js +28 -0
- package/dist/monotonic-budget.js.map +1 -0
- package/dist/needs-you-hub.d.ts +114 -0
- package/dist/needs-you-hub.js +370 -0
- package/dist/needs-you-hub.js.map +1 -0
- package/dist/needs-you-live.d.ts +116 -0
- package/dist/needs-you-live.js +228 -0
- package/dist/needs-you-live.js.map +1 -0
- package/dist/needs-you.d.ts +132 -0
- package/dist/needs-you.js +164 -0
- package/dist/needs-you.js.map +1 -0
- package/dist/node-version-guard.d.ts +30 -0
- package/dist/node-version-guard.js +50 -0
- package/dist/node-version-guard.js.map +1 -0
- package/dist/operator-control-paths.d.ts +4 -0
- package/dist/operator-control-paths.js +24 -0
- package/dist/operator-control-paths.js.map +1 -0
- package/dist/outbound-handlers.d.ts +2 -0
- package/dist/outbound-handlers.js +9 -2
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +5 -5
- package/dist/profile-control.js +4 -20
- package/dist/profile-control.js.map +1 -1
- package/dist/public-web-gateway.d.ts +18 -0
- package/dist/public-web-gateway.js +141 -0
- package/dist/public-web-gateway.js.map +1 -0
- package/dist/public-web-link.d.ts +53 -0
- package/dist/public-web-link.js +175 -0
- package/dist/public-web-link.js.map +1 -0
- package/dist/quickstart-api.d.ts +8 -1
- package/dist/quickstart-api.js +86 -48
- package/dist/quickstart-api.js.map +1 -1
- package/dist/quickstart.d.ts +1 -0
- package/dist/quickstart.js +6 -2
- package/dist/quickstart.js.map +1 -1
- package/dist/scheduler/db.d.ts +7 -1
- package/dist/scheduler/db.js +38 -0
- package/dist/scheduler/db.js.map +1 -1
- package/dist/scheduler/index.d.ts +1 -1
- package/dist/scheduler/retry-label.d.ts +11 -0
- package/dist/scheduler/retry-label.js +24 -0
- package/dist/scheduler/retry-label.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +55 -2
- package/dist/scheduler/scheduler.js +220 -8
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/scheduler/types.d.ts +22 -0
- package/dist/scheduler/types.js.map +1 -1
- package/dist/secret-store.d.ts +5 -0
- package/dist/secret-store.js +20 -0
- package/dist/secret-store.js.map +1 -1
- package/dist/service-activation.d.ts +110 -0
- package/dist/service-activation.js +302 -0
- package/dist/service-activation.js.map +1 -0
- package/dist/service-installer.d.ts +7 -0
- package/dist/service-installer.js +7 -4
- package/dist/service-installer.js.map +1 -1
- package/dist/service-restart-selection.d.ts +6 -5
- package/dist/service-restart-selection.js +6 -5
- package/dist/service-restart-selection.js.map +1 -1
- package/dist/settings-api.d.ts +10 -2
- package/dist/settings-api.js +175 -72
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-baseline.d.ts +20 -0
- package/dist/settings-baseline.js +104 -0
- package/dist/settings-baseline.js.map +1 -0
- package/dist/settings-change.d.ts +21 -0
- package/dist/settings-change.js +133 -0
- package/dist/settings-change.js.map +1 -0
- package/dist/settings-confirmation.d.ts +105 -0
- package/dist/settings-confirmation.js +232 -0
- package/dist/settings-confirmation.js.map +1 -0
- package/dist/settings-control.d.ts +41 -0
- package/dist/settings-control.js +294 -0
- package/dist/settings-control.js.map +1 -0
- package/dist/settings-effect.d.ts +24 -0
- package/dist/settings-effect.js +169 -0
- package/dist/settings-effect.js.map +1 -0
- package/dist/settings-http-confirmation.d.ts +29 -0
- package/dist/settings-http-confirmation.js +123 -0
- package/dist/settings-http-confirmation.js.map +1 -0
- package/dist/settings-instance-patch.d.ts +4 -0
- package/dist/settings-instance-patch.js +26 -0
- package/dist/settings-instance-patch.js.map +1 -0
- package/dist/settings-request-capability.d.ts +16 -0
- package/dist/settings-request-capability.js +32 -0
- package/dist/settings-request-capability.js.map +1 -0
- package/dist/settings-transaction.d.ts +65 -0
- package/dist/settings-transaction.js +223 -0
- package/dist/settings-transaction.js.map +1 -0
- package/dist/setup-auth.d.ts +10 -18
- package/dist/setup-auth.js +14 -38
- package/dist/setup-auth.js.map +1 -1
- package/dist/setup-form.d.ts +1 -1
- package/dist/setup-form.js +75 -18
- package/dist/setup-form.js.map +1 -1
- package/dist/setup-guide.d.ts +13 -0
- package/dist/setup-guide.js +25 -0
- package/dist/setup-guide.js.map +1 -0
- package/dist/setup-host.d.ts +16 -0
- package/dist/setup-host.js +197 -32
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +3 -0
- package/dist/setup-wizard.js.map +1 -1
- package/dist/slash-authz.d.ts +17 -3
- package/dist/slash-authz.js +5 -0
- package/dist/slash-authz.js.map +1 -1
- package/dist/spawn-gate.js +3 -3
- package/dist/spawn-gate.js.map +1 -1
- package/dist/statusline-watcher.d.ts +5 -0
- package/dist/statusline-watcher.js +2 -0
- package/dist/statusline-watcher.js.map +1 -1
- package/dist/steer-capability.d.ts +6 -0
- package/dist/steer-capability.js +8 -0
- package/dist/steer-capability.js.map +1 -1
- package/dist/sync-work-attribution.d.ts +14 -0
- package/dist/sync-work-attribution.js +43 -8
- package/dist/sync-work-attribution.js.map +1 -1
- package/dist/tips.js +10 -5
- package/dist/tips.js.map +1 -1
- package/dist/tmux-control.d.ts +35 -5
- package/dist/tmux-control.js +291 -57
- package/dist/tmux-control.js.map +1 -1
- package/dist/tmux-manager.d.ts +20 -6
- package/dist/tmux-manager.js +90 -47
- package/dist/tmux-manager.js.map +1 -1
- package/dist/tmux-read.d.ts +59 -0
- package/dist/tmux-read.js +213 -0
- package/dist/tmux-read.js.map +1 -0
- package/dist/topic-commands.d.ts +21 -10
- package/dist/topic-commands.js +191 -58
- package/dist/topic-commands.js.map +1 -1
- package/dist/transcript-delta-reader.js +2 -1
- package/dist/transcript-delta-reader.js.map +1 -1
- package/dist/tunnel/cloudflared-install.d.ts +2 -0
- package/dist/tunnel/cloudflared-install.js +1 -1
- package/dist/tunnel/cloudflared-install.js.map +1 -1
- package/dist/tunnel/purpose-lane.d.ts +14 -0
- package/dist/tunnel/purpose-lane.js +43 -0
- package/dist/tunnel/purpose-lane.js.map +1 -0
- package/dist/turn-reply-guard.d.ts +2 -0
- package/dist/turn-reply-guard.js +10 -1
- package/dist/turn-reply-guard.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/ui/app.html +18 -0
- package/dist/ui/chat-render.js +478 -0
- package/dist/ui/chat-store.js +289 -0
- package/dist/ui/chat-strings.js +69 -0
- package/dist/ui/chat-thread.js +209 -0
- package/dist/ui/chat-tour.js +116 -0
- package/dist/ui/fleet-org.js +142 -0
- package/dist/ui/panel-chat.js +418 -0
- package/dist/ui/panel-fleet.js +413 -0
- package/dist/ui/panel-needs.js +202 -0
- package/dist/ui/panel-settings.js +750 -0
- package/dist/ui/preview.js +266 -0
- package/dist/ui/settings-apply.js +239 -0
- package/dist/ui/settings-confirm.js +186 -0
- package/dist/ui/settings-dialogs.js +546 -0
- package/dist/ui/settings-model.js +277 -0
- package/dist/ui/settings-strings.js +652 -0
- package/dist/ui/settings-wizard.js +140 -0
- package/dist/ui/shared/agend-auth.js +93 -0
- package/dist/ui/shared/app-ctx.js +80 -0
- package/dist/ui/shared/app-html.js +9 -0
- package/dist/ui/shared/app-i18n.js +95 -0
- package/dist/ui/shared/app-nav.js +73 -0
- package/dist/ui/shared/app-needs.js +114 -0
- package/dist/ui/shared/app-route.js +73 -0
- package/dist/ui/shared/app-session.js +66 -0
- package/dist/ui/shared/app-shell.js +344 -0
- package/dist/ui/shared/app-store.js +51 -0
- package/dist/ui/shared/app-stream.js +201 -0
- package/dist/ui/shared/app.css +676 -0
- package/dist/ui/shared/app.js +88 -0
- package/dist/ui/shared/fonts/OFL.txt +92 -0
- package/dist/ui/shared/fonts/README.md +21 -0
- package/dist/ui/shared/fonts/inter.woff2 +0 -0
- package/dist/ui/shared/htm.module.js +1 -0
- package/dist/ui/shared/panel-view.js +655 -0
- package/dist/ui/shared/preact-hooks.module.js +2 -0
- package/dist/ui/shared/preact.module.js +2 -0
- package/dist/ui/shared/signin.js +130 -0
- package/dist/ui/shared/theme.js +29 -0
- package/dist/ui/shared/tokens.css +153 -0
- package/dist/ui/shared/ui-confirm.js +43 -0
- package/dist/ui/shared/ui-dialog.js +45 -0
- package/dist/ui/shared/ui-icons.js +61 -0
- package/dist/ui/shared/ui-menu.js +39 -0
- package/dist/ui/shared/ui-states.js +20 -0
- package/dist/ui/shared/ui-toast.js +18 -0
- package/dist/ui/shared/vendor/htm.LICENSE +202 -0
- package/dist/ui/shared/vendor/preact.LICENSE +21 -0
- package/dist/ui/shared/vendor/vendor.json +39 -0
- package/dist/ui/shared/view-strings.js +309 -0
- package/dist/ui/signin.html +51 -0
- package/dist/update-check.d.ts +6 -2
- package/dist/update-check.js +12 -3
- package/dist/update-check.js.map +1 -1
- package/dist/update-install.d.ts +71 -0
- package/dist/update-install.js +162 -0
- package/dist/update-install.js.map +1 -0
- package/dist/upgrade-notices.d.ts +17 -0
- package/dist/upgrade-notices.js +93 -0
- package/dist/upgrade-notices.js.map +1 -0
- package/dist/usage/statusline-usage.d.ts +2 -0
- package/dist/usage/statusline-usage.js +14 -10
- package/dist/usage/statusline-usage.js.map +1 -1
- package/dist/view-api.d.ts +6 -1
- package/dist/view-api.js +62 -51
- package/dist/view-api.js.map +1 -1
- package/dist/web-api.d.ts +71 -14
- package/dist/web-api.js +492 -77
- package/dist/web-api.js.map +1 -1
- package/dist/web-auth.d.ts +71 -27
- package/dist/web-auth.js +169 -69
- package/dist/web-auth.js.map +1 -1
- package/dist/web-body.d.ts +3 -0
- package/dist/web-body.js +55 -0
- package/dist/web-body.js.map +1 -0
- package/dist/web-channel-echo.d.ts +9 -0
- package/dist/web-channel-echo.js +55 -0
- package/dist/web-channel-echo.js.map +1 -0
- package/dist/web-chat-history.d.ts +125 -0
- package/dist/web-chat-history.js +184 -0
- package/dist/web-chat-history.js.map +1 -0
- package/dist/web-continuation.d.ts +7 -0
- package/dist/web-continuation.js +22 -0
- package/dist/web-continuation.js.map +1 -0
- package/dist/web-host-guard.d.ts +34 -0
- package/dist/web-host-guard.js +57 -1
- package/dist/web-host-guard.js.map +1 -1
- package/dist/web-login.d.ts +72 -0
- package/dist/web-login.js +132 -0
- package/dist/web-login.js.map +1 -0
- package/dist/web-org.d.ts +55 -0
- package/dist/web-org.js +67 -0
- package/dist/web-org.js.map +1 -0
- package/dist/web-preview.d.ts +77 -0
- package/dist/web-preview.js +240 -0
- package/dist/web-preview.js.map +1 -0
- package/dist/web-request-context.d.ts +10 -0
- package/dist/web-request-context.js +13 -0
- package/dist/web-request-context.js.map +1 -0
- package/dist/web-session.d.ts +167 -0
- package/dist/web-session.js +389 -0
- package/dist/web-session.js.map +1 -0
- package/dist/web-shell-routes.d.ts +59 -0
- package/dist/web-shell-routes.js +96 -0
- package/dist/web-shell-routes.js.map +1 -0
- package/dist/web-upload.d.ts +183 -0
- package/dist/web-upload.js +468 -0
- package/dist/web-upload.js.map +1 -0
- package/dist/website-tips-renderer.d.ts +2 -2
- package/dist/website-tips-renderer.js +3 -3
- package/dist/website-tips-renderer.js.map +1 -1
- package/package.json +8 -5
- package/scripts/preinstall-guard.cjs +36 -0
- package/dist/ui/dashboard.html +0 -774
- package/dist/ui/settings.html +0 -2165
- package/dist/ui/view.html +0 -1139
package/dist/daemon.js
CHANGED
|
@@ -35,8 +35,10 @@ import { TurnReplyGuard } from "./turn-reply-guard.js";
|
|
|
35
35
|
import { t } from "./locale.js";
|
|
36
36
|
import { MuseUsageRelay, clearMuseUsageSnapshot } from "./muse-usage-relay.js";
|
|
37
37
|
import { deliveryContentDigest } from "./delivery-outbox.js";
|
|
38
|
+
import { consumedWatches } from "./delivery-consumed-watch.js";
|
|
38
39
|
import { queueResumePolicyForAttempt } from "./delivery-queue-evidence.js";
|
|
39
40
|
import { scanTranscriptForDeliveryMarker } from "./delivery-reconciliation.js";
|
|
41
|
+
import { measureSyncWork, noteSyncWork } from "./sync-work-attribution.js";
|
|
40
42
|
import { TranscriptDeltaReader } from "./transcript-delta-reader.js";
|
|
41
43
|
import { buildResumeContinuation, clearInFlightTurnMarker, consumeInFlightTurnMarker, decideTurnResume, normalizeResumeBackend, readResumeFingerprint, RESUME_BLOCKING_DELIVERY_STATES, writeInFlightTurnMarker, } from "./turn-resume.js";
|
|
42
44
|
import { codexLiveRowMatches } from "./backend/codex.js";
|
|
@@ -69,12 +71,12 @@ const REPLY_DROP_WARNING_COOLDOWN_MS = 5 * 60_000;
|
|
|
69
71
|
const REPLY_GUARD_IDLE_CONFIRM_MS = 60_000;
|
|
70
72
|
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.";
|
|
71
73
|
/** Point a resumed CLI at its one backend-native instruction source. */
|
|
72
|
-
/** The outbox evidence for a delivery the CLI's transcript shows taken (#758). */
|
|
74
|
+
/** The outbox evidence for a delivery the CLI's transcript shows taken (#758; #1201 absorbed). */
|
|
73
75
|
function transcriptMarkerEvidence(kind) {
|
|
74
|
-
return kind === "user" ? "transcript-marker" : "transcript-marker-queued";
|
|
76
|
+
return kind === "user" ? "transcript-marker" : kind === "absorbed" ? "transcript-marker-absorbed" : "transcript-marker-queued";
|
|
75
77
|
}
|
|
76
|
-
export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir) {
|
|
77
|
-
const source = binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
|
|
78
|
+
export function buildInstructionReloadNotice(binaryName, instanceName, instanceDir, backendSource) {
|
|
79
|
+
const source = backendSource ? backendSource : binaryName === "codex" || binaryName === "grok" || binaryName === "muse"
|
|
78
80
|
? "AGENTS.md"
|
|
79
81
|
: binaryName === "kiro-cli"
|
|
80
82
|
? `.kiro/steering/agend-${instanceName}.md`
|
|
@@ -597,11 +599,15 @@ const INPUT_RESIDUE_POLL_MS = 100;
|
|
|
597
599
|
/** #829: how many of the latest pastes can prove a draft is AgEnD's own. */
|
|
598
600
|
const RECENT_PASTES_KEPT = 8;
|
|
599
601
|
/** Build a status holder from channel metadata, keeping chat and thread apart. */
|
|
600
|
-
function channelStatus(meta) {
|
|
602
|
+
export function channelStatus(meta) {
|
|
601
603
|
const chatId = meta.chat_id;
|
|
602
604
|
const messageId = meta.message_id;
|
|
603
|
-
|
|
605
|
+
// A web chat message has no platform chat when the fleet has no channel, and still gets its reports:
|
|
606
|
+
// they are the dashboard's ticks (the fleet routes them by the message id, never to a platform).
|
|
607
|
+
if (!messageId || (!chatId && meta.source !== "web"))
|
|
604
608
|
return undefined;
|
|
609
|
+
if (!chatId)
|
|
610
|
+
return { chatId: "", messageId };
|
|
605
611
|
const threadId = meta.thread_id || undefined;
|
|
606
612
|
return threadId ? { chatId, messageId, threadId } : { chatId, messageId };
|
|
607
613
|
}
|
|
@@ -609,6 +615,26 @@ const FIRST_ENTER_SETTLE_MS = 1_750;
|
|
|
609
615
|
const FIRST_DELIVERY_WINDOW_MS = 5_000;
|
|
610
616
|
/** After busy native-queue paste+Enter, wait before checking the pane for silent loss. */
|
|
611
617
|
const NATIVE_QUEUE_PASTE_VERIFY_MS = 2_000;
|
|
618
|
+
/** #1405: how long a steer's paste gets to show in a mode-switched composer before its one Enter (captures included). */
|
|
619
|
+
const STEER_PASTE_VISIBLE_MS = 2_000;
|
|
620
|
+
/** `promise`'s value if it settles within `ms`, else undefined — the late answer is dropped, never awaited (#1405). */
|
|
621
|
+
async function withinMs(promise, ms) {
|
|
622
|
+
if (ms <= 0) {
|
|
623
|
+
promise.catch(() => { });
|
|
624
|
+
return undefined;
|
|
625
|
+
}
|
|
626
|
+
let timer;
|
|
627
|
+
try {
|
|
628
|
+
return await Promise.race([promise, new Promise(resolve => { timer = setTimeout(() => resolve(undefined), ms); })]);
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return undefined;
|
|
632
|
+
}
|
|
633
|
+
finally {
|
|
634
|
+
if (timer)
|
|
635
|
+
clearTimeout(timer);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
612
638
|
/** Enter-confirmation poll: 10 × 200ms ≈ 2s of observed silence before giving up. */
|
|
613
639
|
const CONFIRM_BUSY_POLLS = 10;
|
|
614
640
|
const CONFIRM_BUSY_POLL_MS = 200;
|
|
@@ -1732,7 +1758,7 @@ export class Daemon extends EventEmitter {
|
|
|
1732
1758
|
this.emit("durable_delivery_deferred", { ...delivery, targetBootId: this.bootId, reason });
|
|
1733
1759
|
}
|
|
1734
1760
|
}
|
|
1735
|
-
finishDurableDelivery(delivery, outcome, evidence) {
|
|
1761
|
+
finishDurableDelivery(delivery, outcome, evidence, verdict) {
|
|
1736
1762
|
if (!delivery)
|
|
1737
1763
|
return;
|
|
1738
1764
|
// Delivered or uncertain means the pane write started: the work reached
|
|
@@ -1741,13 +1767,50 @@ export class Daemon extends EventEmitter {
|
|
|
1741
1767
|
if (outcome !== "failed")
|
|
1742
1768
|
this.noteWorkActivity();
|
|
1743
1769
|
this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, outcome, evidence);
|
|
1770
|
+
if (verdict && outcome !== "failed")
|
|
1771
|
+
this.startConsumedWatch(delivery, verdict);
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* #1201: a steer or a native-queue hand-off was accepted into the CLI's input (or may have been) — not read. Watch
|
|
1775
|
+
* the transcript from this attempt's checkpoint for the moment the CLI takes it, and record that on the delivery;
|
|
1776
|
+
* an `uncertain` row becomes `delivered` then. Only claude-code and codex write a transcript AgEnD can read. The
|
|
1777
|
+
* watch belongs to this CLI: a stop, pause or respawn ends it (restart reconciliation owns the row after that).
|
|
1778
|
+
*/
|
|
1779
|
+
startConsumedWatch(delivery, verdict) {
|
|
1780
|
+
const checkpoint = verdict.transcriptCheckpoint;
|
|
1781
|
+
const outbox = this.deliveryOutbox;
|
|
1782
|
+
if (!checkpoint || !outbox?.markConsumed)
|
|
1783
|
+
return;
|
|
1784
|
+
if (verdict.submissionMode !== "steer" && verdict.submissionMode !== "native_queue_handoff")
|
|
1785
|
+
return;
|
|
1786
|
+
if (!["claude-code", "codex"].includes(checkpoint.backend))
|
|
1787
|
+
return;
|
|
1788
|
+
const spawn = this.spawnGeneration, fence = this.launchFenceEpoch, boot = this.bootId;
|
|
1789
|
+
consumedWatches.start({
|
|
1790
|
+
deliveryId: delivery.deliveryId,
|
|
1791
|
+
attemptNo: delivery.attemptNo,
|
|
1792
|
+
backend: checkpoint.backend,
|
|
1793
|
+
path: checkpoint.path,
|
|
1794
|
+
offset: checkpoint.offset,
|
|
1795
|
+
current: () => !this.deliveryWritesStopping && spawn === this.spawnGeneration && fence === this.launchFenceEpoch,
|
|
1796
|
+
consumed: (via, evidence) => {
|
|
1797
|
+
try {
|
|
1798
|
+
const result = outbox.markConsumed(delivery.deliveryId, boot, delivery.attemptNo, via, evidence);
|
|
1799
|
+
this.logger.info({ deliveryId: delivery.deliveryId, via, result }, "The CLI's transcript shows the delivery consumed");
|
|
1800
|
+
}
|
|
1801
|
+
catch (err) {
|
|
1802
|
+
this.logger.warn({ err, deliveryId: delivery.deliveryId }, "Could not record the consumed signal (the row keeps its state)");
|
|
1803
|
+
}
|
|
1804
|
+
},
|
|
1805
|
+
logger: this.logger,
|
|
1806
|
+
});
|
|
1744
1807
|
}
|
|
1745
1808
|
finishDurableSubmission(delivery, verdict) {
|
|
1746
1809
|
// Legacy backends may accept a pane write without enough pane evidence to
|
|
1747
1810
|
// claim submission. Keep that row inspectable instead of recording a false
|
|
1748
1811
|
// delivered state; reconciliation can classify it in a later phase.
|
|
1749
1812
|
if (verdict.proof === "unverified") {
|
|
1750
|
-
this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified
|
|
1813
|
+
this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified`, verdict);
|
|
1751
1814
|
return;
|
|
1752
1815
|
}
|
|
1753
1816
|
// A steer into a busy pane of a CLI whose input row cannot be read: the paste and the one Enter went through and the
|
|
@@ -1755,7 +1818,14 @@ export class Daemon extends EventEmitter {
|
|
|
1755
1818
|
// it", and delivery_status carries delivery_mode=steer — and it is all this class of backend can ever show for a steer
|
|
1756
1819
|
// (#1197). Labelled, so it is never mistaken for a proof that the input row was cleared.
|
|
1757
1820
|
if (verdict.proof === "steer-marker-on-pane") {
|
|
1758
|
-
this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable");
|
|
1821
|
+
this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable", verdict);
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
// A steer into a mode-switched composer (kiro TUI, #1405): the box held the paste before the one Enter and read as
|
|
1825
|
+
// its empty placeholder after it — taken by the CLI (steered into the turn, held for its end on "queue", or a new
|
|
1826
|
+
// turn on "idle"). Accepted, not read; labelled with what the composer showed.
|
|
1827
|
+
if (verdict.proof?.startsWith("steer-composer-")) {
|
|
1828
|
+
this.finishDurableDelivery(delivery, "delivered", `steer-accepted; composer emptied (${verdict.proof.slice("steer-composer-".length)})`, verdict);
|
|
1759
1829
|
return;
|
|
1760
1830
|
}
|
|
1761
1831
|
// A backend whose input row cannot be read: all that vouched for this delivery is that the pane printed something
|
|
@@ -1771,7 +1841,7 @@ export class Daemon extends EventEmitter {
|
|
|
1771
1841
|
return;
|
|
1772
1842
|
}
|
|
1773
1843
|
this.finishDurableDelivery(delivery, "delivered", verdict.transcriptProof
|
|
1774
|
-
? `positive submission proof; ${transcriptMarkerEvidence(verdict.transcriptProof)}` : "positive submission proof");
|
|
1844
|
+
? `positive submission proof; ${transcriptMarkerEvidence(verdict.transcriptProof)}` : "positive submission proof", verdict);
|
|
1775
1845
|
}
|
|
1776
1846
|
/**
|
|
1777
1847
|
* Finish a delivery that only the output edge vouched for, by the delivery's own marker in the CLI's transcript (#758).
|
|
@@ -1798,7 +1868,7 @@ export class Daemon extends EventEmitter {
|
|
|
1798
1868
|
if (this.deliveryWritesStopping)
|
|
1799
1869
|
return; // reconciliation owns the row now
|
|
1800
1870
|
const found = await scanTranscriptForDeliveryMarker(checkpoint.path, checkpoint.offset, checkpoint.backend, delivery.deliveryId);
|
|
1801
|
-
if (found === "user" || found === "queued") {
|
|
1871
|
+
if (found === "user" || found === "absorbed" || found === "queued") {
|
|
1802
1872
|
outcome = "delivered";
|
|
1803
1873
|
evidence = transcriptMarkerEvidence(found);
|
|
1804
1874
|
break;
|
|
@@ -1861,6 +1931,14 @@ export class Daemon extends EventEmitter {
|
|
|
1861
1931
|
seedActivityNow(now = Date.now()) {
|
|
1862
1932
|
this.autoPauseController.recordActivity(now);
|
|
1863
1933
|
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Whether this launch takes a steer into a running turn, for a backend that answers per launch (kiro's TUI vs its
|
|
1936
|
+
* legacy UI, #1405); undefined for every other backend, whose name decides (steer-capability.ts). The backend reads
|
|
1937
|
+
* what it last launched — no probe, no fork, safe on the fleet loop.
|
|
1938
|
+
*/
|
|
1939
|
+
launchSupportsSteer() {
|
|
1940
|
+
return this.backend?.supportsSteer?.();
|
|
1941
|
+
}
|
|
1864
1942
|
/**
|
|
1865
1943
|
* Phase 2b (design §1.4): why this awake instance is not taking input, in
|
|
1866
1944
|
* words for a notice, or null when nothing known holds it. Read from the
|
|
@@ -1904,7 +1982,19 @@ export class Daemon extends EventEmitter {
|
|
|
1904
1982
|
this.logger.warn({ err }, "Failed to attach pipe-pane — instance output log will be missing");
|
|
1905
1983
|
});
|
|
1906
1984
|
}
|
|
1907
|
-
|
|
1985
|
+
startupAdmission;
|
|
1986
|
+
async start(admission) {
|
|
1987
|
+
this.startupAdmission = admission;
|
|
1988
|
+
try {
|
|
1989
|
+
admission?.();
|
|
1990
|
+
await this.startAdmitted();
|
|
1991
|
+
admission?.();
|
|
1992
|
+
}
|
|
1993
|
+
finally {
|
|
1994
|
+
this.startupAdmission = undefined;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
async startAdmitted() {
|
|
1908
1998
|
ensureInstanceDir(this.instanceDir);
|
|
1909
1999
|
writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
|
|
1910
2000
|
this.logger.info(`Starting ${this.name}`);
|
|
@@ -1961,6 +2051,7 @@ export class Daemon extends EventEmitter {
|
|
|
1961
2051
|
this.emit("error", err);
|
|
1962
2052
|
});
|
|
1963
2053
|
await this.ipcServer.listen();
|
|
2054
|
+
this.startupAdmission?.();
|
|
1964
2055
|
ipcListening = true;
|
|
1965
2056
|
// Permanent IPC dispatcher: routes responses to pending requests by type+id key
|
|
1966
2057
|
this.ipcServer.on("message", (msg) => this.routeFleetResponse(msg));
|
|
@@ -2032,7 +2123,8 @@ export class Daemon extends EventEmitter {
|
|
|
2032
2123
|
});
|
|
2033
2124
|
// 2. Tmux — ensure session, create window if not alive
|
|
2034
2125
|
await TmuxManager.ensureSession(this.tmuxSessionName);
|
|
2035
|
-
this.
|
|
2126
|
+
this.startupAdmission?.();
|
|
2127
|
+
this.tmux = new TmuxManager(this.tmuxSessionName, "", resolveTmuxLogicalSize(this.config.terminal), this.controlClient);
|
|
2036
2128
|
// Strategy A: always start fresh Claude window (MCP server has no reconnection)
|
|
2037
2129
|
// Kill any existing window from previous run
|
|
2038
2130
|
const windowIdFile = join(this.instanceDir, "window-id");
|
|
@@ -2041,16 +2133,21 @@ export class Daemon extends EventEmitter {
|
|
|
2041
2133
|
if (savedId) {
|
|
2042
2134
|
const oldTmux = new TmuxManager(this.tmuxSessionName, savedId);
|
|
2043
2135
|
if (await oldTmux.isWindowAlive()) {
|
|
2136
|
+
this.startupAdmission?.();
|
|
2044
2137
|
await this.checkpointSessionId();
|
|
2138
|
+
this.startupAdmission?.();
|
|
2045
2139
|
await oldTmux.killWindow();
|
|
2140
|
+
this.startupAdmission?.();
|
|
2046
2141
|
this.logger.info({ savedId }, "Killed old tmux window for fresh start");
|
|
2047
2142
|
}
|
|
2048
2143
|
}
|
|
2049
2144
|
}
|
|
2050
2145
|
const resumed = await this.spawnClaudeWindow();
|
|
2146
|
+
this.startupAdmission?.();
|
|
2051
2147
|
this.isNewSession = !resumed;
|
|
2052
2148
|
if (!resumed) {
|
|
2053
|
-
await this.injectSnapshotMessage();
|
|
2149
|
+
await this.injectSnapshotMessage(this.startupAdmission);
|
|
2150
|
+
this.startupAdmission?.();
|
|
2054
2151
|
}
|
|
2055
2152
|
else {
|
|
2056
2153
|
// Clean up stale snapshot file — resume restored full context, snapshot not needed
|
|
@@ -2074,6 +2171,7 @@ export class Daemon extends EventEmitter {
|
|
|
2074
2171
|
// we attach — pipe-pane uses `cat >>` on the same inode, so copytruncate
|
|
2075
2172
|
// keeps the writer attached after size resets.
|
|
2076
2173
|
await this.attachPipePaneLog();
|
|
2174
|
+
this.startupAdmission?.();
|
|
2077
2175
|
// 4. Transcript monitor. claude-code is handled inside the monitor
|
|
2078
2176
|
// (statusline transcript); codex/kiro/opencode read their CLI's own
|
|
2079
2177
|
// conversation store via a pluggable source. Backends with no known
|
|
@@ -2084,6 +2182,7 @@ export class Daemon extends EventEmitter {
|
|
|
2084
2182
|
// Baseline in the isolate before this daemon accepts new work. A
|
|
2085
2183
|
// stopped/replaced launch may never re-arm polling after this await.
|
|
2086
2184
|
await transcriptMonitor.initialize();
|
|
2185
|
+
this.startupAdmission?.();
|
|
2087
2186
|
if (this.startupAborted || this.launchFenceEpoch !== transcriptFence || this.transcriptMonitor !== transcriptMonitor) {
|
|
2088
2187
|
transcriptMonitor.stop();
|
|
2089
2188
|
return;
|
|
@@ -2589,7 +2688,7 @@ export class Daemon extends EventEmitter {
|
|
|
2589
2688
|
// Stop the loop permanently — otherwise every tick triggers a respawn, whose
|
|
2590
2689
|
// writeRotationSnapshot fails with ENOENT and gets caught as "Failed to respawn",
|
|
2591
2690
|
// spamming errors every ~30s forever.
|
|
2592
|
-
if (!existsSync(this.instanceDir)) {
|
|
2691
|
+
if (!measureSyncWork("daemon.healthCheck", () => existsSync(this.instanceDir))) {
|
|
2593
2692
|
this.logger.warn({ instanceDir: this.instanceDir }, "Instance directory missing — stopping health check");
|
|
2594
2693
|
this.healthCheckPaused = true;
|
|
2595
2694
|
this.healthCheckTimer = null;
|
|
@@ -2601,16 +2700,26 @@ export class Daemon extends EventEmitter {
|
|
|
2601
2700
|
return;
|
|
2602
2701
|
}
|
|
2603
2702
|
// The CLI owns the MCP server process, so the daemon can only observe it.
|
|
2604
|
-
this.checkMcpServerAlive();
|
|
2703
|
+
measureSyncWork("daemon.healthCheck", () => this.checkMcpServerAlive());
|
|
2605
2704
|
// Human-readable backend label for logs (e.g. "claude", "kiro-cli")
|
|
2606
2705
|
const cliLabel = this.backend?.binaryName ?? "CLI";
|
|
2607
|
-
|
|
2706
|
+
const healthTmux = this.tmux;
|
|
2707
|
+
const healthOwner = this.interactionOwner();
|
|
2708
|
+
const healthCurrent = () => this.tmux === healthTmux
|
|
2709
|
+
&& sameInteractionOwner(healthOwner, this.interactionOwner())
|
|
2710
|
+
&& !this.runtimeMonitorsFrozen && !this.healthCheckPaused && !this.spawning
|
|
2711
|
+
&& !this.isPaused && this.pauseWakeState !== "waking";
|
|
2712
|
+
const discardStaleHealth = () => {
|
|
2713
|
+
if (healthCurrent())
|
|
2714
|
+
return false;
|
|
2715
|
+
scheduleNext();
|
|
2716
|
+
return true;
|
|
2717
|
+
};
|
|
2718
|
+
let paneStatus = await healthTmux.getPaneStatus();
|
|
2608
2719
|
// Auto-pause intentionally exits the pane process. A health tick that
|
|
2609
2720
|
// began just before pause must not classify that exit as a crash.
|
|
2610
|
-
if (
|
|
2611
|
-
scheduleNext();
|
|
2721
|
+
if (discardStaleHealth())
|
|
2612
2722
|
return;
|
|
2613
|
-
}
|
|
2614
2723
|
if (paneStatus?.alive) {
|
|
2615
2724
|
this.windowQueryFailureTicks = 0;
|
|
2616
2725
|
this.stormWindow?.noteWindowAlive(this.name);
|
|
@@ -2620,7 +2729,7 @@ export class Daemon extends EventEmitter {
|
|
|
2620
2729
|
// 10–100 MiB log runs off the event loop, and this tick must not wait for
|
|
2621
2730
|
// it (#1161) — scheduleNext() below is not delayed by a rotation.
|
|
2622
2731
|
if (!this.config.lightweight) {
|
|
2623
|
-
void rotateLogIfNeededAsync(join(this.instanceDir, "output.log"));
|
|
2732
|
+
void measureSyncWork("daemon.healthCheck", () => rotateLogIfNeededAsync(join(this.instanceDir, "output.log")));
|
|
2624
2733
|
}
|
|
2625
2734
|
scheduleNext();
|
|
2626
2735
|
return;
|
|
@@ -2632,7 +2741,11 @@ export class Daemon extends EventEmitter {
|
|
|
2632
2741
|
// and needs no recheck.
|
|
2633
2742
|
if (paneStatus === null) {
|
|
2634
2743
|
await new Promise(r => setTimeout(r, 1500));
|
|
2635
|
-
|
|
2744
|
+
if (discardStaleHealth())
|
|
2745
|
+
return;
|
|
2746
|
+
paneStatus = await healthTmux.getPaneStatus();
|
|
2747
|
+
if (discardStaleHealth())
|
|
2748
|
+
return;
|
|
2636
2749
|
if (paneStatus?.alive) {
|
|
2637
2750
|
this.logger.debug(`[health] ${cliLabel} pane reported gone then alive on recheck — transient query failure, ignoring`);
|
|
2638
2751
|
this.windowQueryFailureTicks = 0;
|
|
@@ -2647,12 +2760,17 @@ export class Daemon extends EventEmitter {
|
|
|
2647
2760
|
this.logger.debug({ exitCode }, `[health] pane exited with code: ${exitCode}`);
|
|
2648
2761
|
// Normal exit (e.g. user Ctrl+C or /exit) — no crash, no respawn
|
|
2649
2762
|
if (paneStatus && exitCode === 0) {
|
|
2650
|
-
this.setProcessStatus("stopped");
|
|
2651
2763
|
// Status 0 is not proof of a clean exit: a codex that hits a quota
|
|
2652
2764
|
// wall exits 0 too. Capture what it printed before the window goes.
|
|
2653
|
-
|
|
2765
|
+
const output = await this.capturePaneOutput();
|
|
2766
|
+
if (discardStaleHealth())
|
|
2767
|
+
return;
|
|
2768
|
+
this.setProcessStatus("stopped");
|
|
2769
|
+
this.logPaneDeath(cliLabel, exitCode, output);
|
|
2654
2770
|
this.logger.info("CLI exited normally (code 0) — pausing health check");
|
|
2655
|
-
await
|
|
2771
|
+
await healthTmux.killWindow();
|
|
2772
|
+
if (discardStaleHealth())
|
|
2773
|
+
return;
|
|
2656
2774
|
this.healthCheckPaused = true;
|
|
2657
2775
|
this.emitSupervisionEnded("the CLI exited normally (code 0)", "Nothing crashed — start it again when you need it.", 0);
|
|
2658
2776
|
return;
|
|
@@ -2665,12 +2783,15 @@ export class Daemon extends EventEmitter {
|
|
|
2665
2783
|
let nullReason;
|
|
2666
2784
|
if (!paneStatus) {
|
|
2667
2785
|
const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
|
|
2786
|
+
if (discardStaleHealth())
|
|
2787
|
+
return;
|
|
2668
2788
|
// A server may have already restarted before this daemon's health
|
|
2669
2789
|
// tick. The new PID is the durable generation boundary: treat it as
|
|
2670
2790
|
// a fleet storm even though `has-session` is true again.
|
|
2671
|
-
const
|
|
2672
|
-
|
|
2673
|
-
|
|
2791
|
+
const serverPid = serverAlive ? await TmuxManager.getServerPid(this.tmuxSessionName) : null;
|
|
2792
|
+
if (discardStaleHealth())
|
|
2793
|
+
return;
|
|
2794
|
+
const generationChanged = serverAlive && this.stormWindow?.observeServerAlive(serverPid) === true;
|
|
2674
2795
|
if (generationChanged)
|
|
2675
2796
|
this.emit("tmux_server_crash", this.name);
|
|
2676
2797
|
if (!serverAlive || generationChanged || this.stormWindow?.needsRecovery(this.name)) {
|
|
@@ -2688,13 +2809,17 @@ export class Daemon extends EventEmitter {
|
|
|
2688
2809
|
// The fleet breaker owns the delay and extends it on every new
|
|
2689
2810
|
// server generation. Do not schedule a competing fixed timer.
|
|
2690
2811
|
await this.stormWindow?.waitForSpawnAllowed();
|
|
2812
|
+
if (discardStaleHealth())
|
|
2813
|
+
return;
|
|
2691
2814
|
}
|
|
2692
2815
|
else {
|
|
2693
2816
|
// null but server alive: window-level disappearance. Probe whether
|
|
2694
2817
|
// the window truly no longer exists vs a transient query glitch.
|
|
2695
2818
|
nullReason = "no_window";
|
|
2696
2819
|
try {
|
|
2697
|
-
const windows = await TmuxManager.
|
|
2820
|
+
const windows = await TmuxManager.listWindowsStrict(this.tmuxSessionName, this.controlClient);
|
|
2821
|
+
if (discardStaleHealth())
|
|
2822
|
+
return;
|
|
2698
2823
|
this.windowQueryFailureTicks = 0;
|
|
2699
2824
|
const currentWindowId = this.tmux.getWindowId();
|
|
2700
2825
|
if (windows.some(w => w.id === currentWindowId)) {
|
|
@@ -2735,22 +2860,26 @@ export class Daemon extends EventEmitter {
|
|
|
2735
2860
|
else {
|
|
2736
2861
|
this.logger.warn({ exitCode }, `${cliLabel} process exited`);
|
|
2737
2862
|
}
|
|
2738
|
-
this.setProcessStatus("crashed");
|
|
2739
2863
|
// Capture last output before killing. Best-effort even when the pane is
|
|
2740
2864
|
// gone (paneStatus null) — gives the crash record something to diagnose
|
|
2741
2865
|
// from instead of an empty lastOutput.
|
|
2742
2866
|
let lastOutput;
|
|
2743
2867
|
try {
|
|
2744
|
-
const raw = await
|
|
2868
|
+
const raw = await healthTmux.capturePaneWithHistory(50);
|
|
2745
2869
|
// Strip ANSI escape codes for readability
|
|
2746
2870
|
const cleaned = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
|
2747
2871
|
lastOutput = cleaned.trimEnd() || undefined;
|
|
2748
2872
|
}
|
|
2749
2873
|
catch { /* best effort — pane may already be gone */ }
|
|
2874
|
+
if (discardStaleHealth())
|
|
2875
|
+
return;
|
|
2876
|
+
this.setProcessStatus("crashed");
|
|
2750
2877
|
this.logPaneDeath(cliLabel, exitCode, lastOutput);
|
|
2751
2878
|
// Kill the dead window (remain-on-exit keeps it around) before respawn
|
|
2752
2879
|
if (paneStatus) {
|
|
2753
|
-
await
|
|
2880
|
+
await healthTmux.killWindow();
|
|
2881
|
+
if (discardStaleHealth())
|
|
2882
|
+
return;
|
|
2754
2883
|
}
|
|
2755
2884
|
// Detect claude-code background session conflict — recover without counting as crash
|
|
2756
2885
|
if (lastOutput && (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
|
|
@@ -3048,6 +3177,9 @@ export class Daemon extends EventEmitter {
|
|
|
3048
3177
|
const captureMono = performance.now();
|
|
3049
3178
|
const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch
|
|
3050
3179
|
|| !sameInteractionOwner(pollOwner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning;
|
|
3180
|
+
let scanStartedAt = null;
|
|
3181
|
+
const endScan = () => { if (scanStartedAt !== null)
|
|
3182
|
+
noteSyncWork("daemon.errorMonitorScan", scanStartedAt); scanStartedAt = null; };
|
|
3051
3183
|
try {
|
|
3052
3184
|
const alive = await this.tmux.isWindowAlive();
|
|
3053
3185
|
if (stale())
|
|
@@ -3056,6 +3188,8 @@ export class Daemon extends EventEmitter {
|
|
|
3056
3188
|
return;
|
|
3057
3189
|
const captureAt = Date.now();
|
|
3058
3190
|
const pane = await this.tmux.capturePane();
|
|
3191
|
+
// The scan of this pane is synchronous until it answers a dialog (an await) or ends: attributed (#1235).
|
|
3192
|
+
scanStartedAt = performance.now();
|
|
3059
3193
|
if (stale())
|
|
3060
3194
|
return;
|
|
3061
3195
|
if (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureAt) {
|
|
@@ -3143,6 +3277,7 @@ export class Daemon extends EventEmitter {
|
|
|
3143
3277
|
// (not queue) when the pane is busy: this poller runs every 5s, and the
|
|
3144
3278
|
// dialog will still be on screen next tick.
|
|
3145
3279
|
let resolved = false;
|
|
3280
|
+
endScan();
|
|
3146
3281
|
const dismissed = await this.paneWriteLock.tryRun(async () => {
|
|
3147
3282
|
// Re-read under the lock: the first capture may have gone stale
|
|
3148
3283
|
// while a delivery was finishing. A stale danger menu must never
|
|
@@ -3278,6 +3413,9 @@ export class Daemon extends EventEmitter {
|
|
|
3278
3413
|
this.unverifyInteraction(captureMono, captureOrder);
|
|
3279
3414
|
// capturePane can fail if window is transitioning — ignore
|
|
3280
3415
|
}
|
|
3416
|
+
finally {
|
|
3417
|
+
endScan();
|
|
3418
|
+
}
|
|
3281
3419
|
}, 5_000); // Check every 5 seconds (runtime dialogs need fast response)
|
|
3282
3420
|
}
|
|
3283
3421
|
/** Evaluate one pane snapshot. Kept synchronous so state-machine edges are unit-testable. */
|
|
@@ -4662,7 +4800,26 @@ export class Daemon extends EventEmitter {
|
|
|
4662
4800
|
*/
|
|
4663
4801
|
maybeConfirmReplyGuardIdle(pane) {
|
|
4664
4802
|
const pending = this.replyGuardIdleConfirm;
|
|
4665
|
-
if
|
|
4803
|
+
// #1377: if a successful settle already cleared the confirmation window,
|
|
4804
|
+
// check if completionDelivered became true — if so, complete the guard
|
|
4805
|
+
// on the next steady-idle tick so the guard doesn't persist indefinitely.
|
|
4806
|
+
// Also handle a late failure ACK (replyAttemptFailed) that arrived after
|
|
4807
|
+
// an idle edge with busyObserved=false: no confirm window was armed at that
|
|
4808
|
+
// edge (no_busy_since_arm returned early), so failure must be reported here.
|
|
4809
|
+
if (!pending) {
|
|
4810
|
+
const turn = this.turnReplyGuard.snapshot();
|
|
4811
|
+
if (!turn || turn.cancelledByUser || turn.phase !== "awaiting")
|
|
4812
|
+
return;
|
|
4813
|
+
if (turn.completionDelivered) {
|
|
4814
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
4815
|
+
}
|
|
4816
|
+
else if (turn.replyAttemptFailed) {
|
|
4817
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
4818
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
4819
|
+
}
|
|
4820
|
+
return;
|
|
4821
|
+
}
|
|
4822
|
+
if (this.isPaused || performance.now() < pending.confirmAt)
|
|
4666
4823
|
return;
|
|
4667
4824
|
this.maybeProxyReplyOnTurnEnd(pane, true);
|
|
4668
4825
|
}
|
|
@@ -4750,14 +4907,6 @@ export class Daemon extends EventEmitter {
|
|
|
4750
4907
|
this.turnReplyGuard.complete(turn.generation);
|
|
4751
4908
|
return;
|
|
4752
4909
|
}
|
|
4753
|
-
if (turn.replyAttempted) {
|
|
4754
|
-
// A provider timeout can be "applied, then timed out". Retrying it would
|
|
4755
|
-
// risk a duplicate; report the unknown result and stop here.
|
|
4756
|
-
this.clearReplyGuardConfirm();
|
|
4757
|
-
this.turnReplyGuard.complete(turn.generation);
|
|
4758
|
-
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
4759
|
-
return;
|
|
4760
|
-
}
|
|
4761
4910
|
// #1241: an idle edge is not proof the turn ended. Two gates before the
|
|
4762
4911
|
// recovery prompt goes anywhere near the CLI's input:
|
|
4763
4912
|
// 1. work must have been observed after this generation armed — otherwise
|
|
@@ -4766,6 +4915,14 @@ export class Daemon extends EventEmitter {
|
|
|
4766
4915
|
// 2. the edge must persist: the first qualifying edge only arms a
|
|
4767
4916
|
// confirmation window, and recovery starts only when steady idle past
|
|
4768
4917
|
// the window still shows no reply and no further work.
|
|
4918
|
+
// Exception (#1377): a definitively failed reply (adapter returned an
|
|
4919
|
+
// error) is known-bad now — no need to observe busy or wait 60s.
|
|
4920
|
+
if (turn.replyAttemptFailed) {
|
|
4921
|
+
this.clearReplyGuardConfirm();
|
|
4922
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
4923
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
4924
|
+
return;
|
|
4925
|
+
}
|
|
4769
4926
|
if (!turn.busyObserved) {
|
|
4770
4927
|
this.logger.info({
|
|
4771
4928
|
correlationId: turn.target.correlationId,
|
|
@@ -4797,6 +4954,15 @@ export class Daemon extends EventEmitter {
|
|
|
4797
4954
|
return;
|
|
4798
4955
|
}
|
|
4799
4956
|
this.clearReplyGuardConfirm();
|
|
4957
|
+
if (turn.replyAttempted) {
|
|
4958
|
+
// A reply was attempted but its deliver result is unknown (in-flight
|
|
4959
|
+
// IPC or a provider timeout). Retrying risks a duplicate; report the
|
|
4960
|
+
// unknown result and stop here. (#1377: moved after the confirm window
|
|
4961
|
+
// so an in-flight settle gets the same 60s deferral as a no-reply turn.)
|
|
4962
|
+
this.turnReplyGuard.complete(turn.generation);
|
|
4963
|
+
this.reportReplyDropWithoutRetry(turn, "reply_failed_or_unknown");
|
|
4964
|
+
return;
|
|
4965
|
+
}
|
|
4800
4966
|
this.startReplyRecovery(turn, "no_valid_call");
|
|
4801
4967
|
}
|
|
4802
4968
|
isClaudeCodeBackend() {
|
|
@@ -5129,22 +5295,27 @@ export class Daemon extends EventEmitter {
|
|
|
5129
5295
|
return;
|
|
5130
5296
|
}
|
|
5131
5297
|
this.statePollInFlight = true;
|
|
5298
|
+
let evaluationStartedAt = null;
|
|
5132
5299
|
const captureStartedAt = Date.now();
|
|
5133
5300
|
const interactionCaptureAt = performance.now();
|
|
5134
5301
|
const interactionCaptureOrder = ++this.interactionCaptureSerial;
|
|
5135
5302
|
const interactionOwner = this.interactionOwner();
|
|
5303
|
+
const captureTmux = this.tmux;
|
|
5136
5304
|
const captureEpoch = `${this.spawnGeneration}:${this.launchAttempt}`;
|
|
5137
|
-
const currentDeliveryCapture = () => captureEpoch === `${this.spawnGeneration}:${this.launchAttempt}`
|
|
5305
|
+
const currentDeliveryCapture = () => this.tmux === captureTmux && captureEpoch === `${this.spawnGeneration}:${this.launchAttempt}`
|
|
5138
5306
|
&& !this.spawning && !this.runtimeMonitorsFrozen && this.instanceStateMonitorActive;
|
|
5139
5307
|
try {
|
|
5140
5308
|
const pane = reason === "interaction_confirmation"
|
|
5141
|
-
? await
|
|
5309
|
+
? await captureTmux.capturePane(1_000) : await captureTmux.capturePane();
|
|
5142
5310
|
if (!currentDeliveryCapture() || !sameInteractionOwner(interactionOwner, this.interactionOwner()))
|
|
5143
5311
|
return;
|
|
5144
5312
|
// Delivery's unknown-footer proof also awaits the TTY mode. Validate
|
|
5145
5313
|
// output and launch freshness AFTER both awaits, before accepting it.
|
|
5146
5314
|
const deliveryCandidate = reason === "delivery_idle_gate"
|
|
5147
5315
|
? await this.probeDeliveryIdleFallback(pane) : null;
|
|
5316
|
+
// Everything from here to the finally is synchronous: the evaluation of this pane (#1235 attribution — a sweep
|
|
5317
|
+
// runs it for every instance in a row).
|
|
5318
|
+
evaluationStartedAt = performance.now();
|
|
5148
5319
|
// An old capture must not retire a new launch's transient guard either.
|
|
5149
5320
|
// This check is only live here, after the probe's await: the identical
|
|
5150
5321
|
// check before the probe was dead (no await in between, so the early
|
|
@@ -5376,6 +5547,8 @@ export class Daemon extends EventEmitter {
|
|
|
5376
5547
|
}
|
|
5377
5548
|
finally {
|
|
5378
5549
|
this.statePollInFlight = false;
|
|
5550
|
+
if (evaluationStartedAt !== null)
|
|
5551
|
+
noteSyncWork(`daemon.stateEvaluate:${reason}`, evaluationStartedAt);
|
|
5379
5552
|
}
|
|
5380
5553
|
}
|
|
5381
5554
|
handleInstancePaneOutput(event) {
|
|
@@ -5896,7 +6069,7 @@ export class Daemon extends EventEmitter {
|
|
|
5896
6069
|
this.markTurnStarted(meta, formatted, deliveryEpoch);
|
|
5897
6070
|
}
|
|
5898
6071
|
else if (durableAttempt && verdict.paneWriteStarted) {
|
|
5899
|
-
this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}
|
|
6072
|
+
this.finishDurableDelivery(durableAttempt, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`, verdict);
|
|
5900
6073
|
}
|
|
5901
6074
|
else if (durableAttempt && verdict.durableBeginCommitted) {
|
|
5902
6075
|
this.abortDurableDelivery(durableAttempt, `${verdict.phase ?? "delivery"}:${verdict.proof ?? "no-pane-write"}`);
|
|
@@ -5910,8 +6083,9 @@ export class Daemon extends EventEmitter {
|
|
|
5910
6083
|
}
|
|
5911
6084
|
}
|
|
5912
6085
|
catch (err) {
|
|
6086
|
+
// The verdict goes along: a write that started and then threw is still a hand-off the CLI may consume (#1201).
|
|
5913
6087
|
if (durableAttempt && verdict.paneWriteStarted)
|
|
5914
|
-
this.finishDurableDelivery(durableAttempt, "uncertain", err.message);
|
|
6088
|
+
this.finishDurableDelivery(durableAttempt, "uncertain", err.message, verdict);
|
|
5915
6089
|
else if (durableAttempt && verdict.durableBeginCommitted)
|
|
5916
6090
|
this.abortDurableDelivery(durableAttempt, err.message);
|
|
5917
6091
|
else if (durableAttempt)
|
|
@@ -6018,7 +6192,7 @@ export class Daemon extends EventEmitter {
|
|
|
6018
6192
|
if (this.pasteQueueDepth > 3) {
|
|
6019
6193
|
this.logger.warn({ depth: this.pasteQueueDepth }, "Message delivery queue backing up");
|
|
6020
6194
|
}
|
|
6021
|
-
if (wasQueued
|
|
6195
|
+
if (wasQueued) {
|
|
6022
6196
|
const queuedStatus = channelStatus(meta);
|
|
6023
6197
|
if (queuedStatus)
|
|
6024
6198
|
this.emit("message_queued", queuedStatus);
|
|
@@ -6039,7 +6213,7 @@ export class Daemon extends EventEmitter {
|
|
|
6039
6213
|
}
|
|
6040
6214
|
if (this.pendingInstructionsNotice) {
|
|
6041
6215
|
this.pendingInstructionsNotice = false;
|
|
6042
|
-
await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), undefined, { deliveryEpoch });
|
|
6216
|
+
await this.deliverMessage(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir, this.backend?.instructionsSource?.()), undefined, { deliveryEpoch });
|
|
6043
6217
|
if (!this.isDeliveryEpochCurrent(deliveryEpoch))
|
|
6044
6218
|
return;
|
|
6045
6219
|
}
|
|
@@ -6064,7 +6238,7 @@ export class Daemon extends EventEmitter {
|
|
|
6064
6238
|
this.markTurnStarted(meta, formatted, deliveryEpoch);
|
|
6065
6239
|
}
|
|
6066
6240
|
else if (durableAttempt && verdict.paneWriteStarted) {
|
|
6067
|
-
this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}
|
|
6241
|
+
this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`, verdict);
|
|
6068
6242
|
}
|
|
6069
6243
|
else if (durableAttempt && verdict.durableBeginCommitted) {
|
|
6070
6244
|
this.abortDurableDelivery(durable, `${verdict.phase ?? "delivery"}:${verdict.proof ?? "no-pane-write"}`);
|
|
@@ -6085,15 +6259,16 @@ export class Daemon extends EventEmitter {
|
|
|
6085
6259
|
this.finishDurableDelivery(durable, "failed", `${verdict.phase ?? "delivery"}:${verdict.proof ?? "failed"}`);
|
|
6086
6260
|
}
|
|
6087
6261
|
else if (verdict.paneWriteStarted) {
|
|
6088
|
-
this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}
|
|
6262
|
+
this.finishDurableDelivery(durable, "uncertain", `${verdict.phase ?? "post-submit"}:${verdict.proof ?? "unproven"}`, verdict);
|
|
6089
6263
|
}
|
|
6090
6264
|
else {
|
|
6091
6265
|
this.abortDurableDelivery(durable, "delivery did not reach pane submission");
|
|
6092
6266
|
}
|
|
6093
6267
|
}
|
|
6094
6268
|
catch (err) {
|
|
6269
|
+
// The verdict goes along: a write that started and then threw is still a hand-off the CLI may consume (#1201).
|
|
6095
6270
|
if (durable && verdict.paneWriteStarted)
|
|
6096
|
-
this.finishDurableDelivery(durable, "uncertain", err.message);
|
|
6271
|
+
this.finishDurableDelivery(durable, "uncertain", err.message, verdict);
|
|
6097
6272
|
else if (durable && verdict.durableBeginCommitted)
|
|
6098
6273
|
this.abortDurableDelivery(durable, err.message);
|
|
6099
6274
|
else if (durable)
|
|
@@ -6191,7 +6366,8 @@ export class Daemon extends EventEmitter {
|
|
|
6191
6366
|
const canHandOff = (supportsQueuedInput || opts?.steer)
|
|
6192
6367
|
&& readiness === "busy"
|
|
6193
6368
|
&& (await this.probeBlockingDialog()).state === "clear"
|
|
6194
|
-
&& await this.hasPositiveDeliveryInput(true)
|
|
6369
|
+
&& await this.hasPositiveDeliveryInput(true)
|
|
6370
|
+
&& (!opts?.steer || (this.launchTakesSteer() && await this.steerComposerAdmits(false)));
|
|
6195
6371
|
if (canHandOff) {
|
|
6196
6372
|
// Native queue (codex), or an explicit /steer: hand the complete
|
|
6197
6373
|
// paste+Enter transaction to the busy CLI now. For steer this is the
|
|
@@ -6290,6 +6466,13 @@ export class Daemon extends EventEmitter {
|
|
|
6290
6466
|
}
|
|
6291
6467
|
if (!(await this.hasPositiveDeliveryInput(handingOffToNativeQueue)))
|
|
6292
6468
|
return "dialog";
|
|
6469
|
+
// #1405: a launch that no longer takes a steer is not the one the gate saw — redo from the top (bounded), where
|
|
6470
|
+
// the gate sends it down the idle path. The composer can also have changed mode since (the user's Ctrl+S), or
|
|
6471
|
+
// the turn ended.
|
|
6472
|
+
if (handingOffToNativeQueue && opts?.steer && !this.launchTakesSteer())
|
|
6473
|
+
return "spawn-started";
|
|
6474
|
+
if (handingOffToNativeQueue && opts?.steer && !(await this.steerComposerAdmits(true)))
|
|
6475
|
+
return "dialog";
|
|
6293
6476
|
// #829: a CLI that restores a cancelled prompt into its input box would
|
|
6294
6477
|
// have this message pasted onto it and both submitted as one. Clear it
|
|
6295
6478
|
// first, or do not write at all. From here every await is fenced: a
|
|
@@ -6860,6 +7043,73 @@ export class Daemon extends EventEmitter {
|
|
|
6860
7043
|
}
|
|
6861
7044
|
return true;
|
|
6862
7045
|
}
|
|
7046
|
+
/**
|
|
7047
|
+
* #1405: a steer is handed to the busy pane only if THIS launch takes one — asked of the backend again at delivery,
|
|
7048
|
+
* not just by the hub when it was sent: a durable steer can outlive the launch it was meant for (a restart onto a
|
|
7049
|
+
* legacy UI or an unverified version), and must then wait for the idle prompt like any message. Backends that do not
|
|
7050
|
+
* answer per launch leave it to the hub's name table.
|
|
7051
|
+
*/
|
|
7052
|
+
launchTakesSteer() {
|
|
7053
|
+
return this.backend?.supportsSteer?.() !== false;
|
|
7054
|
+
}
|
|
7055
|
+
/**
|
|
7056
|
+
* #1405: a steer into a CLI whose busy input steers or queues by a mode the user switches (kiro TUI) goes in only
|
|
7057
|
+
* while a fresh capture reads its composer as "steer" — or "idle" once the turn has ended (`allowIdle`, the pre-write
|
|
7058
|
+
* re-check). "queue" (the user's choice), text in the box, or a screen it cannot read: not now — the caller waits for
|
|
7059
|
+
* the idle prompt as for any message. AgEnD never switches the mode. Backends without the reader are not asked.
|
|
7060
|
+
*/
|
|
7061
|
+
async steerComposerAdmits(allowIdle) {
|
|
7062
|
+
const read = this.backend?.readSteerComposer;
|
|
7063
|
+
if (!read)
|
|
7064
|
+
return true;
|
|
7065
|
+
if (!this.tmux)
|
|
7066
|
+
return false;
|
|
7067
|
+
let mode;
|
|
7068
|
+
try {
|
|
7069
|
+
mode = read.call(this.backend, await this.tmux.capturePane());
|
|
7070
|
+
}
|
|
7071
|
+
catch {
|
|
7072
|
+
return false;
|
|
7073
|
+
}
|
|
7074
|
+
return mode === "steer" || (allowIdle && mode === "idle");
|
|
7075
|
+
}
|
|
7076
|
+
/**
|
|
7077
|
+
* #1405: one look at a mode-switched composer for a steer's own proof: on the tmux of this write, inside `deadline`
|
|
7078
|
+
* (monotonic) — the capture is raced against the time left and a late answer dropped — and `current` asked after the
|
|
7079
|
+
* await. Null when unreadable, late or no longer this write's: never evidence of an empty or a full box. The pane
|
|
7080
|
+
* comes back with the mode, so what follows judges the same capture.
|
|
7081
|
+
*/
|
|
7082
|
+
async steerComposerWithin(tmux, deadline, current) {
|
|
7083
|
+
const read = this.backend?.readSteerComposer;
|
|
7084
|
+
const left = deadline - performance.now();
|
|
7085
|
+
if (!read || left <= 0 || !current())
|
|
7086
|
+
return null;
|
|
7087
|
+
const pane = await withinMs(tmux.capturePane(Math.ceil(left)), left);
|
|
7088
|
+
if (pane === undefined || performance.now() > deadline || !current())
|
|
7089
|
+
return null;
|
|
7090
|
+
const mode = read.call(this.backend, pane);
|
|
7091
|
+
return mode === null ? null : { mode, pane };
|
|
7092
|
+
}
|
|
7093
|
+
/**
|
|
7094
|
+
* #1405: the composer's text is THIS delivery's paste. kiro paints a multi-line paste's first line on the `›` row (live,
|
|
7095
|
+
* 2.27.1 and 2.28.0, TUI v2 and v3); that row must show the payload's first line COMPLETE — for a durable steer that
|
|
7096
|
+
* is its `[agend-delivery-id:<this attempt's id>]` marker, so a draft, someone else's paste, our paste appended to a
|
|
7097
|
+
* draft, or a marker cut short (wrapped, collapsed, truncated) is never taken for it: not attributable, no Enter.
|
|
7098
|
+
*/
|
|
7099
|
+
steerPasteIsOurs(pane, formatted) {
|
|
7100
|
+
const shown = this.backend?.readSteerComposerText?.(pane);
|
|
7101
|
+
if (!shown)
|
|
7102
|
+
return false;
|
|
7103
|
+
const norm = (t) => t.replace(/\s+/g, " ").trim();
|
|
7104
|
+
const first = norm(formatted.split("\n").find(line => line.trim() !== "") ?? "");
|
|
7105
|
+
if (first === "")
|
|
7106
|
+
return false;
|
|
7107
|
+
const row = norm(shown);
|
|
7108
|
+
// The marker line holds nothing but the marker: the row is exactly it.
|
|
7109
|
+
if (/^\[agend-delivery-id:[^\]\s]+\]$/.test(first))
|
|
7110
|
+
return row === first;
|
|
7111
|
+
return row === first || row.startsWith(`${first} `);
|
|
7112
|
+
}
|
|
6863
7113
|
/**
|
|
6864
7114
|
* Codex can paint a prompt before the TTY enters raw mode. Both are required.
|
|
6865
7115
|
*
|
|
@@ -7548,6 +7798,11 @@ export class Daemon extends EventEmitter {
|
|
|
7548
7798
|
let windowId = initialWindowId;
|
|
7549
7799
|
// The spawn this write belongs to: acceptance evidence is only read while it is still the current one (#1197).
|
|
7550
7800
|
const spawnAtWrite = this.spawnGeneration;
|
|
7801
|
+
// #1405: a composer-proven steer is bound to this write's tmux and launch: every read, the Enter and the proof ask
|
|
7802
|
+
// this after each await (a respawn swaps this.tmux; a stop, pause or cancel ends the caller's fence).
|
|
7803
|
+
const tmuxAtWrite = this.tmux;
|
|
7804
|
+
const steerCurrent = () => (!stillCurrent || stillCurrent()) && !this.deliveryWritesStopping
|
|
7805
|
+
&& this.tmux === tmuxAtWrite && this.spawnGeneration === spawnAtWrite;
|
|
7551
7806
|
// Bug A: paste with backoff. Transient failures are usually a stale window id
|
|
7552
7807
|
// after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
|
|
7553
7808
|
const maxAttempts = 3;
|
|
@@ -7572,6 +7827,20 @@ export class Daemon extends EventEmitter {
|
|
|
7572
7827
|
return "handoff-box-unread";
|
|
7573
7828
|
return this.failDelivery(verdict, status, "paste", "handoff-input-unreadable");
|
|
7574
7829
|
}
|
|
7830
|
+
// #1405: on that same last capture a mode-switched composer (kiro TUI) must still read "steer" — or "idle", the
|
|
7831
|
+
// turn over. "queue", text in the box or an unreadable row: not written; handled as the unread box above.
|
|
7832
|
+
if (handingOffToNativeQueue && steer && this.backend?.readSteerComposer
|
|
7833
|
+
&& pasteBaseline?.steerComposer !== "steer" && pasteBaseline?.steerComposer !== "idle") {
|
|
7834
|
+
if (!verdict.durableBeginCommitted)
|
|
7835
|
+
return "handoff-box-unread";
|
|
7836
|
+
return this.failDelivery(verdict, status, "paste", "steer-composer-not-steering");
|
|
7837
|
+
}
|
|
7838
|
+
// …and the launch must still take a steer (see launchTakesSteer). Not written: before the durable begin the row
|
|
7839
|
+
// is retried, as a steer the gate will then send down the idle path.
|
|
7840
|
+
if (handingOffToNativeQueue && steer && !this.launchTakesSteer()) {
|
|
7841
|
+
verdict.fenced = !verdict.durableBeginCommitted;
|
|
7842
|
+
return false;
|
|
7843
|
+
}
|
|
7575
7844
|
// Readiness, idle-gate, dialog, spawn and pane-lock waits have all ended.
|
|
7576
7845
|
// Commit the submission fence at the last possible point before the
|
|
7577
7846
|
// first side effect; a crash during those waits remains safely replayable.
|
|
@@ -7595,6 +7864,7 @@ export class Daemon extends EventEmitter {
|
|
|
7595
7864
|
return false;
|
|
7596
7865
|
}
|
|
7597
7866
|
verdict.durableBeginCommitted = true;
|
|
7867
|
+
verdict.submissionMode = attemptEvidence.submissionMode;
|
|
7598
7868
|
if (attemptEvidence.transcriptPath && attemptEvidence.transcriptOffset !== null) {
|
|
7599
7869
|
verdict.transcriptCheckpoint = {
|
|
7600
7870
|
backend: attemptEvidence.backend,
|
|
@@ -7664,10 +7934,50 @@ export class Daemon extends EventEmitter {
|
|
|
7664
7934
|
await new Promise(r => setTimeout(r, fallbackMs));
|
|
7665
7935
|
settle = { settleMs: fallbackMs, observedPostPasteOutput: false, capHit: false, usedFallback: true };
|
|
7666
7936
|
}
|
|
7937
|
+
// #1405: a steer into a mode-switched composer (kiro TUI) is proven by the box alone — before the Enter it must
|
|
7938
|
+
// positively hold typed text, and after it be the empty placeholder again. The empty placeholder here means the
|
|
7939
|
+
// paste never reached the box; an unreadable box (a failed or late capture, a dialog) proves nothing: no Enter.
|
|
7940
|
+
const steerComposerProof = handingOffToNativeQueue && steer && !!this.backend?.readSteerComposer && tmuxAtWrite !== null;
|
|
7941
|
+
let steerEnterDeadline = Infinity;
|
|
7942
|
+
if (steerComposerProof) {
|
|
7943
|
+
const deadline = performance.now() + STEER_PASTE_VISIBLE_MS;
|
|
7944
|
+
let before = null;
|
|
7945
|
+
for (;;) {
|
|
7946
|
+
before = await this.steerComposerWithin(tmuxAtWrite, deadline, steerCurrent);
|
|
7947
|
+
if (before !== null || !steerCurrent() || performance.now() >= deadline)
|
|
7948
|
+
break;
|
|
7949
|
+
await new Promise(r => setTimeout(r, Math.min(POST_ENTER_PROOF_POLL_MS, Math.max(0, deadline - performance.now()))));
|
|
7950
|
+
}
|
|
7951
|
+
if (!steerCurrent()) {
|
|
7952
|
+
// Written but not entered, and no longer this write's pane to judge: uncertain, never retried here.
|
|
7953
|
+
verdict.phase = "steer-paste";
|
|
7954
|
+
verdict.proof = "fenced-before-enter";
|
|
7955
|
+
return false;
|
|
7956
|
+
}
|
|
7957
|
+
// The caller's own receipt is inside the budget too: a reading whose continuation ran late is not used.
|
|
7958
|
+
if (performance.now() > deadline)
|
|
7959
|
+
return this.failDelivery(verdict, status, "steer-paste", "box-read-late");
|
|
7960
|
+
if (before?.mode !== "text")
|
|
7961
|
+
return this.failDelivery(verdict, status, "steer-paste", before === null ? "box-unread" : `box-still-${before.mode}`);
|
|
7962
|
+
if (!this.steerPasteIsOurs(before.pane, formatted))
|
|
7963
|
+
return this.failDelivery(verdict, status, "steer-paste", "box-not-this-paste");
|
|
7964
|
+
// …and so is the Enter: the key is sent only while the reading is still within it.
|
|
7965
|
+
steerEnterDeadline = deadline;
|
|
7966
|
+
}
|
|
7667
7967
|
let enterAt = Date.now();
|
|
7668
|
-
|
|
7968
|
+
const steerEnterCurrent = () => steerCurrent() && performance.now() <= steerEnterDeadline;
|
|
7969
|
+
if (!(await this.sendDeliveryEnter("initial-submit", steerComposerProof ? steerEnterCurrent : undefined, durableAttempt))) {
|
|
7970
|
+
if (steerComposerProof && !steerCurrent()) {
|
|
7971
|
+
verdict.phase = "submit-enter";
|
|
7972
|
+
verdict.proof = "fenced-before-enter";
|
|
7973
|
+
return false;
|
|
7974
|
+
}
|
|
7975
|
+
if (steerComposerProof && performance.now() > steerEnterDeadline)
|
|
7976
|
+
return this.failDelivery(verdict, status, "submit-enter", "pre-enter-deadline");
|
|
7669
7977
|
return this.failDelivery(verdict, status, "submit-enter", "tmux-send-keys-failed");
|
|
7670
7978
|
}
|
|
7979
|
+
// The proof's whole budget, from the Enter: every capture and dialog probe inside it, a late answer dropped.
|
|
7980
|
+
const steerProofDeadline = performance.now() + NATIVE_QUEUE_PASTE_VERIFY_MS + POST_ENTER_PROOF_WINDOW_MS;
|
|
7671
7981
|
// Kiro's legacy TUI can swallow Enter while it is still processing a large
|
|
7672
7982
|
// paste — not only during the post-ready redraw (#479): on slower hosts it
|
|
7673
7983
|
// happens on ordinary deliveries too (v2.1.2 stable, WSL2 + tmux 3.4).
|
|
@@ -7681,7 +7991,9 @@ export class Daemon extends EventEmitter {
|
|
|
7681
7991
|
// re-baselined to the second Enter so leftover paste-render output between
|
|
7682
7992
|
// the two cannot be what "confirms" the submission.
|
|
7683
7993
|
let enterRetry = false;
|
|
7684
|
-
|
|
7994
|
+
// Not after a composer-proven steer: what a bare Enter does in kiro's TUI composer mid-turn is unverified, and the
|
|
7995
|
+
// box itself shows whether the one Enter took the text.
|
|
7996
|
+
if (!rawPaste && !steerComposerProof && this.backend?.requiresDeliveryEnterRetry?.() === true) {
|
|
7685
7997
|
await new Promise(r => setTimeout(r, 1_000));
|
|
7686
7998
|
const retryAt = Date.now();
|
|
7687
7999
|
if (await this.sendDeliveryEnter("queue-less-defensive-retry")) {
|
|
@@ -7705,6 +8017,41 @@ export class Daemon extends EventEmitter {
|
|
|
7705
8017
|
// can silently swallow it). Idle submissions keep the swallowed-Enter path.
|
|
7706
8018
|
if (handingOffToNativeQueue) {
|
|
7707
8019
|
await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
|
|
8020
|
+
if (steerComposerProof) {
|
|
8021
|
+
// #1405: the box is its empty placeholder again — "steer" (taken into the turn), "queue" (the user switched
|
|
8022
|
+
// mode meanwhile: held for the turn's end) or "idle" (the turn over: it started one) — with no dialog and the
|
|
8023
|
+
// same spawn and window. Kiro paints late, so a bounded poll on a monotonic deadline. Still not empty: the
|
|
8024
|
+
// Enter did not take it — uncertain, never re-pasted and never sent another Enter. Every capture and dialog
|
|
8025
|
+
// probe runs inside the budget set at the Enter; the deadline and this write's fence are asked again,
|
|
8026
|
+
// synchronously, right before it is accepted.
|
|
8027
|
+
const deadline = steerProofDeadline;
|
|
8028
|
+
for (;;) {
|
|
8029
|
+
const look = await this.steerComposerWithin(tmuxAtWrite, deadline, steerCurrent);
|
|
8030
|
+
const after = look?.mode;
|
|
8031
|
+
if (after === "steer" || after === "queue" || after === "idle") {
|
|
8032
|
+
const probe = await withinMs(this.probeBlockingDialog(), deadline - performance.now());
|
|
8033
|
+
if (probe?.state === "clear" && performance.now() <= deadline && steerCurrent() && this.getWindowId() === windowId) {
|
|
8034
|
+
verdict.phase = "steer-accepted";
|
|
8035
|
+
verdict.proof = `steer-composer-${after}`;
|
|
8036
|
+
if (status)
|
|
8037
|
+
this.emit("message_confirmed", status); // ✅ taken from the box
|
|
8038
|
+
return true;
|
|
8039
|
+
}
|
|
8040
|
+
}
|
|
8041
|
+
if (performance.now() >= deadline)
|
|
8042
|
+
break;
|
|
8043
|
+
await new Promise(r => setTimeout(r, Math.min(POST_ENTER_PROOF_POLL_MS, Math.max(0, deadline - performance.now()))));
|
|
8044
|
+
}
|
|
8045
|
+
if (!steerCurrent()) {
|
|
8046
|
+
verdict.phase = "steer-proof";
|
|
8047
|
+
verdict.proof = "fenced";
|
|
8048
|
+
return false;
|
|
8049
|
+
}
|
|
8050
|
+
this.logger.warn("Steer: the composer was not shown empty after the Enter in time — outcome uncertain, not re-sent");
|
|
8051
|
+
verdict.phase = "steer-proof";
|
|
8052
|
+
verdict.proof = "composer-not-emptied";
|
|
8053
|
+
return false;
|
|
8054
|
+
}
|
|
7708
8055
|
const proof = await this.confirmSubmitted(signature, pasteBaseline);
|
|
7709
8056
|
if (proof === "submitted") {
|
|
7710
8057
|
if (status)
|
|
@@ -8120,8 +8467,8 @@ export class Daemon extends EventEmitter {
|
|
|
8120
8467
|
return this.submittedProof();
|
|
8121
8468
|
}
|
|
8122
8469
|
/**
|
|
8123
|
-
* The CLI's transcript, from this attempt's checkpoint, holds this delivery's exact marker: a user entry (read)
|
|
8124
|
-
* one it queued (taken into its queue).
|
|
8470
|
+
* The CLI's transcript, from this attempt's checkpoint, holds this delivery's exact marker: a user entry (read), an
|
|
8471
|
+
* entry Claude absorbed mid-turn, or one it queued (taken into its queue). Any of them is the CLI having taken the
|
|
8125
8472
|
* message, so it outranks the pane — including a box that still shows the paste a moment after Enter (Claude paints
|
|
8126
8473
|
* the submit late, captured live on 2.1.293), which on its own reads as a strand. Positive only; never a downgrade.
|
|
8127
8474
|
*/
|
|
@@ -8133,7 +8480,7 @@ export class Daemon extends EventEmitter {
|
|
|
8133
8480
|
return t.provenBy;
|
|
8134
8481
|
const found = await t.reader.look();
|
|
8135
8482
|
// No side effect here: the caller keeps the hit and retires the guard only after its own fence check.
|
|
8136
|
-
return found === "user" || found === "queued" ? found : null;
|
|
8483
|
+
return found === "user" || found === "absorbed" || found === "queued" ? found : null;
|
|
8137
8484
|
}
|
|
8138
8485
|
/**
|
|
8139
8486
|
* A write's transcript proof: bounded looks from its checkpoint, fenced to the write — its spawn and launch, a stop,
|
|
@@ -8318,6 +8665,7 @@ export class Daemon extends EventEmitter {
|
|
|
8318
8665
|
strandedInput: input != null && inputShowsPastedText(input.text, signature.value),
|
|
8319
8666
|
collapsedPastes: input?.collapsedPastes ?? 0,
|
|
8320
8667
|
inputReadable: input != null,
|
|
8668
|
+
steerComposer: this.backend?.readSteerComposer?.(pane) ?? null,
|
|
8321
8669
|
};
|
|
8322
8670
|
}
|
|
8323
8671
|
/**
|
|
@@ -8381,7 +8729,10 @@ export class Daemon extends EventEmitter {
|
|
|
8381
8729
|
* is asked again after every await that precedes a write, so a cancelled paste adds no further paste and no key
|
|
8382
8730
|
* (the retries included). Without a guard it is exactly the old unconditional path.
|
|
8383
8731
|
*/
|
|
8384
|
-
async submitSystemPaste(text, label, guard) {
|
|
8732
|
+
async submitSystemPaste(text, label, guard, admission) {
|
|
8733
|
+
const current = guard || admission ? () => { admission?.(); return guard?.current() ?? true; } : undefined;
|
|
8734
|
+
if (current && !current())
|
|
8735
|
+
return false;
|
|
8385
8736
|
if (!this.tmux)
|
|
8386
8737
|
return false;
|
|
8387
8738
|
const signature = this.submissionSignature(text);
|
|
@@ -8394,32 +8745,41 @@ export class Daemon extends EventEmitter {
|
|
|
8394
8745
|
catch {
|
|
8395
8746
|
return false;
|
|
8396
8747
|
}
|
|
8397
|
-
if (!
|
|
8748
|
+
if (!current() || !guard.accept(pane))
|
|
8398
8749
|
return false;
|
|
8399
8750
|
baseline = this.paneEvidence(pane, signature);
|
|
8400
8751
|
}
|
|
8401
8752
|
else {
|
|
8402
8753
|
baseline = await this.capturePaneEvidence(signature);
|
|
8403
8754
|
}
|
|
8755
|
+
if (current && !current())
|
|
8756
|
+
return false;
|
|
8404
8757
|
const pasteGeneration = this.spawnGeneration;
|
|
8405
|
-
|
|
8758
|
+
const pasted = current ? await this.tmux.pasteBuffer(text, { guard: current }) : await this.tmux.pasteBuffer(text);
|
|
8759
|
+
if (!pasted) {
|
|
8406
8760
|
this.logger.warn({ label }, "System paste failed to reach the pane");
|
|
8407
8761
|
return false;
|
|
8408
8762
|
}
|
|
8763
|
+
if (current && !current())
|
|
8764
|
+
return false;
|
|
8409
8765
|
this.rememberPaste(text, pasteGeneration);
|
|
8410
8766
|
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
8411
|
-
if (!(await this.sendDeliveryEnter(label,
|
|
8767
|
+
if (!(await this.sendDeliveryEnter(label, current)))
|
|
8412
8768
|
return false;
|
|
8413
8769
|
let proof = await this.confirmSubmitted(signature, baseline);
|
|
8770
|
+
if (current && !current())
|
|
8771
|
+
return false;
|
|
8414
8772
|
// A structural box reader sees the paste in the box until the CLI digests the Enter (claude-code repaints a moment
|
|
8415
8773
|
// later): give that a bounded window before calling it stranded or lost, as the delivery ladder does.
|
|
8416
8774
|
if (this.backend?.readInputRow) {
|
|
8417
8775
|
const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
|
|
8418
8776
|
while (proof !== "submitted" && Date.now() < deadline) {
|
|
8419
8777
|
await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
|
|
8420
|
-
if (
|
|
8778
|
+
if (current && !current())
|
|
8421
8779
|
return false;
|
|
8422
8780
|
proof = await this.confirmSubmitted(signature, baseline);
|
|
8781
|
+
if (current && !current())
|
|
8782
|
+
return false;
|
|
8423
8783
|
}
|
|
8424
8784
|
}
|
|
8425
8785
|
if (proof === "unverifiable") {
|
|
@@ -8437,7 +8797,7 @@ export class Daemon extends EventEmitter {
|
|
|
8437
8797
|
// exists to remove — visible text is also what a strand looks like.
|
|
8438
8798
|
if (this.systemPasteOptions().retryEnter) {
|
|
8439
8799
|
await new Promise(r => setTimeout(r, 1_000));
|
|
8440
|
-
await this.sendDeliveryEnter(`${label}-defensive-retry`,
|
|
8800
|
+
await this.sendDeliveryEnter(`${label}-defensive-retry`, current);
|
|
8441
8801
|
}
|
|
8442
8802
|
return true; // best effort, exactly as before — nothing here is verified
|
|
8443
8803
|
}
|
|
@@ -8446,9 +8806,11 @@ export class Daemon extends EventEmitter {
|
|
|
8446
8806
|
// first one did land; when the text is still in the input row it is the
|
|
8447
8807
|
// submit it never got. Re-pasting would append the text to itself.
|
|
8448
8808
|
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
8449
|
-
if (!(await this.sendDeliveryEnter(`${label}-retry`,
|
|
8809
|
+
if (!(await this.sendDeliveryEnter(`${label}-retry`, current)))
|
|
8450
8810
|
return false;
|
|
8451
8811
|
proof = await this.confirmSubmitted(signature, baseline);
|
|
8812
|
+
if (current && !current())
|
|
8813
|
+
return false;
|
|
8452
8814
|
}
|
|
8453
8815
|
if (proof !== "submitted") {
|
|
8454
8816
|
this.logger.warn({ label, proof }, "System paste may not have been submitted");
|
|
@@ -8458,21 +8820,30 @@ export class Daemon extends EventEmitter {
|
|
|
8458
8820
|
}
|
|
8459
8821
|
/** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
|
|
8460
8822
|
async recoverWindow() {
|
|
8823
|
+
const previousTmux = this.tmux;
|
|
8824
|
+
const recoveryOwner = this.interactionOwner();
|
|
8825
|
+
const recoveryCurrent = () => !this.runtimeMonitorsFrozen
|
|
8826
|
+
&& sameInteractionOwner(recoveryOwner, this.interactionOwner());
|
|
8461
8827
|
const previousWindowId = this.tmux?.getWindowId();
|
|
8462
8828
|
try {
|
|
8463
|
-
const windows = await TmuxManager.listWindows(this.tmuxSessionName);
|
|
8829
|
+
const windows = await TmuxManager.listWindows(this.tmuxSessionName, this.controlClient);
|
|
8830
|
+
if (!recoveryCurrent() || this.tmux !== previousTmux)
|
|
8831
|
+
return undefined;
|
|
8464
8832
|
const match = windows.find(w => w.name === this.name);
|
|
8465
8833
|
if (!match)
|
|
8466
8834
|
return undefined;
|
|
8467
|
-
this.tmux = new TmuxManager(this.tmuxSessionName, match.id, resolveTmuxLogicalSize(this.config.terminal));
|
|
8835
|
+
this.tmux = new TmuxManager(this.tmuxSessionName, match.id, resolveTmuxLogicalSize(this.config.terminal), this.controlClient);
|
|
8836
|
+
const recoveredTmux = this.tmux;
|
|
8468
8837
|
writeFileSync(join(this.instanceDir, "window-id"), match.id);
|
|
8469
8838
|
// The window we were talking to is gone; leaving it registered means the
|
|
8470
|
-
// control client re-resolves a dead id — one
|
|
8839
|
+
// control client re-resolves a dead id — one bounded read — on every
|
|
8471
8840
|
// reconnect, for the life of the fleet process.
|
|
8472
8841
|
if (previousWindowId && previousWindowId !== match.id) {
|
|
8473
8842
|
this.controlClient?.unregisterWindow(previousWindowId);
|
|
8474
8843
|
}
|
|
8475
8844
|
await this.controlClient?.registerWindow(match.id);
|
|
8845
|
+
if (!recoveryCurrent() || this.tmux !== recoveredTmux)
|
|
8846
|
+
return undefined;
|
|
8476
8847
|
this.bindInstanceStateOutputListener(match.id);
|
|
8477
8848
|
this.logger.info({ windowId: match.id }, "Recovered window ID for message delivery");
|
|
8478
8849
|
return match.id;
|
|
@@ -8581,6 +8952,31 @@ export class Daemon extends EventEmitter {
|
|
|
8581
8952
|
// continuation for an already-answered turn.
|
|
8582
8953
|
if (replyAttempt?.completionAction)
|
|
8583
8954
|
clearInFlightTurnMarker(this.instanceDir);
|
|
8955
|
+
// #1377: a confirmed completion action (reply/react/edit_message) ends
|
|
8956
|
+
// the human-facing obligation immediately — the idle edge does not need
|
|
8957
|
+
// to confirm it. Clear any pending confirmation window so a later
|
|
8958
|
+
// cross-instance busy→idle cycle cannot re-arm and fire false recovery.
|
|
8959
|
+
// The guard itself completes on the next steady-idle tick via
|
|
8960
|
+
// maybeConfirmReplyGuardIdle (which checks completionDelivered when
|
|
8961
|
+
// replyGuardIdleConfirm is null) or at the next busy→idle edge.
|
|
8962
|
+
// Cancelled turns are excluded: their completion semantics are handled
|
|
8963
|
+
// by the idle edge's cancelledByUser check.
|
|
8964
|
+
// An older reply that satisfied an earlier obligation but not the latest
|
|
8965
|
+
// one (latestObligation > token.obligation) leaves completionDelivered
|
|
8966
|
+
// false and must not clear the window either.
|
|
8967
|
+
if (replyAttempt?.completionAction) {
|
|
8968
|
+
const snap = this.turnReplyGuard.snapshot();
|
|
8969
|
+
if (snap?.generation === replyAttempt.generation && snap.phase === "awaiting"
|
|
8970
|
+
&& snap.completionDelivered && !snap.cancelledByUser) {
|
|
8971
|
+
this.clearReplyGuardConfirm();
|
|
8972
|
+
}
|
|
8973
|
+
}
|
|
8974
|
+
}
|
|
8975
|
+
else if ((error || result == null) && replyAttempt?.reply && TURN_OUTBOUND_TOOLS.has(tool)) {
|
|
8976
|
+
// #1377: the adapter returned a definitive error — record this so the
|
|
8977
|
+
// confirm window can distinguish "in-flight" (unknown) from "known
|
|
8978
|
+
// failed" and report unknown immediately rather than starting recovery.
|
|
8979
|
+
this.turnReplyGuard.settleToolAttempt(replyAttempt, false);
|
|
8584
8980
|
}
|
|
8585
8981
|
const sent = this.ipcServer?.send(socket, { requestId, result, error, operationId }) ?? false;
|
|
8586
8982
|
if (!sent) {
|
|
@@ -8979,7 +9375,8 @@ export class Daemon extends EventEmitter {
|
|
|
8979
9375
|
* user input so the agent picks up where the previous session left off.
|
|
8980
9376
|
* This replaces the old system-prompt injection approach.
|
|
8981
9377
|
*/
|
|
8982
|
-
async injectSnapshotMessage() {
|
|
9378
|
+
async injectSnapshotMessage(admission) {
|
|
9379
|
+
admission?.();
|
|
8983
9380
|
if (this.snapshotConsumed)
|
|
8984
9381
|
return;
|
|
8985
9382
|
const snapshot = this.buildSnapshotPrompt();
|
|
@@ -8991,11 +9388,16 @@ export class Daemon extends EventEmitter {
|
|
|
8991
9388
|
}
|
|
8992
9389
|
// Small delay to let the CLI fully render its ready prompt
|
|
8993
9390
|
await new Promise(r => setTimeout(r, 1_000));
|
|
9391
|
+
admission?.();
|
|
8994
9392
|
try {
|
|
8995
9393
|
// Messages can arrive during a restart and be queued on pasteLock before the
|
|
8996
9394
|
// snapshot lands; both write to the pane, so both go through the same lock.
|
|
8997
9395
|
const restoreNotice = `[system:session-snapshot]\n${snapshot}\n\nThis is a background context restore — do NOT reply to or acknowledge this message. Simply resume normal operation when the next user or instance message arrives.`;
|
|
8998
|
-
const injected = await this.paneWriteLock.run(() =>
|
|
9396
|
+
const injected = await this.paneWriteLock.run(() => {
|
|
9397
|
+
admission?.();
|
|
9398
|
+
return this.submitSystemPaste(restoreNotice, "session-snapshot-restore", undefined, admission);
|
|
9399
|
+
});
|
|
9400
|
+
admission?.();
|
|
8999
9401
|
if (!injected) {
|
|
9000
9402
|
// rotation-state.json was deleted when the prompt was built, so there is
|
|
9001
9403
|
// nothing left to retry from: the restore is gone either way. Say so —
|
|
@@ -9242,12 +9644,14 @@ export class Daemon extends EventEmitter {
|
|
|
9242
9644
|
// Fresh start or not: the owner is recorded below once this CLI is up, so an
|
|
9243
9645
|
// id it does not own must be out of the way first, or it would be recorded
|
|
9244
9646
|
// as this backend's and resumed by the next start.
|
|
9647
|
+
this.startupAdmission?.();
|
|
9245
9648
|
this.setAsideForeignSession();
|
|
9246
9649
|
const attemptedResume = !this.skipResume;
|
|
9247
9650
|
// A resume launch may get a longer budget than a fresh one (kiro: the
|
|
9248
9651
|
// conversation must come back from the backend before anything paints).
|
|
9249
9652
|
const resumeBudget = attemptedResume ? this.startupBudgetFor(true) : undefined;
|
|
9250
9653
|
let alive = await this.trySpawn(false, resumeBudget);
|
|
9654
|
+
this.startupAdmission?.();
|
|
9251
9655
|
if (!alive && attemptedResume) {
|
|
9252
9656
|
// Resume failed. Before abandoning the session:
|
|
9253
9657
|
// 1. If the backend is unreachable (fleet-level memory, or this very pane
|
|
@@ -9258,15 +9662,22 @@ export class Daemon extends EventEmitter {
|
|
|
9258
9662
|
// 2. Otherwise, for backends that ask for it, retry resume ONCE — the
|
|
9259
9663
|
// first miss is usually slowness, not a broken session.
|
|
9260
9664
|
await this.noteStartupPaneForBackendOutage();
|
|
9665
|
+
this.startupAdmission?.();
|
|
9261
9666
|
await this.failStartupIfBackendUnreachable();
|
|
9667
|
+
this.startupAdmission?.();
|
|
9262
9668
|
if (this.backend.retriesResumeOnStartupFailure?.() !== false) {
|
|
9263
9669
|
this.logger.warn("Resume startup failed — retrying resume once before abandoning the session");
|
|
9264
9670
|
await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
|
|
9671
|
+
this.startupAdmission?.();
|
|
9265
9672
|
await this.tmux.killWindow();
|
|
9673
|
+
this.startupAdmission?.();
|
|
9266
9674
|
alive = await this.trySpawn(false, resumeBudget);
|
|
9675
|
+
this.startupAdmission?.();
|
|
9267
9676
|
if (!alive) {
|
|
9268
9677
|
await this.noteStartupPaneForBackendOutage();
|
|
9678
|
+
this.startupAdmission?.();
|
|
9269
9679
|
await this.failStartupIfBackendUnreachable();
|
|
9680
|
+
this.startupAdmission?.();
|
|
9270
9681
|
}
|
|
9271
9682
|
}
|
|
9272
9683
|
}
|
|
@@ -9288,7 +9699,9 @@ export class Daemon extends EventEmitter {
|
|
|
9288
9699
|
// Keep the session and fail this attempt; the fleet retries with
|
|
9289
9700
|
// backoff, which is also how the backend-outage path behaves.
|
|
9290
9701
|
await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
|
|
9702
|
+
this.startupAdmission?.();
|
|
9291
9703
|
await this.tmux.killWindow();
|
|
9704
|
+
this.startupAdmission?.();
|
|
9292
9705
|
throw new Error(`CLI startup failed with a session to resume (attempt ${failures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
|
|
9293
9706
|
+ "— session kept, will retry");
|
|
9294
9707
|
}
|
|
@@ -9305,11 +9718,16 @@ export class Daemon extends EventEmitter {
|
|
|
9305
9718
|
// a session: nothing about a failed fresh launch says the stored
|
|
9306
9719
|
// conversation is unusable.
|
|
9307
9720
|
await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
|
|
9721
|
+
this.startupAdmission?.();
|
|
9308
9722
|
await this.tmux.killWindow();
|
|
9723
|
+
this.startupAdmission?.();
|
|
9309
9724
|
const retryAlive = await this.trySpawn(false, this.startupBudgetFor(false));
|
|
9725
|
+
this.startupAdmission?.();
|
|
9310
9726
|
if (!retryAlive) {
|
|
9311
9727
|
await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
|
|
9728
|
+
this.startupAdmission?.();
|
|
9312
9729
|
await this.tmux.killWindow();
|
|
9730
|
+
this.startupAdmission?.();
|
|
9313
9731
|
throw new Error("CLI failed to start after retry");
|
|
9314
9732
|
}
|
|
9315
9733
|
}
|
|
@@ -9331,6 +9749,133 @@ export class Daemon extends EventEmitter {
|
|
|
9331
9749
|
}
|
|
9332
9750
|
return resumedSuccessfully;
|
|
9333
9751
|
}
|
|
9752
|
+
/** The whole agent switch after a resume, on the monotonic clock (#906 §3). */
|
|
9753
|
+
static AGENT_SWITCH_BUDGET_MS = 15_000;
|
|
9754
|
+
static AGENT_SWITCH_POLL_MS = 500;
|
|
9755
|
+
/** Each tmux call of the write (load, paste, Enter, take-back) is bounded by this. */
|
|
9756
|
+
static AGENT_SWITCH_TMUX_OP_MS = 1_000;
|
|
9757
|
+
/**
|
|
9758
|
+
* The paste is started only with this much budget left, checked at the paste itself: the load and the paste, the
|
|
9759
|
+
* settle, the Enter — each bounded by AGENT_SWITCH_TMUX_OP_MS — fit in it.
|
|
9760
|
+
*/
|
|
9761
|
+
static AGENT_SWITCH_WRITE_RESERVE_MS = 4_000;
|
|
9762
|
+
/** Between the paste and its Enter, as tmux pastes elsewhere (pasteText). */
|
|
9763
|
+
static AGENT_SWITCH_PASTE_SETTLE_MS = 500;
|
|
9764
|
+
/**
|
|
9765
|
+
* #906 §3: after a resume, make the CLI run as this instance's own agent. Reads the agent off the live layout
|
|
9766
|
+
* (backend.agentSwitch().readActive — the supported bottom layout only, never a name quoted in the conversation);
|
|
9767
|
+
* when it is another one, types the switch command ONCE, under the pane-write lock, and only when ONE final capture
|
|
9768
|
+
* taken there shows the live layout naming another agent AND an idle, input-ready pane (ready, not busy, no
|
|
9769
|
+
* blocking dialog, no input transient) — after the delivery path's own readiness check. It confirms only on a
|
|
9770
|
+
* later capture of the live layout naming ours.
|
|
9771
|
+
*
|
|
9772
|
+
* Fenced like a delivery, from entry on: the spawn generation, launch fence, the pause/wake phase it started in,
|
|
9773
|
+
* the same tmux window, not aborted, writes not stopping — re-checked after every await, and between the paste and
|
|
9774
|
+
* its Enter. The whole step, every await included, ends at one monotonic deadline: a late capture never confirms
|
|
9775
|
+
* and a late admission never writes; a paste whose Enter would land past it is taken back. Not confirmed in time:
|
|
9776
|
+
* the old setup stays, a launch warning says so, the next launch tries again. A stop, pause or respawn ends it
|
|
9777
|
+
* silently, confirming and removing nothing. Never throws into the spawn.
|
|
9778
|
+
*/
|
|
9779
|
+
async ensureBackendAgent() {
|
|
9780
|
+
let sw = null;
|
|
9781
|
+
try {
|
|
9782
|
+
sw = this.backend?.agentSwitch?.() ?? null;
|
|
9783
|
+
}
|
|
9784
|
+
catch {
|
|
9785
|
+
sw = null;
|
|
9786
|
+
}
|
|
9787
|
+
const tmux = this.tmux;
|
|
9788
|
+
if (!sw || !tmux || !this.backend)
|
|
9789
|
+
return;
|
|
9790
|
+
const windowId = tmux.getWindowId();
|
|
9791
|
+
const spawn = this.spawnGeneration, fence = this.launchFenceEpoch, phase = this.pauseWakeState;
|
|
9792
|
+
const fenced = () => !this.startupAborted && !this.deliveryWritesStopping && !this.fatalStartupBlocked
|
|
9793
|
+
&& (phase === "active" || phase === "waking") && this.pauseWakeState === phase
|
|
9794
|
+
&& spawn === this.spawnGeneration && fence === this.launchFenceEpoch && this.tmux === tmux;
|
|
9795
|
+
const deadline = performance.now() + Daemon.AGENT_SWITCH_BUDGET_MS;
|
|
9796
|
+
const live = () => { this.startupAdmission?.(); return fenced() && performance.now() < deadline; };
|
|
9797
|
+
if (!live())
|
|
9798
|
+
return;
|
|
9799
|
+
const TIMEOUT = Symbol("deadline");
|
|
9800
|
+
const within = (work) => {
|
|
9801
|
+
const left = deadline - performance.now();
|
|
9802
|
+
if (left <= 0) {
|
|
9803
|
+
work.catch(() => { });
|
|
9804
|
+
return Promise.resolve(TIMEOUT);
|
|
9805
|
+
}
|
|
9806
|
+
let timer;
|
|
9807
|
+
return Promise.race([work, new Promise(r => { timer = setTimeout(() => r(TIMEOUT), left); })])
|
|
9808
|
+
.finally(() => clearTimeout(timer));
|
|
9809
|
+
};
|
|
9810
|
+
const capture = () => within(tmux.capturePane().catch(() => null));
|
|
9811
|
+
let wroteAt = null;
|
|
9812
|
+
try {
|
|
9813
|
+
while (live()) {
|
|
9814
|
+
const capturedAt = performance.now();
|
|
9815
|
+
const pane = await capture();
|
|
9816
|
+
if (pane === TIMEOUT || !live())
|
|
9817
|
+
break;
|
|
9818
|
+
const active = pane === null ? null : sw.readActive(pane);
|
|
9819
|
+
if (active === sw.agent && (wroteAt === null || capturedAt > wroteAt)) {
|
|
9820
|
+
if (sw.alreadyConfirmed && wroteAt === null)
|
|
9821
|
+
return; // already ours, already on record
|
|
9822
|
+
for (const warning of sw.confirm())
|
|
9823
|
+
this.emit("backend_launch_warning", { name: this.name, message: warning });
|
|
9824
|
+
this.logger.info({ agent: sw.agent, switched: wroteAt !== null }, "The resumed conversation runs as this instance's agent");
|
|
9825
|
+
return;
|
|
9826
|
+
}
|
|
9827
|
+
if (wroteAt === null && active !== null && active !== sw.agent) {
|
|
9828
|
+
const wrote = await within(this.paneWriteLock.run(async () => {
|
|
9829
|
+
// The whole write is one owned transaction under the lock. Every await is bounded; a take-back of a paste
|
|
9830
|
+
// whose Enter would land past the deadline happens here, before the lock is released, so it can never
|
|
9831
|
+
// edit a write admitted after this one.
|
|
9832
|
+
if (!live())
|
|
9833
|
+
return false;
|
|
9834
|
+
const readiness = await within(this.paneReadinessForDelivery(windowId));
|
|
9835
|
+
if (readiness !== "ready" || !live())
|
|
9836
|
+
return false;
|
|
9837
|
+
const final = await capture();
|
|
9838
|
+
if (final === TIMEOUT || final === null || !live())
|
|
9839
|
+
return false;
|
|
9840
|
+
const now = sw.readActive(final);
|
|
9841
|
+
if (now === null || now === sw.agent || this.inputTransientInPane(final) || !this.paneAuthoritativelyIdle(final))
|
|
9842
|
+
return false;
|
|
9843
|
+
// The reserve, at the paste boundary — after the final proof, which may have used some of the budget.
|
|
9844
|
+
if (deadline - performance.now() < Daemon.AGENT_SWITCH_WRITE_RESERVE_MS)
|
|
9845
|
+
return false;
|
|
9846
|
+
const op = Daemon.AGENT_SWITCH_TMUX_OP_MS;
|
|
9847
|
+
// live() is asked before every tmux mutation inside the paste, so a stop, pause, respawn or the deadline
|
|
9848
|
+
// between its load and its paste never reaches the pane.
|
|
9849
|
+
const pasted = await tmux.pasteBuffer(sw.command, { guard: live, timeoutMs: op });
|
|
9850
|
+
if (!pasted)
|
|
9851
|
+
return false;
|
|
9852
|
+
await new Promise(r => setTimeout(r, Daemon.AGENT_SWITCH_PASTE_SETTLE_MS));
|
|
9853
|
+
if (live())
|
|
9854
|
+
return tmux.sendSpecialKey("Enter", op);
|
|
9855
|
+
// Same CLI, but no time left to see it through: take the command back out of its input row (bounded).
|
|
9856
|
+
if (fenced())
|
|
9857
|
+
await tmux.deleteBackward(sw.command.length, op);
|
|
9858
|
+
return false;
|
|
9859
|
+
}));
|
|
9860
|
+
if (wrote === true) {
|
|
9861
|
+
wroteAt = performance.now();
|
|
9862
|
+
this.logger.info({ agent: sw.agent, was: active }, "Switching the resumed conversation to this instance's agent");
|
|
9863
|
+
}
|
|
9864
|
+
}
|
|
9865
|
+
if (!live())
|
|
9866
|
+
break;
|
|
9867
|
+
await within(new Promise(r => setTimeout(r, Math.min(Daemon.AGENT_SWITCH_POLL_MS, Math.max(0, deadline - performance.now())))));
|
|
9868
|
+
}
|
|
9869
|
+
if (!fenced())
|
|
9870
|
+
return; // a stop, pause or respawn: nothing to say, nothing to do
|
|
9871
|
+
this.logger.warn({ agent: sw.agent, typed: wroteAt !== null }, "The resumed conversation did not switch to this instance's agent in time");
|
|
9872
|
+
this.emit("backend_launch_warning", { name: this.name, message: t("kiro.switch_timeout", Daemon.AGENT_SWITCH_BUDGET_MS / 1000) });
|
|
9873
|
+
}
|
|
9874
|
+
catch (err) {
|
|
9875
|
+
this.startupAdmission?.();
|
|
9876
|
+
this.logger.warn({ err }, "The agent switch after resume failed — the shared entries stay; the next launch tries again");
|
|
9877
|
+
}
|
|
9878
|
+
}
|
|
9334
9879
|
/**
|
|
9335
9880
|
* Startup budget for this launch: the backend's override (resume-aware), never
|
|
9336
9881
|
* below a user-configured startup_timeout_ms; undefined = trySpawn's default.
|
|
@@ -9472,13 +10017,19 @@ export class Daemon extends EventEmitter {
|
|
|
9472
10017
|
* Returns true if CLI is ready, false if it failed or got stuck.
|
|
9473
10018
|
*/
|
|
9474
10019
|
async trySpawn(reuseWindow = false, startupTimeoutMs) {
|
|
9475
|
-
|
|
9476
|
-
return this.trySpawnInsideGate(reuseWindow, startupTimeoutMs);
|
|
9477
|
-
return this.spawnGate.run({
|
|
10020
|
+
const ready = !this.spawnGate ? await this.trySpawnInsideGate(reuseWindow, startupTimeoutMs) : await this.spawnGate.run({
|
|
9478
10021
|
instanceName: this.name,
|
|
9479
10022
|
workingDirectory: this.config.working_directory,
|
|
9480
10023
|
reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
|
|
9481
10024
|
}, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
|
|
10025
|
+
// #906: a resumed kiro conversation comes back as the agent it was saved under. Every launch path that reaches a
|
|
10026
|
+
// ready CLI (start, recovery, wake) switches it to this instance's own agent here, while the spawn still holds
|
|
10027
|
+
// deliveries — and outside the spawn gate, which it does not need.
|
|
10028
|
+
this.startupAdmission?.();
|
|
10029
|
+
if (ready)
|
|
10030
|
+
await this.ensureBackendAgent();
|
|
10031
|
+
this.startupAdmission?.();
|
|
10032
|
+
return ready;
|
|
9482
10033
|
}
|
|
9483
10034
|
/**
|
|
9484
10035
|
* One fresh pane capture judged as ready && !busy && !dialog. Transcript
|
|
@@ -9662,7 +10213,7 @@ export class Daemon extends EventEmitter {
|
|
|
9662
10213
|
// This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
|
|
9663
10214
|
// delivery is already in flight or queued. Without the lock the notice and
|
|
9664
10215
|
// that delivery race into the same pane.
|
|
9665
|
-
const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir), "instruction-reload-notice"));
|
|
10216
|
+
const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir, this.backend?.instructionsSource?.()), "instruction-reload-notice"));
|
|
9666
10217
|
if (!told) {
|
|
9667
10218
|
// Recording the new instructions here would mark the agent as told about
|
|
9668
10219
|
// a notice it never received, and every later restart would then skip
|
|
@@ -9683,6 +10234,7 @@ export class Daemon extends EventEmitter {
|
|
|
9683
10234
|
catch { /* non-fatal */ }
|
|
9684
10235
|
}
|
|
9685
10236
|
async trySpawnInsideGate(reuseWindow = false, startupTimeoutMs) {
|
|
10237
|
+
this.startupAdmission?.();
|
|
9686
10238
|
const launchFence = this.launchFenceEpoch;
|
|
9687
10239
|
const backendConfig = this.buildBackendConfig();
|
|
9688
10240
|
// Compare freshly-built instructions against the last value the agent was
|
|
@@ -9722,11 +10274,12 @@ export class Daemon extends EventEmitter {
|
|
|
9722
10274
|
// A backend that has to ask its CLI something before the launch command exists (OpenCode: does
|
|
9723
10275
|
// this binary take --auto?) does it here, off the event loop, bounded, and never fails the launch.
|
|
9724
10276
|
try {
|
|
9725
|
-
await this.backend.prepareLaunch?.();
|
|
10277
|
+
await this.backend.prepareLaunch?.(backendConfig);
|
|
9726
10278
|
}
|
|
9727
10279
|
catch { /* unknown capability → the backend's conservative form */ }
|
|
9728
10280
|
// Thrown, not `false`: a false verdict reads as "the CLI failed to start" and sends the startup path
|
|
9729
10281
|
// into its resume-retry / set-the-session-aside handling, for an instance somebody just stopped.
|
|
10282
|
+
this.startupAdmission?.();
|
|
9730
10283
|
if (launchFence !== this.launchFenceEpoch)
|
|
9731
10284
|
throw new Error("Launch cancelled: the instance was stopped or paused while the launch was being prepared");
|
|
9732
10285
|
this.backend.writeConfig(backendConfig);
|
|
@@ -9781,6 +10334,7 @@ export class Daemon extends EventEmitter {
|
|
|
9781
10334
|
// The CLI's own shell sets a zero coredump_filter first (#1113): this pane
|
|
9782
10335
|
// may belong to a tmux server the fleet did not start, which would hand
|
|
9783
10336
|
// the CLI its own (full) filter instead of the fleet's.
|
|
10337
|
+
this.startupAdmission?.();
|
|
9784
10338
|
const cmd = coredumpFilterLaunchPrefix() + `${envPrefix} ` + this.backend.buildCommand(launchConfig);
|
|
9785
10339
|
// Every launched command re-arms the passive-transient check, including a
|
|
9786
10340
|
// retry inside the same spawn: its load is a new one.
|
|
@@ -9803,6 +10357,7 @@ export class Daemon extends EventEmitter {
|
|
|
9803
10357
|
if (this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName))) {
|
|
9804
10358
|
this.emit("tmux_server_crash", this.name);
|
|
9805
10359
|
}
|
|
10360
|
+
this.startupAdmission?.();
|
|
9806
10361
|
let windowId;
|
|
9807
10362
|
if (reuseWindow) {
|
|
9808
10363
|
this.controlClient?.unregisterWindow(this.tmux.getWindowId());
|
|
@@ -9817,6 +10372,7 @@ export class Daemon extends EventEmitter {
|
|
|
9817
10372
|
if (retired && retired !== windowId)
|
|
9818
10373
|
this.controlClient?.unregisterWindow(retired);
|
|
9819
10374
|
}
|
|
10375
|
+
this.startupAdmission?.();
|
|
9820
10376
|
writeFileSync(join(this.instanceDir, "window-id"), windowId);
|
|
9821
10377
|
// Enable remain-on-exit to capture exit codes on crash
|
|
9822
10378
|
await this.tmux.setRemainOnExit().catch(err => {
|
|
@@ -9925,8 +10481,8 @@ export class Daemon extends EventEmitter {
|
|
|
9925
10481
|
{ pattern: /No, exit|No, quit|Don't trust|I accept|I trust|Yes, continue|Trust folder/i, keys: ["Enter"], description: "Generic confirmation dialog" },
|
|
9926
10482
|
{ pattern: /Resume Session/i, keys: ["Escape"], description: "Resume session picker — start fresh" },
|
|
9927
10483
|
];
|
|
9928
|
-
const deadline =
|
|
9929
|
-
const remaining = () => deadline -
|
|
10484
|
+
const deadline = performance.now() + budgetMs;
|
|
10485
|
+
const remaining = () => deadline - performance.now();
|
|
9930
10486
|
const sleep = async () => { if (remaining() > 0)
|
|
9931
10487
|
await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
|
|
9932
10488
|
let cleanReadyPolls = 0;
|
|
@@ -9938,13 +10494,26 @@ export class Daemon extends EventEmitter {
|
|
|
9938
10494
|
// repainted after Enter, let the following hold-only entry report it
|
|
9939
10495
|
// instead of sending a second Enter into a possibly changed screen.
|
|
9940
10496
|
const attemptedSafetyChoices = new Set();
|
|
10497
|
+
// The scan belongs to this launch: its tmux, spawn and launch fence. A respawn, pause or stop that replaces any of
|
|
10498
|
+
// them retires it — a key meant for this launch's dialog must never reach the replacement (#1435 review).
|
|
10499
|
+
const ownerTmux = this.tmux;
|
|
10500
|
+
const ownerSpawn = this.spawnGeneration;
|
|
10501
|
+
const ownerFence = this.launchFenceEpoch;
|
|
10502
|
+
const owned = () => this.tmux === ownerTmux && this.spawnGeneration === ownerSpawn && this.launchFenceEpoch === ownerFence;
|
|
9941
10503
|
do {
|
|
10504
|
+
this.startupAdmission?.();
|
|
9942
10505
|
attempts++;
|
|
10506
|
+
if (!owned()) {
|
|
10507
|
+
this.logger.info("Startup dialog scan superseded by a newer launch, pause or stop — retiring without answering");
|
|
10508
|
+
return true;
|
|
10509
|
+
}
|
|
9943
10510
|
let pane;
|
|
9944
10511
|
try {
|
|
9945
10512
|
pane = await this.tmux.capturePane();
|
|
10513
|
+
this.startupAdmission?.();
|
|
9946
10514
|
}
|
|
9947
10515
|
catch (err) {
|
|
10516
|
+
this.startupAdmission?.();
|
|
9948
10517
|
// Transient tmux trouble is not evidence about the CLI. Returning false
|
|
9949
10518
|
// here would clear the session; retry within the budget instead.
|
|
9950
10519
|
captureFailures++;
|
|
@@ -10022,21 +10591,34 @@ export class Daemon extends EventEmitter {
|
|
|
10022
10591
|
// delivery on `spawning`. Take the pane lock for the key sequence so a
|
|
10023
10592
|
// queued message cannot be pasted into a half-dismissed trust dialog.
|
|
10024
10593
|
const sent = await this.paneWriteLock.run(async () => {
|
|
10594
|
+
this.startupAdmission?.();
|
|
10025
10595
|
// The capture above may have gone stale while waiting for the
|
|
10026
10596
|
// write lock. Trust/other safety prompts must still be the
|
|
10027
10597
|
// CURRENT menu, with the same safe cursor, at the instant of
|
|
10028
|
-
// the key send. A changed pane falls through to the next scan
|
|
10598
|
+
// the key send. A changed pane falls through to the next scan;
|
|
10599
|
+
// a pane that is no longer this launch's is not read or keyed.
|
|
10600
|
+
if (!owned())
|
|
10601
|
+
return false;
|
|
10029
10602
|
if (dialog.inputBlocked) {
|
|
10030
10603
|
const currentPane = await this.tmux.capturePane();
|
|
10604
|
+
this.startupAdmission?.();
|
|
10605
|
+
// The capture is an await too: a replacement launched during it must find no answer recorded as its own.
|
|
10606
|
+
if (!owned())
|
|
10607
|
+
return false;
|
|
10031
10608
|
if (!Daemon.dialogMatches(dialog, currentPane))
|
|
10032
10609
|
return false;
|
|
10033
10610
|
}
|
|
10034
10611
|
if (dialog.autoResolutionKey) {
|
|
10035
10612
|
attemptedSafetyChoices.add(dialog.autoResolutionKey);
|
|
10036
|
-
this.autoResolvedDialogGeneration =
|
|
10613
|
+
this.autoResolvedDialogGeneration = ownerSpawn;
|
|
10037
10614
|
this.autoResolvedDialogKey = dialog.autoResolutionKey;
|
|
10038
10615
|
}
|
|
10039
10616
|
for (const key of dialog.keys) {
|
|
10617
|
+
this.startupAdmission?.();
|
|
10618
|
+
// Before every key, synchronously: the lock wait, the capture and the gap after the previous key can
|
|
10619
|
+
// each have seen a respawn, pause or stop.
|
|
10620
|
+
if (!owned())
|
|
10621
|
+
return false;
|
|
10040
10622
|
if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
|
|
10041
10623
|
if (!await this.tmux.sendSpecialKey(key))
|
|
10042
10624
|
return false;
|
|
@@ -10118,6 +10700,7 @@ export class Daemon extends EventEmitter {
|
|
|
10118
10700
|
return false;
|
|
10119
10701
|
}
|
|
10120
10702
|
catch (err) {
|
|
10703
|
+
this.startupAdmission?.();
|
|
10121
10704
|
// Key sends / isWindowAlive failing: same rule — log, retry within the budget.
|
|
10122
10705
|
this.logger.warn({ err }, "startup dialog scan step failed — retrying");
|
|
10123
10706
|
if ((await this.paneLiveness()) === "dead")
|
|
@@ -10135,6 +10718,7 @@ export class Daemon extends EventEmitter {
|
|
|
10135
10718
|
else {
|
|
10136
10719
|
this.logger.warn({ attempts, budgetMs, captureFailures }, "Startup scan exhausted without a ready prompt or a known dialog — assuming ready (unknown CLI screen)");
|
|
10137
10720
|
}
|
|
10721
|
+
this.startupAdmission?.();
|
|
10138
10722
|
return true;
|
|
10139
10723
|
}
|
|
10140
10724
|
/**
|