@songsid/agend 2.1.12 → 2.2.0-alpha.2
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 +4 -2
- package/README.zh-TW.md +4 -2
- package/dist/agent-cli.js +6 -1
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +68 -12
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-token-header.d.ts +13 -0
- package/dist/agent-token-header.js +16 -0
- package/dist/agent-token-header.js.map +1 -0
- 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/antigravity.js +4 -3
- package/dist/backend/antigravity.js.map +1 -1
- 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/grok.js +1 -1
- package/dist/backend/grok.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 +90 -7
- package/dist/backend/kiro.js +500 -124
- package/dist/backend/kiro.js.map +1 -1
- package/dist/backend/muse.js +1 -1
- package/dist/backend/muse.js.map +1 -1
- package/dist/backend/types.d.ts +51 -3
- package/dist/backend/types.js.map +1 -1
- package/dist/cache-analysis.d.ts +92 -0
- package/dist/cache-analysis.js +147 -0
- package/dist/cache-analysis.js.map +1 -0
- package/dist/cache-ledger.d.ts +108 -0
- package/dist/cache-ledger.js +230 -0
- package/dist/cache-ledger.js.map +1 -0
- package/dist/cache-prices.d.ts +32 -0
- package/dist/cache-prices.js +73 -0
- package/dist/cache-prices.js.map +1 -0
- package/dist/cache-scan.d.ts +39 -0
- package/dist/cache-scan.js +115 -0
- package/dist/cache-scan.js.map +1 -0
- package/dist/cache-service.d.ts +115 -0
- package/dist/cache-service.js +417 -0
- package/dist/cache-service.js.map +1 -0
- 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-entry.d.ts +19 -0
- package/dist/cli-entry.js +36 -0
- package/dist/cli-entry.js.map +1 -0
- package/dist/cli.js +471 -146
- 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 +95 -4
- package/dist/daemon.js +748 -111
- 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-log.d.ts +4 -1
- package/dist/event-log.js +6 -3
- package/dist/event-log.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-control-audit.d.ts +5 -0
- package/dist/fleet-control-audit.js +7 -0
- package/dist/fleet-control-audit.js.map +1 -1
- package/dist/fleet-level-config.d.ts +2 -2
- package/dist/fleet-level-config.js +28 -1
- package/dist/fleet-level-config.js.map +1 -1
- package/dist/fleet-manager.d.ts +339 -12
- package/dist/fleet-manager.js +2623 -461
- package/dist/fleet-manager.js.map +1 -1
- package/dist/full-restart.js +2 -3
- package/dist/full-restart.js.map +1 -1
- package/dist/general-knowledge/skills/persona-emoji/SKILL.md +3 -1
- package/dist/host-memory.d.ts +4 -0
- package/dist/host-memory.js.map +1 -1
- package/dist/install-lock.d.ts +49 -0
- package/dist/install-lock.js +200 -0
- package/dist/install-lock.js.map +1 -0
- package/dist/instance-config-impact.js +8 -2
- 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 +164 -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/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/package-preimage.d.ts +26 -0
- package/dist/package-preimage.js +96 -0
- package/dist/package-preimage.js.map +1 -0
- 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 +16 -6
- package/dist/quickstart.js.map +1 -1
- package/dist/restart-guard.d.ts +52 -0
- package/dist/restart-guard.js +109 -0
- package/dist/restart-guard.js.map +1 -0
- package/dist/scheduler/db.d.ts +7 -1
- package/dist/scheduler/db.js +44 -2
- 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 +152 -0
- package/dist/service-activation.js +407 -0
- package/dist/service-activation.js.map +1 -0
- package/dist/service-installer.d.ts +63 -1
- package/dist/service-installer.js +141 -16
- package/dist/service-installer.js.map +1 -1
- package/dist/service-plan.d.ts +65 -0
- package/dist/service-plan.js +108 -0
- package/dist/service-plan.js.map +1 -0
- 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 +216 -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 +161 -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 +199 -35
- package/dist/setup-host.js.map +1 -1
- package/dist/setup-wizard.js +5 -1
- 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/sqlite-open-errors.d.ts +12 -0
- package/dist/sqlite-open-errors.js +13 -0
- package/dist/sqlite-open-errors.js.map +1 -0
- 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 +57 -5
- package/dist/tmux-control.js +379 -61
- 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 +205 -63
- 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-commands.js +43 -0
- package/dist/ui/chat-render.js +478 -0
- package/dist/ui/chat-store.js +318 -0
- package/dist/ui/chat-strings.js +89 -0
- package/dist/ui/chat-thread.js +246 -0
- package/dist/ui/chat-tour.js +116 -0
- package/dist/ui/fleet-cache.js +168 -0
- package/dist/ui/fleet-org.js +142 -0
- package/dist/ui/panel-chat.js +504 -0
- package/dist/ui/panel-fleet.js +427 -0
- package/dist/ui/panel-needs.js +202 -0
- package/dist/ui/panel-settings.js +754 -0
- package/dist/ui/preview-panel.js +202 -0
- package/dist/ui/preview.js +270 -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 +654 -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 +346 -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 +820 -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 +142 -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 +63 -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 +52 -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-dispatch.d.ts +28 -0
- package/dist/update-dispatch.js +73 -0
- package/dist/update-dispatch.js.map +1 -0
- package/dist/update-install.d.ts +129 -0
- package/dist/update-install.js +262 -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 +85 -14
- package/dist/web-api.js +557 -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-commands.d.ts +77 -0
- package/dist/web-commands.js +81 -0
- package/dist/web-commands.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/launcher/agend +110 -0
- package/launcher/agend-agent +110 -0
- package/launcher/agend-agent.cjs +4 -0
- package/launcher/agend.cjs +4 -0
- package/launcher/install-admission.cjs +105 -0
- package/launcher/launch.cjs +64 -0
- package/launcher/postinstall.cjs +114 -0
- package/launcher/run-in-process.cjs +13 -0
- package/launcher/runtime-platform.cjs +86 -0
- package/launcher/runtime-select.cjs +217 -0
- package/package.json +18 -7
- package/scripts/preinstall-guard.cjs +55 -0
- package/templates/launchd.plist.ejs +2 -2
- package/templates/systemd.service.ejs +1 -1
- 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;
|
|
@@ -1142,6 +1168,8 @@ export class Daemon extends EventEmitter {
|
|
|
1142
1168
|
/** Generation/signature fence for safety dialogs while their screen is still visible. */
|
|
1143
1169
|
autoResolvedDialogKey = null;
|
|
1144
1170
|
autoResolvedDialogGeneration = 0;
|
|
1171
|
+
/** Per-launch safety phases survive startup handoff, redraws and failed/uncertain key ACKs. */
|
|
1172
|
+
launchDialogClaims = null;
|
|
1145
1173
|
/**
|
|
1146
1174
|
* (#1103) Proof that THIS spawn's Codex went onto Luna Reserve: AgEnD saw (and
|
|
1147
1175
|
* answered) the usage-limit menu that codex shows when it switches itself to
|
|
@@ -1732,7 +1760,7 @@ export class Daemon extends EventEmitter {
|
|
|
1732
1760
|
this.emit("durable_delivery_deferred", { ...delivery, targetBootId: this.bootId, reason });
|
|
1733
1761
|
}
|
|
1734
1762
|
}
|
|
1735
|
-
finishDurableDelivery(delivery, outcome, evidence) {
|
|
1763
|
+
finishDurableDelivery(delivery, outcome, evidence, verdict) {
|
|
1736
1764
|
if (!delivery)
|
|
1737
1765
|
return;
|
|
1738
1766
|
// Delivered or uncertain means the pane write started: the work reached
|
|
@@ -1741,13 +1769,50 @@ export class Daemon extends EventEmitter {
|
|
|
1741
1769
|
if (outcome !== "failed")
|
|
1742
1770
|
this.noteWorkActivity();
|
|
1743
1771
|
this.deliveryOutbox?.complete(delivery.deliveryId, this.bootId, delivery.attemptNo, outcome, evidence);
|
|
1772
|
+
if (verdict && outcome !== "failed")
|
|
1773
|
+
this.startConsumedWatch(delivery, verdict);
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* #1201: a steer or a native-queue hand-off was accepted into the CLI's input (or may have been) — not read. Watch
|
|
1777
|
+
* the transcript from this attempt's checkpoint for the moment the CLI takes it, and record that on the delivery;
|
|
1778
|
+
* an `uncertain` row becomes `delivered` then. Only claude-code and codex write a transcript AgEnD can read. The
|
|
1779
|
+
* watch belongs to this CLI: a stop, pause or respawn ends it (restart reconciliation owns the row after that).
|
|
1780
|
+
*/
|
|
1781
|
+
startConsumedWatch(delivery, verdict) {
|
|
1782
|
+
const checkpoint = verdict.transcriptCheckpoint;
|
|
1783
|
+
const outbox = this.deliveryOutbox;
|
|
1784
|
+
if (!checkpoint || !outbox?.markConsumed)
|
|
1785
|
+
return;
|
|
1786
|
+
if (verdict.submissionMode !== "steer" && verdict.submissionMode !== "native_queue_handoff")
|
|
1787
|
+
return;
|
|
1788
|
+
if (!["claude-code", "codex"].includes(checkpoint.backend))
|
|
1789
|
+
return;
|
|
1790
|
+
const spawn = this.spawnGeneration, fence = this.launchFenceEpoch, boot = this.bootId;
|
|
1791
|
+
consumedWatches.start({
|
|
1792
|
+
deliveryId: delivery.deliveryId,
|
|
1793
|
+
attemptNo: delivery.attemptNo,
|
|
1794
|
+
backend: checkpoint.backend,
|
|
1795
|
+
path: checkpoint.path,
|
|
1796
|
+
offset: checkpoint.offset,
|
|
1797
|
+
current: () => !this.deliveryWritesStopping && spawn === this.spawnGeneration && fence === this.launchFenceEpoch,
|
|
1798
|
+
consumed: (via, evidence) => {
|
|
1799
|
+
try {
|
|
1800
|
+
const result = outbox.markConsumed(delivery.deliveryId, boot, delivery.attemptNo, via, evidence);
|
|
1801
|
+
this.logger.info({ deliveryId: delivery.deliveryId, via, result }, "The CLI's transcript shows the delivery consumed");
|
|
1802
|
+
}
|
|
1803
|
+
catch (err) {
|
|
1804
|
+
this.logger.warn({ err, deliveryId: delivery.deliveryId }, "Could not record the consumed signal (the row keeps its state)");
|
|
1805
|
+
}
|
|
1806
|
+
},
|
|
1807
|
+
logger: this.logger,
|
|
1808
|
+
});
|
|
1744
1809
|
}
|
|
1745
1810
|
finishDurableSubmission(delivery, verdict) {
|
|
1746
1811
|
// Legacy backends may accept a pane write without enough pane evidence to
|
|
1747
1812
|
// claim submission. Keep that row inspectable instead of recording a false
|
|
1748
1813
|
// delivered state; reconciliation can classify it in a later phase.
|
|
1749
1814
|
if (verdict.proof === "unverified") {
|
|
1750
|
-
this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified
|
|
1815
|
+
this.finishDurableDelivery(delivery, "uncertain", `${verdict.phase ?? "submission"}:unverified`, verdict);
|
|
1751
1816
|
return;
|
|
1752
1817
|
}
|
|
1753
1818
|
// 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 +1820,14 @@ export class Daemon extends EventEmitter {
|
|
|
1755
1820
|
// it", and delivery_status carries delivery_mode=steer — and it is all this class of backend can ever show for a steer
|
|
1756
1821
|
// (#1197). Labelled, so it is never mistaken for a proof that the input row was cleared.
|
|
1757
1822
|
if (verdict.proof === "steer-marker-on-pane") {
|
|
1758
|
-
this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable");
|
|
1823
|
+
this.finishDurableDelivery(delivery, "delivered", "steer-accepted-marker-on-pane; input-row-unreadable", verdict);
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
// A steer into a mode-switched composer (kiro TUI, #1405): the box held the paste before the one Enter and read as
|
|
1827
|
+
// its empty placeholder after it — taken by the CLI (steered into the turn, held for its end on "queue", or a new
|
|
1828
|
+
// turn on "idle"). Accepted, not read; labelled with what the composer showed.
|
|
1829
|
+
if (verdict.proof?.startsWith("steer-composer-")) {
|
|
1830
|
+
this.finishDurableDelivery(delivery, "delivered", `steer-accepted; composer emptied (${verdict.proof.slice("steer-composer-".length)})`, verdict);
|
|
1759
1831
|
return;
|
|
1760
1832
|
}
|
|
1761
1833
|
// A backend whose input row cannot be read: all that vouched for this delivery is that the pane printed something
|
|
@@ -1771,7 +1843,7 @@ export class Daemon extends EventEmitter {
|
|
|
1771
1843
|
return;
|
|
1772
1844
|
}
|
|
1773
1845
|
this.finishDurableDelivery(delivery, "delivered", verdict.transcriptProof
|
|
1774
|
-
? `positive submission proof; ${transcriptMarkerEvidence(verdict.transcriptProof)}` : "positive submission proof");
|
|
1846
|
+
? `positive submission proof; ${transcriptMarkerEvidence(verdict.transcriptProof)}` : "positive submission proof", verdict);
|
|
1775
1847
|
}
|
|
1776
1848
|
/**
|
|
1777
1849
|
* Finish a delivery that only the output edge vouched for, by the delivery's own marker in the CLI's transcript (#758).
|
|
@@ -1798,7 +1870,7 @@ export class Daemon extends EventEmitter {
|
|
|
1798
1870
|
if (this.deliveryWritesStopping)
|
|
1799
1871
|
return; // reconciliation owns the row now
|
|
1800
1872
|
const found = await scanTranscriptForDeliveryMarker(checkpoint.path, checkpoint.offset, checkpoint.backend, delivery.deliveryId);
|
|
1801
|
-
if (found === "user" || found === "queued") {
|
|
1873
|
+
if (found === "user" || found === "absorbed" || found === "queued") {
|
|
1802
1874
|
outcome = "delivered";
|
|
1803
1875
|
evidence = transcriptMarkerEvidence(found);
|
|
1804
1876
|
break;
|
|
@@ -1861,6 +1933,14 @@ export class Daemon extends EventEmitter {
|
|
|
1861
1933
|
seedActivityNow(now = Date.now()) {
|
|
1862
1934
|
this.autoPauseController.recordActivity(now);
|
|
1863
1935
|
}
|
|
1936
|
+
/**
|
|
1937
|
+
* Whether this launch takes a steer into a running turn, for a backend that answers per launch (kiro's TUI vs its
|
|
1938
|
+
* legacy UI, #1405); undefined for every other backend, whose name decides (steer-capability.ts). The backend reads
|
|
1939
|
+
* what it last launched — no probe, no fork, safe on the fleet loop.
|
|
1940
|
+
*/
|
|
1941
|
+
launchSupportsSteer() {
|
|
1942
|
+
return this.backend?.supportsSteer?.();
|
|
1943
|
+
}
|
|
1864
1944
|
/**
|
|
1865
1945
|
* Phase 2b (design §1.4): why this awake instance is not taking input, in
|
|
1866
1946
|
* words for a notice, or null when nothing known holds it. Read from the
|
|
@@ -1904,7 +1984,19 @@ export class Daemon extends EventEmitter {
|
|
|
1904
1984
|
this.logger.warn({ err }, "Failed to attach pipe-pane — instance output log will be missing");
|
|
1905
1985
|
});
|
|
1906
1986
|
}
|
|
1907
|
-
|
|
1987
|
+
startupAdmission;
|
|
1988
|
+
async start(admission) {
|
|
1989
|
+
this.startupAdmission = admission;
|
|
1990
|
+
try {
|
|
1991
|
+
admission?.();
|
|
1992
|
+
await this.startAdmitted();
|
|
1993
|
+
admission?.();
|
|
1994
|
+
}
|
|
1995
|
+
finally {
|
|
1996
|
+
this.startupAdmission = undefined;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
async startAdmitted() {
|
|
1908
2000
|
ensureInstanceDir(this.instanceDir);
|
|
1909
2001
|
writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
|
|
1910
2002
|
this.logger.info(`Starting ${this.name}`);
|
|
@@ -1961,6 +2053,7 @@ export class Daemon extends EventEmitter {
|
|
|
1961
2053
|
this.emit("error", err);
|
|
1962
2054
|
});
|
|
1963
2055
|
await this.ipcServer.listen();
|
|
2056
|
+
this.startupAdmission?.();
|
|
1964
2057
|
ipcListening = true;
|
|
1965
2058
|
// Permanent IPC dispatcher: routes responses to pending requests by type+id key
|
|
1966
2059
|
this.ipcServer.on("message", (msg) => this.routeFleetResponse(msg));
|
|
@@ -2032,7 +2125,8 @@ export class Daemon extends EventEmitter {
|
|
|
2032
2125
|
});
|
|
2033
2126
|
// 2. Tmux — ensure session, create window if not alive
|
|
2034
2127
|
await TmuxManager.ensureSession(this.tmuxSessionName);
|
|
2035
|
-
this.
|
|
2128
|
+
this.startupAdmission?.();
|
|
2129
|
+
this.tmux = new TmuxManager(this.tmuxSessionName, "", resolveTmuxLogicalSize(this.config.terminal), this.controlClient);
|
|
2036
2130
|
// Strategy A: always start fresh Claude window (MCP server has no reconnection)
|
|
2037
2131
|
// Kill any existing window from previous run
|
|
2038
2132
|
const windowIdFile = join(this.instanceDir, "window-id");
|
|
@@ -2041,16 +2135,21 @@ export class Daemon extends EventEmitter {
|
|
|
2041
2135
|
if (savedId) {
|
|
2042
2136
|
const oldTmux = new TmuxManager(this.tmuxSessionName, savedId);
|
|
2043
2137
|
if (await oldTmux.isWindowAlive()) {
|
|
2138
|
+
this.startupAdmission?.();
|
|
2044
2139
|
await this.checkpointSessionId();
|
|
2140
|
+
this.startupAdmission?.();
|
|
2045
2141
|
await oldTmux.killWindow();
|
|
2142
|
+
this.startupAdmission?.();
|
|
2046
2143
|
this.logger.info({ savedId }, "Killed old tmux window for fresh start");
|
|
2047
2144
|
}
|
|
2048
2145
|
}
|
|
2049
2146
|
}
|
|
2050
2147
|
const resumed = await this.spawnClaudeWindow();
|
|
2148
|
+
this.startupAdmission?.();
|
|
2051
2149
|
this.isNewSession = !resumed;
|
|
2052
2150
|
if (!resumed) {
|
|
2053
|
-
await this.injectSnapshotMessage();
|
|
2151
|
+
await this.injectSnapshotMessage(this.startupAdmission);
|
|
2152
|
+
this.startupAdmission?.();
|
|
2054
2153
|
}
|
|
2055
2154
|
else {
|
|
2056
2155
|
// Clean up stale snapshot file — resume restored full context, snapshot not needed
|
|
@@ -2074,6 +2173,7 @@ export class Daemon extends EventEmitter {
|
|
|
2074
2173
|
// we attach — pipe-pane uses `cat >>` on the same inode, so copytruncate
|
|
2075
2174
|
// keeps the writer attached after size resets.
|
|
2076
2175
|
await this.attachPipePaneLog();
|
|
2176
|
+
this.startupAdmission?.();
|
|
2077
2177
|
// 4. Transcript monitor. claude-code is handled inside the monitor
|
|
2078
2178
|
// (statusline transcript); codex/kiro/opencode read their CLI's own
|
|
2079
2179
|
// conversation store via a pluggable source. Backends with no known
|
|
@@ -2084,6 +2184,7 @@ export class Daemon extends EventEmitter {
|
|
|
2084
2184
|
// Baseline in the isolate before this daemon accepts new work. A
|
|
2085
2185
|
// stopped/replaced launch may never re-arm polling after this await.
|
|
2086
2186
|
await transcriptMonitor.initialize();
|
|
2187
|
+
this.startupAdmission?.();
|
|
2087
2188
|
if (this.startupAborted || this.launchFenceEpoch !== transcriptFence || this.transcriptMonitor !== transcriptMonitor) {
|
|
2088
2189
|
transcriptMonitor.stop();
|
|
2089
2190
|
return;
|
|
@@ -2589,7 +2690,7 @@ export class Daemon extends EventEmitter {
|
|
|
2589
2690
|
// Stop the loop permanently — otherwise every tick triggers a respawn, whose
|
|
2590
2691
|
// writeRotationSnapshot fails with ENOENT and gets caught as "Failed to respawn",
|
|
2591
2692
|
// spamming errors every ~30s forever.
|
|
2592
|
-
if (!existsSync(this.instanceDir)) {
|
|
2693
|
+
if (!measureSyncWork("daemon.healthCheck", () => existsSync(this.instanceDir))) {
|
|
2593
2694
|
this.logger.warn({ instanceDir: this.instanceDir }, "Instance directory missing — stopping health check");
|
|
2594
2695
|
this.healthCheckPaused = true;
|
|
2595
2696
|
this.healthCheckTimer = null;
|
|
@@ -2601,16 +2702,26 @@ export class Daemon extends EventEmitter {
|
|
|
2601
2702
|
return;
|
|
2602
2703
|
}
|
|
2603
2704
|
// The CLI owns the MCP server process, so the daemon can only observe it.
|
|
2604
|
-
this.checkMcpServerAlive();
|
|
2705
|
+
measureSyncWork("daemon.healthCheck", () => this.checkMcpServerAlive());
|
|
2605
2706
|
// Human-readable backend label for logs (e.g. "claude", "kiro-cli")
|
|
2606
2707
|
const cliLabel = this.backend?.binaryName ?? "CLI";
|
|
2607
|
-
|
|
2708
|
+
const healthTmux = this.tmux;
|
|
2709
|
+
const healthOwner = this.interactionOwner();
|
|
2710
|
+
const healthCurrent = () => this.tmux === healthTmux
|
|
2711
|
+
&& sameInteractionOwner(healthOwner, this.interactionOwner())
|
|
2712
|
+
&& !this.runtimeMonitorsFrozen && !this.healthCheckPaused && !this.spawning
|
|
2713
|
+
&& !this.isPaused && this.pauseWakeState !== "waking";
|
|
2714
|
+
const discardStaleHealth = () => {
|
|
2715
|
+
if (healthCurrent())
|
|
2716
|
+
return false;
|
|
2717
|
+
scheduleNext();
|
|
2718
|
+
return true;
|
|
2719
|
+
};
|
|
2720
|
+
let paneStatus = await healthTmux.getPaneStatus();
|
|
2608
2721
|
// Auto-pause intentionally exits the pane process. A health tick that
|
|
2609
2722
|
// began just before pause must not classify that exit as a crash.
|
|
2610
|
-
if (
|
|
2611
|
-
scheduleNext();
|
|
2723
|
+
if (discardStaleHealth())
|
|
2612
2724
|
return;
|
|
2613
|
-
}
|
|
2614
2725
|
if (paneStatus?.alive) {
|
|
2615
2726
|
this.windowQueryFailureTicks = 0;
|
|
2616
2727
|
this.stormWindow?.noteWindowAlive(this.name);
|
|
@@ -2620,7 +2731,7 @@ export class Daemon extends EventEmitter {
|
|
|
2620
2731
|
// 10–100 MiB log runs off the event loop, and this tick must not wait for
|
|
2621
2732
|
// it (#1161) — scheduleNext() below is not delayed by a rotation.
|
|
2622
2733
|
if (!this.config.lightweight) {
|
|
2623
|
-
void rotateLogIfNeededAsync(join(this.instanceDir, "output.log"));
|
|
2734
|
+
void measureSyncWork("daemon.healthCheck", () => rotateLogIfNeededAsync(join(this.instanceDir, "output.log")));
|
|
2624
2735
|
}
|
|
2625
2736
|
scheduleNext();
|
|
2626
2737
|
return;
|
|
@@ -2632,7 +2743,11 @@ export class Daemon extends EventEmitter {
|
|
|
2632
2743
|
// and needs no recheck.
|
|
2633
2744
|
if (paneStatus === null) {
|
|
2634
2745
|
await new Promise(r => setTimeout(r, 1500));
|
|
2635
|
-
|
|
2746
|
+
if (discardStaleHealth())
|
|
2747
|
+
return;
|
|
2748
|
+
paneStatus = await healthTmux.getPaneStatus();
|
|
2749
|
+
if (discardStaleHealth())
|
|
2750
|
+
return;
|
|
2636
2751
|
if (paneStatus?.alive) {
|
|
2637
2752
|
this.logger.debug(`[health] ${cliLabel} pane reported gone then alive on recheck — transient query failure, ignoring`);
|
|
2638
2753
|
this.windowQueryFailureTicks = 0;
|
|
@@ -2647,12 +2762,17 @@ export class Daemon extends EventEmitter {
|
|
|
2647
2762
|
this.logger.debug({ exitCode }, `[health] pane exited with code: ${exitCode}`);
|
|
2648
2763
|
// Normal exit (e.g. user Ctrl+C or /exit) — no crash, no respawn
|
|
2649
2764
|
if (paneStatus && exitCode === 0) {
|
|
2650
|
-
this.setProcessStatus("stopped");
|
|
2651
2765
|
// Status 0 is not proof of a clean exit: a codex that hits a quota
|
|
2652
2766
|
// wall exits 0 too. Capture what it printed before the window goes.
|
|
2653
|
-
|
|
2767
|
+
const output = await this.capturePaneOutput();
|
|
2768
|
+
if (discardStaleHealth())
|
|
2769
|
+
return;
|
|
2770
|
+
this.setProcessStatus("stopped");
|
|
2771
|
+
this.logPaneDeath(cliLabel, exitCode, output);
|
|
2654
2772
|
this.logger.info("CLI exited normally (code 0) — pausing health check");
|
|
2655
|
-
await
|
|
2773
|
+
await healthTmux.killWindow();
|
|
2774
|
+
if (discardStaleHealth())
|
|
2775
|
+
return;
|
|
2656
2776
|
this.healthCheckPaused = true;
|
|
2657
2777
|
this.emitSupervisionEnded("the CLI exited normally (code 0)", "Nothing crashed — start it again when you need it.", 0);
|
|
2658
2778
|
return;
|
|
@@ -2665,12 +2785,15 @@ export class Daemon extends EventEmitter {
|
|
|
2665
2785
|
let nullReason;
|
|
2666
2786
|
if (!paneStatus) {
|
|
2667
2787
|
const serverAlive = await TmuxManager.sessionExists(this.tmuxSessionName);
|
|
2788
|
+
if (discardStaleHealth())
|
|
2789
|
+
return;
|
|
2668
2790
|
// A server may have already restarted before this daemon's health
|
|
2669
2791
|
// tick. The new PID is the durable generation boundary: treat it as
|
|
2670
2792
|
// a fleet storm even though `has-session` is true again.
|
|
2671
|
-
const
|
|
2672
|
-
|
|
2673
|
-
|
|
2793
|
+
const serverPid = serverAlive ? await TmuxManager.getServerPid(this.tmuxSessionName) : null;
|
|
2794
|
+
if (discardStaleHealth())
|
|
2795
|
+
return;
|
|
2796
|
+
const generationChanged = serverAlive && this.stormWindow?.observeServerAlive(serverPid) === true;
|
|
2674
2797
|
if (generationChanged)
|
|
2675
2798
|
this.emit("tmux_server_crash", this.name);
|
|
2676
2799
|
if (!serverAlive || generationChanged || this.stormWindow?.needsRecovery(this.name)) {
|
|
@@ -2688,13 +2811,17 @@ export class Daemon extends EventEmitter {
|
|
|
2688
2811
|
// The fleet breaker owns the delay and extends it on every new
|
|
2689
2812
|
// server generation. Do not schedule a competing fixed timer.
|
|
2690
2813
|
await this.stormWindow?.waitForSpawnAllowed();
|
|
2814
|
+
if (discardStaleHealth())
|
|
2815
|
+
return;
|
|
2691
2816
|
}
|
|
2692
2817
|
else {
|
|
2693
2818
|
// null but server alive: window-level disappearance. Probe whether
|
|
2694
2819
|
// the window truly no longer exists vs a transient query glitch.
|
|
2695
2820
|
nullReason = "no_window";
|
|
2696
2821
|
try {
|
|
2697
|
-
const windows = await TmuxManager.
|
|
2822
|
+
const windows = await TmuxManager.listWindowsStrict(this.tmuxSessionName, this.controlClient);
|
|
2823
|
+
if (discardStaleHealth())
|
|
2824
|
+
return;
|
|
2698
2825
|
this.windowQueryFailureTicks = 0;
|
|
2699
2826
|
const currentWindowId = this.tmux.getWindowId();
|
|
2700
2827
|
if (windows.some(w => w.id === currentWindowId)) {
|
|
@@ -2735,22 +2862,26 @@ export class Daemon extends EventEmitter {
|
|
|
2735
2862
|
else {
|
|
2736
2863
|
this.logger.warn({ exitCode }, `${cliLabel} process exited`);
|
|
2737
2864
|
}
|
|
2738
|
-
this.setProcessStatus("crashed");
|
|
2739
2865
|
// Capture last output before killing. Best-effort even when the pane is
|
|
2740
2866
|
// gone (paneStatus null) — gives the crash record something to diagnose
|
|
2741
2867
|
// from instead of an empty lastOutput.
|
|
2742
2868
|
let lastOutput;
|
|
2743
2869
|
try {
|
|
2744
|
-
const raw = await
|
|
2870
|
+
const raw = await healthTmux.capturePaneWithHistory(50);
|
|
2745
2871
|
// Strip ANSI escape codes for readability
|
|
2746
2872
|
const cleaned = raw.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
|
2747
2873
|
lastOutput = cleaned.trimEnd() || undefined;
|
|
2748
2874
|
}
|
|
2749
2875
|
catch { /* best effort — pane may already be gone */ }
|
|
2876
|
+
if (discardStaleHealth())
|
|
2877
|
+
return;
|
|
2878
|
+
this.setProcessStatus("crashed");
|
|
2750
2879
|
this.logPaneDeath(cliLabel, exitCode, lastOutput);
|
|
2751
2880
|
// Kill the dead window (remain-on-exit keeps it around) before respawn
|
|
2752
2881
|
if (paneStatus) {
|
|
2753
|
-
await
|
|
2882
|
+
await healthTmux.killWindow();
|
|
2883
|
+
if (discardStaleHealth())
|
|
2884
|
+
return;
|
|
2754
2885
|
}
|
|
2755
2886
|
// Detect claude-code background session conflict — recover without counting as crash
|
|
2756
2887
|
if (lastOutput && (lastOutput.includes("background agent") || lastOutput.includes("Session is currently running"))) {
|
|
@@ -3037,8 +3168,9 @@ export class Daemon extends EventEmitter {
|
|
|
3037
3168
|
const readyPattern = this.backend.getReadyPattern();
|
|
3038
3169
|
const busyPattern = this.backend.getBusyPattern?.() ?? null;
|
|
3039
3170
|
this.errorMonitorTimer = setInterval(async () => {
|
|
3040
|
-
if (!this.tmux || this.spawning)
|
|
3171
|
+
if (!this.tmux || this.spawning || this.deliveryWritesStopping)
|
|
3041
3172
|
return;
|
|
3173
|
+
const pollTmux = this.tmux;
|
|
3042
3174
|
// This poll belongs to the spawn and the monitors it started under. Every await below can outlive them (a stop,
|
|
3043
3175
|
// a pause, a respawn); a stale poll must not touch state a newer one owns, so it checks before committing.
|
|
3044
3176
|
const pollSpawn = this.spawnGeneration;
|
|
@@ -3047,15 +3179,21 @@ export class Daemon extends EventEmitter {
|
|
|
3047
3179
|
const captureOrder = ++this.interactionCaptureSerial;
|
|
3048
3180
|
const captureMono = performance.now();
|
|
3049
3181
|
const stale = () => pollSpawn !== this.spawnGeneration || pollFence !== this.launchFenceEpoch
|
|
3050
|
-
|| !sameInteractionOwner(pollOwner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning
|
|
3182
|
+
|| !sameInteractionOwner(pollOwner, this.interactionOwner()) || this.runtimeMonitorsFrozen || this.spawning
|
|
3183
|
+
|| this.deliveryWritesStopping || this.tmux !== pollTmux;
|
|
3184
|
+
let scanStartedAt = null;
|
|
3185
|
+
const endScan = () => { if (scanStartedAt !== null)
|
|
3186
|
+
noteSyncWork("daemon.errorMonitorScan", scanStartedAt); scanStartedAt = null; };
|
|
3051
3187
|
try {
|
|
3052
|
-
const alive = await
|
|
3188
|
+
const alive = await pollTmux.isWindowAlive();
|
|
3053
3189
|
if (stale())
|
|
3054
3190
|
return;
|
|
3055
3191
|
if (!alive)
|
|
3056
3192
|
return;
|
|
3057
3193
|
const captureAt = Date.now();
|
|
3058
|
-
const pane = await
|
|
3194
|
+
const pane = await pollTmux.capturePane();
|
|
3195
|
+
// The scan of this pane is synchronous until it answers a dialog (an await) or ends: attributed (#1235).
|
|
3196
|
+
scanStartedAt = performance.now();
|
|
3059
3197
|
if (stale())
|
|
3060
3198
|
return;
|
|
3061
3199
|
if (this.instanceStateLastOutputAt > 0 && this.instanceStateLastOutputAt >= captureAt) {
|
|
@@ -3130,9 +3268,7 @@ export class Daemon extends EventEmitter {
|
|
|
3130
3268
|
continue;
|
|
3131
3269
|
}
|
|
3132
3270
|
const autoKey = dialog.autoResolutionKey;
|
|
3133
|
-
if (dialog.verifyAfterKeys && autoKey
|
|
3134
|
-
&& this.autoResolvedDialogGeneration === this.spawnGeneration
|
|
3135
|
-
&& this.autoResolvedDialogKey === autoKey) {
|
|
3271
|
+
if (dialog.verifyAfterKeys && autoKey && this.hasAutoDialogClaim(dialog)) {
|
|
3136
3272
|
// A previous poll sent the safety choice but the CLI has not
|
|
3137
3273
|
// repainted yet. Never send another Enter into the same screen.
|
|
3138
3274
|
continue;
|
|
@@ -3143,6 +3279,7 @@ export class Daemon extends EventEmitter {
|
|
|
3143
3279
|
// (not queue) when the pane is busy: this poller runs every 5s, and the
|
|
3144
3280
|
// dialog will still be on screen next tick.
|
|
3145
3281
|
let resolved = false;
|
|
3282
|
+
endScan();
|
|
3146
3283
|
const dismissed = await this.paneWriteLock.tryRun(async () => {
|
|
3147
3284
|
// Re-read under the lock: the first capture may have gone stale
|
|
3148
3285
|
// while a delivery was finishing. A stale danger menu must never
|
|
@@ -3150,7 +3287,7 @@ export class Daemon extends EventEmitter {
|
|
|
3150
3287
|
const currentAt = Date.now();
|
|
3151
3288
|
const currentMono = performance.now();
|
|
3152
3289
|
const currentOrder = ++this.interactionCaptureSerial;
|
|
3153
|
-
const currentPane = await
|
|
3290
|
+
const currentPane = await pollTmux.capturePane();
|
|
3154
3291
|
// Every await below can outlive the spawn / monitors this poll started under: after each one, before
|
|
3155
3292
|
// any state is touched or any further key is sent, `stale()` (fixed at the poll's start) is asked again.
|
|
3156
3293
|
if (stale())
|
|
@@ -3174,28 +3311,26 @@ export class Daemon extends EventEmitter {
|
|
|
3174
3311
|
// not evidence that the old request was answered, and the one-minute fallback must keep running.
|
|
3175
3312
|
if (this.dialogAnswers?.key === Daemon.answerKey(dialog) && this.dialogAnswers.screen !== before)
|
|
3176
3313
|
this.endDialogEpisode(dialog.requestIdentity !== undefined);
|
|
3177
|
-
if (dialog.verifyAfterKeys && autoKey
|
|
3178
|
-
&& this.autoResolvedDialogGeneration === this.spawnGeneration
|
|
3179
|
-
&& this.autoResolvedDialogKey === autoKey)
|
|
3314
|
+
if (dialog.verifyAfterKeys && autoKey && this.hasAutoDialogClaim(dialog))
|
|
3180
3315
|
return;
|
|
3181
|
-
if (dialog.verifyAfterKeys && autoKey)
|
|
3182
|
-
this.
|
|
3183
|
-
this.autoResolvedDialogKey = autoKey;
|
|
3184
|
-
}
|
|
3316
|
+
if (dialog.verifyAfterKeys && autoKey)
|
|
3317
|
+
this.claimAutoDialog(dialog);
|
|
3185
3318
|
this.logger.info(`Auto-dismissing runtime dialog: ${dialog.description}`);
|
|
3186
3319
|
const SPECIAL_KEYS = new Set(["Up", "Down", "Enter", "Escape", "Right", "Left"]);
|
|
3187
3320
|
for (const key of dialog.keys) {
|
|
3321
|
+
if (stale())
|
|
3322
|
+
return;
|
|
3188
3323
|
let sent = false;
|
|
3189
3324
|
if (SPECIAL_KEYS.has(key)) {
|
|
3190
|
-
sent = await
|
|
3325
|
+
sent = await pollTmux.sendSpecialKey(key);
|
|
3191
3326
|
}
|
|
3192
3327
|
else {
|
|
3193
|
-
sent = await
|
|
3328
|
+
sent = await pollTmux.pasteText(key, this.systemPasteOptions());
|
|
3194
3329
|
}
|
|
3195
3330
|
if (stale())
|
|
3196
3331
|
return;
|
|
3197
3332
|
if (!sent) {
|
|
3198
|
-
if (dialog.verifyAfterKeys && autoKey) {
|
|
3333
|
+
if (dialog.verifyAfterKeys && autoKey && !dialog.oncePerLaunch) {
|
|
3199
3334
|
this.autoResolvedDialogGeneration = 0;
|
|
3200
3335
|
this.autoResolvedDialogKey = null;
|
|
3201
3336
|
}
|
|
@@ -3210,7 +3345,7 @@ export class Daemon extends EventEmitter {
|
|
|
3210
3345
|
const afterKeysAt = Date.now();
|
|
3211
3346
|
const afterKeysMono = performance.now();
|
|
3212
3347
|
const afterKeysOrder = ++this.interactionCaptureSerial;
|
|
3213
|
-
const afterKeysPane = await
|
|
3348
|
+
const afterKeysPane = await pollTmux.capturePane();
|
|
3214
3349
|
if (stale())
|
|
3215
3350
|
return;
|
|
3216
3351
|
const dialogStillActive = dialog.inputBlocked
|
|
@@ -3240,7 +3375,7 @@ export class Daemon extends EventEmitter {
|
|
|
3240
3375
|
// backend-named identity any difference on the screen (a ticking timer) proves nothing either way: nothing
|
|
3241
3376
|
// is reported and the parked clock is left alone. Two requests that look exactly alike cannot be told apart.
|
|
3242
3377
|
await new Promise(r => setTimeout(r, DIALOG_ANSWER_SETTLE_MS));
|
|
3243
|
-
const afterPane = await
|
|
3378
|
+
const afterPane = await pollTmux.capturePane();
|
|
3244
3379
|
if (stale())
|
|
3245
3380
|
return; // (nothing was touched since the last check: only a read)
|
|
3246
3381
|
const stillThere = Daemon.dialogMatches(dialog, afterPane);
|
|
@@ -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
|
|
@@ -6544,6 +6727,32 @@ export class Daemon extends EventEmitter {
|
|
|
6544
6727
|
}
|
|
6545
6728
|
return active;
|
|
6546
6729
|
}
|
|
6730
|
+
hasAutoDialogClaim(dialog) {
|
|
6731
|
+
const key = dialog.autoResolutionKey;
|
|
6732
|
+
if (!key)
|
|
6733
|
+
return false;
|
|
6734
|
+
if (dialog.oncePerLaunch) {
|
|
6735
|
+
const claims = this.launchDialogClaims;
|
|
6736
|
+
return claims !== null && claims.spawn === this.spawnGeneration && claims.attempt === this.launchAttempt
|
|
6737
|
+
&& claims.keys.has(key);
|
|
6738
|
+
}
|
|
6739
|
+
return this.autoResolvedDialogGeneration === this.spawnGeneration && this.autoResolvedDialogKey === key;
|
|
6740
|
+
}
|
|
6741
|
+
claimAutoDialog(dialog) {
|
|
6742
|
+
const key = dialog.autoResolutionKey;
|
|
6743
|
+
if (!key)
|
|
6744
|
+
return;
|
|
6745
|
+
if (dialog.oncePerLaunch) {
|
|
6746
|
+
if (!this.launchDialogClaims || this.launchDialogClaims.spawn !== this.spawnGeneration
|
|
6747
|
+
|| this.launchDialogClaims.attempt !== this.launchAttempt) {
|
|
6748
|
+
this.launchDialogClaims = { spawn: this.spawnGeneration, attempt: this.launchAttempt, keys: new Set() };
|
|
6749
|
+
}
|
|
6750
|
+
this.launchDialogClaims.keys.add(key);
|
|
6751
|
+
return;
|
|
6752
|
+
}
|
|
6753
|
+
this.autoResolvedDialogGeneration = this.spawnGeneration;
|
|
6754
|
+
this.autoResolvedDialogKey = key;
|
|
6755
|
+
}
|
|
6547
6756
|
/** Whether a runtime prompt currently owns the pane's stdin. */
|
|
6548
6757
|
isInputBlocked() {
|
|
6549
6758
|
return this.inputBlockedDialogKey !== null;
|
|
@@ -6605,8 +6814,7 @@ export class Daemon extends EventEmitter {
|
|
|
6605
6814
|
// this spawn is not what the screen is waiting on any more — the entry
|
|
6606
6815
|
// after it (typically a hold) describes it.
|
|
6607
6816
|
if (dialog.verifyAfterKeys && dialog.autoResolutionKey
|
|
6608
|
-
&& this.
|
|
6609
|
-
&& this.autoResolvedDialogKey === dialog.autoResolutionKey
|
|
6817
|
+
&& this.hasAutoDialogClaim(dialog)
|
|
6610
6818
|
&& dialogs.some(other => other !== dialog && Daemon.dialogMatches(other, pane)))
|
|
6611
6819
|
continue;
|
|
6612
6820
|
this.trackDialogParked(dialog);
|
|
@@ -6860,6 +7068,73 @@ export class Daemon extends EventEmitter {
|
|
|
6860
7068
|
}
|
|
6861
7069
|
return true;
|
|
6862
7070
|
}
|
|
7071
|
+
/**
|
|
7072
|
+
* #1405: a steer is handed to the busy pane only if THIS launch takes one — asked of the backend again at delivery,
|
|
7073
|
+
* not just by the hub when it was sent: a durable steer can outlive the launch it was meant for (a restart onto a
|
|
7074
|
+
* legacy UI or an unverified version), and must then wait for the idle prompt like any message. Backends that do not
|
|
7075
|
+
* answer per launch leave it to the hub's name table.
|
|
7076
|
+
*/
|
|
7077
|
+
launchTakesSteer() {
|
|
7078
|
+
return this.backend?.supportsSteer?.() !== false;
|
|
7079
|
+
}
|
|
7080
|
+
/**
|
|
7081
|
+
* #1405: a steer into a CLI whose busy input steers or queues by a mode the user switches (kiro TUI) goes in only
|
|
7082
|
+
* while a fresh capture reads its composer as "steer" — or "idle" once the turn has ended (`allowIdle`, the pre-write
|
|
7083
|
+
* re-check). "queue" (the user's choice), text in the box, or a screen it cannot read: not now — the caller waits for
|
|
7084
|
+
* the idle prompt as for any message. AgEnD never switches the mode. Backends without the reader are not asked.
|
|
7085
|
+
*/
|
|
7086
|
+
async steerComposerAdmits(allowIdle) {
|
|
7087
|
+
const read = this.backend?.readSteerComposer;
|
|
7088
|
+
if (!read)
|
|
7089
|
+
return true;
|
|
7090
|
+
if (!this.tmux)
|
|
7091
|
+
return false;
|
|
7092
|
+
let mode;
|
|
7093
|
+
try {
|
|
7094
|
+
mode = read.call(this.backend, await this.tmux.capturePane());
|
|
7095
|
+
}
|
|
7096
|
+
catch {
|
|
7097
|
+
return false;
|
|
7098
|
+
}
|
|
7099
|
+
return mode === "steer" || (allowIdle && mode === "idle");
|
|
7100
|
+
}
|
|
7101
|
+
/**
|
|
7102
|
+
* #1405: one look at a mode-switched composer for a steer's own proof: on the tmux of this write, inside `deadline`
|
|
7103
|
+
* (monotonic) — the capture is raced against the time left and a late answer dropped — and `current` asked after the
|
|
7104
|
+
* await. Null when unreadable, late or no longer this write's: never evidence of an empty or a full box. The pane
|
|
7105
|
+
* comes back with the mode, so what follows judges the same capture.
|
|
7106
|
+
*/
|
|
7107
|
+
async steerComposerWithin(tmux, deadline, current) {
|
|
7108
|
+
const read = this.backend?.readSteerComposer;
|
|
7109
|
+
const left = deadline - performance.now();
|
|
7110
|
+
if (!read || left <= 0 || !current())
|
|
7111
|
+
return null;
|
|
7112
|
+
const pane = await withinMs(tmux.capturePane(Math.ceil(left)), left);
|
|
7113
|
+
if (pane === undefined || performance.now() > deadline || !current())
|
|
7114
|
+
return null;
|
|
7115
|
+
const mode = read.call(this.backend, pane);
|
|
7116
|
+
return mode === null ? null : { mode, pane };
|
|
7117
|
+
}
|
|
7118
|
+
/**
|
|
7119
|
+
* #1405: the composer's text is THIS delivery's paste. kiro paints a multi-line paste's first line on the `›` row (live,
|
|
7120
|
+
* 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
|
|
7121
|
+
* is its `[agend-delivery-id:<this attempt's id>]` marker, so a draft, someone else's paste, our paste appended to a
|
|
7122
|
+
* draft, or a marker cut short (wrapped, collapsed, truncated) is never taken for it: not attributable, no Enter.
|
|
7123
|
+
*/
|
|
7124
|
+
steerPasteIsOurs(pane, formatted) {
|
|
7125
|
+
const shown = this.backend?.readSteerComposerText?.(pane);
|
|
7126
|
+
if (!shown)
|
|
7127
|
+
return false;
|
|
7128
|
+
const norm = (t) => t.replace(/\s+/g, " ").trim();
|
|
7129
|
+
const first = norm(formatted.split("\n").find(line => line.trim() !== "") ?? "");
|
|
7130
|
+
if (first === "")
|
|
7131
|
+
return false;
|
|
7132
|
+
const row = norm(shown);
|
|
7133
|
+
// The marker line holds nothing but the marker: the row is exactly it.
|
|
7134
|
+
if (/^\[agend-delivery-id:[^\]\s]+\]$/.test(first))
|
|
7135
|
+
return row === first;
|
|
7136
|
+
return row === first || row.startsWith(`${first} `);
|
|
7137
|
+
}
|
|
6863
7138
|
/**
|
|
6864
7139
|
* Codex can paint a prompt before the TTY enters raw mode. Both are required.
|
|
6865
7140
|
*
|
|
@@ -7516,6 +7791,10 @@ export class Daemon extends EventEmitter {
|
|
|
7516
7791
|
async sendDeliveryEnter(phase, stillCurrent, durableAttempt) {
|
|
7517
7792
|
if (!(await this.waitForInputTransientToClear(phase)))
|
|
7518
7793
|
return false;
|
|
7794
|
+
return this.sendDeliveryEnterAfterAvailability(phase, stillCurrent, durableAttempt);
|
|
7795
|
+
}
|
|
7796
|
+
/** Write half only: callers must finish the availability wait before any final recovery ownership proof. */
|
|
7797
|
+
async sendDeliveryEnterAfterAvailability(phase, stillCurrent, durableAttempt) {
|
|
7519
7798
|
// A recovery Enter may have waited for a transient while cancel or spawn
|
|
7520
7799
|
// replaced the delivery. Check at the last point before the tmux write.
|
|
7521
7800
|
if (this.deliveryWritesStopping || (stillCurrent && !stillCurrent()))
|
|
@@ -7548,6 +7827,11 @@ export class Daemon extends EventEmitter {
|
|
|
7548
7827
|
let windowId = initialWindowId;
|
|
7549
7828
|
// The spawn this write belongs to: acceptance evidence is only read while it is still the current one (#1197).
|
|
7550
7829
|
const spawnAtWrite = this.spawnGeneration;
|
|
7830
|
+
// #1405: a composer-proven steer is bound to this write's tmux and launch: every read, the Enter and the proof ask
|
|
7831
|
+
// this after each await (a respawn swaps this.tmux; a stop, pause or cancel ends the caller's fence).
|
|
7832
|
+
const tmuxAtWrite = this.tmux;
|
|
7833
|
+
const steerCurrent = () => (!stillCurrent || stillCurrent()) && !this.deliveryWritesStopping
|
|
7834
|
+
&& this.tmux === tmuxAtWrite && this.spawnGeneration === spawnAtWrite;
|
|
7551
7835
|
// Bug A: paste with backoff. Transient failures are usually a stale window id
|
|
7552
7836
|
// after a crash/respawn — recover by name and retry (max 3 attempts, 2s apart).
|
|
7553
7837
|
const maxAttempts = 3;
|
|
@@ -7572,6 +7856,20 @@ export class Daemon extends EventEmitter {
|
|
|
7572
7856
|
return "handoff-box-unread";
|
|
7573
7857
|
return this.failDelivery(verdict, status, "paste", "handoff-input-unreadable");
|
|
7574
7858
|
}
|
|
7859
|
+
// #1405: on that same last capture a mode-switched composer (kiro TUI) must still read "steer" — or "idle", the
|
|
7860
|
+
// turn over. "queue", text in the box or an unreadable row: not written; handled as the unread box above.
|
|
7861
|
+
if (handingOffToNativeQueue && steer && this.backend?.readSteerComposer
|
|
7862
|
+
&& pasteBaseline?.steerComposer !== "steer" && pasteBaseline?.steerComposer !== "idle") {
|
|
7863
|
+
if (!verdict.durableBeginCommitted)
|
|
7864
|
+
return "handoff-box-unread";
|
|
7865
|
+
return this.failDelivery(verdict, status, "paste", "steer-composer-not-steering");
|
|
7866
|
+
}
|
|
7867
|
+
// …and the launch must still take a steer (see launchTakesSteer). Not written: before the durable begin the row
|
|
7868
|
+
// is retried, as a steer the gate will then send down the idle path.
|
|
7869
|
+
if (handingOffToNativeQueue && steer && !this.launchTakesSteer()) {
|
|
7870
|
+
verdict.fenced = !verdict.durableBeginCommitted;
|
|
7871
|
+
return false;
|
|
7872
|
+
}
|
|
7575
7873
|
// Readiness, idle-gate, dialog, spawn and pane-lock waits have all ended.
|
|
7576
7874
|
// Commit the submission fence at the last possible point before the
|
|
7577
7875
|
// first side effect; a crash during those waits remains safely replayable.
|
|
@@ -7595,6 +7893,7 @@ export class Daemon extends EventEmitter {
|
|
|
7595
7893
|
return false;
|
|
7596
7894
|
}
|
|
7597
7895
|
verdict.durableBeginCommitted = true;
|
|
7896
|
+
verdict.submissionMode = attemptEvidence.submissionMode;
|
|
7598
7897
|
if (attemptEvidence.transcriptPath && attemptEvidence.transcriptOffset !== null) {
|
|
7599
7898
|
verdict.transcriptCheckpoint = {
|
|
7600
7899
|
backend: attemptEvidence.backend,
|
|
@@ -7664,10 +7963,50 @@ export class Daemon extends EventEmitter {
|
|
|
7664
7963
|
await new Promise(r => setTimeout(r, fallbackMs));
|
|
7665
7964
|
settle = { settleMs: fallbackMs, observedPostPasteOutput: false, capHit: false, usedFallback: true };
|
|
7666
7965
|
}
|
|
7966
|
+
// #1405: a steer into a mode-switched composer (kiro TUI) is proven by the box alone — before the Enter it must
|
|
7967
|
+
// positively hold typed text, and after it be the empty placeholder again. The empty placeholder here means the
|
|
7968
|
+
// paste never reached the box; an unreadable box (a failed or late capture, a dialog) proves nothing: no Enter.
|
|
7969
|
+
const steerComposerProof = handingOffToNativeQueue && steer && !!this.backend?.readSteerComposer && tmuxAtWrite !== null;
|
|
7970
|
+
let steerEnterDeadline = Infinity;
|
|
7971
|
+
if (steerComposerProof) {
|
|
7972
|
+
const deadline = performance.now() + STEER_PASTE_VISIBLE_MS;
|
|
7973
|
+
let before = null;
|
|
7974
|
+
for (;;) {
|
|
7975
|
+
before = await this.steerComposerWithin(tmuxAtWrite, deadline, steerCurrent);
|
|
7976
|
+
if (before !== null || !steerCurrent() || performance.now() >= deadline)
|
|
7977
|
+
break;
|
|
7978
|
+
await new Promise(r => setTimeout(r, Math.min(POST_ENTER_PROOF_POLL_MS, Math.max(0, deadline - performance.now()))));
|
|
7979
|
+
}
|
|
7980
|
+
if (!steerCurrent()) {
|
|
7981
|
+
// Written but not entered, and no longer this write's pane to judge: uncertain, never retried here.
|
|
7982
|
+
verdict.phase = "steer-paste";
|
|
7983
|
+
verdict.proof = "fenced-before-enter";
|
|
7984
|
+
return false;
|
|
7985
|
+
}
|
|
7986
|
+
// The caller's own receipt is inside the budget too: a reading whose continuation ran late is not used.
|
|
7987
|
+
if (performance.now() > deadline)
|
|
7988
|
+
return this.failDelivery(verdict, status, "steer-paste", "box-read-late");
|
|
7989
|
+
if (before?.mode !== "text")
|
|
7990
|
+
return this.failDelivery(verdict, status, "steer-paste", before === null ? "box-unread" : `box-still-${before.mode}`);
|
|
7991
|
+
if (!this.steerPasteIsOurs(before.pane, formatted))
|
|
7992
|
+
return this.failDelivery(verdict, status, "steer-paste", "box-not-this-paste");
|
|
7993
|
+
// …and so is the Enter: the key is sent only while the reading is still within it.
|
|
7994
|
+
steerEnterDeadline = deadline;
|
|
7995
|
+
}
|
|
7667
7996
|
let enterAt = Date.now();
|
|
7668
|
-
|
|
7997
|
+
const steerEnterCurrent = () => steerCurrent() && performance.now() <= steerEnterDeadline;
|
|
7998
|
+
if (!(await this.sendDeliveryEnter("initial-submit", steerComposerProof ? steerEnterCurrent : undefined, durableAttempt))) {
|
|
7999
|
+
if (steerComposerProof && !steerCurrent()) {
|
|
8000
|
+
verdict.phase = "submit-enter";
|
|
8001
|
+
verdict.proof = "fenced-before-enter";
|
|
8002
|
+
return false;
|
|
8003
|
+
}
|
|
8004
|
+
if (steerComposerProof && performance.now() > steerEnterDeadline)
|
|
8005
|
+
return this.failDelivery(verdict, status, "submit-enter", "pre-enter-deadline");
|
|
7669
8006
|
return this.failDelivery(verdict, status, "submit-enter", "tmux-send-keys-failed");
|
|
7670
8007
|
}
|
|
8008
|
+
// The proof's whole budget, from the Enter: every capture and dialog probe inside it, a late answer dropped.
|
|
8009
|
+
const steerProofDeadline = performance.now() + NATIVE_QUEUE_PASTE_VERIFY_MS + POST_ENTER_PROOF_WINDOW_MS;
|
|
7671
8010
|
// Kiro's legacy TUI can swallow Enter while it is still processing a large
|
|
7672
8011
|
// paste — not only during the post-ready redraw (#479): on slower hosts it
|
|
7673
8012
|
// happens on ordinary deliveries too (v2.1.2 stable, WSL2 + tmux 3.4).
|
|
@@ -7681,7 +8020,9 @@ export class Daemon extends EventEmitter {
|
|
|
7681
8020
|
// re-baselined to the second Enter so leftover paste-render output between
|
|
7682
8021
|
// the two cannot be what "confirms" the submission.
|
|
7683
8022
|
let enterRetry = false;
|
|
7684
|
-
|
|
8023
|
+
// Not after a composer-proven steer: what a bare Enter does in kiro's TUI composer mid-turn is unverified, and the
|
|
8024
|
+
// box itself shows whether the one Enter took the text.
|
|
8025
|
+
if (!rawPaste && !steerComposerProof && this.backend?.requiresDeliveryEnterRetry?.() === true) {
|
|
7685
8026
|
await new Promise(r => setTimeout(r, 1_000));
|
|
7686
8027
|
const retryAt = Date.now();
|
|
7687
8028
|
if (await this.sendDeliveryEnter("queue-less-defensive-retry")) {
|
|
@@ -7705,6 +8046,41 @@ export class Daemon extends EventEmitter {
|
|
|
7705
8046
|
// can silently swallow it). Idle submissions keep the swallowed-Enter path.
|
|
7706
8047
|
if (handingOffToNativeQueue) {
|
|
7707
8048
|
await new Promise(r => setTimeout(r, NATIVE_QUEUE_PASTE_VERIFY_MS));
|
|
8049
|
+
if (steerComposerProof) {
|
|
8050
|
+
// #1405: the box is its empty placeholder again — "steer" (taken into the turn), "queue" (the user switched
|
|
8051
|
+
// mode meanwhile: held for the turn's end) or "idle" (the turn over: it started one) — with no dialog and the
|
|
8052
|
+
// same spawn and window. Kiro paints late, so a bounded poll on a monotonic deadline. Still not empty: the
|
|
8053
|
+
// Enter did not take it — uncertain, never re-pasted and never sent another Enter. Every capture and dialog
|
|
8054
|
+
// probe runs inside the budget set at the Enter; the deadline and this write's fence are asked again,
|
|
8055
|
+
// synchronously, right before it is accepted.
|
|
8056
|
+
const deadline = steerProofDeadline;
|
|
8057
|
+
for (;;) {
|
|
8058
|
+
const look = await this.steerComposerWithin(tmuxAtWrite, deadline, steerCurrent);
|
|
8059
|
+
const after = look?.mode;
|
|
8060
|
+
if (after === "steer" || after === "queue" || after === "idle") {
|
|
8061
|
+
const probe = await withinMs(this.probeBlockingDialog(), deadline - performance.now());
|
|
8062
|
+
if (probe?.state === "clear" && performance.now() <= deadline && steerCurrent() && this.getWindowId() === windowId) {
|
|
8063
|
+
verdict.phase = "steer-accepted";
|
|
8064
|
+
verdict.proof = `steer-composer-${after}`;
|
|
8065
|
+
if (status)
|
|
8066
|
+
this.emit("message_confirmed", status); // ✅ taken from the box
|
|
8067
|
+
return true;
|
|
8068
|
+
}
|
|
8069
|
+
}
|
|
8070
|
+
if (performance.now() >= deadline)
|
|
8071
|
+
break;
|
|
8072
|
+
await new Promise(r => setTimeout(r, Math.min(POST_ENTER_PROOF_POLL_MS, Math.max(0, deadline - performance.now()))));
|
|
8073
|
+
}
|
|
8074
|
+
if (!steerCurrent()) {
|
|
8075
|
+
verdict.phase = "steer-proof";
|
|
8076
|
+
verdict.proof = "fenced";
|
|
8077
|
+
return false;
|
|
8078
|
+
}
|
|
8079
|
+
this.logger.warn("Steer: the composer was not shown empty after the Enter in time — outcome uncertain, not re-sent");
|
|
8080
|
+
verdict.phase = "steer-proof";
|
|
8081
|
+
verdict.proof = "composer-not-emptied";
|
|
8082
|
+
return false;
|
|
8083
|
+
}
|
|
7708
8084
|
const proof = await this.confirmSubmitted(signature, pasteBaseline);
|
|
7709
8085
|
if (proof === "submitted") {
|
|
7710
8086
|
if (status)
|
|
@@ -8120,8 +8496,8 @@ export class Daemon extends EventEmitter {
|
|
|
8120
8496
|
return this.submittedProof();
|
|
8121
8497
|
}
|
|
8122
8498
|
/**
|
|
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).
|
|
8499
|
+
* The CLI's transcript, from this attempt's checkpoint, holds this delivery's exact marker: a user entry (read), an
|
|
8500
|
+
* entry Claude absorbed mid-turn, or one it queued (taken into its queue). Any of them is the CLI having taken the
|
|
8125
8501
|
* message, so it outranks the pane — including a box that still shows the paste a moment after Enter (Claude paints
|
|
8126
8502
|
* the submit late, captured live on 2.1.293), which on its own reads as a strand. Positive only; never a downgrade.
|
|
8127
8503
|
*/
|
|
@@ -8133,7 +8509,7 @@ export class Daemon extends EventEmitter {
|
|
|
8133
8509
|
return t.provenBy;
|
|
8134
8510
|
const found = await t.reader.look();
|
|
8135
8511
|
// 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;
|
|
8512
|
+
return found === "user" || found === "absorbed" || found === "queued" ? found : null;
|
|
8137
8513
|
}
|
|
8138
8514
|
/**
|
|
8139
8515
|
* A write's transcript proof: bounded looks from its checkpoint, fenced to the write — its spawn and launch, a stop,
|
|
@@ -8200,12 +8576,12 @@ export class Daemon extends EventEmitter {
|
|
|
8200
8576
|
// read as ours, we would press Enter to "recover" it, and the turn IT
|
|
8201
8577
|
// starts would confirm a message that never reached the pane.
|
|
8202
8578
|
//
|
|
8203
|
-
//
|
|
8204
|
-
// unreadable
|
|
8205
|
-
//
|
|
8206
|
-
//
|
|
8579
|
+
// Structured evidence (Claude's box and Codex's current input/footer pair) requires both snapshots to be READ.
|
|
8580
|
+
// An unreadable baseline says nothing about what the box held; treating it as empty would claim an older draft
|
|
8581
|
+
// as ours and press Enter on it (#1353/#1359). Codex's native warning viewer can ignore the paste and first Enter,
|
|
8582
|
+
// then reveal an older draft when the user dismisses it. Row-only backends retain their existing attribution.
|
|
8207
8583
|
const attributable = baseline != null
|
|
8208
|
-
&& (!this.
|
|
8584
|
+
&& (!this.structuredInputEvidence() || (baseline.inputReadable && after.inputReadable));
|
|
8209
8585
|
if (after.strandedInput && (signature.unique || (attributable && baseline.strandedInput === false)))
|
|
8210
8586
|
return "stranded";
|
|
8211
8587
|
// A paste the CLI shows collapsed carries no signature at all. One the box did not hold before we pasted is
|
|
@@ -8318,6 +8694,7 @@ export class Daemon extends EventEmitter {
|
|
|
8318
8694
|
strandedInput: input != null && inputShowsPastedText(input.text, signature.value),
|
|
8319
8695
|
collapsedPastes: input?.collapsedPastes ?? 0,
|
|
8320
8696
|
inputReadable: input != null,
|
|
8697
|
+
steerComposer: this.backend?.readSteerComposer?.(pane) ?? null,
|
|
8321
8698
|
};
|
|
8322
8699
|
}
|
|
8323
8700
|
/**
|
|
@@ -8381,7 +8758,10 @@ export class Daemon extends EventEmitter {
|
|
|
8381
8758
|
* is asked again after every await that precedes a write, so a cancelled paste adds no further paste and no key
|
|
8382
8759
|
* (the retries included). Without a guard it is exactly the old unconditional path.
|
|
8383
8760
|
*/
|
|
8384
|
-
async submitSystemPaste(text, label, guard) {
|
|
8761
|
+
async submitSystemPaste(text, label, guard, admission) {
|
|
8762
|
+
const current = guard || admission ? () => { admission?.(); return guard?.current() ?? true; } : undefined;
|
|
8763
|
+
if (current && !current())
|
|
8764
|
+
return false;
|
|
8385
8765
|
if (!this.tmux)
|
|
8386
8766
|
return false;
|
|
8387
8767
|
const signature = this.submissionSignature(text);
|
|
@@ -8394,32 +8774,41 @@ export class Daemon extends EventEmitter {
|
|
|
8394
8774
|
catch {
|
|
8395
8775
|
return false;
|
|
8396
8776
|
}
|
|
8397
|
-
if (!
|
|
8777
|
+
if (!current() || !guard.accept(pane))
|
|
8398
8778
|
return false;
|
|
8399
8779
|
baseline = this.paneEvidence(pane, signature);
|
|
8400
8780
|
}
|
|
8401
8781
|
else {
|
|
8402
8782
|
baseline = await this.capturePaneEvidence(signature);
|
|
8403
8783
|
}
|
|
8784
|
+
if (current && !current())
|
|
8785
|
+
return false;
|
|
8404
8786
|
const pasteGeneration = this.spawnGeneration;
|
|
8405
|
-
|
|
8787
|
+
const pasted = current ? await this.tmux.pasteBuffer(text, { guard: current }) : await this.tmux.pasteBuffer(text);
|
|
8788
|
+
if (!pasted) {
|
|
8406
8789
|
this.logger.warn({ label }, "System paste failed to reach the pane");
|
|
8407
8790
|
return false;
|
|
8408
8791
|
}
|
|
8792
|
+
if (current && !current())
|
|
8793
|
+
return false;
|
|
8409
8794
|
this.rememberPaste(text, pasteGeneration);
|
|
8410
8795
|
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
8411
|
-
if (!(await this.sendDeliveryEnter(label,
|
|
8796
|
+
if (!(await this.sendDeliveryEnter(label, current)))
|
|
8412
8797
|
return false;
|
|
8413
8798
|
let proof = await this.confirmSubmitted(signature, baseline);
|
|
8799
|
+
if (current && !current())
|
|
8800
|
+
return false;
|
|
8414
8801
|
// A structural box reader sees the paste in the box until the CLI digests the Enter (claude-code repaints a moment
|
|
8415
8802
|
// later): give that a bounded window before calling it stranded or lost, as the delivery ladder does.
|
|
8416
8803
|
if (this.backend?.readInputRow) {
|
|
8417
8804
|
const deadline = Date.now() + POST_ENTER_PROOF_WINDOW_MS;
|
|
8418
8805
|
while (proof !== "submitted" && Date.now() < deadline) {
|
|
8419
8806
|
await new Promise(r => setTimeout(r, POST_ENTER_PROOF_POLL_MS));
|
|
8420
|
-
if (
|
|
8807
|
+
if (current && !current())
|
|
8421
8808
|
return false;
|
|
8422
8809
|
proof = await this.confirmSubmitted(signature, baseline);
|
|
8810
|
+
if (current && !current())
|
|
8811
|
+
return false;
|
|
8423
8812
|
}
|
|
8424
8813
|
}
|
|
8425
8814
|
if (proof === "unverifiable") {
|
|
@@ -8437,18 +8826,44 @@ export class Daemon extends EventEmitter {
|
|
|
8437
8826
|
// exists to remove — visible text is also what a strand looks like.
|
|
8438
8827
|
if (this.systemPasteOptions().retryEnter) {
|
|
8439
8828
|
await new Promise(r => setTimeout(r, 1_000));
|
|
8440
|
-
await this.sendDeliveryEnter(`${label}-defensive-retry`,
|
|
8829
|
+
await this.sendDeliveryEnter(`${label}-defensive-retry`, current);
|
|
8441
8830
|
}
|
|
8442
8831
|
return true; // best effort, exactly as before — nothing here is verified
|
|
8443
8832
|
}
|
|
8444
8833
|
if (proof !== "submitted") {
|
|
8445
|
-
// A bare Enter is a no-op at an empty prompt, so this is safe even if the
|
|
8446
|
-
// first one did land; when the text is still in the input row it is the
|
|
8447
|
-
// submit it never got. Re-pasting would append the text to itself.
|
|
8448
8834
|
await new Promise(r => setTimeout(r, NORMAL_ENTER_SETTLE_MS));
|
|
8449
|
-
if (
|
|
8835
|
+
if (current && !current())
|
|
8450
8836
|
return false;
|
|
8837
|
+
const retryPhase = `${label}-retry`;
|
|
8838
|
+
if (this.structuredInputEvidence()) {
|
|
8839
|
+
// Both the retry delay and the Enter availability wait may outlive the
|
|
8840
|
+
// paste. Complete those waits BEFORE the last ownership/positive proof.
|
|
8841
|
+
if (!(await this.waitForInputTransientToClear(retryPhase)))
|
|
8842
|
+
return false;
|
|
8843
|
+
if (this.deliveryWritesStopping || (current && !current()))
|
|
8844
|
+
return false;
|
|
8845
|
+
proof = await this.confirmSubmitted(signature, baseline);
|
|
8846
|
+
if (this.deliveryWritesStopping || (current && !current()))
|
|
8847
|
+
return false;
|
|
8848
|
+
if (proof === "submitted")
|
|
8849
|
+
return true;
|
|
8850
|
+
if (proof !== "stranded") {
|
|
8851
|
+
this.logger.warn({ label, proof }, "System paste recovery input could not be attributed");
|
|
8852
|
+
return false;
|
|
8853
|
+
}
|
|
8854
|
+
// No further availability wait may invalidate this proof. The write
|
|
8855
|
+
// half retains the same stopping/admission fence immediately before IPC.
|
|
8856
|
+
if (!(await this.sendDeliveryEnterAfterAvailability(retryPhase, current)))
|
|
8857
|
+
return false;
|
|
8858
|
+
}
|
|
8859
|
+
else {
|
|
8860
|
+
// Row-only backends retain their ordinary availability wait and retry.
|
|
8861
|
+
if (!(await this.sendDeliveryEnter(retryPhase, current)))
|
|
8862
|
+
return false;
|
|
8863
|
+
}
|
|
8451
8864
|
proof = await this.confirmSubmitted(signature, baseline);
|
|
8865
|
+
if (current && !current())
|
|
8866
|
+
return false;
|
|
8452
8867
|
}
|
|
8453
8868
|
if (proof !== "submitted") {
|
|
8454
8869
|
this.logger.warn({ label, proof }, "System paste may not have been submitted");
|
|
@@ -8458,21 +8873,30 @@ export class Daemon extends EventEmitter {
|
|
|
8458
8873
|
}
|
|
8459
8874
|
/** Re-resolve this instance's tmux window by name (stale id after crash/respawn). */
|
|
8460
8875
|
async recoverWindow() {
|
|
8876
|
+
const previousTmux = this.tmux;
|
|
8877
|
+
const recoveryOwner = this.interactionOwner();
|
|
8878
|
+
const recoveryCurrent = () => !this.runtimeMonitorsFrozen
|
|
8879
|
+
&& sameInteractionOwner(recoveryOwner, this.interactionOwner());
|
|
8461
8880
|
const previousWindowId = this.tmux?.getWindowId();
|
|
8462
8881
|
try {
|
|
8463
|
-
const windows = await TmuxManager.listWindows(this.tmuxSessionName);
|
|
8882
|
+
const windows = await TmuxManager.listWindows(this.tmuxSessionName, this.controlClient);
|
|
8883
|
+
if (!recoveryCurrent() || this.tmux !== previousTmux)
|
|
8884
|
+
return undefined;
|
|
8464
8885
|
const match = windows.find(w => w.name === this.name);
|
|
8465
8886
|
if (!match)
|
|
8466
8887
|
return undefined;
|
|
8467
|
-
this.tmux = new TmuxManager(this.tmuxSessionName, match.id, resolveTmuxLogicalSize(this.config.terminal));
|
|
8888
|
+
this.tmux = new TmuxManager(this.tmuxSessionName, match.id, resolveTmuxLogicalSize(this.config.terminal), this.controlClient);
|
|
8889
|
+
const recoveredTmux = this.tmux;
|
|
8468
8890
|
writeFileSync(join(this.instanceDir, "window-id"), match.id);
|
|
8469
8891
|
// The window we were talking to is gone; leaving it registered means the
|
|
8470
|
-
// control client re-resolves a dead id — one
|
|
8892
|
+
// control client re-resolves a dead id — one bounded read — on every
|
|
8471
8893
|
// reconnect, for the life of the fleet process.
|
|
8472
8894
|
if (previousWindowId && previousWindowId !== match.id) {
|
|
8473
8895
|
this.controlClient?.unregisterWindow(previousWindowId);
|
|
8474
8896
|
}
|
|
8475
8897
|
await this.controlClient?.registerWindow(match.id);
|
|
8898
|
+
if (!recoveryCurrent() || this.tmux !== recoveredTmux)
|
|
8899
|
+
return undefined;
|
|
8476
8900
|
this.bindInstanceStateOutputListener(match.id);
|
|
8477
8901
|
this.logger.info({ windowId: match.id }, "Recovered window ID for message delivery");
|
|
8478
8902
|
return match.id;
|
|
@@ -8581,6 +9005,31 @@ export class Daemon extends EventEmitter {
|
|
|
8581
9005
|
// continuation for an already-answered turn.
|
|
8582
9006
|
if (replyAttempt?.completionAction)
|
|
8583
9007
|
clearInFlightTurnMarker(this.instanceDir);
|
|
9008
|
+
// #1377: a confirmed completion action (reply/react/edit_message) ends
|
|
9009
|
+
// the human-facing obligation immediately — the idle edge does not need
|
|
9010
|
+
// to confirm it. Clear any pending confirmation window so a later
|
|
9011
|
+
// cross-instance busy→idle cycle cannot re-arm and fire false recovery.
|
|
9012
|
+
// The guard itself completes on the next steady-idle tick via
|
|
9013
|
+
// maybeConfirmReplyGuardIdle (which checks completionDelivered when
|
|
9014
|
+
// replyGuardIdleConfirm is null) or at the next busy→idle edge.
|
|
9015
|
+
// Cancelled turns are excluded: their completion semantics are handled
|
|
9016
|
+
// by the idle edge's cancelledByUser check.
|
|
9017
|
+
// An older reply that satisfied an earlier obligation but not the latest
|
|
9018
|
+
// one (latestObligation > token.obligation) leaves completionDelivered
|
|
9019
|
+
// false and must not clear the window either.
|
|
9020
|
+
if (replyAttempt?.completionAction) {
|
|
9021
|
+
const snap = this.turnReplyGuard.snapshot();
|
|
9022
|
+
if (snap?.generation === replyAttempt.generation && snap.phase === "awaiting"
|
|
9023
|
+
&& snap.completionDelivered && !snap.cancelledByUser) {
|
|
9024
|
+
this.clearReplyGuardConfirm();
|
|
9025
|
+
}
|
|
9026
|
+
}
|
|
9027
|
+
}
|
|
9028
|
+
else if ((error || result == null) && replyAttempt?.reply && TURN_OUTBOUND_TOOLS.has(tool)) {
|
|
9029
|
+
// #1377: the adapter returned a definitive error — record this so the
|
|
9030
|
+
// confirm window can distinguish "in-flight" (unknown) from "known
|
|
9031
|
+
// failed" and report unknown immediately rather than starting recovery.
|
|
9032
|
+
this.turnReplyGuard.settleToolAttempt(replyAttempt, false);
|
|
8584
9033
|
}
|
|
8585
9034
|
const sent = this.ipcServer?.send(socket, { requestId, result, error, operationId }) ?? false;
|
|
8586
9035
|
if (!sent) {
|
|
@@ -8957,7 +9406,8 @@ export class Daemon extends EventEmitter {
|
|
|
8957
9406
|
instanceName: this.name,
|
|
8958
9407
|
mcpServers: isCliMode ? {} : {
|
|
8959
9408
|
"agend": {
|
|
8960
|
-
|
|
9409
|
+
// The Node this daemon runs on (#1450 C5): the CLIs start the MCP server with it, not a `node` from PATH.
|
|
9410
|
+
command: process.execPath,
|
|
8961
9411
|
args: [serverJs],
|
|
8962
9412
|
env: mcpEnv,
|
|
8963
9413
|
},
|
|
@@ -8979,7 +9429,8 @@ export class Daemon extends EventEmitter {
|
|
|
8979
9429
|
* user input so the agent picks up where the previous session left off.
|
|
8980
9430
|
* This replaces the old system-prompt injection approach.
|
|
8981
9431
|
*/
|
|
8982
|
-
async injectSnapshotMessage() {
|
|
9432
|
+
async injectSnapshotMessage(admission) {
|
|
9433
|
+
admission?.();
|
|
8983
9434
|
if (this.snapshotConsumed)
|
|
8984
9435
|
return;
|
|
8985
9436
|
const snapshot = this.buildSnapshotPrompt();
|
|
@@ -8991,11 +9442,16 @@ export class Daemon extends EventEmitter {
|
|
|
8991
9442
|
}
|
|
8992
9443
|
// Small delay to let the CLI fully render its ready prompt
|
|
8993
9444
|
await new Promise(r => setTimeout(r, 1_000));
|
|
9445
|
+
admission?.();
|
|
8994
9446
|
try {
|
|
8995
9447
|
// Messages can arrive during a restart and be queued on pasteLock before the
|
|
8996
9448
|
// snapshot lands; both write to the pane, so both go through the same lock.
|
|
8997
9449
|
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(() =>
|
|
9450
|
+
const injected = await this.paneWriteLock.run(() => {
|
|
9451
|
+
admission?.();
|
|
9452
|
+
return this.submitSystemPaste(restoreNotice, "session-snapshot-restore", undefined, admission);
|
|
9453
|
+
});
|
|
9454
|
+
admission?.();
|
|
8999
9455
|
if (!injected) {
|
|
9000
9456
|
// rotation-state.json was deleted when the prompt was built, so there is
|
|
9001
9457
|
// nothing left to retry from: the restore is gone either way. Say so —
|
|
@@ -9242,12 +9698,14 @@ export class Daemon extends EventEmitter {
|
|
|
9242
9698
|
// Fresh start or not: the owner is recorded below once this CLI is up, so an
|
|
9243
9699
|
// id it does not own must be out of the way first, or it would be recorded
|
|
9244
9700
|
// as this backend's and resumed by the next start.
|
|
9701
|
+
this.startupAdmission?.();
|
|
9245
9702
|
this.setAsideForeignSession();
|
|
9246
9703
|
const attemptedResume = !this.skipResume;
|
|
9247
9704
|
// A resume launch may get a longer budget than a fresh one (kiro: the
|
|
9248
9705
|
// conversation must come back from the backend before anything paints).
|
|
9249
9706
|
const resumeBudget = attemptedResume ? this.startupBudgetFor(true) : undefined;
|
|
9250
9707
|
let alive = await this.trySpawn(false, resumeBudget);
|
|
9708
|
+
this.startupAdmission?.();
|
|
9251
9709
|
if (!alive && attemptedResume) {
|
|
9252
9710
|
// Resume failed. Before abandoning the session:
|
|
9253
9711
|
// 1. If the backend is unreachable (fleet-level memory, or this very pane
|
|
@@ -9258,15 +9716,22 @@ export class Daemon extends EventEmitter {
|
|
|
9258
9716
|
// 2. Otherwise, for backends that ask for it, retry resume ONCE — the
|
|
9259
9717
|
// first miss is usually slowness, not a broken session.
|
|
9260
9718
|
await this.noteStartupPaneForBackendOutage();
|
|
9719
|
+
this.startupAdmission?.();
|
|
9261
9720
|
await this.failStartupIfBackendUnreachable();
|
|
9721
|
+
this.startupAdmission?.();
|
|
9262
9722
|
if (this.backend.retriesResumeOnStartupFailure?.() !== false) {
|
|
9263
9723
|
this.logger.warn("Resume startup failed — retrying resume once before abandoning the session");
|
|
9264
9724
|
await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
|
|
9725
|
+
this.startupAdmission?.();
|
|
9265
9726
|
await this.tmux.killWindow();
|
|
9727
|
+
this.startupAdmission?.();
|
|
9266
9728
|
alive = await this.trySpawn(false, resumeBudget);
|
|
9729
|
+
this.startupAdmission?.();
|
|
9267
9730
|
if (!alive) {
|
|
9268
9731
|
await this.noteStartupPaneForBackendOutage();
|
|
9732
|
+
this.startupAdmission?.();
|
|
9269
9733
|
await this.failStartupIfBackendUnreachable();
|
|
9734
|
+
this.startupAdmission?.();
|
|
9270
9735
|
}
|
|
9271
9736
|
}
|
|
9272
9737
|
}
|
|
@@ -9288,7 +9753,9 @@ export class Daemon extends EventEmitter {
|
|
|
9288
9753
|
// Keep the session and fail this attempt; the fleet retries with
|
|
9289
9754
|
// backoff, which is also how the backend-outage path behaves.
|
|
9290
9755
|
await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
|
|
9756
|
+
this.startupAdmission?.();
|
|
9291
9757
|
await this.tmux.killWindow();
|
|
9758
|
+
this.startupAdmission?.();
|
|
9292
9759
|
throw new Error(`CLI startup failed with a session to resume (attempt ${failures}/${Daemon.MAX_UNPROVEN_RESUME_FAILURES}) `
|
|
9293
9760
|
+ "— session kept, will retry");
|
|
9294
9761
|
}
|
|
@@ -9305,11 +9772,16 @@ export class Daemon extends EventEmitter {
|
|
|
9305
9772
|
// a session: nothing about a failed fresh launch says the stored
|
|
9306
9773
|
// conversation is unusable.
|
|
9307
9774
|
await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
|
|
9775
|
+
this.startupAdmission?.();
|
|
9308
9776
|
await this.tmux.killWindow();
|
|
9777
|
+
this.startupAdmission?.();
|
|
9309
9778
|
const retryAlive = await this.trySpawn(false, this.startupBudgetFor(false));
|
|
9779
|
+
this.startupAdmission?.();
|
|
9310
9780
|
if (!retryAlive) {
|
|
9311
9781
|
await this.killProcessTree("SIGTERM", "spawn: clearing the previous CLI process");
|
|
9782
|
+
this.startupAdmission?.();
|
|
9312
9783
|
await this.tmux.killWindow();
|
|
9784
|
+
this.startupAdmission?.();
|
|
9313
9785
|
throw new Error("CLI failed to start after retry");
|
|
9314
9786
|
}
|
|
9315
9787
|
}
|
|
@@ -9331,6 +9803,133 @@ export class Daemon extends EventEmitter {
|
|
|
9331
9803
|
}
|
|
9332
9804
|
return resumedSuccessfully;
|
|
9333
9805
|
}
|
|
9806
|
+
/** The whole agent switch after a resume, on the monotonic clock (#906 §3). */
|
|
9807
|
+
static AGENT_SWITCH_BUDGET_MS = 15_000;
|
|
9808
|
+
static AGENT_SWITCH_POLL_MS = 500;
|
|
9809
|
+
/** Each tmux call of the write (load, paste, Enter, take-back) is bounded by this. */
|
|
9810
|
+
static AGENT_SWITCH_TMUX_OP_MS = 1_000;
|
|
9811
|
+
/**
|
|
9812
|
+
* The paste is started only with this much budget left, checked at the paste itself: the load and the paste, the
|
|
9813
|
+
* settle, the Enter — each bounded by AGENT_SWITCH_TMUX_OP_MS — fit in it.
|
|
9814
|
+
*/
|
|
9815
|
+
static AGENT_SWITCH_WRITE_RESERVE_MS = 4_000;
|
|
9816
|
+
/** Between the paste and its Enter, as tmux pastes elsewhere (pasteText). */
|
|
9817
|
+
static AGENT_SWITCH_PASTE_SETTLE_MS = 500;
|
|
9818
|
+
/**
|
|
9819
|
+
* #906 §3: after a resume, make the CLI run as this instance's own agent. Reads the agent off the live layout
|
|
9820
|
+
* (backend.agentSwitch().readActive — the supported bottom layout only, never a name quoted in the conversation);
|
|
9821
|
+
* when it is another one, types the switch command ONCE, under the pane-write lock, and only when ONE final capture
|
|
9822
|
+
* taken there shows the live layout naming another agent AND an idle, input-ready pane (ready, not busy, no
|
|
9823
|
+
* blocking dialog, no input transient) — after the delivery path's own readiness check. It confirms only on a
|
|
9824
|
+
* later capture of the live layout naming ours.
|
|
9825
|
+
*
|
|
9826
|
+
* Fenced like a delivery, from entry on: the spawn generation, launch fence, the pause/wake phase it started in,
|
|
9827
|
+
* the same tmux window, not aborted, writes not stopping — re-checked after every await, and between the paste and
|
|
9828
|
+
* its Enter. The whole step, every await included, ends at one monotonic deadline: a late capture never confirms
|
|
9829
|
+
* and a late admission never writes; a paste whose Enter would land past it is taken back. Not confirmed in time:
|
|
9830
|
+
* the old setup stays, a launch warning says so, the next launch tries again. A stop, pause or respawn ends it
|
|
9831
|
+
* silently, confirming and removing nothing. Never throws into the spawn.
|
|
9832
|
+
*/
|
|
9833
|
+
async ensureBackendAgent() {
|
|
9834
|
+
let sw = null;
|
|
9835
|
+
try {
|
|
9836
|
+
sw = this.backend?.agentSwitch?.() ?? null;
|
|
9837
|
+
}
|
|
9838
|
+
catch {
|
|
9839
|
+
sw = null;
|
|
9840
|
+
}
|
|
9841
|
+
const tmux = this.tmux;
|
|
9842
|
+
if (!sw || !tmux || !this.backend)
|
|
9843
|
+
return;
|
|
9844
|
+
const windowId = tmux.getWindowId();
|
|
9845
|
+
const spawn = this.spawnGeneration, fence = this.launchFenceEpoch, phase = this.pauseWakeState;
|
|
9846
|
+
const fenced = () => !this.startupAborted && !this.deliveryWritesStopping && !this.fatalStartupBlocked
|
|
9847
|
+
&& (phase === "active" || phase === "waking") && this.pauseWakeState === phase
|
|
9848
|
+
&& spawn === this.spawnGeneration && fence === this.launchFenceEpoch && this.tmux === tmux;
|
|
9849
|
+
const deadline = performance.now() + Daemon.AGENT_SWITCH_BUDGET_MS;
|
|
9850
|
+
const live = () => { this.startupAdmission?.(); return fenced() && performance.now() < deadline; };
|
|
9851
|
+
if (!live())
|
|
9852
|
+
return;
|
|
9853
|
+
const TIMEOUT = Symbol("deadline");
|
|
9854
|
+
const within = (work) => {
|
|
9855
|
+
const left = deadline - performance.now();
|
|
9856
|
+
if (left <= 0) {
|
|
9857
|
+
work.catch(() => { });
|
|
9858
|
+
return Promise.resolve(TIMEOUT);
|
|
9859
|
+
}
|
|
9860
|
+
let timer;
|
|
9861
|
+
return Promise.race([work, new Promise(r => { timer = setTimeout(() => r(TIMEOUT), left); })])
|
|
9862
|
+
.finally(() => clearTimeout(timer));
|
|
9863
|
+
};
|
|
9864
|
+
const capture = () => within(tmux.capturePane().catch(() => null));
|
|
9865
|
+
let wroteAt = null;
|
|
9866
|
+
try {
|
|
9867
|
+
while (live()) {
|
|
9868
|
+
const capturedAt = performance.now();
|
|
9869
|
+
const pane = await capture();
|
|
9870
|
+
if (pane === TIMEOUT || !live())
|
|
9871
|
+
break;
|
|
9872
|
+
const active = pane === null ? null : sw.readActive(pane);
|
|
9873
|
+
if (active === sw.agent && (wroteAt === null || capturedAt > wroteAt)) {
|
|
9874
|
+
if (sw.alreadyConfirmed && wroteAt === null)
|
|
9875
|
+
return; // already ours, already on record
|
|
9876
|
+
for (const warning of sw.confirm())
|
|
9877
|
+
this.emit("backend_launch_warning", { name: this.name, message: warning });
|
|
9878
|
+
this.logger.info({ agent: sw.agent, switched: wroteAt !== null }, "The resumed conversation runs as this instance's agent");
|
|
9879
|
+
return;
|
|
9880
|
+
}
|
|
9881
|
+
if (wroteAt === null && active !== null && active !== sw.agent) {
|
|
9882
|
+
const wrote = await within(this.paneWriteLock.run(async () => {
|
|
9883
|
+
// The whole write is one owned transaction under the lock. Every await is bounded; a take-back of a paste
|
|
9884
|
+
// whose Enter would land past the deadline happens here, before the lock is released, so it can never
|
|
9885
|
+
// edit a write admitted after this one.
|
|
9886
|
+
if (!live())
|
|
9887
|
+
return false;
|
|
9888
|
+
const readiness = await within(this.paneReadinessForDelivery(windowId));
|
|
9889
|
+
if (readiness !== "ready" || !live())
|
|
9890
|
+
return false;
|
|
9891
|
+
const final = await capture();
|
|
9892
|
+
if (final === TIMEOUT || final === null || !live())
|
|
9893
|
+
return false;
|
|
9894
|
+
const now = sw.readActive(final);
|
|
9895
|
+
if (now === null || now === sw.agent || this.inputTransientInPane(final) || !this.paneAuthoritativelyIdle(final))
|
|
9896
|
+
return false;
|
|
9897
|
+
// The reserve, at the paste boundary — after the final proof, which may have used some of the budget.
|
|
9898
|
+
if (deadline - performance.now() < Daemon.AGENT_SWITCH_WRITE_RESERVE_MS)
|
|
9899
|
+
return false;
|
|
9900
|
+
const op = Daemon.AGENT_SWITCH_TMUX_OP_MS;
|
|
9901
|
+
// live() is asked before every tmux mutation inside the paste, so a stop, pause, respawn or the deadline
|
|
9902
|
+
// between its load and its paste never reaches the pane.
|
|
9903
|
+
const pasted = await tmux.pasteBuffer(sw.command, { guard: live, timeoutMs: op });
|
|
9904
|
+
if (!pasted)
|
|
9905
|
+
return false;
|
|
9906
|
+
await new Promise(r => setTimeout(r, Daemon.AGENT_SWITCH_PASTE_SETTLE_MS));
|
|
9907
|
+
if (live())
|
|
9908
|
+
return tmux.sendSpecialKey("Enter", op);
|
|
9909
|
+
// Same CLI, but no time left to see it through: take the command back out of its input row (bounded).
|
|
9910
|
+
if (fenced())
|
|
9911
|
+
await tmux.deleteBackward(sw.command.length, op);
|
|
9912
|
+
return false;
|
|
9913
|
+
}));
|
|
9914
|
+
if (wrote === true) {
|
|
9915
|
+
wroteAt = performance.now();
|
|
9916
|
+
this.logger.info({ agent: sw.agent, was: active }, "Switching the resumed conversation to this instance's agent");
|
|
9917
|
+
}
|
|
9918
|
+
}
|
|
9919
|
+
if (!live())
|
|
9920
|
+
break;
|
|
9921
|
+
await within(new Promise(r => setTimeout(r, Math.min(Daemon.AGENT_SWITCH_POLL_MS, Math.max(0, deadline - performance.now())))));
|
|
9922
|
+
}
|
|
9923
|
+
if (!fenced())
|
|
9924
|
+
return; // a stop, pause or respawn: nothing to say, nothing to do
|
|
9925
|
+
this.logger.warn({ agent: sw.agent, typed: wroteAt !== null }, "The resumed conversation did not switch to this instance's agent in time");
|
|
9926
|
+
this.emit("backend_launch_warning", { name: this.name, message: t("kiro.switch_timeout", Daemon.AGENT_SWITCH_BUDGET_MS / 1000) });
|
|
9927
|
+
}
|
|
9928
|
+
catch (err) {
|
|
9929
|
+
this.startupAdmission?.();
|
|
9930
|
+
this.logger.warn({ err }, "The agent switch after resume failed — the shared entries stay; the next launch tries again");
|
|
9931
|
+
}
|
|
9932
|
+
}
|
|
9334
9933
|
/**
|
|
9335
9934
|
* Startup budget for this launch: the backend's override (resume-aware), never
|
|
9336
9935
|
* below a user-configured startup_timeout_ms; undefined = trySpawn's default.
|
|
@@ -9472,13 +10071,19 @@ export class Daemon extends EventEmitter {
|
|
|
9472
10071
|
* Returns true if CLI is ready, false if it failed or got stuck.
|
|
9473
10072
|
*/
|
|
9474
10073
|
async trySpawn(reuseWindow = false, startupTimeoutMs) {
|
|
9475
|
-
|
|
9476
|
-
return this.trySpawnInsideGate(reuseWindow, startupTimeoutMs);
|
|
9477
|
-
return this.spawnGate.run({
|
|
10074
|
+
const ready = !this.spawnGate ? await this.trySpawnInsideGate(reuseWindow, startupTimeoutMs) : await this.spawnGate.run({
|
|
9478
10075
|
instanceName: this.name,
|
|
9479
10076
|
workingDirectory: this.config.working_directory,
|
|
9480
10077
|
reason: reuseWindow ? "wake" : this.lastSpawnAt > 0 ? "recovery" : "startup",
|
|
9481
10078
|
}, () => this.trySpawnInsideGate(reuseWindow, startupTimeoutMs));
|
|
10079
|
+
// #906: a resumed kiro conversation comes back as the agent it was saved under. Every launch path that reaches a
|
|
10080
|
+
// ready CLI (start, recovery, wake) switches it to this instance's own agent here, while the spawn still holds
|
|
10081
|
+
// deliveries — and outside the spawn gate, which it does not need.
|
|
10082
|
+
this.startupAdmission?.();
|
|
10083
|
+
if (ready)
|
|
10084
|
+
await this.ensureBackendAgent();
|
|
10085
|
+
this.startupAdmission?.();
|
|
10086
|
+
return ready;
|
|
9482
10087
|
}
|
|
9483
10088
|
/**
|
|
9484
10089
|
* One fresh pane capture judged as ready && !busy && !dialog. Transcript
|
|
@@ -9662,7 +10267,7 @@ export class Daemon extends EventEmitter {
|
|
|
9662
10267
|
// This path only runs when pasteQueueDepth > 0 — i.e. exactly when a real
|
|
9663
10268
|
// delivery is already in flight or queued. Without the lock the notice and
|
|
9664
10269
|
// 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"));
|
|
10270
|
+
const told = await this.paneWriteLock.run(() => this.submitSystemPaste(buildInstructionReloadNotice(this.backend?.binaryName ?? "unknown", this.name, this.instanceDir, this.backend?.instructionsSource?.()), "instruction-reload-notice"));
|
|
9666
10271
|
if (!told) {
|
|
9667
10272
|
// Recording the new instructions here would mark the agent as told about
|
|
9668
10273
|
// a notice it never received, and every later restart would then skip
|
|
@@ -9683,6 +10288,7 @@ export class Daemon extends EventEmitter {
|
|
|
9683
10288
|
catch { /* non-fatal */ }
|
|
9684
10289
|
}
|
|
9685
10290
|
async trySpawnInsideGate(reuseWindow = false, startupTimeoutMs) {
|
|
10291
|
+
this.startupAdmission?.();
|
|
9686
10292
|
const launchFence = this.launchFenceEpoch;
|
|
9687
10293
|
const backendConfig = this.buildBackendConfig();
|
|
9688
10294
|
// Compare freshly-built instructions against the last value the agent was
|
|
@@ -9722,11 +10328,12 @@ export class Daemon extends EventEmitter {
|
|
|
9722
10328
|
// A backend that has to ask its CLI something before the launch command exists (OpenCode: does
|
|
9723
10329
|
// this binary take --auto?) does it here, off the event loop, bounded, and never fails the launch.
|
|
9724
10330
|
try {
|
|
9725
|
-
await this.backend.prepareLaunch?.();
|
|
10331
|
+
await this.backend.prepareLaunch?.(backendConfig);
|
|
9726
10332
|
}
|
|
9727
10333
|
catch { /* unknown capability → the backend's conservative form */ }
|
|
9728
10334
|
// Thrown, not `false`: a false verdict reads as "the CLI failed to start" and sends the startup path
|
|
9729
10335
|
// into its resume-retry / set-the-session-aside handling, for an instance somebody just stopped.
|
|
10336
|
+
this.startupAdmission?.();
|
|
9730
10337
|
if (launchFence !== this.launchFenceEpoch)
|
|
9731
10338
|
throw new Error("Launch cancelled: the instance was stopped or paused while the launch was being prepared");
|
|
9732
10339
|
this.backend.writeConfig(backendConfig);
|
|
@@ -9781,6 +10388,7 @@ export class Daemon extends EventEmitter {
|
|
|
9781
10388
|
// The CLI's own shell sets a zero coredump_filter first (#1113): this pane
|
|
9782
10389
|
// may belong to a tmux server the fleet did not start, which would hand
|
|
9783
10390
|
// the CLI its own (full) filter instead of the fleet's.
|
|
10391
|
+
this.startupAdmission?.();
|
|
9784
10392
|
const cmd = coredumpFilterLaunchPrefix() + `${envPrefix} ` + this.backend.buildCommand(launchConfig);
|
|
9785
10393
|
// Every launched command re-arms the passive-transient check, including a
|
|
9786
10394
|
// retry inside the same spawn: its load is a new one.
|
|
@@ -9803,6 +10411,7 @@ export class Daemon extends EventEmitter {
|
|
|
9803
10411
|
if (this.stormWindow?.observeServerAlive(await TmuxManager.getServerPid(this.tmuxSessionName))) {
|
|
9804
10412
|
this.emit("tmux_server_crash", this.name);
|
|
9805
10413
|
}
|
|
10414
|
+
this.startupAdmission?.();
|
|
9806
10415
|
let windowId;
|
|
9807
10416
|
if (reuseWindow) {
|
|
9808
10417
|
this.controlClient?.unregisterWindow(this.tmux.getWindowId());
|
|
@@ -9817,6 +10426,7 @@ export class Daemon extends EventEmitter {
|
|
|
9817
10426
|
if (retired && retired !== windowId)
|
|
9818
10427
|
this.controlClient?.unregisterWindow(retired);
|
|
9819
10428
|
}
|
|
10429
|
+
this.startupAdmission?.();
|
|
9820
10430
|
writeFileSync(join(this.instanceDir, "window-id"), windowId);
|
|
9821
10431
|
// Enable remain-on-exit to capture exit codes on crash
|
|
9822
10432
|
await this.tmux.setRemainOnExit().catch(err => {
|
|
@@ -9925,8 +10535,8 @@ export class Daemon extends EventEmitter {
|
|
|
9925
10535
|
{ pattern: /No, exit|No, quit|Don't trust|I accept|I trust|Yes, continue|Trust folder/i, keys: ["Enter"], description: "Generic confirmation dialog" },
|
|
9926
10536
|
{ pattern: /Resume Session/i, keys: ["Escape"], description: "Resume session picker — start fresh" },
|
|
9927
10537
|
];
|
|
9928
|
-
const deadline =
|
|
9929
|
-
const remaining = () => deadline -
|
|
10538
|
+
const deadline = performance.now() + budgetMs;
|
|
10539
|
+
const remaining = () => deadline - performance.now();
|
|
9930
10540
|
const sleep = async () => { if (remaining() > 0)
|
|
9931
10541
|
await new Promise(r => setTimeout(r, Math.min(pollMs, Math.max(remaining(), 0)))); };
|
|
9932
10542
|
let cleanReadyPolls = 0;
|
|
@@ -9938,13 +10548,27 @@ export class Daemon extends EventEmitter {
|
|
|
9938
10548
|
// repainted after Enter, let the following hold-only entry report it
|
|
9939
10549
|
// instead of sending a second Enter into a possibly changed screen.
|
|
9940
10550
|
const attemptedSafetyChoices = new Set();
|
|
10551
|
+
// The scan belongs to this launch: its tmux, spawn and launch fence. A respawn, pause or stop that replaces any of
|
|
10552
|
+
// them retires it — a key meant for this launch's dialog must never reach the replacement (#1435 review).
|
|
10553
|
+
const ownerTmux = this.tmux;
|
|
10554
|
+
const ownerSpawn = this.spawnGeneration;
|
|
10555
|
+
const ownerFence = this.launchFenceEpoch;
|
|
10556
|
+
const owned = () => !this.deliveryWritesStopping && this.tmux === ownerTmux
|
|
10557
|
+
&& this.spawnGeneration === ownerSpawn && this.launchFenceEpoch === ownerFence;
|
|
9941
10558
|
do {
|
|
10559
|
+
this.startupAdmission?.();
|
|
9942
10560
|
attempts++;
|
|
10561
|
+
if (!owned()) {
|
|
10562
|
+
this.logger.info("Startup dialog scan superseded by a newer launch, pause or stop — retiring without answering");
|
|
10563
|
+
return true;
|
|
10564
|
+
}
|
|
9943
10565
|
let pane;
|
|
9944
10566
|
try {
|
|
9945
10567
|
pane = await this.tmux.capturePane();
|
|
10568
|
+
this.startupAdmission?.();
|
|
9946
10569
|
}
|
|
9947
10570
|
catch (err) {
|
|
10571
|
+
this.startupAdmission?.();
|
|
9948
10572
|
// Transient tmux trouble is not evidence about the CLI. Returning false
|
|
9949
10573
|
// here would clear the session; retry within the budget instead.
|
|
9950
10574
|
captureFailures++;
|
|
@@ -9976,8 +10600,7 @@ export class Daemon extends EventEmitter {
|
|
|
9976
10600
|
if (Daemon.dialogMatches(dialog, pane)) {
|
|
9977
10601
|
if (dialog.autoResolutionKey
|
|
9978
10602
|
&& (attemptedSafetyChoices.has(dialog.autoResolutionKey)
|
|
9979
|
-
||
|
|
9980
|
-
&& this.autoResolvedDialogKey === dialog.autoResolutionKey)))
|
|
10603
|
+
|| this.hasAutoDialogClaim(dialog)))
|
|
9981
10604
|
continue;
|
|
9982
10605
|
lastDialog = dialog;
|
|
9983
10606
|
this.noteCodexReserveDialog(dialog, pane);
|
|
@@ -10022,21 +10645,33 @@ export class Daemon extends EventEmitter {
|
|
|
10022
10645
|
// delivery on `spawning`. Take the pane lock for the key sequence so a
|
|
10023
10646
|
// queued message cannot be pasted into a half-dismissed trust dialog.
|
|
10024
10647
|
const sent = await this.paneWriteLock.run(async () => {
|
|
10648
|
+
this.startupAdmission?.();
|
|
10025
10649
|
// The capture above may have gone stale while waiting for the
|
|
10026
10650
|
// write lock. Trust/other safety prompts must still be the
|
|
10027
10651
|
// CURRENT menu, with the same safe cursor, at the instant of
|
|
10028
|
-
// the key send. A changed pane falls through to the next scan
|
|
10652
|
+
// the key send. A changed pane falls through to the next scan;
|
|
10653
|
+
// a pane that is no longer this launch's is not read or keyed.
|
|
10654
|
+
if (!owned())
|
|
10655
|
+
return false;
|
|
10029
10656
|
if (dialog.inputBlocked) {
|
|
10030
10657
|
const currentPane = await this.tmux.capturePane();
|
|
10658
|
+
this.startupAdmission?.();
|
|
10659
|
+
// The capture is an await too: a replacement launched during it must find no answer recorded as its own.
|
|
10660
|
+
if (!owned())
|
|
10661
|
+
return false;
|
|
10031
10662
|
if (!Daemon.dialogMatches(dialog, currentPane))
|
|
10032
10663
|
return false;
|
|
10033
10664
|
}
|
|
10034
10665
|
if (dialog.autoResolutionKey) {
|
|
10035
10666
|
attemptedSafetyChoices.add(dialog.autoResolutionKey);
|
|
10036
|
-
this.
|
|
10037
|
-
this.autoResolvedDialogKey = dialog.autoResolutionKey;
|
|
10667
|
+
this.claimAutoDialog(dialog);
|
|
10038
10668
|
}
|
|
10039
10669
|
for (const key of dialog.keys) {
|
|
10670
|
+
this.startupAdmission?.();
|
|
10671
|
+
// Before every key, synchronously: the lock wait, the capture and the gap after the previous key can
|
|
10672
|
+
// each have seen a respawn, pause or stop.
|
|
10673
|
+
if (!owned())
|
|
10674
|
+
return false;
|
|
10040
10675
|
if (key === "Up" || key === "Down" || key === "Enter" || key === "Escape") {
|
|
10041
10676
|
if (!await this.tmux.sendSpecialKey(key))
|
|
10042
10677
|
return false;
|
|
@@ -10118,6 +10753,7 @@ export class Daemon extends EventEmitter {
|
|
|
10118
10753
|
return false;
|
|
10119
10754
|
}
|
|
10120
10755
|
catch (err) {
|
|
10756
|
+
this.startupAdmission?.();
|
|
10121
10757
|
// Key sends / isWindowAlive failing: same rule — log, retry within the budget.
|
|
10122
10758
|
this.logger.warn({ err }, "startup dialog scan step failed — retrying");
|
|
10123
10759
|
if ((await this.paneLiveness()) === "dead")
|
|
@@ -10135,6 +10771,7 @@ export class Daemon extends EventEmitter {
|
|
|
10135
10771
|
else {
|
|
10136
10772
|
this.logger.warn({ attempts, budgetMs, captureFailures }, "Startup scan exhausted without a ready prompt or a known dialog — assuming ready (unknown CLI screen)");
|
|
10137
10773
|
}
|
|
10774
|
+
this.startupAdmission?.();
|
|
10138
10775
|
return true;
|
|
10139
10776
|
}
|
|
10140
10777
|
/**
|